feat: align avatax with plugin (#414)

* refactor: 🚚 OrderLineFragmentFragment -> OrderLineFragment

* refactor: 🚚 getLine... to getTaxBaseLine...

* refactor: ♻️ temporarily remove usage of getTaxBaseLineTaxCode

* feat:  add shipping as line to avatax-order-created

* feat:  add description to order-created lines

* feat:  add itemCode to avatax-order-created line

* feat:  add tests for avatax maps

* feat:  add basic discounts logic

* docs: 🔥 remove comment

* build: 👷 add changeset

* fix: 🐛 shipping amount
This commit is contained in:
Adrian Pilarczyk 2023-04-26 13:40:51 +02:00 committed by GitHub
parent aa27f9d6ef
commit 9eacc88b53
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 426 additions and 50 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-taxes": minor
---
Map new fields from Saleor to Avatax (e.g. discounts, itemCode, description).

View file

@ -1,4 +1,4 @@
fragment OrderLineFragment on OrderLine {
fragment OrderLine on OrderLine {
productSku
productName
quantity
@ -42,12 +42,21 @@ fragment OrderCreatedSubscription on Order {
}
}
shippingPrice {
gross {
amount
}
net {
amount
}
}
lines {
...OrderLineFragment
...OrderLine
}
discounts {
id
amount {
amount
}
}
}
fragment OrderCreatedEventSubscription on Event {

View file

@ -1,4 +1,4 @@
fragment OrderLineFragment on OrderLine {
fragment OrderLine on OrderLine {
productSku
productName
quantity
@ -46,7 +46,7 @@ fragment OrderFulfilledSubscription on Order {
value
}
lines {
...OrderLineFragment
...OrderLine
}
}
fragment OrderFulfilledEventSubscription on Event {

View file

@ -45,7 +45,7 @@ export class AvataxWebhookService implements ProviderWebhookService {
async createOrder(order: OrderCreatedSubscriptionFragment, channel: ChannelConfig) {
this.logger.debug({ order, channel }, "createOrder called with:");
const model = avataxOrderCreatedMaps.mapPayload(order, channel, this.config);
const model = avataxOrderCreatedMaps.mapPayload({ order, channel, config: this.config });
this.logger.debug({ model }, "will call createTransaction with");
const result = await this.client.createTransaction(model);
@ -56,7 +56,7 @@ export class AvataxWebhookService implements ProviderWebhookService {
async fulfillOrder(order: OrderFulfilledSubscriptionFragment, channel: ChannelConfig) {
this.logger.debug({ order, channel }, "fulfillOrder called with:");
const args = avataxOrderFulfilledMaps.mapPayload(order, this.config);
const args = avataxOrderFulfilledMaps.mapPayload({ order, config: this.config });
this.logger.debug({ args }, "will call commitTransaction with");
const result = await this.client.commitTransaction(args);

View file

@ -4,12 +4,11 @@ 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 { numbers } from "../../taxes/numbers";
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
@ -22,7 +21,7 @@ function mapLines(taxBase: TaxBaseFragment): LineItemModel[] {
amount: line.unitPrice.amount,
taxIncluded: line.chargeTaxes,
// todo: get from tax code matcher
taxCode: taxLineResolver.getLineTaxCode(line),
taxCode: "",
quantity: line.quantity,
}));

View file

@ -1,14 +1,190 @@
import { describe, it } from "vitest";
import { describe, expect, it } from "vitest";
import { OrderStatus } from "../../../../generated/graphql";
import {
CreateTransactionMapPayloadArgs,
avataxOrderCreatedMaps,
} from "./avatax-order-created-map";
describe.skip("avataxOrderCreatedMaps", () => {
const MOCKED_ORDER: CreateTransactionMapPayloadArgs = {
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: {
gross: {
amount: 48.33,
},
net: {
amount: 43.74,
},
},
lines: [
{
productSku: "328223581",
productName: "Monospace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 90,
},
},
totalPrice: {
tax: {
amount: 8.55,
},
},
},
{
productSku: "328223580",
productName: "Polyspace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 45,
},
},
totalPrice: {
tax: {
amount: 4.28,
},
},
},
],
discounts: [
{
amount: {
amount: 10,
},
id: "RGlzY291bnREaXNjb3VudDox",
},
{
amount: {
amount: 21.45,
},
id: "RGlzY291bnREaXNjb3VudDoy",
},
],
},
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: true,
isSandbox: true,
name: "Avatax-1",
password: "user-password",
username: "user-name",
},
};
describe("avataxOrderCreatedMaps", () => {
describe.todo("mapResponse", () => {
it.todo("calculation of fields");
it.todo("formatting the fields");
it.todo("rounding of numbers");
});
describe.todo("mapPayload", () => {
describe("mapPayload", () => {
it("returns lines with discounted: true when there are discounts", () => {
const payload = avataxOrderCreatedMaps.mapPayload(MOCKED_ORDER);
const linesWithoutShipping = payload.model.lines.slice(0, -1);
const check = linesWithoutShipping.every((line) => line.discounted === true);
expect(check).toBeTruthy();
});
it.todo("calculation of fields");
it.todo("formatting the fields");
it.todo("rounding of numbers");
});
describe("mapLines", () => {
const lines = avataxOrderCreatedMaps.mapLines(MOCKED_ORDER.order);
it("returns the correct number of lines", () => {
expect(lines).toHaveLength(3);
});
it("includes shipping as a line", () => {
expect(lines).toContainEqual({
itemCode: avataxOrderCreatedMaps.consts.shippingItemCode,
quantity: 1,
amount: 48.33,
});
});
it("includes products as lines", () => {
const [first, second] = lines;
expect(first).toContain({
itemCode: "328223581",
description: "Monospace Tee",
quantity: 1,
amount: 90,
});
expect(second).toContain({
itemCode: "328223580",
description: "Polyspace Tee",
quantity: 1,
amount: 45,
});
});
});
describe("mapDiscounts", () => {
it("sums up all discounts", () => {
const discounts = avataxOrderCreatedMaps.mapDiscounts(MOCKED_ORDER.order.discounts);
expect(discounts).toEqual(31.45);
});
it("returns 0 if there are no discounts", () => {
const discounts = avataxOrderCreatedMaps.mapDiscounts([]);
expect(discounts).toEqual(0);
});
});
});

View file

@ -8,26 +8,64 @@ 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) => ({
/**
* * 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(order: OrderCreatedSubscriptionFragment): LineItemModel[] {
const productLines: LineItemModel[] = order.lines.map((line) => ({
amount: line.unitPrice.net.amount,
quantity: line.quantity,
// todo: get from tax code matcher
taxCode: "",
quantity: line.quantity,
description: line.productName,
itemCode: line.productSku ?? "",
discounted: order.discounts.length > 0,
}));
return productLines;
if (order.shippingPrice.net.amount !== 0) {
// * In Avatax, shipping is a regular line
const shippingLine: LineItemModel = {
amount: order.shippingPrice.gross.amount,
taxIncluded: true,
itemCode: SHIPPING_ITEM_CODE,
/**
* 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,
};
const mapPayload = (
order: OrderCreatedSubscriptionFragment,
channel: ChannelConfig,
config: AvataxConfig
): CreateTransactionArgs => {
return [...productLines, shippingLine];
}
return productLines;
}
function mapDiscounts(discounts: OrderCreatedSubscriptionFragment["discounts"]): number {
return discounts.reduce((total, current) => total + Number(current.amount.amount), 0);
}
export type CreateTransactionMapPayloadArgs = {
order: OrderCreatedSubscriptionFragment;
channel: ChannelConfig;
config: AvataxConfig;
};
const mapPayload = ({
order,
channel,
config,
}: CreateTransactionMapPayloadArgs): CreateTransactionArgs => {
return {
model: {
type: DocumentType.SalesInvoice,
customerCode: order.user?.id ?? "",
customerCode:
order.user?.id ??
"" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the 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,
@ -40,6 +78,7 @@ const mapPayload = (
email: order.user?.email ?? "",
lines: mapLines(order),
date: new Date(order.created),
discount: mapDiscounts(order.discounts),
},
};
};
@ -53,4 +92,9 @@ const mapResponse = (response: TransactionModel): CreateOrderResponse => {
export const avataxOrderCreatedMaps = {
mapPayload,
mapResponse,
mapLines,
mapDiscounts,
consts: {
shippingItemCode: SHIPPING_ITEM_CODE,
},
};

View file

@ -1,14 +1,127 @@
import { describe, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
CommitTransactionMapPayloadArgs,
avataxOrderFulfilledMaps,
} from "./avatax-order-fulfilled-map";
import { OrderFulfilledSubscriptionFragment, OrderStatus } from "../../../../generated/graphql";
import { DocumentType } from "avatax/lib/enums/DocumentType";
describe.skip("avataxOrderFulfilledMaps", () => {
describe.todo("mapResponse", () => {
it.todo("calculation of fields");
it.todo("formatting the fields");
it.todo("rounding of numbers");
const MOCKED_METADATA: OrderFulfilledSubscriptionFragment["privateMetadata"] = [
{
key: avataxOrderFulfilledMaps.providerOrderIdKey,
value: "transaction-code",
},
];
const MOCKED_MAP_PAYLOAD_ARGS: CommitTransactionMapPayloadArgs = {
order: {
id: "T3JkZXI6OTU4MDA5YjQtNDUxZC00NmQ1LThhMWUtMTRkMWRmYjFhNzI5",
created: "2023-04-11T11:03:09.304109+00:00",
privateMetadata: MOCKED_METADATA,
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,
},
},
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: "Polyspace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 45,
},
},
totalPrice: {
tax: {
amount: 4.28,
},
},
},
],
},
config: {
companyCode: "DEFAULT",
isAutocommit: true,
isSandbox: true,
name: "Avatax-1",
password: "user-password",
username: "user-name",
},
};
describe("avataxOrderFulfilledMaps", () => {
describe("getTransactionCodeFromMetadata", () => {
it("should return transaction code", () => {
expect(avataxOrderFulfilledMaps.getTransactionCodeFromMetadata(MOCKED_METADATA)).toBe(
"transaction-code"
);
});
it("should throw error when transaction code not found", () => {
expect(() => avataxOrderFulfilledMaps.getTransactionCodeFromMetadata([])).toThrowError();
});
});
describe("mapPayload", () => {
it("should return mapped payload", () => {
const mappedPayload = avataxOrderFulfilledMaps.mapPayload(MOCKED_MAP_PAYLOAD_ARGS);
expect(mappedPayload).toEqual({
transactionCode: "transaction-code",
companyCode: "DEFAULT",
documentType: DocumentType.SalesInvoice,
model: {
commit: true,
},
});
});
describe.todo("mapPayload", () => {
it.todo("calculation of fields");
it.todo("formatting the fields");
it.todo("rounding of numbers");
});
});

View file

@ -1,9 +1,12 @@
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";
// * 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";
function getTransactionCodeFromMetadata(
metadata: OrderFulfilledSubscriptionFragment["privateMetadata"]
) {
@ -16,10 +19,12 @@ function getTransactionCodeFromMetadata(
return transactionCode.value;
}
const mapPayload = (
order: OrderFulfilledSubscriptionFragment,
config: AvataxConfig
): CommitTransactionArgs => {
export type CommitTransactionMapPayloadArgs = {
order: OrderFulfilledSubscriptionFragment;
config: AvataxConfig;
};
const mapPayload = ({ order, config }: CommitTransactionMapPayloadArgs): CommitTransactionArgs => {
const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata);
return {
@ -34,4 +39,6 @@ const mapPayload = (
export const avataxOrderFulfilledMaps = {
mapPayload,
getTransactionCodeFromMetadata,
providerOrderIdKey: PROVIDER_ORDER_ID_KEY,
};

View file

@ -1,10 +1,10 @@
import { TaxBaseLineFragment } from "../../../generated/graphql";
const getLineDiscount = (
function getTaxBaseLineDiscount(
line: TaxBaseLineFragment,
totalDiscount: number,
allLinesTotal: number
) => {
) {
if (totalDiscount === 0 || allLinesTotal === 0) {
return 0;
}
@ -15,9 +15,15 @@ const getLineDiscount = (
return lineTotalAmount;
}
return discountAmount;
};
}
const getLineTaxCode = (line: TaxBaseLineFragment): string => {
/**
* * currently the CalculateTaxes subscription uses only the taxjar code (see: TaxBaseLine in TaxBase.graphql)
* todo: add ability to pass providers or get codes for all providers
* todo: add `getOrderLineTaxCode`
* todo: later, replace with tax code matcher
*/
function getTaxBaseLineTaxCode(line: TaxBaseLineFragment): string {
if (line.sourceLine.__typename === "OrderLine") {
return (
line.sourceLine.variant?.product.metafield ??
@ -31,9 +37,9 @@ const getLineTaxCode = (line: TaxBaseLineFragment): string => {
line.sourceLine.productVariant.product.productType.metafield) ??
""
);
};
}
export const taxLineResolver = {
getLineDiscount,
getLineTaxCode,
getTaxBaseLineDiscount,
getTaxBaseLineTaxCode,
};

View file

@ -39,13 +39,17 @@ const prepareLinesWithDiscountPayload = (
const totalDiscount = discountsSum <= allLinesTotal ? discountsSum : allLinesTotal;
return lines.map((line) => {
const discountAmount = taxLineResolver.getLineDiscount(line, totalDiscount, allLinesTotal);
const taxCode = taxLineResolver.getLineTaxCode(line);
const discountAmount = taxLineResolver.getTaxBaseLineDiscount(
line,
totalDiscount,
allLinesTotal
);
return {
id: line.sourceLine.id,
chargeTaxes: line.chargeTaxes,
taxCode: taxCode,
// todo: get from tax code matcher
taxCode: "",
quantity: line.quantity,
totalAmount: Number(line.totalPrice.amount),
unitAmount: Number(line.unitPrice.amount),

View file

@ -48,9 +48,12 @@ const MOCKED_ORDER: TaxJarOrderCreatedMapPayloadArgs = {
currency: "USD",
},
shippingPrice: {
net: {
gross: {
amount: 48.33,
},
net: {
amount: 43.74,
},
},
lines: [
{
@ -84,6 +87,14 @@ const MOCKED_ORDER: TaxJarOrderCreatedMapPayloadArgs = {
},
},
],
discounts: [
{
amount: {
amount: 10,
},
id: "RGlzY291bnREaXNjb3VudDox",
},
],
},
channel: {
providerInstanceId: "b8c29f49-7cae-4762-8458-e9a27eb83081",

View file

@ -14,6 +14,7 @@ function mapLines(lines: OrderCreatedSubscriptionFragment["lines"]): LineItem[]
// todo: add from tax code matcher
product_tax_code: "",
sales_tax: line.totalPrice.tax.amount,
description: line.productName,
}));
}

View file

@ -68,6 +68,7 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res,
logger.info({ calculatedTaxes }, "Taxes calculated");
return webhookResponse.success(ctx.buildResponse(calculatedTaxes));
} catch (error) {
logger.error({ error });
return webhookResponse.failureRetry("Error while calculating taxes");
}
});

View file

@ -68,6 +68,7 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx
logger.info({ calculatedTaxes }, "Taxes calculated");
return webhookResponse.success(ctx.buildResponse(calculatedTaxes));
} catch (error) {
logger.error({ error });
return webhookResponse.failureRetry("Error while calculating taxes");
}
});

View file

@ -13,6 +13,7 @@ import { getActiveTaxProvider } from "../../../modules/taxes/active-tax-provider
import { createClient } from "../../../lib/graphql";
import { Client } from "urql";
import { WebhookResponse } from "../../../modules/app/webhook-response";
import { PROVIDER_ORDER_ID_KEY } from "../../../modules/avatax/maps/avatax-order-fulfilled-map";
export const config = {
api: {
@ -33,9 +34,6 @@ export const orderCreatedAsyncWebhook = new SaleorAsyncWebhook<OrderCreatedPaylo
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.
@ -105,6 +103,7 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => {
return webhookResponse.success();
} catch (error) {
logger.error({ error });
return webhookResponse.failureRetry("Error while creating order in tax provider");
}
});