feat: tax code create order (#693)

* feat:  add avatax-order-created-tax-code-matcher && extract calculate-taxes matcher

* refactor: 🚚 TaxJarTaxCodeMatcher -> TaxJarCalculateTaxesTaxCodeMatcher

* feat:  add taxjar-order-created-payload-service with tax code

* feat:  add missing tests

* build: 👷 add changeset
This commit is contained in:
Adrian Pilarczyk 2023-07-03 12:56:07 +02:00 committed by GitHub
parent 47102ba98c
commit d2b21cc1ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 512 additions and 169 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-taxes": minor
---
Added the usage of stored tax code combinations in the create order webhook flow. This doesn't effect the tax calculation, but makes sure the mapped product line has the correct tax code.

View file

@ -2,6 +2,9 @@ fragment OrderLine on OrderLine {
productSku
productName
quantity
taxClass {
id
}
unitPrice {
net {
amount

View file

@ -2,6 +2,9 @@ fragment OrderLine on OrderLine {
productSku
productName
quantity
taxClass {
id
}
unitPrice {
net {
amount

View file

@ -36,7 +36,7 @@ export class AvataxWebhookService implements ProviderWebhookService {
}
async createOrder(order: OrderCreatedSubscriptionFragment) {
const adapter = new AvataxOrderCreatedAdapter(this.config);
const adapter = new AvataxOrderCreatedAdapter(this.config, this.authData);
const response = await adapter.send({ order });

View file

@ -2,7 +2,7 @@ import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxTaxCodeMatcher } from "../tax-code/avatax-tax-code-matcher";
import { AvataxCalculateTaxesTaxCodeMatcher } from "./avatax-calculate-taxes-tax-code-matcher";
import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
export class AvataxCalculateTaxesPayloadLinesTransformer {
@ -13,7 +13,7 @@ export class AvataxCalculateTaxesPayloadLinesTransformer {
): LineItemModel[] {
const isDiscounted = taxBase.discounts.length > 0;
const productLines: LineItemModel[] = taxBase.lines.map((line) => {
const matcher = new AvataxTaxCodeMatcher();
const matcher = new AvataxCalculateTaxesTaxCodeMatcher();
const taxCode = matcher.match(line, matches);
return {

View file

@ -1,11 +1,11 @@
import { TaxBaseLineFragment } from "../../../../generated/graphql";
import { AvataxTaxCodeMatches } from "./avatax-tax-code-match-repository";
import { AvataxTaxCodeMatcher } from "./avatax-tax-code-matcher";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxCalculateTaxesTaxCodeMatcher } from "./avatax-calculate-taxes-tax-code-matcher";
import { describe, expect, it } from "vitest";
const matcher = new AvataxTaxCodeMatcher();
const matcher = new AvataxCalculateTaxesTaxCodeMatcher();
describe("AvataxTaxCodeMatcher", () => {
describe("AvataxCalculateTaxesTaxCodeMatcher", () => {
it("returns empty string when tax class is not found", () => {
const line: TaxBaseLineFragment = {
quantity: 1,

View file

@ -1,7 +1,7 @@
import { TaxBaseLineFragment } from "../../../../generated/graphql";
import { AvataxTaxCodeMatches } from "./avatax-tax-code-match-repository";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
export class AvataxTaxCodeMatcher {
export class AvataxCalculateTaxesTaxCodeMatcher {
private mapTaxClassWithTaxMatch(taxClassId: string, matches: AvataxTaxCodeMatches) {
return matches.find((m) => m.data.saleorTaxClassId === taxClassId);
}

View file

@ -1,3 +1,4 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { Logger, createLogger } from "../../../lib/logger";
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
@ -5,6 +6,7 @@ import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer";
import { AvataxOrderCreatedPayloadService } from "./avatax-order-created-payload.service";
import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer";
type AvataxOrderCreatedPayload = {
@ -17,15 +19,15 @@ export class AvataxOrderCreatedAdapter
{
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
constructor(private readonly config: AvataxConfig, private authData: AuthData) {
this.logger = createLogger({ name: "AvataxOrderCreatedAdapter" });
}
async send(payload: AvataxOrderCreatedPayload): Promise<AvataxOrderCreatedResponse> {
this.logger.debug("Transforming the Saleor payload for creating order with Avatax...");
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(this.config);
const target = payloadTransformer.transform(payload);
const payloadService = new AvataxOrderCreatedPayloadService(this.authData);
const target = await payloadService.getPayload(payload.order, this.config);
this.logger.debug("Calling Avatax createTransaction with transformed payload...");

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer";
import { avataxConfigMock } from "./avatax-order-created-payload-transformer.test";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxOrderCreatedMockGenerator } from "./avatax-order-created-mock-generator";
const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer();
const mockGenerator = new AvataxOrderCreatedMockGenerator();
const orderMock = mockGenerator.generateOrder();
const matches: AvataxTaxCodeMatches = [];
describe("AvataxOrderCreatedPayloadLinesTransformer", () => {
const lines = linesTransformer.transform(orderMock, avataxConfigMock, matches);
it("returns the correct number of lines", () => {
expect(lines).toHaveLength(4);
});
it("includes shipping as a line", () => {
expect(lines).toContainEqual({
itemCode: "Shipping",
taxCode: "FR000000",
quantity: 1,
amount: 59.17,
taxIncluded: true,
});
});
it("includes products as lines", () => {
const [first, second, third] = lines;
expect(first).toContain({
itemCode: "328223580",
description: "Monospace Tee",
quantity: 3,
amount: 65.18,
});
expect(second).toContain({
itemCode: "328223581",
description: "Monospace Tee",
quantity: 1,
amount: 21.73,
});
expect(third).toContain({
itemCode: "118223581",
description: "Paul's Balance 420",
quantity: 2,
amount: 108.63,
});
});
});

View file

@ -0,0 +1,52 @@
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { numbers } from "../../taxes/numbers";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { SHIPPING_ITEM_CODE } from "./avatax-order-created-payload-transformer";
import { AvataxOrderCreatedTaxCodeMatcher } from "./avatax-order-created-tax-code-matcher";
export class AvataxOrderCreatedPayloadLinesTransformer {
transform(
order: OrderCreatedSubscriptionFragment,
config: AvataxConfig,
matches: AvataxTaxCodeMatches
): LineItemModel[] {
const productLines: LineItemModel[] = order.lines.map((line) => {
const matcher = new AvataxOrderCreatedTaxCodeMatcher();
const taxCode = matcher.match(line, matches);
return {
// taxes are included because we treat what is passed in payload as the source of truth
taxIncluded: true,
amount: numbers.roundFloatToTwoDecimals(
line.totalPrice.net.amount + line.totalPrice.tax.amount
),
taxCode,
quantity: line.quantity,
description: line.productName,
itemCode: line.productSku ?? "",
discounted: order.discounts.length > 0,
};
});
if (order.shippingPrice.net.amount !== 0) {
// * In Avatax, shipping is a regular line
const shippingLine: LineItemModel = {
amount: order.shippingPrice.gross.amount,
taxIncluded: true,
itemCode: SHIPPING_ITEM_CODE,
/**
* * Different shipping methods can have different tax codes.
* https://developer.avalara.com/ecommerce-integration-guide/sales-tax-badge/designing/non-standard-items/\
*/
taxCode: config.shippingTaxCode,
quantity: 1,
};
return [...productLines, shippingLine];
}
return productLines;
}
}

View file

@ -1,11 +1,9 @@
import { describe, expect, it } from "vitest";
import { AvataxOrderCreatedMockGenerator } from "./avatax-order-created-mock-generator";
import {
AvataxOrderCreatedPayloadTransformer,
mapLines,
} from "./avatax-order-created-payload-transformer";
import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer";
const mockGenerator = new AvataxOrderCreatedMockGenerator();
const orderMock = mockGenerator.generateOrder();
const discountedOrderMock = mockGenerator.generateOrder({
discounts: [
@ -17,19 +15,14 @@ const discountedOrderMock = mockGenerator.generateOrder({
},
],
});
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const channelConfigMock = mockGenerator.generateChannelConfig();
export const avataxConfigMock = mockGenerator.generateAvataxConfig();
describe("AvataxOrderCreatedPayloadTransformer", () => {
it("returns lines with discounted: true when there are discounts", () => {
const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock);
const payloadMock = {
order: discountedOrderMock,
providerConfig: avataxConfigMock,
channelConfig: channelConfigMock,
};
const transformer = new AvataxOrderCreatedPayloadTransformer();
const payload = transformer.transform(payloadMock);
const payload = transformer.transform(discountedOrderMock, avataxConfigMock, []);
const linesWithoutShipping = payload.model.lines.slice(0, -1);
const check = linesWithoutShipping.every((line) => line.discounted === true);
@ -37,14 +30,8 @@ describe("AvataxOrderCreatedPayloadTransformer", () => {
expect(check).toBe(true);
});
it("returns lines with discounted: false when there are no discounts", () => {
const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock);
const payloadMock = {
order: orderMock,
providerConfig: avataxConfigMock,
channelConfig: channelConfigMock,
};
const payload = transformer.transform(payloadMock);
const transformer = new AvataxOrderCreatedPayloadTransformer();
const payload = transformer.transform(orderMock, avataxConfigMock, []);
const linesWithoutShipping = payload.model.lines.slice(0, -1);
const check = linesWithoutShipping.every((line) => line.discounted === false);
@ -52,44 +39,3 @@ describe("AvataxOrderCreatedPayloadTransformer", () => {
expect(check).toBe(true);
});
});
describe("mapLines", () => {
const lines = mapLines(orderMock, avataxConfigMock);
it("returns the correct number of lines", () => {
expect(lines).toHaveLength(4);
});
it("includes shipping as a line", () => {
expect(lines).toContainEqual({
itemCode: "Shipping",
taxCode: "FR000000",
quantity: 1,
amount: 59.17,
taxIncluded: true,
});
});
it("includes products as lines", () => {
const [first, second, third] = lines;
expect(first).toContain({
itemCode: "328223580",
description: "Monospace Tee",
quantity: 3,
amount: 65.18,
});
expect(second).toContain({
itemCode: "328223581",
description: "Monospace Tee",
quantity: 1,
amount: 21.73,
});
expect(third).toContain({
itemCode: "118223581",
description: "Paul's Balance 420",
quantity: 2,
amount: 108.63,
});
});
});

View file

@ -1,78 +1,44 @@
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { numbers } from "../../taxes/numbers";
import { AvataxConfig } from "../avatax-connection-schema";
import { avataxAddressFactory } from "../address-factory";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { discountUtils } from "../../taxes/discount-utils";
import { avataxAddressFactory } from "../address-factory";
import { CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer";
const SHIPPING_ITEM_CODE = "Shipping";
// ? separate class?
export function mapLines(
order: OrderCreatedSubscriptionFragment,
config: AvataxConfig
): LineItemModel[] {
const productLines: LineItemModel[] = order.lines.map((line) => ({
// taxes are included because we treat what is passed in payload as the source of truth
taxIncluded: true,
amount: numbers.roundFloatToTwoDecimals(
line.totalPrice.net.amount + line.totalPrice.tax.amount
),
// todo: get from tax code matcher
taxCode: "",
quantity: line.quantity,
description: line.productName,
itemCode: line.productSku ?? "",
discounted: order.discounts.length > 0,
}));
if (order.shippingPrice.net.amount !== 0) {
// * In Avatax, shipping is a regular line
const shippingLine: LineItemModel = {
amount: order.shippingPrice.gross.amount,
taxIncluded: true,
itemCode: SHIPPING_ITEM_CODE,
/**
* * Different shipping methods can have different tax codes.
* https://developer.avalara.com/ecommerce-integration-guide/sales-tax-badge/designing/non-standard-items/\
*/
taxCode: config.shippingTaxCode,
quantity: 1,
};
return [...productLines, shippingLine];
}
return productLines;
}
export const SHIPPING_ITEM_CODE = "Shipping";
export class AvataxOrderCreatedPayloadTransformer {
constructor(private readonly providerConfig: AvataxConfig) {}
transform = ({ order }: { order: OrderCreatedSubscriptionFragment }): CreateTransactionArgs => {
transform(
order: OrderCreatedSubscriptionFragment,
avataxConfig: AvataxConfig,
matches: AvataxTaxCodeMatches
): CreateTransactionArgs {
const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer();
return {
model: {
type: DocumentType.SalesInvoice,
customerCode:
order.user?.id ??
"" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the user id. */,
companyCode: this.providerConfig.companyCode,
companyCode: avataxConfig.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: this.providerConfig.isAutocommit,
commit: avataxConfig.isAutocommit,
addresses: {
shipFrom: avataxAddressFactory.fromChannelAddress(this.providerConfig.address),
shipFrom: avataxAddressFactory.fromChannelAddress(avataxConfig.address),
// billing or shipping address?
shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!),
},
currencyCode: order.total.currency,
email: order.user?.email ?? "",
lines: mapLines(order, this.providerConfig),
lines: linesTransformer.transform(order, avataxConfig, matches),
date: new Date(order.created),
discount: discountUtils.sumDiscounts(
order.discounts.map((discount) => discount.amount.amount)
),
},
};
};
}
}

View file

@ -0,0 +1,26 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatchesService } from "../tax-code/avatax-tax-code-matches.service";
import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer";
export class AvataxOrderCreatedPayloadService {
constructor(private authData: AuthData) {}
private getMatches() {
const taxCodeMatchesService = new AvataxTaxCodeMatchesService(this.authData);
return taxCodeMatchesService.getAll();
}
async getPayload(
order: OrderCreatedSubscriptionFragment,
avataxConfig: AvataxConfig
): Promise<CreateTransactionArgs> {
const matches = await this.getMatches();
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer();
return payloadTransformer.transform(order, avataxConfig, matches);
}
}

View file

@ -0,0 +1,57 @@
import { OrderLineFragment } from "../../../../generated/graphql";
import { describe, expect, it } from "vitest";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxOrderCreatedTaxCodeMatcher } from "./avatax-order-created-tax-code-matcher";
const mockedLine: OrderLineFragment = {
productSku: "sku",
productName: "Test product",
quantity: 1,
taxClass: {
id: "tax-class-id-2",
},
unitPrice: {
net: {
amount: 10,
},
},
totalPrice: {
net: {
amount: 10,
},
tax: {
amount: 1,
},
},
};
const matches: AvataxTaxCodeMatches = [
{
data: {
saleorTaxClassId: "tax-class-id",
avataxTaxCode: "P0000000",
},
id: "id-1",
},
{
data: {
saleorTaxClassId: "tax-class-id-3",
avataxTaxCode: "P0000001",
},
id: "id-2",
},
];
describe("AvataxOrderCreatedTaxCodeMatcher", () => {
it("should return empty string if tax class is not found", () => {
const matcher = new AvataxOrderCreatedTaxCodeMatcher();
expect(matcher.match(mockedLine, matches)).toEqual("");
});
it("should return tax code if tax class is found", () => {
const line = structuredClone({ ...mockedLine, taxClass: { id: "tax-class-id" } });
const matcher = new AvataxOrderCreatedTaxCodeMatcher();
expect(matcher.match(line, matches)).toEqual("P0000000");
});
});

View file

@ -0,0 +1,21 @@
import { OrderLineFragment } from "../../../../generated/graphql";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
export class AvataxOrderCreatedTaxCodeMatcher {
private mapTaxClassWithTaxMatch(taxClassId: string, matches: AvataxTaxCodeMatches) {
return matches.find((m) => m.data.saleorTaxClassId === taxClassId);
}
private getTaxClassId(line: OrderLineFragment): string | undefined {
return line.taxClass?.id;
}
match(line: OrderLineFragment, matches: AvataxTaxCodeMatches) {
const taxClassId = this.getTaxClassId(line);
// We can fall back to empty string if we don't have a tax code match
return taxClassId
? this.mapTaxClassWithTaxMatch(taxClassId, matches)?.data.avataxTaxCode ?? ""
: "";
}
}

View file

@ -4,7 +4,7 @@ import {
TaxJarCalculateTaxesPayload,
TaxJarCalculateTaxesTarget,
} from "./taxjar-calculate-taxes-adapter";
import { TaxJarTaxCodeMatcher } from "../tax-code/taxjar-tax-code-matcher";
import { TaxJarCalculateTaxesTaxCodeMatcher } from "./taxjar-calculate-taxes-tax-code-matcher";
export class TaxJarCalculateTaxesPayloadLinesTransformer {
transform(
@ -21,9 +21,9 @@ export class TaxJarCalculateTaxesPayloadLinesTransformer {
const mappedLines: TaxJarCalculateTaxesTarget["params"]["line_items"] = lines.map(
(line, index) => {
const matcher = new TaxJarTaxCodeMatcher();
const discountAmount = distributedDiscounts[index];
const matcher = new TaxJarCalculateTaxesTaxCodeMatcher();
const taxCode = matcher.match(line, matches);
const discountAmount = distributedDiscounts[index];
return {
id: line.sourceLine.id,

View file

@ -1,11 +1,11 @@
import { TaxBaseLineFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatches } from "./taxjar-tax-code-match-repository";
import { TaxJarTaxCodeMatcher } from "./taxjar-tax-code-matcher";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
import { TaxJarCalculateTaxesTaxCodeMatcher } from "./taxjar-calculate-taxes-tax-code-matcher";
import { describe, expect, it } from "vitest";
const matcher = new TaxJarTaxCodeMatcher();
const matcher = new TaxJarCalculateTaxesTaxCodeMatcher();
describe("TaxJarTaxCodeMatcher", () => {
describe("TaxJarCalculateTaxesTaxCodeMatcher", () => {
it("returns empty string when tax class is not found", () => {
const line: TaxBaseLineFragment = {
quantity: 1,

View file

@ -1,7 +1,7 @@
import { TaxBaseLineFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatches } from "./taxjar-tax-code-match-repository";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
export class TaxJarTaxCodeMatcher {
export class TaxJarCalculateTaxesTaxCodeMatcher {
private mapTaxClassWithTaxMatch(taxClassId: string, matches: TaxJarTaxCodeMatches) {
return matches.find((m) => m.data.saleorTaxClassId === taxClassId);
}

View file

@ -1,10 +1,11 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { Logger, createLogger } from "../../../lib/logger";
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { CreateOrderArgs, TaxJarClient } from "../taxjar-client";
import { TaxJarConfig } from "../taxjar-connection-schema";
import { TaxJarOrderCreatedPayloadTransformer } from "./taxjar-order-created-payload-transformer";
import { TaxJarOrderCreatedPayloadService } from "./taxjar-order-created-payload.service";
import { TaxJarOrderCreatedResponseTransformer } from "./taxjar-order-created-response-transformer";
export type TaxJarOrderCreatedPayload = {
@ -17,14 +18,14 @@ export class TaxJarOrderCreatedAdapter
implements WebhookAdapter<TaxJarOrderCreatedPayload, TaxJarOrderCreatedResponse>
{
private logger: Logger;
constructor(private readonly config: TaxJarConfig) {
constructor(private readonly config: TaxJarConfig, private authData: AuthData) {
this.logger = createLogger({ name: "TaxJarOrderCreatedAdapter" });
}
async send(payload: TaxJarOrderCreatedPayload): Promise<TaxJarOrderCreatedResponse> {
this.logger.debug("Transforming the Saleor payload for creating order with TaxJar...");
const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer(this.config);
const target = payloadTransformer.transform(payload);
const payloadService = new TaxJarOrderCreatedPayloadService(this.authData);
const target = await payloadService.getPayload(payload.order, this.config);
this.logger.debug("Calling TaxJar fetchTaxForOrder with transformed payload...");

View file

@ -0,0 +1,91 @@
import { OrderCreatedSubscriptionFragment, OrderLineFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
import { describe, expect, it } from "vitest";
import { TaxJarOrderCreatedPayloadLinesTransformer } from "./taxjar-order-created-payload-lines-transformer";
const transformer = new TaxJarOrderCreatedPayloadLinesTransformer();
const mockedLines: OrderCreatedSubscriptionFragment["lines"] = [
{
productSku: "sku",
productName: "Test product",
quantity: 1,
taxClass: {
id: "tax-class-id-2",
},
unitPrice: {
net: {
amount: 10,
},
},
totalPrice: {
net: {
amount: 10,
},
tax: {
amount: 1,
},
},
},
{
productSku: "sku-2",
productName: "Test product 2",
quantity: 2,
taxClass: {
id: "tax-class-id-3",
},
unitPrice: {
net: {
amount: 15,
},
},
totalPrice: {
net: {
amount: 30,
},
tax: {
amount: 3,
},
},
},
];
const matches: TaxJarTaxCodeMatches = [
{
data: {
saleorTaxClassId: "tax-class-id",
taxJarTaxCode: "P0000000",
},
id: "id-1",
},
{
data: {
saleorTaxClassId: "tax-class-id-3",
taxJarTaxCode: "P0000001",
},
id: "id-2",
},
];
describe("TaxJarOrderCreatedPayloadLinesTransformer", () => {
it("should map payload lines correctly", () => {
expect(transformer.transform(mockedLines, matches)).toEqual([
{
quantity: 1,
unit_price: 10,
product_identifier: "sku",
product_tax_code: "",
sales_tax: 1,
description: "Test product",
},
{
quantity: 2,
unit_price: 15,
product_identifier: "sku-2",
product_tax_code: "P0000001",
sales_tax: 3,
description: "Test product 2",
},
]);
});
});

View file

@ -0,0 +1,25 @@
import { LineItem } from "taxjar/dist/util/types";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
import { TaxJarOrderCreatedTaxCodeMatcher } from "./taxjar-order-created-tax-code-matcher";
export class TaxJarOrderCreatedPayloadLinesTransformer {
transform(
lines: OrderCreatedSubscriptionFragment["lines"],
matches: TaxJarTaxCodeMatches
): LineItem[] {
return lines.map((line) => {
const matcher = new TaxJarOrderCreatedTaxCodeMatcher();
const taxCode = matcher.match(line, matches);
return {
quantity: line.quantity,
unit_price: line.unitPrice.net.amount,
product_identifier: line.productSku ?? "",
product_tax_code: taxCode,
sales_tax: line.totalPrice.tax.amount,
description: line.productName,
};
});
}
}

View file

@ -9,12 +9,10 @@ const mockGenerator = new TaxJarOrderCreatedMockGenerator();
describe("TaxJarOrderCreatedPayloadTransformer", () => {
it("returns the correct order amount", () => {
const payloadMock = {
order: mockGenerator.generateOrder(),
};
const orderMock = mockGenerator.generateOrder();
const providerConfig = mockGenerator.generateProviderConfig();
const transformer = new TaxJarOrderCreatedPayloadTransformer(providerConfig);
const transformedPayload = transformer.transform(payloadMock);
const transformer = new TaxJarOrderCreatedPayloadTransformer();
const transformedPayload = transformer.transform(orderMock, providerConfig, []);
expect(transformedPayload.params.amount).toBe(239.17);
});

View file

@ -2,11 +2,10 @@ import { LineItem } from "taxjar/dist/util/types";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { numbers } from "../../taxes/numbers";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
import { TaxJarConfig } from "../taxjar-connection-schema";
import {
TaxJarOrderCreatedPayload,
TaxJarOrderCreatedTarget,
} from "./taxjar-order-created-adapter";
import { TaxJarOrderCreatedTarget } from "./taxjar-order-created-adapter";
import { TaxJarOrderCreatedPayloadLinesTransformer } from "./taxjar-order-created-payload-lines-transformer";
export function sumPayloadLines(lines: LineItem[]): number {
return numbers.roundFloatToTwoDecimals(
@ -27,21 +26,13 @@ export function sumPayloadLines(lines: LineItem[]): number {
}
export class TaxJarOrderCreatedPayloadTransformer {
constructor(private readonly config: TaxJarConfig) {}
private 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,
description: line.productName,
}));
}
transform({ order }: TaxJarOrderCreatedPayload): TaxJarOrderCreatedTarget {
const lineItems = this.mapLines(order.lines);
transform(
order: OrderCreatedSubscriptionFragment,
taxJarConfig: TaxJarConfig,
matches: TaxJarTaxCodeMatches
): TaxJarOrderCreatedTarget {
const linesTransformer = new TaxJarOrderCreatedPayloadLinesTransformer();
const lineItems = linesTransformer.transform(order.lines, matches);
const lineSum = sumPayloadLines(lineItems);
const shippingAmount = order.shippingPrice.gross.amount;
/**
@ -52,11 +43,11 @@ export class TaxJarOrderCreatedPayloadTransformer {
return {
params: {
from_country: this.config.address.country,
from_zip: this.config.address.zip,
from_state: this.config.address.state,
from_city: this.config.address.city,
from_street: this.config.address.street,
from_country: taxJarConfig.address.country,
from_zip: taxJarConfig.address.zip,
from_state: taxJarConfig.address.state,
from_city: taxJarConfig.address.city,
from_street: taxJarConfig.address.street,
to_country: order.shippingAddress!.country.code,
to_zip: order.shippingAddress!.postalCode,
to_state: order.shippingAddress!.countryArea,

View file

@ -0,0 +1,26 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatchesService } from "../tax-code/taxjar-tax-code-matches.service";
import { TaxJarConfig } from "../taxjar-connection-schema";
import { TaxJarOrderCreatedPayloadTransformer } from "./taxjar-order-created-payload-transformer";
import { CreateOrderArgs } from "../taxjar-client";
export class TaxJarOrderCreatedPayloadService {
constructor(private authData: AuthData) {}
private getMatches() {
const taxCodeMatchesService = new TaxJarTaxCodeMatchesService(this.authData);
return taxCodeMatchesService.getAll();
}
async getPayload(
order: OrderCreatedSubscriptionFragment,
taxJarConfig: TaxJarConfig
): Promise<CreateOrderArgs> {
const matches = await this.getMatches();
const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer();
return payloadTransformer.transform(order, taxJarConfig, matches);
}
}

View file

@ -0,0 +1,57 @@
import { OrderLineFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
import { TaxJarOrderCreatedTaxCodeMatcher } from "./taxjar-order-created-tax-code-matcher";
import { describe, expect, it } from "vitest";
const mockedLine: OrderLineFragment = {
productSku: "sku",
productName: "Test product",
quantity: 1,
taxClass: {
id: "tax-class-id-2",
},
unitPrice: {
net: {
amount: 10,
},
},
totalPrice: {
net: {
amount: 10,
},
tax: {
amount: 1,
},
},
};
const matches: TaxJarTaxCodeMatches = [
{
data: {
saleorTaxClassId: "tax-class-id",
taxJarTaxCode: "P0000000",
},
id: "id-1",
},
{
data: {
saleorTaxClassId: "tax-class-id-3",
taxJarTaxCode: "P0000001",
},
id: "id-2",
},
];
describe("TaxJarOrderCreatedTaxCodeMatcher", () => {
it("should return empty string if tax class is not found", () => {
const matcher = new TaxJarOrderCreatedTaxCodeMatcher();
expect(matcher.match(mockedLine, matches)).toEqual("");
});
it("should return tax code if tax class is found", () => {
const line = structuredClone({ ...mockedLine, taxClass: { id: "tax-class-id" } });
const matcher = new TaxJarOrderCreatedTaxCodeMatcher();
expect(matcher.match(line, matches)).toEqual("P0000000");
});
});

View file

@ -0,0 +1,21 @@
import { OrderLineFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
export class TaxJarOrderCreatedTaxCodeMatcher {
private mapTaxClassWithTaxMatch(taxClassId: string, matches: TaxJarTaxCodeMatches) {
return matches.find((m) => m.data.saleorTaxClassId === taxClassId);
}
private getTaxClassId(line: OrderLineFragment): string | undefined {
return line.taxClass?.id;
}
match(line: OrderLineFragment, matches: TaxJarTaxCodeMatches) {
const taxClassId = this.getTaxClassId(line);
// We can fall back to empty string if we don't have a tax code match
return taxClassId
? this.mapTaxClassWithTaxMatch(taxClassId, matches)?.data.taxJarTaxCode ?? ""
: "";
}
}

View file

@ -31,7 +31,7 @@ export class TaxJarWebhookService implements ProviderWebhookService {
}
async createOrder(order: OrderCreatedSubscriptionFragment) {
const adapter = new TaxJarOrderCreatedAdapter(this.config);
const adapter = new TaxJarOrderCreatedAdapter(this.config, this.authData);
const response = await adapter.send({ order });