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:
parent
47102ba98c
commit
d2b21cc1ab
27 changed files with 512 additions and 169 deletions
5
.changeset/light-rocks-count.md
Normal file
5
.changeset/light-rocks-count.md
Normal 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.
|
|
@ -2,6 +2,9 @@ fragment OrderLine on OrderLine {
|
|||
productSku
|
||||
productName
|
||||
quantity
|
||||
taxClass {
|
||||
id
|
||||
}
|
||||
unitPrice {
|
||||
net {
|
||||
amount
|
||||
|
|
|
@ -2,6 +2,9 @@ fragment OrderLine on OrderLine {
|
|||
productSku
|
||||
productName
|
||||
quantity
|
||||
taxClass {
|
||||
id
|
||||
}
|
||||
unitPrice {
|
||||
net {
|
||||
amount
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
|
@ -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);
|
||||
}
|
|
@ -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...");
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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 ?? ""
|
||||
: "";
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
|
@ -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);
|
||||
}
|
|
@ -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...");
|
||||
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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 ?? ""
|
||||
: "";
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
|
||||
|
|
Loading…
Reference in a new issue