feat: finalize order process (#355)
* feat: ✨ add dummy order-created * refactor: 🔥 unused private-providers-configuration-service * feat: ✨ add dummy order-fulfilled * refactor: 🚚 move provider-config * refactor: 🚚 crudSettingsConfigurator -> crudSettingsManager * refactor: ♻️ [tax-provider].ts -> [tax-provider]-webhook.service.ts * feat: ✨ add dummy createOrder * refactor: ♻️ distinguish between salesOrder and salesInvoice in avatax * refactor: 🚚 [provider]-calculate.ts to [provider]-transform.ts * refactor: 🚚 ResponseTaxPayload to tax-provider-webhook.ts * refactor: 🚚 ResponseTaxPayload -> CalculateTaxesResponse * refactor: ♻️ webhooks with active-tax-provider.service.ts * feat: ✨ add skeleton orderCreate functionality * refactor: ♻️ [provider]-transform.ts -> [provider]-[webhook]-transform.ts * feat: ✨ add order-fulfilled with avatax call * refactor: ♻️ move getActiveTaxProvider to active-tax-provider * refactor: 🏷️ export types for [provider]-client function args * refactor: 🚚 UpdateAppMetadata -> UpdateMetadata * feat: ✨ fulfill order with id from metadata * build: ⬆️ upgrade avatax * feat: ✨ commit transaction on fulfill in avatax * fix: 🐛 return of webhooks to ensure valid retry behavior * refactor: 🚚 [provider]-[webhook]-transform -> [provider]-[webhook]-map * refactor: 🏷️ export types of avatax-calculate-taxes mapPayload * refactor: ♻️ extract address-map to separate function * refactor: ♻️ remove schema.ts * refactor: ♻️ move addressSchema to channels-config.ts * feat: ✨ add tests & placeholder tests for avatax & taxjar maps * refactor: ♻️ throw error if no metadata * refactor: ♻️ change EXTERNAL_ID_KEY to PROVIDER_ORDER_ID_KEY add comments * refactor: ♻️ comments -> it.todo in tests * refactor: 💡 add comment about shipping_item_code * refactor: ✅ add todo items for tests * refactor: ♻️ remove export and add sumLines to taxJarOrderCreated * refactor: ♻️ address-map with avatarAddressFactory * docs: 💡 add comment about MOCKED_SALEOR_PAYLOAD * refactor: ♻️ remove export of mapLines and add to avataxCalculateTaxes * style: 🎨 add newline-after-var warn to eslint-config-saleor * style: 🎨 autofix newline-after-var in taxes * test: ✅ restructure tests according to new naming in address-map * refactor: ♻️ add shippingItemCode to avataxCalculateTaxes wrapper object * refactor: 🚚 payloadProps -> payloadArgs * refactor: ♻️ add Maps suffix to map wrapper objects * refactor: ♻️ remove data: null from ActiveTaxProviderResult * refactor: ♻️ maintain the object hierarchy in tests * refactor: ♻️ refactor webhook responses with WebhookResponseFactory * build: ⬆️ vitest * test: ✅ add tests for get-app-config-test * test: ✅ add tests for getActiveTaxProvider * refactor: ♻️ use address fragment for taxBase and order * refactor: ♻️ rename WebhookResponseFactory -> WebhookResponse * style: 👷 add multiline-comment-style * fix: 🐛 dummy test in get-app-config.test.ts * refactor: ♻️ rename AddressFragment -> Address * refactor: ♻️ use debug instead of error in webhook-response noRetry * refactor: ♻️ refactor as variables in mutation * build: 👷 add changeset * refactor: ♻️ split changesets in two * build: ⬆️ vite * build: ⬆️ vite && vitest in all apps
This commit is contained in:
parent
1fef68b49e
commit
453baf78a1
80 changed files with 2246 additions and 1208 deletions
5
.changeset/polite-mugs-perform.md
Normal file
5
.changeset/polite-mugs-perform.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"saleor-app-taxes": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Completed the order flow with new webhooks: `order_created` and `order_fulfilled`. In TaxJar, an order will be created on `order_created` with no actions on `order_fulfilled`. In Avatax, a transaction will be created on `order_created` and commited on `order_fulfilled`.
|
5
.changeset/tender-monkeys-deny.md
Normal file
5
.changeset/tender-monkeys-deny.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"eslint-config-saleor": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added new warn rules to eslint-config-saleor: `newline-after-var` and `multiline-comment-style`.
|
|
@ -38,7 +38,7 @@
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"vite": "^4.1.4",
|
"vite": "^4.2.1",
|
||||||
"zod": "^3.19.1"
|
"zod": "^3.19.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -62,7 +62,7 @@
|
||||||
"jsdom": "^20.0.3",
|
"jsdom": "^20.0.3",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"typescript": "4.9",
|
"typescript": "4.9",
|
||||||
"vitest": "^0.29.2"
|
"vitest": "^0.30.1"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||||
|
|
|
@ -40,8 +40,8 @@
|
||||||
"react-is": "^18.2.0",
|
"react-is": "^18.2.0",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"vite": "^4.0.4",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.27.1",
|
"vitest": "^0.30.1",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -37,8 +37,8 @@
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"vite": "^4.1.1",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.28.4",
|
"vitest": "^0.30.1",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -50,8 +50,8 @@
|
||||||
"react-query": "^3.39.3",
|
"react-query": "^3.39.3",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"vite": "^4.0.4",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.27.1",
|
"vitest": "^0.30.1",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -68,9 +68,9 @@
|
||||||
"jsdom": "^20.0.3",
|
"jsdom": "^20.0.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"vite": "^4.1.1",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.28.4",
|
"vitest": "^0.30.1",
|
||||||
"@types/semver": "^7.3.13"
|
"@types/semver": "^7.3.13"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||||
|
|
|
@ -31,8 +31,8 @@
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.42.1",
|
"react-hook-form": "^7.42.1",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"vite": "^4.0.4",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.27.1"
|
"vitest": "^0.30.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "3.2.2",
|
"@graphql-codegen/cli": "3.2.2",
|
||||||
|
|
|
@ -44,8 +44,8 @@
|
||||||
"react-is": "^18.2.0",
|
"react-is": "^18.2.0",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"vite": "^4.0.4",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.27.1",
|
"vitest": "^0.30.1",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
fragment AddressFragment on Address {
|
fragment Address on Address {
|
||||||
streetAddress1
|
streetAddress1
|
||||||
streetAddress2
|
streetAddress2
|
||||||
city
|
city
|
||||||
|
@ -7,4 +7,4 @@ fragment AddressFragment on Address {
|
||||||
country {
|
country {
|
||||||
code
|
code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,14 +51,7 @@ fragment TaxBase on TaxableObject {
|
||||||
...TaxDiscount
|
...TaxDiscount
|
||||||
}
|
}
|
||||||
address {
|
address {
|
||||||
streetAddress1
|
...Address
|
||||||
streetAddress2
|
|
||||||
city
|
|
||||||
country {
|
|
||||||
code
|
|
||||||
}
|
|
||||||
countryArea
|
|
||||||
postalCode
|
|
||||||
}
|
}
|
||||||
shippingPrice {
|
shippingPrice {
|
||||||
amount
|
amount
|
||||||
|
@ -66,4 +59,16 @@ fragment TaxBase on TaxableObject {
|
||||||
lines {
|
lines {
|
||||||
...TaxBaseLine
|
...TaxBaseLine
|
||||||
}
|
}
|
||||||
|
sourceObject {
|
||||||
|
... on Checkout {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on Order {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
mutation UpdateMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||||
updatePrivateMetadata(id: $id, input: $input) {
|
updatePrivateMetadata(id: $id, input: $input) {
|
||||||
item {
|
item {
|
||||||
privateMetadata {
|
privateMetadata {
|
|
@ -14,21 +14,26 @@ fragment OrderLineFragment on OrderLine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment OrderSubscription on Order {
|
fragment OrderCreatedSubscription on Order {
|
||||||
id
|
id
|
||||||
userEmail
|
user {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
}
|
||||||
created
|
created
|
||||||
|
status
|
||||||
channel {
|
channel {
|
||||||
id
|
id
|
||||||
slug
|
slug
|
||||||
}
|
}
|
||||||
shippingAddress {
|
shippingAddress {
|
||||||
...AddressFragment
|
...Address
|
||||||
}
|
}
|
||||||
billingAddress {
|
billingAddress {
|
||||||
...AddressFragment
|
...Address
|
||||||
}
|
}
|
||||||
total {
|
total {
|
||||||
|
currency
|
||||||
net {
|
net {
|
||||||
amount
|
amount
|
||||||
}
|
}
|
||||||
|
@ -49,7 +54,13 @@ fragment OrderCreatedEventSubscription on Event {
|
||||||
__typename
|
__typename
|
||||||
... on OrderCreated {
|
... on OrderCreated {
|
||||||
order {
|
order {
|
||||||
...OrderSubscription
|
...OrderCreatedSubscription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recipient {
|
||||||
|
privateMetadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
70
apps/taxes/graphql/subscriptions/OrderFulfilled.graphql
Normal file
70
apps/taxes/graphql/subscriptions/OrderFulfilled.graphql
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
fragment OrderLineFragment on OrderLine {
|
||||||
|
productSku
|
||||||
|
productName
|
||||||
|
quantity
|
||||||
|
unitPrice {
|
||||||
|
net {
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalPrice {
|
||||||
|
tax {
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment OrderFulfilledSubscription on Order {
|
||||||
|
id
|
||||||
|
userEmail
|
||||||
|
created
|
||||||
|
channel {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
shippingAddress {
|
||||||
|
...Address
|
||||||
|
}
|
||||||
|
billingAddress {
|
||||||
|
...Address
|
||||||
|
}
|
||||||
|
total {
|
||||||
|
net {
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
tax {
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shippingPrice {
|
||||||
|
net {
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
privateMetadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
lines {
|
||||||
|
...OrderLineFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fragment OrderFulfilledEventSubscription on Event {
|
||||||
|
__typename
|
||||||
|
... on OrderFulfilled {
|
||||||
|
order {
|
||||||
|
...OrderFulfilledSubscription
|
||||||
|
}
|
||||||
|
recipient {
|
||||||
|
privateMetadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscription OrderFulfilledSubscription {
|
||||||
|
event {
|
||||||
|
...OrderFulfilledEventSubscription
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,7 +30,7 @@
|
||||||
"@trpc/server": "^10.9.0",
|
"@trpc/server": "^10.9.0",
|
||||||
"@urql/exchange-auth": "^1.0.0",
|
"@urql/exchange-auth": "^1.0.0",
|
||||||
"@urql/exchange-multipart-fetch": "^1.0.1",
|
"@urql/exchange-multipart-fetch": "^1.0.1",
|
||||||
"avatax": "^23.2.0",
|
"avatax": "^23.3.2",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
|
@ -44,8 +44,8 @@
|
||||||
"taxjar": "^4.0.1",
|
"taxjar": "^4.0.1",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"vite": "^4.0.1",
|
"vite": "^4.2.1",
|
||||||
"vitest": "^0.25.8",
|
"vitest": "^0.30.1",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -63,7 +63,7 @@
|
||||||
"@types/node": "^18.8.1",
|
"@types/node": "^18.8.1",
|
||||||
"@types/react": "^18.0.21",
|
"@types/react": "^18.0.21",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@vitejs/plugin-react": "^3.0.0",
|
"@vitejs/plugin-react": "^3.1.0",
|
||||||
"eslint": "8.25.0",
|
"eslint": "8.25.0",
|
||||||
"eslint-config-next": "12.3.1",
|
"eslint-config-next": "12.3.1",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
|
|
@ -1,89 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import { CalculateTaxesEventFragment } from "../../../generated/graphql";
|
|
||||||
|
|
||||||
export type ExpectedWebhookPayload = Extract<
|
|
||||||
CalculateTaxesEventFragment,
|
|
||||||
{ __typename: "CalculateTaxes" }
|
|
||||||
>;
|
|
||||||
|
|
||||||
const taxDiscountSchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
amount: z.object({
|
|
||||||
amount: z.number(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const checkoutBaseLineSchema = z.object({
|
|
||||||
__typename: z.literal("CheckoutLine"),
|
|
||||||
id: z.string(),
|
|
||||||
productVariant: z.object({
|
|
||||||
id: z.string(),
|
|
||||||
product: z.object({
|
|
||||||
metafield: z.string().nullable(),
|
|
||||||
productType: z.object({
|
|
||||||
metafield: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const orderBaseLineSchema = z.object({
|
|
||||||
__typename: z.literal("OrderLine"),
|
|
||||||
id: z.string(),
|
|
||||||
variant: z.object({
|
|
||||||
id: z.string(),
|
|
||||||
product: z.object({
|
|
||||||
metafield: z.string().nullable(),
|
|
||||||
productType: z.object({
|
|
||||||
metafield: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const taxAddressSchema = z.object({
|
|
||||||
streetAddress1: z.string(),
|
|
||||||
streetAddress2: z.string(),
|
|
||||||
city: z.string(),
|
|
||||||
country: z.object({
|
|
||||||
code: z.string(),
|
|
||||||
}),
|
|
||||||
countryArea: z.string(),
|
|
||||||
postalCode: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const taxBaseLineSchema = z.object({
|
|
||||||
chargeTaxes: z.boolean(),
|
|
||||||
quantity: z.number(),
|
|
||||||
unitPrice: z.object({
|
|
||||||
amount: z.number(),
|
|
||||||
}),
|
|
||||||
totalPrice: z.object({
|
|
||||||
amount: z.number(),
|
|
||||||
}),
|
|
||||||
sourceLine: z.union([checkoutBaseLineSchema, orderBaseLineSchema]),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const calculateTaxesPayloadSchema: z.ZodType<ExpectedWebhookPayload> = z.object({
|
|
||||||
__typename: z.literal("CalculateTaxes"),
|
|
||||||
recipient: z.object({
|
|
||||||
privateMetadata: z.array(
|
|
||||||
z.object({
|
|
||||||
key: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
taxBase: z.object({
|
|
||||||
currency: z.string(),
|
|
||||||
channel: z.object({
|
|
||||||
slug: z.string(),
|
|
||||||
}),
|
|
||||||
__typename: z.literal("TaxableObject").optional(),
|
|
||||||
discounts: z.array(taxDiscountSchema),
|
|
||||||
address: taxAddressSchema,
|
|
||||||
shippingPrice: z.object({
|
|
||||||
amount: z.number(),
|
|
||||||
}),
|
|
||||||
lines: z.array(taxBaseLineSchema).min(1),
|
|
||||||
}),
|
|
||||||
});
|
|
77
apps/taxes/src/modules/app/get-app-config.test.ts
Normal file
77
apps/taxes/src/modules/app/get-app-config.test.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { encrypt } from "@saleor/app-sdk/settings-manager";
|
||||||
|
import { getAppConfig } from "./get-app-config";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ProvidersConfig } from "../providers-configuration/providers-config";
|
||||||
|
import { MetadataItem } from "../../../generated/graphql";
|
||||||
|
import { ChannelsConfig } from "../channels-configuration/channels-config";
|
||||||
|
|
||||||
|
const mockedSecretKey = "test_secret_key";
|
||||||
|
const mockedProviders: ProvidersConfig = [
|
||||||
|
{
|
||||||
|
provider: "avatax",
|
||||||
|
id: "1",
|
||||||
|
config: {
|
||||||
|
companyCode: "DEFAULT",
|
||||||
|
isAutocommit: false,
|
||||||
|
isSandbox: true,
|
||||||
|
name: "avatax-1",
|
||||||
|
password: "avatax-password",
|
||||||
|
username: "avatax-username",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "taxjar",
|
||||||
|
id: "2",
|
||||||
|
config: {
|
||||||
|
name: "taxjar-1",
|
||||||
|
apiKey: "taxjar-api-key",
|
||||||
|
isSandbox: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const mockedEncryptedProviders = encrypt(JSON.stringify(mockedProviders), mockedSecretKey);
|
||||||
|
|
||||||
|
const mockedChannels: ChannelsConfig = {
|
||||||
|
"default-channel": {
|
||||||
|
address: {
|
||||||
|
city: "New York",
|
||||||
|
country: "US",
|
||||||
|
state: "NY",
|
||||||
|
street: "123 Main St",
|
||||||
|
zip: "10001",
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
providerInstanceId: "1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedEncryptedChannels = encrypt(JSON.stringify(mockedChannels), mockedSecretKey);
|
||||||
|
|
||||||
|
const mockedMetadata: MetadataItem[] = [
|
||||||
|
{
|
||||||
|
key: "providers",
|
||||||
|
value: mockedEncryptedProviders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channels",
|
||||||
|
value: mockedEncryptedChannels,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.stubEnv("SECRET_KEY", mockedSecretKey);
|
||||||
|
|
||||||
|
describe("getAppConfig", () => {
|
||||||
|
it("should return empty providers and channels config when no metadata", () => {
|
||||||
|
const { providers, channels } = getAppConfig([]);
|
||||||
|
|
||||||
|
expect(providers).toEqual([]);
|
||||||
|
expect(channels).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return decrypted providers and channels config when metadata provided", () => {
|
||||||
|
const { providers, channels } = getAppConfig(mockedMetadata);
|
||||||
|
|
||||||
|
expect(providers).toEqual(mockedProviders);
|
||||||
|
expect(channels).toEqual(mockedChannels);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,10 +1,9 @@
|
||||||
import { decrypt } from "@saleor/app-sdk/settings-manager";
|
import { decrypt } from "@saleor/app-sdk/settings-manager";
|
||||||
import { ExpectedWebhookPayload } from "../../lib/saleor/schema";
|
import { MetadataItem } from "../../../generated/graphql";
|
||||||
import { ChannelsConfig, channelsSchema } from "../channels-configuration/channels-config";
|
import { ChannelsConfig, channelsSchema } from "../channels-configuration/channels-config";
|
||||||
import { ProvidersConfig, providersSchema } from "../providers-configuration/providers-config";
|
import { ProvidersConfig, providersSchema } from "../providers-configuration/providers-config";
|
||||||
|
|
||||||
export const getAppConfig = (payload: ExpectedWebhookPayload) => {
|
export const getAppConfig = (metadata: MetadataItem[]) => {
|
||||||
const metadata = payload.recipient?.privateMetadata;
|
|
||||||
let providersConfig = [] as ProvidersConfig;
|
let providersConfig = [] as ProvidersConfig;
|
||||||
let channelsConfig = {} as ChannelsConfig;
|
let channelsConfig = {} as ChannelsConfig;
|
||||||
|
|
||||||
|
@ -14,8 +13,10 @@ export const getAppConfig = (payload: ExpectedWebhookPayload) => {
|
||||||
throw new Error("SECRET_KEY env variable is not set");
|
throw new Error("SECRET_KEY env variable is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
// * The App Config contains two types of data: providers and channels.
|
/**
|
||||||
// * We must recognize which one we are dealing with and parse it accordingly.
|
* The App Config contains two types of data: providers and channels.
|
||||||
|
* We must recognize which one we are dealing with and parse it accordingly.
|
||||||
|
*/
|
||||||
metadata?.forEach((item) => {
|
metadata?.forEach((item) => {
|
||||||
const decrypted = decrypt(item.value, secretKey);
|
const decrypted = decrypt(item.value, secretKey);
|
||||||
const parsed = JSON.parse(decrypted);
|
const parsed = JSON.parse(decrypted);
|
|
@ -3,12 +3,13 @@ import { Client } from "urql";
|
||||||
import {
|
import {
|
||||||
FetchAppDetailsDocument,
|
FetchAppDetailsDocument,
|
||||||
FetchAppDetailsQuery,
|
FetchAppDetailsQuery,
|
||||||
UpdateAppMetadataDocument,
|
UpdateMetadataDocument,
|
||||||
} from "../../../generated/graphql";
|
} from "../../../generated/graphql";
|
||||||
import { logger as pinoLogger } from "../../lib/logger";
|
import { logger as pinoLogger } from "../../lib/logger";
|
||||||
|
|
||||||
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
||||||
const logger = pinoLogger.child({ service: "fetchAllMetadata" });
|
const logger = pinoLogger.child({ service: "fetchAllMetadata" });
|
||||||
|
|
||||||
logger.debug("Fetching metadata from Saleor");
|
logger.debug("Fetching metadata from Saleor");
|
||||||
|
|
||||||
const { error, data } = await client
|
const { error, data } = await client
|
||||||
|
@ -27,6 +28,7 @@ export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]>
|
||||||
|
|
||||||
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
|
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
|
||||||
const logger = pinoLogger.child({ service: "mutateMetadata" });
|
const logger = pinoLogger.child({ service: "mutateMetadata" });
|
||||||
|
|
||||||
logger.debug({ metadata }, "Mutating metadata");
|
logger.debug({ metadata }, "Mutating metadata");
|
||||||
// to update the metadata, ID is required
|
// to update the metadata, ID is required
|
||||||
const { error: idQueryError, data: idQueryData } = await client
|
const { error: idQueryError, data: idQueryData } = await client
|
||||||
|
@ -48,7 +50,7 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error: mutationError, data: mutationData } = await client
|
const { error: mutationError, data: mutationData } = await client
|
||||||
.mutation(UpdateAppMetadataDocument, {
|
.mutation(UpdateMetadataDocument, {
|
||||||
id: appId,
|
id: appId,
|
||||||
input: metadata,
|
input: metadata,
|
||||||
})
|
})
|
||||||
|
@ -67,9 +69,11 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createSettingsManager = (client: Client) => {
|
export const createSettingsManager = (client: Client) => {
|
||||||
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
/**
|
||||||
// We recommend it for production, because all values are encrypted.
|
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||||
// If your use case require plain text values, you can use MetadataManager.
|
* We recommend it for production, because all values are encrypted.
|
||||||
|
* If your use case require plain text values, you can use MetadataManager.
|
||||||
|
*/
|
||||||
return new EncryptedMetadataManager({
|
return new EncryptedMetadataManager({
|
||||||
// Secret key should be randomly created for production and set as environment variable
|
// Secret key should be randomly created for production and set as environment variable
|
||||||
encryptionKey: process.env.SECRET_KEY!,
|
encryptionKey: process.env.SECRET_KEY!,
|
28
apps/taxes/src/modules/app/webhook-response.ts
Normal file
28
apps/taxes/src/modules/app/webhook-response.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { NextApiResponse } from "next";
|
||||||
|
import { Logger } from "pino";
|
||||||
|
import { createLogger } from "../../lib/logger";
|
||||||
|
|
||||||
|
export class WebhookResponse {
|
||||||
|
private logger: Logger;
|
||||||
|
constructor(private res: NextApiResponse) {
|
||||||
|
this.logger = createLogger({ event: "WebhookResponse" });
|
||||||
|
}
|
||||||
|
|
||||||
|
failureNoRetry(error: string) {
|
||||||
|
this.logger.debug({ error }, "failureNoRetry called with:");
|
||||||
|
return this.res.status(200).json({ success: false, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
failureRetry(error: string) {
|
||||||
|
this.logger.error({ error }, "failureRetry called with:");
|
||||||
|
return this.res.status(500).json({ success: false, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
success(data?: any) {
|
||||||
|
this.logger.debug({ data }, "success called with:");
|
||||||
|
return this.res.status(200).json({
|
||||||
|
status: 200,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,112 +0,0 @@
|
||||||
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
|
|
||||||
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
|
||||||
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
|
||||||
import { TaxBaseFragment } from "../../../generated/graphql";
|
|
||||||
|
|
||||||
import { ChannelConfig } from "../channels-configuration/channels-config";
|
|
||||||
import { taxLineResolver } from "../taxes/tax-line-resolver";
|
|
||||||
import { ResponseTaxPayload } from "../taxes/types";
|
|
||||||
import { AvataxConfig } from "./avatax-config";
|
|
||||||
|
|
||||||
const SHIPPING_ITEM_CODE = "Shipping";
|
|
||||||
|
|
||||||
const formatCalculatedAmount = (amount: number) => {
|
|
||||||
return Number(amount.toFixed(2));
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareLines = (taxBase: TaxBaseFragment): LineItemModel[] => {
|
|
||||||
const productLines = taxBase.lines.map((line) => ({
|
|
||||||
amount: line.unitPrice.amount,
|
|
||||||
taxIncluded: line.chargeTaxes,
|
|
||||||
taxCode: taxLineResolver.getLineTaxCode(line),
|
|
||||||
quantity: line.quantity,
|
|
||||||
itemCode: "Product",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (taxBase.shippingPrice.amount !== 0) {
|
|
||||||
// * In Avatax, shipping is a regular line
|
|
||||||
const shippingLine: LineItemModel = {
|
|
||||||
amount: taxBase.shippingPrice.amount,
|
|
||||||
itemCode: SHIPPING_ITEM_CODE,
|
|
||||||
quantity: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [...productLines, shippingLine];
|
|
||||||
}
|
|
||||||
|
|
||||||
return productLines;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultAvataxTransactionModel = {
|
|
||||||
// todo: what is customerCode
|
|
||||||
customerCode: "0",
|
|
||||||
type: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const preparePayload = (
|
|
||||||
taxBase: TaxBaseFragment,
|
|
||||||
channel: ChannelConfig,
|
|
||||||
config: AvataxConfig
|
|
||||||
): CreateTransactionModel => {
|
|
||||||
return {
|
|
||||||
...defaultAvataxTransactionModel,
|
|
||||||
companyCode: config.companyName,
|
|
||||||
// * 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: config.isAutocommit,
|
|
||||||
addresses: {
|
|
||||||
shipFrom: {
|
|
||||||
line1: channel.address.street,
|
|
||||||
city: channel.address.city,
|
|
||||||
region: channel.address.state,
|
|
||||||
postalCode: channel.address.zip,
|
|
||||||
country: channel.address.country,
|
|
||||||
},
|
|
||||||
shipTo: {
|
|
||||||
line1: taxBase.address?.streetAddress1,
|
|
||||||
line2: taxBase.address?.streetAddress2,
|
|
||||||
city: taxBase.address?.city,
|
|
||||||
country: taxBase.address?.country.code,
|
|
||||||
postalCode: taxBase.address?.postalCode,
|
|
||||||
region: taxBase.address?.countryArea,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lines: prepareLines(taxBase),
|
|
||||||
// todo: replace date with order/checkout date
|
|
||||||
date: new Date(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareResponse = (transaction: TransactionModel): ResponseTaxPayload => {
|
|
||||||
const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE);
|
|
||||||
|
|
||||||
const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE);
|
|
||||||
|
|
||||||
const shippingGrossAmount = shippingLine?.taxableAmount ?? 0;
|
|
||||||
const shippingTaxCalculated = shippingLine?.taxCalculated ?? 0;
|
|
||||||
const shippingNetAmount = formatCalculatedAmount(shippingGrossAmount - shippingTaxCalculated);
|
|
||||||
|
|
||||||
return {
|
|
||||||
shipping_price_gross_amount: shippingGrossAmount,
|
|
||||||
shipping_price_net_amount: shippingNetAmount,
|
|
||||||
// todo: add shipping tax rate
|
|
||||||
shipping_tax_rate: 0,
|
|
||||||
lines:
|
|
||||||
productLines?.map((line) => {
|
|
||||||
const lineTaxCalculated = line.taxCalculated ?? 0;
|
|
||||||
const lineTotalNetAmount = line.taxableAmount ?? 0;
|
|
||||||
const lineTotalGrossAmount = formatCalculatedAmount(lineTotalNetAmount + lineTaxCalculated);
|
|
||||||
return {
|
|
||||||
total_gross_amount: lineTotalGrossAmount,
|
|
||||||
total_net_amount: lineTotalNetAmount,
|
|
||||||
// todo: add tax rate
|
|
||||||
tax_rate: 0,
|
|
||||||
};
|
|
||||||
}) ?? [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const avataxCalculate = {
|
|
||||||
preparePayload,
|
|
||||||
prepareResponse,
|
|
||||||
prepareLines,
|
|
||||||
};
|
|
|
@ -4,6 +4,8 @@ import pino from "pino";
|
||||||
import packageJson from "../../../package.json";
|
import packageJson from "../../../package.json";
|
||||||
import { createLogger } from "../../lib/logger";
|
import { createLogger } from "../../lib/logger";
|
||||||
import { AvataxConfig } from "./avatax-config";
|
import { AvataxConfig } from "./avatax-config";
|
||||||
|
import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel";
|
||||||
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
|
|
||||||
type AvataxSettings = {
|
type AvataxSettings = {
|
||||||
appName: string;
|
appName: string;
|
||||||
|
@ -35,6 +37,17 @@ const createAvataxSettings = (config: AvataxConfig): AvataxSettings => {
|
||||||
return settings;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CommitTransactionArgs = {
|
||||||
|
companyCode: string;
|
||||||
|
transactionCode: string;
|
||||||
|
model: CommitTransactionModel;
|
||||||
|
documentType: DocumentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateTransactionArgs = {
|
||||||
|
model: CreateTransactionModel;
|
||||||
|
};
|
||||||
|
|
||||||
export class AvataxClient {
|
export class AvataxClient {
|
||||||
private client: Avatax;
|
private client: Avatax;
|
||||||
private logger: pino.Logger;
|
private logger: pino.Logger;
|
||||||
|
@ -49,16 +62,23 @@ export class AvataxClient {
|
||||||
};
|
};
|
||||||
const settings = createAvataxSettings(config);
|
const settings = createAvataxSettings(config);
|
||||||
const avataxClient = new Avatax(settings).withSecurity(credentials);
|
const avataxClient = new Avatax(settings).withSecurity(credentials);
|
||||||
|
|
||||||
this.logger.trace({ client: avataxClient }, "External Avatax client created");
|
this.logger.trace({ client: avataxClient }, "External Avatax client created");
|
||||||
this.client = avataxClient;
|
this.client = avataxClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchTaxesForOrder(model: CreateTransactionModel) {
|
async createTransaction({ model }: CreateTransactionArgs) {
|
||||||
this.logger.debug({ model }, "fetchTaxesForOrder called with:");
|
this.logger.debug({ model }, "createTransaction called with:");
|
||||||
|
|
||||||
return this.client.createTransaction({ model });
|
return this.client.createTransaction({ model });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async commitTransaction(args: CommitTransactionArgs) {
|
||||||
|
this.logger.debug(args, "commitTransaction called with:");
|
||||||
|
|
||||||
|
return this.client.commitTransaction(args);
|
||||||
|
}
|
||||||
|
|
||||||
async ping() {
|
async ping() {
|
||||||
this.logger.debug("ping called");
|
this.logger.debug("ping called");
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -6,7 +6,7 @@ export const avataxConfigSchema = z.object({
|
||||||
username: z.string().min(1, { message: "Username requires at least one character." }),
|
username: z.string().min(1, { message: "Username requires at least one character." }),
|
||||||
password: z.string().min(1, { message: "Password requires at least one character." }),
|
password: z.string().min(1, { message: "Password requires at least one character." }),
|
||||||
isSandbox: z.boolean(),
|
isSandbox: z.boolean(),
|
||||||
companyName: z.string().min(1, { message: "Company name requires at least one character." }),
|
companyCode: z.string().min(1, { message: "Company code requires at least one character." }),
|
||||||
isAutocommit: z.boolean(),
|
isAutocommit: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ export const defaultAvataxConfig: AvataxConfig = {
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
companyName: "",
|
companyCode: "",
|
||||||
isSandbox: true,
|
isSandbox: true,
|
||||||
isAutocommit: false,
|
isAutocommit: false,
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,6 +18,7 @@ const patchInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
value: avataxConfigSchema.partial().transform((c) => {
|
value: avataxConfigSchema.partial().transform((c) => {
|
||||||
const { username, password, ...config } = c ?? {};
|
const { username, password, ...config } = c ?? {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
...(username && !isObfuscated(username) && { username }),
|
...(username && !isObfuscated(username) && { username }),
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { createLogger } from "../../lib/logger";
|
import { createLogger } from "../../lib/logger";
|
||||||
import { createSettingsManager } from "../app-configuration/metadata-manager";
|
import { createSettingsManager } from "../app/metadata-manager";
|
||||||
import { CrudSettingsConfigurator } from "../crud-settings/crud-settings.service";
|
import { CrudSettingsManager } from "../crud-settings/crud-settings.service";
|
||||||
import { providersSchema } from "../providers-configuration/providers-config";
|
import { providersSchema } from "../providers-configuration/providers-config";
|
||||||
import { TAX_PROVIDER_KEY } from "../providers-configuration/public-providers-configuration-service";
|
import { TAX_PROVIDER_KEY } from "../providers-configuration/public-providers-configuration-service";
|
||||||
import { AvataxClient } from "./avatax-client";
|
import { AvataxClient } from "./avatax-client";
|
||||||
import { AvataxConfig, AvataxInstanceConfig, avataxInstanceConfigSchema } from "./avatax-config";
|
import { AvataxConfig, AvataxInstanceConfig, avataxInstanceConfigSchema } from "./avatax-config";
|
||||||
|
|
||||||
const getSchema = avataxInstanceConfigSchema;
|
const getSchema = avataxInstanceConfigSchema;
|
||||||
|
|
||||||
export class AvataxConfigurationService {
|
export class AvataxConfigurationService {
|
||||||
private crudSettingsConfigurator: CrudSettingsConfigurator;
|
private crudSettingsManager: CrudSettingsManager;
|
||||||
private logger: pino.Logger;
|
private logger: pino.Logger;
|
||||||
constructor(client: Client, saleorApiUrl: string) {
|
constructor(client: Client, saleorApiUrl: string) {
|
||||||
const settingsManager = createSettingsManager(client);
|
const settingsManager = createSettingsManager(client);
|
||||||
this.crudSettingsConfigurator = new CrudSettingsConfigurator(
|
|
||||||
|
this.crudSettingsManager = new CrudSettingsManager(
|
||||||
settingsManager,
|
settingsManager,
|
||||||
saleorApiUrl,
|
saleorApiUrl,
|
||||||
TAX_PROVIDER_KEY
|
TAX_PROVIDER_KEY
|
||||||
|
@ -27,7 +29,7 @@ export class AvataxConfigurationService {
|
||||||
|
|
||||||
async getAll(): Promise<AvataxInstanceConfig[]> {
|
async getAll(): Promise<AvataxInstanceConfig[]> {
|
||||||
this.logger.debug(".getAll called");
|
this.logger.debug(".getAll called");
|
||||||
const { data } = await this.crudSettingsConfigurator.readAll();
|
const { data } = await this.crudSettingsManager.readAll();
|
||||||
const validation = providersSchema.safeParse(data);
|
const validation = providersSchema.safeParse(data);
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
|
@ -44,8 +46,9 @@ export class AvataxConfigurationService {
|
||||||
|
|
||||||
async get(id: string): Promise<AvataxInstanceConfig> {
|
async get(id: string): Promise<AvataxInstanceConfig> {
|
||||||
this.logger.debug(`.get called with id: ${id}`);
|
this.logger.debug(`.get called with id: ${id}`);
|
||||||
const { data } = await this.crudSettingsConfigurator.read(id);
|
const { data } = await this.crudSettingsManager.read(id);
|
||||||
this.logger.debug(`Fetched setting from crudSettingsConfigurator`);
|
|
||||||
|
this.logger.debug(`Fetched setting from CrudSettingsManager`);
|
||||||
|
|
||||||
const validation = getSchema.safeParse(data);
|
const validation = getSchema.safeParse(data);
|
||||||
|
|
||||||
|
@ -67,7 +70,7 @@ export class AvataxConfigurationService {
|
||||||
throw new Error(validation.error);
|
throw new Error(validation.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.crudSettingsConfigurator.create({
|
const result = await this.crudSettingsManager.create({
|
||||||
provider: "avatax",
|
provider: "avatax",
|
||||||
config: config,
|
config: config,
|
||||||
});
|
});
|
||||||
|
@ -81,7 +84,7 @@ export class AvataxConfigurationService {
|
||||||
// omit the key "id" from the result
|
// omit the key "id" from the result
|
||||||
const { id: _, ...setting } = data;
|
const { id: _, ...setting } = data;
|
||||||
|
|
||||||
return this.crudSettingsConfigurator.update(id, {
|
return this.crudSettingsManager.update(id, {
|
||||||
...setting,
|
...setting,
|
||||||
config: { ...setting.config, ...config },
|
config: { ...setting.config, ...config },
|
||||||
});
|
});
|
||||||
|
@ -93,7 +96,7 @@ export class AvataxConfigurationService {
|
||||||
const { id: _, ...setting } = data;
|
const { id: _, ...setting } = data;
|
||||||
|
|
||||||
this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`);
|
this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`);
|
||||||
return this.crudSettingsConfigurator.update(id, {
|
return this.crudSettingsManager.update(id, {
|
||||||
...setting,
|
...setting,
|
||||||
config: { ...config },
|
config: { ...config },
|
||||||
});
|
});
|
||||||
|
@ -101,6 +104,6 @@ export class AvataxConfigurationService {
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
this.logger.debug(`.delete called with id: ${id}`);
|
this.logger.debug(`.delete called with id: ${id}`);
|
||||||
return this.crudSettingsConfigurator.delete(id);
|
return this.crudSettingsManager.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
import pino from "pino";
|
|
||||||
import { TaxBaseFragment } from "../../../generated/graphql";
|
|
||||||
import { createLogger } from "../../lib/logger";
|
|
||||||
import { ChannelConfig } from "../channels-configuration/channels-config";
|
|
||||||
import { TaxProvider } from "../taxes/tax-provider";
|
|
||||||
import { avataxCalculate } from "./avatax-calculate";
|
|
||||||
import { AvataxClient } from "./avatax-client";
|
|
||||||
import { AvataxConfig, defaultAvataxConfig } from "./avatax-config";
|
|
||||||
|
|
||||||
export class AvataxProvider implements TaxProvider {
|
|
||||||
readonly name = "avatax";
|
|
||||||
config = defaultAvataxConfig;
|
|
||||||
client: AvataxClient;
|
|
||||||
private logger: pino.Logger;
|
|
||||||
|
|
||||||
constructor(config: AvataxConfig) {
|
|
||||||
this.logger = createLogger({
|
|
||||||
service: "AvataxProvider",
|
|
||||||
});
|
|
||||||
const avataxClient = new AvataxClient(config);
|
|
||||||
this.logger.trace({ client: avataxClient }, "Internal Avatax client created");
|
|
||||||
|
|
||||||
this.config = config;
|
|
||||||
this.client = avataxClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
|
|
||||||
this.logger.debug({ payload, channel }, "Avatax calculate called with:");
|
|
||||||
const model = avataxCalculate.preparePayload(payload, channel, this.config);
|
|
||||||
const result = await this.client.fetchTaxesForOrder(model);
|
|
||||||
this.logger.debug({ createOrderTransaction: result }, "Avatax createOrderTransaction response");
|
|
||||||
return avataxCalculate.prepareResponse(result);
|
|
||||||
}
|
|
||||||
}
|
|
67
apps/taxes/src/modules/avatax/avatax-webhook.service.ts
Normal file
67
apps/taxes/src/modules/avatax/avatax-webhook.service.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import pino from "pino";
|
||||||
|
import {
|
||||||
|
OrderCreatedSubscriptionFragment,
|
||||||
|
OrderFulfilledSubscriptionFragment,
|
||||||
|
TaxBaseFragment,
|
||||||
|
} from "../../../generated/graphql";
|
||||||
|
import { createLogger } from "../../lib/logger";
|
||||||
|
import { ChannelConfig } from "../channels-configuration/channels-config";
|
||||||
|
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
|
||||||
|
import { avataxCalculateTaxesMaps } from "./maps/avatax-calculate-taxes-map";
|
||||||
|
import { AvataxClient } from "./avatax-client";
|
||||||
|
import { AvataxConfig, defaultAvataxConfig } from "./avatax-config";
|
||||||
|
import { avataxOrderCreatedMaps } from "./maps/avatax-order-created-map";
|
||||||
|
import { avataxOrderFulfilledMaps } from "./maps/avatax-order-fulfilled-map";
|
||||||
|
|
||||||
|
export class AvataxWebhookService implements ProviderWebhookService {
|
||||||
|
config = defaultAvataxConfig;
|
||||||
|
client: AvataxClient;
|
||||||
|
private logger: pino.Logger;
|
||||||
|
|
||||||
|
constructor(config: AvataxConfig) {
|
||||||
|
this.logger = createLogger({
|
||||||
|
service: "AvataxWebhookService",
|
||||||
|
});
|
||||||
|
const avataxClient = new AvataxClient(config);
|
||||||
|
|
||||||
|
this.logger.trace({ client: avataxClient }, "Internal Avatax client created");
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
this.client = avataxClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateTaxes(payload: TaxBaseFragment, channel: ChannelConfig) {
|
||||||
|
this.logger.debug({ payload, channel }, "calculateTaxes called with:");
|
||||||
|
const args = avataxCalculateTaxesMaps.mapPayload({
|
||||||
|
taxBase: payload,
|
||||||
|
channel,
|
||||||
|
config: this.config,
|
||||||
|
});
|
||||||
|
const result = await this.client.createTransaction(args);
|
||||||
|
|
||||||
|
this.logger.debug({ result }, "calculateTaxes response");
|
||||||
|
return avataxCalculateTaxesMaps.mapResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(order: OrderCreatedSubscriptionFragment, channel: ChannelConfig) {
|
||||||
|
this.logger.debug({ order, channel }, "createOrder called with:");
|
||||||
|
const model = avataxOrderCreatedMaps.mapPayload(order, channel, this.config);
|
||||||
|
|
||||||
|
this.logger.debug({ model }, "will call createTransaction with");
|
||||||
|
const result = await this.client.createTransaction(model);
|
||||||
|
|
||||||
|
this.logger.debug({ result }, "createOrder response");
|
||||||
|
return avataxOrderCreatedMaps.mapResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fulfillOrder(order: OrderFulfilledSubscriptionFragment, channel: ChannelConfig) {
|
||||||
|
this.logger.debug({ order, channel }, "fulfillOrder called with:");
|
||||||
|
const args = avataxOrderFulfilledMaps.mapPayload(order, this.config);
|
||||||
|
|
||||||
|
this.logger.debug({ args }, "will call commitTransaction with");
|
||||||
|
const result = await this.client.commitTransaction(args);
|
||||||
|
|
||||||
|
this.logger.debug({ result }, "fulfillOrder response");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
70
apps/taxes/src/modules/avatax/maps/address-factory.test.ts
Normal file
70
apps/taxes/src/modules/avatax/maps/address-factory.test.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { avataxAddressFactory } from "./address-factory";
|
||||||
|
|
||||||
|
describe("avataxAddressFactory", () => {
|
||||||
|
describe("fromChannelAddress", () => {
|
||||||
|
it("returns fields in the expected format", () => {
|
||||||
|
const result = avataxAddressFactory.fromChannelAddress({
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
country: "US",
|
||||||
|
state: "CA",
|
||||||
|
street: "123 Palm Grove Ln",
|
||||||
|
zip: "90002",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
line1: "123 Palm Grove Ln",
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
region: "CA",
|
||||||
|
postalCode: "90002",
|
||||||
|
country: "US",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fromSaleorAddress", () => {
|
||||||
|
it("returns fields in the expected format with line1", () => {
|
||||||
|
const result = avataxAddressFactory.fromSaleorAddress({
|
||||||
|
streetAddress1: "123 Palm Grove Ln",
|
||||||
|
streetAddress2: "",
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
country: {
|
||||||
|
code: "US",
|
||||||
|
},
|
||||||
|
countryArea: "CA",
|
||||||
|
postalCode: "90002",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
line1: "123 Palm Grove Ln",
|
||||||
|
line2: "",
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
region: "CA",
|
||||||
|
postalCode: "90002",
|
||||||
|
country: "US",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns fields in the expected format with line1 and line2", () => {
|
||||||
|
const result = avataxAddressFactory.fromSaleorAddress({
|
||||||
|
streetAddress1: "123 Palm",
|
||||||
|
streetAddress2: "Grove Ln",
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
country: {
|
||||||
|
code: "US",
|
||||||
|
},
|
||||||
|
countryArea: "CA",
|
||||||
|
postalCode: "90002",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
line1: "123 Palm",
|
||||||
|
line2: "Grove Ln",
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
region: "CA",
|
||||||
|
postalCode: "90002",
|
||||||
|
country: "US",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
31
apps/taxes/src/modules/avatax/maps/address-factory.ts
Normal file
31
apps/taxes/src/modules/avatax/maps/address-factory.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
|
||||||
|
import { ChannelAddress } from "../../channels-configuration/channels-config";
|
||||||
|
import { AddressFragment } from "../../../../generated/graphql";
|
||||||
|
|
||||||
|
type SaleorAddress = AddressFragment;
|
||||||
|
|
||||||
|
function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress {
|
||||||
|
return {
|
||||||
|
line1: address.streetAddress1,
|
||||||
|
line2: address.streetAddress2,
|
||||||
|
city: address.city,
|
||||||
|
region: address.countryArea,
|
||||||
|
postalCode: address.postalCode,
|
||||||
|
country: address.country.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChannelAddressToAvataxAddress(address: ChannelAddress): AvataxAddress {
|
||||||
|
return {
|
||||||
|
line1: address.street,
|
||||||
|
city: address.city,
|
||||||
|
region: address.state,
|
||||||
|
postalCode: address.zip,
|
||||||
|
country: address.country,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avataxAddressFactory = {
|
||||||
|
fromSaleorAddress: mapSaleorAddressToAvataxAddress,
|
||||||
|
fromChannelAddress: mapChannelAddressToAvataxAddress,
|
||||||
|
};
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
AvataxCalculateTaxesMapPayloadArgs,
|
||||||
|
avataxCalculateTaxesMaps,
|
||||||
|
} from "./avatax-calculate-taxes-map";
|
||||||
|
|
||||||
|
// * Mocked payload data, channel config and avatax config
|
||||||
|
const MOCKED_CALCULATE_TAXES_ARGS: AvataxCalculateTaxesMapPayloadArgs = {
|
||||||
|
taxBase: {
|
||||||
|
currency: "PLN",
|
||||||
|
channel: {
|
||||||
|
slug: "channel-pln",
|
||||||
|
},
|
||||||
|
sourceObject: {
|
||||||
|
__typename: "Order",
|
||||||
|
user: {
|
||||||
|
id: "VXNlcjo5ZjY3ZjY0Zi1iZjY5LTQ5ZjYtYjQ4Zi1iZjY3ZjY0ZjY0ZjY=",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
discounts: [],
|
||||||
|
address: {
|
||||||
|
streetAddress1: "123 Palm Grove Ln",
|
||||||
|
streetAddress2: "",
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
country: {
|
||||||
|
code: "US",
|
||||||
|
},
|
||||||
|
countryArea: "CA",
|
||||||
|
postalCode: "90002",
|
||||||
|
},
|
||||||
|
shippingPrice: {
|
||||||
|
amount: 48.33,
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeTaxes: true,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: {
|
||||||
|
amount: 84,
|
||||||
|
},
|
||||||
|
totalPrice: {
|
||||||
|
amount: 84,
|
||||||
|
},
|
||||||
|
sourceLine: {
|
||||||
|
__typename: "OrderLine",
|
||||||
|
id: "T3JkZXJMaW5lOmY1NGQ1MWY2LTc1OTctNGY2OC1hNDk0LTFjYjZlYjRmOTlhMQ==",
|
||||||
|
variant: {
|
||||||
|
id: "UHJvZHVjdFZhcmlhbnQ6MzQ2",
|
||||||
|
product: {
|
||||||
|
metafield: null,
|
||||||
|
productType: {
|
||||||
|
metafield: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chargeTaxes: true,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: {
|
||||||
|
amount: 5.99,
|
||||||
|
},
|
||||||
|
totalPrice: {
|
||||||
|
amount: 5.99,
|
||||||
|
},
|
||||||
|
sourceLine: {
|
||||||
|
__typename: "OrderLine",
|
||||||
|
id: "T3JkZXJMaW5lOjU1NTFjNTFjLTM5MWQtNGI0Ny04MGU0LWVjY2Q5ZjU4MjQyNQ==",
|
||||||
|
variant: {
|
||||||
|
id: "UHJvZHVjdFZhcmlhbnQ6Mzg1",
|
||||||
|
product: {
|
||||||
|
metafield: null,
|
||||||
|
productType: {
|
||||||
|
metafield: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
providerInstanceId: "b8c29f49-7cae-4762-8458-e9a27eb83081",
|
||||||
|
enabled: false,
|
||||||
|
address: {
|
||||||
|
country: "US",
|
||||||
|
zip: "92093",
|
||||||
|
state: "CA",
|
||||||
|
city: "La Jolla",
|
||||||
|
street: "9500 Gilman Drive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
companyCode: "DEFAULT",
|
||||||
|
isAutocommit: false,
|
||||||
|
isSandbox: true,
|
||||||
|
name: "Avatax-1",
|
||||||
|
password: "password",
|
||||||
|
username: "username",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("avataxCalculateTaxesMaps", () => {
|
||||||
|
describe.todo("mapResponse", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
describe.todo("mapPayload", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
describe("mapLines", () => {
|
||||||
|
it("includes shipping as a line", () => {
|
||||||
|
const lines = avataxCalculateTaxesMaps.mapLines(MOCKED_CALCULATE_TAXES_ARGS.taxBase);
|
||||||
|
|
||||||
|
expect(lines).toContainEqual({
|
||||||
|
itemCode: avataxCalculateTaxesMaps.shippingItemCode,
|
||||||
|
quantity: 1,
|
||||||
|
amount: 48.33,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
112
apps/taxes/src/modules/avatax/maps/avatax-calculate-taxes-map.ts
Normal file
112
apps/taxes/src/modules/avatax/maps/avatax-calculate-taxes-map.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
||||||
|
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
||||||
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
|
|
||||||
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
|
import { ChannelConfig } from "../../channels-configuration/channels-config";
|
||||||
|
import { taxLineResolver } from "../../taxes/tax-line-resolver";
|
||||||
|
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
||||||
|
import { CreateTransactionArgs } from "../avatax-client";
|
||||||
|
import { AvataxConfig } from "../avatax-config";
|
||||||
|
import { avataxAddressFactory } from "./address-factory";
|
||||||
|
import { numbers } from "../../taxes/numbers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * Shipping is a regular line item in Avatax
|
||||||
|
* https://developer.avalara.com/avatax/dev-guide/shipping-and-handling/taxability-of-shipping-charges/
|
||||||
|
*/
|
||||||
|
const SHIPPING_ITEM_CODE = "Shipping";
|
||||||
|
|
||||||
|
function mapLines(taxBase: TaxBaseFragment): LineItemModel[] {
|
||||||
|
const productLines = taxBase.lines.map((line) => ({
|
||||||
|
amount: line.unitPrice.amount,
|
||||||
|
taxIncluded: line.chargeTaxes,
|
||||||
|
// todo: get from tax code matcher
|
||||||
|
taxCode: taxLineResolver.getLineTaxCode(line),
|
||||||
|
quantity: line.quantity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (taxBase.shippingPrice.amount !== 0) {
|
||||||
|
// * In Avatax, shipping is a regular line
|
||||||
|
const shippingLine: LineItemModel = {
|
||||||
|
amount: taxBase.shippingPrice.amount,
|
||||||
|
itemCode: SHIPPING_ITEM_CODE,
|
||||||
|
/**
|
||||||
|
* todo: add taxCode
|
||||||
|
* * Different shipping methods can have different tax codes
|
||||||
|
* https://developer.avalara.com/ecommerce-integration-guide/sales-tax-badge/designing/non-standard-items/\
|
||||||
|
*/
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...productLines, shippingLine];
|
||||||
|
}
|
||||||
|
|
||||||
|
return productLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvataxCalculateTaxesMapPayloadArgs = {
|
||||||
|
taxBase: TaxBaseFragment;
|
||||||
|
channel: ChannelConfig;
|
||||||
|
config: AvataxConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapPayload = (props: AvataxCalculateTaxesMapPayloadArgs): CreateTransactionArgs => {
|
||||||
|
const { taxBase, channel, config } = props;
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: {
|
||||||
|
type: DocumentType.SalesOrder,
|
||||||
|
customerCode: taxBase.sourceObject.user?.id ?? "",
|
||||||
|
companyCode: config.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: config.isAutocommit,
|
||||||
|
addresses: {
|
||||||
|
shipFrom: avataxAddressFactory.fromChannelAddress(channel.address),
|
||||||
|
shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!),
|
||||||
|
},
|
||||||
|
currencyCode: taxBase.currency,
|
||||||
|
lines: mapLines(taxBase),
|
||||||
|
date: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapResponse = (transaction: TransactionModel): CalculateTaxesResponse => {
|
||||||
|
const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE);
|
||||||
|
const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE);
|
||||||
|
const shippingGrossAmount = shippingLine?.taxableAmount ?? 0;
|
||||||
|
const shippingTaxCalculated = shippingLine?.taxCalculated ?? 0;
|
||||||
|
const shippingNetAmount = numbers.roundFloatToTwoDecimals(
|
||||||
|
shippingGrossAmount - shippingTaxCalculated
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shipping_price_gross_amount: shippingGrossAmount,
|
||||||
|
shipping_price_net_amount: shippingNetAmount,
|
||||||
|
// todo: add shipping tax rate
|
||||||
|
shipping_tax_rate: 0,
|
||||||
|
lines:
|
||||||
|
productLines?.map((line) => {
|
||||||
|
const lineTaxCalculated = line.taxCalculated ?? 0;
|
||||||
|
const lineTotalNetAmount = line.taxableAmount ?? 0;
|
||||||
|
const lineTotalGrossAmount = numbers.roundFloatToTwoDecimals(
|
||||||
|
lineTotalNetAmount + lineTaxCalculated
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_gross_amount: lineTotalGrossAmount,
|
||||||
|
total_net_amount: lineTotalNetAmount,
|
||||||
|
// todo: add tax rate
|
||||||
|
tax_rate: 0,
|
||||||
|
};
|
||||||
|
}) ?? [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const avataxCalculateTaxesMaps = {
|
||||||
|
mapPayload,
|
||||||
|
mapResponse,
|
||||||
|
mapLines,
|
||||||
|
shippingItemCode: SHIPPING_ITEM_CODE,
|
||||||
|
};
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
describe.skip("avataxOrderCreatedMaps", () => {
|
||||||
|
describe.todo("mapResponse", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
describe.todo("mapPayload", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
|
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
||||||
|
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
||||||
|
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
|
||||||
|
import { ChannelConfig } from "../../channels-configuration/channels-config";
|
||||||
|
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
|
||||||
|
import { CreateTransactionArgs } from "../avatax-client";
|
||||||
|
import { AvataxConfig } from "../avatax-config";
|
||||||
|
import { avataxAddressFactory } from "./address-factory";
|
||||||
|
|
||||||
|
const mapLines = (order: OrderCreatedSubscriptionFragment): LineItemModel[] => {
|
||||||
|
const productLines = order.lines.map((line) => ({
|
||||||
|
amount: line.unitPrice.net.amount,
|
||||||
|
quantity: line.quantity,
|
||||||
|
// todo: get from tax code matcher
|
||||||
|
taxCode: "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return productLines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapPayload = (
|
||||||
|
order: OrderCreatedSubscriptionFragment,
|
||||||
|
channel: ChannelConfig,
|
||||||
|
config: AvataxConfig
|
||||||
|
): CreateTransactionArgs => {
|
||||||
|
return {
|
||||||
|
model: {
|
||||||
|
type: DocumentType.SalesInvoice,
|
||||||
|
customerCode: order.user?.id ?? "",
|
||||||
|
companyCode: config.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: config.isAutocommit,
|
||||||
|
addresses: {
|
||||||
|
shipFrom: avataxAddressFactory.fromChannelAddress(channel.address),
|
||||||
|
// billing or shipping address?
|
||||||
|
shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!),
|
||||||
|
},
|
||||||
|
currencyCode: order.total.currency,
|
||||||
|
email: order.user?.email ?? "",
|
||||||
|
lines: mapLines(order),
|
||||||
|
date: new Date(order.created),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapResponse = (response: TransactionModel): CreateOrderResponse => {
|
||||||
|
return {
|
||||||
|
id: response.code ?? "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const avataxOrderCreatedMaps = {
|
||||||
|
mapPayload,
|
||||||
|
mapResponse,
|
||||||
|
};
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
describe.skip("avataxOrderFulfilledMaps", () => {
|
||||||
|
describe.todo("mapResponse", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
describe.todo("mapPayload", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
|
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
|
||||||
|
import { PROVIDER_ORDER_ID_KEY } from "../../../pages/api/webhooks/order-created";
|
||||||
|
import { CommitTransactionArgs } from "../avatax-client";
|
||||||
|
import { AvataxConfig } from "../avatax-config";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapPayload = (
|
||||||
|
order: OrderFulfilledSubscriptionFragment,
|
||||||
|
config: AvataxConfig
|
||||||
|
): CommitTransactionArgs => {
|
||||||
|
const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactionCode,
|
||||||
|
companyCode: config.companyCode,
|
||||||
|
documentType: DocumentType.SalesInvoice,
|
||||||
|
model: {
|
||||||
|
commit: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const avataxOrderFulfilledMaps = {
|
||||||
|
mapPayload,
|
||||||
|
};
|
|
@ -27,10 +27,11 @@ const useStyles = makeStyles((theme) => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const schema = avataxConfigSchema;
|
const schema = avataxConfigSchema;
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
const defaultValues: FormValues = {
|
const defaultValues: FormValues = {
|
||||||
companyName: "",
|
companyCode: "",
|
||||||
isAutocommit: false,
|
isAutocommit: false,
|
||||||
isSandbox: false,
|
isSandbox: false,
|
||||||
password: "",
|
password: "",
|
||||||
|
@ -72,6 +73,7 @@ export const AvataxConfigurationForm = () => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (instance) {
|
if (instance) {
|
||||||
const { config } = instance;
|
const { config } = instance;
|
||||||
|
|
||||||
reset(config);
|
reset(config);
|
||||||
} else {
|
} else {
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
@ -245,15 +247,15 @@ export const AvataxConfigurationForm = () => {
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Controller
|
<Controller
|
||||||
name="companyName"
|
name="companyCode"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={defaultValues.companyName}
|
defaultValue={defaultValues.companyCode}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField type="text" {...field} label="Company name" {...textFieldProps} />
|
<TextField type="text" {...field} label="Company code" {...textFieldProps} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{formState.errors.companyName && (
|
{formState.errors.companyCode && (
|
||||||
<FormHelperText error>{formState.errors.companyName.message}</FormHelperText>
|
<FormHelperText error>{formState.errors.companyCode.message}</FormHelperText>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ChannelFragment } from "../../../generated/graphql";
|
import { ChannelFragment } from "../../../generated/graphql";
|
||||||
import { addressSchema } from "../taxes/tax-common-schema";
|
|
||||||
|
const addressSchema = z.object({
|
||||||
|
country: z.string(),
|
||||||
|
zip: z.string(),
|
||||||
|
state: z.string(),
|
||||||
|
city: z.string(),
|
||||||
|
street: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChannelAddress = z.infer<typeof addressSchema>;
|
||||||
|
|
||||||
export const channelSchema = z.object({
|
export const channelSchema = z.object({
|
||||||
providerInstanceId: z.string(),
|
providerInstanceId: z.string(),
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { logger as pinoLogger } from "../../lib/logger";
|
import { logger as pinoLogger } from "../../lib/logger";
|
||||||
import { createSettingsManager } from "../app-configuration/metadata-manager";
|
import { createSettingsManager } from "../app/metadata-manager";
|
||||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||||
import { router } from "../trpc/trpc-server";
|
import { router } from "../trpc/trpc-server";
|
||||||
import { ChannelsConfig } from "./channels-config";
|
import { ChannelsConfig } from "./channels-config";
|
||||||
|
@ -29,12 +29,14 @@ export const channelsConfigurationRouter = router({
|
||||||
saleorApiUrl: ctx.saleorApiUrl,
|
saleorApiUrl: ctx.saleorApiUrl,
|
||||||
procedure: "channelsConfigurationRouter.upsert",
|
procedure: "channelsConfigurationRouter.upsert",
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(input, "channelsConfigurationRouter.upsert called with input");
|
logger.debug(input, "channelsConfigurationRouter.upsert called with input");
|
||||||
|
|
||||||
const config = await new GetChannelsConfigurationService({
|
const config = await new GetChannelsConfigurationService({
|
||||||
apiClient: ctx.apiClient,
|
apiClient: ctx.apiClient,
|
||||||
saleorApiUrl: ctx.saleorApiUrl,
|
saleorApiUrl: ctx.saleorApiUrl,
|
||||||
}).getConfiguration();
|
}).getConfiguration();
|
||||||
|
|
||||||
logger.debug(config, "Fetched current channels config to update it");
|
logger.debug(config, "Fetched current channels config to update it");
|
||||||
|
|
||||||
const taxChannelsConfigurator = new TaxChannelsConfigurator(
|
const taxChannelsConfigurator = new TaxChannelsConfigurator(
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
import { PrivateMetadataAppConfigurator } from "../app-configuration/app-configurator";
|
import { PrivateMetadataAppConfigurator } from "../app/app-configurator";
|
||||||
import { ChannelsConfig } from "./channels-config";
|
import { ChannelsConfig } from "./channels-config";
|
||||||
|
|
||||||
export class TaxChannelsConfigurator extends PrivateMetadataAppConfigurator<ChannelsConfig> {
|
export class TaxChannelsConfigurator extends PrivateMetadataAppConfigurator<ChannelsConfig> {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { logger as pinoLogger } from "../../lib/logger";
|
import { logger as pinoLogger } from "../../lib/logger";
|
||||||
import { createSettingsManager } from "../app-configuration/metadata-manager";
|
import { createSettingsManager } from "../app/metadata-manager";
|
||||||
import { TaxChannelsConfigurator } from "./channels-configurator";
|
import { TaxChannelsConfigurator } from "./channels-configurator";
|
||||||
|
|
||||||
export class GetChannelsConfigurationService {
|
export class GetChannelsConfigurationService {
|
||||||
|
@ -24,7 +24,6 @@ export class GetChannelsConfigurationService {
|
||||||
saleorApiUrl
|
saleorApiUrl
|
||||||
);
|
);
|
||||||
|
|
||||||
// todo: validate config
|
|
||||||
const appChannelsConfig = (await taxConfigurator.getConfig()) ?? null;
|
const appChannelsConfig = (await taxConfigurator.getConfig()) ?? null;
|
||||||
|
|
||||||
logger.debug(appChannelsConfig, "Retrieved channels config from Metadata");
|
logger.debug(appChannelsConfig, "Retrieved channels config from Metadata");
|
||||||
|
|
|
@ -106,6 +106,7 @@ export const ChannelTaxProviderForm = () => {
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const defaultValues = getDefaultFormValues(channelConfig, providerInstances);
|
const defaultValues = getDefaultFormValues(channelConfig, providerInstances);
|
||||||
|
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
}, [channelConfig, providerInstances, reset]);
|
}, [channelConfig, providerInstances, reset]);
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { useAppRedirect } from "../../../lib/app/redirect";
|
import { useAppRedirect } from "../../../lib/app/redirect";
|
||||||
import { ProviderIcon } from "../../providers-configuration/ui/provider-icon";
|
import { ProviderIcon } from "../../providers-configuration/ui/provider-icon";
|
||||||
import { providerConfig, TaxProviderName } from "../../taxes/providers/config";
|
import { providerConfig, TaxProviderName } from "../../taxes/provider-config";
|
||||||
import { useActiveTab, useChannelSlug, useInstanceId } from "../../taxes/tax-context";
|
import { useActiveTab, useChannelSlug, useInstanceId } from "../../taxes/tax-context";
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
import { AppLink } from "../../ui/app-link";
|
import { AppLink } from "../../ui/app-link";
|
||||||
|
@ -27,6 +27,7 @@ const NoDataPlaceholder = ({
|
||||||
title: string;
|
title: string;
|
||||||
}>) => {
|
}>) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppPaper>
|
<AppPaper>
|
||||||
<div>
|
<div>
|
||||||
|
@ -45,6 +46,7 @@ const NoDataPlaceholder = ({
|
||||||
|
|
||||||
const NoChannelPlaceholder = () => {
|
const NoChannelPlaceholder = () => {
|
||||||
const { redirect } = useAppRedirect();
|
const { redirect } = useAppRedirect();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoDataPlaceholder title={"Channels not found"}>
|
<NoDataPlaceholder title={"Channels not found"}>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { createId } from "../../lib/utils";
|
||||||
const settingSchema = z.record(z.any()).and(z.object({ id: z.string() }));
|
const settingSchema = z.record(z.any()).and(z.object({ id: z.string() }));
|
||||||
const settingsSchema = z.array(settingSchema);
|
const settingsSchema = z.array(settingSchema);
|
||||||
|
|
||||||
export class CrudSettingsConfigurator {
|
export class CrudSettingsManager {
|
||||||
private logger: pino.Logger;
|
private logger: pino.Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -16,7 +16,7 @@ export class CrudSettingsConfigurator {
|
||||||
private metadataKey: string
|
private metadataKey: string
|
||||||
) {
|
) {
|
||||||
this.metadataKey = metadataKey;
|
this.metadataKey = metadataKey;
|
||||||
this.logger = createLogger({ service: "CrudSettingsConfigurator", metadataKey });
|
this.logger = createLogger({ service: "CrudSettingsManager", metadataKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
async readAll() {
|
async readAll() {
|
||||||
|
@ -47,6 +47,7 @@ export class CrudSettingsConfigurator {
|
||||||
const { data: settings } = result;
|
const { data: settings } = result;
|
||||||
|
|
||||||
const item = settings.find((item) => item.id === id);
|
const item = settings.find((item) => item.id === id);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
this.logger.error({ id }, "Item not found");
|
this.logger.error({ id }, "Item not found");
|
||||||
throw new Error("Item not found");
|
throw new Error("Item not found");
|
||||||
|
@ -65,6 +66,7 @@ export class CrudSettingsConfigurator {
|
||||||
|
|
||||||
const id = createId();
|
const id = createId();
|
||||||
const newData = [...prevData, { ...data, id }];
|
const newData = [...prevData, { ...data, id }];
|
||||||
|
|
||||||
await this.metadataManager.set({
|
await this.metadataManager.set({
|
||||||
key: this.metadataKey,
|
key: this.metadataKey,
|
||||||
value: JSON.stringify(newData),
|
value: JSON.stringify(newData),
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
import pino from "pino";
|
|
||||||
import { Client } from "urql";
|
|
||||||
import { createLogger } from "../../lib/logger";
|
|
||||||
import { AvataxConfigurationService } from "../avatax/avatax-configuration.service";
|
|
||||||
import { TaxJarConfigurationService } from "../taxjar/taxjar-configuration.service";
|
|
||||||
|
|
||||||
export const TAX_PROVIDER_KEY = "tax-providers";
|
|
||||||
|
|
||||||
export class PrivateTaxProvidersConfigurationService {
|
|
||||||
private avataxConfigurationService: AvataxConfigurationService;
|
|
||||||
private taxJarConfigurationService: TaxJarConfigurationService;
|
|
||||||
private logger: pino.Logger;
|
|
||||||
constructor(client: Client, saleorApiUrl: string) {
|
|
||||||
this.avataxConfigurationService = new AvataxConfigurationService(client, saleorApiUrl);
|
|
||||||
this.taxJarConfigurationService = new TaxJarConfigurationService(client, saleorApiUrl);
|
|
||||||
this.logger = createLogger({
|
|
||||||
service: "PrivateTaxProvidersConfigurationService",
|
|
||||||
metadataKey: TAX_PROVIDER_KEY,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAll() {
|
|
||||||
this.logger.debug(".getAll called");
|
|
||||||
const taxJar = await this.taxJarConfigurationService.getAll();
|
|
||||||
const avatax = await this.avataxConfigurationService.getAll();
|
|
||||||
return [...taxJar, ...avatax];
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -25,6 +25,7 @@ export class PublicTaxProvidersConfigurationService {
|
||||||
this.logger.debug(".getAll called");
|
this.logger.debug(".getAll called");
|
||||||
const taxJar = await this.taxJarConfigurationService.getAll();
|
const taxJar = await this.taxJarConfigurationService.getAll();
|
||||||
const avatax = await this.avataxConfigurationService.getAll();
|
const avatax = await this.avataxConfigurationService.getAll();
|
||||||
|
|
||||||
return [...obfuscateTaxJarInstances(taxJar), ...obfuscateAvataxInstances(avatax)];
|
return [...obfuscateTaxJarInstances(taxJar), ...obfuscateAvataxInstances(avatax)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
import { makeStyles } from "@saleor/macaw-ui";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { AvataxConfiguration } from "../../avatax/ui/avatax-configuration";
|
import { AvataxConfiguration } from "../../avatax/ui/avatax-configuration";
|
||||||
import { providerConfig, TaxProviderName } from "../../taxes/providers/config";
|
import { providerConfig, TaxProviderName } from "../../taxes/provider-config";
|
||||||
import { TaxJarConfiguration } from "../../taxjar/ui/taxjar-configuration";
|
import { TaxJarConfiguration } from "../../taxjar/ui/taxjar-configuration";
|
||||||
import { useInstanceId } from "../../taxes/tax-context";
|
import { useInstanceId } from "../../taxes/tax-context";
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
@ -48,6 +48,7 @@ export const Configuration = () => {
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const instance = providersConfigurationData?.find((instance) => instance.id === instanceId);
|
const instance = providersConfigurationData?.find((instance) => instance.id === instanceId);
|
||||||
|
|
||||||
setProvider(instance?.provider ?? "taxjar");
|
setProvider(instance?.provider ?? "taxjar");
|
||||||
}, [instanceId, providersConfigurationData]);
|
}, [instanceId, providersConfigurationData]);
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ const useStyles = makeStyles((theme) => ({
|
||||||
|
|
||||||
export const DeleteProviderDialog = (p: DeleteProviderDialogProps) => {
|
export const DeleteProviderDialog = (p: DeleteProviderDialogProps) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={p.isOpen} onClose={p.onClose}>
|
<Dialog open={p.isOpen} onClose={p.onClose}>
|
||||||
<DialogTitle>Delete provider instance?</DialogTitle>
|
<DialogTitle>Delete provider instance?</DialogTitle>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import Image, { ImageProps } from "next/image";
|
import Image, { ImageProps } from "next/image";
|
||||||
import { providerConfig, TaxProviderName } from "../../taxes/providers/config";
|
import { providerConfig, TaxProviderName } from "../../taxes/provider-config";
|
||||||
|
|
||||||
type Size = "small" | "medium" | "large" | "xlarge";
|
type Size = "small" | "medium" | "large" | "xlarge";
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ type ProviderIconProps = {
|
||||||
export const ProviderIcon = ({ provider, size = "medium", ...props }: ProviderIconProps) => {
|
export const ProviderIcon = ({ provider, size = "medium", ...props }: ProviderIconProps) => {
|
||||||
const { icon, label } = providerConfig[provider];
|
const { icon, label } = providerConfig[provider];
|
||||||
const matchedSize = sizes[size];
|
const matchedSize = sizes[size];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Image src={icon} alt={`${label} icon`} width={matchedSize} height={matchedSize} {...props} />
|
<Image src={icon} alt={`${label} icon`} width={matchedSize} height={matchedSize} {...props} />
|
||||||
);
|
);
|
||||||
|
|
149
apps/taxes/src/modules/taxes/active-tax-provider.test.ts
Normal file
149
apps/taxes/src/modules/taxes/active-tax-provider.test.ts
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import { encrypt } from "@saleor/app-sdk/settings-manager";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { MetadataItem } from "../../../generated/graphql";
|
||||||
|
import { ChannelsConfig } from "../channels-configuration/channels-config";
|
||||||
|
import { ProvidersConfig } from "../providers-configuration/providers-config";
|
||||||
|
import { getActiveTaxProvider } from "./active-tax-provider";
|
||||||
|
|
||||||
|
const mockedInvalidMetadata: MetadataItem[] = [
|
||||||
|
{
|
||||||
|
key: "providers",
|
||||||
|
value: JSON.stringify({
|
||||||
|
foo: "bar",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockedSecretKey = "test_secret_key";
|
||||||
|
const mockedProviders: ProvidersConfig = [
|
||||||
|
{
|
||||||
|
provider: "avatax",
|
||||||
|
id: "1",
|
||||||
|
config: {
|
||||||
|
companyCode: "DEFAULT",
|
||||||
|
isAutocommit: false,
|
||||||
|
isSandbox: true,
|
||||||
|
name: "avatax-1",
|
||||||
|
password: "avatax-password",
|
||||||
|
username: "avatax-username",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "taxjar",
|
||||||
|
id: "2",
|
||||||
|
config: {
|
||||||
|
name: "taxjar-1",
|
||||||
|
apiKey: "taxjar-api-key",
|
||||||
|
isSandbox: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const mockedEncryptedProviders = encrypt(JSON.stringify(mockedProviders), mockedSecretKey);
|
||||||
|
|
||||||
|
const mockedChannelsWithInvalidProviderInstanceId: ChannelsConfig = {
|
||||||
|
"default-channel": {
|
||||||
|
address: {
|
||||||
|
city: "New York",
|
||||||
|
country: "US",
|
||||||
|
state: "NY",
|
||||||
|
street: "123 Main St",
|
||||||
|
zip: "10001",
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
providerInstanceId: "3",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedValidChannels: ChannelsConfig = {
|
||||||
|
"default-channel": {
|
||||||
|
address: {
|
||||||
|
city: "New York",
|
||||||
|
country: "US",
|
||||||
|
state: "NY",
|
||||||
|
street: "123 Main St",
|
||||||
|
zip: "10001",
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
providerInstanceId: "1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedInvalidEncryptedChannels = encrypt(
|
||||||
|
JSON.stringify(mockedChannelsWithInvalidProviderInstanceId),
|
||||||
|
mockedSecretKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockedValidEncryptedChannels = encrypt(JSON.stringify(mockedValidChannels), mockedSecretKey);
|
||||||
|
|
||||||
|
vi.stubEnv("SECRET_KEY", mockedSecretKey);
|
||||||
|
|
||||||
|
describe("getActiveTaxProvider", () => {
|
||||||
|
it("should return ok: false when channel slug is missing", () => {
|
||||||
|
const result = getActiveTaxProvider("", mockedInvalidMetadata);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toBe("channel_slug_missing");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ok: false when there are no metadata items", () => {
|
||||||
|
const result = getActiveTaxProvider("default-channel", []);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toBe("app_encrypted_metadata_missing");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ok: false when no providerInstanceId was found", () => {
|
||||||
|
const result = getActiveTaxProvider("default-channel", [
|
||||||
|
{
|
||||||
|
key: "providers",
|
||||||
|
value: mockedEncryptedProviders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channels",
|
||||||
|
value: mockedInvalidEncryptedChannels,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toBe("no_match_for_channel_provider_instance_id");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ok: false when no channel was found for channelSlug", () => {
|
||||||
|
const result = getActiveTaxProvider("invalid-channel", [
|
||||||
|
{
|
||||||
|
key: "providers",
|
||||||
|
value: mockedEncryptedProviders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channels",
|
||||||
|
value: mockedValidEncryptedChannels,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toBe("channel_config_not_found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return ok: true when data is correct", () => {
|
||||||
|
const result = getActiveTaxProvider("default-channel", [
|
||||||
|
{
|
||||||
|
key: "providers",
|
||||||
|
value: mockedEncryptedProviders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channels",
|
||||||
|
value: mockedValidEncryptedChannels,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,45 +1,113 @@
|
||||||
import { TaxBaseFragment } from "../../../generated/graphql";
|
import {
|
||||||
|
MetadataItem,
|
||||||
|
OrderCreatedSubscriptionFragment,
|
||||||
|
OrderFulfilledSubscriptionFragment,
|
||||||
|
TaxBaseFragment,
|
||||||
|
} from "../../../generated/graphql";
|
||||||
import { createLogger } from "../../lib/logger";
|
import { createLogger } from "../../lib/logger";
|
||||||
import { ChannelConfig } from "../channels-configuration/channels-config";
|
import { ChannelConfig } from "../channels-configuration/channels-config";
|
||||||
import { ProviderConfig } from "../providers-configuration/providers-config";
|
import { ProviderConfig } from "../providers-configuration/providers-config";
|
||||||
import { AvataxProvider } from "../avatax/avatax-provider";
|
import { AvataxWebhookService } from "../avatax/avatax-webhook.service";
|
||||||
import { TaxJarProvider } from "../taxjar/taxjar-provider";
|
import { TaxJarWebhookService } from "../taxjar/taxjar-webhook.service";
|
||||||
import { TaxProvider } from "./tax-provider";
|
import { ProviderWebhookService } from "./tax-provider-webhook";
|
||||||
import { TaxProviderError } from "./tax-provider-error";
|
import { TaxProviderError } from "./tax-provider-error";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
import { getAppConfig } from "../app/get-app-config";
|
||||||
|
|
||||||
export class ActiveTaxProvider {
|
type ActiveTaxProviderResult = { ok: true; data: ActiveTaxProvider } | { ok: false; error: string };
|
||||||
private client: TaxProvider;
|
|
||||||
|
export function getActiveTaxProvider(
|
||||||
|
channelSlug: string | undefined,
|
||||||
|
encryptedMetadata: MetadataItem[]
|
||||||
|
): ActiveTaxProviderResult {
|
||||||
|
const logger = createLogger({ service: "getActiveTaxProvider" });
|
||||||
|
|
||||||
|
if (!channelSlug) {
|
||||||
|
logger.error("Channel slug is missing");
|
||||||
|
return { error: "channel_slug_missing", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!encryptedMetadata.length) {
|
||||||
|
logger.error("App encryptedMetadata is missing");
|
||||||
|
return { error: "app_encrypted_metadata_missing", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providers, channels } = getAppConfig(encryptedMetadata);
|
||||||
|
|
||||||
|
const channelConfig = channels[channelSlug];
|
||||||
|
|
||||||
|
if (!channelConfig) {
|
||||||
|
// * will happen when `order-created` webhook is triggered by creating an order in a channel that doesn't use the tax app
|
||||||
|
logger.info(`Channel config not found for channel ${channelSlug}`);
|
||||||
|
return { error: `channel_config_not_found`, ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerInstance = providers.find(
|
||||||
|
(instance) => instance.id === channelConfig.providerInstanceId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!providerInstance) {
|
||||||
|
logger.error(`Channel (${channelSlug}) providerInstanceId does not match any providers`);
|
||||||
|
return {
|
||||||
|
error: `no_match_for_channel_provider_instance_id`,
|
||||||
|
ok: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: refactor so it doesnt create activeTaxProvider
|
||||||
|
const taxProvider = new ActiveTaxProvider(providerInstance, channelConfig);
|
||||||
|
|
||||||
|
return { data: taxProvider, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: refactor to a factory
|
||||||
|
export class ActiveTaxProvider implements ProviderWebhookService {
|
||||||
|
private client: ProviderWebhookService;
|
||||||
private logger: pino.Logger;
|
private logger: pino.Logger;
|
||||||
|
private channel: ChannelConfig;
|
||||||
|
|
||||||
constructor(providerInstance: ProviderConfig) {
|
constructor(providerInstance: ProviderConfig, channelConfig: ChannelConfig) {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
service: "ActiveTaxProvider",
|
service: "ActiveTaxProvider",
|
||||||
});
|
});
|
||||||
|
|
||||||
const taxProviderName = providerInstance.provider;
|
const taxProviderName = providerInstance.provider;
|
||||||
|
|
||||||
this.logger.trace({ taxProviderName }, "Constructing tax provider: ");
|
this.logger.trace({ taxProviderName }, "Constructing tax provider: ");
|
||||||
|
this.channel = channelConfig;
|
||||||
|
|
||||||
switch (taxProviderName) {
|
switch (taxProviderName) {
|
||||||
case "taxjar":
|
case "taxjar":
|
||||||
this.client = new TaxJarProvider(providerInstance.config);
|
this.client = new TaxJarWebhookService(providerInstance.config);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "avatax":
|
case "avatax":
|
||||||
this.client = new AvataxProvider(providerInstance.config);
|
this.client = new AvataxWebhookService(providerInstance.config);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
throw new TaxProviderError(`Tax provider ${taxProviderName} doesnt match`, {
|
throw new TaxProviderError(`Tax provider ${taxProviderName} doesn't match`, {
|
||||||
cause: "TaxProviderNotFound",
|
cause: "TaxProviderNotFound",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
|
async calculateTaxes(payload: TaxBaseFragment) {
|
||||||
this.logger.debug({ payload, channel }, ".calculate called");
|
this.logger.debug({ payload }, ".calculate called");
|
||||||
|
|
||||||
return this.client.calculate(payload, channel);
|
return this.client.calculateTaxes(payload, this.channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(order: OrderCreatedSubscriptionFragment) {
|
||||||
|
this.logger.debug(".createOrder called");
|
||||||
|
|
||||||
|
return this.client.createOrder(order, this.channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fulfillOrder(payload: OrderFulfilledSubscriptionFragment) {
|
||||||
|
this.logger.debug(".fulfillOrder called");
|
||||||
|
|
||||||
|
return this.client.fulfillOrder(payload, this.channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
22
apps/taxes/src/modules/taxes/numbers.test.ts
Normal file
22
apps/taxes/src/modules/taxes/numbers.test.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { numbers } from "./numbers";
|
||||||
|
|
||||||
|
describe("roundFloatToTwoDecimals", () => {
|
||||||
|
it("returns the correct value when multiple decimals", () => {
|
||||||
|
const result = numbers.roundFloatToTwoDecimals(24.33333);
|
||||||
|
|
||||||
|
expect(result).toBe(24.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the correct value when single decimal", () => {
|
||||||
|
const result = numbers.roundFloatToTwoDecimals(24.3);
|
||||||
|
|
||||||
|
expect(result).toBe(24.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the correct value when no decimals", () => {
|
||||||
|
const result = numbers.roundFloatToTwoDecimals(24);
|
||||||
|
|
||||||
|
expect(result).toBe(24);
|
||||||
|
});
|
||||||
|
});
|
7
apps/taxes/src/modules/taxes/numbers.ts
Normal file
7
apps/taxes/src/modules/taxes/numbers.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
function roundFloatToTwoDecimals(float: number): number {
|
||||||
|
return Math.round(float * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const numbers = {
|
||||||
|
roundFloatToTwoDecimals,
|
||||||
|
};
|
|
@ -1,4 +1,4 @@
|
||||||
import { AvataxIcon, TaxJarIcon } from "../../../assets";
|
import { AvataxIcon, TaxJarIcon } from "../../assets";
|
||||||
|
|
||||||
export const providerConfig = {
|
export const providerConfig = {
|
||||||
taxjar: {
|
taxjar: {
|
|
@ -1,9 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const addressSchema = z.object({
|
|
||||||
country: z.string(),
|
|
||||||
zip: z.string(),
|
|
||||||
state: z.string(),
|
|
||||||
city: z.string(),
|
|
||||||
street: z.string(),
|
|
||||||
});
|
|
|
@ -10,6 +10,7 @@ const getLineDiscount = (
|
||||||
}
|
}
|
||||||
const lineTotalAmount = Number(line.totalPrice.amount);
|
const lineTotalAmount = Number(line.totalPrice.amount);
|
||||||
const discountAmount = (lineTotalAmount / allLinesTotal) * totalDiscount;
|
const discountAmount = (lineTotalAmount / allLinesTotal) * totalDiscount;
|
||||||
|
|
||||||
if (discountAmount > lineTotalAmount) {
|
if (discountAmount > lineTotalAmount) {
|
||||||
return lineTotalAmount;
|
return lineTotalAmount;
|
||||||
}
|
}
|
||||||
|
|
26
apps/taxes/src/modules/taxes/tax-provider-webhook.ts
Normal file
26
apps/taxes/src/modules/taxes/tax-provider-webhook.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { SyncWebhookResponsesMap } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import {
|
||||||
|
OrderCreatedSubscriptionFragment,
|
||||||
|
OrderFulfilledSubscriptionFragment,
|
||||||
|
TaxBaseFragment,
|
||||||
|
} from "../../../generated/graphql";
|
||||||
|
import { ChannelConfig } from "../channels-configuration/channels-config";
|
||||||
|
|
||||||
|
export type CalculateTaxesResponse = SyncWebhookResponsesMap["ORDER_CALCULATE_TAXES"];
|
||||||
|
|
||||||
|
export type CreateOrderResponse = { id: string };
|
||||||
|
|
||||||
|
export interface ProviderWebhookService {
|
||||||
|
calculateTaxes: (
|
||||||
|
payload: TaxBaseFragment,
|
||||||
|
channel: ChannelConfig
|
||||||
|
) => Promise<CalculateTaxesResponse>;
|
||||||
|
createOrder: (
|
||||||
|
payload: OrderCreatedSubscriptionFragment,
|
||||||
|
channel: ChannelConfig
|
||||||
|
) => Promise<CreateOrderResponse>;
|
||||||
|
fulfillOrder: (
|
||||||
|
payload: OrderFulfilledSubscriptionFragment,
|
||||||
|
channel: ChannelConfig
|
||||||
|
) => Promise<{ ok: boolean }>;
|
||||||
|
}
|
|
@ -1,9 +0,0 @@
|
||||||
import { TaxBaseFragment } from "../../../generated/graphql";
|
|
||||||
import { ChannelConfig } from "../channels-configuration/channels-config";
|
|
||||||
import { TaxProviderName } from "./providers/config";
|
|
||||||
import { ResponseTaxPayload } from "./types";
|
|
||||||
|
|
||||||
export interface TaxProvider {
|
|
||||||
name: TaxProviderName;
|
|
||||||
calculate: (payload: TaxBaseFragment, channel: ChannelConfig) => Promise<ResponseTaxPayload>;
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
import { SyncWebhookResponsesMap } from "@saleor/app-sdk/handlers/next";
|
|
||||||
|
|
||||||
export type ResponseTaxPayload = SyncWebhookResponsesMap["ORDER_CALCULATE_TAXES"];
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
describe.skip("taxJarCalculateTaxesMaps", () => {
|
||||||
|
describe.todo("mapResponse", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
describe.todo("mapPayload", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,13 +1,13 @@
|
||||||
import { TaxParams } from "taxjar/dist/types/paramTypes";
|
|
||||||
import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
|
import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
|
||||||
import {
|
import {
|
||||||
TaxBaseFragment,
|
TaxBaseFragment,
|
||||||
TaxBaseLineFragment,
|
TaxBaseLineFragment,
|
||||||
TaxDiscountFragment,
|
TaxDiscountFragment,
|
||||||
} from "../../../generated/graphql";
|
} from "../../../../generated/graphql";
|
||||||
import { ChannelConfig } from "../channels-configuration/channels-config";
|
import { ChannelConfig } from "../../channels-configuration/channels-config";
|
||||||
import { taxLineResolver } from "../taxes/tax-line-resolver";
|
import { taxLineResolver } from "../../taxes/tax-line-resolver";
|
||||||
import { ResponseTaxPayload } from "../taxes/types";
|
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
||||||
|
import { FetchTaxForOrderArgs } from "../taxjar-client";
|
||||||
|
|
||||||
const formatCalculatedAmount = (amount: number) => {
|
const formatCalculatedAmount = (amount: number) => {
|
||||||
return Number(amount.toFixed(2));
|
return Number(amount.toFixed(2));
|
||||||
|
@ -54,16 +54,19 @@ const prepareLinesWithDiscountPayload = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const prepareResponse = (
|
const mapResponse = (
|
||||||
payload: TaxBaseFragment,
|
payload: TaxBaseFragment,
|
||||||
response: TaxForOrderRes,
|
response: TaxForOrderRes
|
||||||
linesWithChargeTaxes: FetchTaxesLinePayload[],
|
): CalculateTaxesResponse => {
|
||||||
linesWithDiscount: FetchTaxesLinePayload[]
|
const linesWithDiscount = prepareLinesWithDiscountPayload(payload.lines, payload.discounts);
|
||||||
): ResponseTaxPayload => {
|
const linesWithChargeTaxes = linesWithDiscount.filter((line) => line.chargeTaxes === true);
|
||||||
|
|
||||||
const taxResponse = linesWithChargeTaxes.length !== 0 ? response : undefined;
|
const taxResponse = linesWithChargeTaxes.length !== 0 ? response : undefined;
|
||||||
const taxDetails = taxResponse?.tax.breakdown;
|
const taxDetails = taxResponse?.tax.breakdown;
|
||||||
// todo: investigate
|
/**
|
||||||
// ! There is no shipping in tax.breakdown from TaxJar.
|
* todo: investigate
|
||||||
|
* ! There is no shipping in tax.breakdown from TaxJar.
|
||||||
|
*/
|
||||||
const shippingDetails = taxDetails?.shipping;
|
const shippingDetails = taxDetails?.shipping;
|
||||||
|
|
||||||
const shippingPriceGross = shippingDetails
|
const shippingPriceGross = shippingDetails
|
||||||
|
@ -74,12 +77,15 @@ const prepareResponse = (
|
||||||
: payload.shippingPrice.amount;
|
: payload.shippingPrice.amount;
|
||||||
const shippingTaxRate = shippingDetails ? shippingDetails.combined_tax_rate : 0;
|
const shippingTaxRate = shippingDetails ? shippingDetails.combined_tax_rate : 0;
|
||||||
// ! It appears shippingTaxRate is always 0 from TaxJar.
|
// ! It appears shippingTaxRate is always 0 from TaxJar.
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shipping_price_gross_amount: formatCalculatedAmount(shippingPriceGross),
|
shipping_price_gross_amount: formatCalculatedAmount(shippingPriceGross),
|
||||||
shipping_price_net_amount: formatCalculatedAmount(shippingPriceNet),
|
shipping_price_net_amount: formatCalculatedAmount(shippingPriceNet),
|
||||||
shipping_tax_rate: shippingTaxRate,
|
shipping_tax_rate: shippingTaxRate,
|
||||||
// lines order needs to be the same as for recieved payload.
|
/**
|
||||||
// lines that have chargeTaxes === false will have returned default value
|
* lines order needs to be the same as for received payload.
|
||||||
|
* lines that have chargeTaxes === false will have returned default value
|
||||||
|
*/
|
||||||
lines: linesWithDiscount.map((line) => {
|
lines: linesWithDiscount.map((line) => {
|
||||||
const lineTax = taxDetails?.line_items?.find((l) => l.id === line.id);
|
const lineTax = taxDetails?.line_items?.find((l) => l.id === line.id);
|
||||||
const totalGrossAmount = lineTax
|
const totalGrossAmount = lineTax
|
||||||
|
@ -87,6 +93,7 @@ const prepareResponse = (
|
||||||
: line.totalAmount - line.discount;
|
: line.totalAmount - line.discount;
|
||||||
const totalNetAmount = lineTax ? lineTax.taxable_amount : line.totalAmount - line.discount;
|
const totalNetAmount = lineTax ? lineTax.taxable_amount : line.totalAmount - line.discount;
|
||||||
const taxRate = lineTax ? lineTax.combined_tax_rate : 0;
|
const taxRate = lineTax ? lineTax.combined_tax_rate : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total_gross_amount: formatCalculatedAmount(totalGrossAmount),
|
total_gross_amount: formatCalculatedAmount(totalGrossAmount),
|
||||||
total_net_amount: formatCalculatedAmount(totalNetAmount),
|
total_net_amount: formatCalculatedAmount(totalNetAmount),
|
||||||
|
@ -96,37 +103,37 @@ const prepareResponse = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const preparePayload = (
|
const mapPayload = (taxBase: TaxBaseFragment, channel: ChannelConfig): FetchTaxForOrderArgs => {
|
||||||
taxBase: TaxBaseFragment,
|
const linesWithDiscount = prepareLinesWithDiscountPayload(taxBase.lines, taxBase.discounts);
|
||||||
channel: ChannelConfig,
|
const linesWithChargeTaxes = linesWithDiscount.filter((line) => line.chargeTaxes === true);
|
||||||
linesWithChargeTaxes: FetchTaxesLinePayload[]
|
|
||||||
): TaxParams => {
|
|
||||||
const taxParams = {
|
const taxParams = {
|
||||||
from_country: channel.address.country,
|
params: {
|
||||||
from_zip: channel.address.zip,
|
from_country: channel.address.country,
|
||||||
from_state: channel.address.state,
|
from_zip: channel.address.zip,
|
||||||
from_city: channel.address.city,
|
from_state: channel.address.state,
|
||||||
from_street: channel.address.street,
|
from_city: channel.address.city,
|
||||||
to_country: taxBase.address!.country.code,
|
from_street: channel.address.street,
|
||||||
to_zip: taxBase.address!.postalCode,
|
to_country: taxBase.address!.country.code,
|
||||||
to_state: taxBase.address!.countryArea,
|
to_zip: taxBase.address!.postalCode,
|
||||||
to_city: taxBase.address!.city,
|
to_state: taxBase.address!.countryArea,
|
||||||
to_street: `${taxBase.address!.streetAddress1} ${taxBase.address!.streetAddress2}`,
|
to_city: taxBase.address!.city,
|
||||||
shipping: taxBase.shippingPrice.amount,
|
to_street: `${taxBase.address!.streetAddress1} ${taxBase.address!.streetAddress2}`,
|
||||||
line_items: linesWithChargeTaxes.map((line) => ({
|
shipping: taxBase.shippingPrice.amount,
|
||||||
id: line.id,
|
line_items: linesWithChargeTaxes.map((line) => ({
|
||||||
quantity: line.quantity,
|
id: line.id,
|
||||||
product_tax_code: line.taxCode || undefined,
|
quantity: line.quantity,
|
||||||
unit_price: line.unitAmount,
|
product_tax_code: line.taxCode || undefined,
|
||||||
discount: line.discount,
|
unit_price: line.unitAmount,
|
||||||
})),
|
discount: line.discount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return taxParams;
|
return taxParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const taxJarCalculate = {
|
export const taxJarCalculateTaxesMaps = {
|
||||||
prepareLinesWithDiscountPayload,
|
mapPayload,
|
||||||
prepareResponse,
|
mapResponse,
|
||||||
preparePayload,
|
|
||||||
};
|
};
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { OrderStatus } from "../../../../generated/graphql";
|
||||||
|
import {
|
||||||
|
TaxJarOrderCreatedMapPayloadArgs,
|
||||||
|
taxJarOrderCreatedMaps,
|
||||||
|
} from "./taxjar-order-created-map";
|
||||||
|
|
||||||
|
const MOCKED_ORDER: TaxJarOrderCreatedMapPayloadArgs = {
|
||||||
|
order: {
|
||||||
|
id: "T3JkZXI6OTU4MDA5YjQtNDUxZC00NmQ1LThhMWUtMTRkMWRmYjFhNzI5",
|
||||||
|
created: "2023-04-11T11:03:09.304109+00:00",
|
||||||
|
status: OrderStatus.Unfulfilled,
|
||||||
|
user: {
|
||||||
|
id: "VXNlcjo5ZjY3ZjY0Zi1iZjY5LTQ5ZjYtYjQ4Zi1iZjY3ZjY0ZjY0ZjY=",
|
||||||
|
email: "tester@saleor.io",
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
id: "Q2hhbm5lbDoy",
|
||||||
|
slug: "channel-pln",
|
||||||
|
},
|
||||||
|
shippingAddress: {
|
||||||
|
streetAddress1: "123 Palm Grove Ln",
|
||||||
|
streetAddress2: "",
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
countryArea: "CA",
|
||||||
|
postalCode: "90002",
|
||||||
|
country: {
|
||||||
|
code: "US",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
billingAddress: {
|
||||||
|
streetAddress1: "123 Palm Grove Ln",
|
||||||
|
streetAddress2: "",
|
||||||
|
city: "LOS ANGELES",
|
||||||
|
countryArea: "CA",
|
||||||
|
postalCode: "90002",
|
||||||
|
country: {
|
||||||
|
code: "US",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
net: {
|
||||||
|
amount: 183.33,
|
||||||
|
},
|
||||||
|
tax: {
|
||||||
|
amount: 12.83,
|
||||||
|
},
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
shippingPrice: {
|
||||||
|
net: {
|
||||||
|
amount: 48.33,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
productSku: "328223581",
|
||||||
|
productName: "Monospace Tee",
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: {
|
||||||
|
net: {
|
||||||
|
amount: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
totalPrice: {
|
||||||
|
tax: {
|
||||||
|
amount: 8.55,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productSku: "328223580",
|
||||||
|
productName: "Monospace Tee",
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: {
|
||||||
|
net: {
|
||||||
|
amount: 45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
totalPrice: {
|
||||||
|
tax: {
|
||||||
|
amount: 4.28,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
providerInstanceId: "b8c29f49-7cae-4762-8458-e9a27eb83081",
|
||||||
|
enabled: false,
|
||||||
|
address: {
|
||||||
|
country: "US",
|
||||||
|
zip: "92093",
|
||||||
|
state: "CA",
|
||||||
|
city: "La Jolla",
|
||||||
|
street: "9500 Gilman Drive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("taxJarOrderCreatedMaps", () => {
|
||||||
|
describe("mapPayload", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
it("returns the correct order amount", () => {
|
||||||
|
const result = taxJarOrderCreatedMaps.mapPayload(MOCKED_ORDER);
|
||||||
|
|
||||||
|
expect(result.params.amount).toBe(183.33);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.todo("mapResponse", () => {
|
||||||
|
it.todo("calculation of fields");
|
||||||
|
it.todo("formatting the fields");
|
||||||
|
it.todo("rounding of numbers");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sumLines", () => {
|
||||||
|
it("returns the sum of all line items when items quantity = 1", () => {
|
||||||
|
const result = taxJarOrderCreatedMaps.sumLines([
|
||||||
|
{
|
||||||
|
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 = taxJarOrderCreatedMaps.sumLines([
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the rounded sum of all line items when line items n of decimals > 2", () => {
|
||||||
|
const result = taxJarOrderCreatedMaps.sumLines([
|
||||||
|
{
|
||||||
|
quantity: 3,
|
||||||
|
unit_price: 10.256,
|
||||||
|
product_identifier: "328223581",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 50.512,
|
||||||
|
product_identifier: "328223580",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe(131.79);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { LineItem } from "taxjar/dist/types/paramTypes";
|
||||||
|
import { CreateOrderRes } from "taxjar/dist/types/returnTypes";
|
||||||
|
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
|
||||||
|
import { ChannelConfig } from "../../channels-configuration/channels-config";
|
||||||
|
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
|
||||||
|
import { CreateOrderArgs } from "../taxjar-client";
|
||||||
|
import { numbers } from "../../taxes/numbers";
|
||||||
|
|
||||||
|
function mapLines(lines: OrderCreatedSubscriptionFragment["lines"]): LineItem[] {
|
||||||
|
return lines.map((line) => ({
|
||||||
|
quantity: line.quantity,
|
||||||
|
unit_price: line.unitPrice.net.amount,
|
||||||
|
product_identifier: line.productSku ?? "",
|
||||||
|
// todo: add from tax code matcher
|
||||||
|
product_tax_code: "",
|
||||||
|
sales_tax: line.totalPrice.tax.amount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumLines(lines: LineItem[]): number {
|
||||||
|
return numbers.roundFloatToTwoDecimals(
|
||||||
|
lines.reduce((prev, next) => prev + (next.unit_price ?? 0) * (next.quantity ?? 0), 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaxJarOrderCreatedMapPayloadArgs = {
|
||||||
|
order: OrderCreatedSubscriptionFragment;
|
||||||
|
channel: ChannelConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapPayload = ({ order, channel }: TaxJarOrderCreatedMapPayloadArgs): CreateOrderArgs => {
|
||||||
|
const lineItems = mapLines(order.lines);
|
||||||
|
const lineSum = sumLines(lineItems);
|
||||||
|
const shippingAmount = order.shippingPrice.net.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: channel.address.country,
|
||||||
|
from_zip: channel.address.zip,
|
||||||
|
from_state: channel.address.state,
|
||||||
|
from_city: channel.address.city,
|
||||||
|
from_street: channel.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, // Total amount of the order with shipping, excluding sales tax in dollars.
|
||||||
|
// todo: add sales_tax
|
||||||
|
sales_tax: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapResponse = (response: CreateOrderRes): CreateOrderResponse => {
|
||||||
|
return {
|
||||||
|
id: response.order.transaction_id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const taxJarOrderCreatedMaps = {
|
||||||
|
mapPayload,
|
||||||
|
mapResponse,
|
||||||
|
sumLines,
|
||||||
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import TaxJar from "taxjar";
|
import TaxJar from "taxjar";
|
||||||
import { Config, TaxForOrderRes, TaxParams } from "taxjar/dist/util/types";
|
import { Config, CreateOrderParams, TaxParams } from "taxjar/dist/util/types";
|
||||||
import { createLogger } from "../../lib/logger";
|
import { createLogger } from "../../lib/logger";
|
||||||
import { TaxJarConfig } from "./taxjar-config";
|
import { TaxJarConfig } from "./taxjar-config";
|
||||||
|
|
||||||
|
@ -13,6 +13,14 @@ const createTaxJarSettings = (config: TaxJarConfig): Config => {
|
||||||
return settings;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FetchTaxForOrderArgs = {
|
||||||
|
params: TaxParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateOrderArgs = {
|
||||||
|
params: CreateOrderParams;
|
||||||
|
};
|
||||||
|
|
||||||
export class TaxJarClient {
|
export class TaxJarClient {
|
||||||
private client: TaxJar;
|
private client: TaxJar;
|
||||||
private logger: pino.Logger;
|
private logger: pino.Logger;
|
||||||
|
@ -22,13 +30,15 @@ export class TaxJarClient {
|
||||||
this.logger.trace("TaxJarClient constructor");
|
this.logger.trace("TaxJarClient constructor");
|
||||||
const settings = createTaxJarSettings(providerConfig);
|
const settings = createTaxJarSettings(providerConfig);
|
||||||
const taxJarClient = new TaxJar(settings);
|
const taxJarClient = new TaxJar(settings);
|
||||||
|
|
||||||
this.logger.trace({ client: taxJarClient }, "External TaxJar client created");
|
this.logger.trace({ client: taxJarClient }, "External TaxJar client created");
|
||||||
this.client = taxJarClient;
|
this.client = taxJarClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchTaxesForOrder(params: TaxParams) {
|
async fetchTaxForOrder({ params }: FetchTaxForOrderArgs) {
|
||||||
this.logger.debug({ params }, "fetchTaxesForOrder called with:");
|
this.logger.debug({ params }, "fetchTaxForOrder called with:");
|
||||||
const response: TaxForOrderRes = await this.client.taxForOrder(params);
|
const response = await this.client.taxForOrder(params);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,4 +54,10 @@ export class TaxJarClient {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createOrder({ params }: CreateOrderArgs) {
|
||||||
|
this.logger.debug("createOrder called with:");
|
||||||
|
|
||||||
|
return this.client.createOrder(params);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ const patchInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
value: taxJarConfigSchema.partial().transform((c) => {
|
value: taxJarConfigSchema.partial().transform((c) => {
|
||||||
const { apiKey, ...config } = c ?? {};
|
const { apiKey, ...config } = c ?? {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
...(apiKey && !isObfuscated(apiKey) && { apiKey }),
|
...(apiKey && !isObfuscated(apiKey) && { apiKey }),
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { createLogger } from "../../lib/logger";
|
import { createLogger } from "../../lib/logger";
|
||||||
import { createSettingsManager } from "../app-configuration/metadata-manager";
|
import { createSettingsManager } from "../app/metadata-manager";
|
||||||
import { CrudSettingsConfigurator } from "../crud-settings/crud-settings.service";
|
import { CrudSettingsManager } from "../crud-settings/crud-settings.service";
|
||||||
import { providersSchema } from "../providers-configuration/providers-config";
|
import { providersSchema } from "../providers-configuration/providers-config";
|
||||||
import { TAX_PROVIDER_KEY } from "../providers-configuration/public-providers-configuration-service";
|
import { TAX_PROVIDER_KEY } from "../providers-configuration/public-providers-configuration-service";
|
||||||
import { TaxJarClient } from "./taxjar-client";
|
import { TaxJarClient } from "./taxjar-client";
|
||||||
|
@ -11,11 +11,12 @@ import { TaxJarConfig, TaxJarInstanceConfig, taxJarInstanceConfigSchema } from "
|
||||||
const getSchema = taxJarInstanceConfigSchema;
|
const getSchema = taxJarInstanceConfigSchema;
|
||||||
|
|
||||||
export class TaxJarConfigurationService {
|
export class TaxJarConfigurationService {
|
||||||
private crudSettingsConfigurator: CrudSettingsConfigurator;
|
private crudSettingsManager: CrudSettingsManager;
|
||||||
private logger: pino.Logger;
|
private logger: pino.Logger;
|
||||||
constructor(client: Client, saleorApiUrl: string) {
|
constructor(client: Client, saleorApiUrl: string) {
|
||||||
const settingsManager = createSettingsManager(client);
|
const settingsManager = createSettingsManager(client);
|
||||||
this.crudSettingsConfigurator = new CrudSettingsConfigurator(
|
|
||||||
|
this.crudSettingsManager = new CrudSettingsManager(
|
||||||
settingsManager,
|
settingsManager,
|
||||||
saleorApiUrl,
|
saleorApiUrl,
|
||||||
TAX_PROVIDER_KEY
|
TAX_PROVIDER_KEY
|
||||||
|
@ -28,8 +29,9 @@ export class TaxJarConfigurationService {
|
||||||
|
|
||||||
async getAll(): Promise<TaxJarInstanceConfig[]> {
|
async getAll(): Promise<TaxJarInstanceConfig[]> {
|
||||||
this.logger.debug(".getAll called");
|
this.logger.debug(".getAll called");
|
||||||
const { data } = await this.crudSettingsConfigurator.readAll();
|
const { data } = await this.crudSettingsManager.readAll();
|
||||||
this.logger.debug(`Fetched settings from crudSettingsConfigurator`);
|
|
||||||
|
this.logger.debug(`Fetched settings from CrudSettingsManager`);
|
||||||
const validation = providersSchema.safeParse(data);
|
const validation = providersSchema.safeParse(data);
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
|
@ -46,8 +48,9 @@ export class TaxJarConfigurationService {
|
||||||
|
|
||||||
async get(id: string): Promise<TaxJarInstanceConfig> {
|
async get(id: string): Promise<TaxJarInstanceConfig> {
|
||||||
this.logger.debug(`.get called with id: ${id}`);
|
this.logger.debug(`.get called with id: ${id}`);
|
||||||
const { data } = await this.crudSettingsConfigurator.read(id);
|
const { data } = await this.crudSettingsManager.read(id);
|
||||||
this.logger.debug(`Fetched setting from crudSettingsConfigurator`);
|
|
||||||
|
this.logger.debug(`Fetched setting from CrudSettingsManager`);
|
||||||
|
|
||||||
const validation = getSchema.safeParse(data);
|
const validation = getSchema.safeParse(data);
|
||||||
|
|
||||||
|
@ -68,7 +71,7 @@ export class TaxJarConfigurationService {
|
||||||
this.logger.error({ error: validation.error }, "Validation error while post");
|
this.logger.error({ error: validation.error }, "Validation error while post");
|
||||||
throw new Error(validation.error);
|
throw new Error(validation.error);
|
||||||
}
|
}
|
||||||
const result = await this.crudSettingsConfigurator.create({
|
const result = await this.crudSettingsManager.create({
|
||||||
provider: "taxjar",
|
provider: "taxjar",
|
||||||
config: config,
|
config: config,
|
||||||
});
|
});
|
||||||
|
@ -82,7 +85,7 @@ export class TaxJarConfigurationService {
|
||||||
// omit the key "id" from the result
|
// omit the key "id" from the result
|
||||||
const { id: _, ...setting } = data;
|
const { id: _, ...setting } = data;
|
||||||
|
|
||||||
return this.crudSettingsConfigurator.update(id, {
|
return this.crudSettingsManager.update(id, {
|
||||||
...setting,
|
...setting,
|
||||||
config: { ...setting.config, ...config },
|
config: { ...setting.config, ...config },
|
||||||
});
|
});
|
||||||
|
@ -94,7 +97,7 @@ export class TaxJarConfigurationService {
|
||||||
const { id: _, ...setting } = data;
|
const { id: _, ...setting } = data;
|
||||||
|
|
||||||
this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`);
|
this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`);
|
||||||
return this.crudSettingsConfigurator.update(id, {
|
return this.crudSettingsManager.update(id, {
|
||||||
...setting,
|
...setting,
|
||||||
config: { ...config },
|
config: { ...config },
|
||||||
});
|
});
|
||||||
|
@ -102,6 +105,6 @@ export class TaxJarConfigurationService {
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
this.logger.debug(`.delete called with id: ${id}`);
|
this.logger.debug(`.delete called with id: ${id}`);
|
||||||
return this.crudSettingsConfigurator.delete(id);
|
return this.crudSettingsManager.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
import pino from "pino";
|
|
||||||
import { TaxBaseFragment } from "../../../generated/graphql";
|
|
||||||
import { createLogger } from "../../lib/logger";
|
|
||||||
import { ChannelConfig } from "../channels-configuration/channels-config";
|
|
||||||
import { TaxProvider } from "../taxes/tax-provider";
|
|
||||||
import { taxJarCalculate } from "./taxjar-calculate";
|
|
||||||
import { TaxJarClient } from "./taxjar-client";
|
|
||||||
import { TaxJarConfig } from "./taxjar-config";
|
|
||||||
|
|
||||||
export class TaxJarProvider implements TaxProvider {
|
|
||||||
client: TaxJarClient;
|
|
||||||
readonly name = "taxjar";
|
|
||||||
private logger: pino.Logger;
|
|
||||||
|
|
||||||
constructor(config: TaxJarConfig) {
|
|
||||||
const avataxClient = new TaxJarClient(config);
|
|
||||||
|
|
||||||
this.client = avataxClient;
|
|
||||||
this.logger = createLogger({
|
|
||||||
service: "TaxJarProvider",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
|
|
||||||
this.logger.debug({ payload, channel }, "TaxJar calculate called with:");
|
|
||||||
const linesWithDiscount = taxJarCalculate.prepareLinesWithDiscountPayload(
|
|
||||||
payload.lines,
|
|
||||||
payload.discounts
|
|
||||||
);
|
|
||||||
const linesWithChargeTaxes = linesWithDiscount.filter((line) => line.chargeTaxes === true);
|
|
||||||
const taxParams = taxJarCalculate.preparePayload(payload, channel, linesWithDiscount);
|
|
||||||
const fetchedTaxes = await this.client.fetchTaxesForOrder(taxParams);
|
|
||||||
this.logger.debug({ fetchedTaxes }, "TaxJar createOrderTransaction response");
|
|
||||||
|
|
||||||
return taxJarCalculate.prepareResponse(
|
|
||||||
payload,
|
|
||||||
fetchedTaxes,
|
|
||||||
linesWithChargeTaxes,
|
|
||||||
linesWithDiscount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
48
apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts
Normal file
48
apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import pino from "pino";
|
||||||
|
import { OrderCreatedSubscriptionFragment, TaxBaseFragment } from "../../../generated/graphql";
|
||||||
|
import { createLogger } from "../../lib/logger";
|
||||||
|
import { ChannelConfig } from "../channels-configuration/channels-config";
|
||||||
|
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
|
||||||
|
import { TaxJarClient } from "./taxjar-client";
|
||||||
|
import { TaxJarConfig } from "./taxjar-config";
|
||||||
|
import { taxJarCalculateTaxesMaps } from "./maps/taxjar-calculate-taxes-map";
|
||||||
|
import { taxJarOrderCreatedMaps } from "./maps/taxjar-order-created-map";
|
||||||
|
|
||||||
|
export class TaxJarWebhookService implements ProviderWebhookService {
|
||||||
|
client: TaxJarClient;
|
||||||
|
private logger: pino.Logger;
|
||||||
|
|
||||||
|
constructor(config: TaxJarConfig) {
|
||||||
|
const avataxClient = new TaxJarClient(config);
|
||||||
|
|
||||||
|
this.client = avataxClient;
|
||||||
|
this.logger = createLogger({
|
||||||
|
service: "TaxJarProvider",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateTaxes(payload: TaxBaseFragment, channel: ChannelConfig) {
|
||||||
|
this.logger.debug({ payload, channel }, "calculateTaxes called with:");
|
||||||
|
const args = taxJarCalculateTaxesMaps.mapPayload(payload, channel);
|
||||||
|
const fetchedTaxes = await this.client.fetchTaxForOrder(args);
|
||||||
|
|
||||||
|
this.logger.debug({ fetchedTaxes }, "fetchTaxForOrder response");
|
||||||
|
|
||||||
|
return taxJarCalculateTaxesMaps.mapResponse(payload, fetchedTaxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(order: OrderCreatedSubscriptionFragment, channel: ChannelConfig) {
|
||||||
|
this.logger.debug({ order, channel }, "createOrder called with:");
|
||||||
|
const args = taxJarOrderCreatedMaps.mapPayload({ order, channel });
|
||||||
|
const result = await this.client.createOrder(args);
|
||||||
|
|
||||||
|
this.logger.debug({ createOrder: result }, "createOrder response");
|
||||||
|
|
||||||
|
return taxJarOrderCreatedMaps.mapResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// * TaxJar doesn't require any action on order fulfillment
|
||||||
|
async fulfillOrder() {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ const useStyles = makeStyles((theme) => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const schema = taxJarConfigSchema;
|
const schema = taxJarConfigSchema;
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
const defaultValues: FormValues = {
|
const defaultValues: FormValues = {
|
||||||
|
@ -110,6 +111,7 @@ export const TaxJarConfigurationForm = () => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (instance) {
|
if (instance) {
|
||||||
const { config } = instance;
|
const { config } = instance;
|
||||||
|
|
||||||
reset(config);
|
reset(config);
|
||||||
} else {
|
} else {
|
||||||
reset({ ...defaultValues });
|
reset({ ...defaultValues });
|
||||||
|
|
|
@ -10,6 +10,7 @@ const useStyles = makeStyles({
|
||||||
|
|
||||||
export const AppPaper = ({ children }: { children: React.ReactNode }) => {
|
export const AppPaper = ({ children }: { children: React.ReactNode }) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper elevation={0} className={styles.root}>
|
<Paper elevation={0} className={styles.root}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -19,6 +19,7 @@ const useStyles = makeStyles((theme) => ({
|
||||||
|
|
||||||
export const Instructions = () => {
|
export const Instructions = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppPaper>
|
<AppPaper>
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
|
|
|
@ -65,6 +65,7 @@ function NextApp({ Component, pageProps }: AppProps) {
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const jssStyles = document.querySelector("#jss-server-side");
|
const jssStyles = document.querySelector("#jss-server-side");
|
||||||
|
|
||||||
if (jssStyles) {
|
if (jssStyles) {
|
||||||
jssStyles?.parentElement?.removeChild(jssStyles);
|
jssStyles?.parentElement?.removeChild(jssStyles);
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,20 +16,24 @@
|
||||||
* - https://reactjs.org/docs/error-boundaries.html
|
* - https://reactjs.org/docs/error-boundaries.html
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import NextErrorComponent from 'next/error';
|
import NextErrorComponent from "next/error";
|
||||||
|
|
||||||
const CustomErrorComponent = props => {
|
const CustomErrorComponent = (props) => {
|
||||||
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
|
/**
|
||||||
// compensate for https://github.com/vercel/next.js/issues/8592
|
* If you're using a Nextjs version prior to 12.2.1, uncomment this to
|
||||||
// Sentry.captureUnderscoreErrorException(props);
|
* compensate for https://github.com/vercel/next.js/issues/8592
|
||||||
|
* Sentry.captureUnderscoreErrorException(props);
|
||||||
|
*/
|
||||||
|
|
||||||
return <NextErrorComponent statusCode={props.statusCode} />;
|
return <NextErrorComponent statusCode={props.statusCode} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
CustomErrorComponent.getInitialProps = async contextData => {
|
CustomErrorComponent.getInitialProps = async (contextData) => {
|
||||||
// In case this is running in a serverless function, await this in order to give Sentry
|
/*
|
||||||
// time to send the error before the lambda exits
|
* In case this is running in a serverless function, await this in order to give Sentry
|
||||||
|
* time to send the error before the lambda exits
|
||||||
|
*/
|
||||||
await Sentry.captureUnderscoreErrorException(contextData);
|
await Sentry.captureUnderscoreErrorException(contextData);
|
||||||
|
|
||||||
// This will contain the status code of the response
|
// This will contain the status code of the response
|
||||||
|
|
|
@ -4,6 +4,8 @@ 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 { orderFulfilledAsyncWebhook } from "./webhooks/order-fulfilled";
|
||||||
|
|
||||||
export default createManifestHandler({
|
export default createManifestHandler({
|
||||||
async manifestFactory(context) {
|
async manifestFactory(context) {
|
||||||
|
@ -17,6 +19,8 @@ export default createManifestHandler({
|
||||||
webhooks: [
|
webhooks: [
|
||||||
orderCalculateTaxesSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
orderCalculateTaxesSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||||
checkoutCalculateTaxesSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
checkoutCalculateTaxesSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||||
|
orderCreatedAsyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||||
|
orderFulfilledAsyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||||
],
|
],
|
||||||
extensions: [],
|
extensions: [],
|
||||||
homepageUrl: "https://github.com/saleor/apps",
|
homepageUrl: "https://github.com/saleor/apps",
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
import { UntypedCalculateTaxesDocument } from "../../../../generated/graphql";
|
import {
|
||||||
|
CalculateTaxesEventFragment,
|
||||||
|
UntypedCalculateTaxesDocument,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
import { saleorApp } from "../../../../saleor-app";
|
import { saleorApp } from "../../../../saleor-app";
|
||||||
import { createLogger } from "../../../lib/logger";
|
import { createLogger } from "../../../lib/logger";
|
||||||
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema";
|
import { WebhookResponse } from "../../../modules/app/webhook-response";
|
||||||
import { getAppConfig } from "../../../modules/app-configuration/get-app-config";
|
import { getActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
||||||
|
|
||||||
import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
|
@ -13,7 +14,21 @@ export const config = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<ExpectedWebhookPayload>({
|
type CalculateTaxesPayload = Extract<CalculateTaxesEventFragment, { __typename: "CalculateTaxes" }>;
|
||||||
|
|
||||||
|
function verifyCalculateTaxesPayload(payload: CalculateTaxesPayload) {
|
||||||
|
if (!payload.taxBase.lines) {
|
||||||
|
throw new Error("No lines found in taxBase");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.taxBase.address) {
|
||||||
|
throw new Error("No address found in taxBase");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<CalculateTaxesPayload>({
|
||||||
name: "CheckoutCalculateTaxes",
|
name: "CheckoutCalculateTaxes",
|
||||||
apl: saleorApp.apl,
|
apl: saleorApp.apl,
|
||||||
event: "CHECKOUT_CALCULATE_TAXES",
|
event: "CHECKOUT_CALCULATE_TAXES",
|
||||||
|
@ -24,50 +39,35 @@ export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<ExpectedW
|
||||||
export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
|
export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
|
||||||
const logger = createLogger({ event: ctx.event });
|
const logger = createLogger({ event: ctx.event });
|
||||||
const { payload } = ctx;
|
const { payload } = ctx;
|
||||||
|
const webhookResponse = new WebhookResponse(res);
|
||||||
|
|
||||||
logger.info({ payload }, "Handler called with payload");
|
logger.info({ payload }, "Handler called with payload");
|
||||||
|
|
||||||
const validation = calculateTaxesPayloadSchema.safeParse(payload);
|
try {
|
||||||
|
verifyCalculateTaxesPayload(payload);
|
||||||
if (!validation.success) {
|
logger.info("Payload validated succesfully");
|
||||||
logger.error({ error: validation.error.format() }, "Payload is invalid");
|
} catch (error) {
|
||||||
logger.info("Returning no data");
|
logger.info("Returning no data");
|
||||||
return res.send({});
|
return webhookResponse.failureNoRetry("Payload is invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = validation;
|
|
||||||
logger.info("Payload validated succesfully");
|
|
||||||
|
|
||||||
const { providers, channels } = getAppConfig(data);
|
|
||||||
logger.debug("Successfully parsed providers & channels from payload");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const channelSlug = data.taxBase.channel.slug;
|
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||||
const channelConfig = channels[channelSlug];
|
const channelSlug = payload.taxBase.channel.slug;
|
||||||
|
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata);
|
||||||
|
|
||||||
if (!channelConfig) {
|
if (!activeTaxProvider.ok) {
|
||||||
logger.error(`Channel config not found for channel ${channelSlug}`);
|
|
||||||
logger.info("Returning no data");
|
logger.info("Returning no data");
|
||||||
return res.send({});
|
return webhookResponse.failureNoRetry(activeTaxProvider.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerInstance = providers.find(
|
logger.info({ activeTaxProvider }, "Fetched activeTaxProvider");
|
||||||
(instance) => instance.id === channelConfig.providerInstanceId
|
const taxProvider = activeTaxProvider.data;
|
||||||
);
|
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
|
||||||
|
|
||||||
if (!providerInstance) {
|
|
||||||
logger.error(`Channel (${channelSlug}) providerInstanceId does not match any providers`);
|
|
||||||
logger.info("Returning no data");
|
|
||||||
return res.send({});
|
|
||||||
}
|
|
||||||
|
|
||||||
const taxProvider = new ActiveTaxProvider(providerInstance);
|
|
||||||
const calculatedTaxes = await taxProvider.calculate(data.taxBase, channelConfig);
|
|
||||||
|
|
||||||
logger.info({ calculatedTaxes }, "Taxes calculated");
|
logger.info({ calculatedTaxes }, "Taxes calculated");
|
||||||
return res.send(ctx.buildResponse(calculatedTaxes));
|
return webhookResponse.success(ctx.buildResponse(calculatedTaxes));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, "Error while calculating taxes");
|
return webhookResponse.failureRetry("Error while calculating taxes");
|
||||||
logger.info("Returning no data");
|
|
||||||
return res.send({});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
import { UntypedCalculateTaxesDocument } from "../../../../generated/graphql";
|
import {
|
||||||
|
CalculateTaxesEventFragment,
|
||||||
|
UntypedCalculateTaxesDocument,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
import { saleorApp } from "../../../../saleor-app";
|
import { saleorApp } from "../../../../saleor-app";
|
||||||
import { createLogger } from "../../../lib/logger";
|
import { createLogger } from "../../../lib/logger";
|
||||||
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema";
|
import { getActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
||||||
import { getAppConfig } from "../../../modules/app-configuration/get-app-config";
|
import { WebhookResponse } from "../../../modules/app/webhook-response";
|
||||||
import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
|
@ -12,7 +14,21 @@ export const config = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<ExpectedWebhookPayload>({
|
type CalculateTaxesPayload = Extract<CalculateTaxesEventFragment, { __typename: "CalculateTaxes" }>;
|
||||||
|
|
||||||
|
function verifyCalculateTaxesPayload(payload: CalculateTaxesPayload) {
|
||||||
|
if (!payload.taxBase.lines) {
|
||||||
|
throw new Error("No lines found in taxBase");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.taxBase.address) {
|
||||||
|
throw new Error("No address found in taxBase");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<CalculateTaxesPayload>({
|
||||||
name: "OrderCalculateTaxes",
|
name: "OrderCalculateTaxes",
|
||||||
apl: saleorApp.apl,
|
apl: saleorApp.apl,
|
||||||
event: "ORDER_CALCULATE_TAXES",
|
event: "ORDER_CALCULATE_TAXES",
|
||||||
|
@ -23,50 +39,35 @@ export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<ExpectedWebh
|
||||||
export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
|
export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
|
||||||
const logger = createLogger({ event: ctx.event });
|
const logger = createLogger({ event: ctx.event });
|
||||||
const { payload } = ctx;
|
const { payload } = ctx;
|
||||||
|
const webhookResponse = new WebhookResponse(res);
|
||||||
|
|
||||||
logger.info({ payload }, "Handler called with payload");
|
logger.info({ payload }, "Handler called with payload");
|
||||||
|
|
||||||
const validation = calculateTaxesPayloadSchema.safeParse(payload);
|
try {
|
||||||
|
verifyCalculateTaxesPayload(payload);
|
||||||
if (!validation.success) {
|
logger.info("Payload validated succesfully");
|
||||||
logger.error({ error: validation.error.format() }, "Payload is invalid");
|
} catch (error) {
|
||||||
logger.info("Returning no data");
|
logger.info("Returning no data");
|
||||||
return res.status(200).json({});
|
return webhookResponse.failureNoRetry("Payload is invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = validation;
|
|
||||||
logger.info("Payload validated succesfully");
|
|
||||||
|
|
||||||
const { providers, channels } = getAppConfig(data);
|
|
||||||
logger.debug("Parsed providers & channels from payload");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const channelSlug = data.taxBase.channel.slug;
|
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||||
const channelConfig = channels[channelSlug];
|
const channelSlug = payload.taxBase.channel.slug;
|
||||||
|
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata);
|
||||||
|
|
||||||
if (!channelConfig) {
|
if (!activeTaxProvider.ok) {
|
||||||
logger.error(`Channel config not found for channel ${channelSlug}`);
|
|
||||||
logger.info("Returning no data");
|
logger.info("Returning no data");
|
||||||
return res.send({});
|
return webhookResponse.failureNoRetry(activeTaxProvider.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerInstance = providers.find(
|
logger.info({ activeTaxProvider }, "Fetched activeTaxProvider");
|
||||||
(instance) => instance.id === channelConfig.providerInstanceId
|
const taxProvider = activeTaxProvider.data;
|
||||||
);
|
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
|
||||||
|
|
||||||
if (!providerInstance) {
|
|
||||||
logger.error(`Channel (${channelSlug}) providerInstanceId does not match any providers`);
|
|
||||||
logger.info("Returning no data");
|
|
||||||
return res.send({});
|
|
||||||
}
|
|
||||||
|
|
||||||
const taxProvider = new ActiveTaxProvider(providerInstance);
|
|
||||||
const calculatedTaxes = await taxProvider.calculate(data.taxBase, channelConfig);
|
|
||||||
|
|
||||||
logger.info({ calculatedTaxes }, "Taxes calculated");
|
logger.info({ calculatedTaxes }, "Taxes calculated");
|
||||||
return res.send(ctx.buildResponse(calculatedTaxes));
|
return webhookResponse.success(calculatedTaxes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, "Error while calculating taxes");
|
return webhookResponse.failureRetry("Error while calculating taxes");
|
||||||
logger.info("Returning no data");
|
|
||||||
return res.send({});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
110
apps/taxes/src/pages/api/webhooks/order-created.ts
Normal file
110
apps/taxes/src/pages/api/webhooks/order-created.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import {
|
||||||
|
OrderCreatedEventSubscriptionFragment,
|
||||||
|
OrderStatus,
|
||||||
|
UntypedOrderCreatedSubscriptionDocument,
|
||||||
|
UpdateMetadataDocument,
|
||||||
|
UpdateMetadataMutation,
|
||||||
|
UpdateMetadataMutationVariables,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
|
import { saleorApp } from "../../../../saleor-app";
|
||||||
|
import { createLogger } from "../../../lib/logger";
|
||||||
|
import { getActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
||||||
|
import { createClient } from "../../../lib/graphql";
|
||||||
|
import { Client } from "urql";
|
||||||
|
import { WebhookResponse } from "../../../modules/app/webhook-response";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrderCreatedPayload = Extract<
|
||||||
|
OrderCreatedEventSubscriptionFragment,
|
||||||
|
{ __typename: "OrderCreated" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const orderCreatedAsyncWebhook = new SaleorAsyncWebhook<OrderCreatedPayload>({
|
||||||
|
name: "OrderCreated",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
event: "ORDER_CREATED",
|
||||||
|
query: UntypedOrderCreatedSubscriptionDocument,
|
||||||
|
webhookPath: "/api/webhooks/order-created",
|
||||||
|
});
|
||||||
|
|
||||||
|
// * 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => {
|
||||||
|
const logger = createLogger({ event: ctx.event });
|
||||||
|
const { payload, authData } = ctx;
|
||||||
|
const { saleorApiUrl, token } = authData;
|
||||||
|
const webhookResponse = new WebhookResponse(res);
|
||||||
|
|
||||||
|
logger.info({ payload }, "Handler called with payload");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||||
|
const channelSlug = payload.order?.channel.slug;
|
||||||
|
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata);
|
||||||
|
|
||||||
|
if (!activeTaxProvider.ok) {
|
||||||
|
logger.info("Returning no data");
|
||||||
|
return webhookResponse.failureNoRetry(activeTaxProvider.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ activeTaxProvider }, "Fetched activeTaxProvider");
|
||||||
|
const taxProvider = activeTaxProvider.data;
|
||||||
|
|
||||||
|
// todo: figure out what fields are needed and add validation
|
||||||
|
if (!payload.order) {
|
||||||
|
return webhookResponse.failureNoRetry("Insufficient order data");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.order.status === OrderStatus.Fulfilled) {
|
||||||
|
return webhookResponse.failureNoRetry("Skipping fulfilled order to prevent duplication");
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdOrder = await taxProvider.createOrder(payload.order);
|
||||||
|
|
||||||
|
logger.info({ createdOrder }, "Order created");
|
||||||
|
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token }));
|
||||||
|
|
||||||
|
await updateOrderMetadataWithExternalId(client, payload.order.id, createdOrder.id);
|
||||||
|
logger.info("Updated order metadata with externalId");
|
||||||
|
|
||||||
|
return webhookResponse.success();
|
||||||
|
} catch (error) {
|
||||||
|
return webhookResponse.failureRetry("Error while creating order in tax provider");
|
||||||
|
}
|
||||||
|
});
|
61
apps/taxes/src/pages/api/webhooks/order-fulfilled.ts
Normal file
61
apps/taxes/src/pages/api/webhooks/order-fulfilled.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import {
|
||||||
|
OrderFulfilledEventSubscriptionFragment,
|
||||||
|
UntypedOrderFulfilledSubscriptionDocument,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
|
import { saleorApp } from "../../../../saleor-app";
|
||||||
|
import { createLogger } from "../../../lib/logger";
|
||||||
|
import { getActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
||||||
|
import { WebhookResponse } from "../../../modules/app/webhook-response";
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrderFulfilledPayload = Extract<
|
||||||
|
OrderFulfilledEventSubscriptionFragment,
|
||||||
|
{ __typename: "OrderFulfilled" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const orderFulfilledAsyncWebhook = new SaleorAsyncWebhook<OrderFulfilledPayload>({
|
||||||
|
name: "OrderFulfilled",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
event: "ORDER_FULFILLED",
|
||||||
|
query: UntypedOrderFulfilledSubscriptionDocument,
|
||||||
|
webhookPath: "/api/webhooks/order-fulfilled",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default orderFulfilledAsyncWebhook.createHandler(async (req, res, ctx) => {
|
||||||
|
const logger = createLogger({ event: ctx.event });
|
||||||
|
const { payload } = ctx;
|
||||||
|
const webhookResponse = new WebhookResponse(res);
|
||||||
|
|
||||||
|
logger.info({ payload }, "Handler called with payload");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||||
|
const channelSlug = payload.order?.channel.slug;
|
||||||
|
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata);
|
||||||
|
|
||||||
|
if (!activeTaxProvider.ok) {
|
||||||
|
logger.info("Returning no data");
|
||||||
|
return webhookResponse.failureNoRetry(activeTaxProvider.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ activeTaxProvider }, "Fetched activeTaxProvider");
|
||||||
|
const taxProvider = activeTaxProvider.data;
|
||||||
|
|
||||||
|
// todo: figure out what fields are needed and add validation
|
||||||
|
if (!payload.order) {
|
||||||
|
return webhookResponse.failureNoRetry("Insufficient order data");
|
||||||
|
}
|
||||||
|
const fulfilledOrder = await taxProvider.fulfillOrder(payload.order);
|
||||||
|
|
||||||
|
logger.info({ fulfilledOrder }, "Order fulfilled");
|
||||||
|
|
||||||
|
return webhookResponse.success();
|
||||||
|
} catch (error) {
|
||||||
|
return webhookResponse.failureRetry("Error while fulfilling tax provider order");
|
||||||
|
}
|
||||||
|
});
|
|
@ -4,6 +4,8 @@ module.exports = {
|
||||||
"@next/next/no-html-link-for-pages": "off",
|
"@next/next/no-html-link-for-pages": "off",
|
||||||
"react/jsx-key": "off",
|
"react/jsx-key": "off",
|
||||||
"import/no-default-export": "error",
|
"import/no-default-export": "error",
|
||||||
|
"newline-after-var": "warn",
|
||||||
|
"multiline-comment-style": ["warn", "starred-block"],
|
||||||
},
|
},
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
babelOptions: {
|
babelOptions: {
|
||||||
|
|
1088
pnpm-lock.yaml
1088
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue