diff --git a/.changeset/cold-toes-heal.md b/.changeset/cold-toes-heal.md new file mode 100644 index 0000000..ed9ef35 --- /dev/null +++ b/.changeset/cold-toes-heal.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": minor +--- + +Changed `externalId` order metadata field to `avataxId`. It is now only responsible for storing the id of Avatax transaction. diff --git a/.changeset/forty-shirts-battle.md b/.changeset/forty-shirts-battle.md new file mode 100644 index 0000000..2d4e424 --- /dev/null +++ b/.changeset/forty-shirts-battle.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": minor +--- + +Added support for reading document code from metadata field `avataxDocumentCode`. The default value is the order id from Saleor. The value for the document code is sliced to be under 20 characters. The requirement comes from Avatax API. diff --git a/.changeset/ninety-lobsters-design.md b/.changeset/ninety-lobsters-design.md new file mode 100644 index 0000000..21beda3 --- /dev/null +++ b/.changeset/ninety-lobsters-design.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": minor +--- + +Added support for reading the tax calculation date from metadata field `avataxTaxCalculationDate`. The value has to be valid UTC datetime string (e.g. "2021-08-31T13:00:00.000Z"). diff --git a/.changeset/shiny-meals-wait.md b/.changeset/shiny-meals-wait.md new file mode 100644 index 0000000..0f6dc82 --- /dev/null +++ b/.changeset/shiny-meals-wait.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": minor +--- + +Added `ORDER_CANCELLED` webhook handler with an implementation for Avatax. The transactions for corresponding orders in Saleor will be voided in the Avatax dashboard. diff --git a/.changeset/soft-steaks-know.md b/.changeset/soft-steaks-know.md new file mode 100644 index 0000000..cfa208b --- /dev/null +++ b/.changeset/soft-steaks-know.md @@ -0,0 +1,21 @@ +--- +"saleor-app-taxes": minor +--- + +Started the migration from `OrderCreated` to `OrderConfirmed` webhook event. In the new flow, the provider transactions will be created based on the order confirmation (either automatic or manual) event. The value of the `commit` field will be set based on the "isAutocommit" setting in the provider configuration. + +The `OrderCreated` and `OrderFulfilled` handlers are deprecated. They will be removed on August 23, along with their corresponding webhooks. For now, both flows (`OrderCreated` -> `OrderFulfilled` and `OrderConfirmed`) are supported. + +**Actions needed**: + +The only scenario where you, as the user, may need to do something regarding this release is the following: + +1. You created an order that still needs to be fulfilled (therefore, the corresponding AvaTax transaction is not committed). +2. You are planning to fulfill the order after August 23 (which is the date when we will complete the migration). + +In that case, **remember you will not be able to commit the transaction by fulfilling the order in Saleor**. In the new flow, the transactions are committed in AvaTax while confirming the Saleor order, based on the "isAutocommit" flag. What you have to do is the following: + +1. Make sure "isAutocommit" is set to true. +2. Trigger the `OrderConfirmed` event (either by [`orderConfirm` mutation](https://docs.saleor.io/docs/3.x/api-reference/orders/mutations/order-confirm) or in the Dashboard). + +The AvaTax transaction created on the `OrderCreated` event should be updated with `commit: true`. diff --git a/apps/taxes/.graphqlrc.yml b/apps/taxes/.graphqlrc.yml index b0db306..484ce9c 100644 --- a/apps/taxes/.graphqlrc.yml +++ b/apps/taxes/.graphqlrc.yml @@ -1,5 +1,5 @@ schema: graphql/schema.graphql -documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx] +documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx, scripts/migrations/**/*.ts] extensions: codegen: overwrite: true diff --git a/apps/taxes/graphql/mutations/UpdateMetadata.graphql b/apps/taxes/graphql/mutations/UpdatePrivateMetadata.graphql similarity index 65% rename from apps/taxes/graphql/mutations/UpdateMetadata.graphql rename to apps/taxes/graphql/mutations/UpdatePrivateMetadata.graphql index 76281b0..fee2d4c 100644 --- a/apps/taxes/graphql/mutations/UpdateMetadata.graphql +++ b/apps/taxes/graphql/mutations/UpdatePrivateMetadata.graphql @@ -1,4 +1,4 @@ -mutation UpdateMetadata($id: ID!, $input: [MetadataInput!]!) { +mutation UpdatePrivateMetadata($id: ID!, $input: [MetadataInput!]!) { updatePrivateMetadata(id: $id, input: $input) { item { privateMetadata { diff --git a/apps/taxes/graphql/mutations/UpdatePublicMetadata.graphql b/apps/taxes/graphql/mutations/UpdatePublicMetadata.graphql new file mode 100644 index 0000000..3fc5b65 --- /dev/null +++ b/apps/taxes/graphql/mutations/UpdatePublicMetadata.graphql @@ -0,0 +1,10 @@ +mutation UpdatePublicMetadata($id: ID!, $input: [MetadataInput!]!) { + updateMetadata(id: $id, input: $input) { + item { + metadata { + key + value + } + } + } +} diff --git a/apps/taxes/graphql/subscriptions/OrderCancelled.graphql b/apps/taxes/graphql/subscriptions/OrderCancelled.graphql new file mode 100644 index 0000000..c5ec2a7 --- /dev/null +++ b/apps/taxes/graphql/subscriptions/OrderCancelled.graphql @@ -0,0 +1,29 @@ +fragment OrderCancelledSubscription on Order { + id + avataxId: metafield(key: "avataxId") + channel { + id + slug + } +} + +fragment OrderCancelledEventSubscription on Event { + __typename + ... on OrderCancelled { + order { + ...OrderCancelledSubscription + } + recipient { + privateMetadata { + key + value + } + } + } +} + +subscription OrderCancelledSubscription { + event { + ...OrderCancelledEventSubscription + } +} diff --git a/apps/taxes/graphql/subscriptions/OrderConfirmed.graphql b/apps/taxes/graphql/subscriptions/OrderConfirmed.graphql new file mode 100644 index 0000000..889ff4a --- /dev/null +++ b/apps/taxes/graphql/subscriptions/OrderConfirmed.graphql @@ -0,0 +1,90 @@ +fragment OrderLine on OrderLine { + productSku + productName + quantity + taxClass { + id + } + unitPrice { + net { + amount + } + } + totalPrice { + net { + amount + } + tax { + amount + } + } +} + +fragment OrderConfirmedSubscription on Order { + id + number + user { + id + email + } + created + status + channel { + id + slug + } + shippingAddress { + ...Address + } + billingAddress { + ...Address + } + total { + currency + net { + amount + } + tax { + amount + } + } + shippingPrice { + gross { + amount + } + net { + amount + } + } + lines { + ...OrderLine + } + discounts { + id + amount { + amount + } + } + avataxEntityCode: metafield(key: "avataxEntityCode") + avataxTaxCalculationDate: metafield(key: "avataxTaxCalculationDate") + avataxDocumentCode: metafield(key: "avataxDocumentCode") +} +fragment OrderConfirmedEventSubscription on Event { + __typename + ... on OrderConfirmed { + order { + ...OrderConfirmedSubscription + } + } + recipient { + privateMetadata { + key + value + } + } +} +subscription OrderConfirmedSubscription { + event { + ...OrderConfirmedEventSubscription + } +} diff --git a/apps/taxes/graphql/subscriptions/OrderCreated.graphql b/apps/taxes/graphql/subscriptions/OrderCreated.graphql index 42fb244..54768ae 100644 --- a/apps/taxes/graphql/subscriptions/OrderCreated.graphql +++ b/apps/taxes/graphql/subscriptions/OrderCreated.graphql @@ -65,6 +65,8 @@ fragment OrderCreatedSubscription on Order { } } avataxEntityCode: metafield(key: "avataxEntityCode") + avataxTaxCalculationDate: metafield(key: "avataxTaxCalculationDate") + avataxDocumentCode: metafield(key: "avataxDocumentCode") } fragment OrderCreatedEventSubscription on Event { __typename diff --git a/apps/taxes/graphql/subscriptions/OrderFulfilled.graphql b/apps/taxes/graphql/subscriptions/OrderFulfilled.graphql index 4c9d0c8..a78a168 100644 --- a/apps/taxes/graphql/subscriptions/OrderFulfilled.graphql +++ b/apps/taxes/graphql/subscriptions/OrderFulfilled.graphql @@ -47,10 +47,7 @@ fragment OrderFulfilledSubscription on Order { amount } } - privateMetadata { - key - value - } + avataxId: metafield(key: "avataxId") lines { ...OrderLine } diff --git a/apps/taxes/package.json b/apps/taxes/package.json index 04e67a4..0f8f8a5 100644 --- a/apps/taxes/package.json +++ b/apps/taxes/package.json @@ -25,7 +25,7 @@ "@trpc/react-query": "10.34.0", "@trpc/server": "10.34.0", "@urql/exchange-auth": "^2.1.4", - "avatax": "^23.3.2", + "avatax": "^23.7.0", "clsx": "^1.2.1", "dotenv": "^16.3.1", "graphql": "16.7.1", diff --git a/apps/taxes/scripts/migrations/app-webhook-migrator.test.ts b/apps/taxes/scripts/migrations/app-webhook-migrator.test.ts new file mode 100644 index 0000000..8e392fd --- /dev/null +++ b/apps/taxes/scripts/migrations/app-webhook-migrator.test.ts @@ -0,0 +1,189 @@ +import { SaleorAsyncWebhook, SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { describe, it, vitest, expect } from "vitest"; +import { AppWebhookMigrator } from "./app-webhook-migrator"; +import { AppWebhookRepository } from "./app-webhook-repository"; + +describe("AppWebhookMigrator", () => { + describe("registerWebhookIfItDoesntExist", () => { + describe("in report mode", () => { + it("does not call create", async () => { + const createMock = vitest.fn(); + const appWebhookRepository = { + create: createMock, + getAll: () => [], + } as unknown as AppWebhookRepository; + + const appWebhookMigrator = new AppWebhookMigrator( + { + appWebhookRepository, + apiUrl: "apiUrl", + appId: "appId", + }, + { mode: "report" } + ); + + await appWebhookMigrator.registerWebhookIfItDoesntExist({} as SaleorAsyncWebhook); + + expect(createMock).not.toHaveBeenCalled(); + }); + }); + describe("in migrate mode", () => { + it("calls create", async () => { + const createMock = vitest.fn(); + const appWebhookRepository = { + create: createMock, + getAll: () => [], + } as unknown as AppWebhookRepository; + + const appWebhookMigrator = new AppWebhookMigrator( + { + appWebhookRepository, + apiUrl: "apiUrl", + appId: "appId", + }, + { mode: "migrate" } + ); + + const webhookHandler = { + getWebhookManifest: () => { + return { + name: "OrderCreated", + targetUrl: `targetUrl`, + query: "query", + asyncEvents: ["OrderCreated"], + }; + }, + } as unknown as SaleorAsyncWebhook; + + await appWebhookMigrator.registerWebhookIfItDoesntExist(webhookHandler); + + expect(createMock).toHaveBeenCalled(); + }); + }); + }); + describe("rollbackWebhookMigrations", () => { + describe("in report mode", () => { + it("does not call delete", async () => { + const deleteMock = vitest.fn(); + const appWebhookRepository = { + delete: deleteMock, + getAll: () => [], + } as unknown as AppWebhookRepository; + + const appWebhookMigrator = new AppWebhookMigrator( + { + appWebhookRepository, + apiUrl: "apiUrl", + appId: "appId", + }, + { mode: "report" } + ); + + await appWebhookMigrator.rollbackWebhookMigrations( + "OrderCreated", + {} as unknown as SaleorSyncWebhook + ); + + expect(deleteMock).not.toHaveBeenCalled(); + }); + }); + describe("in migrate mode", async () => { + const deleteMock = vitest.fn(); + const enableMock = vitest.fn(); + const appWebhookRepository = { + delete: deleteMock, + enable: enableMock, + getAll: () => [ + { + id: "id-1234", + name: "OrderConfirmed", + }, + + { + id: "id-5678", + name: "OrderCreated", + }, + { + id: "id", + name: "OrderUpdated", + }, + ], + } as unknown as AppWebhookRepository; + + const appWebhookMigrator = new AppWebhookMigrator( + { + appWebhookRepository, + apiUrl: "apiUrl", + appId: "appId", + }, + { mode: "migrate" } + ); + + await appWebhookMigrator.rollbackWebhookMigrations("OrderCreated", { + name: "OrderConfirmed", + targetUrl: `targetUrl`, + query: "query", + events: ["OrderConfirmed"], + } as unknown as SaleorSyncWebhook); + + it("calls delete with the id that matches the handler", async () => { + expect(deleteMock).toHaveBeenCalledWith("id-1234"); + }); + + it("calls enable with the id that matches the name of the previous webhook", async () => { + expect(enableMock).toHaveBeenCalledWith("id-5678"); + }); + }); + }); + describe("DANGEROUS_DELETE_APP_WEBHOOK_BY_NAME", () => { + describe("in report mode", () => { + it("does not call delete", async () => { + const deleteMock = vitest.fn(); + const appWebhookRepository = { + delete: deleteMock, + getAll: () => [], + } as unknown as AppWebhookRepository; + + const appWebhookMigrator = new AppWebhookMigrator( + { + appWebhookRepository, + apiUrl: "apiUrl", + appId: "appId", + }, + { mode: "report" } + ); + + await appWebhookMigrator.DANGEROUS_DELETE_APP_WEBHOOK_BY_NAME("OrderCreated"); + + expect(deleteMock).not.toHaveBeenCalled(); + }); + }); + describe("in migrate mode", () => { + it("calls delete", async () => { + const deleteMock = vitest.fn(); + const appWebhookRepository = { + delete: deleteMock, + getAll: () => [ + { + name: "OrderCreated", + id: "id-1234", + }, + ], + } as unknown as AppWebhookRepository; + + const appWebhookMigrator = new AppWebhookMigrator( + { + appWebhookRepository, + apiUrl: "apiUrl", + appId: "appId", + }, + { mode: "migrate" } + ); + + await appWebhookMigrator.DANGEROUS_DELETE_APP_WEBHOOK_BY_NAME("OrderCreated"); + + expect(deleteMock).toHaveBeenCalledWith("id-1234"); + }); + }); + }); +}); diff --git a/apps/taxes/scripts/migrations/app-webhook-migrator.ts b/apps/taxes/scripts/migrations/app-webhook-migrator.ts new file mode 100644 index 0000000..11bdfcc --- /dev/null +++ b/apps/taxes/scripts/migrations/app-webhook-migrator.ts @@ -0,0 +1,186 @@ +import { SaleorAsyncWebhook, SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { WebhookEventTypeAsyncEnum, WebhookEventTypeSyncEnum } from "../../generated/graphql"; +import { AppWebhookRepository } from "./app-webhook-repository"; +import { AuthData } from "@saleor/app-sdk/APL"; +import { createGraphQLClient } from "@saleor/apps-shared"; + +type AppWebhookMigratorOptions = { + mode: "report" | "migrate"; +}; + +export class AppWebhookMigrator { + private appWebhookRepository: AppWebhookRepository; + private appId: string; + private apiUrl: string; + private mode: AppWebhookMigratorOptions["mode"]; + + constructor( + { + appWebhookRepository, + apiUrl, + appId, + }: { + apiUrl: string; + appId: string; + appWebhookRepository: AppWebhookRepository; + }, + { mode }: AppWebhookMigratorOptions + ) { + this.appWebhookRepository = appWebhookRepository; + + this.appId = appId; + this.apiUrl = apiUrl; + this.mode = mode; + } + + private registerWebhookFromHandler(webhookHandler: SaleorSyncWebhook | SaleorAsyncWebhook) { + const manifest = webhookHandler.getWebhookManifest(this.apiUrl); + + if (!manifest.query) { + throw new Error("Webhook query is required"); + } + + if (!manifest.name) { + throw new Error("Webhook name is required"); + } + + return this.appWebhookRepository.create({ + appId: this.appId, + name: manifest.name, + query: manifest.query, + targetUrl: manifest.targetUrl, + asyncEvents: (manifest.asyncEvents ?? []) as WebhookEventTypeAsyncEnum[], + syncEvents: (manifest.syncEvents ?? []) as WebhookEventTypeSyncEnum[], + isActive: manifest.isActive ?? true, + }); + } + + private async deleteWebhookById(webhookId: string) { + console.log(`Webhook ${webhookId} will be deleted`); + + if (this.mode === "migrate") { + await this.appWebhookRepository.delete(webhookId); + + console.log(`Webhook ${webhookId} deleted`); + } + } + + private async disableWebhookById(webhookId: string) { + console.log(`Webhook ${webhookId} will be disabled`); + + if (this.mode === "migrate") { + await this.appWebhookRepository.disable(webhookId); + + console.log(`Webhook ${webhookId} disabled`); + } + } + + /** + * @returns all webhooks for the app + * @throws error if fetching webhooks fails + */ + async getAppWebhooks() { + const webhooks = await this.appWebhookRepository.getAll(); + + console.log(`Webhooks for app ${this.appId}: `, webhooks); + + return webhooks; + } + + private async disableFirstWebhookByName(webhookName: string) { + const webhooks = await this.getAppWebhooks(); + + const webhook = webhooks.find((webhook) => webhook.name === webhookName); + + if (!webhook) { + console.log(`Webhook ${webhookName} not found`); + + return; + } + + await this.disableWebhookById(webhook.id); + } + + /** + * Deletes first app webhook that matches the name. + * @param webhookName - name of the webhook to delete + */ + async DANGEROUS_DELETE_APP_WEBHOOK_BY_NAME(webhookName: string) { + const webhooks = await this.getAppWebhooks(); + + const webhook = webhooks.find((webhook) => webhook.name === webhookName); + + if (!webhook) { + console.log(`Webhook ${webhookName} not found`); + + return; + } + + await this.deleteWebhookById(webhook.id); + } + + /** + * Registers a webhook if it doesn't exist based on a handler. + * @param webhookHandler - The handler of the webhook we want to register. + * @example registerWebhookIfItDoesntExist(orderConfirmedAsyncWebhook) + */ + async registerWebhookIfItDoesntExist(webhookHandler: SaleorSyncWebhook | SaleorAsyncWebhook) { + const webhooks = await this.getAppWebhooks(); + + const webhookExists = webhooks.some((webhook) => webhook.name === webhookHandler.name); + + if (webhookExists) { + console.log(`Webhook ${webhookHandler.name} already exists`); + + return; + } + + console.log(`Webhook ${webhookHandler.name} will be registered`); + + if (this.mode === "migrate") { + await this.registerWebhookFromHandler(webhookHandler); + console.log(`Webhook ${webhookHandler.name} registered`); + } + } + + /** + * Rolls back webhook migration by deleting the new webhook and enabling the old one. + * @param prevWebhookName - The name of the webhook we wanted to migrate from. + * @param nextWebhookHandler - The handler of the webhook we wanted to migrate to. + * @example rollbackWebhookMigrations("OrderCreated", orderConfirmedAsyncWebhook) + */ + async rollbackWebhookMigrations( + prevWebhookName: string, + nextWebhookHandler: SaleorSyncWebhook | SaleorAsyncWebhook + ) { + const webhooks = await this.appWebhookRepository.getAll(); + + const webhooksToRemove = webhooks.filter((webhook) => webhook.name === nextWebhookHandler.name); + const webhooksToEnable = webhooks.filter((webhook) => webhook.name === prevWebhookName); + + for (const webhook of webhooksToRemove) { + await this.deleteWebhookById(webhook.id); + } + + for (const webhook of webhooksToEnable) { + await this.appWebhookRepository.enable(webhook.id); + } + } +} + +export function createAppWebhookMigrator(env: AuthData, options: AppWebhookMigratorOptions) { + const client = createGraphQLClient({ + saleorApiUrl: env.saleorApiUrl, + token: env.token, + }); + const appWebhookRepository = new AppWebhookRepository(client); + + return new AppWebhookMigrator( + { + apiUrl: env.saleorApiUrl, + appId: env.appId, + appWebhookRepository, + }, + options + ); +} diff --git a/apps/taxes/scripts/migrations/app-webhook-repository.test.ts b/apps/taxes/scripts/migrations/app-webhook-repository.test.ts new file mode 100644 index 0000000..c3e5a5a --- /dev/null +++ b/apps/taxes/scripts/migrations/app-webhook-repository.test.ts @@ -0,0 +1,60 @@ +import { Client } from "urql"; +import { AppWebhookRepository } from "./app-webhook-repository"; +import { expect, describe, it } from "vitest"; +import { CreateAppWebhookMutationVariables } from "../../generated/graphql"; + +describe("AppWebhookRepository", () => { + describe("getAll", () => { + it("throws error when error returned", async () => { + const client = { + query: () => ({ + toPromise: () => ({ + error: { + message: "error", + }, + }), + }), + } as unknown as Client; + + const appWebhookRepository = new AppWebhookRepository(client); + + await expect(appWebhookRepository.getAll()).rejects.toThrow(); + }); + }); + describe("delete", () => { + it("throws error when error returned", async () => { + const client = { + mutation: () => ({ + toPromise: () => ({ + error: { + message: "error", + }, + }), + }), + } as unknown as Client; + + const appWebhookRepository = new AppWebhookRepository(client); + + await expect(appWebhookRepository.delete("id")).rejects.toThrow(); + }); + }); + describe("create", () => { + it("throws error when error returned", async () => { + const client = { + mutation: () => ({ + toPromise: () => ({ + error: { + message: "error", + }, + }), + }), + } as unknown as Client; + + const appWebhookRepository = new AppWebhookRepository(client); + + await expect( + appWebhookRepository.create({} as unknown as CreateAppWebhookMutationVariables) + ).rejects.toThrow(); + }); + }); +}); diff --git a/apps/taxes/scripts/migrations/app-webhook-repository.ts b/apps/taxes/scripts/migrations/app-webhook-repository.ts new file mode 100644 index 0000000..e0d58b2 --- /dev/null +++ b/apps/taxes/scripts/migrations/app-webhook-repository.ts @@ -0,0 +1,169 @@ +import { createGraphQLClient } from "@saleor/apps-shared"; +import { Client, gql } from "urql"; +import { + CreateAppWebhookDocument, + CreateAppWebhookMutation, + CreateAppWebhookMutationVariables, + DeleteAppWebhookDocument, + DeleteAppWebhookMutation, + DeleteAppWebhookMutationVariables, + DisableWebhookDocument, + DisableWebhookMutation, + DisableWebhookMutationVariables, + EnableWebhookDocument, + EnableWebhookMutation, + EnableWebhookMutationVariables, + FetchAppWebhooksDocument, + FetchAppWebhooksQuery, +} from "../../generated/graphql"; + +gql` + query FetchAppWebhooks { + app { + webhooks { + id + name + } + } + } +`; + +gql` + mutation CreateAppWebhook( + $appId: ID! + $name: String! + $targetUrl: String! + $query: String + $isActive: Boolean! + $asyncEvents: [WebhookEventTypeAsyncEnum!] + $syncEvents: [WebhookEventTypeSyncEnum!] + ) { + webhookCreate( + input: { + app: $appId + name: $name + targetUrl: $targetUrl + query: $query + isActive: $isActive + asyncEvents: $asyncEvents + syncEvents: $syncEvents + } + ) { + webhook { + id + } + } + } +`; + +gql` + mutation DeleteAppWebhook($id: ID!) { + webhookDelete(id: $id) { + webhook { + id + } + } + } +`; + +gql` + mutation DisableWebhook($id: ID!) { + webhookUpdate(id: $id, input: { isActive: false }) { + webhook { + id + } + } + } +`; + +gql` + mutation EnableWebhook($id: ID!) { + webhookUpdate(id: $id, input: { isActive: true }) { + webhook { + id + } + } + } +`; + +export class AppWebhookRepository { + constructor(private client: Client) {} + + async getAll() { + const { error, data } = await this.client + .query(FetchAppWebhooksDocument, {}) + .toPromise(); + + if (error) { + console.log("Was not able to fetch app webhooks", error.message); + + throw error; + } + + return data?.app?.webhooks ?? []; + } + + async create(variables: CreateAppWebhookMutationVariables) { + const { error, data } = await this.client + .mutation(CreateAppWebhookDocument, variables) + .toPromise(); + + if (error) { + console.log(`Was not able to create webhook for the app ${variables.appId}`, error.message); + + throw error; + } + + return data?.webhookCreate?.webhook?.id; + } + + async disable(id: string) { + const { error, data } = await this.client + .mutation(DisableWebhookDocument, { + id, + } as DisableWebhookMutationVariables) + .toPromise(); + + if (error) { + console.log(`Was not able to disable webhook ${id}`, error.message); + + throw error; + } + + return data?.webhookUpdate?.webhook?.id; + } + + async enable(id: string) { + const { error, data } = await this.client + .mutation(EnableWebhookDocument, { + id, + } as EnableWebhookMutationVariables) + .toPromise(); + + if (error) { + console.log(`Was not able to enable webhook ${id}`, error.message); + + throw error; + } + + return data?.webhookUpdate?.webhook?.id; + } + + async delete(id: string) { + const { error, data } = await this.client + .mutation(DeleteAppWebhookDocument, { + id, + } as DeleteAppWebhookMutationVariables) + .toPromise(); + + console.log(data, error); + + if (error) { + console.log(`Was not able to delete webhook ${id}`, error.message); + + throw error; + } + + return data?.webhookDelete?.webhook?.id; + } +} diff --git a/apps/taxes/scripts/migrations/migration-utils.ts b/apps/taxes/scripts/migrations/migration-utils.ts new file mode 100644 index 0000000..d232873 --- /dev/null +++ b/apps/taxes/scripts/migrations/migration-utils.ts @@ -0,0 +1,31 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ + +import { SaleorCloudAPL } from "@saleor/app-sdk/APL"; +import { createSettingsManager } from "../../src/modules/app/metadata-manager"; +import { createGraphQLClient } from "@saleor/apps-shared"; + +export const getMetadataManagerForEnv = (apiUrl: string, appToken: string, appId: string) => { + const client = createGraphQLClient({ + saleorApiUrl: apiUrl, + token: appToken, + }); + + return createSettingsManager(client, appId); +}; + +export const verifyRequiredEnvs = () => { + const requiredEnvs = ["SALEOR_CLOUD_TOKEN", "SALEOR_CLOUD_RESOURCE_URL", "SECRET_KEY"]; + + if (!requiredEnvs.every((env) => process.env[env])) { + throw new Error(`Missing envs: ${requiredEnvs.join(" | ")}`); + } +}; + +export const fetchCloudAplEnvs = () => { + const saleorAPL = new SaleorCloudAPL({ + token: process.env.SALEOR_CLOUD_TOKEN!, + resourceUrl: process.env.SALEOR_CLOUD_RESOURCE_URL!, + }); + + return saleorAPL.getAll(); +}; diff --git a/apps/taxes/scripts/migrations/run-migration.ts b/apps/taxes/scripts/migrations/run-migration.ts new file mode 100644 index 0000000..c087370 --- /dev/null +++ b/apps/taxes/scripts/migrations/run-migration.ts @@ -0,0 +1,38 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ + +import * as dotenv from "dotenv"; +import { createAppWebhookMigrator } from "./app-webhook-migrator"; +import { fetchCloudAplEnvs, verifyRequiredEnvs } from "./migration-utils"; +import { migrateTaxes } from "./taxes-migration"; + +dotenv.config(); + +const runMigration = async () => { + console.log("Starting runMigration"); + + verifyRequiredEnvs(); + + console.log("Env vars verified. Fetching envs"); + + const allEnvs = await fetchCloudAplEnvs().catch((r) => { + console.error(r); + + process.exit(1); + }); + + for (const env of allEnvs) { + try { + console.log("--------------------"); + console.log(`Working on app: ${env.appId} on domain ${env.domain}`); + + const webhookMigrator = createAppWebhookMigrator(env, { mode: "migrate" }); + + await migrateTaxes(webhookMigrator); + } catch (error) { + console.log("Error while migrating webhook. Continuing with the next app."); + continue; + } + } +}; + +runMigration(); diff --git a/apps/taxes/scripts/migrations/run-report.ts b/apps/taxes/scripts/migrations/run-report.ts new file mode 100644 index 0000000..0a9f3a3 --- /dev/null +++ b/apps/taxes/scripts/migrations/run-report.ts @@ -0,0 +1,38 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ + +import * as dotenv from "dotenv"; +import { createAppWebhookMigrator } from "./app-webhook-migrator"; +import { fetchCloudAplEnvs, verifyRequiredEnvs } from "./migration-utils"; +import { migrateTaxes } from "./taxes-migration"; + +dotenv.config(); + +const runReport = async () => { + console.log("Starting runReport"); + + verifyRequiredEnvs(); + + console.log("Env vars verified. Fetching envs"); + + const allEnvs = await fetchCloudAplEnvs().catch((r) => { + console.error(r); + + process.exit(1); + }); + + for (const env of allEnvs) { + try { + console.log("--------------------"); + console.log(`Working on app: ${env.appId} on domain ${env.domain}`); + + const webhookMigrator = createAppWebhookMigrator(env, { mode: "report" }); + + await migrateTaxes(webhookMigrator); + } catch (error) { + console.log("Error while migrating webhook. Continuing with the next app."); + continue; + } + } +}; + +runReport(); diff --git a/apps/taxes/scripts/migrations/taxes-migration.ts b/apps/taxes/scripts/migrations/taxes-migration.ts new file mode 100644 index 0000000..954c886 --- /dev/null +++ b/apps/taxes/scripts/migrations/taxes-migration.ts @@ -0,0 +1,27 @@ +/* eslint-disable multiline-comment-style */ +import { orderConfirmedAsyncWebhook } from "../../src/pages/api/webhooks/order-confirmed"; +import { AppWebhookMigrator } from "./app-webhook-migrator"; + +/** + * Contains the migration logic for the Taxes App. In the 1st step, it is expected to only write, not delete. The cleanup will be done in the 2nd step. + * @param webhookMigrator - The AppWebhookMigrator instance. + */ +export async function migrateTaxes(webhookMigrator: AppWebhookMigrator) { + // Migration plan: + // 1st step + // 1. Create new ORDER_CONFIRMED webhooks for each Taxes App. + await webhookMigrator.registerWebhookIfItDoesntExist(orderConfirmedAsyncWebhook); + + // 2. To confirm if everything is working as expected, we can get all webhooks for apps and check if the ORDER_CONFIRMED webhooks were created. + // await webhookMigrator.getAppWebhooks(); + + // 3. If something went wrong, we can roll back the migration by uncommenting this line: + // await webhookMigrator.DANGEROUS_DELETE_APP_WEBHOOK_BY_NAME(orderConfirmedAsyncWebhook.name); + // It will delete the ORDER_CONFIRMED webhooks created above. + + // 2nd step (after two weeks) + // 1. Comment the 1st step code above. + // 2. Delete the ORDER_CREATED and ORDER_FULFILLED webhooks by uncommenting this line: + // await webhookMigrator.DANGEROUS_DELETE_APP_WEBHOOK_BY_NAME("OrderCreated"); + // await webhookMigrator.DANGEROUS_DELETE_APP_WEBHOOK_BY_NAME("OrderFulfilled"); +} diff --git a/apps/taxes/src/mocks.ts b/apps/taxes/src/mocks.ts index cadb72c..19e56c2 100644 --- a/apps/taxes/src/mocks.ts +++ b/apps/taxes/src/mocks.ts @@ -1,11 +1,12 @@ -import { OrderCreatedSubscriptionFragment, OrderStatus } from "../generated/graphql"; +import { OrderConfirmedSubscriptionFragment, OrderStatus } from "../generated/graphql"; -export const defaultOrder: OrderCreatedSubscriptionFragment = { +export const defaultOrder: OrderConfirmedSubscriptionFragment = { id: "T3JkZXI6ZTUzZTBlM2MtMjk5Yi00OWYxLWIyZDItY2Q4NWExYTgxYjY2", user: { id: "VXNlcjoyMDg0NTEwNDEw", email: "happy.customer@saleor.io", }, + number: "1234", avataxEntityCode: null, created: "2023-05-25T09:18:55.203440+00:00", status: OrderStatus.Unfulfilled, diff --git a/apps/taxes/src/modules/app/metadata-manager.ts b/apps/taxes/src/modules/app/metadata-manager.ts index 988177c..947a342 100644 --- a/apps/taxes/src/modules/app/metadata-manager.ts +++ b/apps/taxes/src/modules/app/metadata-manager.ts @@ -3,7 +3,7 @@ import { Client, gql } from "urql"; import { FetchAppDetailsDocument, FetchAppDetailsQuery, - UpdateMetadataDocument, + UpdatePrivateMetadataDocument, } from "../../../generated/graphql"; gql` @@ -45,7 +45,7 @@ export async function fetchAllMetadata(client: Client): Promise export async function mutateMetadata(client: Client, metadata: MetadataEntry[], appId: string) { const { error: mutationError, data: mutationData } = await client - .mutation(UpdateMetadataDocument, { + .mutation(UpdatePrivateMetadataDocument, { id: appId, input: metadata, }) diff --git a/apps/taxes/src/modules/app/order-metadata-manager.ts b/apps/taxes/src/modules/app/order-metadata-manager.ts new file mode 100644 index 0000000..948a12a --- /dev/null +++ b/apps/taxes/src/modules/app/order-metadata-manager.ts @@ -0,0 +1,45 @@ +import { Client } from "urql"; +import { + UpdatePublicMetadataDocument, + UpdatePublicMetadataMutation, + UpdatePublicMetadataMutationVariables, +} from "../../../generated/graphql"; + +const PROVIDER_ORDER_ID_KEY = "avataxId"; + +export class OrderMetadataManager { + private privateOrderIdKey = PROVIDER_ORDER_ID_KEY; + + constructor(private client: Client) {} + + /** + * We need to store the provider order id in the Saleor order metadata so that we can + * update the provider order when the Saleor order is fulfilled. + */ + /** + * + * @param orderId - Saleor order id + * @param externalId - Provider order id + * @deprecated - This will not be needed when we move to the new webhook flow because the transactions will be commited during OrderConfirmed + */ + async updateOrderMetadataWithExternalId(orderId: string, externalId: string) { + const variables: UpdatePublicMetadataMutationVariables = { + id: orderId, + input: [ + { + key: this.privateOrderIdKey, + value: externalId, + }, + ], + }; + const { error } = await this.client + .mutation(UpdatePublicMetadataDocument, variables) + .toPromise(); + + if (error) { + throw error; + } + + return { ok: true }; + } +} diff --git a/apps/taxes/src/modules/app/webhook-response.ts b/apps/taxes/src/modules/app/webhook-response.ts index 57ac629..ffe6ee8 100644 --- a/apps/taxes/src/modules/app/webhook-response.ts +++ b/apps/taxes/src/modules/app/webhook-response.ts @@ -1,36 +1,58 @@ import { NextApiResponse } from "next"; +import { AvalaraError } from "avatax/lib/AvaTaxClient"; +import { ZodError } from "zod"; import { createLogger, Logger } from "../../lib/logger"; +class WebhookErrorResolver { + private logger: Logger; + constructor() { + this.logger = createLogger({ event: "WebhookErrorResolver" }); + } + + private resolveErrorMessage(error: unknown) { + if (error instanceof ZodError) { + this.logger.error(error.message, "Unexpected Zod error caught:"); + this.logger.debug(error.stack, "Error details:"); + return error.message; + } + + if (error instanceof AvalaraError) { + this.logger.error(error.message, "Unexpected Avalara error caught:"); + this.logger.debug(error.stack, "Error stack:"); + this.logger.debug(error.target, "Error target:"); + return error.message; + } + + if (error instanceof Error) { + this.logger.error(error.stack, "Unexpected error caught:"); + return error.message; + } + + return "Internal server error"; + } + + resolve(error: unknown) { + return this.resolveErrorMessage(error); + } +} + export class WebhookResponse { private logger: Logger; constructor(private res: NextApiResponse) { this.logger = createLogger({ event: "WebhookResponse" }); } - private returnSuccess(data?: unknown) { - this.logger.debug({ data }, "Responding to Saleor with data:"); - return this.res.status(200).json(data ?? {}); - } + error(error: unknown) { + const errorResolver = new WebhookErrorResolver(); + const errorMessage = errorResolver.resolve(error); - private returnError(errorMessage: string) { this.logger.debug({ errorMessage }, "Responding to Saleor with error:"); + return this.res.status(500).json({ error: errorMessage }); } - private resolveError(error: unknown) { - if (error instanceof Error) { - this.logger.error(error.stack, "Unexpected error caught:"); - return this.returnError(error.message); - } - return this.returnError("Internal server error"); - } - - error(error: unknown) { - return this.resolveError(error); - } - success(data?: unknown) { - return this.returnSuccess(data); + return this.res.status(200).json(data ?? {}); } } diff --git a/apps/taxes/src/modules/avatax/avatax-calculation-date-resolver.test.ts b/apps/taxes/src/modules/avatax/avatax-calculation-date-resolver.test.ts new file mode 100644 index 0000000..31dc162 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-calculation-date-resolver.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { OrderConfirmedSubscriptionFragment } from "../../../generated/graphql"; +import { AvataxCalculationDateResolver } from "./avatax-calculation-date-resolver"; + +const resolver = new AvataxCalculationDateResolver(); + +describe("AvataxCalculationDateResolver", () => { + it("should return the metadata tax calculation date if it is set", () => { + const order = { + avataxTaxCalculationDate: "2021-01-01T00:00:00.000Z", + created: "2021-01-02T00:00:00.000Z", + } as any as OrderConfirmedSubscriptionFragment; + + expect(resolver.resolve(order.avataxTaxCalculationDate, order.created)).toEqual( + new Date("2021-01-01T00:00:00.000Z") + ); + }); + it("should fallback to order created when metadata tax calculation date is not a string datetime", () => { + const order = { + avataxTaxCalculationDate: "not-a-datetime", + created: "2021-01-02T00:00:00.000Z", + } as any as OrderConfirmedSubscriptionFragment; + + expect(resolver.resolve(order.avataxTaxCalculationDate, order.created)).toEqual( + new Date("2021-01-02T00:00:00.000Z") + ); + }); + it("should return the order creation date if the metadata tax calculation date is not set", () => { + const order = { + created: "2021-01-02T00:00:00.000Z", + } as any as OrderConfirmedSubscriptionFragment; + + expect(resolver.resolve(order.avataxTaxCalculationDate, order.created)).toEqual( + new Date("2021-01-02T00:00:00.000Z") + ); + }); +}); diff --git a/apps/taxes/src/modules/avatax/avatax-calculation-date-resolver.ts b/apps/taxes/src/modules/avatax/avatax-calculation-date-resolver.ts new file mode 100644 index 0000000..9d18769 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-calculation-date-resolver.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { createLogger } from "../../lib/logger"; + +export class AvataxCalculationDateResolver { + private logger = createLogger({ + name: "AvataxCalculationDateResolver", + }); + + resolve(avataxTaxCalculationDate: string | null | undefined, orderCreatedDate: string): Date { + if (!avataxTaxCalculationDate) { + this.logger.info("No tax calculation date provided. Falling back to order created date."); + return new Date(orderCreatedDate); + } + + // UTC datetime string, e.g. "2021-08-31T13:00:00.000Z" + const taxCalculationParse = z.string().datetime().safeParse(avataxTaxCalculationDate); + + if (taxCalculationParse.success) { + // The user is able to pass other tax calculation date than the order creation date. + this.logger.info( + "Valid UTC tax calculation date found in metadata. Using it for tax calculation." + ); + return new Date(taxCalculationParse.data); + } else { + this.logger.warn( + `The tax calculation date ${avataxTaxCalculationDate} is not a valid UTC datetime. Falling back to order created date.` + ); + + return new Date(orderCreatedDate); + } + } +} diff --git a/apps/taxes/src/modules/avatax/avatax-client.ts b/apps/taxes/src/modules/avatax/avatax-client.ts index 53631d1..ed6b6c6 100644 --- a/apps/taxes/src/modules/avatax/avatax-client.ts +++ b/apps/taxes/src/modules/avatax/avatax-client.ts @@ -1,10 +1,11 @@ import Avatax from "avatax"; import { DocumentType } from "avatax/lib/enums/DocumentType"; +import { VoidReasonCode } from "avatax/lib/enums/VoidReasonCode"; import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo"; import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel"; import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel"; +import { LogOptions } from "avatax/lib/utils/logger"; import packageJson from "../../../package.json"; -import { createLogger, Logger } from "../../lib/logger"; import { AvataxClientTaxCodeService } from "./avatax-client-tax-code.service"; import { BaseAvataxConfig } from "./avatax-connection-schema"; @@ -14,11 +15,7 @@ type AvataxSettings = { environment: "sandbox" | "production"; machineName: string; timeout: number; - logOptions?: { - logEnabled: boolean; - logLevel: number; - logRequestAndResponseInfo: boolean; - }; + logOptions?: LogOptions; }; const defaultAvataxSettings: AvataxSettings = { @@ -53,12 +50,15 @@ export type ValidateAddressArgs = { address: AvataxAddress; }; +export type VoidTransactionArgs = { + transactionCode: string; + companyCode: string; +}; + export class AvataxClient { private client: Avatax; - private logger: Logger; constructor(baseConfig: BaseAvataxConfig) { - this.logger = createLogger({ name: "AvataxClient" }); const settings = createAvataxSettings({ isSandbox: baseConfig.isSandbox }); const avataxClient = new Avatax(settings).withSecurity(baseConfig.credentials); @@ -66,13 +66,32 @@ export class AvataxClient { } async createTransaction({ model }: CreateTransactionArgs) { - return this.client.createTransaction({ model }); + /* + * We use createOrAdjustTransaction instead of createTransaction because + * we must guarantee a way of idempotent update of the transaction due to the + * migration requirements. The transaction can be created in the old flow, but committed in the new flow. + */ + return this.client.createOrAdjustTransaction({ model: { createTransactionModel: model } }); } async commitTransaction(args: CommitTransactionArgs) { return this.client.commitTransaction(args); } + async voidTransaction({ + transactionCode, + companyCode, + }: { + transactionCode: string; + companyCode: string; + }) { + return this.client.voidTransaction({ + transactionCode, + companyCode, + model: { code: VoidReasonCode.DocVoided }, + }); + } + async validateAddress({ address }: ValidateAddressArgs) { return this.client.resolveAddress(address); } diff --git a/apps/taxes/src/modules/avatax/avatax-connection-schema.ts b/apps/taxes/src/modules/avatax/avatax-connection-schema.ts index b9cb731..409b6fc 100644 --- a/apps/taxes/src/modules/avatax/avatax-connection-schema.ts +++ b/apps/taxes/src/modules/avatax/avatax-connection-schema.ts @@ -24,7 +24,7 @@ export type BaseAvataxConfig = z.infer; export const avataxConfigSchema = z .object({ name: z.string().min(1, { message: "Name requires at least one character." }), - companyCode: z.string().optional(), + companyCode: z.string().min(1, { message: "Company code requires at least one character." }), isAutocommit: z.boolean(), shippingTaxCode: z.string().optional(), isDocumentRecordingEnabled: z.boolean().default(true), @@ -36,7 +36,7 @@ export type AvataxConfig = z.infer; export const defaultAvataxConfig: AvataxConfig = { name: "", - companyCode: "", + companyCode: "DEFAULT", isSandbox: false, isAutocommit: false, isDocumentRecordingEnabled: true, diff --git a/apps/taxes/src/modules/avatax/avatax-document-code-resolver.test.ts b/apps/taxes/src/modules/avatax/avatax-document-code-resolver.test.ts new file mode 100644 index 0000000..457f64d --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-document-code-resolver.test.ts @@ -0,0 +1,37 @@ +import { OrderConfirmedSubscriptionFragment } from "../../../generated/graphql"; +import { AvataxDocumentCodeResolver } from "./avatax-document-code-resolver"; +import { expect, describe, it } from "vitest"; + +const resolver = new AvataxDocumentCodeResolver(); + +describe("AvataxDocumentCodeResolver", () => { + it("returns document code when provided in metadata", () => { + const order = { + id: "id", + avataxDocumentCode: "123", + } as unknown as OrderConfirmedSubscriptionFragment; + + expect( + resolver.resolve({ avataxDocumentCode: order.avataxDocumentCode, orderId: order.id }) + ).toBe("123"); + }); + it("returns order id when document code is not provided in metadata", () => { + const order = { + id: "id", + } as unknown as OrderConfirmedSubscriptionFragment; + + expect( + resolver.resolve({ avataxDocumentCode: order.avataxDocumentCode, orderId: order.id }) + ).toBe("id"); + }); + it("returns sliced document code when avataxDocumentCode too long", () => { + expect( + resolver.resolve({ avataxDocumentCode: "123456789012345678901234567890", orderId: "id" }) + ).toBe("12345678901234567890"); + }); + it("returns sliced document code when orderId too long", () => { + expect( + resolver.resolve({ avataxDocumentCode: null, orderId: "123456789012345678901234567890" }) + ).toBe("12345678901234567890"); + }); +}); diff --git a/apps/taxes/src/modules/avatax/avatax-document-code-resolver.ts b/apps/taxes/src/modules/avatax/avatax-document-code-resolver.ts new file mode 100644 index 0000000..39c8be3 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-document-code-resolver.ts @@ -0,0 +1,23 @@ +export class AvataxDocumentCodeResolver { + resolve({ + avataxDocumentCode, + orderId, + }: { + avataxDocumentCode: string | null | undefined; + orderId: string; + }): string { + /* + * The value for "code" can be provided in the metadata. + * Read more: https://developer.avalara.com/erp-integration-guide/sales-tax-badge/transactions/cert-document-codes/ + * The returned document code must be idempotent (same document code for the same order). + */ + + const code = avataxDocumentCode ?? orderId; + + /* + * The requirement from Avatax API is that document code is a string that must be between 1 and 20 characters long. + * // todo: document that its sliced + */ + return code.slice(0, 20); + } +} diff --git a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts index 6def76b..8a876b4 100644 --- a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts +++ b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts @@ -1,14 +1,18 @@ import { AuthData } from "@saleor/app-sdk/APL"; import { + OrderConfirmedSubscriptionFragment, OrderCreatedSubscriptionFragment, OrderFulfilledSubscriptionFragment, TaxBaseFragment, } from "../../../generated/graphql"; import { Logger, createLogger } from "../../lib/logger"; +import { OrderCancelledPayload } from "../../pages/api/webhooks/order-cancelled"; import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; import { AvataxClient } from "./avatax-client"; import { AvataxConfig, defaultAvataxConfig } from "./avatax-connection-schema"; import { AvataxCalculateTaxesAdapter } from "./calculate-taxes/avatax-calculate-taxes-adapter"; +import { AvataxOrderCancelledAdapter } from "./order-cancelled/avatax-order-cancelled-adapter"; +import { AvataxOrderConfirmedAdapter } from "./order-confirmed/avatax-order-confirmed-adapter"; import { AvataxOrderCreatedAdapter } from "./order-created/avatax-order-created-adapter"; import { AvataxOrderFulfilledAdapter } from "./order-fulfilled/avatax-order-fulfilled-adapter"; @@ -35,14 +39,17 @@ export class AvataxWebhookService implements ProviderWebhookService { return response; } - async createOrder(order: OrderCreatedSubscriptionFragment) { - const adapter = new AvataxOrderCreatedAdapter(this.config, this.authData); + async confirmOrder(order: OrderConfirmedSubscriptionFragment) { + const adapter = new AvataxOrderConfirmedAdapter(this.config, this.authData); const response = await adapter.send({ order }); return response; } + /** + * @deprecated This method is deprecated and will be removed in the future. + */ async fulfillOrder(order: OrderFulfilledSubscriptionFragment) { const adapter = new AvataxOrderFulfilledAdapter(this.config); @@ -50,4 +57,21 @@ export class AvataxWebhookService implements ProviderWebhookService { return response; } + + async cancelOrder(payload: OrderCancelledPayload) { + const adapter = new AvataxOrderCancelledAdapter(this.config); + + await adapter.send(payload); + } + + /** + * @deprecated This method is deprecated and will be removed in the future. + */ + async createOrder(payload: OrderCreatedSubscriptionFragment) { + const adapter = new AvataxOrderCreatedAdapter(this.config, this.authData); + + const response = await adapter.send({ order: payload }); + + return response; + } } 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 630d682..97f4c9e 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 @@ -3,7 +3,7 @@ import { TaxBaseFragment } from "../../../../generated/graphql"; import { discountUtils } from "../../taxes/discount-utils"; import { avataxAddressFactory } from "../address-factory"; import { AvataxClient, CreateTransactionArgs } from "../avatax-client"; -import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema"; import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer"; import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher"; @@ -34,7 +34,7 @@ export class AvataxCalculateTaxesPayloadTransformer { type: this.matchDocumentType(avataxConfig), entityUseCode, customerCode: taxBase.sourceObject.user?.id ?? "", - companyCode: avataxConfig.companyCode, + companyCode: avataxConfig.companyCode ?? defaultAvataxConfig.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: avataxConfig.isAutocommit, addresses: { diff --git a/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-adapter.ts b/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-adapter.ts new file mode 100644 index 0000000..ffb2135 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-adapter.ts @@ -0,0 +1,31 @@ +import { Logger, createLogger } from "../../../lib/logger"; +import { OrderCancelledPayload } from "../../../pages/api/webhooks/order-cancelled"; +import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; +import { AvataxClient, VoidTransactionArgs } from "../avatax-client"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxOrderCancelledPayloadTransformer } from "./avatax-order-cancelled-payload-transformer"; + +export type AvataxOrderCancelledTarget = VoidTransactionArgs; + +export class AvataxOrderCancelledAdapter implements WebhookAdapter { + private logger: Logger; + + constructor(private readonly config: AvataxConfig) { + this.logger = createLogger({ name: "AvataxOrderCancelledAdapter" }); + } + + async send(payload: OrderCancelledPayload) { + this.logger.debug("Transforming the Saleor payload for cancelling transaction with Avatax..."); + + const payloadTransformer = new AvataxOrderCancelledPayloadTransformer(this.config); + const target = payloadTransformer.transform({ ...payload }); + + this.logger.debug("Calling Avatax voidTransaction with transformed payload..."); + + const client = new AvataxClient(this.config); + + await client.voidTransaction(target); + + this.logger.debug(`Succesfully voided the transaction of id: ${target.transactionCode}`); + } +} diff --git a/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-payload-transformer.test.ts b/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-payload-transformer.test.ts new file mode 100644 index 0000000..a8f16d2 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-payload-transformer.test.ts @@ -0,0 +1,30 @@ +import { OrderCancelledPayload } from "../../../pages/api/webhooks/order-cancelled"; +import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator"; +import { AvataxOrderCancelledPayloadTransformer } from "./avatax-order-cancelled-payload-transformer"; +import { describe, expect, it } from "vitest"; + +const configMockGenerator = new AvataxConfigMockGenerator(); +const avataxMockConfig = configMockGenerator.generateAvataxConfig(); + +describe("AvataxOrderCancelledPayloadTransformer", () => { + it("throws an error when order = null", () => { + const payload = { order: null } as any as OrderCancelledPayload; + const transformer = new AvataxOrderCancelledPayloadTransformer(avataxMockConfig); + + expect(() => transformer.transform(payload)).toThrow("Order is required"); + }); + it("throws an error when no avataxId is present", () => { + const payload = { order: {} } as any as OrderCancelledPayload; + const transformer = new AvataxOrderCancelledPayloadTransformer(avataxMockConfig); + + expect(() => transformer.transform(payload)).toThrow(); + }); + it("returns a valid AvataxOrderCancelledTarget", () => { + const payload = { order: { avataxId: "123" } } as any as OrderCancelledPayload; + const transformer = new AvataxOrderCancelledPayloadTransformer(avataxMockConfig); + + const target = transformer.transform(payload); + + expect(target).toEqual({ transactionCode: "123", companyCode: "DEFAULT" }); + }); +}); diff --git a/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-payload-transformer.ts b/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-payload-transformer.ts new file mode 100644 index 0000000..c103465 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-cancelled/avatax-order-cancelled-payload-transformer.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { OrderCancelledPayload } from "../../../pages/api/webhooks/order-cancelled"; +import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema"; +import { AvataxOrderCancelledTarget } from "./avatax-order-cancelled-adapter"; + +export class AvataxOrderCancelledPayloadTransformer { + constructor(private readonly config: AvataxConfig) {} + + transform({ order }: OrderCancelledPayload): AvataxOrderCancelledTarget { + if (!order) { + throw new Error("Order is required"); + } + + const transactionCode = z.string().min(1).parse(order.avataxId); + + return { + transactionCode, + companyCode: this.config.companyCode ?? defaultAvataxConfig.companyCode, + }; + } +} diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-adapter.test.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-adapter.test.ts new file mode 100644 index 0000000..575e37f --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-adapter.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "vitest"; + +describe("AvataxOrderConfirmedAdapter", () => { + // ? how to mock internal call to avatax? + it.todo("calls avatax client", () => {}); +}); diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-adapter.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-adapter.ts new file mode 100644 index 0000000..d930ec3 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-adapter.ts @@ -0,0 +1,45 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { OrderConfirmedSubscriptionFragment } from "../../../../generated/graphql"; +import { Logger, createLogger } from "../../../lib/logger"; +import { CreateOrderResponse } from "../../taxes/tax-provider-webhook"; +import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; +import { AvataxClient } from "../avatax-client"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxOrderConfirmedPayloadService } from "./avatax-order-confirmed-payload.service"; +import { AvataxOrderConfirmedResponseTransformer } from "./avatax-order-confirmed-response-transformer"; + +type AvataxOrderConfirmedPayload = { + order: OrderConfirmedSubscriptionFragment; +}; +type AvataxOrderConfirmedResponse = CreateOrderResponse; + +export class AvataxOrderConfirmedAdapter + implements WebhookAdapter +{ + private logger: Logger; + + constructor(private readonly config: AvataxConfig, private authData: AuthData) { + this.logger = createLogger({ name: "AvataxOrderConfirmedAdapter" }); + } + + async send(payload: AvataxOrderConfirmedPayload): Promise { + this.logger.debug("Transforming the Saleor payload for creating order with Avatax..."); + + const payloadService = new AvataxOrderConfirmedPayloadService(this.authData); + const target = await payloadService.getPayload(payload.order, this.config); + + this.logger.debug("Calling Avatax createTransaction with transformed payload..."); + + const client = new AvataxClient(this.config); + const response = await client.createTransaction(target); + + this.logger.debug("Avatax createTransaction successfully responded"); + + const responseTransformer = new AvataxOrderConfirmedResponseTransformer(); + const transformedResponse = responseTransformer.transform(response); + + this.logger.debug("Transformed Avatax createTransaction response"); + + return transformedResponse; + } +} diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-mock-generator.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-mock-generator.ts new file mode 100644 index 0000000..68b069b --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-mock-generator.ts @@ -0,0 +1,56 @@ +import { TransactionModel } from "avatax/lib/models/TransactionModel"; +import { OrderConfirmedSubscriptionFragment } from "../../../../generated/graphql"; +import { ChannelConfig } from "../../channel-configuration/channel-config"; +import { orderConfirmedTransactionMock } from "./avatax-order-confirmed-response-transaction-mock"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { defaultOrder } from "../../../mocks"; +import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator"; + +const defaultChannelConfig: ChannelConfig = { + id: "1", + config: { + providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50", + slug: "default-channel", + }, +}; + +const defaultOrderConfirmedResponse: TransactionModel = orderConfirmedTransactionMock; + +const testingScenariosMap = { + default: { + order: defaultOrder, + channelConfig: defaultChannelConfig, + response: defaultOrderConfirmedResponse, + }, +}; + +type TestingScenario = keyof typeof testingScenariosMap; + +export class AvataxOrderConfirmedMockGenerator { + constructor(private scenario: TestingScenario = "default") {} + generateOrder = ( + overrides: Partial = {} + ): OrderConfirmedSubscriptionFragment => + structuredClone({ + ...testingScenariosMap[this.scenario].order, + ...overrides, + }); + + generateChannelConfig = (overrides: Partial = {}): ChannelConfig => + structuredClone({ + ...testingScenariosMap[this.scenario].channelConfig, + ...overrides, + }); + + generateAvataxConfig = (overrides: Partial = {}): AvataxConfig => { + const mockGenerator = new AvataxConfigMockGenerator(); + + return mockGenerator.generateAvataxConfig(overrides); + }; + + generateResponse = (overrides: Partial = {}): TransactionModel => + structuredClone({ + ...testingScenariosMap[this.scenario].response, + ...overrides, + }); +} diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-lines-transformer.test.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-lines-transformer.test.ts new file mode 100644 index 0000000..0aa702d --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-lines-transformer.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { AvataxOrderConfirmedPayloadLinesTransformer } from "./avatax-order-confirmed-payload-lines-transformer"; +import { avataxConfigMock } from "./avatax-order-confirmed-payload-transformer.test"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; +import { AvataxOrderConfirmedMockGenerator } from "./avatax-order-confirmed-mock-generator"; + +const linesTransformer = new AvataxOrderConfirmedPayloadLinesTransformer(); +const mockGenerator = new AvataxOrderConfirmedMockGenerator(); +const orderMock = mockGenerator.generateOrder(); + +const matches: AvataxTaxCodeMatches = []; + +describe("AvataxOrderConfirmedPayloadLinesTransformer", () => { + 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-confirmed/avatax-order-confirmed-payload-lines-transformer.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-lines-transformer.ts new file mode 100644 index 0000000..3059894 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-lines-transformer.ts @@ -0,0 +1,52 @@ +import { LineItemModel } from "avatax/lib/models/LineItemModel"; +import { OrderConfirmedSubscriptionFragment } 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-confirmed-payload-transformer"; +import { AvataxOrderConfirmedTaxCodeMatcher } from "./avatax-order-confirmed-tax-code-matcher"; + +export class AvataxOrderConfirmedPayloadLinesTransformer { + transform( + order: OrderConfirmedSubscriptionFragment, + config: AvataxConfig, + matches: AvataxTaxCodeMatches + ): LineItemModel[] { + const productLines: LineItemModel[] = order.lines.map((line) => { + const matcher = new AvataxOrderConfirmedTaxCodeMatcher(); + 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-confirmed/avatax-order-confirmed-payload-transformer.test.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-transformer.test.ts new file mode 100644 index 0000000..44f7303 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-transformer.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { AvataxOrderConfirmedMockGenerator } from "./avatax-order-confirmed-mock-generator"; +import { AvataxOrderConfirmedPayloadTransformer } from "./avatax-order-confirmed-payload-transformer"; +import { DocumentType } from "avatax/lib/enums/DocumentType"; + +const mockGenerator = new AvataxOrderConfirmedMockGenerator(); + +const orderMock = mockGenerator.generateOrder(); +const discountedOrderMock = mockGenerator.generateOrder({ + discounts: [ + { + amount: { + amount: 10, + }, + id: "RGlzY291bnREaXNjb3VudDox", + }, + ], +}); + +const transformer = new AvataxOrderConfirmedPayloadTransformer(); + +export const avataxConfigMock = mockGenerator.generateAvataxConfig(); + +describe("AvataxOrderConfirmedPayloadTransformer", () => { + it("returns document type of SalesInvoice when isDocumentRecordingEnabled is true", async () => { + const payload = await transformer.transform(orderMock, avataxConfigMock, []); + + expect(payload.model.type).toBe(DocumentType.SalesInvoice); + }), + it("returns document type of SalesOrder when isDocumentRecordingEnabled is false", async () => { + const payload = await transformer.transform( + orderMock, + { + ...avataxConfigMock, + isDocumentRecordingEnabled: false, + }, + [] + ); + + expect(payload.model.type).toBe(DocumentType.SalesOrder); + }); + it("returns lines with discounted: true when there are discounts", async () => { + const payload = await transformer.transform(discountedOrderMock, avataxConfigMock, []); + + const linesWithoutShipping = payload.model.lines.slice(0, -1); + const check = linesWithoutShipping.every((line) => line.discounted === true); + + expect(check).toBe(true); + }); + it("returns lines with discounted: false when there are no discounts", async () => { + const transformer = new AvataxOrderConfirmedPayloadTransformer(); + const payload = await transformer.transform(orderMock, avataxConfigMock, []); + + const linesWithoutShipping = payload.model.lines.slice(0, -1); + const check = linesWithoutShipping.every((line) => line.discounted === false); + + expect(check).toBe(true); + }); +}); diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-transformer.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-transformer.ts new file mode 100644 index 0000000..5fd753e --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload-transformer.ts @@ -0,0 +1,70 @@ +import { DocumentType } from "avatax/lib/enums/DocumentType"; +import { OrderConfirmedSubscriptionFragment } from "../../../../generated/graphql"; +import { discountUtils } from "../../taxes/discount-utils"; +import { avataxAddressFactory } from "../address-factory"; +import { AvataxClient, CreateTransactionArgs } from "../avatax-client"; +import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema"; +import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; +import { AvataxOrderConfirmedPayloadLinesTransformer } from "./avatax-order-confirmed-payload-lines-transformer"; +import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher"; +import { AvataxDocumentCodeResolver } from "../avatax-document-code-resolver"; +import { AvataxCalculationDateResolver } from "../avatax-calculation-date-resolver"; +import { taxProviderUtils } from "../../taxes/tax-provider-utils"; + +export const SHIPPING_ITEM_CODE = "Shipping"; + +export class AvataxOrderConfirmedPayloadTransformer { + private matchDocumentType(config: AvataxConfig): DocumentType { + if (!config.isDocumentRecordingEnabled) { + // isDocumentRecordingEnabled = false changes all the DocTypes within your AvaTax requests to SalesOrder. This will stop any transaction from being recorded within AvaTax. + return DocumentType.SalesOrder; + } + + return DocumentType.SalesInvoice; + } + async transform( + order: OrderConfirmedSubscriptionFragment, + avataxConfig: AvataxConfig, + matches: AvataxTaxCodeMatches + ): Promise { + const avataxClient = new AvataxClient(avataxConfig); + + const linesTransformer = new AvataxOrderConfirmedPayloadLinesTransformer(); + const entityTypeMatcher = new AvataxEntityTypeMatcher({ client: avataxClient }); + const dateResolver = new AvataxCalculationDateResolver(); + const documentCodeResolver = new AvataxDocumentCodeResolver(); + + const entityUseCode = await entityTypeMatcher.match(order.avataxEntityCode); + const date = dateResolver.resolve(order.avataxTaxCalculationDate, order.created); + const code = documentCodeResolver.resolve({ + avataxDocumentCode: order.avataxDocumentCode, + orderId: order.id, + }); + + return { + model: { + code, + type: this.matchDocumentType(avataxConfig), + entityUseCode, + customerCode: + order.user?.id ?? + "" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the user id. */, + companyCode: avataxConfig.companyCode ?? defaultAvataxConfig.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: avataxConfig.isAutocommit, + addresses: { + shipFrom: avataxAddressFactory.fromChannelAddress(avataxConfig.address), + // billing or shipping address? + shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!), + }, + currencyCode: order.total.currency, + email: taxProviderUtils.resolveStringOrThrow(order.user?.email), + lines: linesTransformer.transform(order, avataxConfig, matches), + date, + discount: discountUtils.sumDiscounts( + order.discounts.map((discount) => discount.amount.amount) + ), + }, + }; + } +} diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload.service.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload.service.ts new file mode 100644 index 0000000..eaadd7d --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-payload.service.ts @@ -0,0 +1,26 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { OrderConfirmedSubscriptionFragment } 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 { AvataxOrderConfirmedPayloadTransformer } from "./avatax-order-confirmed-payload-transformer"; + +export class AvataxOrderConfirmedPayloadService { + constructor(private authData: AuthData) {} + + private getMatches() { + const taxCodeMatchesService = new AvataxTaxCodeMatchesService(this.authData); + + return taxCodeMatchesService.getAll(); + } + + async getPayload( + order: OrderConfirmedSubscriptionFragment, + avataxConfig: AvataxConfig + ): Promise { + const matches = await this.getMatches(); + const payloadTransformer = new AvataxOrderConfirmedPayloadTransformer(); + + return payloadTransformer.transform(order, avataxConfig, matches); + } +} diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transaction-mock.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transaction-mock.ts new file mode 100644 index 0000000..32d5729 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transaction-mock.ts @@ -0,0 +1,1260 @@ +import { TransactionModel } from "avatax/lib/models/TransactionModel"; + +export const orderConfirmedTransactionMock = { + id: 85026453274669, + code: "31fa9e94-e8df-429b-b303-549e7bc10f1e", + companyId: 7799660, + date: "2023-05-25", + status: "Saved", + type: "SalesInvoice", + batchCode: "", + currencyCode: "USD", + exchangeRateCurrencyCode: "USD", + customerUsageType: "", + entityUseCode: "", + customerVendorCode: "VXNlcjoyMDg0NTEwNDEw", + customerCode: "VXNlcjoyMDg0NTEwNDEw", + exemptNo: "", + reconciled: false, + locationCode: "", + reportingLocationCode: "", + purchaseOrderNo: "", + referenceCode: "", + salespersonCode: "", + taxOverrideType: "None", + taxOverrideAmount: 0, + taxOverrideReason: "", + totalAmount: 137.34, + totalExempt: 0, + totalDiscount: 0, + totalTax: 11.83, + totalTaxable: 137.34, + totalTaxCalculated: 11.83, + adjustmentReason: "NotAdjusted", + adjustmentDescription: "", + locked: false, + region: "CA", + country: "US", + version: 1, + softwareVersion: "23.4.0.0", + originAddressId: 85026453274671, + destinationAddressId: 85026453274670, + exchangeRateEffectiveDate: "2023-05-25", + exchangeRate: 1, + description: "", + email: "happy.customer@saleor.io", + businessIdentificationNo: "", + modifiedDate: "2023-05-25T11:11:17.5517302Z", + modifiedUserId: 6479978, + taxDate: "2023-05-25", + lines: [ + { + id: 85026453274675, + transactionId: 85026453274669, + lineNumber: "1", + boundaryOverrideId: 0, + customerUsageType: "", + entityUseCode: "", + description: "Monospace Tee", + destinationAddressId: 85026453274670, + originAddressId: 85026453274671, + discountAmount: 0, + discountTypeId: 0, + exemptAmount: 0, + exemptCertId: 0, + exemptNo: "", + isItemTaxable: true, + isSSTP: false, + itemCode: "328223580", + lineAmount: 18.42, + quantity: 1, + ref1: "", + ref2: "", + reportingDate: "2023-05-25", + revAccount: "", + sourcing: "Mixed", + tax: 1.58, + taxableAmount: 18.42, + taxCalculated: 1.58, + taxCode: "P0000000", + taxCodeId: 8087, + taxDate: "2023-05-25", + taxEngine: "", + taxOverrideType: "None", + businessIdentificationNo: "", + taxOverrideAmount: 0, + taxOverrideReason: "", + taxIncluded: true, + details: [ + { + id: 85026453274685, + transactionLineId: 85026453274675, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "06", + jurisName: "CALIFORNIA", + jurisdictionId: 5000531, + signatureCode: "AGAM", + stateAssignedNo: "", + jurisType: "STA", + jurisdictionType: "State", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.06, + rateRuleId: 1343583, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 1.1, + taxableAmount: 18.42, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA STATE TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 1.1, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 18.42, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 18.42, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 1.1, + reportingTaxCalculated: 1.1, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274686, + transactionLineId: 85026453274675, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "085", + jurisName: "SANTA CLARA", + jurisdictionId: 280, + signatureCode: "AJBI", + stateAssignedNo: "", + jurisType: "CTY", + jurisdictionType: "County", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.0025, + rateRuleId: 1358122, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 0.05, + taxableAmount: 18.42, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA COUNTY TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 0.05, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 18.42, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 18.42, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.05, + reportingTaxCalculated: 0.05, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274687, + transactionLineId: 85026453274675, + transactionId: 85026453274669, + addressId: 85026453274670, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "EMBE0", + jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP", + jurisdictionId: 2001061430, + signatureCode: "EMBE", + stateAssignedNo: "940", + jurisType: "STJ", + jurisdictionType: "Special", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.01375, + rateRuleId: 2443104, + rateSourceId: 3, + serCode: "", + sourcing: "Destination", + tax: 0.25, + taxableAmount: 18.42, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxRegionId: 4024330, + taxCalculated: 0.25, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 18.42, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 18.42, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.25, + reportingTaxCalculated: 0.25, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274688, + transactionLineId: 85026453274675, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "EMUA0", + jurisName: "SANTA CLARA CO LOCAL TAX SL", + jurisdictionId: 2001061797, + signatureCode: "EMUA", + stateAssignedNo: "43", + jurisType: "STJ", + jurisdictionType: "Special", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.01, + rateRuleId: 1316507, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 0.18, + taxableAmount: 18.42, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 0.18, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 18.42, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 18.42, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.18, + reportingTaxCalculated: 0.18, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + ], + nonPassthroughDetails: [], + lineLocationTypes: [ + { + documentLineLocationTypeId: 85026453274680, + documentLineId: 85026453274675, + documentAddressId: 85026453274671, + locationTypeCode: "ShipFrom", + }, + { + documentLineLocationTypeId: 85026453274681, + documentLineId: 85026453274675, + documentAddressId: 85026453274670, + locationTypeCode: "ShipTo", + }, + ], + hsCode: "", + costInsuranceFreight: 0, + vatCode: "", + vatNumberTypeId: 0, + }, + { + id: 85026453274676, + transactionId: 85026453274669, + lineNumber: "2", + boundaryOverrideId: 0, + customerUsageType: "", + entityUseCode: "", + description: "Monospace Tee", + destinationAddressId: 85026453274670, + originAddressId: 85026453274671, + discountAmount: 0, + discountTypeId: 0, + exemptAmount: 0, + exemptCertId: 0, + exemptNo: "", + isItemTaxable: true, + isSSTP: false, + itemCode: "328223581", + lineAmount: 18.42, + quantity: 1, + ref1: "", + ref2: "", + reportingDate: "2023-05-25", + revAccount: "", + sourcing: "Mixed", + tax: 1.58, + taxableAmount: 18.42, + taxCalculated: 1.58, + taxCode: "P0000000", + taxCodeId: 8087, + taxDate: "2023-05-25", + taxEngine: "", + taxOverrideType: "None", + businessIdentificationNo: "", + taxOverrideAmount: 0, + taxOverrideReason: "", + taxIncluded: true, + details: [ + { + id: 85026453274695, + transactionLineId: 85026453274676, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "06", + jurisName: "CALIFORNIA", + jurisdictionId: 5000531, + signatureCode: "AGAM", + stateAssignedNo: "", + jurisType: "STA", + jurisdictionType: "State", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.06, + rateRuleId: 1343583, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 1.1, + taxableAmount: 18.42, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA STATE TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 1.1, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 18.42, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 18.42, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 1.1, + reportingTaxCalculated: 1.1, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274696, + transactionLineId: 85026453274676, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "085", + jurisName: "SANTA CLARA", + jurisdictionId: 280, + signatureCode: "AJBI", + stateAssignedNo: "", + jurisType: "CTY", + jurisdictionType: "County", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.0025, + rateRuleId: 1358122, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 0.05, + taxableAmount: 18.42, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA COUNTY TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 0.05, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 18.42, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 18.42, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.05, + reportingTaxCalculated: 0.05, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274697, + transactionLineId: 85026453274676, + transactionId: 85026453274669, + addressId: 85026453274670, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "EMBE0", + jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP", + jurisdictionId: 2001061430, + signatureCode: "EMBE", + stateAssignedNo: "940", + jurisType: "STJ", + jurisdictionType: "Special", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.01375, + rateRuleId: 2443104, + rateSourceId: 3, + serCode: "", + sourcing: "Destination", + tax: 0.25, + taxableAmount: 18.42, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxRegionId: 4024330, + taxCalculated: 0.25, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 18.42, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 18.42, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.25, + reportingTaxCalculated: 0.25, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274698, + transactionLineId: 85026453274676, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "EMUA0", + jurisName: "SANTA CLARA CO LOCAL TAX SL", + jurisdictionId: 2001061797, + signatureCode: "EMUA", + stateAssignedNo: "43", + jurisType: "STJ", + jurisdictionType: "Special", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.01, + rateRuleId: 1316507, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 0.18, + taxableAmount: 18.42, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 0.18, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 18.42, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 18.42, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.18, + reportingTaxCalculated: 0.18, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + ], + nonPassthroughDetails: [], + lineLocationTypes: [ + { + documentLineLocationTypeId: 85026453274690, + documentLineId: 85026453274676, + documentAddressId: 85026453274671, + locationTypeCode: "ShipFrom", + }, + { + documentLineLocationTypeId: 85026453274691, + documentLineId: 85026453274676, + documentAddressId: 85026453274670, + locationTypeCode: "ShipTo", + }, + ], + hsCode: "", + costInsuranceFreight: 0, + vatCode: "", + vatNumberTypeId: 0, + }, + { + id: 85026453274677, + transactionId: 85026453274669, + lineNumber: "3", + boundaryOverrideId: 0, + customerUsageType: "", + entityUseCode: "", + description: "Paul's Balance 420", + destinationAddressId: 85026453274670, + originAddressId: 85026453274671, + discountAmount: 0, + discountTypeId: 0, + exemptAmount: 0, + exemptCertId: 0, + exemptNo: "", + isItemTaxable: true, + isSSTP: false, + itemCode: "118223581", + lineAmount: 46.03, + quantity: 1, + ref1: "", + ref2: "", + reportingDate: "2023-05-25", + revAccount: "", + sourcing: "Mixed", + tax: 3.97, + taxableAmount: 46.03, + taxCalculated: 3.97, + taxCode: "P0000000", + taxCodeId: 8087, + taxDate: "2023-05-25", + taxEngine: "", + taxOverrideType: "None", + businessIdentificationNo: "", + taxOverrideAmount: 0, + taxOverrideReason: "", + taxIncluded: true, + details: [ + { + id: 85026453274705, + transactionLineId: 85026453274677, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "06", + jurisName: "CALIFORNIA", + jurisdictionId: 5000531, + signatureCode: "AGAM", + stateAssignedNo: "", + jurisType: "STA", + jurisdictionType: "State", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.06, + rateRuleId: 1343583, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 2.76, + taxableAmount: 46.03, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA STATE TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 2.76, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 46.03, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 46.03, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 2.76, + reportingTaxCalculated: 2.76, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274706, + transactionLineId: 85026453274677, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "085", + jurisName: "SANTA CLARA", + jurisdictionId: 280, + signatureCode: "AJBI", + stateAssignedNo: "", + jurisType: "CTY", + jurisdictionType: "County", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.0025, + rateRuleId: 1358122, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 0.12, + taxableAmount: 46.03, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA COUNTY TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 0.12, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 46.03, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 46.03, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.12, + reportingTaxCalculated: 0.12, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274707, + transactionLineId: 85026453274677, + transactionId: 85026453274669, + addressId: 85026453274670, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "EMBE0", + jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP", + jurisdictionId: 2001061430, + signatureCode: "EMBE", + stateAssignedNo: "940", + jurisType: "STJ", + jurisdictionType: "Special", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.01375, + rateRuleId: 2443104, + rateSourceId: 3, + serCode: "", + sourcing: "Destination", + tax: 0.63, + taxableAmount: 46.03, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxRegionId: 4024330, + taxCalculated: 0.63, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 46.03, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 46.03, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.63, + reportingTaxCalculated: 0.63, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274708, + transactionLineId: 85026453274677, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "EMUA0", + jurisName: "SANTA CLARA CO LOCAL TAX SL", + jurisdictionId: 2001061797, + signatureCode: "EMUA", + stateAssignedNo: "43", + jurisType: "STJ", + jurisdictionType: "Special", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.01, + rateRuleId: 1316507, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 0.46, + taxableAmount: 46.03, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 0.46, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 46.03, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 46.03, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.46, + reportingTaxCalculated: 0.46, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + ], + nonPassthroughDetails: [], + lineLocationTypes: [ + { + documentLineLocationTypeId: 85026453274700, + documentLineId: 85026453274677, + documentAddressId: 85026453274671, + locationTypeCode: "ShipFrom", + }, + { + documentLineLocationTypeId: 85026453274701, + documentLineId: 85026453274677, + documentAddressId: 85026453274670, + locationTypeCode: "ShipTo", + }, + ], + hsCode: "", + costInsuranceFreight: 0, + vatCode: "", + vatNumberTypeId: 0, + }, + { + id: 85026453274678, + transactionId: 85026453274669, + lineNumber: "4", + boundaryOverrideId: 0, + customerUsageType: "", + entityUseCode: "", + description: "", + destinationAddressId: 85026453274670, + originAddressId: 85026453274671, + discountAmount: 0, + discountTypeId: 0, + exemptAmount: 0, + exemptCertId: 0, + exemptNo: "", + isItemTaxable: true, + isSSTP: false, + itemCode: "Shipping", + lineAmount: 54.47, + quantity: 1, + ref1: "", + ref2: "", + reportingDate: "2023-05-25", + revAccount: "", + sourcing: "Mixed", + tax: 4.7, + taxableAmount: 54.47, + taxCalculated: 4.7, + taxCode: "P0000000", + taxCodeId: 8087, + taxDate: "2023-05-25", + taxEngine: "", + taxOverrideType: "None", + businessIdentificationNo: "", + taxOverrideAmount: 0, + taxOverrideReason: "", + taxIncluded: true, + details: [ + { + id: 85026453274715, + transactionLineId: 85026453274678, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "06", + jurisName: "CALIFORNIA", + jurisdictionId: 5000531, + signatureCode: "AGAM", + stateAssignedNo: "", + jurisType: "STA", + jurisdictionType: "State", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.06, + rateRuleId: 1343583, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 3.27, + taxableAmount: 54.47, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA STATE TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 3.27, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 54.47, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 54.47, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 3.27, + reportingTaxCalculated: 3.27, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274716, + transactionLineId: 85026453274678, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "085", + jurisName: "SANTA CLARA", + jurisdictionId: 280, + signatureCode: "AJBI", + stateAssignedNo: "", + jurisType: "CTY", + jurisdictionType: "County", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.0025, + rateRuleId: 1358122, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 0.14, + taxableAmount: 54.47, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA COUNTY TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 0.14, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 54.47, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 54.47, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.14, + reportingTaxCalculated: 0.14, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274717, + transactionLineId: 85026453274678, + transactionId: 85026453274669, + addressId: 85026453274670, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "EMBE0", + jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP", + jurisdictionId: 2001061430, + signatureCode: "EMBE", + stateAssignedNo: "940", + jurisType: "STJ", + jurisdictionType: "Special", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.01375, + rateRuleId: 2443104, + rateSourceId: 3, + serCode: "", + sourcing: "Destination", + tax: 0.75, + taxableAmount: 54.47, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxRegionId: 4024330, + taxCalculated: 0.75, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 54.47, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 54.47, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.75, + reportingTaxCalculated: 0.75, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + { + id: 85026453274718, + transactionLineId: 85026453274678, + transactionId: 85026453274669, + addressId: 85026453274671, + country: "US", + region: "CA", + countyFIPS: "", + stateFIPS: "", + exemptAmount: 0, + exemptReasonId: 4, + inState: true, + jurisCode: "EMUA0", + jurisName: "SANTA CLARA CO LOCAL TAX SL", + jurisdictionId: 2001061797, + signatureCode: "EMUA", + stateAssignedNo: "43", + jurisType: "STJ", + jurisdictionType: "Special", + nonTaxableAmount: 0, + nonTaxableRuleId: 0, + nonTaxableType: "RateRule", + rate: 0.01, + rateRuleId: 1316507, + rateSourceId: 3, + serCode: "", + sourcing: "Origin", + tax: 0.54, + taxableAmount: 54.47, + taxType: "Sales", + taxSubTypeId: "S", + taxTypeGroupId: "SalesAndUse", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxRegionId: 2128577, + taxCalculated: 0.54, + taxOverride: 0, + rateType: "General", + rateTypeCode: "G", + taxableUnits: 54.47, + nonTaxableUnits: 0, + exemptUnits: 0, + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 54.47, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.54, + reportingTaxCalculated: 0.54, + liabilityType: "Seller", + chargedTo: "Buyer", + }, + ], + nonPassthroughDetails: [], + lineLocationTypes: [ + { + documentLineLocationTypeId: 85026453274710, + documentLineId: 85026453274678, + documentAddressId: 85026453274671, + locationTypeCode: "ShipFrom", + }, + { + documentLineLocationTypeId: 85026453274711, + documentLineId: 85026453274678, + documentAddressId: 85026453274670, + locationTypeCode: "ShipTo", + }, + ], + hsCode: "", + costInsuranceFreight: 0, + vatCode: "", + vatNumberTypeId: 0, + }, + ], + addresses: [ + { + id: 85026453274670, + transactionId: 85026453274669, + boundaryLevel: "Address", + line1: "600 Montgomery St", + line2: "", + line3: "", + city: "San Francisco", + region: "CA", + postalCode: "94111-2702", + country: "US", + taxRegionId: 4024330, + latitude: "37.795255", + longitude: "-122.40313", + }, + { + id: 85026453274671, + transactionId: 85026453274669, + boundaryLevel: "Address", + line1: "33 N 1st St", + line2: "", + line3: "", + city: "Campbell", + region: "CA", + postalCode: "95008-2050", + country: "US", + taxRegionId: 2128577, + latitude: "37.287589", + longitude: "-121.944955", + }, + ], + locationTypes: [ + { + documentLocationTypeId: 85026453274673, + documentId: 85026453274669, + documentAddressId: 85026453274671, + locationTypeCode: "ShipFrom", + }, + { + documentLocationTypeId: 85026453274674, + documentId: 85026453274669, + documentAddressId: 85026453274670, + locationTypeCode: "ShipTo", + }, + ], + summary: [ + { + country: "US", + region: "CA", + jurisType: "State", + jurisCode: "06", + jurisName: "CALIFORNIA", + taxAuthorityType: 45, + stateAssignedNo: "", + taxType: "Sales", + taxSubType: "S", + taxName: "CA STATE TAX", + rateType: "General", + taxable: 137.34, + rate: 0.06, + tax: 8.23, + taxCalculated: 8.23, + nonTaxable: 0, + exemption: 0, + }, + { + country: "US", + region: "CA", + jurisType: "County", + jurisCode: "085", + jurisName: "SANTA CLARA", + taxAuthorityType: 45, + stateAssignedNo: "", + taxType: "Sales", + taxSubType: "S", + taxName: "CA COUNTY TAX", + rateType: "General", + taxable: 137.34, + rate: 0.0025, + tax: 0.36, + taxCalculated: 0.36, + nonTaxable: 0, + exemption: 0, + }, + { + country: "US", + region: "CA", + jurisType: "Special", + jurisCode: "EMBE0", + jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP", + taxAuthorityType: 45, + stateAssignedNo: "940", + taxType: "Sales", + taxSubType: "S", + taxName: "CA SPECIAL TAX", + rateType: "General", + taxable: 137.34, + rate: 0.01375, + tax: 1.88, + taxCalculated: 1.88, + nonTaxable: 0, + exemption: 0, + }, + { + country: "US", + region: "CA", + jurisType: "Special", + jurisCode: "EMUA0", + jurisName: "SANTA CLARA CO LOCAL TAX SL", + taxAuthorityType: 45, + stateAssignedNo: "43", + taxType: "Sales", + taxSubType: "S", + taxName: "CA SPECIAL TAX", + rateType: "General", + taxable: 137.34, + rate: 0.01, + tax: 1.36, + taxCalculated: 1.36, + nonTaxable: 0, + exemption: 0, + }, + ], +} as unknown as TransactionModel; diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transformer.test.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transformer.test.ts new file mode 100644 index 0000000..a003026 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transformer.test.ts @@ -0,0 +1,514 @@ +import { AdjustmentReason } from "avatax/lib/enums/AdjustmentReason"; +import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel"; +import { ChargedTo } from "avatax/lib/enums/ChargedTo"; +import { DocumentStatus } from "avatax/lib/enums/DocumentStatus"; +import { DocumentType } from "avatax/lib/enums/DocumentType"; +import { JurisTypeId } from "avatax/lib/enums/JurisTypeId"; +import { JurisdictionType } from "avatax/lib/enums/JurisdictionType"; +import { LiabilityType } from "avatax/lib/enums/LiabilityType"; +import { RateType } from "avatax/lib/enums/RateType"; +import { TransactionModel } from "avatax/lib/models/TransactionModel"; +import { describe, expect, it } from "vitest"; +import { AvataxOrderConfirmedResponseTransformer } from "./avatax-order-confirmed-response-transformer"; + +const MOCKED_TRANSACTION: TransactionModel = { + id: 0, + code: "8fc875ce-a929-4556-9f30-0165b1597d9f", + companyId: 7799640, + date: new Date(), + paymentDate: new Date(), + status: DocumentStatus.Temporary, + type: DocumentType.SalesOrder, + batchCode: "", + currencyCode: "USD", + exchangeRateCurrencyCode: "USD", + customerUsageType: "", + entityUseCode: "", + customerVendorCode: "VXNlcjoyMDg0NTEwNDEw", + customerCode: "VXNlcjoyMDg0NTEwNDEw", + exemptNo: "", + reconciled: false, + locationCode: "", + reportingLocationCode: "", + purchaseOrderNo: "", + referenceCode: "", + salespersonCode: "", + totalAmount: 107.31, + totalExempt: 0, + totalDiscount: 0, + totalTax: 10.2, + totalTaxable: 107.31, + totalTaxCalculated: 10.2, + adjustmentReason: AdjustmentReason.NotAdjusted, + locked: false, + version: 1, + exchangeRateEffectiveDate: new Date(), + exchangeRate: 1, + modifiedDate: new Date(), + modifiedUserId: 6479978, + taxDate: new Date(), + lines: [ + { + id: 0, + transactionId: 0, + lineNumber: "1", + customerUsageType: "", + entityUseCode: "", + discountAmount: 0, + exemptAmount: 0, + exemptCertId: 0, + exemptNo: "", + isItemTaxable: true, + itemCode: "", + lineAmount: 36.53, + quantity: 2, + ref1: "", + ref2: "", + reportingDate: new Date(), + tax: 3.47, + taxableAmount: 36.53, + taxCalculated: 3.47, + taxCode: "P0000000", + taxCodeId: 8087, + taxDate: new Date(), + taxIncluded: true, + details: [ + { + id: 0, + transactionLineId: 0, + transactionId: 0, + country: "US", + region: "CA", + exemptAmount: 0, + jurisCode: "06", + jurisName: "CALIFORNIA", + stateAssignedNo: "", + jurisType: JurisTypeId.STA, + jurisdictionType: JurisdictionType.State, + nonTaxableAmount: 0, + rate: 0.06, + tax: 2.19, + taxableAmount: 36.53, + taxType: "Use", + taxSubTypeId: "U", + taxName: "CA STATE TAX", + taxAuthorityTypeId: 45, + taxCalculated: 2.19, + rateType: RateType.General, + rateTypeCode: "G", + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 36.53, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 2.19, + reportingTaxCalculated: 2.19, + liabilityType: LiabilityType.Seller, + chargedTo: ChargedTo.Buyer, + }, + { + id: 0, + transactionLineId: 0, + transactionId: 0, + country: "US", + region: "CA", + exemptAmount: 0, + jurisCode: "037", + jurisName: "LOS ANGELES", + stateAssignedNo: "", + jurisType: JurisTypeId.CTY, + jurisdictionType: JurisdictionType.County, + nonTaxableAmount: 0, + rate: 0.0025, + tax: 0.09, + taxableAmount: 36.53, + taxType: "Use", + taxSubTypeId: "U", + taxName: "CA COUNTY TAX", + taxAuthorityTypeId: 45, + taxCalculated: 0.09, + rateType: RateType.General, + rateTypeCode: "G", + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 36.53, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.09, + reportingTaxCalculated: 0.09, + liabilityType: LiabilityType.Seller, + chargedTo: ChargedTo.Buyer, + }, + { + id: 0, + transactionLineId: 0, + transactionId: 0, + country: "US", + region: "CA", + exemptAmount: 0, + jurisCode: "EMAR0", + jurisName: "LOS ANGELES COUNTY DISTRICT TAX SP", + stateAssignedNo: "594", + jurisType: JurisTypeId.STJ, + jurisdictionType: JurisdictionType.Special, + nonTaxableAmount: 0, + rate: 0.0225, + tax: 0.82, + taxableAmount: 36.53, + taxType: "Use", + taxSubTypeId: "U", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxCalculated: 0.82, + rateType: RateType.General, + rateTypeCode: "G", + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 36.53, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.82, + reportingTaxCalculated: 0.82, + liabilityType: LiabilityType.Seller, + chargedTo: ChargedTo.Buyer, + }, + { + id: 0, + transactionLineId: 0, + transactionId: 0, + country: "US", + region: "CA", + exemptAmount: 0, + jurisCode: "EMTC0", + jurisName: "LOS ANGELES CO LOCAL TAX SL", + stateAssignedNo: "19", + jurisType: JurisTypeId.STJ, + jurisdictionType: JurisdictionType.Special, + nonTaxableAmount: 0, + rate: 0.01, + tax: 0.37, + taxableAmount: 36.53, + taxType: "Use", + taxSubTypeId: "U", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxCalculated: 0.37, + rateType: RateType.General, + rateTypeCode: "G", + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 36.53, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.37, + reportingTaxCalculated: 0.37, + liabilityType: LiabilityType.Seller, + chargedTo: ChargedTo.Buyer, + }, + ], + nonPassthroughDetails: [], + hsCode: "", + costInsuranceFreight: 0, + vatCode: "", + vatNumberTypeId: 0, + }, + { + id: 0, + transactionId: 0, + lineNumber: "2", + customerUsageType: "", + entityUseCode: "", + discountAmount: 0, + exemptAmount: 0, + exemptCertId: 0, + exemptNo: "", + isItemTaxable: true, + itemCode: "Shipping", + lineAmount: 70.78, + quantity: 1, + ref1: "", + ref2: "", + reportingDate: new Date(), + tax: 6.73, + taxableAmount: 70.78, + taxCalculated: 6.73, + taxCode: "P0000000", + taxCodeId: 8087, + taxDate: new Date(), + taxIncluded: true, + details: [ + { + id: 0, + transactionLineId: 0, + transactionId: 0, + country: "US", + region: "CA", + exemptAmount: 0, + jurisCode: "06", + jurisName: "CALIFORNIA", + stateAssignedNo: "", + jurisType: JurisTypeId.STA, + jurisdictionType: JurisdictionType.State, + nonTaxableAmount: 0, + rate: 0.06, + tax: 4.25, + taxableAmount: 70.78, + taxType: "Use", + taxSubTypeId: "U", + taxName: "CA STATE TAX", + taxAuthorityTypeId: 45, + taxCalculated: 4.25, + rateType: RateType.General, + rateTypeCode: "G", + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 70.78, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 4.25, + reportingTaxCalculated: 4.25, + liabilityType: LiabilityType.Seller, + chargedTo: ChargedTo.Buyer, + }, + { + id: 0, + transactionLineId: 0, + transactionId: 0, + country: "US", + region: "CA", + exemptAmount: 0, + jurisCode: "037", + jurisName: "LOS ANGELES", + stateAssignedNo: "", + jurisType: JurisTypeId.CTY, + jurisdictionType: JurisdictionType.County, + nonTaxableAmount: 0, + rate: 0.0025, + tax: 0.18, + taxableAmount: 70.78, + taxType: "Use", + taxSubTypeId: "U", + taxName: "CA COUNTY TAX", + taxAuthorityTypeId: 45, + taxCalculated: 0.18, + rateType: RateType.General, + rateTypeCode: "G", + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 70.78, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.18, + reportingTaxCalculated: 0.18, + liabilityType: LiabilityType.Seller, + chargedTo: ChargedTo.Buyer, + }, + { + id: 0, + transactionLineId: 0, + transactionId: 0, + country: "US", + region: "CA", + exemptAmount: 0, + jurisCode: "EMAR0", + jurisName: "LOS ANGELES COUNTY DISTRICT TAX SP", + stateAssignedNo: "594", + jurisType: JurisTypeId.STJ, + jurisdictionType: JurisdictionType.Special, + nonTaxableAmount: 0, + rate: 0.0225, + tax: 1.59, + taxableAmount: 70.78, + taxType: "Use", + taxSubTypeId: "U", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxCalculated: 1.59, + rateType: RateType.General, + rateTypeCode: "G", + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 70.78, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 1.59, + reportingTaxCalculated: 1.59, + liabilityType: LiabilityType.Seller, + chargedTo: ChargedTo.Buyer, + }, + { + id: 0, + transactionLineId: 0, + transactionId: 0, + country: "US", + region: "CA", + exemptAmount: 0, + jurisCode: "EMTC0", + jurisName: "LOS ANGELES CO LOCAL TAX SL", + stateAssignedNo: "19", + jurisType: JurisTypeId.STJ, + jurisdictionType: JurisdictionType.Special, + nonTaxableAmount: 0, + rate: 0.01, + tax: 0.71, + taxableAmount: 70.78, + taxType: "Use", + taxSubTypeId: "U", + taxName: "CA SPECIAL TAX", + taxAuthorityTypeId: 45, + taxCalculated: 0.71, + rateType: RateType.General, + rateTypeCode: "G", + unitOfBasis: "PerCurrencyUnit", + isNonPassThru: false, + isFee: false, + reportingTaxableUnits: 70.78, + reportingNonTaxableUnits: 0, + reportingExemptUnits: 0, + reportingTax: 0.71, + reportingTaxCalculated: 0.71, + liabilityType: LiabilityType.Seller, + chargedTo: ChargedTo.Buyer, + }, + ], + nonPassthroughDetails: [], + hsCode: "", + costInsuranceFreight: 0, + vatCode: "", + vatNumberTypeId: 0, + }, + ], + addresses: [ + { + id: 0, + transactionId: 0, + boundaryLevel: BoundaryLevel.Zip5, + line1: "123 Palm Grove Ln", + line2: "", + line3: "", + city: "LOS ANGELES", + region: "CA", + postalCode: "90002", + country: "US", + taxRegionId: 4017056, + latitude: "33.948712", + longitude: "-118.245951", + }, + { + id: 0, + transactionId: 0, + boundaryLevel: BoundaryLevel.Zip5, + line1: "8559 Lake Avenue", + line2: "", + line3: "", + city: "New York", + region: "NY", + postalCode: "10001", + country: "US", + taxRegionId: 2088629, + latitude: "40.748481", + longitude: "-73.993125", + }, + ], + summary: [ + { + country: "US", + region: "CA", + jurisType: JurisdictionType.State, + jurisCode: "06", + jurisName: "CALIFORNIA", + taxAuthorityType: 45, + stateAssignedNo: "", + taxType: "Use", + taxSubType: "U", + taxName: "CA STATE TAX", + rateType: RateType.General, + taxable: 107.31, + rate: 0.06, + tax: 6.44, + taxCalculated: 6.44, + nonTaxable: 0, + exemption: 0, + }, + { + country: "US", + region: "CA", + jurisType: JurisdictionType.County, + jurisCode: "037", + jurisName: "LOS ANGELES", + taxAuthorityType: 45, + stateAssignedNo: "", + taxType: "Use", + taxSubType: "U", + taxName: "CA COUNTY TAX", + rateType: RateType.General, + taxable: 107.31, + rate: 0.0025, + tax: 0.27, + taxCalculated: 0.27, + nonTaxable: 0, + exemption: 0, + }, + { + country: "US", + region: "CA", + jurisType: JurisdictionType.Special, + jurisCode: "EMTC0", + jurisName: "LOS ANGELES CO LOCAL TAX SL", + taxAuthorityType: 45, + stateAssignedNo: "19", + taxType: "Use", + taxSubType: "U", + taxName: "CA SPECIAL TAX", + rateType: RateType.General, + taxable: 107.31, + rate: 0.01, + tax: 1.08, + taxCalculated: 1.08, + nonTaxable: 0, + exemption: 0, + }, + { + country: "US", + region: "CA", + jurisType: JurisdictionType.Special, + jurisCode: "EMAR0", + jurisName: "LOS ANGELES COUNTY DISTRICT TAX SP", + taxAuthorityType: 45, + stateAssignedNo: "594", + taxType: "Use", + taxSubType: "U", + taxName: "CA SPECIAL TAX", + rateType: RateType.General, + taxable: 107.31, + rate: 0.0225, + tax: 2.41, + taxCalculated: 2.41, + nonTaxable: 0, + exemption: 0, + }, + ], +}; + +describe("AvataxOrderConfirmedResponseTransformer", () => { + it("returns orded id in response", () => { + const transformer = new AvataxOrderConfirmedResponseTransformer(); + const result = transformer.transform(MOCKED_TRANSACTION); + + expect(result).toEqual({ + id: "8fc875ce-a929-4556-9f30-0165b1597d9f", + }); + }); + it("throws an error when no transaction id is present", () => { + const transformer = new AvataxOrderConfirmedResponseTransformer(); + + expect(() => transformer.transform({} as any)).toThrowError(); + }); +}); diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transformer.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transformer.ts new file mode 100644 index 0000000..756260a --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-response-transformer.ts @@ -0,0 +1,16 @@ +import { CreateOrderResponse } from "../../taxes/tax-provider-webhook"; +import { TransactionModel } from "avatax/lib/models/TransactionModel"; +import { taxProviderUtils } from "../../taxes/tax-provider-utils"; + +export class AvataxOrderConfirmedResponseTransformer { + transform(response: TransactionModel): CreateOrderResponse { + return { + id: taxProviderUtils.resolveOptionalOrThrow( + response.code, + new Error( + "Could not update the order metadata with Avatax transaction code because it was not returned from the createTransaction mutation." + ) + ), + }; + } +} diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-tax-code-matcher.test.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-tax-code-matcher.test.ts new file mode 100644 index 0000000..e47ab0f --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-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 { AvataxOrderConfirmedTaxCodeMatcher } from "./avatax-order-confirmed-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("AvataxOrderConfirmedTaxCodeMatcher", () => { + it("should return empty string if tax class is not found", () => { + const matcher = new AvataxOrderConfirmedTaxCodeMatcher(); + + 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 AvataxOrderConfirmedTaxCodeMatcher(); + + expect(matcher.match(line, matches)).toEqual("P0000000"); + }); +}); diff --git a/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-tax-code-matcher.ts b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-tax-code-matcher.ts new file mode 100644 index 0000000..f582316 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-confirmed/avatax-order-confirmed-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 AvataxOrderConfirmedTaxCodeMatcher { + 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/avatax/order-created/avatax-order-created-payload-transformer.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts index c91d5ba..e81d9ae 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 @@ -7,6 +7,9 @@ import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer"; import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher"; +import { AvataxCalculationDateResolver } from "../avatax-calculation-date-resolver"; +import { AvataxDocumentCodeResolver } from "../avatax-document-code-resolver"; +import { taxProviderUtils } from "../../taxes/tax-provider-utils"; export const SHIPPING_ITEM_CODE = "Shipping"; @@ -24,15 +27,25 @@ export class AvataxOrderCreatedPayloadTransformer { avataxConfig: AvataxConfig, matches: AvataxTaxCodeMatches ): Promise { - const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer(); const avataxClient = new AvataxClient(avataxConfig); + + const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer(); const entityTypeMatcher = new AvataxEntityTypeMatcher({ client: avataxClient }); + const dateResolver = new AvataxCalculationDateResolver(); + const documentCodeResolver = new AvataxDocumentCodeResolver(); + const entityUseCode = await entityTypeMatcher.match(order.avataxEntityCode); + const date = dateResolver.resolve(order.avataxTaxCalculationDate, order.created); + const code = documentCodeResolver.resolve({ + avataxDocumentCode: order.avataxDocumentCode, + orderId: order.id, + }); return { model: { type: this.matchDocumentType(avataxConfig), entityUseCode, + code, customerCode: order.user?.id ?? "" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the user id. */, @@ -45,9 +58,9 @@ export class AvataxOrderCreatedPayloadTransformer { shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!), }, currencyCode: order.total.currency, - email: order.user?.email ?? "", + email: taxProviderUtils.resolveStringOrThrow(order.user?.email), lines: linesTransformer.transform(order, avataxConfig, matches), - date: new Date(order.created), + date, discount: discountUtils.sumDiscounts( order.discounts.map((discount) => discount.amount.amount) ), diff --git a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.test.ts b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.test.ts index 9964a8e..2c39f22 100644 --- a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.test.ts +++ b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.test.ts @@ -2,11 +2,7 @@ import { DocumentType } from "avatax/lib/enums/DocumentType"; import { describe, expect, it } from "vitest"; import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql"; import { AvataxConfig } from "../avatax-connection-schema"; -import { - AvataxOrderFulfilledPayloadTransformer, - PROVIDER_ORDER_ID_KEY, - getTransactionCodeFromMetadata, -} from "./avatax-order-fulfilled-payload-transformer"; +import { AvataxOrderFulfilledPayloadTransformer } from "./avatax-order-fulfilled-payload-transformer"; // todo: add AvataxOrderFulfilledMockGenerator @@ -30,19 +26,12 @@ const MOCK_AVATAX_CONFIG: AvataxConfig = { }, }; -const MOCKED_METADATA: OrderFulfilledSubscriptionFragment["privateMetadata"] = [ - { - key: PROVIDER_ORDER_ID_KEY, - value: "transaction-code", - }, -]; - type OrderFulfilled = OrderFulfilledSubscriptionFragment; const ORDER_FULFILLED_MOCK: OrderFulfilled = { id: "T3JkZXI6OTU4MDA5YjQtNDUxZC00NmQ1LThhMWUtMTRkMWRmYjFhNzI5", created: "2023-04-11T11:03:09.304109+00:00", - privateMetadata: MOCKED_METADATA, + avataxId: "transaction-code", channel: { id: "Q2hhbm5lbDoy", slug: "channel-pln", @@ -120,16 +109,6 @@ const ORDER_FULFILLED_MOCK: OrderFulfilled = { ], }; -describe("getTransactionCodeFromMetadata", () => { - it("returns transaction code", () => { - expect(getTransactionCodeFromMetadata(MOCKED_METADATA)).toBe("transaction-code"); - }); - - it("throws error when transaction code not found", () => { - expect(() => getTransactionCodeFromMetadata([])).toThrowError(); - }); -}); - const MOCKED_ORDER_FULFILLED_PAYLOAD: { order: OrderFulfilledSubscriptionFragment; } = { @@ -137,6 +116,19 @@ const MOCKED_ORDER_FULFILLED_PAYLOAD: { }; describe("AvataxOrderFulfilledPayloadTransformer", () => { + it("throws error when no avataxId", () => { + const transformer = new AvataxOrderFulfilledPayloadTransformer(MOCK_AVATAX_CONFIG); + + expect(() => + transformer.transform({ + ...MOCKED_ORDER_FULFILLED_PAYLOAD, + order: { + ...MOCKED_ORDER_FULFILLED_PAYLOAD.order, + avataxId: null, + }, + }) + ).toThrow(); + }); it("returns document type of SalesOrder when isDocumentRecordingEnabled is false", () => { const transformer = new AvataxOrderFulfilledPayloadTransformer({ ...MOCK_AVATAX_CONFIG, diff --git a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.ts b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.ts index f7a2b6b..ca9b3af 100644 --- a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.ts +++ b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.ts @@ -1,27 +1,11 @@ import { DocumentType } from "avatax/lib/enums/DocumentType"; -import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql"; +import { z } from "zod"; import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxOrderFulfilledPayload, AvataxOrderFulfilledTarget, } from "./avatax-order-fulfilled-adapter"; -// * This is the key that we use to store the provider order id in the Saleor order metadata. - -export const PROVIDER_ORDER_ID_KEY = "externalId"; - -export function getTransactionCodeFromMetadata( - metadata: OrderFulfilledSubscriptionFragment["privateMetadata"] -) { - const transactionCode = metadata.find((item) => item.key === PROVIDER_ORDER_ID_KEY); - - if (!transactionCode) { - throw new Error("Transaction code not found"); - } - - return transactionCode.value; -} - export class AvataxOrderFulfilledPayloadTransformer { constructor(private readonly config: AvataxConfig) {} private matchDocumentType(config: AvataxConfig): DocumentType { @@ -32,11 +16,11 @@ export class AvataxOrderFulfilledPayloadTransformer { return DocumentType.SalesInvoice; } transform({ order }: AvataxOrderFulfilledPayload): AvataxOrderFulfilledTarget { - const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata); + const transactionCode = z.string().min(1).parse(order.avataxId); return { transactionCode, - companyCode: this.config.companyCode ?? "", + companyCode: this.config.companyCode, documentType: this.matchDocumentType(this.config), model: { commit: true, diff --git a/apps/taxes/src/modules/taxes/get-active-connection-service.ts b/apps/taxes/src/modules/taxes/get-active-connection-service.ts index 480b165..78ed3ab 100644 --- a/apps/taxes/src/modules/taxes/get-active-connection-service.ts +++ b/apps/taxes/src/modules/taxes/get-active-connection-service.ts @@ -1,12 +1,14 @@ import { AuthData } from "@saleor/app-sdk/APL"; import { MetadataItem, + OrderConfirmedSubscriptionFragment, OrderCreatedSubscriptionFragment, OrderFulfilledSubscriptionFragment, TaxBaseFragment, } from "../../../generated/graphql"; import { Logger, createLogger } from "../../lib/logger"; +import { OrderCancelledPayload } from "../../pages/api/webhooks/order-cancelled"; import { getAppConfig } from "../app/get-app-config"; import { AvataxWebhookService } from "../avatax/avatax-webhook.service"; import { ProviderConnection } from "../provider-connections/provider-connections"; @@ -48,13 +50,27 @@ class ActiveTaxProviderService implements ProviderWebhookService { return this.client.calculateTaxes(payload); } - async createOrder(order: OrderCreatedSubscriptionFragment) { - return this.client.createOrder(order); + async confirmOrder(order: OrderConfirmedSubscriptionFragment) { + return this.client.confirmOrder(order); } + /** + * @deprecated This method is deprecated and will be removed in the future. + */ async fulfillOrder(payload: OrderFulfilledSubscriptionFragment) { return this.client.fulfillOrder(payload); } + + async cancelOrder(payload: OrderCancelledPayload) { + this.client.cancelOrder(payload); + } + + /** + * @deprecated This method is deprecated and will be removed in the future. + */ + async createOrder(payload: OrderCreatedSubscriptionFragment) { + return this.client.createOrder(payload); + } } export function getActiveConnectionService( diff --git a/apps/taxes/src/modules/taxes/tax-provider-utils.ts b/apps/taxes/src/modules/taxes/tax-provider-utils.ts index 568ee54..31f81b6 100644 --- a/apps/taxes/src/modules/taxes/tax-provider-utils.ts +++ b/apps/taxes/src/modules/taxes/tax-provider-utils.ts @@ -1,10 +1,12 @@ +import { z } from "zod"; + /* * The providers sdk types claim to sometimes return undefined. * If it ever happens, we have nothing to fall back to, so we throw an error. * Should only be used for values that are required for further calculation. */ -function resolveOptionalOrThrow(value: T | undefined, error?: Error): T { - if (value === undefined) { +function resolveOptionalOrThrow(value: T | undefined | null, error?: Error): T { + if (value === undefined || value === null) { throw error ? error : new Error("Could not resolve data. Value needed for further calculation is undefined."); @@ -13,6 +15,11 @@ function resolveOptionalOrThrow(value: T | undefined, error?: Error): T { return value; } +function resolveStringOrThrow(value: string | undefined | null): string { + return z.string().min(1, { message: "This field can not be empty." }).parse(value); +} + export const taxProviderUtils = { resolveOptionalOrThrow, + resolveStringOrThrow, }; diff --git a/apps/taxes/src/modules/taxes/tax-provider-webhook.ts b/apps/taxes/src/modules/taxes/tax-provider-webhook.ts index a516f60..e2d830c 100644 --- a/apps/taxes/src/modules/taxes/tax-provider-webhook.ts +++ b/apps/taxes/src/modules/taxes/tax-provider-webhook.ts @@ -1,10 +1,11 @@ import { SyncWebhookResponsesMap } from "@saleor/app-sdk/handlers/next"; import { + OrderConfirmedSubscriptionFragment, OrderCreatedSubscriptionFragment, OrderFulfilledSubscriptionFragment, TaxBaseFragment, } from "../../../generated/graphql"; -import { ChannelConfig } from "../channel-configuration/channel-config"; +import { OrderCancelledPayload } from "../../pages/api/webhooks/order-cancelled"; export type CalculateTaxesResponse = SyncWebhookResponsesMap["ORDER_CALCULATE_TAXES"]; @@ -12,6 +13,15 @@ export type CreateOrderResponse = { id: string }; export interface ProviderWebhookService { calculateTaxes: (payload: TaxBaseFragment) => Promise; - createOrder: (payload: OrderCreatedSubscriptionFragment) => Promise; + confirmOrder: (payload: OrderConfirmedSubscriptionFragment) => Promise; + cancelOrder: (payload: OrderCancelledPayload) => Promise; + + /** + * @deprecated This method is deprecated and will be removed in the future. + */ fulfillOrder: (payload: OrderFulfilledSubscriptionFragment) => Promise<{ ok: boolean }>; + /** + * @deprecated This method is deprecated and will be removed in the future. + */ + createOrder: (payload: OrderCreatedSubscriptionFragment) => Promise; } diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-adapter.test.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-adapter.test.ts new file mode 100644 index 0000000..000395c --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-adapter.test.ts @@ -0,0 +1,3 @@ +import { describe } from "vitest"; + +describe.todo("TaxJarOrderConfirmedAdapter", () => {}); diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-adapter.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-adapter.ts new file mode 100644 index 0000000..f5aa737 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-adapter.ts @@ -0,0 +1,43 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { OrderConfirmedSubscriptionFragment } 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 { TaxJarOrderConfirmedPayloadService } from "./taxjar-order-confirmed-payload.service"; +import { TaxJarOrderConfirmedResponseTransformer } from "./taxjar-order-confirmed-response-transformer"; + +export type TaxJarOrderConfirmedPayload = { + order: OrderConfirmedSubscriptionFragment; +}; +export type TaxJarOrderConfirmedTarget = CreateOrderArgs; +export type TaxJarOrderConfirmedResponse = CreateOrderResponse; + +export class TaxJarOrderConfirmedAdapter + implements WebhookAdapter +{ + private logger: Logger; + constructor(private readonly config: TaxJarConfig, private authData: AuthData) { + this.logger = createLogger({ name: "TaxJarOrderConfirmedAdapter" }); + } + + async send(payload: TaxJarOrderConfirmedPayload): Promise { + this.logger.debug("Transforming the Saleor payload for creating order with TaxJar..."); + const payloadService = new TaxJarOrderConfirmedPayloadService(this.authData); + const target = await payloadService.getPayload(payload.order, this.config); + + this.logger.debug("Calling TaxJar fetchTaxForOrder with transformed payload..."); + + const client = new TaxJarClient(this.config); + const response = await client.createOrder(target); + + this.logger.debug("TaxJar createOrder successfully responded"); + const responseTransformer = new TaxJarOrderConfirmedResponseTransformer(); + const transformedResponse = responseTransformer.transform(response); + + this.logger.debug("Transformed TaxJar createOrder response"); + + return transformedResponse; + } +} diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-mock-generator.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-mock-generator.ts new file mode 100644 index 0000000..b2f37b5 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-mock-generator.ts @@ -0,0 +1,117 @@ +import { CreateOrderRes } from "taxjar/dist/types/returnTypes"; +import { OrderConfirmedSubscriptionFragment } from "../../../../generated/graphql"; +import { defaultOrder } from "../../../mocks"; +import { ChannelConfig } from "../../channel-configuration/channel-config"; +import { ChannelConfigMockGenerator } from "../../channel-configuration/channel-config-mock-generator"; +import { TaxJarConfig } from "../taxjar-connection-schema"; + +type Order = OrderConfirmedSubscriptionFragment; + +// providerConfigMockGenerator class that other classes extend? +const defaultProviderConfig: TaxJarConfig = { + name: "taxjar-1", + credentials: { + apiKey: "test", + }, + isSandbox: false, + address: { + country: "US", + zip: "95008", + state: "CA", + city: "Campbell", + street: "33 N. First Street", + }, +}; + +const defaultOrderConfirmedResponse: CreateOrderRes = { + order: { + user_id: 314973, + transaction_reference_id: null, + transaction_id: "T3JkZXI6ZTUzZTBlM2MtMjk5Yi00OWYxLWIyZDItY2Q4NWExYTgxYjY2", + transaction_date: "2023-05-25T09:18:55.203Z", + to_zip: "94111", + to_street: "600 Montgomery St", + to_state: "CA", + to_country: "US", + to_city: "SAN FRANCISCO", + shipping: 59.17, + sales_tax: 0.0, + provider: "api", + line_items: [ + { + unit_price: 20.0, + sales_tax: 5.18, + quantity: 3, + product_tax_code: "", + product_identifier: "328223580", + id: "0", + discount: 0.0, + description: "Monospace Tee", + }, + { + unit_price: 20.0, + sales_tax: 1.73, + quantity: 1, + product_tax_code: "", + product_identifier: "328223581", + id: "1", + discount: 0.0, + description: "Monospace Tee", + }, + { + unit_price: 50.0, + sales_tax: 8.63, + quantity: 2, + product_tax_code: "", + product_identifier: "118223581", + id: "2", + discount: 0.0, + description: "Paul's Balance 420", + }, + ], + from_zip: "95008", + from_street: "33 N. First Street", + from_state: "CA", + from_country: "US", + from_city: "CAMPBELL", + exemption_type: null, + amount: 239.17, + }, +}; + +const testingScenariosMap = { + default: { + order: defaultOrder, + response: defaultOrderConfirmedResponse, + providerConfig: defaultProviderConfig, + }, +}; + +type TestingScenario = keyof typeof testingScenariosMap; + +export class TaxJarOrderConfirmedMockGenerator { + constructor(private scenario: TestingScenario = "default") {} + generateOrder = (overrides: Partial = {}): Order => + structuredClone({ + ...testingScenariosMap[this.scenario].order, + ...overrides, + }); + + generateChannelConfig = (overrides: Partial = {}): ChannelConfig => { + const mockGenerator = new ChannelConfigMockGenerator(); + + return mockGenerator.generateChannelConfig(overrides); + }; + + generateResponse = (overrides: Partial = {}): CreateOrderRes => + structuredClone({ + ...testingScenariosMap[this.scenario].response, + ...overrides, + }); + + generateProviderConfig = (overrides: Partial = {}): TaxJarConfig => + structuredClone({ + ...testingScenariosMap[this.scenario].providerConfig, + ...overrides, + }); +} diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-lines-transformer.test.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-lines-transformer.test.ts new file mode 100644 index 0000000..8c741ed --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-lines-transformer.test.ts @@ -0,0 +1,91 @@ +import { OrderConfirmedSubscriptionFragment } from "../../../../generated/graphql"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; + +import { describe, expect, it } from "vitest"; +import { TaxJarOrderConfirmedPayloadLinesTransformer } from "./taxjar-order-confirmed-payload-lines-transformer"; + +const transformer = new TaxJarOrderConfirmedPayloadLinesTransformer(); + +const mockedLines: OrderConfirmedSubscriptionFragment["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("TaxJarOrderConfirmedPayloadLinesTransformer", () => { + 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-confirmed/taxjar-order-confirmed-payload-lines-transformer.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-lines-transformer.ts new file mode 100644 index 0000000..1443994 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-lines-transformer.ts @@ -0,0 +1,25 @@ +import { LineItem } from "taxjar/dist/util/types"; +import { OrderConfirmedSubscriptionFragment } from "../../../../generated/graphql"; +import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository"; +import { TaxJarOrderConfirmedTaxCodeMatcher } from "./taxjar-order-confirmed-tax-code-matcher"; + +export class TaxJarOrderConfirmedPayloadLinesTransformer { + transform( + lines: OrderConfirmedSubscriptionFragment["lines"], + matches: TaxJarTaxCodeMatches + ): LineItem[] { + return lines.map((line) => { + const matcher = new TaxJarOrderConfirmedTaxCodeMatcher(); + 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-confirmed/taxjar-order-confirmed-payload-transformer.test.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-transformer.test.ts new file mode 100644 index 0000000..89834bc --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-transformer.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { TaxJarOrderConfirmedMockGenerator } from "./taxjar-order-confirmed-mock-generator"; +import { + TaxJarOrderConfirmedPayloadTransformer, + sumPayloadLines, +} from "./taxjar-order-confirmed-payload-transformer"; + +const mockGenerator = new TaxJarOrderConfirmedMockGenerator(); + +describe("TaxJarOrderConfirmedPayloadTransformer", () => { + it("returns the correct order amount", () => { + const orderMock = mockGenerator.generateOrder(); + const providerConfig = mockGenerator.generateProviderConfig(); + const transformer = new TaxJarOrderConfirmedPayloadTransformer(); + const transformedPayload = transformer.transform(orderMock, providerConfig, []); + + expect(transformedPayload.params.amount).toBe(239.17); + }); +}); + +describe("sumPayloadLines", () => { + it("returns the sum of all line items when items quantity = 1", () => { + const result = sumPayloadLines([ + { + quantity: 1, + unit_price: 90.45, + product_identifier: "328223581", + }, + { + quantity: 1, + unit_price: 45.25, + product_identifier: "328223580", + }, + ]); + + expect(result).toBe(135.7); + }); + it("returns the sum of all line items when items quantity > 1", () => { + const result = sumPayloadLines([ + { + quantity: 3, + unit_price: 90.45, + product_identifier: "328223581", + }, + { + quantity: 2, + unit_price: 45.25, + product_identifier: "328223580", + }, + { + quantity: 1, + unit_price: 50.25, + product_identifier: "328223580", + }, + ]); + + expect(result).toBe(412.1); + }); +}); diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-transformer.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-transformer.ts new file mode 100644 index 0000000..f4d6bf7 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload-transformer.ts @@ -0,0 +1,70 @@ +import { LineItem } from "taxjar/dist/util/types"; +import { OrderConfirmedSubscriptionFragment } 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 { TaxJarOrderConfirmedTarget } from "./taxjar-order-confirmed-adapter"; +import { TaxJarOrderConfirmedPayloadLinesTransformer } from "./taxjar-order-confirmed-payload-lines-transformer"; + +// todo: refactor to a move descriptive class +export function sumPayloadLines(lines: LineItem[]): number { + return numbers.roundFloatToTwoDecimals( + lines.reduce( + (prev, line) => + prev + + taxProviderUtils.resolveOptionalOrThrow( + line.unit_price, + new Error("Line unit_price is required to calculate order taxes") + ) * + taxProviderUtils.resolveOptionalOrThrow( + line.quantity, + new Error("Line quantity is required to calculate order taxes") + ), + 0 + ) + ); +} + +export class TaxJarOrderConfirmedPayloadTransformer { + transform( + order: OrderConfirmedSubscriptionFragment, + taxJarConfig: TaxJarConfig, + matches: TaxJarTaxCodeMatches + ): TaxJarOrderConfirmedTarget { + const linesTransformer = new TaxJarOrderConfirmedPayloadLinesTransformer(); + const lineItems = linesTransformer.transform(order.lines, matches); + const lineSum = sumPayloadLines(lineItems); + const shippingAmount = order.shippingPrice.gross.amount; + /** + * "The TaxJar API performs arbitrary-precision decimal arithmetic for accurately calculating sales tax." + * but we want to round to 2 decimals for consistency + */ + const orderAmount = numbers.roundFloatToTwoDecimals(shippingAmount + lineSum); + + return { + params: { + 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, + to_city: order.shippingAddress!.city, + to_street: `${order.shippingAddress!.streetAddress1} ${ + order.shippingAddress!.streetAddress2 + }`, + shipping: shippingAmount, + line_items: lineItems, + transaction_date: order.created, + transaction_id: order.id, + amount: orderAmount, + + // todo: add sales_tax + sales_tax: 0, + }, + }; + } +} diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload.service.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload.service.ts new file mode 100644 index 0000000..b31501f --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-payload.service.ts @@ -0,0 +1,26 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { OrderConfirmedSubscriptionFragment } from "../../../../generated/graphql"; +import { TaxJarTaxCodeMatchesService } from "../tax-code/taxjar-tax-code-matches.service"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { TaxJarOrderConfirmedPayloadTransformer } from "./taxjar-order-confirmed-payload-transformer"; +import { CreateOrderArgs } from "../taxjar-client"; + +export class TaxJarOrderConfirmedPayloadService { + constructor(private authData: AuthData) {} + + private getMatches() { + const taxCodeMatchesService = new TaxJarTaxCodeMatchesService(this.authData); + + return taxCodeMatchesService.getAll(); + } + + async getPayload( + order: OrderConfirmedSubscriptionFragment, + taxJarConfig: TaxJarConfig + ): Promise { + const matches = await this.getMatches(); + const payloadTransformer = new TaxJarOrderConfirmedPayloadTransformer(); + + return payloadTransformer.transform(order, taxJarConfig, matches); + } +} diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-response-transformer.test.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-response-transformer.test.ts new file mode 100644 index 0000000..b08cd74 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-response-transformer.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { TaxJarOrderConfirmedMockGenerator } from "./taxjar-order-confirmed-mock-generator"; +import { TaxJarOrderConfirmedResponseTransformer } from "./taxjar-order-confirmed-response-transformer"; + +describe("TaxJarOrderConfirmedResponseTransformer", () => { + it("returns orded id in response", () => { + const mockGenerator = new TaxJarOrderConfirmedMockGenerator(); + const responseMock = mockGenerator.generateResponse(); + const transformer = new TaxJarOrderConfirmedResponseTransformer(); + const result = transformer.transform(responseMock); + + expect(result).toEqual({ + id: "T3JkZXI6ZTUzZTBlM2MtMjk5Yi00OWYxLWIyZDItY2Q4NWExYTgxYjY2", + }); + }); +}); diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-response-transformer.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-response-transformer.ts new file mode 100644 index 0000000..9185b8f --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-response-transformer.ts @@ -0,0 +1,10 @@ +import { CreateOrderRes } from "taxjar/dist/types/returnTypes"; +import { CreateOrderResponse } from "../../taxes/tax-provider-webhook"; + +export class TaxJarOrderConfirmedResponseTransformer { + transform(response: CreateOrderRes): CreateOrderResponse { + return { + id: response.order.transaction_id, + }; + } +} diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-tax-code-matcher.test.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-tax-code-matcher.test.ts new file mode 100644 index 0000000..15fca46 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-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 { TaxJarOrderConfirmedTaxCodeMatcher } from "./taxjar-order-confirmed-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("TaxJarOrderConfirmedTaxCodeMatcher", () => { + it("should return empty string if tax class is not found", () => { + const matcher = new TaxJarOrderConfirmedTaxCodeMatcher(); + + 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 TaxJarOrderConfirmedTaxCodeMatcher(); + + expect(matcher.match(line, matches)).toEqual("P0000000"); + }); +}); diff --git a/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-tax-code-matcher.ts b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-tax-code-matcher.ts new file mode 100644 index 0000000..e3e745e --- /dev/null +++ b/apps/taxes/src/modules/taxjar/order-confirmed/taxjar-order-confirmed-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 TaxJarOrderConfirmedTaxCodeMatcher { + 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 120c0a4..11c09da 100644 --- a/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts +++ b/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts @@ -1,11 +1,17 @@ -import { OrderCreatedSubscriptionFragment, TaxBaseFragment } from "../../../generated/graphql"; +import { AuthData } from "@saleor/app-sdk/APL"; +import { + OrderCancelledEventSubscriptionFragment, + OrderConfirmedSubscriptionFragment, + OrderCreatedSubscriptionFragment, + TaxBaseFragment, +} from "../../../generated/graphql"; import { Logger, createLogger } from "../../lib/logger"; +import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; import { TaxJarCalculateTaxesAdapter } from "./calculate-taxes/taxjar-calculate-taxes-adapter"; +import { TaxJarOrderConfirmedAdapter } from "./order-confirmed/taxjar-order-confirmed-adapter"; +import { TaxJarOrderCreatedAdapter } from "./order-created/taxjar-order-created-adapter"; import { TaxJarClient } from "./taxjar-client"; import { TaxJarConfig } from "./taxjar-connection-schema"; -import { TaxJarOrderCreatedAdapter } from "./order-created/taxjar-order-created-adapter"; -import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; -import { AuthData } from "@saleor/app-sdk/APL"; export class TaxJarWebhookService implements ProviderWebhookService { client: TaxJarClient; @@ -30,16 +36,33 @@ export class TaxJarWebhookService implements ProviderWebhookService { return response; } - async createOrder(order: OrderCreatedSubscriptionFragment) { - const adapter = new TaxJarOrderCreatedAdapter(this.config, this.authData); + async confirmOrder(order: OrderConfirmedSubscriptionFragment) { + const adapter = new TaxJarOrderConfirmedAdapter(this.config, this.authData); const response = await adapter.send({ order }); return response; } - // * TaxJar doesn't require any action on order fulfillment + /** + * @deprecated This method is deprecated and will be removed in the future. + */ + async createOrder(payload: OrderCreatedSubscriptionFragment) { + const adapter = new TaxJarOrderCreatedAdapter(this.config, this.authData); + + const response = await adapter.send({ order: payload }); + + return response; + } + + /** + * @deprecated This method is deprecated and will be removed in the future. + */ async fulfillOrder() { return { ok: true }; } + + async cancelOrder(payload: OrderCancelledEventSubscriptionFragment) { + // TaxJar isn't implemented yet + } } diff --git a/apps/taxes/src/pages/api/manifest.ts b/apps/taxes/src/pages/api/manifest.ts index 2cd6910..82b6e9d 100644 --- a/apps/taxes/src/pages/api/manifest.ts +++ b/apps/taxes/src/pages/api/manifest.ts @@ -4,9 +4,11 @@ import { AppManifest } from "@saleor/app-sdk/types"; import packageJson from "../../../package.json"; import { checkoutCalculateTaxesSyncWebhook } from "./webhooks/checkout-calculate-taxes"; import { orderCalculateTaxesSyncWebhook } from "./webhooks/order-calculate-taxes"; -import { orderCreatedAsyncWebhook } from "./webhooks/order-created"; -import { orderFulfilledAsyncWebhook } from "./webhooks/order-fulfilled"; +import { orderConfirmedAsyncWebhook } from "./webhooks/order-confirmed"; import { REQUIRED_SALEOR_VERSION } from "../../../saleor-app"; +import { orderCancelledAsyncWebhook } from "./webhooks/order-cancelled"; +import { orderFulfilledAsyncWebhook } from "./webhooks/order-fulfilled"; +import { orderCreatedAsyncWebhook } from "./webhooks/order-created"; export default createManifestHandler({ async manifestFactory({ appBaseUrl }) { @@ -35,8 +37,10 @@ export default createManifestHandler({ webhooks: [ orderCalculateTaxesSyncWebhook.getWebhookManifest(apiBaseURL), checkoutCalculateTaxesSyncWebhook.getWebhookManifest(apiBaseURL), - orderCreatedAsyncWebhook.getWebhookManifest(apiBaseURL), + orderConfirmedAsyncWebhook.getWebhookManifest(apiBaseURL), + orderCancelledAsyncWebhook.getWebhookManifest(apiBaseURL), orderFulfilledAsyncWebhook.getWebhookManifest(apiBaseURL), + orderCreatedAsyncWebhook.getWebhookManifest(apiBaseURL), ], }; 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 3987f59..af9bd3e 100644 --- a/apps/taxes/src/pages/api/webhooks/checkout-calculate-taxes.ts +++ b/apps/taxes/src/pages/api/webhooks/checkout-calculate-taxes.ts @@ -46,12 +46,7 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res, try { verifyCalculateTaxesPayload(payload); logger.debug("Payload validated succesfully"); - } catch (error) { - logger.debug("Payload validation failed"); - return webhookResponse.error(error); - } - try { const appMetadata = payload.recipient?.privateMetadata ?? []; const channelSlug = payload.taxBase.channel.slug; const activeConnectionService = getActiveConnectionService( 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 ec54daf..e8ed2ed 100644 --- a/apps/taxes/src/pages/api/webhooks/order-calculate-taxes.ts +++ b/apps/taxes/src/pages/api/webhooks/order-calculate-taxes.ts @@ -46,12 +46,7 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx try { verifyCalculateTaxesPayload(payload); logger.debug("Payload validated succesfully"); - } catch (error) { - logger.debug("Payload validation failed"); - return webhookResponse.error(error); - } - try { const appMetadata = payload.recipient?.privateMetadata ?? []; const channelSlug = payload.taxBase.channel.slug; const activeConnectionService = getActiveConnectionService( diff --git a/apps/taxes/src/pages/api/webhooks/order-cancelled.ts b/apps/taxes/src/pages/api/webhooks/order-cancelled.ts new file mode 100644 index 0000000..1f2aa9f --- /dev/null +++ b/apps/taxes/src/pages/api/webhooks/order-cancelled.ts @@ -0,0 +1,55 @@ +import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { + OrderCancelledEventSubscriptionFragment, + UntypedOrderCancelledSubscriptionDocument, +} from "../../../../generated/graphql"; +import { saleorApp } from "../../../../saleor-app"; +import { createLogger } from "../../../lib/logger"; +import { getActiveConnectionService } from "../../../modules/taxes/get-active-connection-service"; +import { WebhookResponse } from "../../../modules/app/webhook-response"; +export const config = { + api: { + bodyParser: false, + }, +}; + +export type OrderCancelledPayload = Extract< + OrderCancelledEventSubscriptionFragment, + { __typename: "OrderCancelled" } +>; + +export const orderCancelledAsyncWebhook = new SaleorAsyncWebhook({ + name: "OrderCancelled", + apl: saleorApp.apl, + event: "ORDER_CANCELLED", + query: UntypedOrderCancelledSubscriptionDocument, + webhookPath: "/api/webhooks/order-cancelled", +}); + +export default orderCancelledAsyncWebhook.createHandler(async (req, res, ctx) => { + const logger = createLogger({ event: ctx.event }); + const { payload } = ctx; + const webhookResponse = new WebhookResponse(res); + + logger.info("Handler called with payload"); + + if (!payload.order) { + return webhookResponse.error(new Error("Insufficient order data")); + } + + try { + const appMetadata = payload.recipient?.privateMetadata ?? []; + const channelSlug = payload.order.channel.slug; + const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData); + + logger.info("Cancelling order..."); + + await taxProvider.cancelOrder(payload); + + logger.info("Order cancelled"); + + return webhookResponse.success(); + } catch (error) { + return webhookResponse.error(new Error("Error while cancelling tax provider order")); + } +}); diff --git a/apps/taxes/src/pages/api/webhooks/order-confirmed.ts b/apps/taxes/src/pages/api/webhooks/order-confirmed.ts new file mode 100644 index 0000000..2d479f2 --- /dev/null +++ b/apps/taxes/src/pages/api/webhooks/order-confirmed.ts @@ -0,0 +1,78 @@ +import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { + OrderConfirmedEventSubscriptionFragment, + OrderStatus, + UntypedOrderConfirmedSubscriptionDocument, +} from "../../../../generated/graphql"; +import { saleorApp } from "../../../../saleor-app"; +import { createLogger } from "../../../lib/logger"; +import { getActiveConnectionService } from "../../../modules/taxes/get-active-connection-service"; +import { WebhookResponse } from "../../../modules/app/webhook-response"; +import { createGraphQLClient } from "@saleor/apps-shared"; +import { OrderMetadataManager } from "../../../modules/app/order-metadata-manager"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +type OrderConfirmedPayload = Extract< + OrderConfirmedEventSubscriptionFragment, + { __typename: "OrderConfirmed" } +>; + +export const orderConfirmedAsyncWebhook = new SaleorAsyncWebhook({ + name: "OrderConfirmed", + apl: saleorApp.apl, + event: "ORDER_CONFIRMED", + query: UntypedOrderConfirmedSubscriptionDocument, + webhookPath: "/api/webhooks/order-confirmed", +}); + +export default orderConfirmedAsyncWebhook.createHandler(async (req, res, ctx) => { + const logger = createLogger({ event: ctx.event }); + const { payload, authData } = ctx; + const { saleorApiUrl, token } = authData; + const webhookResponse = new WebhookResponse(res); + + logger.info("Handler called with payload"); + + try { + const appMetadata = payload.recipient?.privateMetadata ?? []; + const channelSlug = payload.order?.channel.slug; + const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData); + + // todo: figure out what fields are needed and add validation + if (!payload.order) { + return webhookResponse.error(new Error("Insufficient order data")); + } + + if (payload.order.status === OrderStatus.Fulfilled) { + return webhookResponse.error(new Error("Skipping fulfilled order to prevent duplication")); + } + + logger.info("Confirming order..."); + + const confirmedOrder = await taxProvider.confirmOrder(payload.order); + + logger.info({ confirmedOrder }, "Order confirmed"); + const client = createGraphQLClient({ + saleorApiUrl, + token, + }); + + const orderMetadataManager = new OrderMetadataManager(client); + + await orderMetadataManager.updateOrderMetadataWithExternalId( + payload.order.id, + confirmedOrder.id + ); + logger.info("Updated order metadata with externalId"); + + return webhookResponse.success(); + } catch (error) { + logger.error({ error }); + return webhookResponse.error(error); + } +}); diff --git a/apps/taxes/src/pages/api/webhooks/order-created.ts b/apps/taxes/src/pages/api/webhooks/order-created.ts index 76e12c2..fcd6b17 100644 --- a/apps/taxes/src/pages/api/webhooks/order-created.ts +++ b/apps/taxes/src/pages/api/webhooks/order-created.ts @@ -3,17 +3,13 @@ import { OrderCreatedEventSubscriptionFragment, OrderStatus, UntypedOrderCreatedSubscriptionDocument, - UpdateMetadataDocument, - UpdateMetadataMutation, - UpdateMetadataMutationVariables, } from "../../../../generated/graphql"; import { saleorApp } from "../../../../saleor-app"; import { createLogger } from "../../../lib/logger"; import { getActiveConnectionService } from "../../../modules/taxes/get-active-connection-service"; -import { Client } from "urql"; import { WebhookResponse } from "../../../modules/app/webhook-response"; -import { PROVIDER_ORDER_ID_KEY } from "../../../modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer"; import { createGraphQLClient } from "@saleor/apps-shared"; +import { OrderMetadataManager } from "../../../modules/app/order-metadata-manager"; export const config = { api: { @@ -26,6 +22,9 @@ type OrderCreatedPayload = Extract< { __typename: "OrderCreated" } >; +/** + * @deprecated This handler is deprecated and will be removed in the future. + */ export const orderCreatedAsyncWebhook = new SaleorAsyncWebhook({ name: "OrderCreated", apl: saleorApp.apl, @@ -34,35 +33,6 @@ export const orderCreatedAsyncWebhook = new SaleorAsyncWebhook(UpdateMetadataDocument, variables) - .toPromise(); - - if (error) { - throw error; - } - - return { ok: true }; -} - export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => { const logger = createLogger({ event: ctx.event }); const { payload, authData } = ctx; @@ -76,8 +46,6 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => { const channelSlug = payload.order?.channel.slug; const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData); - logger.info("Fetched taxProvider"); - // todo: figure out what fields are needed and add validation if (!payload.order) { return webhookResponse.error(new Error("Insufficient order data")); @@ -87,6 +55,8 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => { return webhookResponse.error(new Error("Skipping fulfilled order to prevent duplication")); } + logger.info("Creating order..."); + const createdOrder = await taxProvider.createOrder(payload.order); logger.info({ createdOrder }, "Order created"); @@ -95,12 +65,14 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => { token, }); - await updateOrderMetadataWithExternalId(client, payload.order.id, createdOrder.id); + const orderMetadataManager = new OrderMetadataManager(client); + + await orderMetadataManager.updateOrderMetadataWithExternalId(payload.order.id, createdOrder.id); logger.info("Updated order metadata with externalId"); return webhookResponse.success(); } catch (error) { logger.error({ error }); - return webhookResponse.error(new Error("Error while creating order in tax provider")); + return webhookResponse.error(error); } }); diff --git a/apps/taxes/src/pages/api/webhooks/order-fulfilled.ts b/apps/taxes/src/pages/api/webhooks/order-fulfilled.ts index b7278f3..a26fe12 100644 --- a/apps/taxes/src/pages/api/webhooks/order-fulfilled.ts +++ b/apps/taxes/src/pages/api/webhooks/order-fulfilled.ts @@ -18,6 +18,9 @@ type OrderFulfilledPayload = Extract< { __typename: "OrderFulfilled" } >; +/** + * @deprecated This handler is deprecated and will be removed in the future. + */ export const orderFulfilledAsyncWebhook = new SaleorAsyncWebhook({ name: "OrderFulfilled", apl: saleorApp.apl, @@ -38,18 +41,18 @@ export default orderFulfilledAsyncWebhook.createHandler(async (req, res, ctx) => const channelSlug = payload.order?.channel.slug; const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData); - logger.info("Fetched taxProvider"); - // todo: figure out what fields are needed and add validation if (!payload.order) { return webhookResponse.error(new Error("Insufficient order data")); } + logger.info("Fulfilling order..."); + await taxProvider.fulfillOrder(payload.order); logger.info("Order fulfilled"); return webhookResponse.success(); } catch (error) { - return webhookResponse.error(new Error("Error while fulfilling tax provider order")); + return webhookResponse.error(error); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ac3c5e..41765d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1338,8 +1338,8 @@ importers: specifier: ^2.1.4 version: 2.1.4(graphql@16.7.1) avatax: - specifier: ^23.3.2 - version: 23.3.2 + specifier: ^23.7.0 + version: 23.7.0 clsx: specifier: ^1.2.1 version: 1.2.1 @@ -11440,8 +11440,8 @@ packages: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} - /avatax@23.3.2: - resolution: {integrity: sha512-WGjSIQNvdM0XSyukjb+QIxB1z4xaEOdaGmPhXwLJYgxIuJlDw7Uq1nHUHo+yeHKZk3dKGzCyrFaa8N1Wx9nSbQ==} + /avatax@23.7.0: + resolution: {integrity: sha512-34CuafkIgzfj6B8gTMmeUN5iYWdgHtSKRwIRWIYFcP/OpUMDOGB0WMMdGrZUgthLjYCWOkQdi2QReWuM/TSROg==} dependencies: '@types/node-fetch': 2.6.4 form-data: 4.0.0