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:
parent
c7b3d35f5d
commit
416c92fb6c
76 changed files with 4308 additions and 160 deletions
5
.changeset/cold-toes-heal.md
Normal file
5
.changeset/cold-toes-heal.md
Normal 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.
|
5
.changeset/forty-shirts-battle.md
Normal file
5
.changeset/forty-shirts-battle.md
Normal 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.
|
5
.changeset/ninety-lobsters-design.md
Normal file
5
.changeset/ninety-lobsters-design.md
Normal 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").
|
5
.changeset/shiny-meals-wait.md
Normal file
5
.changeset/shiny-meals-wait.md
Normal 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.
|
21
.changeset/soft-steaks-know.md
Normal file
21
.changeset/soft-steaks-know.md
Normal 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`.
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
10
apps/taxes/graphql/mutations/UpdatePublicMetadata.graphql
Normal file
10
apps/taxes/graphql/mutations/UpdatePublicMetadata.graphql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
mutation UpdatePublicMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||||
|
updateMetadata(id: $id, input: $input) {
|
||||||
|
item {
|
||||||
|
metadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
29
apps/taxes/graphql/subscriptions/OrderCancelled.graphql
Normal file
29
apps/taxes/graphql/subscriptions/OrderCancelled.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
90
apps/taxes/graphql/subscriptions/OrderConfirmed.graphql
Normal file
90
apps/taxes/graphql/subscriptions/OrderConfirmed.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -47,10 +47,7 @@ fragment OrderFulfilledSubscription on Order {
|
||||||
amount
|
amount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
privateMetadata {
|
avataxId: metafield(key: "avataxId")
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
lines {
|
lines {
|
||||||
...OrderLine
|
...OrderLine
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
189
apps/taxes/scripts/migrations/app-webhook-migrator.test.ts
Normal file
189
apps/taxes/scripts/migrations/app-webhook-migrator.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
186
apps/taxes/scripts/migrations/app-webhook-migrator.ts
Normal file
186
apps/taxes/scripts/migrations/app-webhook-migrator.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
60
apps/taxes/scripts/migrations/app-webhook-repository.test.ts
Normal file
60
apps/taxes/scripts/migrations/app-webhook-repository.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
169
apps/taxes/scripts/migrations/app-webhook-repository.ts
Normal file
169
apps/taxes/scripts/migrations/app-webhook-repository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
31
apps/taxes/scripts/migrations/migration-utils.ts
Normal file
31
apps/taxes/scripts/migrations/migration-utils.ts
Normal 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();
|
||||||
|
};
|
38
apps/taxes/scripts/migrations/run-migration.ts
Normal file
38
apps/taxes/scripts/migrations/run-migration.ts
Normal 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();
|
38
apps/taxes/scripts/migrations/run-report.ts
Normal file
38
apps/taxes/scripts/migrations/run-report.ts
Normal 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();
|
27
apps/taxes/scripts/migrations/taxes-migration.ts
Normal file
27
apps/taxes/scripts/migrations/taxes-migration.ts
Normal 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");
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
45
apps/taxes/src/modules/app/order-metadata-manager.ts
Normal file
45
apps/taxes/src/modules/app/order-metadata-manager.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 ?? {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
describe("AvataxOrderConfirmedAdapter", () => {
|
||||||
|
// ? how to mock internal call to avatax?
|
||||||
|
it.todo("calls avatax client", () => {});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 ?? ""
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
),
|
),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { describe } from "vitest";
|
||||||
|
|
||||||
|
describe.todo("TaxJarOrderConfirmedAdapter", () => {});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 ?? ""
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
55
apps/taxes/src/pages/api/webhooks/order-cancelled.ts
Normal file
55
apps/taxes/src/pages/api/webhooks/order-cancelled.ts
Normal 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"));
|
||||||
|
}
|
||||||
|
});
|
78
apps/taxes/src/pages/api/webhooks/order-confirmed.ts
Normal file
78
apps/taxes/src/pages/api/webhooks/order-confirmed.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue