feat: change the flow from OrderCreated to OrderConfirmed (#826)

* refactor: ♻️ extract order-metadata-manager

* feat: 🚧 add basic boilerplate

* feat:  add readExternalIdFromOrderMetadata

* Revert "feat:  add readExternalIdFromOrderMetadata"

This reverts commit a78d9d4597672f8605cf998a9f784aebaab27de1.

* feat:  add order-cancelled avatax adapter

* test:  add tests for AvataxOrderCancelledPayloadTransformer

* refactor: avataxId instead of externalId

* refactor: ♻️ split up webhook response

* build: ⬆️ upgrade avatax

* refactor: ♻️ extend logging in webhook response errors

* fix: 🐛 split privateMetadata and publicMetadata

* fix: 🐛 use "DEFAULT" value of companyCode for commit to work

* fix: ⚗️ fix voidTransaction type

* refactor: 🚚 order_created -> order_confirmed

* fix: 🐛 change voidReason

* build: 👷 add changeset

* refactor: 🔥 order_fulfilled webhook

* feat: Avatax metadata tax calculation date (#843)

* feat:  add metadata tax calculation date

* build: 👷 add changeset

* feat: Avatax metadata document code (#844)

* feat:  provide document code through metadata field

* build: 👷 add changeset

* refactor: ♻️ fallback to default company code for migration

* refactor: ♻️ patch order-created files and add deprecation note

* Revert "refactor: 🔥 order_fulfilled webhook"

This reverts commit fd098642735ae9d62e3a876088226bd0f108afd6.

* refactor: ♻️ patch order-fulfilled files and add deprecation note

* fix: 🐛 bring back deprecated webhooks to manifest

* feat: ⚗️ add AppWebhookMigrator (#850)

* refactor: 🚚 order_created -> order_confirmed

* refactor: 🔥 order_fulfilled webhook

* feat: ⚗️ add AppWebhookMigrator

* feat:  add mode to migrator

* feat:  add draft of run-report and migrateWebhook method

* refactor: ♻️ address feedback

* feat:  add tests and new structure

* refactor: 🔥 util

* feat:  add enable/disable webhook rollback flow

* refactor: ♻️ modify the taxes-migration flow

* refactor: ♻️ generalize document code & date resolver

* chore: 🗃️ add run-migration

* chore: 💡 update comments about migration flow

* fix: 🐛 slice document code

* refactor: ♻️ try/catch at the top level

* chore: 💡 add comments

* Update shiny-meals-wait.md

* Update soft-steaks-know.md

* Update soft-steaks-know.md

* fix:  fix test

* feat:  change createTransaction to createOrAdjustTransaction

this feature grants idempotency of the transaction flow

* feat:  add number field to OrderConfirmed payload

* chore: 💡 add deprecation comment to metadata method

* docs: 📝 add todo comment to refactor sumPayloadLines

* feat:  add resolveStringOrThrow and use it for email

* fix: 🐛 add missing number to mock
This commit is contained in:
Adrian Pilarczyk 2023-08-10 13:08:20 +02:00 committed by GitHub
parent c7b3d35f5d
commit 416c92fb6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 4308 additions and 160 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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").

View file

@ -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.

View file

@ -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`.

View file

@ -1,5 +1,5 @@
schema: graphql/schema.graphql schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx] documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx, scripts/migrations/**/*.ts]
extensions: extensions:
codegen: codegen:
overwrite: true overwrite: true

View file

@ -1,4 +1,4 @@
mutation UpdateMetadata($id: ID!, $input: [MetadataInput!]!) { mutation UpdatePrivateMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) { updatePrivateMetadata(id: $id, input: $input) {
item { item {
privateMetadata { privateMetadata {

View file

@ -0,0 +1,10 @@
mutation UpdatePublicMetadata($id: ID!, $input: [MetadataInput!]!) {
updateMetadata(id: $id, input: $input) {
item {
metadata {
key
value
}
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -65,6 +65,8 @@ fragment OrderCreatedSubscription on Order {
} }
} }
avataxEntityCode: metafield(key: "avataxEntityCode") avataxEntityCode: metafield(key: "avataxEntityCode")
avataxTaxCalculationDate: metafield(key: "avataxTaxCalculationDate")
avataxDocumentCode: metafield(key: "avataxDocumentCode")
} }
fragment OrderCreatedEventSubscription on Event { fragment OrderCreatedEventSubscription on Event {
__typename __typename

View file

@ -47,10 +47,7 @@ fragment OrderFulfilledSubscription on Order {
amount amount
} }
} }
privateMetadata { avataxId: metafield(key: "avataxId")
key
value
}
lines { lines {
...OrderLine ...OrderLine
} }

View file

@ -25,7 +25,7 @@
"@trpc/react-query": "10.34.0", "@trpc/react-query": "10.34.0",
"@trpc/server": "10.34.0", "@trpc/server": "10.34.0",
"@urql/exchange-auth": "^2.1.4", "@urql/exchange-auth": "^2.1.4",
"avatax": "^23.3.2", "avatax": "^23.7.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"graphql": "16.7.1", "graphql": "16.7.1",

View file

@ -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");
});
});
});
});

View file

@ -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
);
}

View file

@ -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();
});
});
});

View file

@ -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<FetchAppWebhooksQuery>(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<CreateAppWebhookMutation>(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<DisableWebhookMutation>(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<EnableWebhookMutation>(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<DeleteAppWebhookMutation>(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;
}
}

View file

@ -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();
};

View file

@ -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();

View file

@ -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();

View file

@ -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");
}

View file

@ -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", id: "T3JkZXI6ZTUzZTBlM2MtMjk5Yi00OWYxLWIyZDItY2Q4NWExYTgxYjY2",
user: { user: {
id: "VXNlcjoyMDg0NTEwNDEw", id: "VXNlcjoyMDg0NTEwNDEw",
email: "happy.customer@saleor.io", email: "happy.customer@saleor.io",
}, },
number: "1234",
avataxEntityCode: null, avataxEntityCode: null,
created: "2023-05-25T09:18:55.203440+00:00", created: "2023-05-25T09:18:55.203440+00:00",
status: OrderStatus.Unfulfilled, status: OrderStatus.Unfulfilled,

View file

@ -3,7 +3,7 @@ import { Client, gql } from "urql";
import { import {
FetchAppDetailsDocument, FetchAppDetailsDocument,
FetchAppDetailsQuery, FetchAppDetailsQuery,
UpdateMetadataDocument, UpdatePrivateMetadataDocument,
} from "../../../generated/graphql"; } from "../../../generated/graphql";
gql` gql`
@ -45,7 +45,7 @@ export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]>
export async function mutateMetadata(client: Client, metadata: MetadataEntry[], appId: string) { export async function mutateMetadata(client: Client, metadata: MetadataEntry[], appId: string) {
const { error: mutationError, data: mutationData } = await client const { error: mutationError, data: mutationData } = await client
.mutation(UpdateMetadataDocument, { .mutation(UpdatePrivateMetadataDocument, {
id: appId, id: appId,
input: metadata, input: metadata,
}) })

View file

@ -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<UpdatePublicMetadataMutation>(UpdatePublicMetadataDocument, variables)
.toPromise();
if (error) {
throw error;
}
return { ok: true };
}
}

View file

@ -1,36 +1,58 @@
import { NextApiResponse } from "next"; import { NextApiResponse } from "next";
import { AvalaraError } from "avatax/lib/AvaTaxClient";
import { ZodError } from "zod";
import { createLogger, Logger } from "../../lib/logger"; 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 { export class WebhookResponse {
private logger: Logger; private logger: Logger;
constructor(private res: NextApiResponse) { constructor(private res: NextApiResponse) {
this.logger = createLogger({ event: "WebhookResponse" }); this.logger = createLogger({ event: "WebhookResponse" });
} }
private returnSuccess(data?: unknown) { error(error: unknown) {
this.logger.debug({ data }, "Responding to Saleor with data:"); const errorResolver = new WebhookErrorResolver();
return this.res.status(200).json(data ?? {}); const errorMessage = errorResolver.resolve(error);
}
private returnError(errorMessage: string) {
this.logger.debug({ errorMessage }, "Responding to Saleor with error:"); this.logger.debug({ errorMessage }, "Responding to Saleor with error:");
return this.res.status(500).json({ error: errorMessage }); 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) { success(data?: unknown) {
return this.returnSuccess(data); return this.res.status(200).json(data ?? {});
} }
} }

View file

@ -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")
);
});
});

View file

@ -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);
}
}
}

View file

@ -1,10 +1,11 @@
import Avatax from "avatax"; import Avatax from "avatax";
import { DocumentType } from "avatax/lib/enums/DocumentType"; import { DocumentType } from "avatax/lib/enums/DocumentType";
import { VoidReasonCode } from "avatax/lib/enums/VoidReasonCode";
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo"; import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel"; import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel";
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel"; import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
import { LogOptions } from "avatax/lib/utils/logger";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import { createLogger, Logger } from "../../lib/logger";
import { AvataxClientTaxCodeService } from "./avatax-client-tax-code.service"; import { AvataxClientTaxCodeService } from "./avatax-client-tax-code.service";
import { BaseAvataxConfig } from "./avatax-connection-schema"; import { BaseAvataxConfig } from "./avatax-connection-schema";
@ -14,11 +15,7 @@ type AvataxSettings = {
environment: "sandbox" | "production"; environment: "sandbox" | "production";
machineName: string; machineName: string;
timeout: number; timeout: number;
logOptions?: { logOptions?: LogOptions;
logEnabled: boolean;
logLevel: number;
logRequestAndResponseInfo: boolean;
};
}; };
const defaultAvataxSettings: AvataxSettings = { const defaultAvataxSettings: AvataxSettings = {
@ -53,12 +50,15 @@ export type ValidateAddressArgs = {
address: AvataxAddress; address: AvataxAddress;
}; };
export type VoidTransactionArgs = {
transactionCode: string;
companyCode: string;
};
export class AvataxClient { export class AvataxClient {
private client: Avatax; private client: Avatax;
private logger: Logger;
constructor(baseConfig: BaseAvataxConfig) { constructor(baseConfig: BaseAvataxConfig) {
this.logger = createLogger({ name: "AvataxClient" });
const settings = createAvataxSettings({ isSandbox: baseConfig.isSandbox }); const settings = createAvataxSettings({ isSandbox: baseConfig.isSandbox });
const avataxClient = new Avatax(settings).withSecurity(baseConfig.credentials); const avataxClient = new Avatax(settings).withSecurity(baseConfig.credentials);
@ -66,13 +66,32 @@ export class AvataxClient {
} }
async createTransaction({ model }: CreateTransactionArgs) { 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) { async commitTransaction(args: CommitTransactionArgs) {
return this.client.commitTransaction(args); 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) { async validateAddress({ address }: ValidateAddressArgs) {
return this.client.resolveAddress(address); return this.client.resolveAddress(address);
} }

View file

@ -24,7 +24,7 @@ export type BaseAvataxConfig = z.infer<typeof baseAvataxConfigSchema>;
export const avataxConfigSchema = z export const avataxConfigSchema = z
.object({ .object({
name: z.string().min(1, { message: "Name requires at least one character." }), 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(), isAutocommit: z.boolean(),
shippingTaxCode: z.string().optional(), shippingTaxCode: z.string().optional(),
isDocumentRecordingEnabled: z.boolean().default(true), isDocumentRecordingEnabled: z.boolean().default(true),
@ -36,7 +36,7 @@ export type AvataxConfig = z.infer<typeof avataxConfigSchema>;
export const defaultAvataxConfig: AvataxConfig = { export const defaultAvataxConfig: AvataxConfig = {
name: "", name: "",
companyCode: "", companyCode: "DEFAULT",
isSandbox: false, isSandbox: false,
isAutocommit: false, isAutocommit: false,
isDocumentRecordingEnabled: true, isDocumentRecordingEnabled: true,

View file

@ -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");
});
});

View file

@ -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);
}
}

View file

@ -1,14 +1,18 @@
import { AuthData } from "@saleor/app-sdk/APL"; import { AuthData } from "@saleor/app-sdk/APL";
import { import {
OrderConfirmedSubscriptionFragment,
OrderCreatedSubscriptionFragment, OrderCreatedSubscriptionFragment,
OrderFulfilledSubscriptionFragment, OrderFulfilledSubscriptionFragment,
TaxBaseFragment, TaxBaseFragment,
} from "../../../generated/graphql"; } from "../../../generated/graphql";
import { Logger, createLogger } from "../../lib/logger"; import { Logger, createLogger } from "../../lib/logger";
import { OrderCancelledPayload } from "../../pages/api/webhooks/order-cancelled";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { AvataxClient } from "./avatax-client"; import { AvataxClient } from "./avatax-client";
import { AvataxConfig, defaultAvataxConfig } from "./avatax-connection-schema"; import { AvataxConfig, defaultAvataxConfig } from "./avatax-connection-schema";
import { AvataxCalculateTaxesAdapter } from "./calculate-taxes/avatax-calculate-taxes-adapter"; 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 { AvataxOrderCreatedAdapter } from "./order-created/avatax-order-created-adapter";
import { AvataxOrderFulfilledAdapter } from "./order-fulfilled/avatax-order-fulfilled-adapter"; import { AvataxOrderFulfilledAdapter } from "./order-fulfilled/avatax-order-fulfilled-adapter";
@ -35,14 +39,17 @@ export class AvataxWebhookService implements ProviderWebhookService {
return response; return response;
} }
async createOrder(order: OrderCreatedSubscriptionFragment) { async confirmOrder(order: OrderConfirmedSubscriptionFragment) {
const adapter = new AvataxOrderCreatedAdapter(this.config, this.authData); const adapter = new AvataxOrderConfirmedAdapter(this.config, this.authData);
const response = await adapter.send({ order }); const response = await adapter.send({ order });
return response; return response;
} }
/**
* @deprecated This method is deprecated and will be removed in the future.
*/
async fulfillOrder(order: OrderFulfilledSubscriptionFragment) { async fulfillOrder(order: OrderFulfilledSubscriptionFragment) {
const adapter = new AvataxOrderFulfilledAdapter(this.config); const adapter = new AvataxOrderFulfilledAdapter(this.config);
@ -50,4 +57,21 @@ export class AvataxWebhookService implements ProviderWebhookService {
return response; 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;
}
} }

View file

@ -3,7 +3,7 @@ import { TaxBaseFragment } from "../../../../generated/graphql";
import { discountUtils } from "../../taxes/discount-utils"; import { discountUtils } from "../../taxes/discount-utils";
import { avataxAddressFactory } from "../address-factory"; import { avataxAddressFactory } from "../address-factory";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client"; 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 { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer"; import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer";
import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher"; import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher";
@ -34,7 +34,7 @@ export class AvataxCalculateTaxesPayloadTransformer {
type: this.matchDocumentType(avataxConfig), type: this.matchDocumentType(avataxConfig),
entityUseCode, entityUseCode,
customerCode: taxBase.sourceObject.user?.id ?? "", 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: 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, commit: avataxConfig.isAutocommit,
addresses: { addresses: {

View file

@ -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<OrderCancelledPayload, void> {
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}`);
}
}

View file

@ -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" });
});
});

