diff --git a/.changeset/chatty-mugs-accept.md b/.changeset/chatty-mugs-accept.md new file mode 100644 index 0000000..ef2cdb7 --- /dev/null +++ b/.changeset/chatty-mugs-accept.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": minor +--- + +Added the possibility to pass entityUseCode as order `avataxEntityCode` metadata field. This makes tax exempting groups of customers possible. diff --git a/apps/taxes/graphql/fragments/TaxBase.graphql b/apps/taxes/graphql/fragments/TaxBase.graphql index 898146c..2c28eb7 100644 --- a/apps/taxes/graphql/fragments/TaxBase.graphql +++ b/apps/taxes/graphql/fragments/TaxBase.graphql @@ -61,11 +61,13 @@ fragment TaxBase on TaxableObject { } sourceObject { ... on Checkout { + avataxEntityCode: metafield(key: "avataxEntityCode") user { id } } ... on Order { + avataxEntityCode: metafield(key: "avataxEntityCode") user { id } diff --git a/apps/taxes/graphql/subscriptions/OrderCreated.graphql b/apps/taxes/graphql/subscriptions/OrderCreated.graphql index c9a0a00..42fb244 100644 --- a/apps/taxes/graphql/subscriptions/OrderCreated.graphql +++ b/apps/taxes/graphql/subscriptions/OrderCreated.graphql @@ -64,6 +64,7 @@ fragment OrderCreatedSubscription on Order { amount } } + avataxEntityCode: metafield(key: "avataxEntityCode") } fragment OrderCreatedEventSubscription on Event { __typename diff --git a/apps/taxes/src/mocks.ts b/apps/taxes/src/mocks.ts index 13ff847..cadb72c 100644 --- a/apps/taxes/src/mocks.ts +++ b/apps/taxes/src/mocks.ts @@ -6,6 +6,7 @@ export const defaultOrder: OrderCreatedSubscriptionFragment = { id: "VXNlcjoyMDg0NTEwNDEw", email: "happy.customer@saleor.io", }, + avataxEntityCode: null, created: "2023-05-25T09:18:55.203440+00:00", status: OrderStatus.Unfulfilled, channel: { diff --git a/apps/taxes/src/modules/avatax/avatax-client.ts b/apps/taxes/src/modules/avatax/avatax-client.ts index 90f291c..53631d1 100644 --- a/apps/taxes/src/modules/avatax/avatax-client.ts +++ b/apps/taxes/src/modules/avatax/avatax-client.ts @@ -86,4 +86,11 @@ export class AvataxClient { async ping() { return this.client.ping(); } + + async getEntityUseCode(useCode: string) { + return this.client.listEntityUseCodes({ + // https://developer.avalara.com/avatax/filtering-in-rest/ + filter: `code eq ${useCode}`, + }); + } } diff --git a/apps/taxes/src/modules/avatax/avatax-entity-type-matcher.test.ts b/apps/taxes/src/modules/avatax/avatax-entity-type-matcher.test.ts new file mode 100644 index 0000000..d28bb69 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-entity-type-matcher.test.ts @@ -0,0 +1,44 @@ +import { AvataxClient } from "./avatax-client"; +import { AvataxEntityTypeMatcher } from "./avatax-entity-type-matcher"; +import { describe, expect, it, vi } from "vitest"; + +const mockGetEntityUseCode = vi.fn(); + +describe("AvataxEntityTypeMatcher", () => { + it("returns empty string when no entity code", async () => { + const mockAvataxClient = { + getEntityUseCode: mockGetEntityUseCode.mockReturnValue( + Promise.resolve({ value: [{ code: "entityCode" }] }) + ), + } as any as AvataxClient; + + const matcher = new AvataxEntityTypeMatcher({ client: mockAvataxClient }); + const result = await matcher.match(null); + + expect(result).toBe(""); + }); + it("returns empty string when entity code is present in metadata but not in avatax", async () => { + const mockAvataxClient = { + getEntityUseCode: mockGetEntityUseCode.mockReturnValue(Promise.resolve({})), + } as any as AvataxClient; + + const matcher = new AvataxEntityTypeMatcher({ client: mockAvataxClient }); + + const result = await matcher.match("entityCode"); + + expect(result).toBe(""); + }); + it("returns entity code when entity code is present in metadata and in avatax", async () => { + const mockAvataxClient = { + getEntityUseCode: mockGetEntityUseCode.mockReturnValue( + Promise.resolve({ value: [{ code: "entityCode" }] }) + ), + } as any as AvataxClient; + + const matcher = new AvataxEntityTypeMatcher({ client: mockAvataxClient }); + + const result = await matcher.match("entityCode"); + + expect(result).toBe("entityCode"); + }); +}); diff --git a/apps/taxes/src/modules/avatax/avatax-entity-type-matcher.ts b/apps/taxes/src/modules/avatax/avatax-entity-type-matcher.ts new file mode 100644 index 0000000..3a4718e --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-entity-type-matcher.ts @@ -0,0 +1,45 @@ +import { Logger, createLogger } from "../../lib/logger"; +import { AvataxClient } from "./avatax-client"; + +/* + * Arbitrary key-value pair that is used to store the entity code in the metadata. + * see: https://docs.saleor.io/docs/3.x/developer/app-store/apps/taxes/avatax#mapping-the-entity-type + */ +const AVATAX_ENTITY_CODE = "avataxEntityCode"; + +export class AvataxEntityTypeMatcher { + private client: AvataxClient; + private logger: Logger; + + constructor({ client }: { client: AvataxClient }) { + this.client = client; + this.logger = createLogger({ + name: "AvataxEntityTypeMatcher", + }); + } + + private returnFallback() { + // Empty string will be treated as non existing entity code. + return ""; + } + + private async validateEntityCode(entityCode: string) { + const result = await this.client.getEntityUseCode(entityCode); + + // If verified, return the entity code. If not, return empty string. + return result.value?.[0].code || this.returnFallback(); + } + + async match(entityCode: string | null | undefined) { + if (!entityCode) { + return this.returnFallback(); + } + + try { + return this.validateEntityCode(entityCode); + } catch (error) { + this.logger.debug({ error }, "Failed to verify entity code"); + return this.returnFallback(); + } + } +} diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-mock-generator.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-mock-generator.ts index 6331c8a..4e7797a 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-mock-generator.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-mock-generator.ts @@ -106,6 +106,7 @@ const defaultTaxBase: TaxBase = { }, ], sourceObject: { + avataxEntityCode: null, user: { id: "VXNlcjoyMDg0NTEwNDEw", }, diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.test.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.test.ts index 542073d..de4729f 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.test.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.test.ts @@ -7,11 +7,11 @@ const mockGenerator = new AvataxCalculateTaxesMockGenerator(); const avataxConfigMock = mockGenerator.generateAvataxConfig(); describe("AvataxCalculateTaxesPayloadTransformer", () => { - it("returns document type of SalesInvoice", () => { + it("returns document type of SalesInvoice", async () => { const taxBaseMock = mockGenerator.generateTaxBase(); const matchesMock = mockGenerator.generateTaxCodeMatches(); - const payload = new AvataxCalculateTaxesPayloadTransformer().transform( + const payload = await new AvataxCalculateTaxesPayloadTransformer().transform( taxBaseMock, avataxConfigMock, matchesMock @@ -19,11 +19,11 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => { expect(payload.model.type).toBe(DocumentType.SalesOrder); }); - it("when discounts, calculates the sum of discounts", () => { + it("when discounts, calculates the sum of discounts", async () => { const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] }); const matchesMock = mockGenerator.generateTaxCodeMatches(); - const payload = new AvataxCalculateTaxesPayloadTransformer().transform( + const payload = await new AvataxCalculateTaxesPayloadTransformer().transform( taxBaseMock, avataxConfigMock, matchesMock @@ -31,11 +31,13 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => { expect(payload.model.discount).toEqual(10); }); - it("when no discounts, the sum of discount is 0", () => { + it("when no discounts, the sum of discount is 0", async () => { + const mockGenerator = new AvataxCalculateTaxesMockGenerator(); + const avataxConfigMock = mockGenerator.generateAvataxConfig(); const taxBaseMock = mockGenerator.generateTaxBase(); const matchesMock = mockGenerator.generateTaxCodeMatches(); - const payload = new AvataxCalculateTaxesPayloadTransformer().transform( + const payload = await new AvataxCalculateTaxesPayloadTransformer().transform( taxBaseMock, avataxConfigMock, matchesMock diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.ts index 6e9066c..630d682 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.ts @@ -2,32 +2,37 @@ import { DocumentType } from "avatax/lib/enums/DocumentType"; import { TaxBaseFragment } from "../../../../generated/graphql"; import { discountUtils } from "../../taxes/discount-utils"; import { avataxAddressFactory } from "../address-factory"; -import { CreateTransactionArgs } from "../avatax-client"; +import { AvataxClient, CreateTransactionArgs } from "../avatax-client"; import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer"; +import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher"; export class AvataxCalculateTaxesPayloadTransformer { private matchDocumentType(config: AvataxConfig): DocumentType { /* * * For calculating taxes, we always use DocumentType.SalesOrder because it doesn't cause transaction recording. * * The full flow is described here: https://developer.avalara.com/ecommerce-integration-guide/sales-tax-badge/design-document-workflow/should-i-commit/ - * * config.isDocumentRecordingEnabledEnabled is used to determine if the transaction should be recorded (hence if the document type should be SalesOrder). + * * config.isDocumentRecordingEnabled is used to determine if the transaction should be recorded (hence if the document type should be SalesOrder). * * Given that we never want to record the transaction in calculate taxes, we always return DocumentType.SalesOrder. */ return DocumentType.SalesOrder; } - transform( + async transform( taxBase: TaxBaseFragment, avataxConfig: AvataxConfig, matches: AvataxTaxCodeMatches - ): CreateTransactionArgs { + ): Promise { const payloadLinesTransformer = new AvataxCalculateTaxesPayloadLinesTransformer(); + const avataxClient = new AvataxClient(avataxConfig); + const entityTypeMatcher = new AvataxEntityTypeMatcher({ client: avataxClient }); + const entityUseCode = await entityTypeMatcher.match(taxBase.sourceObject.avataxEntityCode); return { model: { type: this.matchDocumentType(avataxConfig), + entityUseCode, customerCode: taxBase.sourceObject.user?.id ?? "", 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 diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.test.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.test.ts index 8123f7c..9846ddb 100644 --- a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.test.ts +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.test.ts @@ -22,13 +22,13 @@ const transformer = new AvataxOrderCreatedPayloadTransformer(); export const avataxConfigMock = mockGenerator.generateAvataxConfig(); describe("AvataxOrderCreatedPayloadTransformer", () => { - it("returns document type of SalesInvoice when isDocumentRecordingEnabled is true", () => { - const payload = transformer.transform(orderMock, avataxConfigMock, []); + it("returns document type of SalesInvoice when isDocumentRecordingEnabled is true", async () => { + const payload = await transformer.transform(orderMock, avataxConfigMock, []); expect(payload.model.type).toBe(DocumentType.SalesInvoice); }), - it("returns document type of SalesOrder when isDocumentRecordingEnabled is false", () => { - const payload = transformer.transform( + it("returns document type of SalesOrder when isDocumentRecordingEnabled is false", async () => { + const payload = await transformer.transform( orderMock, { ...avataxConfigMock, @@ -39,16 +39,17 @@ describe("AvataxOrderCreatedPayloadTransformer", () => { expect(payload.model.type).toBe(DocumentType.SalesOrder); }); - it("returns lines with discounted: true when there are discounts", () => { - const payload = transformer.transform(discountedOrderMock, avataxConfigMock, []); + it("returns lines with discounted: true when there are discounts", async () => { + const payload = await transformer.transform(discountedOrderMock, avataxConfigMock, []); const linesWithoutShipping = payload.model.lines.slice(0, -1); const check = linesWithoutShipping.every((line) => line.discounted === true); expect(check).toBe(true); }); - it("returns lines with discounted: false when there are no discounts", () => { - const payload = transformer.transform(orderMock, avataxConfigMock, []); + it("returns lines with discounted: false when there are no discounts", async () => { + const transformer = new AvataxOrderCreatedPayloadTransformer(); + const payload = await transformer.transform(orderMock, avataxConfigMock, []); const linesWithoutShipping = payload.model.lines.slice(0, -1); const check = linesWithoutShipping.every((line) => line.discounted === false); diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts index 2ffe4fa..c91d5ba 100644 --- a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts @@ -2,10 +2,11 @@ import { DocumentType } from "avatax/lib/enums/DocumentType"; import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; import { discountUtils } from "../../taxes/discount-utils"; import { avataxAddressFactory } from "../address-factory"; -import { CreateTransactionArgs } from "../avatax-client"; +import { AvataxClient, CreateTransactionArgs } from "../avatax-client"; import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer"; +import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher"; export const SHIPPING_ITEM_CODE = "Shipping"; @@ -18,16 +19,20 @@ export class AvataxOrderCreatedPayloadTransformer { return DocumentType.SalesInvoice; } - transform( + async transform( order: OrderCreatedSubscriptionFragment, avataxConfig: AvataxConfig, matches: AvataxTaxCodeMatches - ): CreateTransactionArgs { + ): Promise { const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer(); + const avataxClient = new AvataxClient(avataxConfig); + const entityTypeMatcher = new AvataxEntityTypeMatcher({ client: avataxClient }); + const entityUseCode = await entityTypeMatcher.match(order.avataxEntityCode); return { model: { type: this.matchDocumentType(avataxConfig), + entityUseCode, customerCode: order.user?.id ?? "" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the user id. */, diff --git a/apps/taxes/src/modules/channel-configuration/ui/channel-section.tsx b/apps/taxes/src/modules/channel-configuration/ui/channel-section.tsx index f353db4..ca63285 100644 --- a/apps/taxes/src/modules/channel-configuration/ui/channel-section.tsx +++ b/apps/taxes/src/modules/channel-configuration/ui/channel-section.tsx @@ -1,3 +1,4 @@ +import { Text } from "@saleor/macaw-ui/next"; import { Section } from "../../ui/app-section"; import { ChannelList } from "./channel-list"; import { AppDashboardLink } from "../../ui/app-dashboard-link"; @@ -9,15 +10,17 @@ const Intro = () => { data-testid="channel-intro" description={ <> - This table displays all the channels configured to use the tax app as the tax calculation - method. -
-
- You can change the tax configuration method for each channel in the{" "} - - Configuration → Taxes - {" "} - view. + + This table displays all the channels configured to use the tax app as the tax + calculation method. + + + You can change the tax configuration method for each channel in the{" "} + + Configuration → Taxes + {" "} + view. + } /> diff --git a/apps/taxes/src/modules/provider-connections/ui/providers-section.tsx b/apps/taxes/src/modules/provider-connections/ui/providers-section.tsx index 0510108..21d0b9b 100644 --- a/apps/taxes/src/modules/provider-connections/ui/providers-section.tsx +++ b/apps/taxes/src/modules/provider-connections/ui/providers-section.tsx @@ -1,6 +1,7 @@ import { TextLink } from "@saleor/apps-ui"; import { Section } from "../../ui/app-section"; import { ProvidersList } from "./providers-list"; +import { Text } from "@saleor/macaw-ui/next"; const Intro = () => { return ( @@ -9,18 +10,20 @@ const Intro = () => { data-testid="providers-intro" description={ <> - Saleor offers two ways of calculating taxes: flat or dynamic rates. -
-
- Taxes App leverages the dynamic rates by delegating the tax calculation to third-party - services. -
-
- You can read more about how Saleor deals with taxes in{" "} - - our documentation - - . + + Saleor offers two ways of calculating taxes: flat or dynamic rates. + + + Taxes App leverages the dynamic rates by delegating the tax calculation to third-party + services. + + + You can read more about how Saleor deals with taxes in{" "} + + our documentation + + . + } /> diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-mock-generator.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-mock-generator.ts index e5d8f21..352f50a 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-mock-generator.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-mock-generator.ts @@ -95,6 +95,7 @@ const taxIncludedTaxBase: TaxBase = { }, ], sourceObject: { + avataxEntityCode: null, user: { id: "VXNlcjoyMDg0NTEwNDEw", }, @@ -148,6 +149,7 @@ const taxExcludedTaxBase: TaxBase = { sourceLine: { __typename: "OrderLine", id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==", + orderProductVariant: { id: "UHJvZHVjdFZhcmlhbnQ6MzQ5", product: { @@ -170,6 +172,7 @@ const taxExcludedTaxBase: TaxBase = { sourceLine: { __typename: "OrderLine", id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==", + orderProductVariant: { id: "UHJvZHVjdFZhcmlhbnQ6MzQw", product: { @@ -190,6 +193,7 @@ const taxExcludedTaxBase: TaxBase = { }, ], sourceObject: { + avataxEntityCode: null, user: { id: "VXNlcjoyMDg0NTEwNDEw", }, diff --git a/apps/taxes/src/modules/taxjar/ui/taxjar-instructions.tsx b/apps/taxes/src/modules/taxjar/ui/taxjar-instructions.tsx index abc399f..1fae20c 100644 --- a/apps/taxes/src/modules/taxjar/ui/taxjar-instructions.tsx +++ b/apps/taxes/src/modules/taxjar/ui/taxjar-instructions.tsx @@ -8,11 +8,13 @@ export const TaxJarInstructions = () => { title={"TaxJar Configuration"} description={ <> - The form consists of two sections: Credentials and Address. -
-
- Credentials will fail if: - + + The form consists of two sections: Credentials and Address. + + + Credentials will fail if: + +
  • - The API Key is incorrect.
  • @@ -20,10 +22,10 @@ export const TaxJarInstructions = () => { - The API Key does not match "sandbox mode" setting.
    -
    -
    - Address will fail if: - + + Address will fail if: + +
  • - The address does not match{" "} @@ -34,13 +36,13 @@ export const TaxJarInstructions = () => {
  • -
    -
    - If the configuration fails, please visit the{" "} - - TaxJar documentation - - . + + If the configuration fails, please visit the{" "} + + TaxJar documentation + + . + } />