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:
Adrian Pilarczyk 2023-04-17 13:58:21 +02:00 committed by GitHub
parent 1fef68b49e
commit 453baf78a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 2246 additions and 1208 deletions

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

View file

@ -0,0 +1,5 @@
---
"eslint-config-saleor": minor
---
Added new warn rules to eslint-config-saleor: `newline-after-var` and `multiline-comment-style`.

View file

@ -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",

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -68,8 +68,8 @@
"jsdom": "^20.0.3",
"rimraf": "^3.0.2",
"typescript": "4.9.5",
"vite": "^4.1.1",
"vitest": "^0.28.4",
"vite": "^4.2.1",
"vitest": "^0.30.1",
"@types/semver": "^7.3.13"
},
"lint-staged": {

View file

@ -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",

View file

@ -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": {

View file

@ -1,4 +1,4 @@
fragment AddressFragment on Address {
fragment Address on Address {
streetAddress1
streetAddress2
city

View file

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

View file

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

View file

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

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

View file

@ -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",

View file

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

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

View file

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

View file

@ -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!,

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -106,6 +106,7 @@ export const ChannelTaxProviderForm = () => {
React.useEffect(() => {
const defaultValues = getDefaultFormValues(channelConfig, providerInstances);
reset(defaultValues);
}, [channelConfig, providerInstances, reset]);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

@ -0,0 +1,7 @@
function roundFloatToTwoDecimals(float: number): number {
return Math.round(float * 100) / 100;
}
export const numbers = {
roundFloatToTwoDecimals,
};

View file

@ -1,4 +1,4 @@
import { AvataxIcon, TaxJarIcon } from "../../../assets";
import { AvataxIcon, TaxJarIcon } from "../../assets";
export const providerConfig = {
taxjar: {

View file

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

View file

@ -10,6 +10,7 @@ const getLineDiscount = (
}
const lineTotalAmount = Number(line.totalPrice.amount);
const discountAmount = (lineTotalAmount / allLinesTotal) * totalDiscount;
if (discountAmount > lineTotalAmount) {
return lineTotalAmount;
}

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

View file

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

View file

@ -1,3 +0,0 @@
import { SyncWebhookResponsesMap } from "@saleor/app-sdk/handlers/next";
export type ResponseTaxPayload = SyncWebhookResponsesMap["ORDER_CALCULATE_TAXES"];

View file

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

View file

@ -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,12 +103,12 @@ 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 = {
params: {
from_country: channel.address.country,
from_zip: channel.address.zip,
from_state: channel.address.state,
@ -120,13 +127,13 @@ const preparePayload = (
unit_price: line.unitAmount,
discount: line.discount,
})),
},
};
return taxParams;
};
export const taxJarCalculate = {
prepareLinesWithDiscountPayload,
prepareResponse,
preparePayload,
export const taxJarCalculateTaxesMaps = {
mapPayload,
mapResponse,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@ const useStyles = makeStyles((theme) => ({
export const Instructions = () => {
const styles = useStyles();
return (
<AppPaper>
<div className={styles.root}>

View file

@ -65,6 +65,7 @@ function NextApp({ Component, pageProps }: AppProps) {
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}

View file

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

View file

@ -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",

View file

@ -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");
logger.info("Returning no data");
return res.send({});
}
const { data } = validation;
try {
verifyCalculateTaxesPayload(payload);
logger.info("Payload validated succesfully");
const { providers, channels } = getAppConfig(data);
logger.debug("Successfully parsed providers & channels from payload");
} catch (error) {
logger.info("Returning no data");
return webhookResponse.failureNoRetry("Payload is invalid");
}
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");
}
});

View file

@ -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");
logger.info("Returning no data");
return res.status(200).json({});
}
const { data } = validation;
try {
verifyCalculateTaxesPayload(payload);
logger.info("Payload validated succesfully");
const { providers, channels } = getAppConfig(data);
logger.debug("Parsed providers & channels from payload");
} catch (error) {
logger.info("Returning no data");
return webhookResponse.failureNoRetry("Payload is invalid");
}
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");
}
});

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

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

View file

@ -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: {

File diff suppressed because it is too large Load diff