feat: tax code matcher (#564)
* feat: ✨ add tax-classes-fetcher * refactor: ♻️ add "byId" suffix to crud-settings & remove upsert * feat: ✨ add updateMany method to CrudSettingsManager * feat: ✨ add avatax & taxjar tax-code-map-repository * refactor: 🚚 move to tax-code directory * feat: ✨ add getTaxCodes methods to provider clients * refactor: ♻️ extract taxClass and taxCode schema * refactor: 🚚 tax-code-map -> tax-code-match * feat: ✨ add taxjar-tax-code.service * feat: ✨ add avatax-tax-code.service * feat: ✨ add taxClass to graphql fragment * feat: ✨ use tax codes in calculate taxes * fix: 🐛 undefined tax code bug & add tests * build: 👷 add changeset * Update avatax-tax-code-mapper.ts * feat: ✨ add routers & get rid of adapters & mappers * refactor: ♻️ logger location -> name * refactor: ♻️ clean up & rephrase logs * refactor: ♻️ remove __typename from query * docs: 💡 make comments about tax codes more informative * refactor: ♻️ use resolveOptionalOrThrow on tax code description * refactor: ♻️ rename tax-codes -> tax-classes, move and rename tax-code-schema * refactor: 🚚 ctx -> authData * refactor: 🚚 createUrqlClientFromCtx -> createUrqlClientFromAuthdata * refactor: ♻️ dont spread ctx * docs: 💡 add comment about fallback tax code * refactor: ♻️ remove ..ctx * fix: 🐛 use createGraphQLClient * feat: tax code matcher ui (#658) * feat: ✨ use tax codes in calculate taxes * feat: ✨ add getTaxCodes methods to provider clients * feat: ✨ add matcher tables * feat: ✨ add log errors middleware * fix: 🔊 fix misleading logs * fix: 🐛 ctx appToken bug * feat: ✨ add Select override with wrapping label * feat: ✨ pre-select match * docs: 💡 add comments about first connection * docs: 💡 add comment about redirect * refactor: 🔥 duplicate file * feat: ✨ add AppCard to tables * feat: ✨ add _error to breadcrumbs exceptions * fix: 🐛 value not set on data * feat: 🥅 add error for no channels configured * fix: 🐛 replace update with upsert * refactor: 🚚 channel-configuration-settings to repository * fix: 🐛 updating a channel configuration * fix: 🧪 fix wrong mock * fix: 🐛 duplicating configs * Update cool-turtles-reflect.md
This commit is contained in:
parent
59ff617282
commit
09e07995b5
81 changed files with 1847 additions and 509 deletions
5
.changeset/cool-turtles-reflect.md
Normal file
5
.changeset/cool-turtles-reflect.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"saleor-app-taxes": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added fetching tax codes from tax providers and storing the matched combinations of the provider tax codes/Saleor tax classes. The mapped tax codes are then used in the tax calculation process.
|
|
@ -3,24 +3,24 @@ fragment TaxBaseLine on TaxableObjectLine {
|
||||||
__typename
|
__typename
|
||||||
... on CheckoutLine {
|
... on CheckoutLine {
|
||||||
id
|
id
|
||||||
productVariant: variant {
|
checkoutProductVariant: variant {
|
||||||
id
|
id
|
||||||
product {
|
product {
|
||||||
metafield(key: "taxjar_tax_code")
|
taxClass {
|
||||||
productType {
|
id
|
||||||
metafield(key: "taxjar_tax_code")
|
name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on OrderLine {
|
... on OrderLine {
|
||||||
id
|
id
|
||||||
variant {
|
orderProductVariant: variant {
|
||||||
id
|
id
|
||||||
product {
|
product {
|
||||||
metafield(key: "taxjar_tax_code")
|
taxClass {
|
||||||
productType {
|
id
|
||||||
metafield(key: "taxjar_tax_code")
|
name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
28
apps/taxes/graphql/queries/TaxClassesList.graphql
Normal file
28
apps/taxes/graphql/queries/TaxClassesList.graphql
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
query TaxClassesList(
|
||||||
|
$before: String
|
||||||
|
$after: String
|
||||||
|
$first: Int
|
||||||
|
$last: Int
|
||||||
|
$filter: TaxClassFilterInput
|
||||||
|
$sortBy: TaxClassSortingInput
|
||||||
|
) {
|
||||||
|
taxClasses(
|
||||||
|
before: $before
|
||||||
|
after: $after
|
||||||
|
first: $first
|
||||||
|
last: $last
|
||||||
|
filter: $filter
|
||||||
|
sortBy: $sortBy
|
||||||
|
) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...TaxClass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment TaxClass on TaxClass {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ export class TaxChannelsV1toV2MigrationManager {
|
||||||
private options: { mode: "report" | "migrate" } = { mode: "migrate" }
|
private options: { mode: "report" | "migrate" } = { mode: "migrate" }
|
||||||
) {
|
) {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "TaxChannelsV1toV2MigrationManager",
|
name: "TaxChannelsV1toV2MigrationManager",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ export class TaxProvidersV1toV2MigrationManager {
|
||||||
private options: { mode: "report" | "migrate" } = { mode: "migrate" }
|
private options: { mode: "report" | "migrate" } = { mode: "migrate" }
|
||||||
) {
|
) {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "TaxProvidersV1toV2MigrationManager",
|
name: "TaxProvidersV1toV2MigrationManager",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import Avatax from "avatax";
|
||||||
|
import { createLogger, Logger } from "../../lib/logger";
|
||||||
|
import { FetchResult } from "avatax/lib/utils/fetch_result";
|
||||||
|
import { TaxCodeModel } from "avatax/lib/models/TaxCodeModel";
|
||||||
|
|
||||||
|
export class AvataxClientTaxCodeService {
|
||||||
|
// * These are the tax codes that we don't want to show to the user. For some reason, Avatax has them as active.
|
||||||
|
private readonly notSuitableKeys = ["Expired Tax Code - Do Not Use"];
|
||||||
|
private logger: Logger;
|
||||||
|
constructor(private client: Avatax) {
|
||||||
|
this.logger = createLogger({ name: "AvataxClientTaxCodeService" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterOutInvalid(response: FetchResult<TaxCodeModel>) {
|
||||||
|
return response.value.filter((taxCode) => {
|
||||||
|
return (
|
||||||
|
taxCode.isActive &&
|
||||||
|
taxCode.description &&
|
||||||
|
!this.notSuitableKeys.includes(taxCode.description)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTaxCodes() {
|
||||||
|
// * If we want to do filtering on the front-end, we can use the `filter` parameter.
|
||||||
|
const result = await this.client.listTaxCodes({});
|
||||||
|
|
||||||
|
return this.filterOutInvalid(result);
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import { AvataxConfig } from "./avatax-connection-schema";
|
||||||
import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel";
|
import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel";
|
||||||
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
|
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
|
||||||
|
import { AvataxClientTaxCodeService } from "./avatax-client-tax-code.service";
|
||||||
|
|
||||||
type AvataxSettings = {
|
type AvataxSettings = {
|
||||||
appName: string;
|
appName: string;
|
||||||
|
@ -57,7 +58,7 @@ export class AvataxClient {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(config: AvataxConfig) {
|
constructor(config: AvataxConfig) {
|
||||||
this.logger = createLogger({ location: "AvataxClient" });
|
this.logger = createLogger({ name: "AvataxClient" });
|
||||||
const settings = createAvataxSettings(config);
|
const settings = createAvataxSettings(config);
|
||||||
const avataxClient = new Avatax(settings).withSecurity(config.credentials);
|
const avataxClient = new Avatax(settings).withSecurity(config.credentials);
|
||||||
|
|
||||||
|
@ -75,4 +76,10 @@ export class AvataxClient {
|
||||||
async validateAddress({ address }: ValidateAddressArgs) {
|
async validateAddress({ address }: ValidateAddressArgs) {
|
||||||
return this.client.resolveAddress(address);
|
return this.client.resolveAddress(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTaxCodes() {
|
||||||
|
const taxCodeService = new AvataxClientTaxCodeService(this.client);
|
||||||
|
|
||||||
|
return taxCodeService.getTaxCodes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,6 @@ const postInputSchema = z.object({
|
||||||
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
||||||
next({
|
next({
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
|
||||||
connectionService: new PublicAvataxConnectionService(
|
connectionService: new PublicAvataxConnectionService(
|
||||||
ctx.apiClient,
|
ctx.apiClient,
|
||||||
ctx.appId!,
|
ctx.appId!,
|
||||||
|
@ -38,7 +37,7 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
|
||||||
export const avataxConnectionRouter = router({
|
export const avataxConnectionRouter = router({
|
||||||
getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => {
|
getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
location: "avataxConnectionRouter.get",
|
name: "avataxConnectionRouter.get",
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug("Route get called");
|
logger.debug("Route get called");
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
import {
|
import {
|
||||||
OrderCreatedSubscriptionFragment,
|
OrderCreatedSubscriptionFragment,
|
||||||
OrderFulfilledSubscriptionFragment,
|
OrderFulfilledSubscriptionFragment,
|
||||||
|
@ -16,9 +17,9 @@ export class AvataxWebhookService implements ProviderWebhookService {
|
||||||
client: AvataxClient;
|
client: AvataxClient;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(config: AvataxConfig) {
|
constructor(config: AvataxConfig, private authData: AuthData) {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "AvataxWebhookService",
|
name: "AvataxWebhookService",
|
||||||
});
|
});
|
||||||
const avataxClient = new AvataxClient(config);
|
const avataxClient = new AvataxClient(config);
|
||||||
|
|
||||||
|
@ -27,7 +28,7 @@ export class AvataxWebhookService implements ProviderWebhookService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async calculateTaxes(taxBase: TaxBaseFragment) {
|
async calculateTaxes(taxBase: TaxBaseFragment) {
|
||||||
const adapter = new AvataxCalculateTaxesAdapter(this.config);
|
const adapter = new AvataxCalculateTaxesAdapter(this.config, this.authData);
|
||||||
|
|
||||||
const response = await adapter.send({ taxBase });
|
const response = await adapter.send({ taxBase });
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
import { TaxBaseFragment } from "../../../../generated/graphql";
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
import { Logger, createLogger } from "../../../lib/logger";
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
||||||
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
||||||
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
|
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
|
||||||
import { AvataxConfig } from "../avatax-connection-schema";
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
|
import { AvataxCalculateTaxesPayloadService } from "./avatax-calculate-taxes-payload.service";
|
||||||
import { AvataxCalculateTaxesResponseTransformer } from "./avatax-calculate-taxes-response-transformer";
|
import { AvataxCalculateTaxesResponseTransformer } from "./avatax-calculate-taxes-response-transformer";
|
||||||
|
|
||||||
export const SHIPPING_ITEM_CODE = "Shipping";
|
export const SHIPPING_ITEM_CODE = "Shipping";
|
||||||
|
@ -20,29 +21,27 @@ export class AvataxCalculateTaxesAdapter
|
||||||
implements WebhookAdapter<AvataxCalculateTaxesPayload, AvataxCalculateTaxesResponse>
|
implements WebhookAdapter<AvataxCalculateTaxesPayload, AvataxCalculateTaxesResponse>
|
||||||
{
|
{
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
constructor(private readonly config: AvataxConfig) {
|
constructor(private readonly config: AvataxConfig, private authData: AuthData) {
|
||||||
this.logger = createLogger({ location: "AvataxCalculateTaxesAdapter" });
|
this.logger = createLogger({ name: "AvataxCalculateTaxesAdapter" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo: refactor because its getting too big
|
||||||
async send(payload: AvataxCalculateTaxesPayload): Promise<AvataxCalculateTaxesResponse> {
|
async send(payload: AvataxCalculateTaxesPayload): Promise<AvataxCalculateTaxesResponse> {
|
||||||
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
|
this.logger.debug("Transforming the Saleor payload for calculating taxes with Avatax...");
|
||||||
const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer();
|
const payloadService = new AvataxCalculateTaxesPayloadService(this.authData);
|
||||||
const target = payloadTransformer.transform({ ...payload, providerConfig: this.config });
|
const target = await payloadService.getPayload(payload.taxBase, this.config);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug("Calling Avatax createTransaction with transformed payload...");
|
||||||
{ transformedPayload: target },
|
|
||||||
"Will call Avatax createTransaction with transformed payload:"
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new AvataxClient(this.config);
|
const client = new AvataxClient(this.config);
|
||||||
const response = await client.createTransaction(target);
|
const response = await client.createTransaction(target);
|
||||||
|
|
||||||
this.logger.debug({ response }, "Avatax createTransaction responded with:");
|
this.logger.debug("Avatax createTransaction successfully responded");
|
||||||
|
|
||||||
const responseTransformer = new AvataxCalculateTaxesResponseTransformer();
|
const responseTransformer = new AvataxCalculateTaxesResponseTransformer();
|
||||||
const transformedResponse = responseTransformer.transform(response);
|
const transformedResponse = responseTransformer.transform(response);
|
||||||
|
|
||||||
this.logger.debug({ transformedResponse }, "Transformed Avatax createTransaction response to:");
|
this.logger.debug("Transformed Avatax createTransaction response");
|
||||||
|
|
||||||
return transformedResponse;
|
return transformedResponse;
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel";
|
||||||
import { AvataxConfig } from "../avatax-connection-schema";
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator";
|
import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator";
|
||||||
import { ChannelConfigMockGenerator } from "../../channel-configuration/channel-config-mock-generator";
|
import { ChannelConfigMockGenerator } from "../../channel-configuration/channel-config-mock-generator";
|
||||||
|
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
|
||||||
|
|
||||||
type TaxBase = TaxBaseFragment;
|
type TaxBase = TaxBaseFragment;
|
||||||
|
|
||||||
|
@ -41,12 +42,12 @@ const defaultTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOjNmMjYwZmMyLTZjN2UtNGM5Ni1iYTMwLTEyMjAyODMzOTUyZA==",
|
id: "T3JkZXJMaW5lOjNmMjYwZmMyLTZjN2UtNGM5Ni1iYTMwLTEyMjAyODMzOTUyZA==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "VGF4Q2xhc3M6MjI=",
|
||||||
metafield: null,
|
name: "Clothing",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -63,12 +64,12 @@ const defaultTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOjNlNGZjODdkLTIyMmEtNDZiYi1iYzIzLWJiYWVkODVlOTQ4Mg==",
|
id: "T3JkZXJMaW5lOjNlNGZjODdkLTIyMmEtNDZiYi1iYzIzLWJiYWVkODVlOTQ4Mg==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzUw",
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ6",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "VGF4Q2xhc3M7MjI=",
|
||||||
metafield: null,
|
name: "Shoes",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -85,12 +86,12 @@ const defaultTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOmM2NTBhMzVkLWQ1YjQtNGRhNy1hMjNjLWEzODU4ZDE1MzI2Mw==",
|
id: "T3JkZXJMaW5lOmM2NTBhMzVkLWQ1YjQtNGRhNy1hMjNjLWEzODU4ZDE1MzI2Mw==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
|
id: "UHJvZHFjdFZhcmlhbnQ6MzQ5",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "VGF4Q2xhc3M6TWjI=",
|
||||||
metafield: null,
|
name: "Sweets",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -933,10 +934,21 @@ const defaultTransactionModel: TransactionModel = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultTaxCodeMatches: AvataxTaxCodeMatches = [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
avataxTaxCode: "P0000000",
|
||||||
|
saleorTaxClassId: "VGF4Q2xhc3M6MjI=",
|
||||||
|
},
|
||||||
|
id: "VGF4Q29kZTox",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const testingScenariosMap = {
|
const testingScenariosMap = {
|
||||||
default: {
|
default: {
|
||||||
taxBase: defaultTaxBase,
|
taxBase: defaultTaxBase,
|
||||||
response: defaultTransactionModel,
|
response: defaultTransactionModel,
|
||||||
|
matches: defaultTaxCodeMatches,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -967,4 +979,7 @@ export class AvataxCalculateTaxesMockGenerator {
|
||||||
...testingScenariosMap[this.scenario].response,
|
...testingScenariosMap[this.scenario].response,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
generateTaxCodeMatches = (overrides: AvataxTaxCodeMatches = []): AvataxTaxCodeMatches =>
|
||||||
|
structuredClone([...testingScenariosMap[this.scenario].matches, ...overrides]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AvataxCalculateTaxesMockGenerator } from "./avatax-calculate-taxes-mock-generator";
|
||||||
|
import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer";
|
||||||
|
|
||||||
|
const transformer = new AvataxCalculateTaxesPayloadLinesTransformer();
|
||||||
|
|
||||||
|
describe("AvataxCalculateTaxesPayloadLinesTransformer", () => {
|
||||||
|
describe("transform", () => {
|
||||||
|
it("maps lines, adds shipping as line and maps the tax code of one product", () => {
|
||||||
|
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
||||||
|
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
||||||
|
const taxBaseMock = mockGenerator.generateTaxBase();
|
||||||
|
const matchesMock = mockGenerator.generateTaxCodeMatches();
|
||||||
|
|
||||||
|
const lines = transformer.transform(taxBaseMock, avataxConfigMock, matchesMock);
|
||||||
|
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{
|
||||||
|
amount: 60,
|
||||||
|
quantity: 3,
|
||||||
|
taxCode: "P0000000",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 20,
|
||||||
|
quantity: 1,
|
||||||
|
taxCode: "",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 100,
|
||||||
|
quantity: 2,
|
||||||
|
taxCode: "",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 48.33,
|
||||||
|
itemCode: "Shipping",
|
||||||
|
quantity: 1,
|
||||||
|
taxCode: "FR000000",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("when no shipping in tax base, does not add shipping as line", () => {
|
||||||
|
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
||||||
|
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
||||||
|
const matchesMock = mockGenerator.generateTaxCodeMatches();
|
||||||
|
const taxBaseMock = mockGenerator.generateTaxBase({ shippingPrice: { amount: 0 } });
|
||||||
|
|
||||||
|
const lines = transformer.transform(taxBaseMock, avataxConfigMock, matchesMock);
|
||||||
|
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{
|
||||||
|
amount: 60,
|
||||||
|
quantity: 3,
|
||||||
|
taxCode: "P0000000",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 20,
|
||||||
|
quantity: 1,
|
||||||
|
taxCode: "",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 100,
|
||||||
|
quantity: 2,
|
||||||
|
taxCode: "",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("when discounts, sets discounted to true", () => {
|
||||||
|
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
||||||
|
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
||||||
|
const matchesMock = mockGenerator.generateTaxCodeMatches();
|
||||||
|
const taxBaseMock = mockGenerator.generateTaxBase({
|
||||||
|
discounts: [{ amount: { amount: 10 } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = transformer.transform(taxBaseMock, avataxConfigMock, matchesMock);
|
||||||
|
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{
|
||||||
|
amount: 60,
|
||||||
|
quantity: 3,
|
||||||
|
taxCode: "P0000000",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 20,
|
||||||
|
quantity: 1,
|
||||||
|
taxCode: "",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 100,
|
||||||
|
quantity: 2,
|
||||||
|
taxCode: "",
|
||||||
|
taxIncluded: true,
|
||||||
|
discounted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 48.33,
|
||||||
|
discounted: true,
|
||||||
|
itemCode: "Shipping",
|
||||||
|
quantity: 1,
|
||||||
|
taxCode: "FR000000",
|
||||||
|
taxIncluded: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
||||||
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
|
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
|
||||||
|
import { AvataxTaxCodeMatcher } from "../tax-code/avatax-tax-code-matcher";
|
||||||
|
import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
|
||||||
|
|
||||||
|
export class AvataxCalculateTaxesPayloadLinesTransformer {
|
||||||
|
transform(
|
||||||
|
taxBase: TaxBaseFragment,
|
||||||
|
config: AvataxConfig,
|
||||||
|
matches: AvataxTaxCodeMatches
|
||||||
|
): LineItemModel[] {
|
||||||
|
const isDiscounted = taxBase.discounts.length > 0;
|
||||||
|
const productLines: LineItemModel[] = taxBase.lines.map((line) => {
|
||||||
|
const matcher = new AvataxTaxCodeMatcher();
|
||||||
|
const taxCode = matcher.match(line, matches);
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: line.totalPrice.amount,
|
||||||
|
taxIncluded: taxBase.pricesEnteredWithTax,
|
||||||
|
taxCode,
|
||||||
|
quantity: line.quantity,
|
||||||
|
discounted: isDiscounted,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (taxBase.shippingPrice.amount !== 0) {
|
||||||
|
// * In Avatax, shipping is a regular line
|
||||||
|
const shippingLine: LineItemModel = {
|
||||||
|
amount: taxBase.shippingPrice.amount,
|
||||||
|
itemCode: SHIPPING_ITEM_CODE,
|
||||||
|
taxCode: config.shippingTaxCode,
|
||||||
|
quantity: 1,
|
||||||
|
taxIncluded: taxBase.pricesEnteredWithTax,
|
||||||
|
discounted: isDiscounted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...productLines, shippingLine];
|
||||||
|
}
|
||||||
|
|
||||||
|
return productLines;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +1,19 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { AvataxCalculateTaxesMockGenerator } from "./avatax-calculate-taxes-mock-generator";
|
import { AvataxCalculateTaxesMockGenerator } from "./avatax-calculate-taxes-mock-generator";
|
||||||
import {
|
import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
|
||||||
AvataxCalculateTaxesPayloadTransformer,
|
|
||||||
mapPayloadLines,
|
|
||||||
} from "./avatax-calculate-taxes-payload-transformer";
|
|
||||||
|
|
||||||
describe("AvataxCalculateTaxesPayloadTransformer", () => {
|
describe("AvataxCalculateTaxesPayloadTransformer", () => {
|
||||||
it("when discounts, calculates the sum of discounts", () => {
|
it("when discounts, calculates the sum of discounts", () => {
|
||||||
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
||||||
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
||||||
const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] });
|
const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] });
|
||||||
|
const matchesMock = mockGenerator.generateTaxCodeMatches();
|
||||||
|
|
||||||
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
|
const payload = new AvataxCalculateTaxesPayloadTransformer().transform(
|
||||||
taxBase: taxBaseMock,
|
taxBaseMock,
|
||||||
providerConfig: avataxConfigMock,
|
avataxConfigMock,
|
||||||
});
|
matchesMock
|
||||||
|
);
|
||||||
|
|
||||||
expect(payload.model.discount).toEqual(10);
|
expect(payload.model.discount).toEqual(10);
|
||||||
});
|
});
|
||||||
|
@ -22,123 +21,14 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => {
|
||||||
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
||||||
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
||||||
const taxBaseMock = mockGenerator.generateTaxBase();
|
const taxBaseMock = mockGenerator.generateTaxBase();
|
||||||
|
const matchesMock = mockGenerator.generateTaxCodeMatches();
|
||||||
|
|
||||||
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
|
const payload = new AvataxCalculateTaxesPayloadTransformer().transform(
|
||||||
taxBase: taxBaseMock,
|
taxBaseMock,
|
||||||
providerConfig: avataxConfigMock,
|
avataxConfigMock,
|
||||||
});
|
matchesMock
|
||||||
|
);
|
||||||
|
|
||||||
expect(payload.model.discount).toEqual(0);
|
expect(payload.model.discount).toEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mapPayloadLines", () => {
|
|
||||||
it("map lines and adds shipping as line", () => {
|
|
||||||
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
|
||||||
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
|
||||||
const taxBaseMock = mockGenerator.generateTaxBase();
|
|
||||||
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
|
|
||||||
|
|
||||||
expect(lines).toEqual([
|
|
||||||
{
|
|
||||||
amount: 60,
|
|
||||||
quantity: 3,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: 20,
|
|
||||||
quantity: 1,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: 100,
|
|
||||||
quantity: 2,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: 48.33,
|
|
||||||
itemCode: "Shipping",
|
|
||||||
quantity: 1,
|
|
||||||
taxCode: "FR000000",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("when no shipping in tax base, does not add shipping as line", () => {
|
|
||||||
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
|
||||||
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
|
||||||
const taxBaseMock = mockGenerator.generateTaxBase({ shippingPrice: { amount: 0 } });
|
|
||||||
|
|
||||||
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
|
|
||||||
|
|
||||||
expect(lines).toEqual([
|
|
||||||
{
|
|
||||||
amount: 60,
|
|
||||||
quantity: 3,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: 20,
|
|
||||||
quantity: 1,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: 100,
|
|
||||||
quantity: 2,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("when discounts, sets discounted to true", () => {
|
|
||||||
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
|
|
||||||
const avataxConfigMock = mockGenerator.generateAvataxConfig();
|
|
||||||
const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] });
|
|
||||||
|
|
||||||
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
|
|
||||||
|
|
||||||
expect(lines).toEqual([
|
|
||||||
{
|
|
||||||
amount: 60,
|
|
||||||
quantity: 3,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: 20,
|
|
||||||
quantity: 1,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: 100,
|
|
||||||
quantity: 2,
|
|
||||||
taxCode: "",
|
|
||||||
taxIncluded: true,
|
|
||||||
discounted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: 48.33,
|
|
||||||
discounted: true,
|
|
||||||
itemCode: "Shipping",
|
|
||||||
quantity: 1,
|
|
||||||
taxCode: "FR000000",
|
|
||||||
taxIncluded: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,61 +1,33 @@
|
||||||
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
|
||||||
import { TaxBaseFragment } from "../../../../generated/graphql";
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
import { discountUtils } from "../../taxes/discount-utils";
|
import { discountUtils } from "../../taxes/discount-utils";
|
||||||
import { avataxAddressFactory } from "../address-factory";
|
import { avataxAddressFactory } from "../address-factory";
|
||||||
import { CreateTransactionArgs } from "../avatax-client";
|
import { CreateTransactionArgs } from "../avatax-client";
|
||||||
import { AvataxConfig } from "../avatax-connection-schema";
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
|
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
|
||||||
|
import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer";
|
||||||
export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig): LineItemModel[] {
|
|
||||||
const isDiscounted = taxBase.discounts.length > 0;
|
|
||||||
const productLines: LineItemModel[] = taxBase.lines.map((line) => ({
|
|
||||||
amount: line.totalPrice.amount,
|
|
||||||
taxIncluded: taxBase.pricesEnteredWithTax,
|
|
||||||
// todo: get from tax code matcher
|
|
||||||
taxCode: "",
|
|
||||||
quantity: line.quantity,
|
|
||||||
discounted: isDiscounted,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (taxBase.shippingPrice.amount !== 0) {
|
|
||||||
// * In Avatax, shipping is a regular line
|
|
||||||
const shippingLine: LineItemModel = {
|
|
||||||
amount: taxBase.shippingPrice.amount,
|
|
||||||
itemCode: SHIPPING_ITEM_CODE,
|
|
||||||
taxCode: config.shippingTaxCode,
|
|
||||||
quantity: 1,
|
|
||||||
taxIncluded: taxBase.pricesEnteredWithTax,
|
|
||||||
discounted: isDiscounted,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [...productLines, shippingLine];
|
|
||||||
}
|
|
||||||
|
|
||||||
return productLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AvataxCalculateTaxesPayloadTransformer {
|
export class AvataxCalculateTaxesPayloadTransformer {
|
||||||
transform({
|
transform(
|
||||||
taxBase,
|
taxBase: TaxBaseFragment,
|
||||||
providerConfig,
|
avataxConfig: AvataxConfig,
|
||||||
}: {
|
matches: AvataxTaxCodeMatches
|
||||||
taxBase: TaxBaseFragment;
|
): CreateTransactionArgs {
|
||||||
providerConfig: AvataxConfig;
|
const payloadLinesTransformer = new AvataxCalculateTaxesPayloadLinesTransformer();
|
||||||
}): CreateTransactionArgs {
|
|
||||||
return {
|
return {
|
||||||
model: {
|
model: {
|
||||||
type: DocumentType.SalesOrder,
|
type: DocumentType.SalesOrder,
|
||||||
customerCode: taxBase.sourceObject.user?.id ?? "",
|
customerCode: taxBase.sourceObject.user?.id ?? "",
|
||||||
companyCode: providerConfig.companyCode,
|
companyCode: avataxConfig.companyCode,
|
||||||
// * commit: If true, the transaction will be committed immediately after it is created. See: https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit
|
// * commit: If true, the transaction will be committed immediately after it is created. See: https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit
|
||||||
commit: providerConfig.isAutocommit,
|
commit: avataxConfig.isAutocommit,
|
||||||
addresses: {
|
addresses: {
|
||||||
shipFrom: avataxAddressFactory.fromChannelAddress(providerConfig.address),
|
shipFrom: avataxAddressFactory.fromChannelAddress(avataxConfig.address),
|
||||||
shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!),
|
shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!),
|
||||||
},
|
},
|
||||||
currencyCode: taxBase.currency,
|
currencyCode: taxBase.currency,
|
||||||
lines: mapPayloadLines(taxBase, providerConfig),
|
lines: payloadLinesTransformer.transform(taxBase, avataxConfig, matches),
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
discount: discountUtils.sumDiscounts(
|
discount: discountUtils.sumDiscounts(
|
||||||
taxBase.discounts.map((discount) => discount.amount.amount)
|
taxBase.discounts.map((discount) => discount.amount.amount)
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
|
import { CreateTransactionArgs } from "../avatax-client";
|
||||||
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
|
import { AvataxTaxCodeMatchesService } from "../tax-code/avatax-tax-code-matches.service";
|
||||||
|
import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
|
||||||
|
|
||||||
|
export class AvataxCalculateTaxesPayloadService {
|
||||||
|
constructor(private authData: AuthData) {}
|
||||||
|
|
||||||
|
private getMatches() {
|
||||||
|
const taxCodeMatchesService = new AvataxTaxCodeMatchesService(this.authData);
|
||||||
|
|
||||||
|
return taxCodeMatchesService.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPayload(
|
||||||
|
taxBase: TaxBaseFragment,
|
||||||
|
avataxConfig: AvataxConfig
|
||||||
|
): Promise<CreateTransactionArgs> {
|
||||||
|
const matches = await this.getMatches();
|
||||||
|
const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer();
|
||||||
|
|
||||||
|
return payloadTransformer.transform(taxBase, avataxConfig, matches);
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ export class AvataxConnectionRepository {
|
||||||
TAX_PROVIDER_KEY
|
TAX_PROVIDER_KEY
|
||||||
);
|
);
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "AvataxConnectionRepository",
|
name: "AvataxConnectionRepository",
|
||||||
metadataKey: TAX_PROVIDER_KEY,
|
metadataKey: TAX_PROVIDER_KEY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -67,7 +67,7 @@ export class AvataxConnectionRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<AvataxConnection> {
|
async get(id: string): Promise<AvataxConnection> {
|
||||||
const { data } = await this.crudSettingsManager.read(id);
|
const { data } = await this.crudSettingsManager.readById(id);
|
||||||
|
|
||||||
const connection = getSchema.parse(data);
|
const connection = getSchema.parse(data);
|
||||||
|
|
||||||
|
@ -84,7 +84,7 @@ export class AvataxConnectionRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async patch(id: string, input: Pick<AvataxConnection, "config">): Promise<void> {
|
async patch(id: string, input: Pick<AvataxConnection, "config">): Promise<void> {
|
||||||
return this.crudSettingsManager.update(id, input);
|
return this.crudSettingsManager.updateById(id, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
|
|
|
@ -11,7 +11,7 @@ export class AvataxConnectionService {
|
||||||
private avataxConnectionRepository: AvataxConnectionRepository;
|
private avataxConnectionRepository: AvataxConnectionRepository;
|
||||||
constructor(client: Client, appId: string, saleorApiUrl: string) {
|
constructor(client: Client, appId: string, saleorApiUrl: string) {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "AvataxConnectionService",
|
name: "AvataxConnectionService",
|
||||||
});
|
});
|
||||||
|
|
||||||
const settingsManager = createSettingsManager(client, appId);
|
const settingsManager = createSettingsManager(client, appId);
|
||||||
|
|
|
@ -11,7 +11,7 @@ export class AvataxValidationService {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "AvataxValidationService",
|
name: "AvataxValidationService",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,29 +18,26 @@ export class AvataxOrderCreatedAdapter
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(private readonly config: AvataxConfig) {
|
constructor(private readonly config: AvataxConfig) {
|
||||||
this.logger = createLogger({ location: "AvataxOrderCreatedAdapter" });
|
this.logger = createLogger({ name: "AvataxOrderCreatedAdapter" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(payload: AvataxOrderCreatedPayload): Promise<AvataxOrderCreatedResponse> {
|
async send(payload: AvataxOrderCreatedPayload): Promise<AvataxOrderCreatedResponse> {
|
||||||
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
|
this.logger.debug("Transforming the Saleor payload for creating order with Avatax...");
|
||||||
|
|
||||||
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(this.config);
|
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(this.config);
|
||||||
const target = payloadTransformer.transform(payload);
|
const target = payloadTransformer.transform(payload);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug("Calling Avatax createTransaction with transformed payload...");
|
||||||
{ transformedPayload: target },
|
|
||||||
"Will call Avatax createTransaction with transformed payload:"
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new AvataxClient(this.config);
|
const client = new AvataxClient(this.config);
|
||||||
const response = await client.createTransaction(target);
|
const response = await client.createTransaction(target);
|
||||||
|
|
||||||
this.logger.debug({ response }, "Avatax createTransaction responded with:");
|
this.logger.debug("Avatax createTransaction successfully responded");
|
||||||
|
|
||||||
const responseTransformer = new AvataxOrderCreatedResponseTransformer();
|
const responseTransformer = new AvataxOrderCreatedResponseTransformer();
|
||||||
const transformedResponse = responseTransformer.transform(response);
|
const transformedResponse = responseTransformer.transform(response);
|
||||||
|
|
||||||
this.logger.debug({ transformedResponse }, "Transformed Avatax createTransaction response to:");
|
this.logger.debug("Transformed Avatax createTransaction response");
|
||||||
|
|
||||||
return transformedResponse;
|
return transformedResponse;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,29 +18,26 @@ export class AvataxOrderFulfilledAdapter
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(private readonly config: AvataxConfig) {
|
constructor(private readonly config: AvataxConfig) {
|
||||||
this.logger = createLogger({ location: "AvataxOrderFulfilledAdapter" });
|
this.logger = createLogger({ name: "AvataxOrderFulfilledAdapter" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(payload: AvataxOrderFulfilledPayload): Promise<AvataxOrderFulfilledResponse> {
|
async send(payload: AvataxOrderFulfilledPayload): Promise<AvataxOrderFulfilledResponse> {
|
||||||
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
|
this.logger.debug("Transforming the Saleor payload for commiting transaction with Avatax...");
|
||||||
|
|
||||||
const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer(this.config);
|
const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer(this.config);
|
||||||
const target = payloadTransformer.transform({ ...payload });
|
const target = payloadTransformer.transform({ ...payload });
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug("Calling Avatax commitTransaction with transformed payload...");
|
||||||
{ transformedPayload: target },
|
|
||||||
"Will call Avatax commitTransaction with transformed payload:"
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new AvataxClient(this.config);
|
const client = new AvataxClient(this.config);
|
||||||
const response = await client.commitTransaction(target);
|
const response = await client.commitTransaction(target);
|
||||||
|
|
||||||
this.logger.debug({ response }, "Avatax commitTransaction responded with:");
|
this.logger.debug("Avatax commitTransaction succesfully responded");
|
||||||
|
|
||||||
const responseTransformer = new AvataxOrderFulfilledResponseTransformer();
|
const responseTransformer = new AvataxOrderFulfilledResponseTransformer();
|
||||||
const transformedResponse = responseTransformer.transform(response);
|
const transformedResponse = responseTransformer.transform(response);
|
||||||
|
|
||||||
this.logger.debug({ transformedResponse }, "Transformed Avatax commitTransaction response to:");
|
this.logger.debug("Transformed Avatax commitTransaction response");
|
||||||
|
|
||||||
return transformedResponse;
|
return transformedResponse;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
|
import { CrudSettingsManager } from "../../crud-settings/crud-settings.service";
|
||||||
|
|
||||||
|
export const avataxTaxCodeMatchSchema = z.object({
|
||||||
|
saleorTaxClassId: z.string(),
|
||||||
|
avataxTaxCode: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AvataxTaxCodeMatch = z.infer<typeof avataxTaxCodeMatchSchema>;
|
||||||
|
|
||||||
|
const avataxTaxCodeMatchesSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
data: avataxTaxCodeMatchSchema,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AvataxTaxCodeMatches = z.infer<typeof avataxTaxCodeMatchesSchema>;
|
||||||
|
|
||||||
|
const metadataKey = "avatax-tax-code-map";
|
||||||
|
|
||||||
|
export class AvataxTaxCodeMatchRepository {
|
||||||
|
private crudSettingsManager: CrudSettingsManager;
|
||||||
|
private logger: Logger;
|
||||||
|
constructor(settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
|
||||||
|
this.crudSettingsManager = new CrudSettingsManager(settingsManager, saleorApiUrl, metadataKey);
|
||||||
|
this.logger = createLogger({
|
||||||
|
name: "AvataxTaxCodeMatchRepository",
|
||||||
|
metadataKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<AvataxTaxCodeMatches> {
|
||||||
|
const { data } = await this.crudSettingsManager.readAll();
|
||||||
|
|
||||||
|
return avataxTaxCodeMatchesSchema.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: AvataxTaxCodeMatch): Promise<{ data: { id: string } }> {
|
||||||
|
return this.crudSettingsManager.create({ data: input });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateById(id: string, input: AvataxTaxCodeMatch): Promise<void> {
|
||||||
|
return this.crudSettingsManager.updateById(id, { data: input });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { TaxBaseLineFragment } from "../../../../generated/graphql";
|
||||||
|
import { AvataxTaxCodeMatches } from "./avatax-tax-code-match-repository";
|
||||||
|
import { AvataxTaxCodeMatcher } from "./avatax-tax-code-matcher";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const matcher = new AvataxTaxCodeMatcher();
|
||||||
|
|
||||||
|
describe("AvataxTaxCodeMatcher", () => {
|
||||||
|
it("returns empty string when tax class is not found", () => {
|
||||||
|
const line: TaxBaseLineFragment = {
|
||||||
|
quantity: 1,
|
||||||
|
totalPrice: {
|
||||||
|
amount: 1,
|
||||||
|
},
|
||||||
|
unitPrice: {
|
||||||
|
amount: 1,
|
||||||
|
},
|
||||||
|
sourceLine: {
|
||||||
|
id: "1",
|
||||||
|
__typename: "OrderLine",
|
||||||
|
orderProductVariant: {
|
||||||
|
id: "1",
|
||||||
|
product: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const matches: AvataxTaxCodeMatches = [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
saleorTaxClassId: "",
|
||||||
|
avataxTaxCode: "1",
|
||||||
|
},
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(matcher.match(line, matches)).toEqual("");
|
||||||
|
});
|
||||||
|
it("returns a match when tax class is found", () => {
|
||||||
|
const line: TaxBaseLineFragment = {
|
||||||
|
quantity: 1,
|
||||||
|
totalPrice: {
|
||||||
|
amount: 1,
|
||||||
|
},
|
||||||
|
unitPrice: {
|
||||||
|
amount: 1,
|
||||||
|
},
|
||||||
|
sourceLine: {
|
||||||
|
id: "1",
|
||||||
|
__typename: "OrderLine",
|
||||||
|
orderProductVariant: {
|
||||||
|
id: "1",
|
||||||
|
product: {
|
||||||
|
taxClass: {
|
||||||
|
name: "Clothing",
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const matches: AvataxTaxCodeMatches = [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
saleorTaxClassId: "1",
|
||||||
|
avataxTaxCode: "123412",
|
||||||
|
},
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const taxCode = matcher.match(line, matches);
|
||||||
|
|
||||||
|
expect(taxCode).toEqual("123412");
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { TaxBaseLineFragment } from "../../../../generated/graphql";
|
||||||
|
import { AvataxTaxCodeMatches } from "./avatax-tax-code-match-repository";
|
||||||
|
|
||||||
|
export class AvataxTaxCodeMatcher {
|
||||||
|
private mapTaxClassWithTaxMatch(taxClassId: string, matches: AvataxTaxCodeMatches) {
|
||||||
|
return matches.find((m) => m.data.saleorTaxClassId === taxClassId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTaxClassId(line: TaxBaseLineFragment): string | undefined {
|
||||||
|
if (line.sourceLine.__typename === "CheckoutLine") {
|
||||||
|
return line.sourceLine.checkoutProductVariant.product.taxClass?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.sourceLine.__typename === "OrderLine") {
|
||||||
|
return line.sourceLine.orderProductVariant?.product.taxClass?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match(line: TaxBaseLineFragment, matches: AvataxTaxCodeMatches) {
|
||||||
|
const taxClassId = this.getTaxClassId(line);
|
||||||
|
|
||||||
|
// We can fall back to empty string if we don't have a tax code match
|
||||||
|
return taxClassId
|
||||||
|
? this.mapTaxClassWithTaxMatch(taxClassId, matches)?.data.avataxTaxCode ?? ""
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { createLogger } from "../../../lib/logger";
|
||||||
|
import { AvataxTaxCodeMatchesService } from "./avatax-tax-code-matches.service";
|
||||||
|
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||||
|
import { router } from "../../trpc/trpc-server";
|
||||||
|
import { avataxTaxCodeMatchSchema } from "./avatax-tax-code-match-repository";
|
||||||
|
|
||||||
|
const protectedWithAvataxTaxCodeMatchesService = protectedClientProcedure.use(({ next, ctx }) =>
|
||||||
|
next({
|
||||||
|
ctx: {
|
||||||
|
taxCodeMatchesService: new AvataxTaxCodeMatchesService({
|
||||||
|
saleorApiUrl: ctx.saleorApiUrl,
|
||||||
|
token: ctx.appToken!,
|
||||||
|
appId: ctx.appId!,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const avataxTaxCodeMatchesRouter = router({
|
||||||
|
getAll: protectedWithAvataxTaxCodeMatchesService.query(async ({ ctx }) => {
|
||||||
|
const logger = createLogger({
|
||||||
|
name: "avataxTaxCodeMatchesRouter.fetch",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Returning tax code matches");
|
||||||
|
|
||||||
|
return ctx.taxCodeMatchesService.getAll();
|
||||||
|
}),
|
||||||
|
upsert: protectedWithAvataxTaxCodeMatchesService
|
||||||
|
.input(avataxTaxCodeMatchSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const logger = createLogger({
|
||||||
|
name: "avataxTaxCodeMatchesRouter.upsert",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Upserting tax code match");
|
||||||
|
|
||||||
|
return ctx.taxCodeMatchesService.upsert(input);
|
||||||
|
}),
|
||||||
|
});
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
|
import { createSettingsManager } from "../../app/metadata-manager";
|
||||||
|
import {
|
||||||
|
AvataxTaxCodeMatch,
|
||||||
|
AvataxTaxCodeMatchRepository,
|
||||||
|
AvataxTaxCodeMatches,
|
||||||
|
} from "./avatax-tax-code-match-repository";
|
||||||
|
import { createGraphQLClient } from "@saleor/apps-shared";
|
||||||
|
|
||||||
|
export class AvataxTaxCodeMatchesService {
|
||||||
|
private logger: Logger;
|
||||||
|
private taxCodeMatchRepository: AvataxTaxCodeMatchRepository;
|
||||||
|
|
||||||
|
constructor(authData: AuthData) {
|
||||||
|
this.logger = createLogger({ name: "AvataxTaxCodeMatchesService" });
|
||||||
|
const client = createGraphQLClient({
|
||||||
|
saleorApiUrl: authData.saleorApiUrl,
|
||||||
|
token: authData.token,
|
||||||
|
});
|
||||||
|
const { appId, saleorApiUrl } = authData;
|
||||||
|
const settingsManager = createSettingsManager(client, appId);
|
||||||
|
|
||||||
|
this.taxCodeMatchRepository = new AvataxTaxCodeMatchRepository(settingsManager, saleorApiUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<AvataxTaxCodeMatches> {
|
||||||
|
return this.taxCodeMatchRepository.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(input: AvataxTaxCodeMatch): Promise<void | { data: { id: string } }> {
|
||||||
|
const taxCodeMatches = await this.getAll();
|
||||||
|
const taxCodeMatch = taxCodeMatches.find(
|
||||||
|
(match) => match.data.saleorTaxClassId === input.saleorTaxClassId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taxCodeMatch) {
|
||||||
|
return this.taxCodeMatchRepository.updateById(taxCodeMatch.id, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.taxCodeMatchRepository.create(input);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { router } from "../../trpc/trpc-server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createLogger } from "../../../lib/logger";
|
||||||
|
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||||
|
import { AvataxConnectionService } from "../configuration/avatax-connection.service";
|
||||||
|
import { AvataxTaxCodesService } from "./avatax-tax-codes.service";
|
||||||
|
|
||||||
|
const getAllForIdSchema = z.object({ connectionId: z.string() });
|
||||||
|
|
||||||
|
export const avataxTaxCodesRouter = router({
|
||||||
|
getAllForId: protectedClientProcedure.input(getAllForIdSchema).query(async ({ ctx, input }) => {
|
||||||
|
const logger = createLogger({
|
||||||
|
name: "avataxTaxCodesRouter.getAllForId",
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionService = new AvataxConnectionService(
|
||||||
|
ctx.apiClient,
|
||||||
|
ctx.appId!,
|
||||||
|
ctx.saleorApiUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
const connection = await connectionService.getById(input.connectionId);
|
||||||
|
const taxCodesService = new AvataxTaxCodesService(connection.config);
|
||||||
|
|
||||||
|
logger.debug("Returning tax codes");
|
||||||
|
|
||||||
|
return taxCodesService.getAll();
|
||||||
|
}),
|
||||||
|
});
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { AvataxClient } from "../avatax-client";
|
||||||
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
|
import type { TaxCode } from "../../taxes/tax-code";
|
||||||
|
import { FetchResult } from "avatax/lib/utils/fetch_result";
|
||||||
|
import { TaxCodeModel } from "avatax/lib/models/TaxCodeModel";
|
||||||
|
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
|
||||||
|
|
||||||
|
export class AvataxTaxCodesService {
|
||||||
|
private client: AvataxClient;
|
||||||
|
|
||||||
|
constructor(config: AvataxConfig) {
|
||||||
|
this.client = new AvataxClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private adapt(taxCodes: TaxCodeModel[]): TaxCode[] {
|
||||||
|
return taxCodes.map((item) => ({
|
||||||
|
description: taxProviderUtils.resolveOptionalOrThrow(item.description),
|
||||||
|
code: item.taxCode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll() {
|
||||||
|
const response = await this.client.getTaxCodes();
|
||||||
|
|
||||||
|
return this.adapt(response);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import React from "react";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { Table } from "../../ui/table";
|
||||||
|
import { Select } from "../../ui/_select";
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { AppCard } from "../../ui/app-card";
|
||||||
|
|
||||||
|
const SelectTaxCode = ({ taxClassId }: { taxClassId: string }) => {
|
||||||
|
const [value, setValue] = React.useState("");
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data: avataxMatches, isLoading: isMatchesLoading } =
|
||||||
|
trpcClient.avataxMatches.getAll.useQuery();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (avataxMatches) {
|
||||||
|
const match = avataxMatches?.find((item) => item.data.saleorTaxClassId === taxClassId);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
setValue(match.data.avataxTaxCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [avataxMatches, taxClassId]);
|
||||||
|
|
||||||
|
const { mutate: updateMutation } = trpcClient.avataxMatches.upsert.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Updated Avatax tax code matches");
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
notifyError("Error", error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: providers } = trpcClient.providersConfiguration.getAll.useQuery();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tax Code Matcher is only available when there is at least one connection.
|
||||||
|
* The reason for it is that we need any working credentials to fetch the provider tax codes.
|
||||||
|
*/
|
||||||
|
const firstConnectionId = providers?.[0].id;
|
||||||
|
|
||||||
|
const { data: taxCodes = [], isLoading: isCodesLoading } =
|
||||||
|
trpcClient.avataxTaxCodes.getAllForId.useQuery(
|
||||||
|
{
|
||||||
|
connectionId: firstConnectionId!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: firstConnectionId !== undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeValue = (avataxTaxCode: string) => {
|
||||||
|
setValue(avataxTaxCode);
|
||||||
|
updateMutation({
|
||||||
|
saleorTaxClassId: taxClassId,
|
||||||
|
avataxTaxCode,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = isMatchesLoading || isCodesLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value ?? ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={(value) => changeValue(String(value))}
|
||||||
|
options={[
|
||||||
|
...(isLoading
|
||||||
|
? [{ value: "", label: "Loading..." }]
|
||||||
|
: [{ value: "", label: "Not assigned" }]),
|
||||||
|
...taxCodes.map((item) => ({
|
||||||
|
value: item.code,
|
||||||
|
label: `${item.code} | ${item.description}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AvataxTaxCodeMatcherTable = () => {
|
||||||
|
const { data: taxClasses = [], isLoading } = trpcClient.taxClasses.getAll.useQuery();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="textNeutralSubdued">Loading...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppCard>
|
||||||
|
<Table.Container>
|
||||||
|
<Table.THead>
|
||||||
|
<Table.TR>
|
||||||
|
<Table.TH>Saleor tax class</Table.TH>
|
||||||
|
<Table.TH>Avatax tax code</Table.TH>
|
||||||
|
</Table.TR>
|
||||||
|
</Table.THead>
|
||||||
|
<Table.TBody>
|
||||||
|
{taxClasses.map((taxClass) => {
|
||||||
|
return (
|
||||||
|
<Table.TR key={taxClass.id}>
|
||||||
|
<Table.TD>{taxClass.name}</Table.TD>
|
||||||
|
<Table.TD>
|
||||||
|
<SelectTaxCode taxClassId={taxClass.id} />
|
||||||
|
</Table.TD>
|
||||||
|
</Table.TR>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.TBody>
|
||||||
|
</Table.Container>
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,7 +1,7 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const channelConfigPropertiesSchema = z.object({
|
export const channelConfigPropertiesSchema = z.object({
|
||||||
providerConnectionId: z.string().or(z.null()),
|
providerConnectionId: z.string().nonempty().or(z.null()),
|
||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ describe("ChannelConfigurationMerger", () => {
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
id: "1",
|
id: expect.any(String),
|
||||||
config: {
|
config: {
|
||||||
providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50",
|
providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50",
|
||||||
slug: "default-channel",
|
slug: "default-channel",
|
||||||
|
@ -37,7 +37,7 @@ describe("ChannelConfigurationMerger", () => {
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
id: "1",
|
id: expect.any(String),
|
||||||
config: {
|
config: {
|
||||||
providerConnectionId: null,
|
providerConnectionId: null,
|
||||||
slug: "default-channel",
|
slug: "default-channel",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { ChannelFragment } from "../../../generated/graphql";
|
import { ChannelFragment } from "../../../generated/graphql";
|
||||||
|
import { createId } from "../../lib/utils";
|
||||||
import { ChannelsConfig } from "./channel-config";
|
import { ChannelsConfig } from "./channel-config";
|
||||||
|
|
||||||
export class ChannelConfigurationMerger {
|
export class ChannelConfigurationMerger {
|
||||||
|
@ -6,10 +7,20 @@ export class ChannelConfigurationMerger {
|
||||||
return channels.map((channel) => {
|
return channels.map((channel) => {
|
||||||
const channelConfig = channelsConfig.find((c) => c.config.slug === channel.slug);
|
const channelConfig = channelsConfig.find((c) => c.config.slug === channel.slug);
|
||||||
|
|
||||||
|
if (!channelConfig) {
|
||||||
return {
|
return {
|
||||||
id: channel.id,
|
id: createId(),
|
||||||
config: {
|
config: {
|
||||||
providerConnectionId: channelConfig?.config.providerConnectionId ?? null,
|
providerConnectionId: null,
|
||||||
|
slug: channel.slug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: channelConfig.id,
|
||||||
|
config: {
|
||||||
|
providerConnectionId: channelConfig.config.providerConnectionId,
|
||||||
slug: channel.slug,
|
slug: channel.slug,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
|
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
|
||||||
import { Logger, createLogger } from "../../lib/logger";
|
import { Logger, createLogger } from "../../lib/logger";
|
||||||
import { CrudSettingsManager } from "../crud-settings/crud-settings.service";
|
import { CrudSettingsManager } from "../crud-settings/crud-settings.service";
|
||||||
import { ChannelConfigProperties, channelsSchema } from "./channel-config";
|
import { ChannelConfig, channelsSchema } from "./channel-config";
|
||||||
|
|
||||||
export class ChannelConfigurationSettings {
|
export class ChannelConfigurationRepository {
|
||||||
private crudSettingsManager: CrudSettingsManager;
|
private crudSettingsManager: CrudSettingsManager;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
constructor(private settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
|
constructor(settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
|
||||||
this.crudSettingsManager = new CrudSettingsManager(
|
this.crudSettingsManager = new CrudSettingsManager(
|
||||||
settingsManager,
|
settingsManager,
|
||||||
saleorApiUrl,
|
saleorApiUrl,
|
||||||
"channel-configuration"
|
"channel-configuration"
|
||||||
);
|
);
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "ChannelConfigurationSettings",
|
name: "ChannelConfigurationRepository",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,7 +23,11 @@ export class ChannelConfigurationSettings {
|
||||||
return channelsSchema.parse(data);
|
return channelsSchema.parse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(id: string, data: ChannelConfigProperties) {
|
async updateById(id: string, input: Pick<ChannelConfig, "config">) {
|
||||||
await this.crudSettingsManager.upsert(id, { config: data });
|
return this.crudSettingsManager.updateById(id, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: Pick<ChannelConfig, "config">) {
|
||||||
|
return this.crudSettingsManager.create(input);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,13 +1,12 @@
|
||||||
import { createLogger } from "../../lib/logger";
|
import { createLogger } from "../../lib/logger";
|
||||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||||
import { router } from "../trpc/trpc-server";
|
import { router } from "../trpc/trpc-server";
|
||||||
import { channelConfigSchema } from "./channel-config";
|
import { channelConfigPropertiesSchema } from "./channel-config";
|
||||||
import { ChannelConfigurationService } from "./channel-configuration.service";
|
import { ChannelConfigurationService } from "./channel-configuration.service";
|
||||||
|
|
||||||
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
||||||
next({
|
next({
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
|
||||||
connectionService: new ChannelConfigurationService(
|
connectionService: new ChannelConfigurationService(
|
||||||
ctx.apiClient,
|
ctx.apiClient,
|
||||||
ctx.appId!,
|
ctx.appId!,
|
||||||
|
@ -18,9 +17,9 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const channelsConfigurationRouter = router({
|
export const channelsConfigurationRouter = router({
|
||||||
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
|
getAll: protectedWithConfigurationService.query(async ({ ctx }) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
location: "channelsConfigurationRouter.fetch",
|
name: "channelsConfigurationRouter.fetch",
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelConfiguration = ctx.connectionService;
|
const channelConfiguration = ctx.connectionService;
|
||||||
|
@ -30,17 +29,17 @@ export const channelsConfigurationRouter = router({
|
||||||
return channelConfiguration.getAll();
|
return channelConfiguration.getAll();
|
||||||
}),
|
}),
|
||||||
upsert: protectedWithConfigurationService
|
upsert: protectedWithConfigurationService
|
||||||
.input(channelConfigSchema)
|
.input(channelConfigPropertiesSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
saleorApiUrl: ctx.saleorApiUrl,
|
saleorApiUrl: ctx.saleorApiUrl,
|
||||||
procedure: "channelsConfigurationRouter.upsert",
|
procedure: "channelsConfigurationRouter.upsert",
|
||||||
});
|
});
|
||||||
|
|
||||||
const configurationService = ctx.connectionService;
|
const result = await ctx.connectionService.upsert(input);
|
||||||
|
|
||||||
await configurationService.update(input.id, input.config);
|
logger.info("Channel configuration upserted");
|
||||||
|
|
||||||
logger.info("Channel configuration updated");
|
return result;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { ChannelConfigProperties } from "./channel-config";
|
import { ChannelConfigProperties } from "./channel-config";
|
||||||
import { ChannelConfigurationSettings } from "./channel-configuration-settings";
|
import { ChannelConfigurationRepository } from "./channel-configuration-repository";
|
||||||
import { ChannelsFetcher } from "./channel-fetcher";
|
import { ChannelsFetcher } from "./channel-fetcher";
|
||||||
import { ChannelConfigurationMerger } from "./channel-configuration-merger";
|
import { ChannelConfigurationMerger } from "./channel-configuration-merger";
|
||||||
import { TaxChannelsV1toV2MigrationManager } from "../../../scripts/migrations/tax-channels-migration-v1-to-v2";
|
import { TaxChannelsV1toV2MigrationManager } from "../../../scripts/migrations/tax-channels-migration-v1-to-v2";
|
||||||
|
@ -9,7 +9,7 @@ import { Logger, createLogger } from "../../lib/logger";
|
||||||
import { createSettingsManager } from "../app/metadata-manager";
|
import { createSettingsManager } from "../app/metadata-manager";
|
||||||
|
|
||||||
export class ChannelConfigurationService {
|
export class ChannelConfigurationService {
|
||||||
private configurationService: ChannelConfigurationSettings;
|
private configurationRepository: ChannelConfigurationRepository;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private settingsManager: EncryptedMetadataManager;
|
private settingsManager: EncryptedMetadataManager;
|
||||||
constructor(private client: Client, private appId: string, private saleorApiUrl: string) {
|
constructor(private client: Client, private appId: string, private saleorApiUrl: string) {
|
||||||
|
@ -18,10 +18,13 @@ export class ChannelConfigurationService {
|
||||||
this.settingsManager = settingsManager;
|
this.settingsManager = settingsManager;
|
||||||
|
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "ChannelConfigurationService",
|
name: "ChannelConfigurationService",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.configurationService = new ChannelConfigurationSettings(settingsManager, saleorApiUrl);
|
this.configurationRepository = new ChannelConfigurationRepository(
|
||||||
|
settingsManager,
|
||||||
|
saleorApiUrl
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll() {
|
async getAll() {
|
||||||
|
@ -42,14 +45,23 @@ export class ChannelConfigurationService {
|
||||||
this.logger.info("Config is up to date, no need to migrate.");
|
this.logger.info("Config is up to date, no need to migrate.");
|
||||||
const channels = await channelsFetcher.fetchChannels();
|
const channels = await channelsFetcher.fetchChannels();
|
||||||
|
|
||||||
const channelConfiguration = await this.configurationService.getAll();
|
const channelConfiguration = await this.configurationRepository.getAll();
|
||||||
|
|
||||||
const configurationMerger = new ChannelConfigurationMerger();
|
const configurationMerger = new ChannelConfigurationMerger();
|
||||||
|
|
||||||
return configurationMerger.merge(channels, channelConfiguration);
|
return configurationMerger.merge(channels, channelConfiguration);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: ChannelConfigProperties) {
|
async upsert(data: ChannelConfigProperties) {
|
||||||
await this.configurationService.upsert(id, data);
|
const { slug } = data;
|
||||||
|
const configurations = await this.configurationRepository.getAll();
|
||||||
|
|
||||||
|
const existingConfiguration = configurations.find((c) => c.config.slug === slug);
|
||||||
|
|
||||||
|
if (existingConfiguration) {
|
||||||
|
return this.configurationRepository.updateById(existingConfiguration.id, { config: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.configurationRepository.create({ config: data });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ const Skeleton = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChannelList = () => {
|
export const ChannelList = () => {
|
||||||
const { data = [], isFetching, isFetched } = trpcClient.channelsConfiguration.fetch.useQuery();
|
const { data = [], isFetching, isFetched } = trpcClient.channelsConfiguration.getAll.useQuery();
|
||||||
|
|
||||||
const isAnyChannelConfigured = data.length > 0;
|
const isAnyChannelConfigured = data.length > 0;
|
||||||
const isResult = isFetched && isAnyChannelConfigured;
|
const isResult = isFetched && isAnyChannelConfigured;
|
||||||
|
|
|
@ -1,15 +1,8 @@
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { Text } from "@saleor/macaw-ui/next";
|
|
||||||
import { Section } from "../../ui/app-section";
|
import { Section } from "../../ui/app-section";
|
||||||
import { ChannelList } from "./channel-list";
|
import { ChannelList } from "./channel-list";
|
||||||
|
import { AppDashboardLink } from "../../ui/app-dashboard-link";
|
||||||
|
|
||||||
const Intro = () => {
|
const Intro = () => {
|
||||||
const appBridge = useAppBridge();
|
|
||||||
|
|
||||||
const redirectToTaxes = () => {
|
|
||||||
appBridge.appBridge?.dispatch(actions.Redirect({ to: "/taxes/channels" }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section.Description
|
<Section.Description
|
||||||
title="Available channels"
|
title="Available channels"
|
||||||
|
@ -20,19 +13,7 @@ const Intro = () => {
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
You can change the tax configuration method for each channel in the{" "}
|
You can change the tax configuration method for each channel in the{" "}
|
||||||
<Text
|
<AppDashboardLink href="/taxes/channels">Configuration → Taxes</AppDashboardLink> view.
|
||||||
transition={"ease"}
|
|
||||||
variant={"bodyStrong"}
|
|
||||||
cursor={"pointer"}
|
|
||||||
color={{
|
|
||||||
default: "text3Decorative",
|
|
||||||
hover: "text1Decorative",
|
|
||||||
}}
|
|
||||||
onClick={redirectToTaxes}
|
|
||||||
>
|
|
||||||
Configuration → Taxes
|
|
||||||
</Text>{" "}
|
|
||||||
view.
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -8,12 +8,11 @@ import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
const SelectProvider = (channelConfig: ChannelConfig) => {
|
const SelectProvider = (channelConfig: ChannelConfig) => {
|
||||||
const {
|
const {
|
||||||
config: { providerConnectionId = "", slug },
|
config: { providerConnectionId = "", slug },
|
||||||
id,
|
|
||||||
} = channelConfig;
|
} = channelConfig;
|
||||||
const [value, setValue] = React.useState(providerConnectionId);
|
const [value, setValue] = React.useState(providerConnectionId);
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
const { mutate: upsertMutation } = trpcClient.channelsConfiguration.upsert.useMutation({
|
const { mutate: updateMutation } = trpcClient.channelsConfiguration.upsert.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
notifySuccess("Success", "Updated channel configuration");
|
notifySuccess("Success", "Updated channel configuration");
|
||||||
},
|
},
|
||||||
|
@ -24,14 +23,11 @@ const SelectProvider = (channelConfig: ChannelConfig) => {
|
||||||
|
|
||||||
const { data: providerConfigurations = [] } = trpcClient.providersConfiguration.getAll.useQuery();
|
const { data: providerConfigurations = [] } = trpcClient.providersConfiguration.getAll.useQuery();
|
||||||
|
|
||||||
const changeValue = (nextproviderConnectionId: string) => {
|
const changeValue = (nextProviderConnectionId: string) => {
|
||||||
setValue(nextproviderConnectionId);
|
setValue(nextProviderConnectionId);
|
||||||
upsertMutation({
|
updateMutation({
|
||||||
id,
|
providerConnectionId: nextProviderConnectionId === "" ? null : nextProviderConnectionId,
|
||||||
config: {
|
|
||||||
providerConnectionId: nextproviderConnectionId,
|
|
||||||
slug,
|
slug,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -51,7 +47,7 @@ const SelectProvider = (channelConfig: ChannelConfig) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChannelTable = () => {
|
export const ChannelTable = () => {
|
||||||
const { data = [] } = trpcClient.channelsConfiguration.fetch.useQuery();
|
const { data = [] } = trpcClient.channelsConfiguration.getAll.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Container>
|
<Table.Container>
|
||||||
|
|
|
@ -57,7 +57,7 @@ describe("CrudSettingsService", () => {
|
||||||
return encryptedValue;
|
return encryptedValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.read("id")).rejects.toThrowError("Error while validating metadata");
|
await expect(service.readById("id")).rejects.toThrowError("Error while validating metadata");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error if the item is not found", async () => {
|
it("throws an error if the item is not found", async () => {
|
||||||
|
@ -67,7 +67,7 @@ describe("CrudSettingsService", () => {
|
||||||
return encryptedValue;
|
return encryptedValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.read("id2")).rejects.toThrowError("Item not found");
|
await expect(service.readById("id2")).rejects.toThrowError("Item not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the item if found", async () => {
|
it("returns the item if found", async () => {
|
||||||
|
@ -77,7 +77,7 @@ describe("CrudSettingsService", () => {
|
||||||
return encryptedValue;
|
return encryptedValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.read("id");
|
const result = await service.readById("id");
|
||||||
|
|
||||||
expect(result).toEqual({ data: { id: "id", key: "value" } });
|
expect(result).toEqual({ data: { id: "id", key: "value" } });
|
||||||
});
|
});
|
||||||
|
@ -109,46 +109,13 @@ describe("CrudSettingsService", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("upsert", () => {
|
describe("updateById", () => {
|
||||||
it("creates a new item if it doesn't exist", async () => {
|
|
||||||
vi.mocked(mockSettingsManager.get).mockImplementation(async () => {
|
|
||||||
return JSON.stringify([{ id: "id", key: "value" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.upsert("id2", { key: "value2" });
|
|
||||||
|
|
||||||
expect(mockSettingsManager.set).toHaveBeenCalledWith({
|
|
||||||
domain: "apiUrl",
|
|
||||||
key: "metadataKey",
|
|
||||||
value: JSON.stringify([
|
|
||||||
{ id: "id", key: "value" },
|
|
||||||
{ id: "id2", key: "value2" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates an existing item", async () => {
|
|
||||||
vi.mocked(mockSettingsManager.get).mockImplementation(async () => {
|
|
||||||
return JSON.stringify([{ id: "id", key: "value" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.upsert("id", { key: "value2" });
|
|
||||||
|
|
||||||
expect(mockSettingsManager.set).toHaveBeenCalledWith({
|
|
||||||
domain: "apiUrl",
|
|
||||||
key: "metadataKey",
|
|
||||||
value: JSON.stringify([{ id: "id", key: "value2" }]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("update", () => {
|
|
||||||
it("partially updates an existing item", async () => {
|
it("partially updates an existing item", async () => {
|
||||||
vi.mocked(mockSettingsManager.get).mockImplementation(async () => {
|
vi.mocked(mockSettingsManager.get).mockImplementation(async () => {
|
||||||
return JSON.stringify([{ id: "id", data: [], config: { foo: "bar" } }]);
|
return JSON.stringify([{ id: "id", data: [], config: { foo: "bar" } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.update("id", { config: { foo: "baz" } });
|
await service.updateById("id", { config: { foo: "baz" } });
|
||||||
|
|
||||||
expect(mockSettingsManager.set).toHaveBeenCalledWith({
|
expect(mockSettingsManager.set).toHaveBeenCalledWith({
|
||||||
domain: "apiUrl",
|
domain: "apiUrl",
|
||||||
|
|
|
@ -25,7 +25,7 @@ export class CrudSettingsManager {
|
||||||
private metadataKey: string
|
private metadataKey: string
|
||||||
) {
|
) {
|
||||||
this.metadataKey = metadataKey;
|
this.metadataKey = metadataKey;
|
||||||
this.logger = createLogger({ location: "CrudSettingsManager", metadataKey });
|
this.logger = createLogger({ name: "CrudSettingsManager", metadataKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
async readAll() {
|
async readAll() {
|
||||||
|
@ -48,7 +48,7 @@ export class CrudSettingsManager {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(id: string) {
|
async readById(id: string) {
|
||||||
const result = await this.readAll();
|
const result = await this.readAll();
|
||||||
const { data: settings } = result;
|
const { data: settings } = result;
|
||||||
|
|
||||||
|
@ -94,7 +94,7 @@ export class CrudSettingsManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, input: any) {
|
async updateById(id: string, input: any) {
|
||||||
const { data: currentSettings } = await this.readAll();
|
const { data: currentSettings } = await this.readAll();
|
||||||
const nextSettings = currentSettings.map((item) => {
|
const nextSettings = currentSettings.map((item) => {
|
||||||
if (item.id === id) {
|
if (item.id === id) {
|
||||||
|
@ -105,29 +105,6 @@ export class CrudSettingsManager {
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.debug({ nextSettings }, "nextSettings");
|
|
||||||
|
|
||||||
await this.metadataManager.set({
|
|
||||||
key: this.metadataKey,
|
|
||||||
value: JSON.stringify(nextSettings),
|
|
||||||
domain: this.saleorApiUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsert(id: string, input: any) {
|
|
||||||
const { data: currentSettings } = await this.readAll();
|
|
||||||
// update if its there
|
|
||||||
const nextSettings = currentSettings.map((item) => {
|
|
||||||
if (item.id === id) {
|
|
||||||
return { id, ...input };
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentSettings.find((item) => item.id === id)) {
|
|
||||||
nextSettings.push({ id, ...input });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.metadataManager.set({
|
await this.metadataManager.set({
|
||||||
key: this.metadataKey,
|
key: this.metadataKey,
|
||||||
value: JSON.stringify(nextSettings),
|
value: JSON.stringify(nextSettings),
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { PublicProviderConnectionsService } from "./public-provider-connections.
|
||||||
export const providerConnectionsRouter = router({
|
export const providerConnectionsRouter = router({
|
||||||
getAll: protectedClientProcedure.query(async ({ ctx }) => {
|
getAll: protectedClientProcedure.query(async ({ ctx }) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
location: "providerConnectionsRouter.getAll",
|
name: "providerConnectionsRouter.getAll",
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = await new PublicProviderConnectionsService(
|
const items = await new PublicProviderConnectionsService(
|
||||||
|
|
|
@ -13,7 +13,7 @@ export class PublicProviderConnectionsService {
|
||||||
this.avataxConnectionService = new PublicAvataxConnectionService(client, appId, saleorApiUrl);
|
this.avataxConnectionService = new PublicAvataxConnectionService(client, appId, saleorApiUrl);
|
||||||
this.taxJarConnectionService = new PublicTaxJarConnectionService(client, appId, saleorApiUrl);
|
this.taxJarConnectionService = new PublicTaxJarConnectionService(client, appId, saleorApiUrl);
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "PublicProviderConnectionsService",
|
name: "PublicProviderConnectionsService",
|
||||||
metadataKey: TAX_PROVIDER_KEY,
|
metadataKey: TAX_PROVIDER_KEY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
25
apps/taxes/src/modules/tax-classes/tax-classes-fetcher.ts
Normal file
25
apps/taxes/src/modules/tax-classes/tax-classes-fetcher.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { Client } from "urql";
|
||||||
|
import {
|
||||||
|
TaxClassFragment,
|
||||||
|
TaxClassesListDocument,
|
||||||
|
TaxClassesListQueryVariables,
|
||||||
|
} from "../../../generated/graphql";
|
||||||
|
|
||||||
|
export class TaxClassesFetcher {
|
||||||
|
constructor(private client: Client) {}
|
||||||
|
|
||||||
|
fetch(): Promise<TaxClassFragment[]> {
|
||||||
|
return this.client
|
||||||
|
.query(TaxClassesListDocument, {
|
||||||
|
/**
|
||||||
|
* todo: add pagination
|
||||||
|
* * arbitrary limit
|
||||||
|
*/
|
||||||
|
first: 100,
|
||||||
|
} as TaxClassesListQueryVariables)
|
||||||
|
.toPromise()
|
||||||
|
.then((r) => {
|
||||||
|
return r.data?.taxClasses?.edges.map(({ node }) => node) ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
20
apps/taxes/src/modules/tax-classes/tax-classes.router.ts
Normal file
20
apps/taxes/src/modules/tax-classes/tax-classes.router.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { createLogger } from "../../lib/logger";
|
||||||
|
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||||
|
import { router } from "../trpc/trpc-server";
|
||||||
|
import { TaxClassesFetcher } from "./tax-classes-fetcher";
|
||||||
|
|
||||||
|
export const taxClassesRouter = router({
|
||||||
|
getAll: protectedClientProcedure.query(async ({ ctx, input }) => {
|
||||||
|
const logger = createLogger({
|
||||||
|
name: "taxClassesRouter.getAll",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug("getAll called");
|
||||||
|
|
||||||
|
const taxClassesFetcher = new TaxClassesFetcher(ctx.apiClient);
|
||||||
|
|
||||||
|
logger.debug("Returning tax classes");
|
||||||
|
|
||||||
|
return taxClassesFetcher.fetch();
|
||||||
|
}),
|
||||||
|
});
|
|
@ -4,6 +4,7 @@ import { MetadataItem } from "../../../generated/graphql";
|
||||||
import { ChannelsConfig } from "../channel-configuration/channel-config";
|
import { ChannelsConfig } from "../channel-configuration/channel-config";
|
||||||
import { ProviderConnections } from "../provider-connections/provider-connections";
|
import { ProviderConnections } from "../provider-connections/provider-connections";
|
||||||
import { getActiveConnection } from "./active-connection";
|
import { getActiveConnection } from "./active-connection";
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
|
|
||||||
const mockedInvalidMetadata: MetadataItem[] = [
|
const mockedInvalidMetadata: MetadataItem[] = [
|
||||||
{
|
{
|
||||||
|
@ -86,24 +87,32 @@ const mockedInvalidEncryptedChannels = encrypt(
|
||||||
|
|
||||||
const mockedValidEncryptedChannels = encrypt(JSON.stringify(mockedValidChannels), mockedSecretKey);
|
const mockedValidEncryptedChannels = encrypt(JSON.stringify(mockedValidChannels), mockedSecretKey);
|
||||||
|
|
||||||
|
const mockedAuthData: AuthData = {
|
||||||
|
appId: "test-app-id",
|
||||||
|
saleorApiUrl: "https://test.saleor.io/graphql/",
|
||||||
|
token: "test-token",
|
||||||
|
};
|
||||||
|
|
||||||
vi.stubEnv("SECRET_KEY", mockedSecretKey);
|
vi.stubEnv("SECRET_KEY", mockedSecretKey);
|
||||||
|
|
||||||
describe("getActiveConnection", () => {
|
describe("getActiveConnection", () => {
|
||||||
it("throws error when channel slug is missing", () => {
|
it("throws error when channel slug is missing", () => {
|
||||||
expect(() => getActiveConnection("", mockedInvalidMetadata)).toThrow(
|
expect(() => getActiveConnection("", mockedInvalidMetadata, mockedAuthData)).toThrow(
|
||||||
"Channel slug was not found in the webhook payload"
|
"Channel slug was not found in the webhook payload"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws error when there are no metadata items", () => {
|
it("throws error when there are no metadata items", () => {
|
||||||
expect(() => getActiveConnection("default-channel", [])).toThrow(
|
expect(() => getActiveConnection("default-channel", [], mockedAuthData)).toThrow(
|
||||||
"App encryptedMetadata was not found in the webhook payload"
|
"App encryptedMetadata was not found in the webhook payload"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws error when no providerConnectionId was found", () => {
|
it("throws error when no providerConnectionId was found", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
getActiveConnection("default-channel", [
|
getActiveConnection(
|
||||||
|
"default-channel",
|
||||||
|
[
|
||||||
{
|
{
|
||||||
key: "providers",
|
key: "providers",
|
||||||
value: mockedEncryptedProviders,
|
value: mockedEncryptedProviders,
|
||||||
|
@ -112,13 +121,17 @@ describe("getActiveConnection", () => {
|
||||||
key: "channels",
|
key: "channels",
|
||||||
value: mockedInvalidEncryptedChannels,
|
value: mockedInvalidEncryptedChannels,
|
||||||
},
|
},
|
||||||
])
|
],
|
||||||
|
mockedAuthData
|
||||||
|
)
|
||||||
).toThrow("Channel config providerConnectionId does not match any providers");
|
).toThrow("Channel config providerConnectionId does not match any providers");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws error when no channel was found for channelSlug", () => {
|
it("throws error when no channel was found for channelSlug", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
getActiveConnection("invalid-channel", [
|
getActiveConnection(
|
||||||
|
"invalid-channel",
|
||||||
|
[
|
||||||
{
|
{
|
||||||
key: "providers",
|
key: "providers",
|
||||||
value: mockedEncryptedProviders,
|
value: mockedEncryptedProviders,
|
||||||
|
@ -127,12 +140,16 @@ describe("getActiveConnection", () => {
|
||||||
key: "channels",
|
key: "channels",
|
||||||
value: mockedValidEncryptedChannels,
|
value: mockedValidEncryptedChannels,
|
||||||
},
|
},
|
||||||
])
|
],
|
||||||
|
mockedAuthData
|
||||||
|
)
|
||||||
).toThrow("Channel config was not found for channel invalid-channel");
|
).toThrow("Channel config was not found for channel invalid-channel");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns provider when data is correct", () => {
|
it("returns provider when data is correct", () => {
|
||||||
const result = getActiveConnection("default-channel", [
|
const result = getActiveConnection(
|
||||||
|
"default-channel",
|
||||||
|
[
|
||||||
{
|
{
|
||||||
key: "providers",
|
key: "providers",
|
||||||
value: mockedEncryptedProviders,
|
value: mockedEncryptedProviders,
|
||||||
|
@ -141,7 +158,9 @@ describe("getActiveConnection", () => {
|
||||||
key: "channels",
|
key: "channels",
|
||||||
value: mockedValidEncryptedChannels,
|
value: mockedValidEncryptedChannels,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
mockedAuthData
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
import {
|
import {
|
||||||
MetadataItem,
|
MetadataItem,
|
||||||
OrderCreatedSubscriptionFragment,
|
OrderCreatedSubscriptionFragment,
|
||||||
|
@ -17,9 +18,9 @@ export class ActiveTaxProvider implements ProviderWebhookService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private client: TaxJarWebhookService | AvataxWebhookService;
|
private client: TaxJarWebhookService | AvataxWebhookService;
|
||||||
|
|
||||||
constructor(providerConnection: ProviderConnection) {
|
constructor(providerConnection: ProviderConnection, private authData: AuthData) {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "ActiveTaxProvider",
|
name: "ActiveTaxProvider",
|
||||||
});
|
});
|
||||||
|
|
||||||
const taxProviderName = providerConnection.provider;
|
const taxProviderName = providerConnection.provider;
|
||||||
|
@ -27,13 +28,13 @@ export class ActiveTaxProvider implements ProviderWebhookService {
|
||||||
switch (taxProviderName) {
|
switch (taxProviderName) {
|
||||||
case "taxjar": {
|
case "taxjar": {
|
||||||
this.logger.debug("Selecting TaxJar as tax provider");
|
this.logger.debug("Selecting TaxJar as tax provider");
|
||||||
this.client = new TaxJarWebhookService(providerConnection.config);
|
this.client = new TaxJarWebhookService(providerConnection.config, this.authData);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "avatax": {
|
case "avatax": {
|
||||||
this.logger.debug("Selecting Avatax as tax provider");
|
this.logger.debug("Selecting Avatax as tax provider");
|
||||||
this.client = new AvataxWebhookService(providerConnection.config);
|
this.client = new AvataxWebhookService(providerConnection.config, this.authData);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,10 +59,11 @@ export class ActiveTaxProvider implements ProviderWebhookService {
|
||||||
|
|
||||||
export function getActiveConnection(
|
export function getActiveConnection(
|
||||||
channelSlug: string | undefined,
|
channelSlug: string | undefined,
|
||||||
encryptedMetadata: MetadataItem[]
|
encryptedMetadata: MetadataItem[],
|
||||||
|
authData: AuthData
|
||||||
): ActiveTaxProvider {
|
): ActiveTaxProvider {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
location: "getActiveConnection",
|
name: "getActiveConnection",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!channelSlug) {
|
if (!channelSlug) {
|
||||||
|
@ -74,11 +76,15 @@ export function getActiveConnection(
|
||||||
|
|
||||||
const { providerConnections, channels } = getAppConfig(encryptedMetadata);
|
const { providerConnections, channels } = getAppConfig(encryptedMetadata);
|
||||||
|
|
||||||
|
if (!channels.length) {
|
||||||
|
throw new Error("You must assign a provider to the channel");
|
||||||
|
}
|
||||||
|
|
||||||
const channelConfig = channels.find((channel) => channel.config.slug === channelSlug);
|
const channelConfig = channels.find((channel) => channel.config.slug === channelSlug);
|
||||||
|
|
||||||
if (!channelConfig) {
|
if (!channelConfig) {
|
||||||
// * will happen when `order-created` webhook is triggered by creating an order in a channel that doesn't use the tax app
|
// * will happen when `order-created` webhook is triggered by creating an order in a channel that doesn't use the tax app
|
||||||
logger.debug({ channelSlug, channelConfig }, "Channel config was not found for channel slug");
|
logger.debug({ channelSlug }, "Channel config was not found for channel slug");
|
||||||
throw new Error(`Channel config was not found for channel ${channelSlug}`);
|
throw new Error(`Channel config was not found for channel ${channelSlug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,7 +100,7 @@ export function getActiveConnection(
|
||||||
throw new Error(`Channel config providerConnectionId does not match any providers`);
|
throw new Error(`Channel config providerConnectionId does not match any providers`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const taxProvider = new ActiveTaxProvider(providerConnection);
|
const taxProvider = new ActiveTaxProvider(providerConnection, authData);
|
||||||
|
|
||||||
return taxProvider;
|
return taxProvider;
|
||||||
}
|
}
|
||||||
|
|
4
apps/taxes/src/modules/taxes/tax-code.ts
Normal file
4
apps/taxes/src/modules/taxes/tax-code.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export type TaxCode = {
|
||||||
|
description: string;
|
||||||
|
code: string;
|
||||||
|
};
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
import { TaxBaseFragment } from "../../../../generated/graphql";
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
import { Logger, createLogger } from "../../../lib/logger";
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
||||||
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
||||||
import { FetchTaxForOrderArgs, TaxJarClient } from "../taxjar-client";
|
import { FetchTaxForOrderArgs, TaxJarClient } from "../taxjar-client";
|
||||||
import { TaxJarConfig } from "../taxjar-connection-schema";
|
import { TaxJarConfig } from "../taxjar-connection-schema";
|
||||||
import { TaxJarCalculateTaxesPayloadTransformer } from "./taxjar-calculate-taxes-payload-transformer";
|
import { TaxJarCalculateTaxesPayloadService } from "./taxjar-calculate-taxes-payload-service";
|
||||||
import { TaxJarCalculateTaxesResponseTransformer } from "./taxjar-calculate-taxes-response-transformer";
|
import { TaxJarCalculateTaxesResponseTransformer } from "./taxjar-calculate-taxes-response-transformer";
|
||||||
|
|
||||||
export type TaxJarCalculateTaxesPayload = {
|
export type TaxJarCalculateTaxesPayload = {
|
||||||
|
@ -18,29 +19,27 @@ export class TaxJarCalculateTaxesAdapter
|
||||||
implements WebhookAdapter<TaxJarCalculateTaxesPayload, TaxJarCalculateTaxesResponse>
|
implements WebhookAdapter<TaxJarCalculateTaxesPayload, TaxJarCalculateTaxesResponse>
|
||||||
{
|
{
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
constructor(private readonly config: TaxJarConfig) {
|
constructor(private readonly config: TaxJarConfig, private authData: AuthData) {
|
||||||
this.logger = createLogger({ location: "TaxJarCalculateTaxesAdapter" });
|
this.logger = createLogger({ name: "TaxJarCalculateTaxesAdapter" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo: refactor because its getting too big
|
||||||
async send(payload: TaxJarCalculateTaxesPayload): Promise<TaxJarCalculateTaxesResponse> {
|
async send(payload: TaxJarCalculateTaxesPayload): Promise<TaxJarCalculateTaxesResponse> {
|
||||||
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
|
this.logger.debug("Transforming the Saleor payload for calculating taxes with TaxJar...");
|
||||||
const payloadTransformer = new TaxJarCalculateTaxesPayloadTransformer(this.config);
|
const payloadService = new TaxJarCalculateTaxesPayloadService(this.config, this.authData);
|
||||||
const target = payloadTransformer.transform(payload);
|
const target = await payloadService.getPayload(payload);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug("Calling TaxJar fetchTaxForOrder with transformed payload...");
|
||||||
{ transformedPayload: target },
|
|
||||||
"Will call TaxJar fetchTaxForOrder with transformed payload:"
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new TaxJarClient(this.config);
|
const client = new TaxJarClient(this.config);
|
||||||
const response = await client.fetchTaxForOrder(target);
|
const response = await client.fetchTaxForOrder(target);
|
||||||
|
|
||||||
this.logger.debug({ response }, "TaxJar fetchTaxForOrder responded with:");
|
this.logger.debug("TaxJar fetchTaxForOrder responded with:");
|
||||||
|
|
||||||
const responseTransformer = new TaxJarCalculateTaxesResponseTransformer();
|
const responseTransformer = new TaxJarCalculateTaxesResponseTransformer();
|
||||||
const transformedResponse = responseTransformer.transform(payload, response);
|
const transformedResponse = responseTransformer.transform(payload, response);
|
||||||
|
|
||||||
this.logger.debug({ transformedResponse }, "Transformed TaxJar fetchTaxForOrder response to:");
|
this.logger.debug("Transformed TaxJar fetchTaxForOrder response to");
|
||||||
|
|
||||||
return transformedResponse;
|
return transformedResponse;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
|
||||||
import { TaxBaseFragment } from "../../../../generated/graphql";
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
import { ChannelConfig } from "../../channel-configuration/channel-config";
|
import { ChannelConfig } from "../../channel-configuration/channel-config";
|
||||||
import { TaxJarConfig } from "../taxjar-connection-schema";
|
import { TaxJarConfig } from "../taxjar-connection-schema";
|
||||||
|
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
|
||||||
|
|
||||||
type TaxBase = TaxBaseFragment;
|
type TaxBase = TaxBaseFragment;
|
||||||
|
|
||||||
|
@ -30,12 +31,12 @@ const taxIncludedTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
|
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzQ4",
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ4",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "VGF4Q2xhc3M6MjI=",
|
||||||
metafield: null,
|
name: "Clothing",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -52,12 +53,12 @@ const taxIncludedTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
|
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ6",
|
||||||
metafield: null,
|
name: "Shoes",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -74,12 +75,12 @@ const taxIncludedTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
|
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ6",
|
||||||
metafield: null,
|
name: "Shoes",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -125,12 +126,12 @@ const taxExcludedTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
|
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzQ4",
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ4",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "",
|
||||||
metafield: null,
|
name: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -147,12 +148,12 @@ const taxExcludedTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
|
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "",
|
||||||
metafield: null,
|
name: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -169,12 +170,12 @@ const taxExcludedTaxBase: TaxBase = {
|
||||||
sourceLine: {
|
sourceLine: {
|
||||||
__typename: "OrderLine",
|
__typename: "OrderLine",
|
||||||
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
|
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
|
||||||
variant: {
|
orderProductVariant: {
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
|
||||||
product: {
|
product: {
|
||||||
metafield: null,
|
taxClass: {
|
||||||
productType: {
|
id: "",
|
||||||
metafield: null,
|
name: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -457,6 +458,16 @@ const withNexusTaxIncludedTaxForOrderMock: TaxForOrder = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultTaxCodeMatches: TaxJarTaxCodeMatches = [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
taxJarTaxCode: "P0000000",
|
||||||
|
saleorTaxClassId: "VGF4Q2xhc3M6MjI=",
|
||||||
|
},
|
||||||
|
id: "VGF4Q29kZTox",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// with/without tax
|
// with/without tax
|
||||||
const testingScenariosMap = {
|
const testingScenariosMap = {
|
||||||
with_no_nexus_tax_included: {
|
with_no_nexus_tax_included: {
|
||||||
|
@ -464,24 +475,28 @@ const testingScenariosMap = {
|
||||||
channelConfig,
|
channelConfig,
|
||||||
providerConfig,
|
providerConfig,
|
||||||
response: noNexusTaxForOrderMock,
|
response: noNexusTaxForOrderMock,
|
||||||
|
matches: defaultTaxCodeMatches,
|
||||||
},
|
},
|
||||||
with_no_nexus_tax_excluded: {
|
with_no_nexus_tax_excluded: {
|
||||||
taxBase: taxExcludedTaxBase,
|
taxBase: taxExcludedTaxBase,
|
||||||
channelConfig,
|
channelConfig,
|
||||||
providerConfig,
|
providerConfig,
|
||||||
response: noNexusTaxForOrderMock,
|
response: noNexusTaxForOrderMock,
|
||||||
|
matches: defaultTaxCodeMatches,
|
||||||
},
|
},
|
||||||
with_nexus_tax_included: {
|
with_nexus_tax_included: {
|
||||||
taxBase: taxIncludedTaxBase,
|
taxBase: taxIncludedTaxBase,
|
||||||
channelConfig,
|
channelConfig,
|
||||||
providerConfig,
|
providerConfig,
|
||||||
response: withNexusTaxIncludedTaxForOrderMock,
|
response: withNexusTaxIncludedTaxForOrderMock,
|
||||||
|
matches: defaultTaxCodeMatches,
|
||||||
},
|
},
|
||||||
with_nexus_tax_excluded: {
|
with_nexus_tax_excluded: {
|
||||||
taxBase: taxExcludedTaxBase,
|
taxBase: taxExcludedTaxBase,
|
||||||
channelConfig,
|
channelConfig,
|
||||||
providerConfig,
|
providerConfig,
|
||||||
response: withNexusTaxExcludedTaxForOrderMock,
|
response: withNexusTaxExcludedTaxForOrderMock,
|
||||||
|
matches: defaultTaxCodeMatches,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -512,4 +527,7 @@ export class TaxJarCalculateTaxesMockGenerator {
|
||||||
...testingScenariosMap[this.scenario].response,
|
...testingScenariosMap[this.scenario].response,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
generateTaxCodeMatches = (overrides: TaxJarTaxCodeMatches = []): TaxJarTaxCodeMatches =>
|
||||||
|
structuredClone([...testingScenariosMap[this.scenario].matches, ...overrides]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { discountUtils } from "../../taxes/discount-utils";
|
||||||
|
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
|
||||||
|
import {
|
||||||
|
TaxJarCalculateTaxesPayload,
|
||||||
|
TaxJarCalculateTaxesTarget,
|
||||||
|
} from "./taxjar-calculate-taxes-adapter";
|
||||||
|
import { TaxJarTaxCodeMatcher } from "../tax-code/taxjar-tax-code-matcher";
|
||||||
|
|
||||||
|
export class TaxJarCalculateTaxesPayloadLinesTransformer {
|
||||||
|
transform(
|
||||||
|
taxBase: TaxJarCalculateTaxesPayload["taxBase"],
|
||||||
|
matches: TaxJarTaxCodeMatches
|
||||||
|
): TaxJarCalculateTaxesTarget["params"]["line_items"] {
|
||||||
|
const { lines, discounts } = taxBase;
|
||||||
|
const discountSum = discounts?.reduce(
|
||||||
|
(total, current) => total + Number(current.amount.amount),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const linePrices = lines.map((line) => Number(line.totalPrice.amount));
|
||||||
|
const distributedDiscounts = discountUtils.distributeDiscount(discountSum, linePrices);
|
||||||
|
|
||||||
|
const mappedLines: TaxJarCalculateTaxesTarget["params"]["line_items"] = lines.map(
|
||||||
|
(line, index) => {
|
||||||
|
const matcher = new TaxJarTaxCodeMatcher();
|
||||||
|
const discountAmount = distributedDiscounts[index];
|
||||||
|
const taxCode = matcher.match(line, matches);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: line.sourceLine.id,
|
||||||
|
product_tax_code: taxCode,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unit_price: Number(line.unitPrice.amount),
|
||||||
|
discount: discountAmount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return mappedLines;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
|
import { TaxJarTaxCodeMatchesService } from "../tax-code/taxjar-tax-code-matches.service";
|
||||||
|
import { TaxJarConfig } from "../taxjar-connection-schema";
|
||||||
|
import {
|
||||||
|
TaxJarCalculateTaxesPayload,
|
||||||
|
TaxJarCalculateTaxesTarget,
|
||||||
|
} from "./taxjar-calculate-taxes-adapter";
|
||||||
|
import { TaxJarCalculateTaxesPayloadTransformer } from "./taxjar-calculate-taxes-payload-transformer";
|
||||||
|
|
||||||
|
export class TaxJarCalculateTaxesPayloadService {
|
||||||
|
constructor(private readonly config: TaxJarConfig, private authData: AuthData) {}
|
||||||
|
|
||||||
|
private getMatches() {
|
||||||
|
const taxCodeMatchesService = new TaxJarTaxCodeMatchesService(this.authData);
|
||||||
|
|
||||||
|
return taxCodeMatchesService.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPayload(payload: TaxJarCalculateTaxesPayload): Promise<TaxJarCalculateTaxesTarget> {
|
||||||
|
const payloadTransformer = new TaxJarCalculateTaxesPayloadTransformer(this.config);
|
||||||
|
|
||||||
|
const matches = await this.getMatches();
|
||||||
|
|
||||||
|
return payloadTransformer.transform(payload.taxBase, matches);
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,9 +9,8 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
|
||||||
|
|
||||||
it("returns payload containing line_items without discounts", () => {
|
it("returns payload containing line_items without discounts", () => {
|
||||||
const taxBase = mockGenerator.generateTaxBase();
|
const taxBase = mockGenerator.generateTaxBase();
|
||||||
const transformedPayload = transformer.transform({
|
const matchesMock = mockGenerator.generateTaxCodeMatches();
|
||||||
taxBase,
|
const transformedPayload = transformer.transform(taxBase, matchesMock);
|
||||||
});
|
|
||||||
|
|
||||||
expect(transformedPayload).toEqual({
|
expect(transformedPayload).toEqual({
|
||||||
params: {
|
params: {
|
||||||
|
@ -32,7 +31,7 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
|
||||||
quantity: 3,
|
quantity: 3,
|
||||||
unit_price: 20,
|
unit_price: 20,
|
||||||
discount: 0,
|
discount: 0,
|
||||||
product_tax_code: "",
|
product_tax_code: "P0000000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
|
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
|
||||||
|
@ -61,9 +60,8 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const transformedPayload = transformer.transform({
|
const matchesMock = mockGenerator.generateTaxCodeMatches();
|
||||||
taxBase,
|
const transformedPayload = transformer.transform(taxBase, matchesMock);
|
||||||
});
|
|
||||||
|
|
||||||
const payloadLines = transformedPayload.params.line_items ?? [];
|
const payloadLines = transformedPayload.params.line_items ?? [];
|
||||||
const discountSum = payloadLines.reduce((sum, line) => sum + (line.discount ?? 0), 0);
|
const discountSum = payloadLines.reduce((sum, line) => sum + (line.discount ?? 0), 0);
|
||||||
|
@ -74,7 +72,7 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
|
||||||
quantity: 3,
|
quantity: 3,
|
||||||
unit_price: 20,
|
unit_price: 20,
|
||||||
discount: 3.33,
|
discount: 3.33,
|
||||||
product_tax_code: "",
|
product_tax_code: "P0000000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
|
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
|
||||||
|
@ -97,11 +95,10 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
|
||||||
it("throws error when no address", () => {
|
it("throws error when no address", () => {
|
||||||
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
|
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
|
||||||
const taxBase = mockGenerator.generateTaxBase({ address: null });
|
const taxBase = mockGenerator.generateTaxBase({ address: null });
|
||||||
|
const matchesMock = mockGenerator.generateTaxCodeMatches();
|
||||||
|
|
||||||
expect(() =>
|
expect(() => transformer.transform(taxBase, matchesMock)).toThrow(
|
||||||
transformer.transform({
|
"Customer address is required to calculate taxes in TaxJar."
|
||||||
taxBase,
|
);
|
||||||
})
|
|
||||||
).toThrow("Customer address is required to calculate taxes in TaxJar.");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,49 +1,21 @@
|
||||||
import { discountUtils } from "../../taxes/discount-utils";
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
import { taxJarAddressFactory } from "../address-factory";
|
import { taxJarAddressFactory } from "../address-factory";
|
||||||
|
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
|
||||||
import { TaxJarConfig } from "../taxjar-connection-schema";
|
import { TaxJarConfig } from "../taxjar-connection-schema";
|
||||||
import {
|
import { TaxJarCalculateTaxesPayloadLinesTransformer } from "./taxjar-calculate-taxes-payload-lines-transformer";
|
||||||
TaxJarCalculateTaxesPayload,
|
import { TaxJarCalculateTaxesTarget } from "./taxjar-calculate-taxes-adapter";
|
||||||
TaxJarCalculateTaxesTarget,
|
|
||||||
} from "./taxjar-calculate-taxes-adapter";
|
|
||||||
|
|
||||||
export class TaxJarCalculateTaxesPayloadTransformer {
|
export class TaxJarCalculateTaxesPayloadTransformer {
|
||||||
constructor(private readonly config: TaxJarConfig) {}
|
constructor(private readonly config: TaxJarConfig) {}
|
||||||
private mapLines(
|
|
||||||
taxBase: TaxJarCalculateTaxesPayload["taxBase"]
|
|
||||||
): TaxJarCalculateTaxesTarget["params"]["line_items"] {
|
|
||||||
const { lines, discounts } = taxBase;
|
|
||||||
const discountSum = discounts?.reduce(
|
|
||||||
(total, current) => total + Number(current.amount.amount),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const linePrices = lines.map((line) => Number(line.totalPrice.amount));
|
|
||||||
const distributedDiscounts = discountUtils.distributeDiscount(discountSum, linePrices);
|
|
||||||
|
|
||||||
const mappedLines: TaxJarCalculateTaxesTarget["params"]["line_items"] = lines.map(
|
transform(taxBase: TaxBaseFragment, matches: TaxJarTaxCodeMatches): TaxJarCalculateTaxesTarget {
|
||||||
(line, index) => {
|
|
||||||
const discountAmount = distributedDiscounts[index];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: line.sourceLine.id,
|
|
||||||
// todo: get from tax code matcher
|
|
||||||
product_tax_code: "",
|
|
||||||
quantity: line.quantity,
|
|
||||||
unit_price: Number(line.unitPrice.amount),
|
|
||||||
discount: discountAmount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return mappedLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
transform({ taxBase }: TaxJarCalculateTaxesPayload): TaxJarCalculateTaxesTarget {
|
|
||||||
const fromAddress = taxJarAddressFactory.fromChannelToTax(this.config.address);
|
const fromAddress = taxJarAddressFactory.fromChannelToTax(this.config.address);
|
||||||
|
|
||||||
if (!taxBase.address) {
|
if (!taxBase.address) {
|
||||||
throw new Error("Customer address is required to calculate taxes in TaxJar.");
|
throw new Error("Customer address is required to calculate taxes in TaxJar.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lineTransformer = new TaxJarCalculateTaxesPayloadLinesTransformer();
|
||||||
const toAddress = taxJarAddressFactory.fromSaleorToTax(taxBase.address);
|
const toAddress = taxJarAddressFactory.fromSaleorToTax(taxBase.address);
|
||||||
|
|
||||||
const taxParams: TaxJarCalculateTaxesTarget = {
|
const taxParams: TaxJarCalculateTaxesTarget = {
|
||||||
|
@ -51,7 +23,7 @@ export class TaxJarCalculateTaxesPayloadTransformer {
|
||||||
...fromAddress,
|
...fromAddress,
|
||||||
...toAddress,
|
...toAddress,
|
||||||
shipping: taxBase.shippingPrice.amount,
|
shipping: taxBase.shippingPrice.amount,
|
||||||
line_items: this.mapLines(taxBase),
|
line_items: lineTransformer.transform(taxBase, matches),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ export class TaxJarConnectionRepository {
|
||||||
TAX_PROVIDER_KEY
|
TAX_PROVIDER_KEY
|
||||||
);
|
);
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "TaxJarConnectionRepository",
|
name: "TaxJarConnectionRepository",
|
||||||
metadataKey: TAX_PROVIDER_KEY,
|
metadataKey: TAX_PROVIDER_KEY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -62,8 +62,8 @@ export class TaxJarConnectionRepository {
|
||||||
return taxJarConnections;
|
return taxJarConnections;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<TaxJarConnection> {
|
async getById(id: string): Promise<TaxJarConnection> {
|
||||||
const { data } = await this.crudSettingsManager.read(id);
|
const { data } = await this.crudSettingsManager.readById(id);
|
||||||
|
|
||||||
const connection = getSchema.parse(data);
|
const connection = getSchema.parse(data);
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ export class TaxJarConnectionRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async patch(id: string, input: Pick<TaxJarConnection, "config">): Promise<void> {
|
async patch(id: string, input: Pick<TaxJarConnection, "config">): Promise<void> {
|
||||||
return this.crudSettingsManager.update(id, input);
|
return this.crudSettingsManager.updateById(id, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
|
|
|
@ -11,7 +11,7 @@ export class TaxJarConnectionService {
|
||||||
private taxJarConnectionRepository: TaxJarConnectionRepository;
|
private taxJarConnectionRepository: TaxJarConnectionRepository;
|
||||||
constructor(client: Client, appId: string, saleorApiUrl: string) {
|
constructor(client: Client, appId: string, saleorApiUrl: string) {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "TaxJarConnectionService",
|
name: "TaxJarConnectionService",
|
||||||
});
|
});
|
||||||
|
|
||||||
const settingsManager = createSettingsManager(client, appId);
|
const settingsManager = createSettingsManager(client, appId);
|
||||||
|
@ -24,7 +24,7 @@ export class TaxJarConnectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
getById(id: string): Promise<TaxJarConnection> {
|
getById(id: string): Promise<TaxJarConnection> {
|
||||||
return this.taxJarConnectionRepository.get(id);
|
return this.taxJarConnectionRepository.getById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(config: TaxJarConfig): Promise<{ id: string }> {
|
async create(config: TaxJarConfig): Promise<{ id: string }> {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { CreateOrderRes } from "taxjar/dist/types/returnTypes";
|
|
||||||
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
|
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
|
||||||
import { Logger, createLogger } from "../../../lib/logger";
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
|
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
|
||||||
|
@ -19,27 +18,24 @@ export class TaxJarOrderCreatedAdapter
|
||||||
{
|
{
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
constructor(private readonly config: TaxJarConfig) {
|
constructor(private readonly config: TaxJarConfig) {
|
||||||
this.logger = createLogger({ location: "TaxJarOrderCreatedAdapter" });
|
this.logger = createLogger({ name: "TaxJarOrderCreatedAdapter" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(payload: TaxJarOrderCreatedPayload): Promise<TaxJarOrderCreatedResponse> {
|
async send(payload: TaxJarOrderCreatedPayload): Promise<TaxJarOrderCreatedResponse> {
|
||||||
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
|
this.logger.debug("Transforming the Saleor payload for creating order with TaxJar...");
|
||||||
const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer(this.config);
|
const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer(this.config);
|
||||||
const target = payloadTransformer.transform(payload);
|
const target = payloadTransformer.transform(payload);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug("Calling TaxJar fetchTaxForOrder with transformed payload...");
|
||||||
{ transformedPayload: target },
|
|
||||||
"Will call TaxJar fetchTaxForOrder with transformed payload:"
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new TaxJarClient(this.config);
|
const client = new TaxJarClient(this.config);
|
||||||
const response = await client.createOrder(target);
|
const response = await client.createOrder(target);
|
||||||
|
|
||||||
this.logger.debug({ response }, "TaxJar createOrder responded with:");
|
this.logger.debug("TaxJar createOrder successfully responded");
|
||||||
const responseTransformer = new TaxJarOrderCreatedResponseTransformer();
|
const responseTransformer = new TaxJarOrderCreatedResponseTransformer();
|
||||||
const transformedResponse = responseTransformer.transform(response);
|
const transformedResponse = responseTransformer.transform(response);
|
||||||
|
|
||||||
this.logger.debug({ transformedResponse }, "Transformed TaxJar createOrder response to:");
|
this.logger.debug("Transformed TaxJar createOrder response");
|
||||||
|
|
||||||
return transformedResponse;
|
return transformedResponse;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
|
import { CrudSettingsManager } from "../../crud-settings/crud-settings.service";
|
||||||
|
|
||||||
|
export const taxJarTaxCodeMatchSchema = z.object({
|
||||||
|
saleorTaxClassId: z.string(),
|
||||||
|
taxJarTaxCode: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TaxJarTaxCodeMatch = z.infer<typeof taxJarTaxCodeMatchSchema>;
|
||||||
|
|
||||||
|
const taxJarTaxCodeMatchesSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
data: taxJarTaxCodeMatchSchema,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TaxJarTaxCodeMatches = z.infer<typeof taxJarTaxCodeMatchesSchema>;
|
||||||
|
|
||||||
|
const metadataKey = "taxjar-tax-code-map";
|
||||||
|
|
||||||
|
export class TaxJarTaxCodeMatchRepository {
|
||||||
|
private crudSettingsManager: CrudSettingsManager;
|
||||||
|
private logger: Logger;
|
||||||
|
constructor(settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
|
||||||
|
this.crudSettingsManager = new CrudSettingsManager(settingsManager, saleorApiUrl, metadataKey);
|
||||||
|
this.logger = createLogger({
|
||||||
|
name: "TaxJarTaxCodeMatchRepository",
|
||||||
|
metadataKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<TaxJarTaxCodeMatches> {
|
||||||
|
const { data } = await this.crudSettingsManager.readAll();
|
||||||
|
|
||||||
|
return taxJarTaxCodeMatchesSchema.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: TaxJarTaxCodeMatch): Promise<void> {
|
||||||
|
await this.crudSettingsManager.create({ data: input });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateById(id: string, input: TaxJarTaxCodeMatch): Promise<void> {
|
||||||
|
await this.crudSettingsManager.updateById(id, { data: input });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { TaxBaseLineFragment } from "../../../../generated/graphql";
|
||||||
|
import { TaxJarTaxCodeMatches } from "./taxjar-tax-code-match-repository";
|
||||||
|
import { TaxJarTaxCodeMatcher } from "./taxjar-tax-code-matcher";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const matcher = new TaxJarTaxCodeMatcher();
|
||||||
|
|
||||||
|
describe("TaxJarTaxCodeMatcher", () => {
|
||||||
|
it("returns empty string when tax class is not found", () => {
|
||||||
|
const line: TaxBaseLineFragment = {
|
||||||
|
quantity: 1,
|
||||||
|
totalPrice: {
|
||||||
|
amount: 1,
|
||||||
|
},
|
||||||
|
unitPrice: {
|
||||||
|
amount: 1,
|
||||||
|
},
|
||||||
|
sourceLine: {
|
||||||
|
id: "1",
|
||||||
|
__typename: "OrderLine",
|
||||||
|
orderProductVariant: {
|
||||||
|
id: "1",
|
||||||
|
product: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const matches: TaxJarTaxCodeMatches = [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
saleorTaxClassId: "",
|
||||||
|
taxJarTaxCode: "1",
|
||||||
|
},
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(matcher.match(line, matches)).toEqual("");
|
||||||
|
});
|
||||||
|
it("returns a match when tax class is found", () => {
|
||||||
|
const line: TaxBaseLineFragment = {
|
||||||
|
quantity: 1,
|
||||||
|
totalPrice: {
|
||||||
|
amount: 1,
|
||||||
|
},
|
||||||
|
unitPrice: {
|
||||||
|
amount: 1,
|
||||||
|
},
|
||||||
|
sourceLine: {
|
||||||
|
id: "1",
|
||||||
|
__typename: "OrderLine",
|
||||||
|
orderProductVariant: {
|
||||||
|
id: "1",
|
||||||
|
product: {
|
||||||
|
taxClass: {
|
||||||
|
name: "Clothing",
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const matches: TaxJarTaxCodeMatches = [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
saleorTaxClassId: "1",
|
||||||
|
taxJarTaxCode: "123412",
|
||||||
|
},
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const taxCode = matcher.match(line, matches);
|
||||||
|
|
||||||
|
expect(taxCode).toEqual("123412");
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { TaxBaseLineFragment } from "../../../../generated/graphql";
|
||||||
|
import { TaxJarTaxCodeMatches } from "./taxjar-tax-code-match-repository";
|
||||||
|
|
||||||
|
export class TaxJarTaxCodeMatcher {
|
||||||
|
private mapTaxClassWithTaxMatch(taxClassId: string, matches: TaxJarTaxCodeMatches) {
|
||||||
|
return matches.find((m) => m.data.saleorTaxClassId === taxClassId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTaxClassId(line: TaxBaseLineFragment): string | undefined {
|
||||||
|
if (line.sourceLine.__typename === "CheckoutLine") {
|
||||||
|
return line.sourceLine.checkoutProductVariant.product.taxClass?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.sourceLine.__typename === "OrderLine") {
|
||||||
|
return line.sourceLine.orderProductVariant?.product.taxClass?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match(line: TaxBaseLineFragment, matches: TaxJarTaxCodeMatches) {
|
||||||
|
const taxClassId = this.getTaxClassId(line);
|
||||||
|
|
||||||
|
// We can fall back to empty string if we don't have a tax code match
|
||||||
|
return taxClassId
|
||||||
|
? this.mapTaxClassWithTaxMatch(taxClassId, matches)?.data.taxJarTaxCode ?? ""
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { createLogger } from "../../../lib/logger";
|
||||||
|
import { TaxJarTaxCodeMatchesService } from "./taxjar-tax-code-matches.service";
|
||||||
|
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||||
|
import { router } from "../../trpc/trpc-server";
|
||||||
|
import { taxJarTaxCodeMatchSchema } from "./taxjar-tax-code-match-repository";
|
||||||
|
|
||||||
|
const protectedWithTaxJarTaxCodeMatchesService = protectedClientProcedure.use(({ next, ctx }) =>
|
||||||
|
next({
|
||||||
|
ctx: {
|
||||||
|
taxCodeMatchesService: new TaxJarTaxCodeMatchesService({
|
||||||
|
saleorApiUrl: ctx.saleorApiUrl,
|
||||||
|
token: ctx.appToken!,
|
||||||
|
appId: ctx.appId!,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const taxJarTaxCodeMatchesRouter = router({
|
||||||
|
getAll: protectedWithTaxJarTaxCodeMatchesService.query(async ({ ctx }) => {
|
||||||
|
const logger = createLogger({
|
||||||
|
name: "taxjarTaxCodeMatchesRouter.fetch",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Returning tax code matches");
|
||||||
|
|
||||||
|
return ctx.taxCodeMatchesService.getAll();
|
||||||
|
}),
|
||||||
|
upsert: protectedWithTaxJarTaxCodeMatchesService
|
||||||
|
.input(taxJarTaxCodeMatchSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const logger = createLogger({
|
||||||
|
name: "taxjarTaxCodeMatchesRouter.upsert",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Upserting tax code match");
|
||||||
|
|
||||||
|
return ctx.taxCodeMatchesService.upsert(input);
|
||||||
|
}),
|
||||||
|
});
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
|
import { createGraphQLClient } from "@saleor/apps-shared";
|
||||||
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
|
import { createSettingsManager } from "../../app/metadata-manager";
|
||||||
|
import {
|
||||||
|
TaxJarTaxCodeMatch,
|
||||||
|
TaxJarTaxCodeMatchRepository,
|
||||||
|
TaxJarTaxCodeMatches,
|
||||||
|
} from "./taxjar-tax-code-match-repository";
|
||||||
|
|
||||||
|
export class TaxJarTaxCodeMatchesService {
|
||||||
|
private logger: Logger;
|
||||||
|
private taxCodeMatchRepository: TaxJarTaxCodeMatchRepository;
|
||||||
|
|
||||||
|
constructor(authData: AuthData) {
|
||||||
|
this.logger = createLogger({ name: "TaxJarTaxCodeService" });
|
||||||
|
const client = createGraphQLClient({
|
||||||
|
saleorApiUrl: authData.saleorApiUrl,
|
||||||
|
token: authData.token,
|
||||||
|
});
|
||||||
|
const settingsManager = createSettingsManager(client, authData.appId);
|
||||||
|
|
||||||
|
this.taxCodeMatchRepository = new TaxJarTaxCodeMatchRepository(
|
||||||
|
settingsManager,
|
||||||
|
authData.saleorApiUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<TaxJarTaxCodeMatches> {
|
||||||
|
return this.taxCodeMatchRepository.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(input: TaxJarTaxCodeMatch): Promise<void | { data: { id: string } }> {
|
||||||
|
const taxCodeMatches = await this.getAll();
|
||||||
|
const taxCodeMatch = taxCodeMatches.find(
|
||||||
|
(match) => match.data.saleorTaxClassId === input.saleorTaxClassId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taxCodeMatch) {
|
||||||
|
return this.taxCodeMatchRepository.updateById(taxCodeMatch.id, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.taxCodeMatchRepository.create(input);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { router } from "../../trpc/trpc-server";
|
||||||
|
|
||||||
|
import { createLogger } from "../../../lib/logger";
|
||||||
|
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { TaxJarConnectionService } from "../configuration/taxjar-connection.service";
|
||||||
|
import { TaxJarTaxCodesService } from "./taxjar-tax-codes.service";
|
||||||
|
|
||||||
|
const getAllForIdSchema = z.object({ connectionId: z.string() });
|
||||||
|
|
||||||
|
export const taxJarTaxCodesRouter = router({
|
||||||
|
getAllForId: protectedClientProcedure.input(getAllForIdSchema).query(async ({ ctx, input }) => {
|
||||||
|
const logger = createLogger({
|
||||||
|
name: "taxjarTaxCodesRouter.getAllForId",
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionService = new TaxJarConnectionService(
|
||||||
|
ctx.apiClient,
|
||||||
|
ctx.appId!,
|
||||||
|
ctx.saleorApiUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
const connection = await connectionService.getById(input.connectionId);
|
||||||
|
const taxCodesService = new TaxJarTaxCodesService(connection.config);
|
||||||
|
|
||||||
|
logger.debug("Returning tax codes");
|
||||||
|
|
||||||
|
return taxCodesService.getAll();
|
||||||
|
}),
|
||||||
|
});
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { CategoriesRes } from "taxjar/dist/types/returnTypes";
|
||||||
|
import { TaxJarClient } from "../taxjar-client";
|
||||||
|
import { TaxJarConfig } from "../taxjar-connection-schema";
|
||||||
|
import type { TaxCode } from "../../taxes/tax-code";
|
||||||
|
|
||||||
|
export class TaxJarTaxCodesService {
|
||||||
|
private client: TaxJarClient;
|
||||||
|
|
||||||
|
constructor(config: TaxJarConfig) {
|
||||||
|
this.client = new TaxJarClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private adapt(response: CategoriesRes): TaxCode[] {
|
||||||
|
return response.categories.map((category) => ({
|
||||||
|
description: category.name,
|
||||||
|
code: category.product_tax_code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll() {
|
||||||
|
const response = await this.client.getTaxCodes();
|
||||||
|
|
||||||
|
return this.adapt(response);
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,7 +29,7 @@ export class TaxJarClient {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(providerConfig: TaxJarConfig) {
|
constructor(providerConfig: TaxJarConfig) {
|
||||||
this.logger = createLogger({ location: "TaxJarClient" });
|
this.logger = createLogger({ name: "TaxJarClient" });
|
||||||
const settings = createTaxJarSettings(providerConfig);
|
const settings = createTaxJarSettings(providerConfig);
|
||||||
const taxJarClient = new TaxJar(settings);
|
const taxJarClient = new TaxJar(settings);
|
||||||
|
|
||||||
|
@ -54,4 +54,9 @@ export class TaxJarClient {
|
||||||
async validateAddress({ params }: ValidateAddressArgs) {
|
async validateAddress({ params }: ValidateAddressArgs) {
|
||||||
// return this.client.validateAddress(params);
|
// return this.client.validateAddress(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTaxCodes() {
|
||||||
|
// ! This function doesn't accept any params. This may be troublesome if we want to do pagination/filtering on the frontend.
|
||||||
|
return this.client.categories();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,6 @@ const postInputSchema = z.object({
|
||||||
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
||||||
next({
|
next({
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
|
||||||
connectionService: new PublicTaxJarConnectionService(
|
connectionService: new PublicTaxJarConnectionService(
|
||||||
ctx.apiClient,
|
ctx.apiClient,
|
||||||
ctx.appId!,
|
ctx.appId!,
|
||||||
|
@ -38,7 +37,7 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
|
||||||
export const taxjarConnectionRouter = router({
|
export const taxjarConnectionRouter = router({
|
||||||
getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => {
|
getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
location: "taxjarConnectionRouter.get",
|
name: "taxjarConnectionRouter.get",
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug("taxjarConnectionRouter.get called");
|
logger.debug("taxjarConnectionRouter.get called");
|
||||||
|
@ -53,7 +52,7 @@ export const taxjarConnectionRouter = router({
|
||||||
.input(postInputSchema)
|
.input(postInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
location: "taxjarConnectionRouter.post",
|
name: "taxjarConnectionRouter.post",
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug("Attempting to create configuration");
|
logger.debug("Attempting to create configuration");
|
||||||
|
@ -68,7 +67,7 @@ export const taxjarConnectionRouter = router({
|
||||||
.input(deleteInputSchema)
|
.input(deleteInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
location: "taxjarConnectionRouter.delete",
|
name: "taxjarConnectionRouter.delete",
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug("Route delete called");
|
logger.debug("Route delete called");
|
||||||
|
@ -82,10 +81,10 @@ export const taxjarConnectionRouter = router({
|
||||||
.input(patchInputSchema)
|
.input(patchInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
location: "taxjarConnectionRouter.patch",
|
name: "taxjarConnectionRouter.patch",
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug({ input }, "Route patch called");
|
logger.debug("Route update called");
|
||||||
|
|
||||||
const result = await ctx.connectionService.update(input.id, input.value);
|
const result = await ctx.connectionService.update(input.id, input.value);
|
||||||
|
|
||||||
|
|
|
@ -5,24 +5,25 @@ import { TaxJarClient } from "./taxjar-client";
|
||||||
import { TaxJarConfig } from "./taxjar-connection-schema";
|
import { TaxJarConfig } from "./taxjar-connection-schema";
|
||||||
import { TaxJarOrderCreatedAdapter } from "./order-created/taxjar-order-created-adapter";
|
import { TaxJarOrderCreatedAdapter } from "./order-created/taxjar-order-created-adapter";
|
||||||
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
|
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
|
|
||||||
export class TaxJarWebhookService implements ProviderWebhookService {
|
export class TaxJarWebhookService implements ProviderWebhookService {
|
||||||
client: TaxJarClient;
|
client: TaxJarClient;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private config: TaxJarConfig;
|
private config: TaxJarConfig;
|
||||||
|
|
||||||
constructor(config: TaxJarConfig) {
|
constructor(config: TaxJarConfig, private authData: AuthData) {
|
||||||
const taxJarClient = new TaxJarClient(config);
|
const taxJarClient = new TaxJarClient(config);
|
||||||
|
|
||||||
this.client = taxJarClient;
|
this.client = taxJarClient;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
location: "TaxJarWebhookService",
|
name: "TaxJarWebhookService",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async calculateTaxes(taxBase: TaxBaseFragment) {
|
async calculateTaxes(taxBase: TaxBaseFragment) {
|
||||||
const adapter = new TaxJarCalculateTaxesAdapter(this.config);
|
const adapter = new TaxJarCalculateTaxesAdapter(this.config, this.authData);
|
||||||
|
|
||||||
const response = await adapter.send({ taxBase });
|
const response = await adapter.send({ taxBase });
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import React from "react";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { Table } from "../../ui/table";
|
||||||
|
import { Select } from "../../ui/_select";
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { AppCard } from "../../ui/app-card";
|
||||||
|
|
||||||
|
const SelectTaxCode = ({ taxClassId }: { taxClassId: string }) => {
|
||||||
|
const [value, setValue] = React.useState("");
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data: taxJarMatches, isLoading: isMatchesLoading } =
|
||||||
|
trpcClient.taxJarMatches.getAll.useQuery();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (taxJarMatches) {
|
||||||
|
const match = taxJarMatches?.find((item) => item.data.saleorTaxClassId === taxClassId);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
setValue(match.data.taxJarTaxCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [taxJarMatches, taxClassId]);
|
||||||
|
|
||||||
|
const { mutate: updateMutation } = trpcClient.taxJarMatches.upsert.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Updated TaxJar tax code matches");
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
notifyError("Error", error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: providers } = trpcClient.providersConfiguration.getAll.useQuery();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tax Code Matcher is only available when there is at least one connection.
|
||||||
|
* The reason for it is that we need any working credentials to fetch the provider tax codes.
|
||||||
|
*/
|
||||||
|
const firstConnectionId = providers?.[0].id;
|
||||||
|
|
||||||
|
const { data: taxCodes = [], isLoading: isCodesLoading } =
|
||||||
|
trpcClient.taxJarTaxCodes.getAllForId.useQuery(
|
||||||
|
{
|
||||||
|
connectionId: firstConnectionId!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: firstConnectionId !== undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeValue = (taxJarTaxCode: string) => {
|
||||||
|
setValue(taxJarTaxCode);
|
||||||
|
updateMutation({
|
||||||
|
saleorTaxClassId: taxClassId,
|
||||||
|
taxJarTaxCode,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = isMatchesLoading || isCodesLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value ?? ""}
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={(value) => changeValue(String(value))}
|
||||||
|
options={[
|
||||||
|
...(isLoading
|
||||||
|
? [{ value: "", label: "Loading..." }]
|
||||||
|
: [{ value: "", label: "Not assigned" }]),
|
||||||
|
...taxCodes.map((item) => ({
|
||||||
|
value: item.code,
|
||||||
|
label: `${item.code} | ${item.description}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TaxJarTaxCodeMatcherTable = () => {
|
||||||
|
const { data: taxClasses = [], isLoading } = trpcClient.taxClasses.getAll.useQuery();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="textNeutralSubdued">Loading...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppCard>
|
||||||
|
<Table.Container>
|
||||||
|
<Table.THead>
|
||||||
|
<Table.TR>
|
||||||
|
<Table.TH>Saleor tax class</Table.TH>
|
||||||
|
<Table.TH>TaxJar tax code</Table.TH>
|
||||||
|
</Table.TR>
|
||||||
|
</Table.THead>
|
||||||
|
<Table.TBody>
|
||||||
|
{taxClasses.map((taxClass) => {
|
||||||
|
return (
|
||||||
|
<Table.TR key={taxClass.id}>
|
||||||
|
<Table.TD>{taxClass.name}</Table.TD>
|
||||||
|
<Table.TD>
|
||||||
|
<SelectTaxCode taxClassId={taxClass.id} />
|
||||||
|
</Table.TD>
|
||||||
|
</Table.TR>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.TBody>
|
||||||
|
</Table.Container>
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
};
|
|
@ -3,7 +3,7 @@ import { middleware, procedure } from "./trpc-server";
|
||||||
import { saleorApp } from "../../../saleor-app";
|
import { saleorApp } from "../../../saleor-app";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
|
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
|
||||||
import { logger } from "../../lib/logger";
|
import { createLogger, logger } from "../../lib/logger";
|
||||||
import { createGraphQLClient } from "@saleor/apps-shared";
|
import { createGraphQLClient } from "@saleor/apps-shared";
|
||||||
|
|
||||||
const attachAppToken = middleware(async ({ ctx, next }) => {
|
const attachAppToken = middleware(async ({ ctx, next }) => {
|
||||||
|
@ -85,7 +85,6 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => {
|
||||||
|
|
||||||
return next({
|
return next({
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
|
||||||
saleorApiUrl: ctx.saleorApiUrl,
|
saleorApiUrl: ctx.saleorApiUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -99,7 +98,21 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => {
|
||||||
*
|
*
|
||||||
* TODO Rethink middleware composition to enable safe server-side router calls
|
* TODO Rethink middleware composition to enable safe server-side router calls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const logErrors = middleware(async ({ next }) => {
|
||||||
|
const logger = createLogger({ name: "trpcServer" });
|
||||||
|
|
||||||
|
const result = await next();
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
logger.error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
export const protectedClientProcedure = procedure
|
export const protectedClientProcedure = procedure
|
||||||
|
.use(logErrors)
|
||||||
.use(attachAppToken)
|
.use(attachAppToken)
|
||||||
.use(validateClientToken)
|
.use(validateClientToken)
|
||||||
.use(async ({ ctx, next }) => {
|
.use(async ({ ctx, next }) => {
|
||||||
|
|
|
@ -3,12 +3,26 @@ import { providerConnectionsRouter } from "../provider-connections/provider-conn
|
||||||
import { channelsConfigurationRouter } from "../channel-configuration/channel-configuration.router";
|
import { channelsConfigurationRouter } from "../channel-configuration/channel-configuration.router";
|
||||||
import { taxjarConnectionRouter } from "../taxjar/taxjar-connection.router";
|
import { taxjarConnectionRouter } from "../taxjar/taxjar-connection.router";
|
||||||
import { avataxConnectionRouter } from "../avatax/avatax-connection.router";
|
import { avataxConnectionRouter } from "../avatax/avatax-connection.router";
|
||||||
|
import { taxClassesRouter } from "../tax-classes/tax-classes.router";
|
||||||
|
import { avataxTaxCodesRouter } from "../avatax/tax-code/avatax-tax-codes.router";
|
||||||
|
import { taxJarTaxCodesRouter } from "../taxjar/tax-code/taxjar-tax-codes.router";
|
||||||
|
import { taxJarTaxCodeMatchesRouter } from "../taxjar/tax-code/taxjar-tax-code-matches.router";
|
||||||
|
import { avataxTaxCodeMatchesRouter } from "../avatax/tax-code/avatax-tax-code-matches.router";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* // todo: split to namespaces, e.g.:
|
||||||
|
* avatax: { connection, taxCodes, taxCodeMatches }
|
||||||
|
*/
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
providersConfiguration: providerConnectionsRouter,
|
providersConfiguration: providerConnectionsRouter,
|
||||||
channelsConfiguration: channelsConfigurationRouter,
|
channelsConfiguration: channelsConfigurationRouter,
|
||||||
taxJarConnection: taxjarConnectionRouter,
|
taxJarConnection: taxjarConnectionRouter,
|
||||||
avataxConnection: avataxConnectionRouter,
|
avataxConnection: avataxConnectionRouter,
|
||||||
|
taxClasses: taxClassesRouter,
|
||||||
|
avataxTaxCodes: avataxTaxCodesRouter,
|
||||||
|
taxJarTaxCodes: taxJarTaxCodesRouter,
|
||||||
|
taxJarMatches: taxJarTaxCodeMatchesRouter,
|
||||||
|
avataxMatches: avataxTaxCodeMatchesRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
17
apps/taxes/src/modules/ui/_select.tsx
Normal file
17
apps/taxes/src/modules/ui/_select.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { Select as _Select } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
type SelectProps = React.ComponentProps<typeof _Select>;
|
||||||
|
/**
|
||||||
|
* The macaw-ui Select doesn't truncate the label text, so we need to override it.
|
||||||
|
* @see: https://github.com/saleor/macaw-ui/issues/477
|
||||||
|
*/
|
||||||
|
export const Select = (props: SelectProps) => {
|
||||||
|
return (
|
||||||
|
<_Select
|
||||||
|
{...props}
|
||||||
|
__whiteSpace={"preline"}
|
||||||
|
__overflow={"hidden"}
|
||||||
|
__textOverflow={"ellipsis"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -33,7 +33,17 @@ const breadcrumbsForRoute: Record<string, Breadcrumb[]> = {
|
||||||
href: "/providers/taxjar",
|
href: "/providers/taxjar",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"/providers/taxjar/matcher": [
|
||||||
|
...newProviderBreadcrumbs,
|
||||||
|
{
|
||||||
|
label: "TaxJar",
|
||||||
|
href: "/providers/taxjar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tax code matcher",
|
||||||
|
href: "/providers/taxjar/matcher",
|
||||||
|
},
|
||||||
|
],
|
||||||
"/providers/taxjar/[id]": [
|
"/providers/taxjar/[id]": [
|
||||||
...newProviderBreadcrumbs,
|
...newProviderBreadcrumbs,
|
||||||
{
|
{
|
||||||
|
@ -48,6 +58,17 @@ const breadcrumbsForRoute: Record<string, Breadcrumb[]> = {
|
||||||
href: "/providers/avatax",
|
href: "/providers/avatax",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"/providers/avatax/matcher": [
|
||||||
|
...newProviderBreadcrumbs,
|
||||||
|
{
|
||||||
|
label: "Avatax",
|
||||||
|
href: "/providers/avatax",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tax code matcher",
|
||||||
|
href: "/providers/avatax/matcher",
|
||||||
|
},
|
||||||
|
],
|
||||||
"/providers/avatax/[id]": [
|
"/providers/avatax/[id]": [
|
||||||
...newProviderBreadcrumbs,
|
...newProviderBreadcrumbs,
|
||||||
{
|
{
|
||||||
|
@ -61,7 +82,7 @@ const useBreadcrumbs = () => {
|
||||||
const { pathname } = useRouter();
|
const { pathname } = useRouter();
|
||||||
const breadcrumbs = breadcrumbsForRoute[pathname];
|
const breadcrumbs = breadcrumbsForRoute[pathname];
|
||||||
|
|
||||||
if (pathname !== "/" && !breadcrumbs) {
|
if (pathname !== "/" && pathname !== "_error" && !breadcrumbs) {
|
||||||
throw new Error(`No breadcrumbs for route ${pathname}`);
|
throw new Error(`No breadcrumbs for route ${pathname}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,9 @@ import React, { PropsWithChildren } from "react";
|
||||||
|
|
||||||
export const AppColumns = ({ top, children }: PropsWithChildren<{ top: React.ReactNode }>) => {
|
export const AppColumns = ({ top, children }: PropsWithChildren<{ top: React.ReactNode }>) => {
|
||||||
return (
|
return (
|
||||||
<Box display={"grid"} __gap={"60px"}>
|
<Box display={"grid"} __gap={"64px"} __marginBottom={"64px"}>
|
||||||
<Box>{top}</Box>
|
<Box>{top}</Box>
|
||||||
<Box display={"grid"} gap={10} __gridTemplateColumns={"1fr 1fr"}>
|
<Box display={"grid"} __gridTemplateColumns={"1fr 1fr"} __rowGap={"96px"} columnGap={10}>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
35
apps/taxes/src/modules/ui/app-dashboard-link.tsx
Normal file
35
apps/taxes/src/modules/ui/app-dashboard-link.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* * ui/TextLink currently supports either in-app links or external links. This is in-dashboard link.
|
||||||
|
* // todo: move the logic to TextLink
|
||||||
|
*/
|
||||||
|
export const AppDashboardLink = ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: PropsWithChildren<{ href: string }>) => {
|
||||||
|
const appBridge = useAppBridge();
|
||||||
|
|
||||||
|
const redirectToDashboardPath = () => {
|
||||||
|
appBridge.appBridge?.dispatch(actions.Redirect({ to: href }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
transition={"ease"}
|
||||||
|
variant={"bodyStrong"}
|
||||||
|
cursor={"pointer"}
|
||||||
|
color={{
|
||||||
|
default: "text3Decorative",
|
||||||
|
hover: "text1Decorative",
|
||||||
|
}}
|
||||||
|
onClick={redirectToDashboardPath}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
90
apps/taxes/src/modules/ui/matcher-section.tsx
Normal file
90
apps/taxes/src/modules/ui/matcher-section.tsx
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
import { AppCard } from "./app-card";
|
||||||
|
import { Section } from "./app-section";
|
||||||
|
import { ProviderLabel } from "./provider-label";
|
||||||
|
import { Table } from "./table";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
const MatcherTable = () => {
|
||||||
|
const { data: connections = [] } = trpcClient.providersConfiguration.getAll.useQuery();
|
||||||
|
|
||||||
|
const isAvatax = connections.some(({ provider }) => provider === "avatax");
|
||||||
|
const isTaxJar = connections.some(({ provider }) => provider === "taxjar");
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppCard __minHeight={"320px"} height="100%">
|
||||||
|
<Table.Container>
|
||||||
|
<Table.THead>
|
||||||
|
<Table.TR>
|
||||||
|
<Table.TH>Provider</Table.TH>
|
||||||
|
</Table.TR>
|
||||||
|
</Table.THead>
|
||||||
|
<Table.TBody>
|
||||||
|
{isAvatax && (
|
||||||
|
<Table.TR>
|
||||||
|
<Table.TD>
|
||||||
|
<ProviderLabel name="avatax" />
|
||||||
|
</Table.TD>
|
||||||
|
<Table.TD>
|
||||||
|
<Box display="flex" justifyContent={"flex-end"}>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push("/providers/avatax/matcher")}
|
||||||
|
variant="tertiary"
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</Button>{" "}
|
||||||
|
</Box>{" "}
|
||||||
|
</Table.TD>
|
||||||
|
</Table.TR>
|
||||||
|
)}
|
||||||
|
{isTaxJar && (
|
||||||
|
<Table.TR>
|
||||||
|
<Table.TD>
|
||||||
|
<ProviderLabel name="taxjar" />
|
||||||
|
</Table.TD>
|
||||||
|
<Table.TD>
|
||||||
|
<Box display="flex" justifyContent={"flex-end"}>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push("/providers/taxjar/matcher")}
|
||||||
|
variant="tertiary"
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</Button>{" "}
|
||||||
|
</Box>
|
||||||
|
</Table.TD>
|
||||||
|
</Table.TR>
|
||||||
|
)}
|
||||||
|
</Table.TBody>
|
||||||
|
</Table.Container>
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Intro = () => {
|
||||||
|
return (
|
||||||
|
<Section.Description
|
||||||
|
title="Tax code matcher"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Tax Code Matcher allows you to map Saleor tax classes to provider tax codes to extend
|
||||||
|
products base tax rate.
|
||||||
|
<Text as="span" display="block" marginY={4}>
|
||||||
|
You need to have at least one provider configured to use this feature.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MatcherSection = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Intro />
|
||||||
|
<MatcherTable />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -37,7 +37,7 @@ export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<Calculate
|
||||||
});
|
});
|
||||||
|
|
||||||
export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
|
export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
|
||||||
const logger = createLogger({ location: "checkoutCalculateTaxesSyncWebhook" });
|
const logger = createLogger({ name: "checkoutCalculateTaxesSyncWebhook" });
|
||||||
const { payload } = ctx;
|
const { payload } = ctx;
|
||||||
const webhookResponse = new WebhookResponse(res);
|
const webhookResponse = new WebhookResponse(res);
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res,
|
||||||
try {
|
try {
|
||||||
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||||
const channelSlug = payload.taxBase.channel.slug;
|
const channelSlug = payload.taxBase.channel.slug;
|
||||||
const taxProvider = getActiveConnection(channelSlug, appMetadata);
|
const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData);
|
||||||
|
|
||||||
logger.info({ taxProvider }, "Will calculate taxes using the tax provider:");
|
logger.info({ taxProvider }, "Will calculate taxes using the tax provider:");
|
||||||
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
|
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
|
||||||
|
|
|
@ -37,7 +37,7 @@ export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<CalculateTax
|
||||||
});
|
});
|
||||||
|
|
||||||
export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
|
export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
|
||||||
const logger = createLogger({ location: "orderCalculateTaxesSyncWebhook" });
|
const logger = createLogger({ name: "orderCalculateTaxesSyncWebhook" });
|
||||||
const { payload } = ctx;
|
const { payload } = ctx;
|
||||||
const webhookResponse = new WebhookResponse(res);
|
const webhookResponse = new WebhookResponse(res);
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx
|
||||||
try {
|
try {
|
||||||
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||||
const channelSlug = payload.taxBase.channel.slug;
|
const channelSlug = payload.taxBase.channel.slug;
|
||||||
const taxProvider = getActiveConnection(channelSlug, appMetadata);
|
const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData);
|
||||||
|
|
||||||
logger.info({ taxProvider }, "Will calculate taxes using the tax provider:");
|
logger.info({ taxProvider }, "Will calculate taxes using the tax provider:");
|
||||||
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
|
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
|
||||||
|
|
|
@ -74,7 +74,7 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => {
|
||||||
try {
|
try {
|
||||||
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||||
const channelSlug = payload.order?.channel.slug;
|
const channelSlug = payload.order?.channel.slug;
|
||||||
const taxProvider = getActiveConnection(channelSlug, appMetadata);
|
const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData);
|
||||||
|
|
||||||
logger.info({ taxProvider }, "Fetched taxProvider");
|
logger.info({ taxProvider }, "Fetched taxProvider");
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ export default orderFulfilledAsyncWebhook.createHandler(async (req, res, ctx) =>
|
||||||
try {
|
try {
|
||||||
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||||
const channelSlug = payload.order?.channel.slug;
|
const channelSlug = payload.order?.channel.slug;
|
||||||
const taxProvider = getActiveConnection(channelSlug, appMetadata);
|
const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData);
|
||||||
|
|
||||||
logger.info({ taxProvider }, "Fetched taxProvider");
|
logger.info({ taxProvider }, "Fetched taxProvider");
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { ChannelSection } from "../modules/channel-configuration/ui/channel-section";
|
import { ChannelSection } from "../modules/channel-configuration/ui/channel-section";
|
||||||
import { ProvidersSection } from "../modules/provider-connections/ui/providers-section";
|
import { ProvidersSection } from "../modules/provider-connections/ui/providers-section";
|
||||||
|
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||||
import { AppColumns } from "../modules/ui/app-columns";
|
import { AppColumns } from "../modules/ui/app-columns";
|
||||||
import { Section } from "../modules/ui/app-section";
|
import { Section } from "../modules/ui/app-section";
|
||||||
|
import { MatcherSection } from "../modules/ui/matcher-section";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -12,10 +14,14 @@ const Header = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ConfigurationPage = () => {
|
const ConfigurationPage = () => {
|
||||||
|
const { data: providers = [] } = trpcClient.providersConfiguration.getAll.useQuery();
|
||||||
|
const isProviders = providers.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppColumns top={<Header />}>
|
<AppColumns top={<Header />}>
|
||||||
<ProvidersSection />
|
<ProvidersSection />
|
||||||
<ChannelSection />
|
<ChannelSection />
|
||||||
|
{isProviders && <MatcherSection />}
|
||||||
</AppColumns>
|
</AppColumns>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
58
apps/taxes/src/pages/providers/avatax/matcher.tsx
Normal file
58
apps/taxes/src/pages/providers/avatax/matcher.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { AvataxTaxCodeMatcherTable } from "../../../modules/avatax/ui/avatax-tax-code-matcher-table";
|
||||||
|
import { AppColumns } from "../../../modules/ui/app-columns";
|
||||||
|
import { AppDashboardLink } from "../../../modules/ui/app-dashboard-link";
|
||||||
|
import { Section } from "../../../modules/ui/app-section";
|
||||||
|
import { TextLink } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
return <Section.Header>Match Saleor tax classes to Avatax tax codes</Section.Header>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Description = () => {
|
||||||
|
return (
|
||||||
|
<Section.Description
|
||||||
|
title="Avatax tax code matcher"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text display="block" as="span" marginBottom={4}>
|
||||||
|
To extend the base tax rate of your products, you can map Saleor tax classes to Avatax
|
||||||
|
tax codes.
|
||||||
|
</Text>
|
||||||
|
<Text display="block" as="span" marginBottom={4}>
|
||||||
|
This way, the product's Saleor tax class will be used to determine the Avatax tax
|
||||||
|
code needed to calculate the tax rate.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginBottom={4}>
|
||||||
|
If you haven't created any tax classes yet, you can do it in the{" "}
|
||||||
|
<AppDashboardLink href="/taxes/tax-classes">
|
||||||
|
Configuration → Taxes → Tax classes
|
||||||
|
</AppDashboardLink>{" "}
|
||||||
|
view.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginBottom={4}>
|
||||||
|
To learn more about Avatax tax codes, please visit{" "}
|
||||||
|
<TextLink href="https://taxcode.avatax.avalara.com/search?q=OF400000" newTab>
|
||||||
|
Avatax documentation
|
||||||
|
</TextLink>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AvataxMatcher = () => {
|
||||||
|
return (
|
||||||
|
<AppColumns top={<Header />}>
|
||||||
|
<Description />
|
||||||
|
<AvataxTaxCodeMatcherTable />
|
||||||
|
</AppColumns>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* todo: add redirect if no connection
|
||||||
|
*/
|
||||||
|
export default AvataxMatcher;
|
61
apps/taxes/src/pages/providers/taxjar/matcher.tsx
Normal file
61
apps/taxes/src/pages/providers/taxjar/matcher.tsx
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import { Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { AppColumns } from "../../../modules/ui/app-columns";
|
||||||
|
import { AppDashboardLink } from "../../../modules/ui/app-dashboard-link";
|
||||||
|
import { Section } from "../../../modules/ui/app-section";
|
||||||
|
import { TextLink } from "@saleor/apps-ui";
|
||||||
|
import { TaxJarTaxCodeMatcherTable } from "../../../modules/taxjar/ui/taxjar-tax-code-matcher-table";
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
return <Section.Header>Match Saleor tax classes to TaxJar tax categories</Section.Header>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Description = () => {
|
||||||
|
return (
|
||||||
|
<Section.Description
|
||||||
|
title="TaxJar tax code matcher"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text display="block" as="span" marginBottom={4}>
|
||||||
|
To extend the base tax rate of your products, you can map Saleor tax classes to TaxJar
|
||||||
|
tax categories.
|
||||||
|
</Text>
|
||||||
|
<Text display="block" as="span" marginBottom={4}>
|
||||||
|
This way, the product's Saleor tax class will be used to determine the TaxJar tax
|
||||||
|
code needed to calculate the tax rate.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginBottom={4}>
|
||||||
|
If you haven't created any tax classes yet, you can do it in the{" "}
|
||||||
|
<AppDashboardLink href="/taxes/tax-classes">
|
||||||
|
Configuration → Taxes → Tax classes
|
||||||
|
</AppDashboardLink>{" "}
|
||||||
|
view.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginBottom={4}>
|
||||||
|
To learn more about TaxJar tax categories, please visit{" "}
|
||||||
|
<TextLink
|
||||||
|
href="https://support.taxjar.com/article/555-what-product-tax-codes-does-taxjar-support"
|
||||||
|
newTab
|
||||||
|
>
|
||||||
|
TaxJar documentation
|
||||||
|
</TextLink>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TaxJarMatcher = () => {
|
||||||
|
return (
|
||||||
|
<AppColumns top={<Header />}>
|
||||||
|
<Description />
|
||||||
|
<TaxJarTaxCodeMatcherTable />
|
||||||
|
</AppColumns>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* todo: add redirect if no connection
|
||||||
|
*/
|
||||||
|
export default TaxJarMatcher;
|
Loading…
Reference in a new issue