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",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"uuid": "^9.0.0",
|
||||
"vite": "^4.1.4",
|
||||
"vite": "^4.2.1",
|
||||
"zod": "^3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -62,7 +62,7 @@
|
|||
"jsdom": "^20.0.3",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "4.9",
|
||||
"vitest": "^0.29.2"
|
||||
"vitest": "^0.30.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||
|
|
|
@ -40,8 +40,8 @@
|
|||
"react-is": "^18.2.0",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.0.4",
|
||||
"vitest": "^0.27.1",
|
||||
"vite": "^4.2.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -37,8 +37,8 @@
|
|||
"react-dom": "18.2.0",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.1.1",
|
||||
"vitest": "^0.28.4",
|
||||
"vite": "^4.2.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -50,8 +50,8 @@
|
|||
"react-query": "^3.39.3",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.0.4",
|
||||
"vitest": "^0.27.1",
|
||||
"vite": "^4.2.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -68,9 +68,9 @@
|
|||
"jsdom": "^20.0.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"typescript": "4.9.5",
|
||||
"vite": "^4.1.1",
|
||||
"vitest": "^0.28.4",
|
||||
"@types/semver": "^7.3.13"
|
||||
"vite": "^4.2.1",
|
||||
"vitest": "^0.30.1",
|
||||
"@types/semver": "^7.3.13"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||
|
|
|
@ -31,8 +31,8 @@
|
|||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
"urql": "^3.0.3",
|
||||
"vite": "^4.0.4",
|
||||
"vitest": "^0.27.1"
|
||||
"vite": "^4.2.1",
|
||||
"vitest": "^0.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "3.2.2",
|
||||
|
|
|
@ -44,8 +44,8 @@
|
|||
"react-is": "^18.2.0",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.0.4",
|
||||
"vitest": "^0.27.1",
|
||||
"vite": "^4.2.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
fragment AddressFragment on Address {
|
||||
fragment Address on Address {
|
||||
streetAddress1
|
||||
streetAddress2
|
||||
city
|
||||
|
@ -7,4 +7,4 @@ fragment AddressFragment on Address {
|
|||
country {
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,14 +51,7 @@ fragment TaxBase on TaxableObject {
|
|||
...TaxDiscount
|
||||
}
|
||||
address {
|
||||
streetAddress1
|
||||
streetAddress2
|
||||
city
|
||||
country {
|
||||
code
|
||||
}
|
||||
countryArea
|
||||
postalCode
|
||||
...Address
|
||||
}
|
||||
shippingPrice {
|
||||
amount
|
||||
|
@ -66,4 +59,16 @@ fragment TaxBase on TaxableObject {
|
|||
lines {
|
||||
...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) {
|
||||
item {
|
||||
privateMetadata {
|
|
@ -14,21 +14,26 @@ fragment OrderLineFragment on OrderLine {
|
|||
}
|
||||
}
|
||||
|
||||
fragment OrderSubscription on Order {
|
||||
fragment OrderCreatedSubscription on Order {
|
||||
id
|
||||
userEmail
|
||||
user {
|
||||
id
|
||||
email
|
||||
}
|
||||
created
|
||||
status
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
shippingAddress {
|
||||
...AddressFragment
|
||||
...Address
|
||||
}
|
||||
billingAddress {
|
||||
...AddressFragment
|
||||
...Address
|
||||
}
|
||||
total {
|
||||
currency
|
||||
net {
|
||||
amount
|
||||
}
|
||||
|
@ -49,7 +54,13 @@ fragment OrderCreatedEventSubscription on Event {
|
|||
__typename
|
||||
... on OrderCreated {
|
||||
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",
|
||||
"@urql/exchange-auth": "^1.0.0",
|
||||
"@urql/exchange-multipart-fetch": "^1.0.1",
|
||||
"avatax": "^23.2.0",
|
||||
"avatax": "^23.3.2",
|
||||
"clsx": "^1.2.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
|
@ -44,8 +44,8 @@
|
|||
"taxjar": "^4.0.1",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.0.1",
|
||||
"vitest": "^0.25.8",
|
||||
"vite": "^4.2.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -63,7 +63,7 @@
|
|||
"@types/node": "^18.8.1",
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"eslint": "8.25.0",
|
||||
"eslint-config-next": "12.3.1",
|
||||
"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 { ExpectedWebhookPayload } from "../../lib/saleor/schema";
|
||||
import { MetadataItem } from "../../../generated/graphql";
|
||||
import { ChannelsConfig, channelsSchema } from "../channels-configuration/channels-config";
|
||||
import { ProvidersConfig, providersSchema } from "../providers-configuration/providers-config";
|
||||
|
||||
export const getAppConfig = (payload: ExpectedWebhookPayload) => {
|
||||
const metadata = payload.recipient?.privateMetadata;
|
||||
export const getAppConfig = (metadata: MetadataItem[]) => {
|
||||
let providersConfig = [] as ProvidersConfig;
|
||||
let channelsConfig = {} as ChannelsConfig;
|
||||
|
||||
|
@ -14,8 +13,10 @@ export const getAppConfig = (payload: ExpectedWebhookPayload) => {
|
|||
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) => {
|
||||
const decrypted = decrypt(item.value, secretKey);
|
||||
const parsed = JSON.parse(decrypted);
|
|
@ -3,12 +3,13 @@ import { Client } from "urql";
|
|||
import {
|
||||
FetchAppDetailsDocument,
|
||||
FetchAppDetailsQuery,
|
||||
UpdateAppMetadataDocument,
|
||||
UpdateMetadataDocument,
|
||||
} from "../../../generated/graphql";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
|
||||
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
||||
const logger = pinoLogger.child({ service: "fetchAllMetadata" });
|
||||
|
||||
logger.debug("Fetching metadata from Saleor");
|
||||
|
||||
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[]) {
|
||||
const logger = pinoLogger.child({ service: "mutateMetadata" });
|
||||
|
||||
logger.debug({ metadata }, "Mutating metadata");
|
||||
// to update the metadata, ID is required
|
||||
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
|
||||
.mutation(UpdateAppMetadataDocument, {
|
||||
.mutation(UpdateMetadataDocument, {
|
||||
id: appId,
|
||||
input: metadata,
|
||||
})
|
||||
|
@ -67,9 +69,11 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
|
|||
}
|
||||
|
||||
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.
|
||||
// If your use case require plain text values, you can use MetadataManager.
|
||||
/**
|
||||
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||
* 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({
|
||||
// Secret key should be randomly created for production and set as environment variable
|
||||
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 { createLogger } from "../../lib/logger";
|
||||
import { AvataxConfig } from "./avatax-config";
|
||||
import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel";
|
||||
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||
|
||||
type AvataxSettings = {
|
||||
appName: string;
|
||||
|
@ -35,6 +37,17 @@ const createAvataxSettings = (config: AvataxConfig): AvataxSettings => {
|
|||
return settings;
|
||||
};
|
||||
|
||||
export type CommitTransactionArgs = {
|
||||
companyCode: string;
|
||||
transactionCode: string;
|
||||
model: CommitTransactionModel;
|
||||
documentType: DocumentType;
|
||||
};
|
||||
|
||||
export type CreateTransactionArgs = {
|
||||
model: CreateTransactionModel;
|
||||
};
|
||||
|
||||
export class AvataxClient {
|
||||
private client: Avatax;
|
||||
private logger: pino.Logger;
|
||||
|
@ -49,16 +62,23 @@ export class AvataxClient {
|
|||
};
|
||||
const settings = createAvataxSettings(config);
|
||||
const avataxClient = new Avatax(settings).withSecurity(credentials);
|
||||
|
||||
this.logger.trace({ client: avataxClient }, "External Avatax client created");
|
||||
this.client = avataxClient;
|
||||
}
|
||||
|
||||
async fetchTaxesForOrder(model: CreateTransactionModel) {
|
||||
this.logger.debug({ model }, "fetchTaxesForOrder called with:");
|
||||
async createTransaction({ model }: CreateTransactionArgs) {
|
||||
this.logger.debug({ model }, "createTransaction called with:");
|
||||
|
||||
return this.client.createTransaction({ model });
|
||||
}
|
||||
|
||||
async commitTransaction(args: CommitTransactionArgs) {
|
||||
this.logger.debug(args, "commitTransaction called with:");
|
||||
|
||||
return this.client.commitTransaction(args);
|
||||
}
|
||||
|
||||
async ping() {
|
||||
this.logger.debug("ping called");
|
||||
try {
|
||||
|
|
|
@ -6,7 +6,7 @@ export const avataxConfigSchema = z.object({
|
|||
username: z.string().min(1, { message: "Username requires at least one character." }),
|
||||
password: z.string().min(1, { message: "Password requires at least one character." }),
|
||||
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(),
|
||||
});
|
||||
|
||||
|
@ -16,7 +16,7 @@ export const defaultAvataxConfig: AvataxConfig = {
|
|||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
companyName: "",
|
||||
companyCode: "",
|
||||
isSandbox: true,
|
||||
isAutocommit: false,
|
||||
};
|
||||
|
|
|
@ -18,6 +18,7 @@ const patchInputSchema = z.object({
|
|||
id: z.string(),
|
||||
value: avataxConfigSchema.partial().transform((c) => {
|
||||
const { username, password, ...config } = c ?? {};
|
||||
|
||||
return {
|
||||
...config,
|
||||
...(username && !isObfuscated(username) && { username }),
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
import pino from "pino";
|
||||
import { Client } from "urql";
|
||||
import { createLogger } from "../../lib/logger";
|
||||
import { createSettingsManager } from "../app-configuration/metadata-manager";
|
||||
import { CrudSettingsConfigurator } from "../crud-settings/crud-settings.service";
|
||||
import { createSettingsManager } from "../app/metadata-manager";
|
||||
import { CrudSettingsManager } from "../crud-settings/crud-settings.service";
|
||||
import { providersSchema } from "../providers-configuration/providers-config";
|
||||
import { TAX_PROVIDER_KEY } from "../providers-configuration/public-providers-configuration-service";
|
||||
import { AvataxClient } from "./avatax-client";
|
||||
import { AvataxConfig, AvataxInstanceConfig, avataxInstanceConfigSchema } from "./avatax-config";
|
||||
|
||||
const getSchema = avataxInstanceConfigSchema;
|
||||
|
||||
export class AvataxConfigurationService {
|
||||
private crudSettingsConfigurator: CrudSettingsConfigurator;
|
||||
private crudSettingsManager: CrudSettingsManager;
|
||||
private logger: pino.Logger;
|
||||
constructor(client: Client, saleorApiUrl: string) {
|
||||
const settingsManager = createSettingsManager(client);
|
||||
this.crudSettingsConfigurator = new CrudSettingsConfigurator(
|
||||
|
||||
this.crudSettingsManager = new CrudSettingsManager(
|
||||
settingsManager,
|
||||
saleorApiUrl,
|
||||
TAX_PROVIDER_KEY
|
||||
|
@ -27,7 +29,7 @@ export class AvataxConfigurationService {
|
|||
|
||||
async getAll(): Promise<AvataxInstanceConfig[]> {
|
||||
this.logger.debug(".getAll called");
|
||||
const { data } = await this.crudSettingsConfigurator.readAll();
|
||||
const { data } = await this.crudSettingsManager.readAll();
|
||||
const validation = providersSchema.safeParse(data);
|
||||
|
||||
if (!validation.success) {
|
||||
|
@ -44,8 +46,9 @@ export class AvataxConfigurationService {
|
|||
|
||||
async get(id: string): Promise<AvataxInstanceConfig> {
|
||||
this.logger.debug(`.get called with id: ${id}`);
|
||||
const { data } = await this.crudSettingsConfigurator.read(id);
|
||||
this.logger.debug(`Fetched setting from crudSettingsConfigurator`);
|
||||
const { data } = await this.crudSettingsManager.read(id);
|
||||
|
||||
this.logger.debug(`Fetched setting from CrudSettingsManager`);
|
||||
|
||||
const validation = getSchema.safeParse(data);
|
||||
|
||||
|
@ -67,7 +70,7 @@ export class AvataxConfigurationService {
|
|||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
const result = await this.crudSettingsConfigurator.create({
|
||||
const result = await this.crudSettingsManager.create({
|
||||
provider: "avatax",
|
||||
config: config,
|
||||
});
|
||||
|
@ -81,7 +84,7 @@ export class AvataxConfigurationService {
|
|||
// omit the key "id" from the result
|
||||
const { id: _, ...setting } = data;
|
||||
|
||||
return this.crudSettingsConfigurator.update(id, {
|
||||
return this.crudSettingsManager.update(id, {
|
||||
...setting,
|
||||
config: { ...setting.config, ...config },
|
||||
});
|
||||
|
@ -93,7 +96,7 @@ export class AvataxConfigurationService {
|
|||
const { id: _, ...setting } = data;
|
||||
|
||||
this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`);
|
||||
return this.crudSettingsConfigurator.update(id, {
|
||||
return this.crudSettingsManager.update(id, {
|
||||
...setting,
|
||||
config: { ...config },
|
||||
});
|
||||
|
@ -101,6 +104,6 @@ export class AvataxConfigurationService {
|
|||
|
||||
async delete(id: string): Promise<void> {
|
||||
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;
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
companyName: "",
|
||||
companyCode: "",
|
||||
isAutocommit: false,
|
||||
isSandbox: false,
|
||||
password: "",
|
||||
|
@ -72,6 +73,7 @@ export const AvataxConfigurationForm = () => {
|
|||
React.useEffect(() => {
|
||||
if (instance) {
|
||||
const { config } = instance;
|
||||
|
||||
reset(config);
|
||||
} else {
|
||||
reset(defaultValues);
|
||||
|
@ -245,15 +247,15 @@ export const AvataxConfigurationForm = () => {
|
|||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="companyName"
|
||||
name="companyCode"
|
||||
control={control}
|
||||
defaultValue={defaultValues.companyName}
|
||||
defaultValue={defaultValues.companyCode}
|
||||
render={({ field }) => (
|
||||
<TextField type="text" {...field} label="Company name" {...textFieldProps} />
|
||||
<TextField type="text" {...field} label="Company code" {...textFieldProps} />
|
||||
)}
|
||||
/>
|
||||
{formState.errors.companyName && (
|
||||
<FormHelperText error>{formState.errors.companyName.message}</FormHelperText>
|
||||
{formState.errors.companyCode && (
|
||||
<FormHelperText error>{formState.errors.companyCode.message}</FormHelperText>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
@ -1,6 +1,15 @@
|
|||
import { z } from "zod";
|
||||
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({
|
||||
providerInstanceId: z.string(),
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 { router } from "../trpc/trpc-server";
|
||||
import { ChannelsConfig } from "./channels-config";
|
||||
|
@ -29,12 +29,14 @@ export const channelsConfigurationRouter = router({
|
|||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
procedure: "channelsConfigurationRouter.upsert",
|
||||
});
|
||||
|
||||
logger.debug(input, "channelsConfigurationRouter.upsert called with input");
|
||||
|
||||
const config = await new GetChannelsConfigurationService({
|
||||
apiClient: ctx.apiClient,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
}).getConfiguration();
|
||||
|
||||
logger.debug(config, "Fetched current channels config to update it");
|
||||
|
||||
const taxChannelsConfigurator = new TaxChannelsConfigurator(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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";
|
||||
|
||||
export class TaxChannelsConfigurator extends PrivateMetadataAppConfigurator<ChannelsConfig> {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Client } from "urql";
|
||||
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";
|
||||
|
||||
export class GetChannelsConfigurationService {
|
||||
|
@ -24,7 +24,6 @@ export class GetChannelsConfigurationService {
|
|||
saleorApiUrl
|
||||
);
|
||||
|
||||
// todo: validate config
|
||||
const appChannelsConfig = (await taxConfigurator.getConfig()) ?? null;
|
||||
|
||||
logger.debug(appChannelsConfig, "Retrieved channels config from Metadata");
|
||||
|
|
|
@ -106,6 +106,7 @@ export const ChannelTaxProviderForm = () => {
|
|||
|
||||
React.useEffect(() => {
|
||||
const defaultValues = getDefaultFormValues(channelConfig, providerInstances);
|
||||
|
||||
reset(defaultValues);
|
||||
}, [channelConfig, providerInstances, reset]);
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Button, makeStyles } from "@saleor/macaw-ui";
|
|||
import { PropsWithChildren } from "react";
|
||||
import { useAppRedirect } from "../../../lib/app/redirect";
|
||||
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 { trpcClient } from "../../trpc/trpc-client";
|
||||
import { AppLink } from "../../ui/app-link";
|
||||
|
@ -27,6 +27,7 @@ const NoDataPlaceholder = ({
|
|||
title: string;
|
||||
}>) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<AppPaper>
|
||||
<div>
|
||||
|
@ -45,6 +46,7 @@ const NoDataPlaceholder = ({
|
|||
|
||||
const NoChannelPlaceholder = () => {
|
||||
const { redirect } = useAppRedirect();
|
||||
|
||||
return (
|
||||
<NoDataPlaceholder title={"Channels not found"}>
|
||||
<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 settingsSchema = z.array(settingSchema);
|
||||
|
||||
export class CrudSettingsConfigurator {
|
||||
export class CrudSettingsManager {
|
||||
private logger: pino.Logger;
|
||||
|
||||
constructor(
|
||||
|
@ -16,7 +16,7 @@ export class CrudSettingsConfigurator {
|
|||
private metadataKey: string
|
||||
) {
|
||||
this.metadataKey = metadataKey;
|
||||
this.logger = createLogger({ service: "CrudSettingsConfigurator", metadataKey });
|
||||
this.logger = createLogger({ service: "CrudSettingsManager", metadataKey });
|
||||
}
|
||||
|
||||
async readAll() {
|
||||
|
@ -47,6 +47,7 @@ export class CrudSettingsConfigurator {
|
|||
const { data: settings } = result;
|
||||
|
||||
const item = settings.find((item) => item.id === id);
|
||||
|
||||
if (!item) {
|
||||
this.logger.error({ id }, "Item not found");
|
||||
throw new Error("Item not found");
|
||||
|
@ -65,6 +66,7 @@ export class CrudSettingsConfigurator {
|
|||
|
||||
const id = createId();
|
||||
const newData = [...prevData, { ...data, id }];
|
||||
|
||||
await this.metadataManager.set({
|
||||
key: this.metadataKey,
|
||||
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");
|
||||
const taxJar = await this.taxJarConfigurationService.getAll();
|
||||
const avatax = await this.avataxConfigurationService.getAll();
|
||||
|
||||
return [...obfuscateTaxJarInstances(taxJar), ...obfuscateAvataxInstances(avatax)];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material
|
|||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import React from "react";
|
||||
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 { useInstanceId } from "../../taxes/tax-context";
|
||||
import { trpcClient } from "../../trpc/trpc-client";
|
||||
|
@ -48,6 +48,7 @@ export const Configuration = () => {
|
|||
|
||||
React.useEffect(() => {
|
||||
const instance = providersConfigurationData?.find((instance) => instance.id === instanceId);
|
||||
|
||||
setProvider(instance?.provider ?? "taxjar");
|
||||
}, [instanceId, providersConfigurationData]);
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ const useStyles = makeStyles((theme) => ({
|
|||
|
||||
export const DeleteProviderDialog = (p: DeleteProviderDialogProps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<Dialog open={p.isOpen} onClose={p.onClose}>
|
||||
<DialogTitle>Delete provider instance?</DialogTitle>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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";
|
||||
|
||||
|
@ -18,6 +18,7 @@ type ProviderIconProps = {
|
|||
export const ProviderIcon = ({ provider, size = "medium", ...props }: ProviderIconProps) => {
|
||||
const { icon, label } = providerConfig[provider];
|
||||
const matchedSize = sizes[size];
|
||||
|
||||
return (
|
||||
<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 { ChannelConfig } from "../channels-configuration/channels-config";
|
||||
import { ProviderConfig } from "../providers-configuration/providers-config";
|
||||
import { AvataxProvider } from "../avatax/avatax-provider";
|
||||
import { TaxJarProvider } from "../taxjar/taxjar-provider";
|
||||
import { TaxProvider } from "./tax-provider";
|
||||
import { AvataxWebhookService } from "../avatax/avatax-webhook.service";
|
||||
import { TaxJarWebhookService } from "../taxjar/taxjar-webhook.service";
|
||||
import { ProviderWebhookService } from "./tax-provider-webhook";
|
||||
import { TaxProviderError } from "./tax-provider-error";
|
||||
import pino from "pino";
|
||||
import { getAppConfig } from "../app/get-app-config";
|
||||
|
||||
export class ActiveTaxProvider {
|
||||
private client: TaxProvider;
|
||||
type ActiveTaxProviderResult = { ok: true; data: ActiveTaxProvider } | { ok: false; error: string };
|
||||
|
||||
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 channel: ChannelConfig;
|
||||
|
||||
constructor(providerInstance: ProviderConfig) {
|
||||
constructor(providerInstance: ProviderConfig, channelConfig: ChannelConfig) {
|
||||
this.logger = createLogger({
|
||||
service: "ActiveTaxProvider",
|
||||
});
|
||||
|
||||
const taxProviderName = providerInstance.provider;
|
||||
|
||||
this.logger.trace({ taxProviderName }, "Constructing tax provider: ");
|
||||
this.channel = channelConfig;
|
||||
|
||||
switch (taxProviderName) {
|
||||
case "taxjar":
|
||||
this.client = new TaxJarProvider(providerInstance.config);
|
||||
this.client = new TaxJarWebhookService(providerInstance.config);
|
||||
break;
|
||||
|
||||
case "avatax":
|
||||
this.client = new AvataxProvider(providerInstance.config);
|
||||
this.client = new AvataxWebhookService(providerInstance.config);
|
||||
break;
|
||||
|
||||
default: {
|
||||
throw new TaxProviderError(`Tax provider ${taxProviderName} doesnt match`, {
|
||||
throw new TaxProviderError(`Tax provider ${taxProviderName} doesn't match`, {
|
||||
cause: "TaxProviderNotFound",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
|
||||
this.logger.debug({ payload, channel }, ".calculate called");
|
||||
async calculateTaxes(payload: TaxBaseFragment) {
|
||||
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 = {
|
||||
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 discountAmount = (lineTotalAmount / allLinesTotal) * totalDiscount;
|
||||
|
||||
if (discountAmount > 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 {
|
||||
TaxBaseFragment,
|
||||
TaxBaseLineFragment,
|
||||
TaxDiscountFragment,
|
||||
} from "../../../generated/graphql";
|
||||
import { ChannelConfig } from "../channels-configuration/channels-config";
|
||||
import { taxLineResolver } from "../taxes/tax-line-resolver";
|
||||
import { ResponseTaxPayload } from "../taxes/types";
|
||||
} from "../../../../generated/graphql";
|
||||
import { ChannelConfig } from "../../channels-configuration/channels-config";
|
||||
import { taxLineResolver } from "../../taxes/tax-line-resolver";
|
||||
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
||||
import { FetchTaxForOrderArgs } from "../taxjar-client";
|
||||
|
||||
const formatCalculatedAmount = (amount: number) => {
|
||||
return Number(amount.toFixed(2));
|
||||
|
@ -54,16 +54,19 @@ const prepareLinesWithDiscountPayload = (
|
|||
});
|
||||
};
|
||||
|
||||
const prepareResponse = (
|
||||
const mapResponse = (
|
||||
payload: TaxBaseFragment,
|
||||
response: TaxForOrderRes,
|
||||
linesWithChargeTaxes: FetchTaxesLinePayload[],
|
||||
linesWithDiscount: FetchTaxesLinePayload[]
|
||||
): ResponseTaxPayload => {
|
||||
response: TaxForOrderRes
|
||||
): CalculateTaxesResponse => {
|
||||
const linesWithDiscount = prepareLinesWithDiscountPayload(payload.lines, payload.discounts);
|
||||
const linesWithChargeTaxes = linesWithDiscount.filter((line) => line.chargeTaxes === true);
|
||||
|
||||
const taxResponse = linesWithChargeTaxes.length !== 0 ? response : undefined;
|
||||
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 shippingPriceGross = shippingDetails
|
||||
|
@ -74,12 +77,15 @@ const prepareResponse = (
|
|||
: payload.shippingPrice.amount;
|
||||
const shippingTaxRate = shippingDetails ? shippingDetails.combined_tax_rate : 0;
|
||||
// ! It appears shippingTaxRate is always 0 from TaxJar.
|
||||
|
||||
return {
|
||||
shipping_price_gross_amount: formatCalculatedAmount(shippingPriceGross),
|
||||
shipping_price_net_amount: formatCalculatedAmount(shippingPriceNet),
|
||||
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) => {
|
||||
const lineTax = taxDetails?.line_items?.find((l) => l.id === line.id);
|
||||
const totalGrossAmount = lineTax
|
||||
|
@ -87,6 +93,7 @@ const prepareResponse = (
|
|||
: line.totalAmount - line.discount;
|
||||
const totalNetAmount = lineTax ? lineTax.taxable_amount : line.totalAmount - line.discount;
|
||||
const taxRate = lineTax ? lineTax.combined_tax_rate : 0;
|
||||
|
||||
return {
|
||||
total_gross_amount: formatCalculatedAmount(totalGrossAmount),
|
||||
total_net_amount: formatCalculatedAmount(totalNetAmount),
|
||||
|
@ -96,37 +103,37 @@ const prepareResponse = (
|
|||
};
|
||||
};
|
||||
|
||||
const preparePayload = (
|
||||
taxBase: TaxBaseFragment,
|
||||
channel: ChannelConfig,
|
||||
linesWithChargeTaxes: FetchTaxesLinePayload[]
|
||||
): TaxParams => {
|
||||
const mapPayload = (taxBase: TaxBaseFragment, channel: ChannelConfig): FetchTaxForOrderArgs => {
|
||||
const linesWithDiscount = prepareLinesWithDiscountPayload(taxBase.lines, taxBase.discounts);
|
||||
const linesWithChargeTaxes = linesWithDiscount.filter((line) => line.chargeTaxes === true);
|
||||
|
||||
const taxParams = {
|
||||
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: taxBase.address!.country.code,
|
||||
to_zip: taxBase.address!.postalCode,
|
||||
to_state: taxBase.address!.countryArea,
|
||||
to_city: taxBase.address!.city,
|
||||
to_street: `${taxBase.address!.streetAddress1} ${taxBase.address!.streetAddress2}`,
|
||||
shipping: taxBase.shippingPrice.amount,
|
||||
line_items: linesWithChargeTaxes.map((line) => ({
|
||||
id: line.id,
|
||||
quantity: line.quantity,
|
||||
product_tax_code: line.taxCode || undefined,
|
||||
unit_price: line.unitAmount,
|
||||
discount: line.discount,
|
||||
})),
|
||||
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: taxBase.address!.country.code,
|
||||
to_zip: taxBase.address!.postalCode,
|
||||
to_state: taxBase.address!.countryArea,
|
||||
to_city: taxBase.address!.city,
|
||||
to_street: `${taxBase.address!.streetAddress1} ${taxBase.address!.streetAddress2}`,
|
||||
shipping: taxBase.shippingPrice.amount,
|
||||
line_items: linesWithChargeTaxes.map((line) => ({
|
||||
id: line.id,
|
||||
quantity: line.quantity,
|
||||
product_tax_code: line.taxCode || undefined,
|
||||
unit_price: line.unitAmount,
|
||||
discount: line.discount,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return taxParams;
|
||||
};
|
||||
|
||||
export const taxJarCalculate = {
|
||||
prepareLinesWithDiscountPayload,
|
||||
prepareResponse,
|
||||
preparePayload,
|
||||
export const taxJarCalculateTaxesMaps = {
|
||||
mapPayload,
|
||||
mapResponse,
|
||||
};
|
|
@ -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 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 { TaxJarConfig } from "./taxjar-config";
|
||||
|
||||
|
@ -13,6 +13,14 @@ const createTaxJarSettings = (config: TaxJarConfig): Config => {
|
|||
return settings;
|
||||
};
|
||||
|
||||
export type FetchTaxForOrderArgs = {
|
||||
params: TaxParams;
|
||||
};
|
||||
|
||||
export type CreateOrderArgs = {
|
||||
params: CreateOrderParams;
|
||||
};
|
||||
|
||||
export class TaxJarClient {
|
||||
private client: TaxJar;
|
||||
private logger: pino.Logger;
|
||||
|
@ -22,13 +30,15 @@ export class TaxJarClient {
|
|||
this.logger.trace("TaxJarClient constructor");
|
||||
const settings = createTaxJarSettings(providerConfig);
|
||||
const taxJarClient = new TaxJar(settings);
|
||||
|
||||
this.logger.trace({ client: taxJarClient }, "External TaxJar client created");
|
||||
this.client = taxJarClient;
|
||||
}
|
||||
|
||||
async fetchTaxesForOrder(params: TaxParams) {
|
||||
this.logger.debug({ params }, "fetchTaxesForOrder called with:");
|
||||
const response: TaxForOrderRes = await this.client.taxForOrder(params);
|
||||
async fetchTaxForOrder({ params }: FetchTaxForOrderArgs) {
|
||||
this.logger.debug({ params }, "fetchTaxForOrder called with:");
|
||||
const response = await this.client.taxForOrder(params);
|
||||
|
||||
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(),
|
||||
value: taxJarConfigSchema.partial().transform((c) => {
|
||||
const { apiKey, ...config } = c ?? {};
|
||||
|
||||
return {
|
||||
...config,
|
||||
...(apiKey && !isObfuscated(apiKey) && { apiKey }),
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import pino from "pino";
|
||||
import { Client } from "urql";
|
||||
import { createLogger } from "../../lib/logger";
|
||||
import { createSettingsManager } from "../app-configuration/metadata-manager";
|
||||
import { CrudSettingsConfigurator } from "../crud-settings/crud-settings.service";
|
||||
import { createSettingsManager } from "../app/metadata-manager";
|
||||
import { CrudSettingsManager } from "../crud-settings/crud-settings.service";
|
||||
import { providersSchema } from "../providers-configuration/providers-config";
|
||||
import { TAX_PROVIDER_KEY } from "../providers-configuration/public-providers-configuration-service";
|
||||
import { TaxJarClient } from "./taxjar-client";
|
||||
|
@ -11,11 +11,12 @@ import { TaxJarConfig, TaxJarInstanceConfig, taxJarInstanceConfigSchema } from "
|
|||
const getSchema = taxJarInstanceConfigSchema;
|
||||
|
||||
export class TaxJarConfigurationService {
|
||||
private crudSettingsConfigurator: CrudSettingsConfigurator;
|
||||
private crudSettingsManager: CrudSettingsManager;
|
||||
private logger: pino.Logger;
|
||||
constructor(client: Client, saleorApiUrl: string) {
|
||||
const settingsManager = createSettingsManager(client);
|
||||
this.crudSettingsConfigurator = new CrudSettingsConfigurator(
|
||||
|
||||
this.crudSettingsManager = new CrudSettingsManager(
|
||||
settingsManager,
|
||||
saleorApiUrl,
|
||||
TAX_PROVIDER_KEY
|
||||
|
@ -28,8 +29,9 @@ export class TaxJarConfigurationService {
|
|||
|
||||
async getAll(): Promise<TaxJarInstanceConfig[]> {
|
||||
this.logger.debug(".getAll called");
|
||||
const { data } = await this.crudSettingsConfigurator.readAll();
|
||||
this.logger.debug(`Fetched settings from crudSettingsConfigurator`);
|
||||
const { data } = await this.crudSettingsManager.readAll();
|
||||
|
||||
this.logger.debug(`Fetched settings from CrudSettingsManager`);
|
||||
const validation = providersSchema.safeParse(data);
|
||||
|
||||
if (!validation.success) {
|
||||
|
@ -46,8 +48,9 @@ export class TaxJarConfigurationService {
|
|||
|
||||
async get(id: string): Promise<TaxJarInstanceConfig> {
|
||||
this.logger.debug(`.get called with id: ${id}`);
|
||||
const { data } = await this.crudSettingsConfigurator.read(id);
|
||||
this.logger.debug(`Fetched setting from crudSettingsConfigurator`);
|
||||
const { data } = await this.crudSettingsManager.read(id);
|
||||
|
||||
this.logger.debug(`Fetched setting from CrudSettingsManager`);
|
||||
|
||||
const validation = getSchema.safeParse(data);
|
||||
|
||||
|
@ -68,7 +71,7 @@ export class TaxJarConfigurationService {
|
|||
this.logger.error({ error: validation.error }, "Validation error while post");
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
const result = await this.crudSettingsConfigurator.create({
|
||||
const result = await this.crudSettingsManager.create({
|
||||
provider: "taxjar",
|
||||
config: config,
|
||||
});
|
||||
|
@ -82,7 +85,7 @@ export class TaxJarConfigurationService {
|
|||
// omit the key "id" from the result
|
||||
const { id: _, ...setting } = data;
|
||||
|
||||
return this.crudSettingsConfigurator.update(id, {
|
||||
return this.crudSettingsManager.update(id, {
|
||||
...setting,
|
||||
config: { ...setting.config, ...config },
|
||||
});
|
||||
|
@ -94,7 +97,7 @@ export class TaxJarConfigurationService {
|
|||
const { id: _, ...setting } = data;
|
||||
|
||||
this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`);
|
||||
return this.crudSettingsConfigurator.update(id, {
|
||||
return this.crudSettingsManager.update(id, {
|
||||
...setting,
|
||||
config: { ...config },
|
||||
});
|
||||
|
@ -102,6 +105,6 @@ export class TaxJarConfigurationService {
|
|||
|
||||
async delete(id: string): Promise<void> {
|
||||
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;
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
|
@ -110,6 +111,7 @@ export const TaxJarConfigurationForm = () => {
|
|||
React.useEffect(() => {
|
||||
if (instance) {
|
||||
const { config } = instance;
|
||||
|
||||
reset(config);
|
||||
} else {
|
||||
reset({ ...defaultValues });
|
||||
|
|
|
@ -10,6 +10,7 @@ const useStyles = makeStyles({
|
|||
|
||||
export const AppPaper = ({ children }: { children: React.ReactNode }) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<Paper elevation={0} className={styles.root}>
|
||||
{children}
|
||||
|
|
|
@ -19,6 +19,7 @@ const useStyles = makeStyles((theme) => ({
|
|||
|
||||
export const Instructions = () => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<AppPaper>
|
||||
<div className={styles.root}>
|
||||
|
|
|
@ -65,6 +65,7 @@ function NextApp({ Component, pageProps }: AppProps) {
|
|||
*/
|
||||
useEffect(() => {
|
||||
const jssStyles = document.querySelector("#jss-server-side");
|
||||
|
||||
if (jssStyles) {
|
||||
jssStyles?.parentElement?.removeChild(jssStyles);
|
||||
}
|
||||
|
|
|
@ -16,20 +16,24 @@
|
|||
* - https://reactjs.org/docs/error-boundaries.html
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import NextErrorComponent from 'next/error';
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextErrorComponent from "next/error";
|
||||
|
||||
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
|
||||
// Sentry.captureUnderscoreErrorException(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
|
||||
* Sentry.captureUnderscoreErrorException(props);
|
||||
*/
|
||||
|
||||
return <NextErrorComponent statusCode={props.statusCode} />;
|
||||
};
|
||||
|
||||
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
|
||||
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
|
||||
*/
|
||||
await Sentry.captureUnderscoreErrorException(contextData);
|
||||
|
||||
// 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 { checkoutCalculateTaxesSyncWebhook } from "./webhooks/checkout-calculate-taxes";
|
||||
import { orderCalculateTaxesSyncWebhook } from "./webhooks/order-calculate-taxes";
|
||||
import { orderCreatedAsyncWebhook } from "./webhooks/order-created";
|
||||
import { orderFulfilledAsyncWebhook } from "./webhooks/order-fulfilled";
|
||||
|
||||
export default createManifestHandler({
|
||||
async manifestFactory(context) {
|
||||
|
@ -17,6 +19,8 @@ export default createManifestHandler({
|
|||
webhooks: [
|
||||
orderCalculateTaxesSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
checkoutCalculateTaxesSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
orderCreatedAsyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
orderFulfilledAsyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
],
|
||||
extensions: [],
|
||||
homepageUrl: "https://github.com/saleor/apps",
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
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 { createLogger } from "../../../lib/logger";
|
||||
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema";
|
||||
import { getAppConfig } from "../../../modules/app-configuration/get-app-config";
|
||||
|
||||
import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
||||
import { WebhookResponse } from "../../../modules/app/webhook-response";
|
||||
import { getActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
||||
|
||||
export const config = {
|
||||
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",
|
||||
apl: saleorApp.apl,
|
||||
event: "CHECKOUT_CALCULATE_TAXES",
|
||||
|
@ -24,50 +39,35 @@ export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<ExpectedW
|
|||
export default checkoutCalculateTaxesSyncWebhook.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");
|
||||
|
||||
const validation = calculateTaxesPayloadSchema.safeParse(payload);
|
||||
|
||||
if (!validation.success) {
|
||||
logger.error({ error: validation.error.format() }, "Payload is invalid");
|
||||
try {
|
||||
verifyCalculateTaxesPayload(payload);
|
||||
logger.info("Payload validated succesfully");
|
||||
} catch (error) {
|
||||
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 {
|
||||
const channelSlug = data.taxBase.channel.slug;
|
||||
const channelConfig = channels[channelSlug];
|
||||
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||
const channelSlug = payload.taxBase.channel.slug;
|
||||
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata);
|
||||
|
||||
if (!channelConfig) {
|
||||
logger.error(`Channel config not found for channel ${channelSlug}`);
|
||||
if (!activeTaxProvider.ok) {
|
||||
logger.info("Returning no data");
|
||||
return res.send({});
|
||||
return webhookResponse.failureNoRetry(activeTaxProvider.error);
|
||||
}
|
||||
|
||||
const providerInstance = providers.find(
|
||||
(instance) => instance.id === channelConfig.providerInstanceId
|
||||
);
|
||||
|
||||
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({ activeTaxProvider }, "Fetched activeTaxProvider");
|
||||
const taxProvider = activeTaxProvider.data;
|
||||
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
|
||||
|
||||
logger.info({ calculatedTaxes }, "Taxes calculated");
|
||||
return res.send(ctx.buildResponse(calculatedTaxes));
|
||||
return webhookResponse.success(ctx.buildResponse(calculatedTaxes));
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error while calculating taxes");
|
||||
logger.info("Returning no data");
|
||||
return res.send({});
|
||||
return webhookResponse.failureRetry("Error while calculating taxes");
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
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 { createLogger } from "../../../lib/logger";
|
||||
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema";
|
||||
import { getAppConfig } from "../../../modules/app-configuration/get-app-config";
|
||||
import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
||||
import { getActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
|
||||
import { WebhookResponse } from "../../../modules/app/webhook-response";
|
||||
|
||||
export const config = {
|
||||
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",
|
||||
apl: saleorApp.apl,
|
||||
event: "ORDER_CALCULATE_TAXES",
|
||||
|
@ -23,50 +39,35 @@ export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<ExpectedWebh
|
|||
export default orderCalculateTaxesSyncWebhook.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");
|
||||
|
||||
const validation = calculateTaxesPayloadSchema.safeParse(payload);
|
||||
|
||||
if (!validation.success) {
|
||||
logger.error({ error: validation.error.format() }, "Payload is invalid");
|
||||
try {
|
||||
verifyCalculateTaxesPayload(payload);
|
||||
logger.info("Payload validated succesfully");
|
||||
} catch (error) {
|
||||
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 {
|
||||
const channelSlug = data.taxBase.channel.slug;
|
||||
const channelConfig = channels[channelSlug];
|
||||
const appMetadata = payload.recipient?.privateMetadata ?? [];
|
||||
const channelSlug = payload.taxBase.channel.slug;
|
||||
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata);
|
||||
|
||||
if (!channelConfig) {
|
||||
logger.error(`Channel config not found for channel ${channelSlug}`);
|
||||
if (!activeTaxProvider.ok) {
|
||||
logger.info("Returning no data");
|
||||
return res.send({});
|
||||
return webhookResponse.failureNoRetry(activeTaxProvider.error);
|
||||
}
|
||||
|
||||
const providerInstance = providers.find(
|
||||
(instance) => instance.id === channelConfig.providerInstanceId
|
||||
);
|
||||
|
||||
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({ activeTaxProvider }, "Fetched activeTaxProvider");
|
||||
const taxProvider = activeTaxProvider.data;
|
||||
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
|
||||
|
||||
logger.info({ calculatedTaxes }, "Taxes calculated");
|
||||
return res.send(ctx.buildResponse(calculatedTaxes));
|
||||
return webhookResponse.success(calculatedTaxes);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error while calculating taxes");
|
||||
logger.info("Returning no data");
|
||||
return res.send({});
|
||||
return webhookResponse.failureRetry("Error while calculating taxes");
|
||||
}
|
||||
});
|
||||
|
|
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",
|
||||
"react/jsx-key": "off",
|
||||
"import/no-default-export": "error",
|
||||
"newline-after-var": "warn",
|
||||
"multiline-comment-style": ["warn", "starred-block"],
|
||||
},
|
||||
parserOptions: {
|
||||
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