diff --git a/.changeset/light-rocks-count.md b/.changeset/light-rocks-count.md new file mode 100644 index 0000000..73db36b --- /dev/null +++ b/.changeset/light-rocks-count.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": minor +--- + +Added the usage of stored tax code combinations in the create order webhook flow. This doesn't effect the tax calculation, but makes sure the mapped product line has the correct tax code. diff --git a/apps/taxes/graphql/subscriptions/OrderCreated.graphql b/apps/taxes/graphql/subscriptions/OrderCreated.graphql index c6e9dd2..c9a0a00 100644 --- a/apps/taxes/graphql/subscriptions/OrderCreated.graphql +++ b/apps/taxes/graphql/subscriptions/OrderCreated.graphql @@ -2,6 +2,9 @@ fragment OrderLine on OrderLine { productSku productName quantity + taxClass { + id + } unitPrice { net { amount diff --git a/apps/taxes/graphql/subscriptions/OrderFulfilled.graphql b/apps/taxes/graphql/subscriptions/OrderFulfilled.graphql index aeed5e2..4c9d0c8 100644 --- a/apps/taxes/graphql/subscriptions/OrderFulfilled.graphql +++ b/apps/taxes/graphql/subscriptions/OrderFulfilled.graphql @@ -2,6 +2,9 @@ fragment OrderLine on OrderLine { productSku productName quantity + taxClass { + id + } unitPrice { net { amount diff --git a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts index 4138c87..6def76b 100644 --- a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts +++ b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts @@ -36,7 +36,7 @@ export class AvataxWebhookService implements ProviderWebhookService { } async createOrder(order: OrderCreatedSubscriptionFragment) { - const adapter = new AvataxOrderCreatedAdapter(this.config); + const adapter = new AvataxOrderCreatedAdapter(this.config, this.authData); const response = await adapter.send({ order }); diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-lines-transformer.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-lines-transformer.ts index 74c092e..3234a88 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-lines-transformer.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-lines-transformer.ts @@ -2,7 +2,7 @@ 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 { AvataxCalculateTaxesTaxCodeMatcher } from "./avatax-calculate-taxes-tax-code-matcher"; import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter"; export class AvataxCalculateTaxesPayloadLinesTransformer { @@ -13,7 +13,7 @@ export class AvataxCalculateTaxesPayloadLinesTransformer { ): LineItemModel[] { const isDiscounted = taxBase.discounts.length > 0; const productLines: LineItemModel[] = taxBase.lines.map((line) => { - const matcher = new AvataxTaxCodeMatcher(); + const matcher = new AvataxCalculateTaxesTaxCodeMatcher(); const taxCode = matcher.match(line, matches); return { diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.test.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-tax-code-matcher.test.ts similarity index 83% rename from apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.test.ts rename to apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-tax-code-matcher.test.ts index 68fa83e..f87a34f 100644 --- a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.test.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-tax-code-matcher.test.ts @@ -1,11 +1,11 @@ import { TaxBaseLineFragment } from "../../../../generated/graphql"; -import { AvataxTaxCodeMatches } from "./avatax-tax-code-match-repository"; -import { AvataxTaxCodeMatcher } from "./avatax-tax-code-matcher"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; +import { AvataxCalculateTaxesTaxCodeMatcher } from "./avatax-calculate-taxes-tax-code-matcher"; import { describe, expect, it } from "vitest"; -const matcher = new AvataxTaxCodeMatcher(); +const matcher = new AvataxCalculateTaxesTaxCodeMatcher(); -describe("AvataxTaxCodeMatcher", () => { +describe("AvataxCalculateTaxesTaxCodeMatcher", () => { it("returns empty string when tax class is not found", () => { const line: TaxBaseLineFragment = { quantity: 1, diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-tax-code-matcher.ts similarity index 86% rename from apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.ts rename to apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-tax-code-matcher.ts index 3465805..fdbdb15 100644 --- a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-tax-code-matcher.ts @@ -1,7 +1,7 @@ import { TaxBaseLineFragment } from "../../../../generated/graphql"; -import { AvataxTaxCodeMatches } from "./avatax-tax-code-match-repository"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; -export class AvataxTaxCodeMatcher { +export class AvataxCalculateTaxesTaxCodeMatcher { private mapTaxClassWithTaxMatch(taxClassId: string, matches: AvataxTaxCodeMatches) { return matches.find((m) => m.data.saleorTaxClassId === taxClassId); } diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-adapter.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-adapter.ts index 6f30eb9..d707d2c 100644 --- a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-adapter.ts +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-adapter.ts @@ -1,3 +1,4 @@ +import { AuthData } from "@saleor/app-sdk/APL"; import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; import { Logger, createLogger } from "../../../lib/logger"; import { CreateOrderResponse } from "../../taxes/tax-provider-webhook"; @@ -5,6 +6,7 @@ import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; import { AvataxClient } from "../avatax-client"; import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer"; +import { AvataxOrderCreatedPayloadService } from "./avatax-order-created-payload.service"; import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer"; type AvataxOrderCreatedPayload = { @@ -17,15 +19,15 @@ export class AvataxOrderCreatedAdapter { private logger: Logger; - constructor(private readonly config: AvataxConfig) { + constructor(private readonly config: AvataxConfig, private authData: AuthData) { this.logger = createLogger({ name: "AvataxOrderCreatedAdapter" }); } async send(payload: AvataxOrderCreatedPayload): Promise { this.logger.debug("Transforming the Saleor payload for creating order with Avatax..."); - const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(this.config); - const target = payloadTransformer.transform(payload); + const payloadService = new AvataxOrderCreatedPayloadService(this.authData); + const target = await payloadService.getPayload(payload.order, this.config); this.logger.debug("Calling Avatax createTransaction with transformed payload..."); diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-lines-transformer.test.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-lines-transformer.test.ts new file mode 100644 index 0000000..5060fa2 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-lines-transformer.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer"; +import { avataxConfigMock } from "./avatax-order-created-payload-transformer.test"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; +import { AvataxOrderCreatedMockGenerator } from "./avatax-order-created-mock-generator"; + +const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer(); +const mockGenerator = new AvataxOrderCreatedMockGenerator(); +const orderMock = mockGenerator.generateOrder(); + +const matches: AvataxTaxCodeMatches = []; + +describe("AvataxOrderCreatedPayloadLinesTransformer", () => { + const lines = linesTransformer.transform(orderMock, avataxConfigMock, matches); + + it("returns the correct number of lines", () => { + expect(lines).toHaveLength(4); + }); + + it("includes shipping as a line", () => { + expect(lines).toContainEqual({ + itemCode: "Shipping", + taxCode: "FR000000", + quantity: 1, + amount: 59.17, + taxIncluded: true, + }); + }); + + it("includes products as lines", () => { + const [first, second, third] = lines; + + expect(first).toContain({ + itemCode: "328223580", + description: "Monospace Tee", + quantity: 3, + amount: 65.18, + }); + expect(second).toContain({ + itemCode: "328223581", + description: "Monospace Tee", + quantity: 1, + amount: 21.73, + }); + expect(third).toContain({ + itemCode: "118223581", + description: "Paul's Balance 420", + quantity: 2, + amount: 108.63, + }); + }); +}); diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-lines-transformer.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-lines-transformer.ts new file mode 100644 index 0000000..742f935 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-lines-transformer.ts @@ -0,0 +1,52 @@ +import { LineItemModel } from "avatax/lib/models/LineItemModel"; +import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; +import { numbers } from "../../taxes/numbers"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; +import { SHIPPING_ITEM_CODE } from "./avatax-order-created-payload-transformer"; +import { AvataxOrderCreatedTaxCodeMatcher } from "./avatax-order-created-tax-code-matcher"; + +export class AvataxOrderCreatedPayloadLinesTransformer { + transform( + order: OrderCreatedSubscriptionFragment, + config: AvataxConfig, + matches: AvataxTaxCodeMatches + ): LineItemModel[] { + const productLines: LineItemModel[] = order.lines.map((line) => { + const matcher = new AvataxOrderCreatedTaxCodeMatcher(); + const taxCode = matcher.match(line, matches); + + return { + // taxes are included because we treat what is passed in payload as the source of truth + taxIncluded: true, + amount: numbers.roundFloatToTwoDecimals( + line.totalPrice.net.amount + line.totalPrice.tax.amount + ), + taxCode, + quantity: line.quantity, + description: line.productName, + itemCode: line.productSku ?? "", + discounted: order.discounts.length > 0, + }; + }); + + if (order.shippingPrice.net.amount !== 0) { + // * In Avatax, shipping is a regular line + const shippingLine: LineItemModel = { + amount: order.shippingPrice.gross.amount, + taxIncluded: true, + itemCode: SHIPPING_ITEM_CODE, + /** + * * Different shipping methods can have different tax codes. + * https://developer.avalara.com/ecommerce-integration-guide/sales-tax-badge/designing/non-standard-items/\ + */ + taxCode: config.shippingTaxCode, + quantity: 1, + }; + + return [...productLines, shippingLine]; + } + + return productLines; + } +} 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 2cf1c1c..c88be2d 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 @@ -1,11 +1,9 @@ import { describe, expect, it } from "vitest"; import { AvataxOrderCreatedMockGenerator } from "./avatax-order-created-mock-generator"; -import { - AvataxOrderCreatedPayloadTransformer, - mapLines, -} from "./avatax-order-created-payload-transformer"; +import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer"; const mockGenerator = new AvataxOrderCreatedMockGenerator(); + const orderMock = mockGenerator.generateOrder(); const discountedOrderMock = mockGenerator.generateOrder({ discounts: [ @@ -17,19 +15,14 @@ const discountedOrderMock = mockGenerator.generateOrder({ }, ], }); -const avataxConfigMock = mockGenerator.generateAvataxConfig(); -const channelConfigMock = mockGenerator.generateChannelConfig(); + +export const avataxConfigMock = mockGenerator.generateAvataxConfig(); describe("AvataxOrderCreatedPayloadTransformer", () => { it("returns lines with discounted: true when there are discounts", () => { - const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock); - const payloadMock = { - order: discountedOrderMock, - providerConfig: avataxConfigMock, - channelConfig: channelConfigMock, - }; + const transformer = new AvataxOrderCreatedPayloadTransformer(); - const payload = transformer.transform(payloadMock); + const payload = transformer.transform(discountedOrderMock, avataxConfigMock, []); const linesWithoutShipping = payload.model.lines.slice(0, -1); const check = linesWithoutShipping.every((line) => line.discounted === true); @@ -37,14 +30,8 @@ describe("AvataxOrderCreatedPayloadTransformer", () => { expect(check).toBe(true); }); it("returns lines with discounted: false when there are no discounts", () => { - const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock); - const payloadMock = { - order: orderMock, - providerConfig: avataxConfigMock, - channelConfig: channelConfigMock, - }; - - const payload = transformer.transform(payloadMock); + const transformer = new AvataxOrderCreatedPayloadTransformer(); + const payload = transformer.transform(orderMock, avataxConfigMock, []); const linesWithoutShipping = payload.model.lines.slice(0, -1); const check = linesWithoutShipping.every((line) => line.discounted === false); @@ -52,44 +39,3 @@ describe("AvataxOrderCreatedPayloadTransformer", () => { expect(check).toBe(true); }); }); - -describe("mapLines", () => { - const lines = mapLines(orderMock, avataxConfigMock); - - it("returns the correct number of lines", () => { - expect(lines).toHaveLength(4); - }); - - it("includes shipping as a line", () => { - expect(lines).toContainEqual({ - itemCode: "Shipping", - taxCode: "FR000000", - quantity: 1, - amount: 59.17, - taxIncluded: true, - }); - }); - - it("includes products as lines", () => { - const [first, second, third] = lines; - - expect(first).toContain({ - itemCode: "328223580", - description: "Monospace Tee", - quantity: 3, - amount: 65.18, - }); - expect(second).toContain({ - itemCode: "328223581", - description: "Monospace Tee", - quantity: 1, - amount: 21.73, - }); - expect(third).toContain({ - itemCode: "118223581", - description: "Paul's Balance 420", - quantity: 2, - amount: 108.63, - }); - }); -}); 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 b70c1b9..3307b5d 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 @@ -1,78 +1,44 @@ -import { LineItemModel } from "avatax/lib/models/LineItemModel"; -import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; -import { numbers } from "../../taxes/numbers"; -import { AvataxConfig } from "../avatax-connection-schema"; -import { avataxAddressFactory } from "../address-factory"; 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 { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; +import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer"; -const SHIPPING_ITEM_CODE = "Shipping"; - -// ? separate class? -export function mapLines( - order: OrderCreatedSubscriptionFragment, - config: AvataxConfig -): LineItemModel[] { - const productLines: LineItemModel[] = order.lines.map((line) => ({ - // taxes are included because we treat what is passed in payload as the source of truth - taxIncluded: true, - amount: numbers.roundFloatToTwoDecimals( - line.totalPrice.net.amount + line.totalPrice.tax.amount - ), - // todo: get from tax code matcher - taxCode: "", - quantity: line.quantity, - description: line.productName, - itemCode: line.productSku ?? "", - discounted: order.discounts.length > 0, - })); - - if (order.shippingPrice.net.amount !== 0) { - // * In Avatax, shipping is a regular line - const shippingLine: LineItemModel = { - amount: order.shippingPrice.gross.amount, - taxIncluded: true, - itemCode: SHIPPING_ITEM_CODE, - /** - * * Different shipping methods can have different tax codes. - * https://developer.avalara.com/ecommerce-integration-guide/sales-tax-badge/designing/non-standard-items/\ - */ - taxCode: config.shippingTaxCode, - quantity: 1, - }; - - return [...productLines, shippingLine]; - } - - return productLines; -} +export const SHIPPING_ITEM_CODE = "Shipping"; export class AvataxOrderCreatedPayloadTransformer { - constructor(private readonly providerConfig: AvataxConfig) {} - transform = ({ order }: { order: OrderCreatedSubscriptionFragment }): CreateTransactionArgs => { + transform( + order: OrderCreatedSubscriptionFragment, + avataxConfig: AvataxConfig, + matches: AvataxTaxCodeMatches + ): CreateTransactionArgs { + const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer(); + return { model: { type: DocumentType.SalesInvoice, customerCode: order.user?.id ?? "" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the user id. */, - companyCode: this.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: this.providerConfig.isAutocommit, + commit: avataxConfig.isAutocommit, addresses: { - shipFrom: avataxAddressFactory.fromChannelAddress(this.providerConfig.address), + shipFrom: avataxAddressFactory.fromChannelAddress(avataxConfig.address), // billing or shipping address? shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!), }, currencyCode: order.total.currency, email: order.user?.email ?? "", - lines: mapLines(order, this.providerConfig), + lines: linesTransformer.transform(order, avataxConfig, matches), date: new Date(order.created), discount: discountUtils.sumDiscounts( order.discounts.map((discount) => discount.amount.amount) ), }, }; - }; + } } diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload.service.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload.service.ts new file mode 100644 index 0000000..d1ffd0d --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload.service.ts @@ -0,0 +1,26 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { OrderCreatedSubscriptionFragment } 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 { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer"; + +export class AvataxOrderCreatedPayloadService { + constructor(private authData: AuthData) {} + + private getMatches() { + const taxCodeMatchesService = new AvataxTaxCodeMatchesService(this.authData); + + return taxCodeMatchesService.getAll(); + } + + async getPayload( + order: OrderCreatedSubscriptionFragment, + avataxConfig: AvataxConfig + ): Promise { + const matches = await this.getMatches(); + const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(); + + return payloadTransformer.transform(order, avataxConfig, matches); + } +} diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-tax-code-matcher.test.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-tax-code-matcher.test.ts new file mode 100644 index 0000000..e6c781d --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-tax-code-matcher.test.ts @@ -0,0 +1,57 @@ +import { OrderLineFragment } from "../../../../generated/graphql"; +import { describe, expect, it } from "vitest"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; +import { AvataxOrderCreatedTaxCodeMatcher } from "./avatax-order-created-tax-code-matcher"; + +const mockedLine: OrderLineFragment = { + productSku: "sku", + productName: "Test product", + quantity: 1, + taxClass: { + id: "tax-class-id-2", + }, + unitPrice: { + net: { + amount: 10, + }, + }, + totalPrice: { + net: { + amount: 10, + }, + tax: { + amount: 1, + }, + }, +}; + +const matches: AvataxTaxCodeMatches = [ + { + data: { + saleorTaxClassId: "tax-class-id", + avataxTaxCode: "P0000000", + }, + id: "id-1", + }, + { + data: { + saleorTaxClassId: "tax-class-id-3", + avataxTaxCode: "P0000001", + }, + id: "id-2", + }, +]; + +describe("AvataxOrderCreatedTaxCodeMatcher", () => { + it("should return empty string if tax class is not found", () => { + const matcher = new AvataxOrderCreatedTaxCodeMatcher(); + + expect(matcher.match(mockedLine, matches)).toEqual(""); + }); + it("should return tax code if tax class is found", () => { + const line = structuredClone({ ...mockedLine, taxClass: { id: "tax-class-id" } }); + const matcher = new AvataxOrderCreatedTaxCodeMatcher(); + + expect(matcher.match(line, matches)).toEqual("P0000000"); + }); +}); diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-tax-code-matcher.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-tax-code-matcher.ts new file mode 100644 index 0000000..77a5e74 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-tax-code-matcher.ts @@ -0,0 +1,21 @@ +import { OrderLineFragment } from "../../../../generated/graphql"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; + +export class AvataxOrderCreatedTaxCodeMatcher { + private mapTaxClassWithTaxMatch(taxClassId: string, matches: AvataxTaxCodeMatches) { + return matches.find((m) => m.data.saleorTaxClassId === taxClassId); + } + + private getTaxClassId(line: OrderLineFragment): string | undefined { + return line.taxClass?.id; + } + + match(line: OrderLineFragment, 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 ?? "" + : ""; + } +} diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-lines-transformer.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-lines-transformer.ts index d3a3868..f02c97b 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-lines-transformer.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-lines-transformer.ts @@ -4,7 +4,7 @@ import { TaxJarCalculateTaxesPayload, TaxJarCalculateTaxesTarget, } from "./taxjar-calculate-taxes-adapter"; -import { TaxJarTaxCodeMatcher } from "../tax-code/taxjar-tax-code-matcher"; +import { TaxJarCalculateTaxesTaxCodeMatcher } from "./taxjar-calculate-taxes-tax-code-matcher"; export class TaxJarCalculateTaxesPayloadLinesTransformer { transform( @@ -21,9 +21,9 @@ export class TaxJarCalculateTaxesPayloadLinesTransformer { const mappedLines: TaxJarCalculateTaxesTarget["params"]["line_items"] = lines.map( (line, index) => { - const matcher = new TaxJarTaxCodeMatcher(); - const discountAmount = distributedDiscounts[index]; + const matcher = new TaxJarCalculateTaxesTaxCodeMatcher(); const taxCode = matcher.match(line, matches); + const discountAmount = distributedDiscounts[index]; return { id: line.sourceLine.id, diff --git a/apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-code-matcher.test.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-tax-code-matcher.test.ts similarity index 83% rename from apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-code-matcher.test.ts rename to apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-tax-code-matcher.test.ts index f1cf425..83c899b 100644 --- a/apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-code-matcher.test.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-tax-code-matcher.test.ts @@ -1,11 +1,11 @@ import { TaxBaseLineFragment } from "../../../../generated/graphql"; -import { TaxJarTaxCodeMatches } from "./taxjar-tax-code-match-repository"; -import { TaxJarTaxCodeMatcher } from "./taxjar-tax-code-matcher"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; +import { TaxJarCalculateTaxesTaxCodeMatcher } from "./taxjar-calculate-taxes-tax-code-matcher"; import { describe, expect, it } from "vitest"; -const matcher = new TaxJarTaxCodeMatcher(); +const matcher = new TaxJarCalculateTaxesTaxCodeMatcher(); -describe("TaxJarTaxCodeMatcher", () => { +describe("TaxJarCalculateTaxesTaxCodeMatcher", () => { it("returns empty string when tax class is not found", () => { const line: TaxBaseLineFragment = { quantity: 1, diff --git a/apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-code-matcher.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-tax-code-matcher.ts similarity index 86% rename from apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-code-matcher.ts rename to apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-tax-code-matcher.ts index 72f0c3a..6646f08 100644 --- a/apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-code-matcher.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-tax-code-matcher.ts @@ -1,7 +1,7 @@ import { TaxBaseLineFragment } from "../../../../generated/graphql"; -import { TaxJarTaxCodeMatches } from "./taxjar-tax-code-match-repository"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; -export class TaxJarTaxCodeMatcher { +export class TaxJarCalculateTaxesTaxCodeMatcher { private mapTaxClassWithTaxMatch(taxClassId: string, matches: TaxJarTaxCodeMatches) { return matches.find((m) => m.data.saleorTaxClassId === taxClassId); } diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-adapter.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-adapter.ts index 2a38a09..331e766 100644 --- a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-adapter.ts +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-adapter.ts @@ -1,10 +1,11 @@ +import { AuthData } from "@saleor/app-sdk/APL"; import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; import { Logger, createLogger } from "../../../lib/logger"; import { CreateOrderResponse } from "../../taxes/tax-provider-webhook"; import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; import { CreateOrderArgs, TaxJarClient } from "../taxjar-client"; import { TaxJarConfig } from "../taxjar-connection-schema"; -import { TaxJarOrderCreatedPayloadTransformer } from "./taxjar-order-created-payload-transformer"; +import { TaxJarOrderCreatedPayloadService } from "./taxjar-order-created-payload.service"; import { TaxJarOrderCreatedResponseTransformer } from "./taxjar-order-created-response-transformer"; export type TaxJarOrderCreatedPayload = { @@ -17,14 +18,14 @@ export class TaxJarOrderCreatedAdapter implements WebhookAdapter { private logger: Logger; - constructor(private readonly config: TaxJarConfig) { + constructor(private readonly config: TaxJarConfig, private authData: AuthData) { this.logger = createLogger({ name: "TaxJarOrderCreatedAdapter" }); } async send(payload: TaxJarOrderCreatedPayload): Promise { this.logger.debug("Transforming the Saleor payload for creating order with TaxJar..."); - const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer(this.config); - const target = payloadTransformer.transform(payload); + const payloadService = new TaxJarOrderCreatedPayloadService(this.authData); + const target = await payloadService.getPayload(payload.order, this.config); this.logger.debug("Calling TaxJar fetchTaxForOrder with transformed payload..."); diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-lines-transformer.test.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-lines-transformer.test.ts new file mode 100644 index 0000000..92029ad --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-lines-transformer.test.ts @@ -0,0 +1,91 @@ +import { OrderCreatedSubscriptionFragment, OrderLineFragment } from "../../../../generated/graphql"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; + +import { describe, expect, it } from "vitest"; +import { TaxJarOrderCreatedPayloadLinesTransformer } from "./taxjar-order-created-payload-lines-transformer"; + +const transformer = new TaxJarOrderCreatedPayloadLinesTransformer(); + +const mockedLines: OrderCreatedSubscriptionFragment["lines"] = [ + { + productSku: "sku", + productName: "Test product", + quantity: 1, + taxClass: { + id: "tax-class-id-2", + }, + unitPrice: { + net: { + amount: 10, + }, + }, + totalPrice: { + net: { + amount: 10, + }, + tax: { + amount: 1, + }, + }, + }, + { + productSku: "sku-2", + productName: "Test product 2", + quantity: 2, + taxClass: { + id: "tax-class-id-3", + }, + unitPrice: { + net: { + amount: 15, + }, + }, + totalPrice: { + net: { + amount: 30, + }, + tax: { + amount: 3, + }, + }, + }, +]; +const matches: TaxJarTaxCodeMatches = [ + { + data: { + saleorTaxClassId: "tax-class-id", + taxJarTaxCode: "P0000000", + }, + id: "id-1", + }, + { + data: { + saleorTaxClassId: "tax-class-id-3", + taxJarTaxCode: "P0000001", + }, + id: "id-2", + }, +]; + +describe("TaxJarOrderCreatedPayloadLinesTransformer", () => { + it("should map payload lines correctly", () => { + expect(transformer.transform(mockedLines, matches)).toEqual([ + { + quantity: 1, + unit_price: 10, + product_identifier: "sku", + product_tax_code: "", + sales_tax: 1, + description: "Test product", + }, + { + quantity: 2, + unit_price: 15, + product_identifier: "sku-2", + product_tax_code: "P0000001", + sales_tax: 3, + description: "Test product 2", + }, + ]); + }); +}); diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-lines-transformer.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-lines-transformer.ts new file mode 100644 index 0000000..c0eff39 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-lines-transformer.ts @@ -0,0 +1,25 @@ +import { LineItem } from "taxjar/dist/util/types"; +import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; +import { TaxJarOrderCreatedTaxCodeMatcher } from "./taxjar-order-created-tax-code-matcher"; + +export class TaxJarOrderCreatedPayloadLinesTransformer { + transform( + lines: OrderCreatedSubscriptionFragment["lines"], + matches: TaxJarTaxCodeMatches + ): LineItem[] { + return lines.map((line) => { + const matcher = new TaxJarOrderCreatedTaxCodeMatcher(); + const taxCode = matcher.match(line, matches); + + return { + quantity: line.quantity, + unit_price: line.unitPrice.net.amount, + product_identifier: line.productSku ?? "", + product_tax_code: taxCode, + sales_tax: line.totalPrice.tax.amount, + description: line.productName, + }; + }); + } +} diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.test.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.test.ts index b03cff3..cab9009 100644 --- a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.test.ts +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.test.ts @@ -9,12 +9,10 @@ const mockGenerator = new TaxJarOrderCreatedMockGenerator(); describe("TaxJarOrderCreatedPayloadTransformer", () => { it("returns the correct order amount", () => { - const payloadMock = { - order: mockGenerator.generateOrder(), - }; + const orderMock = mockGenerator.generateOrder(); const providerConfig = mockGenerator.generateProviderConfig(); - const transformer = new TaxJarOrderCreatedPayloadTransformer(providerConfig); - const transformedPayload = transformer.transform(payloadMock); + const transformer = new TaxJarOrderCreatedPayloadTransformer(); + const transformedPayload = transformer.transform(orderMock, providerConfig, []); expect(transformedPayload.params.amount).toBe(239.17); }); diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.ts index 6810815..3b669f4 100644 --- a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.ts +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.ts @@ -2,11 +2,10 @@ import { LineItem } from "taxjar/dist/util/types"; import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; import { numbers } from "../../taxes/numbers"; import { taxProviderUtils } from "../../taxes/tax-provider-utils"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; import { TaxJarConfig } from "../taxjar-connection-schema"; -import { - TaxJarOrderCreatedPayload, - TaxJarOrderCreatedTarget, -} from "./taxjar-order-created-adapter"; +import { TaxJarOrderCreatedTarget } from "./taxjar-order-created-adapter"; +import { TaxJarOrderCreatedPayloadLinesTransformer } from "./taxjar-order-created-payload-lines-transformer"; export function sumPayloadLines(lines: LineItem[]): number { return numbers.roundFloatToTwoDecimals( @@ -27,21 +26,13 @@ export function sumPayloadLines(lines: LineItem[]): number { } export class TaxJarOrderCreatedPayloadTransformer { - constructor(private readonly config: TaxJarConfig) {} - private mapLines(lines: OrderCreatedSubscriptionFragment["lines"]): LineItem[] { - return lines.map((line) => ({ - quantity: line.quantity, - unit_price: line.unitPrice.net.amount, - product_identifier: line.productSku ?? "", - // todo: add from tax code matcher - product_tax_code: "", - sales_tax: line.totalPrice.tax.amount, - description: line.productName, - })); - } - - transform({ order }: TaxJarOrderCreatedPayload): TaxJarOrderCreatedTarget { - const lineItems = this.mapLines(order.lines); + transform( + order: OrderCreatedSubscriptionFragment, + taxJarConfig: TaxJarConfig, + matches: TaxJarTaxCodeMatches + ): TaxJarOrderCreatedTarget { + const linesTransformer = new TaxJarOrderCreatedPayloadLinesTransformer(); + const lineItems = linesTransformer.transform(order.lines, matches); const lineSum = sumPayloadLines(lineItems); const shippingAmount = order.shippingPrice.gross.amount; /** @@ -52,11 +43,11 @@ export class TaxJarOrderCreatedPayloadTransformer { return { params: { - from_country: this.config.address.country, - from_zip: this.config.address.zip, - from_state: this.config.address.state, - from_city: this.config.address.city, - from_street: this.config.address.street, + from_country: taxJarConfig.address.country, + from_zip: taxJarConfig.address.zip, + from_state: taxJarConfig.address.state, + from_city: taxJarConfig.address.city, + from_street: taxJarConfig.address.street, to_country: order.shippingAddress!.country.code, to_zip: order.shippingAddress!.postalCode, to_state: order.shippingAddress!.countryArea, diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload.service.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload.service.ts new file mode 100644 index 0000000..b42582c --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload.service.ts @@ -0,0 +1,26 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; +import { TaxJarTaxCodeMatchesService } from "../tax-code/taxjar-tax-code-matches.service"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { TaxJarOrderCreatedPayloadTransformer } from "./taxjar-order-created-payload-transformer"; +import { CreateOrderArgs } from "../taxjar-client"; + +export class TaxJarOrderCreatedPayloadService { + constructor(private authData: AuthData) {} + + private getMatches() { + const taxCodeMatchesService = new TaxJarTaxCodeMatchesService(this.authData); + + return taxCodeMatchesService.getAll(); + } + + async getPayload( + order: OrderCreatedSubscriptionFragment, + taxJarConfig: TaxJarConfig + ): Promise { + const matches = await this.getMatches(); + const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer(); + + return payloadTransformer.transform(order, taxJarConfig, matches); + } +} diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-tax-code-matcher.test.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-tax-code-matcher.test.ts new file mode 100644 index 0000000..88d74ba --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-tax-code-matcher.test.ts @@ -0,0 +1,57 @@ +import { OrderLineFragment } from "../../../../generated/graphql"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; +import { TaxJarOrderCreatedTaxCodeMatcher } from "./taxjar-order-created-tax-code-matcher"; +import { describe, expect, it } from "vitest"; + +const mockedLine: OrderLineFragment = { + productSku: "sku", + productName: "Test product", + quantity: 1, + taxClass: { + id: "tax-class-id-2", + }, + unitPrice: { + net: { + amount: 10, + }, + }, + totalPrice: { + net: { + amount: 10, + }, + tax: { + amount: 1, + }, + }, +}; + +const matches: TaxJarTaxCodeMatches = [ + { + data: { + saleorTaxClassId: "tax-class-id", + taxJarTaxCode: "P0000000", + }, + id: "id-1", + }, + { + data: { + saleorTaxClassId: "tax-class-id-3", + taxJarTaxCode: "P0000001", + }, + id: "id-2", + }, +]; + +describe("TaxJarOrderCreatedTaxCodeMatcher", () => { + it("should return empty string if tax class is not found", () => { + const matcher = new TaxJarOrderCreatedTaxCodeMatcher(); + + expect(matcher.match(mockedLine, matches)).toEqual(""); + }); + it("should return tax code if tax class is found", () => { + const line = structuredClone({ ...mockedLine, taxClass: { id: "tax-class-id" } }); + const matcher = new TaxJarOrderCreatedTaxCodeMatcher(); + + expect(matcher.match(line, matches)).toEqual("P0000000"); + }); +}); diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-tax-code-matcher.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-tax-code-matcher.ts new file mode 100644 index 0000000..62e5ff4 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-tax-code-matcher.ts @@ -0,0 +1,21 @@ +import { OrderLineFragment } from "../../../../generated/graphql"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; + +export class TaxJarOrderCreatedTaxCodeMatcher { + private mapTaxClassWithTaxMatch(taxClassId: string, matches: TaxJarTaxCodeMatches) { + return matches.find((m) => m.data.saleorTaxClassId === taxClassId); + } + + private getTaxClassId(line: OrderLineFragment): string | undefined { + return line.taxClass?.id; + } + + match(line: OrderLineFragment, 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 ?? "" + : ""; + } +} diff --git a/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts b/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts index bfb3ece..120c0a4 100644 --- a/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts +++ b/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts @@ -31,7 +31,7 @@ export class TaxJarWebhookService implements ProviderWebhookService { } async createOrder(order: OrderCreatedSubscriptionFragment) { - const adapter = new TaxJarOrderCreatedAdapter(this.config); + const adapter = new TaxJarOrderCreatedAdapter(this.config, this.authData); const response = await adapter.send({ order });