View file

@ -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,
};
}
}

View file

@ -0,0 +1,6 @@
import { describe, it } from "vitest";
describe("AvataxOrderConfirmedAdapter", () => {
// ? how to mock internal call to avatax?
it.todo("calls avatax client", () => {});
});

View file

@ -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<AvataxOrderConfirmedPayload, AvataxOrderConfirmedResponse>
{
private logger: Logger;
constructor(private readonly config: AvataxConfig, private authData: AuthData) {
this.logger = createLogger({ name: "AvataxOrderConfirmedAdapter" });
}
async send(payload: AvataxOrderConfirmedPayload): Promise<AvataxOrderConfirmedResponse> {
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;
}
}

View file

@ -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> = {}
): OrderConfirmedSubscriptionFragment =>
structuredClone({
...testingScenariosMap[this.scenario].order,
...overrides,
});
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig =>
structuredClone({
...testingScenariosMap[this.scenario].channelConfig,
...overrides,
});
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig => {
const mockGenerator = new AvataxConfigMockGenerator();
return mockGenerator.generateAvataxConfig(overrides);
};
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
structuredClone({
...testingScenariosMap[this.scenario].response,
...overrides,
});
}

View file

@ -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,
});
});
});

View file

@ -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;
}
}

View file

@ -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);
});
});

