diff --git a/.changeset/cool-turtles-reflect.md b/.changeset/cool-turtles-reflect.md new file mode 100644 index 0000000..ccf4622 --- /dev/null +++ b/.changeset/cool-turtles-reflect.md @@ -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. diff --git a/apps/taxes/graphql/fragments/TaxBase.graphql b/apps/taxes/graphql/fragments/TaxBase.graphql index 1bcd7d9..898146c 100644 --- a/apps/taxes/graphql/fragments/TaxBase.graphql +++ b/apps/taxes/graphql/fragments/TaxBase.graphql @@ -3,24 +3,24 @@ fragment TaxBaseLine on TaxableObjectLine { __typename ... on CheckoutLine { id - productVariant: variant { + checkoutProductVariant: variant { id product { - metafield(key: "taxjar_tax_code") - productType { - metafield(key: "taxjar_tax_code") + taxClass { + id + name } } } } ... on OrderLine { id - variant { + orderProductVariant: variant { id product { - metafield(key: "taxjar_tax_code") - productType { - metafield(key: "taxjar_tax_code") + taxClass { + id + name } } } diff --git a/apps/taxes/graphql/queries/TaxClassesList.graphql b/apps/taxes/graphql/queries/TaxClassesList.graphql new file mode 100644 index 0000000..5b389f3 --- /dev/null +++ b/apps/taxes/graphql/queries/TaxClassesList.graphql @@ -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 +} diff --git a/apps/taxes/scripts/migrations/tax-channels-migration-v1-to-v2.ts b/apps/taxes/scripts/migrations/tax-channels-migration-v1-to-v2.ts index 9963ff2..eddcf7b 100644 --- a/apps/taxes/scripts/migrations/tax-channels-migration-v1-to-v2.ts +++ b/apps/taxes/scripts/migrations/tax-channels-migration-v1-to-v2.ts @@ -12,7 +12,7 @@ export class TaxChannelsV1toV2MigrationManager { private options: { mode: "report" | "migrate" } = { mode: "migrate" } ) { this.logger = createLogger({ - location: "TaxChannelsV1toV2MigrationManager", + name: "TaxChannelsV1toV2MigrationManager", }); } diff --git a/apps/taxes/scripts/migrations/tax-providers-migration-v1-to-v2.ts b/apps/taxes/scripts/migrations/tax-providers-migration-v1-to-v2.ts index 68bc71c..8d1abd1 100644 --- a/apps/taxes/scripts/migrations/tax-providers-migration-v1-to-v2.ts +++ b/apps/taxes/scripts/migrations/tax-providers-migration-v1-to-v2.ts @@ -13,7 +13,7 @@ export class TaxProvidersV1toV2MigrationManager { private options: { mode: "report" | "migrate" } = { mode: "migrate" } ) { this.logger = createLogger({ - location: "TaxProvidersV1toV2MigrationManager", + name: "TaxProvidersV1toV2MigrationManager", }); } diff --git a/apps/taxes/src/modules/avatax/avatax-client-tax-code.service.ts b/apps/taxes/src/modules/avatax/avatax-client-tax-code.service.ts new file mode 100644 index 0000000..13eb3c0 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-client-tax-code.service.ts @@ -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) { + 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); + } +} diff --git a/apps/taxes/src/modules/avatax/avatax-client.ts b/apps/taxes/src/modules/avatax/avatax-client.ts index 263e694..4911c65 100644 --- a/apps/taxes/src/modules/avatax/avatax-client.ts +++ b/apps/taxes/src/modules/avatax/avatax-client.ts @@ -6,6 +6,7 @@ import { AvataxConfig } from "./avatax-connection-schema"; import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel"; import { DocumentType } from "avatax/lib/enums/DocumentType"; import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo"; +import { AvataxClientTaxCodeService } from "./avatax-client-tax-code.service"; type AvataxSettings = { appName: string; @@ -57,7 +58,7 @@ export class AvataxClient { private logger: Logger; constructor(config: AvataxConfig) { - this.logger = createLogger({ location: "AvataxClient" }); + this.logger = createLogger({ name: "AvataxClient" }); const settings = createAvataxSettings(config); const avataxClient = new Avatax(settings).withSecurity(config.credentials); @@ -75,4 +76,10 @@ export class AvataxClient { async validateAddress({ address }: ValidateAddressArgs) { return this.client.resolveAddress(address); } + + async getTaxCodes() { + const taxCodeService = new AvataxClientTaxCodeService(this.client); + + return taxCodeService.getTaxCodes(); + } } diff --git a/apps/taxes/src/modules/avatax/avatax-connection.router.ts b/apps/taxes/src/modules/avatax/avatax-connection.router.ts index 6b492d6..5aab1bb 100644 --- a/apps/taxes/src/modules/avatax/avatax-connection.router.ts +++ b/apps/taxes/src/modules/avatax/avatax-connection.router.ts @@ -25,7 +25,6 @@ const postInputSchema = z.object({ const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => next({ ctx: { - ...ctx, connectionService: new PublicAvataxConnectionService( ctx.apiClient, ctx.appId!, @@ -38,7 +37,7 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next, export const avataxConnectionRouter = router({ getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => { const logger = createLogger({ - location: "avataxConnectionRouter.get", + name: "avataxConnectionRouter.get", }); logger.debug("Route get called"); diff --git a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts index 3c9d769..4138c87 100644 --- a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts +++ b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts @@ -1,3 +1,4 @@ +import { AuthData } from "@saleor/app-sdk/APL"; import { OrderCreatedSubscriptionFragment, OrderFulfilledSubscriptionFragment, @@ -16,9 +17,9 @@ export class AvataxWebhookService implements ProviderWebhookService { client: AvataxClient; private logger: Logger; - constructor(config: AvataxConfig) { + constructor(config: AvataxConfig, private authData: AuthData) { this.logger = createLogger({ - location: "AvataxWebhookService", + name: "AvataxWebhookService", }); const avataxClient = new AvataxClient(config); @@ -27,7 +28,7 @@ export class AvataxWebhookService implements ProviderWebhookService { } async calculateTaxes(taxBase: TaxBaseFragment) { - const adapter = new AvataxCalculateTaxesAdapter(this.config); + const adapter = new AvataxCalculateTaxesAdapter(this.config, this.authData); const response = await adapter.send({ taxBase }); diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-adapter.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-adapter.ts index f6cc112..4d235cf 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-adapter.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-adapter.ts @@ -1,10 +1,11 @@ +import { AuthData } from "@saleor/app-sdk/APL"; import { TaxBaseFragment } from "../../../../generated/graphql"; import { Logger, createLogger } from "../../../lib/logger"; import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook"; import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; import { AvataxClient, CreateTransactionArgs } from "../avatax-client"; 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"; export const SHIPPING_ITEM_CODE = "Shipping"; @@ -20,29 +21,27 @@ export class AvataxCalculateTaxesAdapter implements WebhookAdapter { private logger: Logger; - constructor(private readonly config: AvataxConfig) { - this.logger = createLogger({ location: "AvataxCalculateTaxesAdapter" }); + constructor(private readonly config: AvataxConfig, private authData: AuthData) { + this.logger = createLogger({ name: "AvataxCalculateTaxesAdapter" }); } + // todo: refactor because its getting too big async send(payload: AvataxCalculateTaxesPayload): Promise { - this.logger.debug({ payload }, "Transforming the following Saleor payload:"); - const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer(); - const target = payloadTransformer.transform({ ...payload, providerConfig: this.config }); + this.logger.debug("Transforming the Saleor payload for calculating taxes with Avatax..."); + const payloadService = new AvataxCalculateTaxesPayloadService(this.authData); + const target = await payloadService.getPayload(payload.taxBase, this.config); - this.logger.debug( - { transformedPayload: target }, - "Will call Avatax createTransaction with transformed payload:" - ); + this.logger.debug("Calling Avatax createTransaction with transformed payload..."); const client = new AvataxClient(this.config); 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 transformedResponse = responseTransformer.transform(response); - this.logger.debug({ transformedResponse }, "Transformed Avatax createTransaction response to:"); + this.logger.debug("Transformed Avatax createTransaction response"); return transformedResponse; } 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 a025525..6331c8a 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 @@ -13,6 +13,7 @@ import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel"; import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxConfigMockGenerator } from "../avatax-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; @@ -41,12 +42,12 @@ const defaultTaxBase: TaxBase = { sourceLine: { __typename: "OrderLine", id: "T3JkZXJMaW5lOjNmMjYwZmMyLTZjN2UtNGM5Ni1iYTMwLTEyMjAyODMzOTUyZA==", - variant: { + orderProductVariant: { id: "UHJvZHVjdFZhcmlhbnQ6MzQ5", product: { - metafield: null, - productType: { - metafield: null, + taxClass: { + id: "VGF4Q2xhc3M6MjI=", + name: "Clothing", }, }, }, @@ -63,12 +64,12 @@ const defaultTaxBase: TaxBase = { sourceLine: { __typename: "OrderLine", id: "T3JkZXJMaW5lOjNlNGZjODdkLTIyMmEtNDZiYi1iYzIzLWJiYWVkODVlOTQ4Mg==", - variant: { - id: "UHJvZHVjdFZhcmlhbnQ6MzUw", + orderProductVariant: { + id: "UHJvZHVjdFZhcmlhbnQ6MzQ6", product: { - metafield: null, - productType: { - metafield: null, + taxClass: { + id: "VGF4Q2xhc3M7MjI=", + name: "Shoes", }, }, }, @@ -85,12 +86,12 @@ const defaultTaxBase: TaxBase = { sourceLine: { __typename: "OrderLine", id: "T3JkZXJMaW5lOmM2NTBhMzVkLWQ1YjQtNGRhNy1hMjNjLWEzODU4ZDE1MzI2Mw==", - variant: { - id: "UHJvZHVjdFZhcmlhbnQ6MzQw", + orderProductVariant: { + id: "UHJvZHFjdFZhcmlhbnQ6MzQ5", product: { - metafield: null, - productType: { - metafield: null, + taxClass: { + id: "VGF4Q2xhc3M6TWjI=", + name: "Sweets", }, }, }, @@ -933,10 +934,21 @@ const defaultTransactionModel: TransactionModel = { ], }; +const defaultTaxCodeMatches: AvataxTaxCodeMatches = [ + { + data: { + avataxTaxCode: "P0000000", + saleorTaxClassId: "VGF4Q2xhc3M6MjI=", + }, + id: "VGF4Q29kZTox", + }, +]; + const testingScenariosMap = { default: { taxBase: defaultTaxBase, response: defaultTransactionModel, + matches: defaultTaxCodeMatches, }, }; @@ -967,4 +979,7 @@ export class AvataxCalculateTaxesMockGenerator { ...testingScenariosMap[this.scenario].response, ...overrides, }); + + generateTaxCodeMatches = (overrides: AvataxTaxCodeMatches = []): AvataxTaxCodeMatches => + structuredClone([...testingScenariosMap[this.scenario].matches, ...overrides]); } diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-lines-transformer.test.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-lines-transformer.test.ts new file mode 100644 index 0000000..beb59f5 --- /dev/null +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-lines-transformer.test.ts @@ -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, + }, + ]); + }); + }); +}); 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 new file mode 100644 index 0000000..74c092e --- /dev/null +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-lines-transformer.ts @@ -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; + } +} 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 f24f7e6..878d64d 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 @@ -1,20 +1,19 @@ import { describe, expect, it } from "vitest"; import { AvataxCalculateTaxesMockGenerator } from "./avatax-calculate-taxes-mock-generator"; -import { - AvataxCalculateTaxesPayloadTransformer, - mapPayloadLines, -} from "./avatax-calculate-taxes-payload-transformer"; +import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer"; describe("AvataxCalculateTaxesPayloadTransformer", () => { it("when discounts, calculates the sum of discounts", () => { const mockGenerator = new AvataxCalculateTaxesMockGenerator(); const avataxConfigMock = mockGenerator.generateAvataxConfig(); const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] }); + const matchesMock = mockGenerator.generateTaxCodeMatches(); - const payload = new AvataxCalculateTaxesPayloadTransformer().transform({ - taxBase: taxBaseMock, - providerConfig: avataxConfigMock, - }); + const payload = new AvataxCalculateTaxesPayloadTransformer().transform( + taxBaseMock, + avataxConfigMock, + matchesMock + ); expect(payload.model.discount).toEqual(10); }); @@ -22,123 +21,14 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => { const mockGenerator = new AvataxCalculateTaxesMockGenerator(); const avataxConfigMock = mockGenerator.generateAvataxConfig(); const taxBaseMock = mockGenerator.generateTaxBase(); + const matchesMock = mockGenerator.generateTaxCodeMatches(); - const payload = new AvataxCalculateTaxesPayloadTransformer().transform({ - taxBase: taxBaseMock, - providerConfig: avataxConfigMock, - }); + const payload = new AvataxCalculateTaxesPayloadTransformer().transform( + taxBaseMock, + avataxConfigMock, + matchesMock + ); 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, - }, - ]); - }); -}); 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 18c46b0..90bc9a0 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 @@ -1,61 +1,33 @@ import { DocumentType } from "avatax/lib/enums/DocumentType"; -import { LineItemModel } from "avatax/lib/models/LineItemModel"; import { TaxBaseFragment } 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 { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter"; - -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; -} +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; +import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer"; export class AvataxCalculateTaxesPayloadTransformer { - transform({ - taxBase, - providerConfig, - }: { - taxBase: TaxBaseFragment; - providerConfig: AvataxConfig; - }): CreateTransactionArgs { + transform( + taxBase: TaxBaseFragment, + avataxConfig: AvataxConfig, + matches: AvataxTaxCodeMatches + ): CreateTransactionArgs { + const payloadLinesTransformer = new AvataxCalculateTaxesPayloadLinesTransformer(); + return { model: { type: DocumentType.SalesOrder, 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: providerConfig.isAutocommit, + commit: avataxConfig.isAutocommit, addresses: { - shipFrom: avataxAddressFactory.fromChannelAddress(providerConfig.address), + shipFrom: avataxAddressFactory.fromChannelAddress(avataxConfig.address), shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!), }, currencyCode: taxBase.currency, - lines: mapPayloadLines(taxBase, providerConfig), + lines: payloadLinesTransformer.transform(taxBase, avataxConfig, matches), date: new Date(), discount: discountUtils.sumDiscounts( taxBase.discounts.map((discount) => discount.amount.amount) diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload.service.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload.service.ts new file mode 100644 index 0000000..76a9fbe --- /dev/null +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload.service.ts @@ -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 { + const matches = await this.getMatches(); + const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer(); + + return payloadTransformer.transform(taxBase, avataxConfig, matches); + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-connection-repository.ts b/apps/taxes/src/modules/avatax/configuration/avatax-connection-repository.ts index 59de3e1..f496908 100644 --- a/apps/taxes/src/modules/avatax/configuration/avatax-connection-repository.ts +++ b/apps/taxes/src/modules/avatax/configuration/avatax-connection-repository.ts @@ -25,7 +25,7 @@ export class AvataxConnectionRepository { TAX_PROVIDER_KEY ); this.logger = createLogger({ - location: "AvataxConnectionRepository", + name: "AvataxConnectionRepository", metadataKey: TAX_PROVIDER_KEY, }); } @@ -67,7 +67,7 @@ export class AvataxConnectionRepository { } async get(id: string): Promise { - const { data } = await this.crudSettingsManager.read(id); + const { data } = await this.crudSettingsManager.readById(id); const connection = getSchema.parse(data); @@ -84,7 +84,7 @@ export class AvataxConnectionRepository { } async patch(id: string, input: Pick): Promise { - return this.crudSettingsManager.update(id, input); + return this.crudSettingsManager.updateById(id, input); } async delete(id: string): Promise { diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts index a9b8597..6d31983 100644 --- a/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts +++ b/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts @@ -11,7 +11,7 @@ export class AvataxConnectionService { private avataxConnectionRepository: AvataxConnectionRepository; constructor(client: Client, appId: string, saleorApiUrl: string) { this.logger = createLogger({ - location: "AvataxConnectionService", + name: "AvataxConnectionService", }); const settingsManager = createSettingsManager(client, appId); diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts index ba9b9e1..582fe3d 100644 --- a/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts +++ b/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts @@ -11,7 +11,7 @@ export class AvataxValidationService { constructor() { this.logger = createLogger({ - location: "AvataxValidationService", + name: "AvataxValidationService", }); } 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 64f8ebc..6f30eb9 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 @@ -18,29 +18,26 @@ export class AvataxOrderCreatedAdapter private logger: Logger; constructor(private readonly config: AvataxConfig) { - this.logger = createLogger({ location: "AvataxOrderCreatedAdapter" }); + this.logger = createLogger({ name: "AvataxOrderCreatedAdapter" }); } async send(payload: AvataxOrderCreatedPayload): Promise { - 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 target = payloadTransformer.transform(payload); - this.logger.debug( - { transformedPayload: target }, - "Will call Avatax createTransaction with transformed payload:" - ); + this.logger.debug("Calling Avatax createTransaction with transformed payload..."); const client = new AvataxClient(this.config); 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 transformedResponse = responseTransformer.transform(response); - this.logger.debug({ transformedResponse }, "Transformed Avatax createTransaction response to:"); + this.logger.debug("Transformed Avatax createTransaction response"); return transformedResponse; } diff --git a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-adapter.ts b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-adapter.ts index cae94bf..7ce181e 100644 --- a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-adapter.ts +++ b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-adapter.ts @@ -18,29 +18,26 @@ export class AvataxOrderFulfilledAdapter private logger: Logger; constructor(private readonly config: AvataxConfig) { - this.logger = createLogger({ location: "AvataxOrderFulfilledAdapter" }); + this.logger = createLogger({ name: "AvataxOrderFulfilledAdapter" }); } async send(payload: AvataxOrderFulfilledPayload): Promise { - 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 target = payloadTransformer.transform({ ...payload }); - this.logger.debug( - { transformedPayload: target }, - "Will call Avatax commitTransaction with transformed payload:" - ); + this.logger.debug("Calling Avatax commitTransaction with transformed payload..."); const client = new AvataxClient(this.config); 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 transformedResponse = responseTransformer.transform(response); - this.logger.debug({ transformedResponse }, "Transformed Avatax commitTransaction response to:"); + this.logger.debug("Transformed Avatax commitTransaction response"); return transformedResponse; } diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-match-repository.ts b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-match-repository.ts new file mode 100644 index 0000000..e9185a3 --- /dev/null +++ b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-match-repository.ts @@ -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; + +const avataxTaxCodeMatchesSchema = z.array( + z.object({ + id: z.string(), + data: avataxTaxCodeMatchSchema, + }) +); + +export type AvataxTaxCodeMatches = z.infer; + +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 { + 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 { + return this.crudSettingsManager.updateById(id, { data: input }); + } +} diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.test.ts b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.test.ts new file mode 100644 index 0000000..68fa83e --- /dev/null +++ b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.test.ts @@ -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"); + }); +}); diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.ts b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.ts new file mode 100644 index 0000000..3465805 --- /dev/null +++ b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matcher.ts @@ -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 ?? "" + : ""; + } +} diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matches.router.ts b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matches.router.ts new file mode 100644 index 0000000..01478a7 --- /dev/null +++ b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matches.router.ts @@ -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); + }), +}); diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matches.service.ts b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matches.service.ts new file mode 100644 index 0000000..17ce636 --- /dev/null +++ b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-code-matches.service.ts @@ -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 { + return this.taxCodeMatchRepository.getAll(); + } + + async upsert(input: AvataxTaxCodeMatch): Promise { + 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); + } +} diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.router.ts b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.router.ts new file mode 100644 index 0000000..a720acf --- /dev/null +++ b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.router.ts @@ -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(); + }), +}); diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.service.ts b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.service.ts new file mode 100644 index 0000000..3d26164 --- /dev/null +++ b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.service.ts @@ -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); + } +} diff --git a/apps/taxes/src/modules/avatax/ui/avatax-tax-code-matcher-table.tsx b/apps/taxes/src/modules/avatax/ui/avatax-tax-code-matcher-table.tsx new file mode 100644 index 0000000..8229d07 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/avatax-tax-code-matcher-table.tsx @@ -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 ( + 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 ( + + Loading... + + ); + } + + return ( + + + + + Saleor tax class + TaxJar tax code + + + + {taxClasses.map((taxClass) => { + return ( + + {taxClass.name} + + + + + ); + })} + + + + ); +}; diff --git a/apps/taxes/src/modules/trpc/protected-client-procedure.ts b/apps/taxes/src/modules/trpc/protected-client-procedure.ts index 52594a3..152bbca 100644 --- a/apps/taxes/src/modules/trpc/protected-client-procedure.ts +++ b/apps/taxes/src/modules/trpc/protected-client-procedure.ts @@ -3,7 +3,7 @@ import { middleware, procedure } from "./trpc-server"; import { saleorApp } from "../../../saleor-app"; import { TRPCError } from "@trpc/server"; 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"; const attachAppToken = middleware(async ({ ctx, next }) => { @@ -85,7 +85,6 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => { return next({ ctx: { - ...ctx, 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 */ + +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 + .use(logErrors) .use(attachAppToken) .use(validateClientToken) .use(async ({ ctx, next }) => { diff --git a/apps/taxes/src/modules/trpc/trpc-app-router.ts b/apps/taxes/src/modules/trpc/trpc-app-router.ts index 3f1b250..3d01ca9 100644 --- a/apps/taxes/src/modules/trpc/trpc-app-router.ts +++ b/apps/taxes/src/modules/trpc/trpc-app-router.ts @@ -3,12 +3,26 @@ import { providerConnectionsRouter } from "../provider-connections/provider-conn import { channelsConfigurationRouter } from "../channel-configuration/channel-configuration.router"; import { taxjarConnectionRouter } from "../taxjar/taxjar-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({ providersConfiguration: providerConnectionsRouter, channelsConfiguration: channelsConfigurationRouter, taxJarConnection: taxjarConnectionRouter, avataxConnection: avataxConnectionRouter, + taxClasses: taxClassesRouter, + avataxTaxCodes: avataxTaxCodesRouter, + taxJarTaxCodes: taxJarTaxCodesRouter, + taxJarMatches: taxJarTaxCodeMatchesRouter, + avataxMatches: avataxTaxCodeMatchesRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/taxes/src/modules/ui/_select.tsx b/apps/taxes/src/modules/ui/_select.tsx new file mode 100644 index 0000000..33219db --- /dev/null +++ b/apps/taxes/src/modules/ui/_select.tsx @@ -0,0 +1,17 @@ +import { Select as _Select } from "@saleor/macaw-ui/next"; + +type SelectProps = React.ComponentProps; +/** + * 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"} + /> + ); +}; diff --git a/apps/taxes/src/modules/ui/app-breadcrumbs.tsx b/apps/taxes/src/modules/ui/app-breadcrumbs.tsx index 8cdc7b3..75a2b49 100644 --- a/apps/taxes/src/modules/ui/app-breadcrumbs.tsx +++ b/apps/taxes/src/modules/ui/app-breadcrumbs.tsx @@ -33,7 +33,17 @@ const breadcrumbsForRoute: Record = { href: "/providers/taxjar", }, ], - + "/providers/taxjar/matcher": [ + ...newProviderBreadcrumbs, + { + label: "TaxJar", + href: "/providers/taxjar", + }, + { + label: "Tax code matcher", + href: "/providers/taxjar/matcher", + }, + ], "/providers/taxjar/[id]": [ ...newProviderBreadcrumbs, { @@ -48,6 +58,17 @@ const breadcrumbsForRoute: Record = { href: "/providers/avatax", }, ], + "/providers/avatax/matcher": [ + ...newProviderBreadcrumbs, + { + label: "Avatax", + href: "/providers/avatax", + }, + { + label: "Tax code matcher", + href: "/providers/avatax/matcher", + }, + ], "/providers/avatax/[id]": [ ...newProviderBreadcrumbs, { @@ -61,7 +82,7 @@ const useBreadcrumbs = () => { const { pathname } = useRouter(); const breadcrumbs = breadcrumbsForRoute[pathname]; - if (pathname !== "/" && !breadcrumbs) { + if (pathname !== "/" && pathname !== "_error" && !breadcrumbs) { throw new Error(`No breadcrumbs for route ${pathname}`); } diff --git a/apps/taxes/src/modules/ui/app-columns.tsx b/apps/taxes/src/modules/ui/app-columns.tsx index 877c0fc..410c8b4 100644 --- a/apps/taxes/src/modules/ui/app-columns.tsx +++ b/apps/taxes/src/modules/ui/app-columns.tsx @@ -3,9 +3,9 @@ import React, { PropsWithChildren } from "react"; export const AppColumns = ({ top, children }: PropsWithChildren<{ top: React.ReactNode }>) => { return ( - + {top} - + {children} diff --git a/apps/taxes/src/modules/ui/app-dashboard-link.tsx b/apps/taxes/src/modules/ui/app-dashboard-link.tsx new file mode 100644 index 0000000..b36469b --- /dev/null +++ b/apps/taxes/src/modules/ui/app-dashboard-link.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/apps/taxes/src/modules/ui/matcher-section.tsx b/apps/taxes/src/modules/ui/matcher-section.tsx new file mode 100644 index 0000000..fd8804a --- /dev/null +++ b/apps/taxes/src/modules/ui/matcher-section.tsx @@ -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 ( + + + + + Provider + + + + {isAvatax && ( + + + + + + + {" "} + {" "} + + + )} + {isTaxJar && ( + + + + + + + {" "} + + + + )} + + + + ); +}; + +const Intro = () => { + return ( + + Tax Code Matcher allows you to map Saleor tax classes to provider tax codes to extend + products base tax rate. + + You need to have at least one provider configured to use this feature. + + + } + /> + ); +}; + +export const MatcherSection = () => { + return ( + <> + + + + ); +}; diff --git a/apps/taxes/src/pages/api/webhooks/checkout-calculate-taxes.ts b/apps/taxes/src/pages/api/webhooks/checkout-calculate-taxes.ts index 62e889c..cd355d0 100644 --- a/apps/taxes/src/pages/api/webhooks/checkout-calculate-taxes.ts +++ b/apps/taxes/src/pages/api/webhooks/checkout-calculate-taxes.ts @@ -37,7 +37,7 @@ export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook { - const logger = createLogger({ location: "checkoutCalculateTaxesSyncWebhook" }); + const logger = createLogger({ name: "checkoutCalculateTaxesSyncWebhook" }); const { payload } = ctx; const webhookResponse = new WebhookResponse(res); @@ -54,7 +54,7 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res, try { const appMetadata = payload.recipient?.privateMetadata ?? []; 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:"); const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase); diff --git a/apps/taxes/src/pages/api/webhooks/order-calculate-taxes.ts b/apps/taxes/src/pages/api/webhooks/order-calculate-taxes.ts index 8c007dc..df221d1 100644 --- a/apps/taxes/src/pages/api/webhooks/order-calculate-taxes.ts +++ b/apps/taxes/src/pages/api/webhooks/order-calculate-taxes.ts @@ -37,7 +37,7 @@ export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook { - const logger = createLogger({ location: "orderCalculateTaxesSyncWebhook" }); + const logger = createLogger({ name: "orderCalculateTaxesSyncWebhook" }); const { payload } = ctx; const webhookResponse = new WebhookResponse(res); @@ -54,7 +54,7 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx try { const appMetadata = payload.recipient?.privateMetadata ?? []; 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:"); const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase); diff --git a/apps/taxes/src/pages/api/webhooks/order-created.ts b/apps/taxes/src/pages/api/webhooks/order-created.ts index dbe3fef..1ea03e8 100644 --- a/apps/taxes/src/pages/api/webhooks/order-created.ts +++ b/apps/taxes/src/pages/api/webhooks/order-created.ts @@ -74,7 +74,7 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => { try { const appMetadata = payload.recipient?.privateMetadata ?? []; const channelSlug = payload.order?.channel.slug; - const taxProvider = getActiveConnection(channelSlug, appMetadata); + const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData); logger.info({ taxProvider }, "Fetched taxProvider"); diff --git a/apps/taxes/src/pages/api/webhooks/order-fulfilled.ts b/apps/taxes/src/pages/api/webhooks/order-fulfilled.ts index 53acf3d..d8d92f2 100644 --- a/apps/taxes/src/pages/api/webhooks/order-fulfilled.ts +++ b/apps/taxes/src/pages/api/webhooks/order-fulfilled.ts @@ -36,7 +36,7 @@ export default orderFulfilledAsyncWebhook.createHandler(async (req, res, ctx) => try { const appMetadata = payload.recipient?.privateMetadata ?? []; const channelSlug = payload.order?.channel.slug; - const taxProvider = getActiveConnection(channelSlug, appMetadata); + const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData); logger.info({ taxProvider }, "Fetched taxProvider"); diff --git a/apps/taxes/src/pages/configuration.tsx b/apps/taxes/src/pages/configuration.tsx index 92735a9..75bb06f 100644 --- a/apps/taxes/src/pages/configuration.tsx +++ b/apps/taxes/src/pages/configuration.tsx @@ -1,7 +1,9 @@ import { ChannelSection } from "../modules/channel-configuration/ui/channel-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 { Section } from "../modules/ui/app-section"; +import { MatcherSection } from "../modules/ui/matcher-section"; const Header = () => { return ( @@ -12,10 +14,14 @@ const Header = () => { }; const ConfigurationPage = () => { + const { data: providers = [] } = trpcClient.providersConfiguration.getAll.useQuery(); + const isProviders = providers.length > 0; + return ( }> + {isProviders && } ); }; diff --git a/apps/taxes/src/pages/providers/avatax/matcher.tsx b/apps/taxes/src/pages/providers/avatax/matcher.tsx new file mode 100644 index 0000000..3b84f48 --- /dev/null +++ b/apps/taxes/src/pages/providers/avatax/matcher.tsx @@ -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 Match Saleor tax classes to Avatax tax codes; +}; + +const Description = () => { + return ( + + + To extend the base tax rate of your products, you can map Saleor tax classes to Avatax + tax codes. + + + This way, the product's Saleor tax class will be used to determine the Avatax tax + code needed to calculate the tax rate. + + + If you haven't created any tax classes yet, you can do it in the{" "} + + Configuration → Taxes → Tax classes + {" "} + view. + + + To learn more about Avatax tax codes, please visit{" "} + + Avatax documentation + + . + + + } + /> + ); +}; + +const AvataxMatcher = () => { + return ( + }> + + + + ); +}; + +/* + * todo: add redirect if no connection + */ +export default AvataxMatcher; diff --git a/apps/taxes/src/pages/providers/taxjar/matcher.tsx b/apps/taxes/src/pages/providers/taxjar/matcher.tsx new file mode 100644 index 0000000..069f278 --- /dev/null +++ b/apps/taxes/src/pages/providers/taxjar/matcher.tsx @@ -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 Match Saleor tax classes to TaxJar tax categories; +}; + +const Description = () => { + return ( + + + To extend the base tax rate of your products, you can map Saleor tax classes to TaxJar + tax categories. + + + This way, the product's Saleor tax class will be used to determine the TaxJar tax + code needed to calculate the tax rate. + + + If you haven't created any tax classes yet, you can do it in the{" "} + + Configuration → Taxes → Tax classes + {" "} + view. + + + To learn more about TaxJar tax categories, please visit{" "} + + TaxJar documentation + + . + + + } + /> + ); +}; + +const TaxJarMatcher = () => { + return ( + }> + + + + ); +}; + +/* + * todo: add redirect if no connection + */ +export default TaxJarMatcher;