View file

@ -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<CreateTransactionArgs> {
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)
),
},
};
}
}

View file

@ -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<CreateTransactionArgs> {
const matches = await this.getMatches();
const payloadTransformer = new AvataxOrderConfirmedPayloadTransformer();
return payloadTransformer.transform(order, avataxConfig, matches);
}
}

View file

@ -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();
});
});

View file

@ -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."
)
),
};
}
}

View file

@ -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");
});
});

View file

@ -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 ?? ""
: "";
}
}

View file

@ -7,6 +7,9 @@ import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository"; import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer"; import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer";
import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher"; 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"; export const SHIPPING_ITEM_CODE = "Shipping";
@ -24,15 +27,25 @@ export class AvataxOrderCreatedPayloadTransformer {
avataxConfig: AvataxConfig, avataxConfig: AvataxConfig,
matches: AvataxTaxCodeMatches matches: AvataxTaxCodeMatches
): Promise<CreateTransactionArgs> { ): Promise<CreateTransactionArgs> {
const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer();
const avataxClient = new AvataxClient(avataxConfig); const avataxClient = new AvataxClient(avataxConfig);
const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer();
const entityTypeMatcher = new AvataxEntityTypeMatcher({ client: avataxClient }); const entityTypeMatcher = new AvataxEntityTypeMatcher({ client: avataxClient });
const dateResolver = new AvataxCalculationDateResolver();
const documentCodeResolver = new AvataxDocumentCodeResolver();
const entityUseCode = await entityTypeMatcher.match(order.avataxEntityCode); 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 { return {
model: { model: {
type: this.matchDocumentType(avataxConfig), type: this.matchDocumentType(avataxConfig),
entityUseCode, entityUseCode,
code,
customerCode: customerCode:
order.user?.id ?? order.user?.id ??
"" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the 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!), shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!),
}, },
currencyCode: order.total.currency, currencyCode: order.total.currency,
email: order.user?.email ?? "", email: taxProviderUtils.resolveStringOrThrow(order.user?.email),
lines: linesTransformer.transform(order, avataxConfig, matches), lines: linesTransformer.transform(order, avataxConfig, matches),
date: new Date(order.created), date,
discount: discountUtils.sumDiscounts( discount: discountUtils.sumDiscounts(
order.discounts.map((discount) => discount.amount.amount) order.discounts.map((discount) => discount.amount.amount)
), ),

View file

@ -2,11 +2,7 @@ import { DocumentType } from "avatax/lib/enums/DocumentType";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql"; import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxConfig } from "../avatax-connection-schema";
import { import { AvataxOrderFulfilledPayloadTransformer } from "./avatax-order-fulfilled-payload-transformer";
AvataxOrderFulfilledPayloadTransformer,
PROVIDER_ORDER_ID_KEY,
getTransactionCodeFromMetadata,
} from "./avatax-order-fulfilled-payload-transformer";
// todo: add AvataxOrderFulfilledMockGenerator // 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; type OrderFulfilled = OrderFulfilledSubscriptionFragment;
const ORDER_FULFILLED_MOCK: OrderFulfilled = { const ORDER_FULFILLED_MOCK: OrderFulfilled = {
id: "T3JkZXI6OTU4MDA5YjQtNDUxZC00NmQ1LThhMWUtMTRkMWRmYjFhNzI5", id: "T3JkZXI6OTU4MDA5YjQtNDUxZC00NmQ1LThhMWUtMTRkMWRmYjFhNzI5",
created: "2023-04-11T11:03:09.304109+00:00", created: "2023-04-11T11:03:09.304109+00:00",
privateMetadata: MOCKED_METADATA, avataxId: "transaction-code",
channel: { channel: {
id: "Q2hhbm5lbDoy", id: "Q2hhbm5lbDoy",
slug: "channel-pln", 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: { const MOCKED_ORDER_FULFILLED_PAYLOAD: {
order: OrderFulfilledSubscriptionFragment; order: OrderFulfilledSubscriptionFragment;
} = { } = {
@ -137,6 +116,19 @@ const MOCKED_ORDER_FULFILLED_PAYLOAD: {
}; };
describe("AvataxOrderFulfilledPayloadTransformer", () => { 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", () => { it("returns document type of SalesOrder when isDocumentRecordingEnabled is false", () => {
const transformer = new AvataxOrderFulfilledPayloadTransformer({ const transformer = new AvataxOrderFulfilledPayloadTransformer({
...MOCK_AVATAX_CONFIG, ...MOCK_AVATAX_CONFIG,

View file

@ -1,27 +1,11 @@
import { DocumentType } from "avatax/lib/enums/DocumentType"; import { DocumentType } from "avatax/lib/enums/DocumentType";
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql"; import { z } from "zod";
import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxConfig } from "../avatax-connection-schema";
import { import {
AvataxOrderFulfilledPayload, AvataxOrderFulfilledPayload,
AvataxOrderFulfilledTarget, AvataxOrderFulfilledTarget,
} from "./avatax-order-fulfilled-adapter"; } 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 { export class AvataxOrderFulfilledPayloadTransformer {
constructor(private readonly config: AvataxConfig) {} constructor(private readonly config: AvataxConfig) {}
private matchDocumentType(config: AvataxConfig): DocumentType { private matchDocumentType(config: AvataxConfig): DocumentType {
@ -32,11 +16,11 @@ export class AvataxOrderFulfilledPayloadTransformer {
return DocumentType.SalesInvoice; return DocumentType.SalesInvoice;
} }
transform({ order }: AvataxOrderFulfilledPayload): AvataxOrderFulfilledTarget { transform({ order }: AvataxOrderFulfilledPayload): AvataxOrderFulfilledTarget {
const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata); const transactionCode = z.string().min(1).parse(order.avataxId);
return { return {
transactionCode, transactionCode,
companyCode: this.config.companyCode ?? "", companyCode: this.config.companyCode,
documentType: this.matchDocumentType(this.config), documentType: this.matchDocumentType(this.config),
model: { model: {
commit: true, commit: true,

View file

@ -1,12 +1,14 @@
import { AuthData } from "@saleor/app-sdk/APL"; import { AuthData } from "@saleor/app-sdk/APL";
import { import {
MetadataItem, MetadataItem,
OrderConfirmedSubscriptionFragment,
OrderCreatedSubscriptionFragment, OrderCreatedSubscriptionFragment,
OrderFulfilledSubscriptionFragment, OrderFulfilledSubscriptionFragment,
TaxBaseFragment, TaxBaseFragment,
} from "../../../generated/graphql"; } from "../../../generated/graphql";
import { Logger, createLogger } from "../../lib/logger"; import { Logger, createLogger } from "../../lib/logger";
import { OrderCancelledPayload } from "../../pages/api/webhooks/order-cancelled";
import { getAppConfig } from "../app/get-app-config"; import { getAppConfig } from "../app/get-app-config";
import { AvataxWebhookService } from "../avatax/avatax-webhook.service"; import { AvataxWebhookService } from "../avatax/avatax-webhook.service";
import { ProviderConnection } from "../provider-connections/provider-connections"; import { ProviderConnection } from "../provider-connections/provider-connections";
@ -48,13 +50,27 @@ class ActiveTaxProviderService implements ProviderWebhookService {
return this.client.calculateTaxes(payload); return this.client.calculateTaxes(payload);
} }
async createOrder(order: OrderCreatedSubscriptionFragment) { async confirmOrder(order: OrderConfirmedSubscriptionFragment) {
return this.client.createOrder(order); return this.client.confirmOrder(order);
} }
/**
* @deprecated This method is deprecated and will be removed in the future.
*/
async fulfillOrder(payload: OrderFulfilledSubscriptionFragment) { async fulfillOrder(payload: OrderFulfilledSubscriptionFragment) {
return this.client.fulfillOrder(payload); 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( export function getActiveConnectionService(

View file

@ -1,10 +1,12 @@
import { z } from "zod";
/* /*
* The providers sdk types claim to sometimes return undefined. * 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. * 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. * Should only be used for values that are required for further calculation.
*/ */
function resolveOptionalOrThrow<T>(value: T | undefined, error?: Error): T { function resolveOptionalOrThrow<T>(value: T | undefined | null, error?: Error): T {
if (value === undefined) { if (value === undefined || value === null) {
throw error throw error
? error ? error
: new Error("Could not resolve data. Value needed for further calculation is undefined."); : new Error("Could not resolve data. Value needed for further calculation is undefined.");
@ -13,6 +15,11 @@ function resolveOptionalOrThrow<T>(value: T | undefined, error?: Error): T {
return value; 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 = { export const taxProviderUtils = {
resolveOptionalOrThrow, resolveOptionalOrThrow,
resolveStringOrThrow,
}; };

View file

@ -1,10 +1,11 @@
import { SyncWebhookResponsesMap } from "@saleor/app-sdk/handlers/next"; import { SyncWebhookResponsesMap } from "@saleor/app-sdk/handlers/next";
import { import {
OrderConfirmedSubscriptionFragment,
OrderCreatedSubscriptionFragment, OrderCreatedSubscriptionFragment,
OrderFulfilledSubscriptionFragment, OrderFulfilledSubscriptionFragment,
TaxBaseFragment, TaxBaseFragment,
} from "../../../generated/graphql"; } 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"]; export type CalculateTaxesResponse = SyncWebhookResponsesMap["ORDER_CALCULATE_TAXES"];
@ -12,6 +13,15 @@ export type CreateOrderResponse = { id: string };
export interface ProviderWebhookService { export interface ProviderWebhookService {
calculateTaxes: (payload: TaxBaseFragment) => Promise<CalculateTaxesResponse>; calculateTaxes: (payload: TaxBaseFragment) => Promise<CalculateTaxesResponse>;
createOrder: (payload: OrderCreatedSubscriptionFragment) => Promise<CreateOrderResponse>; confirmOrder: (payload: OrderConfirmedSubscriptionFragment) => Promise<CreateOrderResponse>;
cancelOrder: (payload: OrderCancelledPayload) => Promise<void>;
/**
* @deprecated This method is deprecated and will be removed in the future.
*/
fulfillOrder: (payload: OrderFulfilledSubscriptionFragment) => Promise<{ ok: boolean }>; fulfillOrder: (payload: OrderFulfilledSubscriptionFragment) => Promise<{ ok: boolean }>;
/**
* @deprecated This method is deprecated and will be removed in the future.
*/
createOrder: (payload: OrderCreatedSubscriptionFragment) => Promise<CreateOrderResponse>;
} }

View file

@ -0,0 +1,3 @@
import { describe } from "vitest";
describe.todo("TaxJarOrderConfirmedAdapter", () => {});

View file

@ -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<TaxJarOrderConfirmedPayload, TaxJarOrderConfirmedResponse>
{
private logger: Logger;
constructor(private readonly config: TaxJarConfig, private authData: AuthData) {
this.logger = createLogger({ name: "TaxJarOrderConfirmedAdapter" });
}
async send(payload: TaxJarOrderConfirmedPayload): Promise<TaxJarOrderConfirmedResponse> {
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;
}
}

View file

@ -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> = {}): Order =>
structuredClone({
...testingScenariosMap[this.scenario].order,
...overrides,
});
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig => {
const mockGenerator = new ChannelConfigMockGenerator();
return mockGenerator.generateChannelConfig(overrides);
};
generateResponse = (overrides: Partial<CreateOrderRes> = {}): CreateOrderRes =>
structuredClone({
...testingScenariosMap[this.scenario].response,
...overrides,
});
generateProviderConfig = (overrides: Partial<TaxJarConfig> = {}): TaxJarConfig =>
structuredClone({
...testingScenariosMap[this.scenario].providerConfig,
...overrides,
});
}

View file

@ -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",
},
]);
});
});

View file

@ -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,
};
});
}
}

View file

@ -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);
});
});

View file

@ -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,
},
};
}
}

View file

@ -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<CreateOrderArgs> {
const matches = await this.getMatches();
const payloadTransformer = new TaxJarOrderConfirmedPayloadTransformer();
return payloadTransformer.transform(order, taxJarConfig, matches);
}
}

View file

@ -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",
});
});
});

View file

@ -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,
};
}
}

View file

@ -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");
});
});

View file

@ -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 ?? ""
: "";
}
}

View file

@ -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 { Logger, createLogger } from "../../lib/logger";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { TaxJarCalculateTaxesAdapter } from "./calculate-taxes/taxjar-calculate-taxes-adapter"; 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 { TaxJarClient } from "./taxjar-client";
import { TaxJarConfig } from "./taxjar-connection-schema"; import { TaxJarConfig } from "./taxjar-connection-schema";
import { TaxJarOrderCreatedAdapter } from "./order-created/taxjar-order-created-adapter";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { AuthData } from "@saleor/app-sdk/APL";
export class TaxJarWebhookService implements ProviderWebhookService { export class TaxJarWebhookService implements ProviderWebhookService {
client: TaxJarClient; client: TaxJarClient;
@ -30,16 +36,33 @@ export class TaxJarWebhookService implements ProviderWebhookService {
return response; return response;
} }
async createOrder(order: OrderCreatedSubscriptionFragment) { async confirmOrder(order: OrderConfirmedSubscriptionFragment) {
const adapter = new TaxJarOrderCreatedAdapter(this.config, this.authData); const adapter = new TaxJarOrderConfirmedAdapter(this.config, this.authData);
const response = await adapter.send({ order }); const response = await adapter.send({ order });
return response; 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() { async fulfillOrder() {
return { ok: true }; return { ok: true };
} }
async cancelOrder(payload: OrderCancelledEventSubscriptionFragment) {
// TaxJar isn't implemented yet
}
} }

View file

@ -4,9 +4,11 @@ import { AppManifest } from "@saleor/app-sdk/types";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import { checkoutCalculateTaxesSyncWebhook } from "./webhooks/checkout-calculate-taxes"; import { checkoutCalculateTaxesSyncWebhook } from "./webhooks/checkout-calculate-taxes";
import { orderCalculateTaxesSyncWebhook } from "./webhooks/order-calculate-taxes"; import { orderCalculateTaxesSyncWebhook } from "./webhooks/order-calculate-taxes";
import { orderCreatedAsyncWebhook } from "./webhooks/order-created"; import { orderConfirmedAsyncWebhook } from "./webhooks/order-confirmed";
import { orderFulfilledAsyncWebhook } from "./webhooks/order-fulfilled";
import { REQUIRED_SALEOR_VERSION } from "../../../saleor-app"; 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({ export default createManifestHandler({
async manifestFactory({ appBaseUrl }) { async manifestFactory({ appBaseUrl }) {
@ -35,8 +37,10 @@ export default createManifestHandler({
webhooks: [ webhooks: [
orderCalculateTaxesSyncWebhook.getWebhookManifest(apiBaseURL), orderCalculateTaxesSyncWebhook.getWebhookManifest(apiBaseURL),
checkoutCalculateTaxesSyncWebhook.getWebhookManifest(apiBaseURL), checkoutCalculateTaxesSyncWebhook.getWebhookManifest(apiBaseURL),
orderCreatedAsyncWebhook.getWebhookManifest(apiBaseURL), orderConfirmedAsyncWebhook.getWebhookManifest(apiBaseURL),
orderCancelledAsyncWebhook.getWebhookManifest(apiBaseURL),
orderFulfilledAsyncWebhook.getWebhookManifest(apiBaseURL), orderFulfilledAsyncWebhook.getWebhookManifest(apiBaseURL),
orderCreatedAsyncWebhook.getWebhookManifest(apiBaseURL),
], ],
}; };

View file

@ -46,12 +46,7 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res,
try { try {
verifyCalculateTaxesPayload(payload); verifyCalculateTaxesPayload(payload);
logger.debug("Payload validated succesfully"); logger.debug("Payload validated succesfully");
} catch (error) {
logger.debug("Payload validation failed");
return webhookResponse.error(error);
}
try {
const appMetadata = payload.recipient?.privateMetadata ?? []; const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.taxBase.channel.slug; const channelSlug = payload.taxBase.channel.slug;
const activeConnectionService = getActiveConnectionService( const activeConnectionService = getActiveConnectionService(

View file

@ -46,12 +46,7 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx
try { try {
verifyCalculateTaxesPayload(payload); verifyCalculateTaxesPayload(payload);
logger.debug("Payload validated succesfully"); logger.debug("Payload validated succesfully");
} catch (error) {
logger.debug("Payload validation failed");
return webhookResponse.error(error);
}
try {
const appMetadata = payload.recipient?.privateMetadata ?? []; const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.taxBase.channel.slug; const channelSlug = payload.taxBase.channel.slug;
const activeConnectionService = getActiveConnectionService( const activeConnectionService = getActiveConnectionService(

View file

@ -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<OrderCancelledPayload>({
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"));
}
});

View file

@ -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<OrderConfirmedPayload>({
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);
}
});

View file

@ -3,17 +3,13 @@ import {
OrderCreatedEventSubscriptionFragment, OrderCreatedEventSubscriptionFragment,
OrderStatus, OrderStatus,
UntypedOrderCreatedSubscriptionDocument, UntypedOrderCreatedSubscriptionDocument,
UpdateMetadataDocument,
UpdateMetadataMutation,
UpdateMetadataMutationVariables,
} from "../../../../generated/graphql"; } from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app"; import { saleorApp } from "../../../../saleor-app";
import { createLogger } from "../../../lib/logger"; import { createLogger } from "../../../lib/logger";
import { getActiveConnectionService } from "../../../modules/taxes/get-active-connection-service"; import { getActiveConnectionService } from "../../../modules/taxes/get-active-connection-service";
import { Client } from "urql";
import { WebhookResponse } from "../../../modules/app/webhook-response"; 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 { createGraphQLClient } from "@saleor/apps-shared";
import { OrderMetadataManager } from "../../../modules/app/order-metadata-manager";
export const config = { export const config = {
api: { api: {
@ -26,6 +22,9 @@ type OrderCreatedPayload = Extract<
{ __typename: "OrderCreated" } { __typename: "OrderCreated" }
>; >;
/**
* @deprecated This handler is deprecated and will be removed in the future.
*/
export const orderCreatedAsyncWebhook = new SaleorAsyncWebhook<OrderCreatedPayload>({ export const orderCreatedAsyncWebhook = new SaleorAsyncWebhook<OrderCreatedPayload>({
name: "OrderCreated", name: "OrderCreated",
apl: saleorApp.apl, apl: saleorApp.apl,
@ -34,35 +33,6 @@ export const orderCreatedAsyncWebhook = new SaleorAsyncWebhook<OrderCreatedPaylo
webhookPath: "/api/webhooks/order-created", webhookPath: "/api/webhooks/order-created",
}); });
/**
* 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.
*/
async function updateOrderMetadataWithExternalId(
client: Client,
orderId: string,
externalId: string
) {
const variables: UpdateMetadataMutationVariables = {
id: orderId,
input: [
{
key: PROVIDER_ORDER_ID_KEY,
value: externalId,
},
],
};
const { error } = await client
.mutation<UpdateMetadataMutation>(UpdateMetadataDocument, variables)
.toPromise();
if (error) {
throw error;
}
return { ok: true };
}
export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => { export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => {
const logger = createLogger({ event: ctx.event }); const logger = createLogger({ event: ctx.event });
const { payload, authData } = ctx; const { payload, authData } = ctx;
@ -76,8 +46,6 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => {
const channelSlug = payload.order?.channel.slug; const channelSlug = payload.order?.channel.slug;
const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData); const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData);
logger.info("Fetched taxProvider");
// todo: figure out what fields are needed and add validation // todo: figure out what fields are needed and add validation
if (!payload.order) { if (!payload.order) {
return webhookResponse.error(new Error("Insufficient order data")); 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")); return webhookResponse.error(new Error("Skipping fulfilled order to prevent duplication"));
} }
logger.info("Creating order...");
const createdOrder = await taxProvider.createOrder(payload.order); const createdOrder = await taxProvider.createOrder(payload.order);
logger.info({ createdOrder }, "Order created"); logger.info({ createdOrder }, "Order created");
@ -95,12 +65,14 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => {
token, 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"); logger.info("Updated order metadata with externalId");
return webhookResponse.success(); return webhookResponse.success();
} catch (error) { } catch (error) {
logger.error({ error }); logger.error({ error });
return webhookResponse.error(new Error("Error while creating order in tax provider")); return webhookResponse.error(error);
} }
}); });

View file

@ -18,6 +18,9 @@ type OrderFulfilledPayload = Extract<
{ __typename: "OrderFulfilled" } { __typename: "OrderFulfilled" }
>; >;
/**
* @deprecated This handler is deprecated and will be removed in the future.
*/
export const orderFulfilledAsyncWebhook = new SaleorAsyncWebhook<OrderFulfilledPayload>({ export const orderFulfilledAsyncWebhook = new SaleorAsyncWebhook<OrderFulfilledPayload>({
name: "OrderFulfilled", name: "OrderFulfilled",
apl: saleorApp.apl, apl: saleorApp.apl,
@ -38,18 +41,18 @@ export default orderFulfilledAsyncWebhook.createHandler(async (req, res, ctx) =>
const channelSlug = payload.order?.channel.slug; const channelSlug = payload.order?.channel.slug;
const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData); const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData);
logger.info("Fetched taxProvider");
// todo: figure out what fields are needed and add validation // todo: figure out what fields are needed and add validation
if (!payload.order) { if (!payload.order) {
return webhookResponse.error(new Error("Insufficient order data")); return webhookResponse.error(new Error("Insufficient order data"));
} }
logger.info("Fulfilling order...");
await taxProvider.fulfillOrder(payload.order); await taxProvider.fulfillOrder(payload.order);
logger.info("Order fulfilled"); logger.info("Order fulfilled");
return webhookResponse.success(); return webhookResponse.success();
} catch (error) { } catch (error) {
return webhookResponse.error(new Error("Error while fulfilling tax provider order")); return webhookResponse.error(error);
} }
}); });

View file

@ -1338,8 +1338,8 @@ importers:
specifier: ^2.1.4 specifier: ^2.1.4
version: 2.1.4(graphql@16.7.1) version: 2.1.4(graphql@16.7.1)
avatax: avatax:
specifier: ^23.3.2 specifier: ^23.7.0
version: 23.3.2 version: 23.7.0
clsx: clsx:
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1 version: 1.2.1
@ -11440,8 +11440,8 @@ packages:
resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
/avatax@23.3.2: /avatax@23.7.0:
resolution: {integrity: sha512-WGjSIQNvdM0XSyukjb+QIxB1z4xaEOdaGmPhXwLJYgxIuJlDw7Uq1nHUHo+yeHKZk3dKGzCyrFaa8N1Wx9nSbQ==} resolution: {integrity: sha512-34CuafkIgzfj6B8gTMmeUN5iYWdgHtSKRwIRWIYFcP/OpUMDOGB0WMMdGrZUgthLjYCWOkQdi2QReWuM/TSROg==}
dependencies: dependencies:
'@types/node-fetch': 2.6.4 '@types/node-fetch': 2.6.4
form-data: 4.0.0 form-data: 4.0.0