refactor: refine taxjar (#494)

* feat:  add first tests & use address-factory

* feat:  add distributeDiscount

* refactor: taxjar maps to adapters (#495)

* refactor: ♻️ taxjar-calculate-taxes-map with taxjar-calculate-taxes-adapter

* refactor: ♻️ taxjar-order-created-map -> taxjar-order-created-adapter

* refactor: ♻️ address 1st batch of feedback

* refactor: ♻️ split up taxjar-calculate-taxes-adapter

* refactor: 🚚 extract shipping transformer

* docs: 💡 add comment about refunds in distribute-discount

* refactor: 🚚 split up taxjar-order-created-adapter classes

* refactor: ♻️ mocks with taxjar-mock-factory

* refactor: ♻️ mocks with avatax-mock-factory

* refactor: avatax maps to adapters (#506)

* refactor: ♻️ move around & refactor avatax-order-created-map -> adapter

* refactor: 🚚 move avatax-order-created- to its own folder

* refactor: ♻️ avatax-calculate-taxes-map -> adapter

* refactor: ♻️ avatax-order-fulfilled-maps -> adapter

* feat:  add logger to adapters

* refactor: ♻️ mocks -> avatax-mock-transaction-factory & fix tests

* feat: add tests for taxjar (#509)

* fix: 🚚 tax-provider-utils.test name

* feat:  add nexus tests & other taxjar tests

* feat: 🥅 add ExpectedError and use it in webhook-response

* refactor:  unify taxjar-calculate-taxes tests with mock-generator

* feat:  add TaxJarOrderCreatedMockGenerator

* feat:  add avatax-calculate-taxes-mock-generator

* feat:  add AvataxOrderCreatedMockGenerator

* refactor: 🔥 tax-mock-factory

* fix: 🐛 housekeeping

* fix: 🐛 feedback

* feat:  add taxBase with discounts test

* fix: 🐛 address feedback

* refactor: 🔥 unused avatax-mock-factory functions

* feat:  use discount utils in all providers

* feat:  differentiate between pricesEnteredWithTax in taxjar
This commit is contained in:
Adrian Pilarczyk 2023-05-29 10:35:34 +02:00 committed by GitHub
parent 6e69f4f9f0
commit ca4306162f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 6953 additions and 2338 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-taxes": minor
---
Adds new way of distributing discounts on items (proportional). Adds distinguishment between when TaxJar nexus was found and was not. Now, the "not found" behavior is not treated as error, and will return untaxed values. Fixes bugs: item quantity in TaxJar; when shipping = 0; pricesEnteredWithTax influences shipping price.

109
apps/taxes/src/mocks.ts Normal file
View file

@ -0,0 +1,109 @@
import { OrderCreatedSubscriptionFragment, OrderStatus } from "../generated/graphql";
export const defaultOrder: OrderCreatedSubscriptionFragment = {
id: "T3JkZXI6ZTUzZTBlM2MtMjk5Yi00OWYxLWIyZDItY2Q4NWExYTgxYjY2",
user: {
id: "VXNlcjoyMDg0NTEwNDEw",
email: "happy.customer@saleor.io",
},
created: "2023-05-25T09:18:55.203440+00:00",
status: OrderStatus.Unfulfilled,
channel: {
id: "Q2hhbm5lbDox",
slug: "default-channel",
},
shippingAddress: {
streetAddress1: "600 Montgomery St",
streetAddress2: "",
city: "SAN FRANCISCO",
countryArea: "CA",
postalCode: "94111",
country: {
code: "US",
},
},
billingAddress: {
streetAddress1: "600 Montgomery St",
streetAddress2: "",
city: "SAN FRANCISCO",
countryArea: "CA",
postalCode: "94111",
country: {
code: "US",
},
},
total: {
currency: "USD",
net: {
amount: 239.17,
},
tax: {
amount: 15.54,
},
},
shippingPrice: {
gross: {
amount: 59.17,
},
net: {
amount: 59.17,
},
},
lines: [
{
productSku: "328223580",
productName: "Monospace Tee",
quantity: 3,
unitPrice: {
net: {
amount: 20,
},
},
totalPrice: {
net: {
amount: 60,
},
tax: {
amount: 5.18,
},
},
},
{
productSku: "328223581",
productName: "Monospace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 20,
},
},
totalPrice: {
net: {
amount: 20,
},
tax: {
amount: 1.73,
},
},
},
{
productSku: "118223581",
productName: "Paul's Balance 420",
quantity: 2,
unitPrice: {
net: {
amount: 50,
},
},
totalPrice: {
net: {
amount: 100,
},
tax: {
amount: 8.63,
},
},
},
],
discounts: [],
};

View file

@ -62,14 +62,14 @@ const mockedMetadata: MetadataItem[] = [
vi.stubEnv("SECRET_KEY", mockedSecretKey); vi.stubEnv("SECRET_KEY", mockedSecretKey);
describe("getAppConfig", () => { describe("getAppConfig", () => {
it("should return empty providers and channels config when no metadata", () => { it("returns empty providers and channels config when no metadata", () => {
const { providers, channels } = getAppConfig([]); const { providers, channels } = getAppConfig([]);
expect(providers).toEqual([]); expect(providers).toEqual([]);
expect(channels).toEqual({}); expect(channels).toEqual({});
}); });
it("should return decrypted providers and channels config when metadata provided", () => { it("returns decrypted providers and channels config when metadata provided", () => {
const { providers, channels } = getAppConfig(mockedMetadata); const { providers, channels } = getAppConfig(mockedMetadata);
expect(providers).toEqual(mockedProviders); expect(providers).toEqual(mockedProviders);

View file

@ -0,0 +1,39 @@
import { NextApiResponse } from "next";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { WebhookResponse } from "./webhook-response";
let jsonMock = vi.fn();
let statusMock = vi.fn().mockReturnValueOnce({ json: jsonMock });
let mockResponse = {
status: statusMock,
} as unknown as NextApiResponse;
beforeEach(() => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnValueOnce({ json: jsonMock });
mockResponse = {
status: statusMock,
} as unknown as NextApiResponse;
});
describe("WebhookResponse", () => {
it("returns 500 when thrown unexpected error", () => {
const webhookResponse = new WebhookResponse(mockResponse);
const unexpectedError = new Error("Unexpected error");
webhookResponse.error(unexpectedError);
expect(statusMock).toHaveBeenCalledWith(500);
});
it("returns 200 and data when success is called", () => {
const webhookResponse = new WebhookResponse(mockResponse);
webhookResponse.success({ foo: "bar" });
expect(statusMock).toHaveBeenCalledWith(200);
expect(jsonMock).toHaveBeenCalledWith({ foo: "bar" });
});
});

View file

@ -2,24 +2,35 @@ import { NextApiResponse } from "next";
import { createLogger, Logger } from "../../lib/logger"; import { createLogger, Logger } from "../../lib/logger";
/*
* idea: distinguish between async and sync webhooks
* when sync webhooks, require passing the event and enforce the required response format using ctx.buildResponse
* when async webhooks, dont require anything
*/
export class WebhookResponse { export class WebhookResponse {
private logger: Logger; private logger: Logger;
constructor(private res: NextApiResponse) { constructor(private res: NextApiResponse) {
this.logger = createLogger({ event: "WebhookResponse" }); this.logger = createLogger({ event: "WebhookResponse" });
} }
failure(error: string) { private returnSuccess(data?: unknown) {
this.logger.debug({ error }, "failure called with:"); this.logger.debug({ data }, "success called with:");
return this.res.status(500).json({ error }); return this.res.status(200).json(data ?? {});
} }
success(data?: any) { private returnError(errorMessage: string) {
this.logger.debug({ data }, "success called with:"); this.logger.debug({ errorMessage }, "returning error:");
return this.res.send(data); return this.res.status(500).json({ error: errorMessage });
}
private resolveError(error: unknown) {
if (error instanceof Error) {
this.logger.error(error.stack, "Unexpected error caught:");
return this.returnError(error.message);
}
return this.returnError("Internal server error");
}
error(error: unknown) {
return this.resolveError(error);
}
success(data?: unknown) {
return this.returnSuccess(data);
} }
} }

View file

@ -1,6 +1,6 @@
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo"; import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
import { ChannelAddress } from "../../channels-configuration/channels-config"; import { ChannelAddress } from "../channels-configuration/channels-config";
import { AddressFragment as SaleorAddress } from "../../../../generated/graphql"; import { AddressFragment as SaleorAddress } from "../../../generated/graphql";
function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress { function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress {
return { return {

View file

@ -72,19 +72,19 @@ export class AvataxClient {
} }
async createTransaction({ model }: CreateTransactionArgs) { async createTransaction({ model }: CreateTransactionArgs) {
this.logger.debug({ model }, "createTransaction called with:"); this.logger.trace({ model }, "createTransaction called with:");
return this.client.createTransaction({ model }); return this.client.createTransaction({ model });
} }
async commitTransaction(args: CommitTransactionArgs) { async commitTransaction(args: CommitTransactionArgs) {
this.logger.debug(args, "commitTransaction called with:"); this.logger.trace(args, "commitTransaction called with:");
return this.client.commitTransaction(args); return this.client.commitTransaction(args);
} }
async ping() { async ping() {
this.logger.debug("ping called"); this.logger.trace("ping called");
try { try {
const result = await this.client.ping(); const result = await this.client.ping();
@ -103,7 +103,7 @@ export class AvataxClient {
} }
async validateAddress({ address }: ValidateAddressArgs) { async validateAddress({ address }: ValidateAddressArgs) {
this.logger.debug({ address }, "validateAddress called with:"); this.logger.trace({ address }, "validateAddress called with:");
return this.client.resolveAddress(address); return this.client.resolveAddress(address);
} }

View file

@ -0,0 +1,5 @@
import { avataxMockTransactionFactory } from "./avatax-mock-transaction-factory";
export const avataxMockFactory = {
...avataxMockTransactionFactory,
};

View file

@ -3,14 +3,14 @@ import {
OrderFulfilledSubscriptionFragment, OrderFulfilledSubscriptionFragment,
TaxBaseFragment, TaxBaseFragment,
} from "../../../generated/graphql"; } from "../../../generated/graphql";
import { createLogger, Logger } from "../../lib/logger"; import { Logger, createLogger } from "../../lib/logger";
import { ChannelConfig } from "../channels-configuration/channels-config"; import { ChannelConfig } from "../channels-configuration/channels-config";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { avataxCalculateTaxesMaps } from "./maps/avatax-calculate-taxes-map";
import { AvataxClient } from "./avatax-client"; import { AvataxClient } from "./avatax-client";
import { AvataxConfig, defaultAvataxConfig } from "./avatax-config"; import { AvataxConfig, defaultAvataxConfig } from "./avatax-config";
import { avataxOrderCreatedMaps } from "./maps/avatax-order-created-map"; import { AvataxCalculateTaxesAdapter } from "./calculate-taxes/avatax-calculate-taxes-adapter";
import { avataxOrderFulfilledMaps } from "./maps/avatax-order-fulfilled-map"; import { AvataxOrderCreatedAdapter } from "./order-created/avatax-order-created-adapter";
import { AvataxOrderFulfilledAdapter } from "./order-fulfilled/avatax-order-fulfilled-adapter";
export class AvataxWebhookService implements ProviderWebhookService { export class AvataxWebhookService implements ProviderWebhookService {
config = defaultAvataxConfig; config = defaultAvataxConfig;
@ -29,38 +29,38 @@ export class AvataxWebhookService implements ProviderWebhookService {
this.client = avataxClient; this.client = avataxClient;
} }
async calculateTaxes(payload: TaxBaseFragment, channel: ChannelConfig) { async calculateTaxes(taxBase: TaxBaseFragment, channelConfig: ChannelConfig) {
this.logger.debug({ payload, channel }, "calculateTaxes called with:"); this.logger.debug({ taxBase, channelConfig }, "calculateTaxes called with:");
const args = avataxCalculateTaxesMaps.mapPayload({ const adapter = new AvataxCalculateTaxesAdapter(this.config);
taxBase: payload,
channel,
config: this.config,
});
const result = await this.client.createTransaction(args);
this.logger.debug({ result }, "calculateTaxes response"); const response = await adapter.send({ channelConfig, taxBase });
return avataxCalculateTaxesMaps.mapResponse(result);
this.logger.debug({ response }, "calculateTaxes response:");
return response;
} }
async createOrder(order: OrderCreatedSubscriptionFragment, channel: ChannelConfig) { async createOrder(order: OrderCreatedSubscriptionFragment, channelConfig: ChannelConfig) {
this.logger.debug({ order, channel }, "createOrder called with:"); this.logger.debug({ order, channelConfig }, "createOrder called with:");
const model = avataxOrderCreatedMaps.mapPayload({ order, channel, config: this.config });
this.logger.debug({ model }, "will call createTransaction with"); const adapter = new AvataxOrderCreatedAdapter(this.config);
const result = await this.client.createTransaction(model);
this.logger.debug({ result }, "createOrder response"); const response = await adapter.send({ channelConfig, order });
return avataxOrderCreatedMaps.mapResponse(result);
this.logger.debug({ response }, "createOrder response:");
return response;
} }
async fulfillOrder(order: OrderFulfilledSubscriptionFragment, channel: ChannelConfig) { async fulfillOrder(order: OrderFulfilledSubscriptionFragment, channelConfig: ChannelConfig) {
this.logger.debug({ order, channel }, "fulfillOrder called with:"); this.logger.debug({ order, channelConfig }, "fulfillOrder called with:");
const args = avataxOrderFulfilledMaps.mapPayload({ order, config: this.config });
this.logger.debug({ args }, "will call commitTransaction with"); const adapter = new AvataxOrderFulfilledAdapter(this.config);
const result = await this.client.commitTransaction(args);
this.logger.debug({ result }, "fulfillOrder response"); const response = await adapter.send({ order });
return { ok: true };
this.logger.debug({ response }, "fulfillOrder response:");
return response;
} }
} }

View file

@ -0,0 +1,45 @@
import { TaxBaseFragment } from "../../../../generated/graphql";
import { Logger, createLogger } from "../../../lib/logger";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-config";
import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
import { AvataxCalculateTaxesResponseTransformer } from "./avatax-calculate-taxes-response-transformer";
export const SHIPPING_ITEM_CODE = "Shipping";
export type Payload = {
taxBase: TaxBaseFragment;
channelConfig: ChannelConfig;
config: AvataxConfig;
};
export type Target = CreateTransactionArgs;
export type Response = CalculateTaxesResponse;
export class AvataxCalculateTaxesAdapter implements WebhookAdapter<Payload, Response> {
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ service: "AvataxCalculateTaxesAdapter" });
}
async send(payload: Pick<Payload, "channelConfig" | "taxBase">): Promise<Response> {
this.logger.debug({ payload }, "send called with:");
const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer();
const target = payloadTransformer.transform({ ...payload, config: this.config });
const client = new AvataxClient(this.config);
const response = await client.createTransaction(target);
this.logger.debug({ response }, "Avatax createTransaction response:");
const responseTransformer = new AvataxCalculateTaxesResponseTransformer();
const transformedResponse = responseTransformer.transform(response);
this.logger.debug({ transformedResponse }, "Transformed Avatax createTransaction response to:");
return transformedResponse;
}
}

View file

@ -0,0 +1,992 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { DocumentStatus } from "avatax/lib/enums/DocumentStatus";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { AdjustmentReason } from "avatax/lib/enums/AdjustmentReason";
import { JurisTypeId } from "avatax/lib/enums/JurisTypeId";
import { LiabilityType } from "avatax/lib/enums/LiabilityType";
import { RateType } from "avatax/lib/enums/RateType";
import { ChargedTo } from "avatax/lib/enums/ChargedTo";
import { JurisdictionType } from "avatax/lib/enums/JurisdictionType";
import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel";
import { AvataxConfig } from "../avatax-config";
type TaxBase = TaxBaseFragment;
const defaultTaxBase: TaxBase = {
pricesEnteredWithTax: true,
currency: "USD",
channel: {
slug: "default-channel",
},
discounts: [],
address: {
streetAddress1: "600 Montgomery St",
streetAddress2: "",
city: "SAN FRANCISCO",
countryArea: "CA",
postalCode: "94111",
country: {
code: "US",
},
},
shippingPrice: {
amount: 48.33,
},
lines: [
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjNmMjYwZmMyLTZjN2UtNGM5Ni1iYTMwLTEyMjAyODMzOTUyZA==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 3,
unitPrice: {
amount: 20,
},
totalPrice: {
amount: 60,
},
},
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjNlNGZjODdkLTIyMmEtNDZiYi1iYzIzLWJiYWVkODVlOTQ4Mg==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzUw",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 1,
unitPrice: {
amount: 20,
},
totalPrice: {
amount: 20,
},
},
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOmM2NTBhMzVkLWQ1YjQtNGRhNy1hMjNjLWEzODU4ZDE1MzI2Mw==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 2,
unitPrice: {
amount: 50,
},
totalPrice: {
amount: 100,
},
},
],
sourceObject: {
user: {
id: "VXNlcjoyMDg0NTEwNDEw",
},
},
};
const defaultChannelConfig: ChannelConfig = {
providerInstanceId: "b8c29f49-7cae-4762-8458-e9a27eb83081",
enabled: false,
address: {
country: "US",
zip: "92093",
state: "CA",
city: "La Jolla",
street: "9500 Gilman Drive",
},
};
const defaultTransactionModel: TransactionModel = {
id: 0,
code: "aec372bb-f3b3-40fb-9d84-2b46cd67e516",
companyId: 7799660,
date: new Date("2023-05-25"),
paymentDate: new Date("2023-05-25"),
status: DocumentStatus.Temporary,
type: DocumentType.SalesOrder,
batchCode: "",
currencyCode: "USD",
exchangeRateCurrencyCode: "USD",
customerUsageType: "",
entityUseCode: "",
customerVendorCode: "VXNlcjoyMDg0NTEwNDEw",
customerCode: "VXNlcjoyMDg0NTEwNDEw",
exemptNo: "",
reconciled: false,
locationCode: "",
reportingLocationCode: "",
purchaseOrderNo: "",
referenceCode: "",
salespersonCode: "",
totalAmount: 137.34,
totalExempt: 0,
totalDiscount: 0,
totalTax: 11.83,
totalTaxable: 137.34,
totalTaxCalculated: 11.83,
adjustmentReason: AdjustmentReason.NotAdjusted,
locked: false,
version: 1,
exchangeRateEffectiveDate: new Date("2023-05-25"),
exchangeRate: 1,
modifiedDate: new Date("2023-05-25T10:23:15.317354Z"),
modifiedUserId: 6479978,
taxDate: new Date("2023-05-25"),
lines: [
{
id: 0,
transactionId: 0,
lineNumber: "1",
customerUsageType: "",
entityUseCode: "",
discountAmount: 0,
exemptAmount: 0,
exemptCertId: 0,
exemptNo: "",
isItemTaxable: true,
itemCode: "",
lineAmount: 18.42,
quantity: 1,
ref1: "",
ref2: "",
reportingDate: new Date("2023-05-25"),
tax: 1.58,
taxableAmount: 18.42,
taxCalculated: 1.58,
taxCode: "P0000000",
taxCodeId: 8087,
taxDate: new Date("2023-05-25"),
taxIncluded: true,
details: [
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "06",
jurisName: "CALIFORNIA",
stateAssignedNo: "",
jurisType: JurisTypeId.STA,
jurisdictionType: JurisdictionType.State,
nonTaxableAmount: 0,
rate: 0.06,
tax: 1.1,
taxableAmount: 18.42,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA STATE TAX",
taxAuthorityTypeId: 45,
taxCalculated: 1.1,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 18.42,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 1.1,
reportingTaxCalculated: 1.1,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "085",
jurisName: "SANTA CLARA",
stateAssignedNo: "",
jurisType: JurisTypeId.STA,
jurisdictionType: JurisdictionType.County,
nonTaxableAmount: 0,
rate: 0.0025,
tax: 0.05,
taxableAmount: 18.42,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA COUNTY TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.05,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 18.42,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.05,
reportingTaxCalculated: 0.05,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMBE0",
jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP",
stateAssignedNo: "940",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01375,
tax: 0.25,
taxableAmount: 18.42,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.25,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 18.42,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.25,
reportingTaxCalculated: 0.25,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMUA0",
jurisName: "SANTA CLARA CO LOCAL TAX SL",
stateAssignedNo: "43",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01,
tax: 0.18,
taxableAmount: 18.42,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.18,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 18.42,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.18,
reportingTaxCalculated: 0.18,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
],
nonPassthroughDetails: [],
hsCode: "",
costInsuranceFreight: 0,
vatCode: "",
vatNumberTypeId: 0,
},
{
id: 0,
transactionId: 0,
lineNumber: "2",
customerUsageType: "",
entityUseCode: "",
discountAmount: 0,
exemptAmount: 0,
exemptCertId: 0,
exemptNo: "",
isItemTaxable: true,
itemCode: "",
lineAmount: 18.42,
quantity: 1,
ref1: "",
ref2: "",
reportingDate: new Date("2023-05-25"),
tax: 1.58,
taxableAmount: 18.42,
taxCalculated: 1.58,
taxCode: "P0000000",
taxCodeId: 8087,
taxDate: new Date("2023-05-25"),
taxIncluded: true,
details: [
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "06",
jurisName: "CALIFORNIA",
stateAssignedNo: "",
jurisType: JurisTypeId.STA,
jurisdictionType: JurisdictionType.State,
nonTaxableAmount: 0,
rate: 0.06,
tax: 1.1,
taxableAmount: 18.42,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA STATE TAX",
taxAuthorityTypeId: 45,
taxCalculated: 1.1,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 18.42,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 1.1,
reportingTaxCalculated: 1.1,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "085",
jurisName: "SANTA CLARA",
stateAssignedNo: "",
jurisType: JurisTypeId.CTY,
jurisdictionType: JurisdictionType.County,
nonTaxableAmount: 0,
rate: 0.0025,
tax: 0.05,
taxableAmount: 18.42,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA COUNTY TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.05,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 18.42,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.05,
reportingTaxCalculated: 0.05,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMBE0",
jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP",
stateAssignedNo: "940",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01375,
tax: 0.25,
taxableAmount: 18.42,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.25,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 18.42,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.25,
reportingTaxCalculated: 0.25,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMUA0",
jurisName: "SANTA CLARA CO LOCAL TAX SL",
stateAssignedNo: "43",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01,
tax: 0.18,
taxableAmount: 18.42,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.18,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 18.42,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.18,
reportingTaxCalculated: 0.18,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
],
nonPassthroughDetails: [],
hsCode: "",
costInsuranceFreight: 0,
vatCode: "",
vatNumberTypeId: 0,
},
{
id: 0,
transactionId: 0,
lineNumber: "3",
customerUsageType: "",
entityUseCode: "",
discountAmount: 0,
exemptAmount: 0,
exemptCertId: 0,
exemptNo: "",
isItemTaxable: true,
itemCode: "",
lineAmount: 46.03,
quantity: 1,
ref1: "",
ref2: "",
reportingDate: new Date("2023-05-25"),
tax: 3.97,
taxableAmount: 46.03,
taxCalculated: 3.97,
taxCode: "P0000000",
taxCodeId: 8087,
taxDate: new Date("2023-05-25"),
taxIncluded: true,
details: [
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "06",
jurisName: "CALIFORNIA",
stateAssignedNo: "",
jurisType: JurisTypeId.STA,
jurisdictionType: JurisdictionType.State,
nonTaxableAmount: 0,
rate: 0.06,
tax: 2.76,
taxableAmount: 46.03,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA STATE TAX",
taxAuthorityTypeId: 45,
taxCalculated: 2.76,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 46.03,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 2.76,
reportingTaxCalculated: 2.76,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "085",
jurisName: "SANTA CLARA",
stateAssignedNo: "",
jurisType: JurisTypeId.CTY,
jurisdictionType: JurisdictionType.County,
nonTaxableAmount: 0,
rate: 0.0025,
tax: 0.12,
taxableAmount: 46.03,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA COUNTY TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.12,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 46.03,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.12,
reportingTaxCalculated: 0.12,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMBE0",
jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP",
stateAssignedNo: "940",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01375,
tax: 0.63,
taxableAmount: 46.03,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.63,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 46.03,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.63,
reportingTaxCalculated: 0.63,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMUA0",
jurisName: "SANTA CLARA CO LOCAL TAX SL",
stateAssignedNo: "43",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01,
tax: 0.46,
taxableAmount: 46.03,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.46,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 46.03,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.46,
reportingTaxCalculated: 0.46,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
],
nonPassthroughDetails: [],
hsCode: "",
costInsuranceFreight: 0,
vatCode: "",
vatNumberTypeId: 0,
},
{
id: 0,
transactionId: 0,
lineNumber: "4",
customerUsageType: "",
entityUseCode: "",
discountAmount: 0,
exemptAmount: 0,
exemptCertId: 0,
exemptNo: "",
isItemTaxable: true,
itemCode: "Shipping",
lineAmount: 54.47,
quantity: 1,
ref1: "",
ref2: "",
reportingDate: new Date("2023-05-25"),
tax: 4.7,
taxableAmount: 54.47,
taxCalculated: 4.7,
taxCode: "P0000000",
taxCodeId: 8087,
taxDate: new Date("2023-05-25"),
taxIncluded: true,
details: [
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "06",
jurisName: "CALIFORNIA",
stateAssignedNo: "",
jurisType: JurisTypeId.STA,
jurisdictionType: JurisdictionType.State,
nonTaxableAmount: 0,
rate: 0.06,
tax: 3.27,
taxableAmount: 54.47,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA STATE TAX",
taxAuthorityTypeId: 45,
taxCalculated: 3.27,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 54.47,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 3.27,
reportingTaxCalculated: 3.27,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "085",
jurisName: "SANTA CLARA",
stateAssignedNo: "",
jurisType: JurisTypeId.CTY,
jurisdictionType: JurisdictionType.County,
nonTaxableAmount: 0,
rate: 0.0025,
tax: 0.14,
taxableAmount: 54.47,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA COUNTY TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.14,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 54.47,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.14,
reportingTaxCalculated: 0.14,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMBE0",
jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP",
stateAssignedNo: "940",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01375,
tax: 0.75,
taxableAmount: 54.47,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.75,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 54.47,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.75,
reportingTaxCalculated: 0.75,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMUA0",
jurisName: "SANTA CLARA CO LOCAL TAX SL",
stateAssignedNo: "43",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01,
tax: 0.54,
taxableAmount: 54.47,
taxType: "Sales",
taxSubTypeId: "S",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.54,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 54.47,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.54,
reportingTaxCalculated: 0.54,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
],
nonPassthroughDetails: [],
hsCode: "",
costInsuranceFreight: 0,
vatCode: "",
vatNumberTypeId: 0,
},
],
addresses: [
{
id: 0,
transactionId: 0,
boundaryLevel: BoundaryLevel.Address,
line1: "600 Montgomery St",
line2: "",
line3: "",
city: "SAN FRANCISCO",
region: "CA",
postalCode: "94111",
country: "US",
taxRegionId: 4024330,
latitude: "37.795255",
longitude: "-122.40313",
},
{
id: 0,
transactionId: 0,
boundaryLevel: BoundaryLevel.Address,
line1: "33 N. First Street",
line2: "",
line3: "",
city: "Campbell",
region: "CA",
postalCode: "95008",
country: "US",
taxRegionId: 2128577,
latitude: "37.287589",
longitude: "-121.944955",
},
],
summary: [
{
country: "US",
region: "CA",
jurisType: JurisdictionType.Special,
jurisCode: "06",
jurisName: "CALIFORNIA",
taxAuthorityType: 45,
stateAssignedNo: "",
taxType: "Sales",
taxSubType: "S",
taxName: "CA STATE TAX",
rateType: RateType.General,
taxable: 137.34,
rate: 0.06,
tax: 8.23,
taxCalculated: 8.23,
nonTaxable: 0,
exemption: 0,
},
{
country: "US",
region: "CA",
jurisType: JurisdictionType.County,
jurisCode: "085",
jurisName: "SANTA CLARA",
taxAuthorityType: 45,
stateAssignedNo: "",
taxType: "Sales",
taxSubType: "S",
taxName: "CA COUNTY TAX",
rateType: RateType.General,
taxable: 137.34,
rate: 0.0025,
tax: 0.36,
taxCalculated: 0.36,
nonTaxable: 0,
exemption: 0,
},
{
country: "US",
region: "CA",
jurisType: JurisdictionType.County,
jurisCode: "EMBE0",
jurisName: "SAN FRANCISCO COUNTY DISTRICT TAX SP",
taxAuthorityType: 45,
stateAssignedNo: "940",
taxType: "Sales",
taxSubType: "S",
taxName: "CA SPECIAL TAX",
rateType: RateType.General,
taxable: 137.34,
rate: 0.01375,
tax: 1.88,
taxCalculated: 1.88,
nonTaxable: 0,
exemption: 0,
},
{
country: "US",
region: "CA",
jurisType: JurisdictionType.Special,
jurisCode: "EMUA0",
jurisName: "SANTA CLARA CO LOCAL TAX SL",
taxAuthorityType: 45,
stateAssignedNo: "43",
taxType: "Sales",
taxSubType: "S",
taxName: "CA SPECIAL TAX",
rateType: RateType.General,
taxable: 137.34,
rate: 0.01,
tax: 1.36,
taxCalculated: 1.36,
nonTaxable: 0,
exemption: 0,
},
],
};
const defaultAvataxConfig: AvataxConfig = {
companyCode: "DEFAULT",
isAutocommit: false,
isSandbox: true,
name: "Avatax-1",
password: "password",
username: "username",
shippingTaxCode: "FR000000",
};
const testingScenariosMap = {
default: {
taxBase: defaultTaxBase,
channelConfig: defaultChannelConfig,
avataxConfig: defaultAvataxConfig,
response: defaultTransactionModel,
},
};
type TestingScenario = keyof typeof testingScenariosMap;
export class AvataxCalculateTaxesMockGenerator {
constructor(private scenario: TestingScenario = "default") {}
generateTaxBase = (overrides: Partial<TaxBase> = {}): TaxBase =>
structuredClone({
...testingScenariosMap[this.scenario].taxBase,
...overrides,
});
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig =>
structuredClone({
...testingScenariosMap[this.scenario].channelConfig,
...overrides,
});
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig =>
structuredClone({
...testingScenariosMap[this.scenario].avataxConfig,
...overrides,
});
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
structuredClone({
...testingScenariosMap[this.scenario].response,
...overrides,
});
}

View file

@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import { AvataxCalculateTaxesMockGenerator } from "./avatax-calculate-taxes-mock-generator";
import {
AvataxCalculateTaxesPayloadTransformer,
mapPayloadLines,
} from "./avatax-calculate-taxes-payload-transformer";
describe("AvataxCalculateTaxesPayloadTransformer", () => {
it("when discounts, calculates the sum of discounts", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] });
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
taxBase: taxBaseMock,
channelConfig: mockGenerator.generateChannelConfig(),
config: avataxConfigMock,
});
expect(payload.model.discount).toEqual(10);
});
it("when no discounts, the sum of discount is 0", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase();
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
taxBase: taxBaseMock,
channelConfig: mockGenerator.generateChannelConfig(),
config: avataxConfigMock,
});
expect(payload.model.discount).toEqual(0);
});
});
describe("mapPayloadLines", () => {
it("map lines and adds shipping as line", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase();
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 48.33,
itemCode: "Shipping",
quantity: 1,
taxCode: "FR000000",
taxIncluded: true,
discounted: false,
},
]);
});
it("when no shipping in tax base, does not add shipping as line", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase({ shippingPrice: { amount: 0 } });
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: false,
},
]);
});
it("when discounts, sets discounted to true", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] });
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "",
taxIncluded: true,
discounted: true,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: true,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: true,
},
{
amount: 48.33,
discounted: true,
itemCode: "Shipping",
quantity: 1,
taxCode: "FR000000",
taxIncluded: true,
},
]);
});
});

View file

@ -0,0 +1,61 @@
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { AvataxConfig } from "../avatax-config";
import { avataxAddressFactory } from "../address-factory";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { SHIPPING_ITEM_CODE, Payload, Target } from "./avatax-calculate-taxes-adapter";
import { discountUtils } from "../../taxes/discount-utils";
export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig): LineItemModel[] {
const isDiscounted = taxBase.discounts.length > 0;
const productLines: LineItemModel[] = taxBase.lines.map((line) => ({
amount: line.totalPrice.amount,
taxIncluded: taxBase.pricesEnteredWithTax,
// todo: get from tax code matcher
taxCode: "",
quantity: line.quantity,
discounted: isDiscounted,
}));
if (taxBase.shippingPrice.amount !== 0) {
// * In Avatax, shipping is a regular line
const shippingLine: LineItemModel = {
amount: taxBase.shippingPrice.amount,
itemCode: SHIPPING_ITEM_CODE,
taxCode: config.shippingTaxCode,
quantity: 1,
taxIncluded: taxBase.pricesEnteredWithTax,
discounted: isDiscounted,
};
return [...productLines, shippingLine];
}
return productLines;
}
export class AvataxCalculateTaxesPayloadTransformer {
transform(props: Payload): Target {
const { taxBase, channelConfig, 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(channelConfig.address),
shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!),
},
currencyCode: taxBase.currency,
lines: mapPayloadLines(taxBase, config),
date: new Date(),
discount: discountUtils.sumDiscounts(
taxBase.discounts.map((discount) => discount.amount.amount)
),
},
};
}
}

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { avataxMockFactory } from "../avatax-mock-factory";
import { AvataxCalculateTaxesResponseLinesTransformer } from "./avatax-calculate-taxes-response-lines-transformer";
const transformer = new AvataxCalculateTaxesResponseLinesTransformer();
const NON_TAXABLE_TRANSACTION_MOCK = avataxMockFactory.createMockTransaction("nonTaxable");
const TAX_INCLUDED_TRANSACTION_MOCK =
avataxMockFactory.createMockTransaction("taxIncludedShipping");
const TAX_EXCLUDED_TRANSACTION_MOCK =
avataxMockFactory.createMockTransaction("taxExcludedShipping");
describe("AvataxCalculateTaxesResponseLinesTransformer", () => {
it("when product lines are not taxable, returns line amount", () => {
const nonTaxableProductLines = transformer.transform(NON_TAXABLE_TRANSACTION_MOCK);
expect(nonTaxableProductLines).toEqual([
{
total_gross_amount: 20,
total_net_amount: 20,
tax_rate: 0,
},
]);
});
it("when product lines are taxable and tax is included, returns calculated gross & net amounts", () => {
const taxableProductLines = transformer.transform(TAX_INCLUDED_TRANSACTION_MOCK);
expect(taxableProductLines).toEqual([
{
total_gross_amount: 40,
total_net_amount: 36.53,
tax_rate: 0,
},
{
total_gross_amount: 40,
total_net_amount: 36.53,
tax_rate: 0,
},
]);
});
it("when product lines are taxable and tax is not included, returns calculated gross & net amounts", () => {
const taxableProductLines = transformer.transform(TAX_EXCLUDED_TRANSACTION_MOCK);
expect(taxableProductLines).toEqual([
{
total_gross_amount: 43.8,
total_net_amount: 40,
tax_rate: 0,
},
{
total_gross_amount: 43.8,
total_net_amount: 40,
tax_rate: 0,
},
]);
});
});

View file

@ -0,0 +1,49 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { numbers } from "../../taxes/numbers";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { Response, SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
export class AvataxCalculateTaxesResponseLinesTransformer {
transform(transaction: TransactionModel): Response["lines"] {
const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE);
return (
productLines?.map((line) => {
if (!line.isItemTaxable) {
return {
total_gross_amount: taxProviderUtils.resolveOptionalOrThrow(
line.lineAmount,
new Error("line.lineAmount is undefined")
),
total_net_amount: taxProviderUtils.resolveOptionalOrThrow(
line.lineAmount,
new Error("line.lineAmount is undefined")
),
tax_rate: 0,
};
}
const lineTaxCalculated = taxProviderUtils.resolveOptionalOrThrow(
line.taxCalculated,
new Error("line.taxCalculated is undefined")
);
const lineTotalNetAmount = taxProviderUtils.resolveOptionalOrThrow(
line.taxableAmount,
new Error("line.taxableAmount is undefined")
);
const lineTotalGrossAmount = numbers.roundFloatToTwoDecimals(
lineTotalNetAmount + lineTaxCalculated
);
return {
total_gross_amount: lineTotalGrossAmount,
total_net_amount: lineTotalNetAmount,
/*
* avatax doesnt return combined tax rate
* // todo: calculate percentage tax rate
*/ tax_rate: 0,
};
}) ?? []
);
}
}

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { avataxMockFactory } from "../avatax-mock-factory";
import { AvataxCalculateTaxesResponseShippingTransformer } from "./avatax-calculate-taxes-response-shipping-transformer";
const transformer = new AvataxCalculateTaxesResponseShippingTransformer();
const TAX_EXCLUDED_NO_SHIPPING_TRANSACTION_MOCK =
avataxMockFactory.createMockTransaction("taxExcludedNoShipping");
const NON_TAXABLE_TRANSACTION_MOCK = avataxMockFactory.createMockTransaction("nonTaxable");
const TAX_INCLUDED_SHIPPING_TRANSACTION_MOCK =
avataxMockFactory.createMockTransaction("taxIncludedShipping");
const TAX_EXCLUDED_SHIPPING_TRANSACTION_MOCK =
avataxMockFactory.createMockTransaction("taxExcludedShipping");
describe("AvataxCalculateTaxesResponseShippingTransformer", () => {
it("when shipping line is not present, returns 0s", () => {
const shippingLine = transformer.transform(TAX_EXCLUDED_NO_SHIPPING_TRANSACTION_MOCK);
expect(shippingLine).toEqual({
shipping_price_gross_amount: 0,
shipping_price_net_amount: 0,
shipping_tax_rate: 0,
});
});
it("when shipping line is not taxable, returns line amount", () => {
const nonTaxableShippingLine = transformer.transform(NON_TAXABLE_TRANSACTION_MOCK);
expect(nonTaxableShippingLine).toEqual({
shipping_price_gross_amount: 77.51,
shipping_price_net_amount: 77.51,
shipping_tax_rate: 0,
});
});
it("when shipping line is taxable and tax is included, returns calculated gross & net amounts", () => {
const taxableShippingLine = transformer.transform(TAX_INCLUDED_SHIPPING_TRANSACTION_MOCK);
expect(taxableShippingLine).toEqual({
shipping_price_gross_amount: 77.51,
shipping_price_net_amount: 70.78,
shipping_tax_rate: 0,
});
});
it("when shipping line is taxable and tax is not included, returns calculated gross & net amounts", () => {
const taxableShippingLine = transformer.transform(TAX_EXCLUDED_SHIPPING_TRANSACTION_MOCK);
expect(taxableShippingLine).toEqual({
shipping_price_gross_amount: 84.87,
shipping_price_net_amount: 77.51,
shipping_tax_rate: 0,
});
});
});

View file

@ -0,0 +1,59 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { numbers } from "../../taxes/numbers";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { Response, SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
export class AvataxCalculateTaxesResponseShippingTransformer {
transform(
transaction: TransactionModel
): Pick<
Response,
"shipping_price_gross_amount" | "shipping_price_net_amount" | "shipping_tax_rate"
> {
const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE);
if (!shippingLine) {
return {
shipping_price_gross_amount: 0,
shipping_price_net_amount: 0,
shipping_tax_rate: 0,
};
}
if (!shippingLine.isItemTaxable) {
return {
shipping_price_gross_amount: taxProviderUtils.resolveOptionalOrThrow(
shippingLine.lineAmount,
new Error("shippingLine.lineAmount is undefined")
),
shipping_price_net_amount: taxProviderUtils.resolveOptionalOrThrow(
shippingLine.lineAmount,
new Error("shippingLine.lineAmount is undefined")
),
/*
* avatax doesnt return combined tax rate
* // todo: calculate percentage tax rate
*/
shipping_tax_rate: 0,
};
}
const shippingTaxCalculated = taxProviderUtils.resolveOptionalOrThrow(
shippingLine.taxCalculated,
new Error("shippingLine.taxCalculated is undefined")
);
const shippingTaxableAmount = taxProviderUtils.resolveOptionalOrThrow(
shippingLine.taxableAmount,
new Error("shippingLine.taxableAmount is undefined")
);
const shippingGrossAmount = numbers.roundFloatToTwoDecimals(
shippingTaxableAmount + shippingTaxCalculated
);
return {
shipping_price_gross_amount: shippingGrossAmount,
shipping_price_net_amount: shippingTaxableAmount,
shipping_tax_rate: 0,
};
}
}

View file

@ -0,0 +1,19 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { Response } from "./avatax-calculate-taxes-adapter";
import { AvataxCalculateTaxesResponseLinesTransformer } from "./avatax-calculate-taxes-response-lines-transformer";
import { AvataxCalculateTaxesResponseShippingTransformer } from "./avatax-calculate-taxes-response-shipping-transformer";
export class AvataxCalculateTaxesResponseTransformer {
transform(response: TransactionModel): Response {
const shippingTransformer = new AvataxCalculateTaxesResponseShippingTransformer();
const shipping = shippingTransformer.transform(response);
const linesTransformer = new AvataxCalculateTaxesResponseLinesTransformer();
const lines = linesTransformer.transform(response);
return {
...shipping,
lines,
};
}
}

View file

@ -1,126 +0,0 @@
import { describe, expect, it } from "vitest";
import {
SHIPPING_ITEM_CODE,
mapPayloadLines,
mapResponseProductLines,
mapResponseShippingLine,
} from "./avatax-calculate-taxes-map";
import { mapPayloadArgsMocks, transactionModelMocks } from "./mocks";
describe("avataxCalculateTaxesMaps", () => {
describe("mapResponseShippingLine", () => {
it("when shipping line is not present, returns 0s", () => {
const shippingLine = mapResponseShippingLine(transactionModelMocks.noShippingLine);
expect(shippingLine).toEqual({
shipping_price_gross_amount: 0,
shipping_price_net_amount: 0,
shipping_tax_rate: 0,
});
});
it("when shipping line is not taxable, returns line amount", () => {
const nonTaxableShippingLine = mapResponseShippingLine(transactionModelMocks.nonTaxable);
expect(nonTaxableShippingLine).toEqual({
shipping_price_gross_amount: 77.51,
shipping_price_net_amount: 77.51,
shipping_tax_rate: 0,
});
});
it("when shipping line is taxable and tax is included, returns calculated gross & net amounts", () => {
const taxableShippingLine = mapResponseShippingLine(
transactionModelMocks.taxable.taxIncluded
);
expect(taxableShippingLine).toEqual({
shipping_price_gross_amount: 77.51,
shipping_price_net_amount: 70.78,
shipping_tax_rate: 0,
});
});
it("when shipping line is taxable and tax is not included, returns calculated gross & net amounts", () => {
const taxableShippingLine = mapResponseShippingLine(
transactionModelMocks.taxable.taxNotIncluded
);
expect(taxableShippingLine).toEqual({
shipping_price_gross_amount: 84.87,
shipping_price_net_amount: 77.51,
shipping_tax_rate: 0,
});
});
});
describe("mapResponseProductLines", () => {
it("when product lines are not taxable, returns line amount", () => {
const nonTaxableProductLines = mapResponseProductLines(transactionModelMocks.nonTaxable);
expect(nonTaxableProductLines).toEqual([
{
total_gross_amount: 20,
total_net_amount: 20,
tax_rate: 0,
},
]);
});
it("when product lines are taxable and tax is included, returns calculated gross & net amounts", () => {
const taxableProductLines = mapResponseProductLines(
transactionModelMocks.taxable.taxIncluded
);
expect(taxableProductLines).toEqual([
{
total_gross_amount: 40,
total_net_amount: 36.53,
tax_rate: 0,
},
]);
});
it("when product lines are taxable and tax is not included, returns calculated gross & net amounts", () => {
const taxableProductLines = mapResponseProductLines(
transactionModelMocks.taxable.taxNotIncluded
);
expect(taxableProductLines).toEqual([
{
total_gross_amount: 43.8,
total_net_amount: 40,
tax_rate: 0,
},
]);
});
});
describe.todo("mapPayload", () => {
it.todo("calculation of fields");
it.todo("formatting the fields");
it.todo("rounding of numbers");
});
describe("mapLines", () => {
const lines = mapPayloadLines(
mapPayloadArgsMocks.default.taxBase,
mapPayloadArgsMocks.default.config
);
it("includes shipping as a line", () => {
expect(lines).toContainEqual({
itemCode: SHIPPING_ITEM_CODE,
quantity: 1,
amount: 48.33,
taxCode: mapPayloadArgsMocks.default.config.shippingTaxCode,
taxIncluded: false,
});
});
it("returns the correct quantity of individual lines", () => {
expect(lines).toContainEqual({
quantity: 3,
amount: 252,
taxCode: "",
taxIncluded: false,
});
});
});
});

View file

@ -1,182 +0,0 @@
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 { 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 { taxProviderUtils } from "../../taxes/tax-provider-utils";
/**
* * Shipping is a regular line item in Avatax
* https://developer.avalara.com/avatax/dev-guide/shipping-and-handling/taxability-of-shipping-charges/
*/
export const SHIPPING_ITEM_CODE = "Shipping";
export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig): LineItemModel[] {
const productLines = taxBase.lines.map((line) => ({
amount: line.totalPrice.amount,
taxIncluded: taxBase.pricesEnteredWithTax,
// todo: get from tax code matcher
taxCode: "",
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,
taxCode: config.shippingTaxCode,
quantity: 1,
taxIncluded: taxBase.pricesEnteredWithTax,
};
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: mapPayloadLines(taxBase, config),
date: new Date(),
},
};
};
export function mapResponseShippingLine(
transaction: TransactionModel
): Pick<
CalculateTaxesResponse,
"shipping_price_gross_amount" | "shipping_price_net_amount" | "shipping_tax_rate"
> {
const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE);
if (!shippingLine) {
return {
shipping_price_gross_amount: 0,
shipping_price_net_amount: 0,
shipping_tax_rate: 0,
};
}
if (!shippingLine.isItemTaxable) {
return {
shipping_price_gross_amount: taxProviderUtils.resolveOptionalOrThrow(
shippingLine.lineAmount,
new Error("shippingLine.lineAmount is undefined")
),
shipping_price_net_amount: taxProviderUtils.resolveOptionalOrThrow(
shippingLine.lineAmount,
new Error("shippingLine.lineAmount is undefined")
),
/*
* avatax doesnt return combined tax rate
* // todo: calculate percentage tax rate
*/
shipping_tax_rate: 0,
};
}
const shippingTaxCalculated = taxProviderUtils.resolveOptionalOrThrow(
shippingLine.taxCalculated,
new Error("shippingLine.taxCalculated is undefined")
);
const shippingTaxableAmount = taxProviderUtils.resolveOptionalOrThrow(
shippingLine.taxableAmount,
new Error("shippingLine.taxableAmount is undefined")
);
const shippingGrossAmount = numbers.roundFloatToTwoDecimals(
shippingTaxableAmount + shippingTaxCalculated
);
return {
shipping_price_gross_amount: shippingGrossAmount,
shipping_price_net_amount: shippingTaxableAmount,
shipping_tax_rate: 0,
};
}
export function mapResponseProductLines(
transaction: TransactionModel
): CalculateTaxesResponse["lines"] {
const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE);
return (
productLines?.map((line) => {
if (!line.isItemTaxable) {
return {
total_gross_amount: taxProviderUtils.resolveOptionalOrThrow(
line.lineAmount,
new Error("line.lineAmount is undefined")
),
total_net_amount: taxProviderUtils.resolveOptionalOrThrow(
line.lineAmount,
new Error("line.lineAmount is undefined")
),
tax_rate: 0,
};
}
const lineTaxCalculated = taxProviderUtils.resolveOptionalOrThrow(
line.taxCalculated,
new Error("line.taxCalculated is undefined")
);
const lineTotalNetAmount = taxProviderUtils.resolveOptionalOrThrow(
line.taxableAmount,
new Error("line.taxableAmount is undefined")
);
const lineTotalGrossAmount = numbers.roundFloatToTwoDecimals(
lineTotalNetAmount + lineTaxCalculated
);
return {
total_gross_amount: lineTotalGrossAmount,
total_net_amount: lineTotalNetAmount,
/*
* avatax doesnt return combined tax rate
* // todo: calculate percentage tax rate
*/ tax_rate: 0,
};
}) ?? []
);
}
const mapResponse = (transaction: TransactionModel): CalculateTaxesResponse => {
const shipping = mapResponseShippingLine(transaction);
return {
...shipping,
lines: mapResponseProductLines(transaction),
};
};
export const avataxCalculateTaxesMaps = {
mapPayload,
mapResponse,
};

View file

@ -1,200 +0,0 @@
import { describe, expect, it } from "vitest";
import { OrderStatus } from "../../../../generated/graphql";
import {
CreateTransactionMapPayloadArgs,
avataxOrderCreatedMaps,
} from "./avatax-order-created-map";
const MOCKED_ARGS: 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: 3,
unitPrice: {
net: {
amount: 90,
},
},
totalPrice: {
net: {
amount: 270,
},
tax: {
amount: 8.55,
},
},
},
{
productSku: "328223580",
productName: "Polyspace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 45,
},
},
totalPrice: {
net: {
amount: 45,
},
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",
shippingTaxCode: "FR000000",
},
};
describe("avataxOrderCreatedMaps", () => {
describe.todo("mapResponse", () => {
it.todo("calculation of fields");
it.todo("formatting the fields");
it.todo("rounding of numbers");
});
describe("mapPayload", () => {
it("returns lines with discounted: true when there are discounts", () => {
const payload = avataxOrderCreatedMaps.mapPayload(MOCKED_ARGS);
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_ARGS.order, MOCKED_ARGS.config);
it("returns the correct number of lines", () => {
expect(lines).toHaveLength(3);
});
it("includes shipping as a line", () => {
expect(lines).toContainEqual({
itemCode: avataxOrderCreatedMaps.consts.shippingItemCode,
taxCode: MOCKED_ARGS.config.shippingTaxCode,
quantity: 1,
amount: 48.33,
taxIncluded: true,
});
});
it("includes products as lines", () => {
const [first, second] = lines;
expect(first).toContain({
itemCode: "328223581",
description: "Monospace Tee",
quantity: 3,
amount: 278.55,
});
expect(second).toContain({
itemCode: "328223580",
description: "Polyspace Tee",
quantity: 1,
amount: 49.28,
});
});
});
describe("mapDiscounts", () => {
it("sums up all discounts", () => {
const discounts = avataxOrderCreatedMaps.mapDiscounts(MOCKED_ARGS.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

@ -1,104 +0,0 @@
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";
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(order: OrderCreatedSubscriptionFragment, config: AvataxConfig): LineItemModel[] {
const productLines: LineItemModel[] = order.lines.map((line) => ({
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;
}
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 ??
"" /* 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,
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, config),
date: new Date(order.created),
discount: mapDiscounts(order.discounts),
},
};
};
const mapResponse = (response: TransactionModel): CreateOrderResponse => {
return {
id: response.code ?? "",
};
};
export const avataxOrderCreatedMaps = {
mapPayload,
mapResponse,
mapLines,
mapDiscounts,
consts: {
shippingItemCode: SHIPPING_ITEM_CODE,
},
};

View file

@ -1,134 +0,0 @@
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";
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: {
net: {
amount: 90,
},
tax: {
amount: 8.55,
},
},
},
{
productSku: "328223580",
productName: "Polyspace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 45,
},
},
totalPrice: {
net: {
amount: 45,
},
tax: {
amount: 4.28,
},
},
},
],
},
config: {
companyCode: "DEFAULT",
isAutocommit: true,
isSandbox: true,
name: "Avatax-1",
password: "user-password",
username: "user-name",
shippingTaxCode: "FR000000",
},
};
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,
},
});
});
});
});

View file

@ -1,44 +0,0 @@
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
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"]
) {
const transactionCode = metadata.find((item) => item.key === PROVIDER_ORDER_ID_KEY);
if (!transactionCode) {
throw new Error("Transaction code not found");
}
return transactionCode.value;
}
export type CommitTransactionMapPayloadArgs = {
order: OrderFulfilledSubscriptionFragment;
config: AvataxConfig;
};
const mapPayload = ({ order, config }: CommitTransactionMapPayloadArgs): CommitTransactionArgs => {
const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata);
return {
transactionCode,
companyCode: config.companyCode ?? "",
documentType: DocumentType.SalesInvoice,
model: {
commit: true,
},
};
};
export const avataxOrderFulfilledMaps = {
mapPayload,
getTransactionCodeFromMetadata,
providerOrderIdKey: PROVIDER_ORDER_ID_KEY,
};

View file

@ -0,0 +1,6 @@
import { describe, it } from "vitest";
describe("AvataxOrderCreatedAdapter", () => {
// ? how to mock internal call to avatax?
it.todo("calls avatax client", () => {});
});

View file

@ -0,0 +1,44 @@
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-config";
import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer";
import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer";
import { Logger, createLogger } from "../../../lib/logger";
export type Payload = {
order: OrderCreatedSubscriptionFragment;
channelConfig: ChannelConfig;
config: AvataxConfig;
};
export type Target = CreateTransactionArgs;
type Response = CreateOrderResponse;
export class AvataxOrderCreatedAdapter implements WebhookAdapter<Payload, Response> {
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ service: "AvataxOrderCreatedAdapter" });
}
async send(payload: Pick<Payload, "channelConfig" | "order">): Promise<Response> {
this.logger.debug({ payload }, "send called with:");
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer();
const target = payloadTransformer.transform({ ...payload, config: this.config });
const client = new AvataxClient(this.config);
const response = await client.createTransaction(target);
this.logger.debug({ response }, "Avatax createTransaction response:");
const responseTransformer = new AvataxOrderCreatedResponseTransformer();
const transformedResponse = responseTransformer.transform(response);
this.logger.debug({ transformedResponse }, "Transformed Avatax createTransaction response to:");
return transformedResponse;
}
}

View file

@ -0,0 +1,70 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { orderCreatedTransactionMock } from "./avatax-order-created-response-transaction-mock";
import { AvataxConfig } from "../avatax-config";
import { defaultOrder } from "../../../mocks";
const defaultChannelConfig: ChannelConfig = {
providerInstanceId: "aa5293e5-7f5d-4782-a619-222ead918e50",
enabled: false,
address: {
country: "US",
zip: "95008",
state: "CA",
city: "Campbell",
street: "33 N. First Street",
},
};
const defaultOrderCreatedResponse: TransactionModel = orderCreatedTransactionMock;
const defaultAvataxConfig: AvataxConfig = {
companyCode: "DEFAULT",
isAutocommit: false,
isSandbox: true,
name: "Avatax-1",
password: "password",
username: "username",
shippingTaxCode: "FR000000",
};
const testingScenariosMap = {
default: {
order: defaultOrder,
channelConfig: defaultChannelConfig,
response: defaultOrderCreatedResponse,
avataxConfig: defaultAvataxConfig,
},
};
type TestingScenario = keyof typeof testingScenariosMap;
export class AvataxOrderCreatedMockGenerator {
constructor(private scenario: TestingScenario = "default") {}
generateOrder = (
overrides: Partial<OrderCreatedSubscriptionFragment> = {}
): OrderCreatedSubscriptionFragment =>
structuredClone({
...testingScenariosMap[this.scenario].order,
...overrides,
});
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig =>
structuredClone({
...testingScenariosMap[this.scenario].channelConfig,
...overrides,
});
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig =>
structuredClone({
...testingScenariosMap[this.scenario].avataxConfig,
...overrides,
});
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
structuredClone({
...testingScenariosMap[this.scenario].response,
...overrides,
});
}

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { AvataxOrderCreatedMockGenerator } from "./avatax-order-created-mock-generator";
import {
AvataxOrderCreatedPayloadTransformer,
mapLines,
} from "./avatax-order-created-payload-transformer";
const mockGenerator = new AvataxOrderCreatedMockGenerator();
const orderMock = mockGenerator.generateOrder();
const discountedOrderMock = mockGenerator.generateOrder({
discounts: [
{
amount: {
amount: 10,
},
id: "RGlzY291bnREaXNjb3VudDox",
},
],
});
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const channelConfigMock = mockGenerator.generateChannelConfig();
describe("AvataxOrderCreatedPayloadTransformer", () => {
it("returns lines with discounted: true when there are discounts", () => {
const transformer = new AvataxOrderCreatedPayloadTransformer();
const payloadMock = {
order: discountedOrderMock,
config: avataxConfigMock,
channelConfig: channelConfigMock,
};
const payload = transformer.transform(payloadMock);
const linesWithoutShipping = payload.model.lines.slice(0, -1);
const check = linesWithoutShipping.every((line) => line.discounted === true);
expect(check).toBe(true);
});
it("returns lines with discounted: false when there are no discounts", () => {
const transformer = new AvataxOrderCreatedPayloadTransformer();
const payloadMock = {
order: orderMock,
config: avataxConfigMock,
channelConfig: channelConfigMock,
};
const payload = transformer.transform(payloadMock);
const linesWithoutShipping = payload.model.lines.slice(0, -1);
const check = linesWithoutShipping.every((line) => line.discounted === false);
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

@ -0,0 +1,77 @@
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { numbers } from "../../taxes/numbers";
import { AvataxConfig } from "../avatax-config";
import { avataxAddressFactory } from "../address-factory";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { Payload, Target } from "./avatax-order-created-adapter";
import { discountUtils } from "../../taxes/discount-utils";
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 class AvataxOrderCreatedPayloadTransformer {
transform = ({ order, channelConfig, config }: Payload): Target => {
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: 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(channelConfig.address),
// billing or shipping address?
shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!),
},
currencyCode: order.total.currency,
email: order.user?.email ?? "",
lines: mapLines(order, config),
date: new Date(order.created),
discount: discountUtils.sumDiscounts(
order.discounts.map((discount) => discount.amount.amount)
),
},
};
};
}

View file

@ -0,0 +1,514 @@
import { AdjustmentReason } from "avatax/lib/enums/AdjustmentReason";
import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel";
import { ChargedTo } from "avatax/lib/enums/ChargedTo";
import { DocumentStatus } from "avatax/lib/enums/DocumentStatus";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { JurisTypeId } from "avatax/lib/enums/JurisTypeId";
import { JurisdictionType } from "avatax/lib/enums/JurisdictionType";
import { LiabilityType } from "avatax/lib/enums/LiabilityType";
import { RateType } from "avatax/lib/enums/RateType";
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { describe, expect, it } from "vitest";
import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer";
const MOCKED_TRANSACTION: TransactionModel = {
id: 0,
code: "8fc875ce-a929-4556-9f30-0165b1597d9f",
companyId: 7799640,
date: new Date(),
paymentDate: new Date(),
status: DocumentStatus.Temporary,
type: DocumentType.SalesOrder,
batchCode: "",
currencyCode: "USD",
exchangeRateCurrencyCode: "USD",
customerUsageType: "",
entityUseCode: "",
customerVendorCode: "VXNlcjoyMDg0NTEwNDEw",
customerCode: "VXNlcjoyMDg0NTEwNDEw",
exemptNo: "",
reconciled: false,
locationCode: "",
reportingLocationCode: "",
purchaseOrderNo: "",
referenceCode: "",
salespersonCode: "",
totalAmount: 107.31,
totalExempt: 0,
totalDiscount: 0,
totalTax: 10.2,
totalTaxable: 107.31,
totalTaxCalculated: 10.2,
adjustmentReason: AdjustmentReason.NotAdjusted,
locked: false,
version: 1,
exchangeRateEffectiveDate: new Date(),
exchangeRate: 1,
modifiedDate: new Date(),
modifiedUserId: 6479978,
taxDate: new Date(),
lines: [
{
id: 0,
transactionId: 0,
lineNumber: "1",
customerUsageType: "",
entityUseCode: "",
discountAmount: 0,
exemptAmount: 0,
exemptCertId: 0,
exemptNo: "",
isItemTaxable: true,
itemCode: "",
lineAmount: 36.53,
quantity: 2,
ref1: "",
ref2: "",
reportingDate: new Date(),
tax: 3.47,
taxableAmount: 36.53,
taxCalculated: 3.47,
taxCode: "P0000000",
taxCodeId: 8087,
taxDate: new Date(),
taxIncluded: true,
details: [
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "06",
jurisName: "CALIFORNIA",
stateAssignedNo: "",
jurisType: JurisTypeId.STA,
jurisdictionType: JurisdictionType.State,
nonTaxableAmount: 0,
rate: 0.06,
tax: 2.19,
taxableAmount: 36.53,
taxType: "Use",
taxSubTypeId: "U",
taxName: "CA STATE TAX",
taxAuthorityTypeId: 45,
taxCalculated: 2.19,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 36.53,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 2.19,
reportingTaxCalculated: 2.19,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "037",
jurisName: "LOS ANGELES",
stateAssignedNo: "",
jurisType: JurisTypeId.CTY,
jurisdictionType: JurisdictionType.County,
nonTaxableAmount: 0,
rate: 0.0025,
tax: 0.09,
taxableAmount: 36.53,
taxType: "Use",
taxSubTypeId: "U",
taxName: "CA COUNTY TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.09,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 36.53,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.09,
reportingTaxCalculated: 0.09,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMAR0",
jurisName: "LOS ANGELES COUNTY DISTRICT TAX SP",
stateAssignedNo: "594",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.0225,
tax: 0.82,
taxableAmount: 36.53,
taxType: "Use",
taxSubTypeId: "U",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.82,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 36.53,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.82,
reportingTaxCalculated: 0.82,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMTC0",
jurisName: "LOS ANGELES CO LOCAL TAX SL",
stateAssignedNo: "19",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01,
tax: 0.37,
taxableAmount: 36.53,
taxType: "Use",
taxSubTypeId: "U",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.37,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 36.53,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.37,
reportingTaxCalculated: 0.37,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
],
nonPassthroughDetails: [],
hsCode: "",
costInsuranceFreight: 0,
vatCode: "",
vatNumberTypeId: 0,
},
{
id: 0,
transactionId: 0,
lineNumber: "2",
customerUsageType: "",
entityUseCode: "",
discountAmount: 0,
exemptAmount: 0,
exemptCertId: 0,
exemptNo: "",
isItemTaxable: true,
itemCode: "Shipping",
lineAmount: 70.78,
quantity: 1,
ref1: "",
ref2: "",
reportingDate: new Date(),
tax: 6.73,
taxableAmount: 70.78,
taxCalculated: 6.73,
taxCode: "P0000000",
taxCodeId: 8087,
taxDate: new Date(),
taxIncluded: true,
details: [
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "06",
jurisName: "CALIFORNIA",
stateAssignedNo: "",
jurisType: JurisTypeId.STA,
jurisdictionType: JurisdictionType.State,
nonTaxableAmount: 0,
rate: 0.06,
tax: 4.25,
taxableAmount: 70.78,
taxType: "Use",
taxSubTypeId: "U",
taxName: "CA STATE TAX",
taxAuthorityTypeId: 45,
taxCalculated: 4.25,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 70.78,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 4.25,
reportingTaxCalculated: 4.25,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "037",
jurisName: "LOS ANGELES",
stateAssignedNo: "",
jurisType: JurisTypeId.CTY,
jurisdictionType: JurisdictionType.County,
nonTaxableAmount: 0,
rate: 0.0025,
tax: 0.18,
taxableAmount: 70.78,
taxType: "Use",
taxSubTypeId: "U",
taxName: "CA COUNTY TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.18,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 70.78,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.18,
reportingTaxCalculated: 0.18,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMAR0",
jurisName: "LOS ANGELES COUNTY DISTRICT TAX SP",
stateAssignedNo: "594",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.0225,
tax: 1.59,
taxableAmount: 70.78,
taxType: "Use",
taxSubTypeId: "U",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 1.59,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 70.78,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 1.59,
reportingTaxCalculated: 1.59,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
{
id: 0,
transactionLineId: 0,
transactionId: 0,
country: "US",
region: "CA",
exemptAmount: 0,
jurisCode: "EMTC0",
jurisName: "LOS ANGELES CO LOCAL TAX SL",
stateAssignedNo: "19",
jurisType: JurisTypeId.STJ,
jurisdictionType: JurisdictionType.Special,
nonTaxableAmount: 0,
rate: 0.01,
tax: 0.71,
taxableAmount: 70.78,
taxType: "Use",
taxSubTypeId: "U",
taxName: "CA SPECIAL TAX",
taxAuthorityTypeId: 45,
taxCalculated: 0.71,
rateType: RateType.General,
rateTypeCode: "G",
unitOfBasis: "PerCurrencyUnit",
isNonPassThru: false,
isFee: false,
reportingTaxableUnits: 70.78,
reportingNonTaxableUnits: 0,
reportingExemptUnits: 0,
reportingTax: 0.71,
reportingTaxCalculated: 0.71,
liabilityType: LiabilityType.Seller,
chargedTo: ChargedTo.Buyer,
},
],
nonPassthroughDetails: [],
hsCode: "",
costInsuranceFreight: 0,
vatCode: "",
vatNumberTypeId: 0,
},
],
addresses: [
{
id: 0,
transactionId: 0,
boundaryLevel: BoundaryLevel.Zip5,
line1: "123 Palm Grove Ln",
line2: "",
line3: "",
city: "LOS ANGELES",
region: "CA",
postalCode: "90002",
country: "US",
taxRegionId: 4017056,
latitude: "33.948712",
longitude: "-118.245951",
},
{
id: 0,
transactionId: 0,
boundaryLevel: BoundaryLevel.Zip5,
line1: "8559 Lake Avenue",
line2: "",
line3: "",
city: "New York",
region: "NY",
postalCode: "10001",
country: "US",
taxRegionId: 2088629,
latitude: "40.748481",
longitude: "-73.993125",
},
],
summary: [
{
country: "US",
region: "CA",
jurisType: JurisdictionType.State,
jurisCode: "06",
jurisName: "CALIFORNIA",
taxAuthorityType: 45,
stateAssignedNo: "",
taxType: "Use",
taxSubType: "U",
taxName: "CA STATE TAX",
rateType: RateType.General,
taxable: 107.31,
rate: 0.06,
tax: 6.44,
taxCalculated: 6.44,
nonTaxable: 0,
exemption: 0,
},
{
country: "US",
region: "CA",
jurisType: JurisdictionType.County,
jurisCode: "037",
jurisName: "LOS ANGELES",
taxAuthorityType: 45,
stateAssignedNo: "",
taxType: "Use",
taxSubType: "U",
taxName: "CA COUNTY TAX",
rateType: RateType.General,
taxable: 107.31,
rate: 0.0025,
tax: 0.27,
taxCalculated: 0.27,
nonTaxable: 0,
exemption: 0,
},
{
country: "US",
region: "CA",
jurisType: JurisdictionType.Special,
jurisCode: "EMTC0",
jurisName: "LOS ANGELES CO LOCAL TAX SL",
taxAuthorityType: 45,
stateAssignedNo: "19",
taxType: "Use",
taxSubType: "U",
taxName: "CA SPECIAL TAX",
rateType: RateType.General,
taxable: 107.31,
rate: 0.01,
tax: 1.08,
taxCalculated: 1.08,
nonTaxable: 0,
exemption: 0,
},
{
country: "US",
region: "CA",
jurisType: JurisdictionType.Special,
jurisCode: "EMAR0",
jurisName: "LOS ANGELES COUNTY DISTRICT TAX SP",
taxAuthorityType: 45,
stateAssignedNo: "594",
taxType: "Use",
taxSubType: "U",
taxName: "CA SPECIAL TAX",
rateType: RateType.General,
taxable: 107.31,
rate: 0.0225,
tax: 2.41,
taxCalculated: 2.41,
nonTaxable: 0,
exemption: 0,
},
],
};
describe("AvataxOrderCreatedResponseTransformer", () => {
it("returns orded id in response", () => {
const transformer = new AvataxOrderCreatedResponseTransformer();
const result = transformer.transform(MOCKED_TRANSACTION);
expect(result).toEqual({
id: "8fc875ce-a929-4556-9f30-0165b1597d9f",
});
});
it("throws an error when no transaction id is present", () => {
const transformer = new AvataxOrderCreatedResponseTransformer();
expect(() => transformer.transform({} as any)).toThrowError();
});
});

View file

@ -0,0 +1,16 @@
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
export class AvataxOrderCreatedResponseTransformer {
transform(response: TransactionModel): CreateOrderResponse {
return {
id: taxProviderUtils.resolveOptionalOrThrow(
response.code,
new Error(
"Could not update the order metadata with Avatax transaction code because it was not returned from the createTransaction mutation."
)
),
};
}
}

View file

@ -0,0 +1,41 @@
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
import { Logger, createLogger } from "../../../lib/logger";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient, CommitTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-config";
import { AvataxOrderFulfilledPayloadTransformer } from "./avatax-order-fulfilled-payload-transformer";
import { AvataxOrderFulfilledResponseTransformer } from "./avatax-order-fulfilled-response-transformer";
export type Payload = {
order: OrderFulfilledSubscriptionFragment;
config: AvataxConfig;
};
export type Target = CommitTransactionArgs;
export type Response = { ok: true };
export class AvataxOrderFulfilledAdapter implements WebhookAdapter<Payload, Response> {
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ service: "AvataxOrderFulfilledAdapter" });
}
async send(payload: Pick<Payload, "order">): Promise<Response> {
this.logger.debug({ payload }, "send called with:");
const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer();
const target = payloadTransformer.transform({ ...payload, config: this.config });
const client = new AvataxClient(this.config);
const response = await client.commitTransaction(target);
this.logger.debug({ response }, "Avatax commitTransaction response:");
const responseTransformer = new AvataxOrderFulfilledResponseTransformer();
const transformedResponse = responseTransformer.transform(response);
this.logger.debug({ transformedResponse }, "Transformed Avatax commitTransaction response to:");
return transformedResponse;
}
}

View file

@ -0,0 +1,144 @@
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { describe, expect, it } from "vitest";
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
import { AvataxConfig } from "../avatax-config";
import { Payload } from "./avatax-order-fulfilled-adapter";
import {
AvataxOrderFulfilledPayloadTransformer,
PROVIDER_ORDER_ID_KEY,
getTransactionCodeFromMetadata,
} from "./avatax-order-fulfilled-payload-transformer";
// todo: add AvataxOrderFulfilledMockGenerator
const MOCK_AVATAX_CONFIG: AvataxConfig = {
companyCode: "DEFAULT",
isAutocommit: false,
isSandbox: true,
name: "Avatax-1",
password: "password",
username: "username",
shippingTaxCode: "FR000000",
};
const MOCKED_METADATA: OrderFulfilledSubscriptionFragment["privateMetadata"] = [
{
key: PROVIDER_ORDER_ID_KEY,
value: "transaction-code",
},
];
type OrderFulfilled = OrderFulfilledSubscriptionFragment;
const ORDER_FULFILLED_MOCK: OrderFulfilled = {
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: {
net: {
amount: 90,
},
tax: {
amount: 8.55,
},
},
},
{
productSku: "328223580",
productName: "Polyspace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 45,
},
},
totalPrice: {
net: {
amount: 45,
},
tax: {
amount: 4.28,
},
},
},
],
};
describe("getTransactionCodeFromMetadata", () => {
it("returns transaction code", () => {
expect(getTransactionCodeFromMetadata(MOCKED_METADATA)).toBe("transaction-code");
});
it("throws error when transaction code not found", () => {
expect(() => getTransactionCodeFromMetadata([])).toThrowError();
});
});
const transformer = new AvataxOrderFulfilledPayloadTransformer();
const MOCKED_ORDER_FULFILLED_PAYLOAD: Payload = {
order: ORDER_FULFILLED_MOCK,
config: MOCK_AVATAX_CONFIG,
};
describe("AvataxOrderFulfilledPayloadTransformer", () => {
it("returns transformed payload", () => {
const mappedPayload = transformer.transform(MOCKED_ORDER_FULFILLED_PAYLOAD);
expect(mappedPayload).toEqual({
transactionCode: "transaction-code",
companyCode: "DEFAULT",
documentType: DocumentType.SalesInvoice,
model: {
commit: true,
},
});
});
});

View file

@ -0,0 +1,34 @@
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { Payload, Target } from "./avatax-order-fulfilled-adapter";
// * 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";
export 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;
}
export class AvataxOrderFulfilledPayloadTransformer {
transform({ order, config }: Payload): Target {
const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata);
return {
transactionCode,
companyCode: config.companyCode ?? "",
documentType: DocumentType.SalesInvoice,
model: {
commit: true,
},
};
}
}

View file

@ -0,0 +1,8 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { Response } from "./avatax-order-fulfilled-adapter";
export class AvataxOrderFulfilledResponseTransformer {
transform(response: TransactionModel): Response {
return { ok: true };
}
}

View file

@ -79,26 +79,21 @@ const mockedValidEncryptedChannels = encrypt(JSON.stringify(mockedValidChannels)
vi.stubEnv("SECRET_KEY", mockedSecretKey); vi.stubEnv("SECRET_KEY", mockedSecretKey);
describe("getActiveTaxProvider", () => { describe("getActiveTaxProvider", () => {
it("should return ok: false when channel slug is missing", () => { it("throws error when channel slug is missing", () => {
const result = getActiveTaxProvider("", mockedInvalidMetadata); expect(() => getActiveTaxProvider("", mockedInvalidMetadata)).toThrow(
"Channel slug is missing"
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", () => { it("throws error when there are no metadata items", () => {
const result = getActiveTaxProvider("default-channel", []); expect(() => getActiveTaxProvider("default-channel", [])).toThrow(
"App encryptedMetadata is missing"
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", () => { it("throws error when no providerInstanceId was found", () => {
const result = getActiveTaxProvider("default-channel", [ expect(() =>
getActiveTaxProvider("default-channel", [
{ {
key: "providers", key: "providers",
value: mockedEncryptedProviders, value: mockedEncryptedProviders,
@ -107,16 +102,13 @@ describe("getActiveTaxProvider", () => {
key: "channels", key: "channels",
value: mockedInvalidEncryptedChannels, value: mockedInvalidEncryptedChannels,
}, },
]); ])
).toThrow("Channel (default-channel) providerInstanceId does not match any providers");
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", () => { it("throws error when no channel was found for channelSlug", () => {
const result = getActiveTaxProvider("invalid-channel", [ expect(() =>
getActiveTaxProvider("invalid-channel", [
{ {
key: "providers", key: "providers",
value: mockedEncryptedProviders, value: mockedEncryptedProviders,
@ -125,15 +117,11 @@ describe("getActiveTaxProvider", () => {
key: "channels", key: "channels",
value: mockedValidEncryptedChannels, value: mockedValidEncryptedChannels,
}, },
]); ])
).toThrow("Channel config not found for channel invalid-channel");
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", () => { it("returns provider when data is correct", () => {
const result = getActiveTaxProvider("default-channel", [ const result = getActiveTaxProvider("default-channel", [
{ {
key: "providers", key: "providers",
@ -145,6 +133,6 @@ describe("getActiveTaxProvider", () => {
}, },
]); ]);
expect(result.ok).toBe(true); expect(result).toBeDefined();
}); });
}); });

View file

@ -4,61 +4,14 @@ import {
OrderFulfilledSubscriptionFragment, OrderFulfilledSubscriptionFragment,
TaxBaseFragment, TaxBaseFragment,
} from "../../../generated/graphql"; } from "../../../generated/graphql";
import { createLogger, Logger } from "../../lib/logger"; import { Logger, createLogger } from "../../lib/logger";
import { ChannelConfig } from "../channels-configuration/channels-config";
import { ProviderConfig } from "../providers-configuration/providers-config";
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 { getAppConfig } from "../app/get-app-config"; import { getAppConfig } from "../app/get-app-config";
import { AvataxWebhookService } from "../avatax/avatax-webhook.service";
type ActiveTaxProviderResult = { ok: true; data: ActiveTaxProvider } | { ok: false; error: string }; import { ChannelConfig } from "../channels-configuration/channels-config";
import { ProviderConfig } from "../providers-configuration/providers-config";
export function getActiveTaxProvider( import { TaxJarWebhookService } from "../taxjar/taxjar-webhook.service";
channelSlug: string | undefined, import { ProviderWebhookService } from "./tax-provider-webhook";
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 // todo: refactor to a factory
export class ActiveTaxProvider implements ProviderWebhookService { export class ActiveTaxProvider implements ProviderWebhookService {
@ -86,28 +39,60 @@ export class ActiveTaxProvider implements ProviderWebhookService {
break; break;
default: { default: {
throw new TaxProviderError(`Tax provider ${taxProviderName} doesn't match`, { throw new Error(`Tax provider ${taxProviderName} doesn't match`);
cause: "TaxProviderNotFound",
});
} }
} }
} }
async calculateTaxes(payload: TaxBaseFragment) { async calculateTaxes(payload: TaxBaseFragment) {
this.logger.debug({ payload }, ".calculate called"); this.logger.trace({ payload }, ".calculate called");
return this.client.calculateTaxes(payload, this.channel); return this.client.calculateTaxes(payload, this.channel);
} }
async createOrder(order: OrderCreatedSubscriptionFragment) { async createOrder(order: OrderCreatedSubscriptionFragment) {
this.logger.debug(".createOrder called"); this.logger.trace(".createOrder called");
return this.client.createOrder(order, this.channel); return this.client.createOrder(order, this.channel);
} }
async fulfillOrder(payload: OrderFulfilledSubscriptionFragment) { async fulfillOrder(payload: OrderFulfilledSubscriptionFragment) {
this.logger.debug(".fulfillOrder called"); this.logger.trace(".fulfillOrder called");
return this.client.fulfillOrder(payload, this.channel); return this.client.fulfillOrder(payload, this.channel);
} }
} }
export function getActiveTaxProvider(
channelSlug: string | undefined,
encryptedMetadata: MetadataItem[]
): ActiveTaxProvider {
if (!channelSlug) {
throw new Error("Channel slug is missing");
}
if (!encryptedMetadata.length) {
throw new Error("App encryptedMetadata is missing");
}
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
throw new Error(`Channel config not found for channel ${channelSlug}`);
}
const providerInstance = providers.find(
(instance) => instance.id === channelConfig.providerInstanceId
);
if (!providerInstance) {
throw new Error(`Channel (${channelSlug}) providerInstanceId does not match any providers`);
}
const taxProvider = new ActiveTaxProvider(providerInstance, channelConfig);
return taxProvider;
}

View file

@ -0,0 +1,59 @@
import { expect, describe, it } from "vitest";
import { discountUtils } from "./discount-utils";
describe("discountUtils", () => {
describe("distributeDiscount", () => {
it("returns a numbers array thats sum is equal original sum - the discount", () => {
const discount = 10;
const nums = [42, 55, 67, 49];
const result = discountUtils.distributeDiscount(discount, nums);
const resultSum = result.reduce((acc, curr) => acc + curr, 0);
expect(resultSum).toEqual(discount);
});
it("returns a numbers array where all items are >= 0", () => {
const discount = 10;
const nums = [1, 2, 3, 5];
const result = discountUtils.distributeDiscount(discount, nums);
expect(result.every((num) => num >= 0)).toBe(true);
});
it("throws an error when discount is greater than the sum of the numbers array", () => {
const discount = 100;
const nums = [1, 2, 3, 5];
expect(() => discountUtils.distributeDiscount(discount, nums)).toThrowError();
});
it("returns the same numbers when no discount", () => {
const discount = 0;
const nums = [1, 2, 3, 5];
const result = discountUtils.distributeDiscount(discount, nums);
expect(result).toEqual([0, 0, 0, 0]);
});
it("returns throw error when discount = 0 and numbers = 0", () => {
const discount = 0;
const nums = [0, 0, 0, 0];
expect(() => discountUtils.distributeDiscount(discount, nums)).toThrowError();
});
});
describe("sumDiscounts", () => {
it("sums up all discounts", () => {
const discountsArray = [1, 2, 3, 4];
const discounts = discountUtils.sumDiscounts(discountsArray);
expect(discounts).toEqual(10);
});
it("returns 0 if there are no discounts", () => {
const discounts = discountUtils.sumDiscounts([]);
expect(discounts).toEqual(0);
});
});
});

View file

@ -0,0 +1,42 @@
import { numbers } from "./numbers";
// ? shouldn't it be used in all providers?
/*
* Saleor provides discounts as an array of objects with an amount. This function takes in the sum of those discounts and the prices of the line items and returns an array of numbers that represent the discount for each item. You can then use this array to return the individual discounts or to calculate the discounted prices.
*/
/*
* // todo: look into how refunds affect the prices and discounts:
* https://github.com/saleor/apps/pull/495#discussion_r1200321165
*/
function distributeDiscount(discountSum: number, prices: number[]) {
const totalSum = prices.reduce((sum, number) => sum + number, 0);
if (discountSum > totalSum) {
throw new Error("Discount cannot be greater than total sum of line prices.");
}
if (totalSum === 0) {
throw new Error("Cannot distribute discount when total sum is 0.");
}
const discountRatio = discountSum / totalSum;
const distributedDiscounts = prices.map((number) => {
const discountAmount = number * discountRatio;
return numbers.roundFloatToTwoDecimals(Number(discountAmount));
});
return distributedDiscounts;
}
function sumDiscounts(discounts: number[]): number {
return discounts.reduce((total, current) => total + Number(current), 0);
}
export const discountUtils = {
distributeDiscount,
sumDiscounts,
};

View file

@ -1,8 +0,0 @@
export type TaxProviderValidationError = "TaxProviderNotFound";
type TaxProviderErrorName = TaxProviderValidationError;
export class TaxProviderError extends Error {
constructor(message: string, options: { cause: TaxProviderErrorName }) {
super(message, options);
}
}

View file

@ -5,15 +5,15 @@ import { taxProviderUtils } from "./tax-provider-utils";
describe("taxProviderUtils", () => { describe("taxProviderUtils", () => {
describe("resolveOptionalOrThrow", () => { describe("resolveOptionalOrThrow", () => {
it("should throw a default error if value is undefined", () => { it("throws a default error if value is undefined", () => {
expect(() => taxProviderUtils.resolveOptionalOrThrow(undefined)).toThrowError(); expect(() => taxProviderUtils.resolveOptionalOrThrow(undefined)).toThrowError();
}); });
it("should throw a custom error if value is undefined", () => { it("throws a custom error if value is undefined", () => {
expect(() => expect(() =>
taxProviderUtils.resolveOptionalOrThrow(undefined, new Error("test")) taxProviderUtils.resolveOptionalOrThrow(undefined, new Error("test"))
).toThrowError("test"); ).toThrowError("test");
}), }),
it("should return value if value is not undefined", () => { it("returns value if value is not undefined", () => {
expect(taxProviderUtils.resolveOptionalOrThrow("test")).toBe("test"); expect(taxProviderUtils.resolveOptionalOrThrow("test")).toBe("test");
}); });
}); });

View file

@ -0,0 +1,3 @@
export interface WebhookAdapter<TPayload extends Record<string, any>, TResponse extends any> {
send(payload: TPayload): Promise<TResponse>;
}

View file

@ -13,11 +13,11 @@ describe("taxJarAddressFactory", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
street: "123 Palm Grove Ln", from_street: "123 Palm Grove Ln",
city: "LOS ANGELES", from_city: "LOS ANGELES",
state: "CA", from_state: "CA",
zip: "90002", from_zip: "90002",
country: "US", from_country: "US",
}); });
}); });
}); });
@ -36,11 +36,11 @@ describe("taxJarAddressFactory", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
street: "123 Palm Grove Ln", to_street: "123 Palm Grove Ln",
city: "LOS ANGELES", to_city: "LOS ANGELES",
state: "CA", to_state: "CA",
zip: "90002", to_zip: "90002",
country: "US", to_country: "US",
}); });
}); });
@ -57,11 +57,11 @@ describe("taxJarAddressFactory", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
street: "123 Palm Grove Ln", to_street: "123 Palm Grove Ln",
city: "LOS ANGELES", to_city: "LOS ANGELES",
state: "CA", to_state: "CA",
zip: "90002", to_zip: "90002",
country: "US", to_country: "US",
}); });
}); });
}); });

View file

@ -0,0 +1,36 @@
import { ChannelAddress } from "../channels-configuration/channels-config";
import { AddressFragment as SaleorAddress } from "../../../generated/graphql";
import { AddressParams as TaxJarAddress, TaxParams } from "taxjar/dist/types/paramTypes";
function joinAddresses(address1: string, address2: string): string {
return `${address1}${address2.length > 0 ? " " + address2 : ""}`;
}
function mapSaleorAddressToTaxJarAddress(
address: SaleorAddress
): Pick<TaxParams, "to_city" | "to_country" | "to_state" | "to_street" | "to_zip"> {
return {
to_street: joinAddresses(address.streetAddress1, address.streetAddress2),
to_city: address.city,
to_zip: address.postalCode,
to_state: address.countryArea,
to_country: address.country.code,
};
}
function mapChannelAddressToTaxJarAddress(
address: ChannelAddress
): Pick<TaxParams, "from_city" | "from_country" | "from_state" | "from_street" | "from_zip"> {
return {
from_city: address.city,
from_country: address.country,
from_state: address.state,
from_street: address.street,
from_zip: address.zip,
};
}
export const taxJarAddressFactory = {
fromSaleorAddress: mapSaleorAddressToTaxJarAddress,
fromChannelAddress: mapChannelAddressToTaxJarAddress,
};

View file

@ -0,0 +1,3 @@
import { describe } from "vitest";
describe.todo("TaxJarCalculateTaxesAdapter", () => {});

View file

@ -0,0 +1,44 @@
import { TaxBaseFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { FetchTaxForOrderArgs, TaxJarClient } from "../taxjar-client";
import { TaxJarConfig } from "../taxjar-config";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { TaxJarCalculateTaxesPayloadTransformer } from "./taxjar-calculate-taxes-payload-transformer";
import { TaxJarCalculateTaxesResponseTransformer } from "./taxjar-calculate-taxes-response-transformer";
import { Logger, createLogger } from "../../../lib/logger";
export type Payload = {
taxBase: TaxBaseFragment;
channelConfig: ChannelConfig;
};
export type Target = FetchTaxForOrderArgs;
export type Response = CalculateTaxesResponse;
export class TaxJarCalculateTaxesAdapter implements WebhookAdapter<Payload, Response> {
private logger: Logger;
constructor(private readonly config: TaxJarConfig) {
this.logger = createLogger({ service: "TaxJarCalculateTaxesAdapter" });
}
async send(payload: Payload): Promise<Response> {
this.logger.debug({ payload }, "send called with:");
const payloadTransformer = new TaxJarCalculateTaxesPayloadTransformer();
const target = payloadTransformer.transform(payload);
this.logger.debug({ transformedPayload: target }, "Will call fetchTaxForOrder with:");
const client = new TaxJarClient(this.config);
const response = await client.fetchTaxForOrder(target);
this.logger.debug({ response }, "TaxJar fetchTaxForOrder response:");
const responseTransformer = new TaxJarCalculateTaxesResponseTransformer();
const transformedResponse = responseTransformer.transform(payload, response);
this.logger.debug({ transformedResponse }, "Transformed TaxJar fetchTaxForOrder response to:");
return transformedResponse;
}
}

View file

@ -0,0 +1,505 @@
import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
type TaxBase = TaxBaseFragment;
const taxIncludedTaxBase: TaxBase = {
pricesEnteredWithTax: true,
currency: "USD",
channel: {
slug: "default-channel",
},
discounts: [],
address: {
streetAddress1: "668 Route Six",
streetAddress2: "",
city: "MAHOPAC",
countryArea: "NY",
postalCode: "10541",
country: {
code: "US",
},
},
shippingPrice: {
amount: 59.17,
},
lines: [
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ4",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 3,
unitPrice: {
amount: 20,
},
totalPrice: {
amount: 60,
},
},
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 1,
unitPrice: {
amount: 20,
},
totalPrice: {
amount: 20,
},
},
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 2,
unitPrice: {
amount: 50,
},
totalPrice: {
amount: 100,
},
},
],
sourceObject: {
user: {
id: "VXNlcjoyMDg0NTEwNDEw",
},
},
};
const taxExcludedTaxBase: TaxBase = {
pricesEnteredWithTax: false,
currency: "USD",
channel: {
slug: "default-channel",
},
discounts: [],
address: {
streetAddress1: "668 Route Six",
streetAddress2: "",
city: "MAHOPAC",
countryArea: "NY",
postalCode: "10541",
country: {
code: "US",
},
},
shippingPrice: {
amount: 59.17,
},
lines: [
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ4",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 3,
unitPrice: {
amount: 20,
},
totalPrice: {
amount: 60,
},
},
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 1,
unitPrice: {
amount: 20,
},
totalPrice: {
amount: 20,
},
},
{
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
product: {
metafield: null,
productType: {
metafield: null,
},
},
},
},
quantity: 2,
unitPrice: {
amount: 50,
},
totalPrice: {
amount: 100,
},
},
],
sourceObject: {
user: {
id: "VXNlcjoyMDg0NTEwNDEw",
},
},
};
const withNexusChannelConfig: ChannelConfig = {
providerInstanceId: "b8c29f49-7cae-4762-8458-e9a27eb83081",
enabled: false,
address: {
country: "US",
zip: "10118",
state: "NY",
city: "New York",
street: "350 5th Avenue",
},
};
const noNexusChannelConfig: ChannelConfig = {
providerInstanceId: "aa5293e5-7f5d-4782-a619-222ead918e50",
enabled: false,
address: {
country: "US",
zip: "10118",
state: "NY",
city: "New York",
street: "350 5th Avenue",
},
};
type TaxForOrder = TaxForOrderRes;
const noNexusTaxForOrderMock: TaxForOrder = {
tax: {
amount_to_collect: 0,
freight_taxable: false,
has_nexus: false,
order_total_amount: 0,
rate: 0,
shipping: 0,
tax_source: "",
taxable_amount: 0,
exemption_type: "",
jurisdictions: {
country: "",
},
},
};
const withNexusTaxExcludedTaxForOrderMock: TaxForOrder = {
tax: {
exemption_type: "",
amount_to_collect: 20.03,
breakdown: {
city_tax_collectable: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_tax_collectable: 10.46,
county_tax_rate: 0.04375,
county_taxable_amount: 239.17,
line_items: [
{
city_amount: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_amount: 0.88,
county_tax_rate: 0.04375,
county_taxable_amount: 20,
id: taxExcludedTaxBase.lines[0].sourceLine.id,
special_district_amount: 0,
special_district_taxable_amount: 0,
special_tax_rate: 0,
state_amount: 0.8,
state_sales_tax_rate: 0.04,
state_taxable_amount: 20,
tax_collectable: 1.68,
taxable_amount: 20,
},
{
city_amount: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_amount: 4.38,
county_tax_rate: 0.04375,
county_taxable_amount: 100,
id: taxExcludedTaxBase.lines[1].sourceLine.id,
special_district_amount: 0,
special_district_taxable_amount: 0,
special_tax_rate: 0,
state_amount: 4,
state_sales_tax_rate: 0.04,
state_taxable_amount: 100,
tax_collectable: 8.38,
taxable_amount: 100,
},
{
city_amount: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_amount: 2.63,
county_tax_rate: 0.04375,
county_taxable_amount: 60,
id: taxExcludedTaxBase.lines[2].sourceLine.id,
special_district_amount: 0,
special_district_taxable_amount: 0,
special_tax_rate: 0,
state_amount: 2.4,
state_sales_tax_rate: 0.04,
state_taxable_amount: 60,
tax_collectable: 5.03,
taxable_amount: 60,
},
],
shipping: {
city_amount: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_amount: 2.59,
county_tax_rate: 0.04375,
county_taxable_amount: 59.17,
special_district_amount: 0,
special_tax_rate: 0,
special_taxable_amount: 0,
state_amount: 2.37,
state_sales_tax_rate: 0.04,
state_taxable_amount: 59.17,
tax_collectable: 4.96,
taxable_amount: 59.17,
},
special_district_tax_collectable: 0,
special_district_taxable_amount: 0,
special_tax_rate: 0,
state_tax_collectable: 9.57,
state_tax_rate: 0.04,
state_taxable_amount: 239.17,
tax_collectable: 20.03,
taxable_amount: 239.17,
},
freight_taxable: true,
has_nexus: true,
jurisdictions: {
city: "MAHOPAC",
country: "US",
county: "PUTNAM",
state: "NY",
},
order_total_amount: 239.17,
rate: 0.08375,
shipping: 59.17,
tax_source: "destination",
taxable_amount: 239.17,
},
};
const withNexusTaxIncludedTaxForOrderMock: TaxForOrder = {
tax: {
exemption_type: "",
amount_to_collect: 20.03,
breakdown: {
city_tax_collectable: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_tax_collectable: 10.46,
county_tax_rate: 0.04375,
county_taxable_amount: 239.17,
line_items: [
{
city_amount: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_amount: 0.88,
county_tax_rate: 0.04375,
county_taxable_amount: 20,
id: taxIncludedTaxBase.lines[0].sourceLine.id,
special_district_amount: 0,
special_district_taxable_amount: 0,
special_tax_rate: 0,
state_amount: 0.8,
state_sales_tax_rate: 0.04,
state_taxable_amount: 20,
tax_collectable: 1.68,
taxable_amount: 20,
},
{
city_amount: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_amount: 4.38,
county_tax_rate: 0.04375,
county_taxable_amount: 100,
id: taxIncludedTaxBase.lines[1].sourceLine.id,
special_district_amount: 0,
special_district_taxable_amount: 0,
special_tax_rate: 0,
state_amount: 4,
state_sales_tax_rate: 0.04,
state_taxable_amount: 100,
tax_collectable: 8.38,
taxable_amount: 100,
},
{
city_amount: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_amount: 2.63,
county_tax_rate: 0.04375,
county_taxable_amount: 60,
id: taxIncludedTaxBase.lines[2].sourceLine.id,
special_district_amount: 0,
special_district_taxable_amount: 0,
special_tax_rate: 0,
state_amount: 2.4,
state_sales_tax_rate: 0.04,
state_taxable_amount: 60,
tax_collectable: 5.03,
taxable_amount: 60,
},
],
shipping: {
city_amount: 0,
city_tax_rate: 0,
city_taxable_amount: 0,
combined_tax_rate: 0.08375,
county_amount: 2.59,
county_tax_rate: 0.04375,
county_taxable_amount: 59.17,
special_district_amount: 0,
special_tax_rate: 0,
special_taxable_amount: 0,
state_amount: 2.37,
state_sales_tax_rate: 0.04,
state_taxable_amount: 59.17,
tax_collectable: 4.96,
taxable_amount: 59.17,
},
special_district_tax_collectable: 0,
special_district_taxable_amount: 0,
special_tax_rate: 0,
state_tax_collectable: 9.57,
state_tax_rate: 0.04,
state_taxable_amount: 239.17,
tax_collectable: 20.03,
taxable_amount: 239.17,
},
freight_taxable: true,
has_nexus: true,
jurisdictions: {
city: "MAHOPAC",
country: "US",
county: "PUTNAM",
state: "NY",
},
order_total_amount: 239.17,
rate: 0.08375,
shipping: 59.17,
tax_source: "destination",
taxable_amount: 239.17,
},
};
// with/without tax
const testingScenariosMap = {
with_no_nexus_tax_included: {
taxBase: taxIncludedTaxBase,
channelConfig: noNexusChannelConfig,
response: noNexusTaxForOrderMock,
},
with_no_nexus_tax_excluded: {
taxBase: taxExcludedTaxBase,
channelConfig: noNexusChannelConfig,
response: noNexusTaxForOrderMock,
},
with_nexus_tax_included: {
taxBase: taxIncludedTaxBase,
channelConfig: withNexusChannelConfig,
response: withNexusTaxIncludedTaxForOrderMock,
},
with_nexus_tax_excluded: {
taxBase: taxExcludedTaxBase,
channelConfig: withNexusChannelConfig,
response: withNexusTaxExcludedTaxForOrderMock,
},
};
type TestingScenario = keyof typeof testingScenariosMap;
export class TaxJarCalculateTaxesMockGenerator {
constructor(private scenario: TestingScenario) {}
generateTaxBase = (overrides: Partial<TaxBase> = {}): TaxBase =>
structuredClone({
...testingScenariosMap[this.scenario].taxBase,
...overrides,
});
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig =>
structuredClone({
...testingScenariosMap[this.scenario].channelConfig,
...overrides,
});
generateResponse = (overrides: Partial<TaxForOrder> = {}): TaxForOrder =>
structuredClone({
...testingScenariosMap[this.scenario].response,
...overrides,
});
}

View file

@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { TaxJarCalculateTaxesMockGenerator } from "./taxjar-calculate-taxes-mock-generator";
import { TaxJarCalculateTaxesPayloadTransformer } from "./taxjar-calculate-taxes-payload-transformer";
const transformer = new TaxJarCalculateTaxesPayloadTransformer();
describe("TaxJarCalculateTaxesPayloadTransformer", () => {
it("returns payload containing line_items without discounts", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const taxBase = mockGenerator.generateTaxBase();
const channelConfig = mockGenerator.generateChannelConfig();
const transformedPayload = transformer.transform({
taxBase,
channelConfig,
});
expect(transformedPayload).toEqual({
params: {
from_country: "US",
from_zip: "10118",
from_state: "NY",
from_city: "New York",
from_street: "350 5th Avenue",
to_country: "US",
to_zip: "10541",
to_state: "NY",
to_city: "MAHOPAC",
to_street: "668 Route Six",
shipping: 59.17,
line_items: [
{
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
quantity: 3,
unit_price: 20,
discount: 0,
product_tax_code: "",
},
{
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
quantity: 1,
unit_price: 20,
discount: 0,
product_tax_code: "",
},
{
discount: 0,
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
product_tax_code: "",
quantity: 2,
unit_price: 50,
},
],
},
});
});
it("returns payload containing line_items with discounts", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const taxBase = mockGenerator.generateTaxBase({
discounts: [
{
amount: { amount: 10 },
},
],
});
const channelConfig = mockGenerator.generateChannelConfig();
const transformedPayload = transformer.transform({
taxBase,
channelConfig,
});
const payloadLines = transformedPayload.params.line_items ?? [];
const discountSum = payloadLines.reduce((sum, line) => sum + (line.discount ?? 0), 0);
expect(transformedPayload.params.line_items).toEqual([
{
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
quantity: 3,
unit_price: 20,
discount: 3.33,
product_tax_code: "",
},
{
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
quantity: 1,
unit_price: 20,
discount: 1.11,
product_tax_code: "",
},
{
discount: 5.56,
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
product_tax_code: "",
quantity: 2,
unit_price: 50,
},
]);
expect(discountSum).toEqual(10);
});
it("throws error when no address", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const taxBase = mockGenerator.generateTaxBase({ address: null });
const channelConfig = mockGenerator.generateChannelConfig();
expect(() =>
transformer.transform({
taxBase,
channelConfig,
})
).toThrow("Customer address is required to calculate taxes in TaxJar.");
});
});

View file

@ -0,0 +1,51 @@
import { discountUtils } from "../../taxes/discount-utils";
import { taxJarAddressFactory } from "../address-factory";
import { Payload, Target } from "./taxjar-calculate-taxes-adapter";
export class TaxJarCalculateTaxesPayloadTransformer {
private mapLines(taxBase: Payload["taxBase"]): Target["params"]["line_items"] {
const { lines, discounts } = taxBase;
const discountSum = discounts?.reduce(
(total, current) => total + Number(current.amount.amount),
0
);
const linePrices = lines.map((line) => Number(line.totalPrice.amount));
const distributedDiscounts = discountUtils.distributeDiscount(discountSum, linePrices);
const mappedLines: Target["params"]["line_items"] = lines.map((line, index) => {
const discountAmount = distributedDiscounts[index];
return {
id: line.sourceLine.id,
// todo: get from tax code matcher
product_tax_code: "",
quantity: line.quantity,
unit_price: Number(line.unitPrice.amount),
discount: discountAmount,
};
});
return mappedLines;
}
transform({ taxBase, channelConfig }: Payload): Target {
const fromAddress = taxJarAddressFactory.fromChannelAddress(channelConfig.address);
if (!taxBase.address) {
throw new Error("Customer address is required to calculate taxes in TaxJar.");
}
const toAddress = taxJarAddressFactory.fromSaleorAddress(taxBase.address);
const taxParams: Target = {
params: {
...fromAddress,
...toAddress,
shipping: taxBase.shippingPrice.amount,
line_items: this.mapLines(taxBase),
},
};
return taxParams;
}
}

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { TaxJarCalculateTaxesMockGenerator } from "./taxjar-calculate-taxes-mock-generator";
import { matchPayloadLinesToResponseLines } from "./taxjar-calculate-taxes-response-lines-transformer";
describe("matchPayloadLinesToResponseLines", () => {
it("shold return the response lines in the order of payload lines", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const responseMock = mockGenerator.generateResponse();
const payloadMock = mockGenerator.generateTaxBase();
const payloadLines = payloadMock.lines;
const responseLines = responseMock.tax.breakdown?.line_items ?? [];
const [first, second, third] = matchPayloadLinesToResponseLines(payloadLines, responseLines);
expect(first!.id).toBe(payloadLines[0].sourceLine.id);
expect(second!.id).toBe(payloadLines[1].sourceLine.id);
expect(third!.id).toBe(payloadLines[2].sourceLine.id);
});
it("throws error if there is no match for a payload line in response lines", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const responseMock = mockGenerator.generateResponse();
const payloadMock = mockGenerator.generateTaxBase();
const payloadLines = payloadMock.lines;
const responseLines = (responseMock.tax.breakdown?.line_items ?? []).slice(0, 2);
expect(() => matchPayloadLinesToResponseLines(payloadLines, responseLines)).toThrowError(
`Saleor product line with id ${payloadLines[2].sourceLine.id} not found in TaxJar response.`
);
});
});

View file

@ -0,0 +1,59 @@
import Breakdown from "taxjar/dist/types/breakdown";
import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { Payload, Response } from "./taxjar-calculate-taxes-adapter";
/*
* TaxJar doesn't guarantee the order of the response items to match the payload items order.
* The order needs to be maintained because the response items are by it in Saleor.
*/
export function matchPayloadLinesToResponseLines(
payloadLines: TaxBaseFragment["lines"],
responseLines: NonNullable<Breakdown["line_items"]>
) {
return payloadLines.map((payloadLine) => {
const responseLine = responseLines.find((line) => line.id === payloadLine.sourceLine.id);
if (!responseLine) {
throw new Error(
`Saleor product line with id ${payloadLine.sourceLine.id} not found in TaxJar response.`
);
}
return responseLine;
});
}
export class TaxJarCalculateTaxesResponseLinesTransformer {
transform(payload: Payload, response: TaxForOrderRes): Response["lines"] {
const responseLines = response.tax.breakdown?.line_items ?? [];
const lines = matchPayloadLinesToResponseLines(payload.taxBase.lines, responseLines);
return lines.map((line) => {
const taxableAmount = taxProviderUtils.resolveOptionalOrThrow(
line?.taxable_amount,
new Error("Line taxable amount is required to calculate net amount")
);
const taxCollectable = taxProviderUtils.resolveOptionalOrThrow(
line?.tax_collectable,
new Error("Line tax collectable is required to calculate net amount")
);
const taxRate = taxProviderUtils.resolveOptionalOrThrow(
line?.combined_tax_rate,
new Error("Line combined tax rate is required to calculate net amount")
);
return {
total_gross_amount: payload.taxBase.pricesEnteredWithTax
? taxableAmount
: taxableAmount + taxCollectable,
total_net_amount: payload.taxBase.pricesEnteredWithTax
? taxableAmount - taxCollectable
: taxableAmount,
tax_rate: taxRate,
};
});
}
}

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { TaxJarCalculateTaxesMockGenerator } from "./taxjar-calculate-taxes-mock-generator";
import { TaxJarCalculateTaxesResponseShippingTransformer } from "./taxjar-calculate-taxes-response-shipping-transformer";
const transformer = new TaxJarCalculateTaxesResponseShippingTransformer();
describe("TaxJarCalculateTaxesResponseShippingTransformer", () => {
it("returns shipping with taxes", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const response = mockGenerator.generateResponse();
const taxBase = mockGenerator.generateTaxBase();
const result = transformer.transform(taxBase, response);
expect(result).toEqual({
shipping_price_gross_amount: 59.17,
shipping_price_net_amount: 54.21,
shipping_tax_rate: 0.08375,
});
});
it("returns no taxes when shipping not taxable", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const response = mockGenerator.generateResponse();
const payload = mockGenerator.generateTaxBase();
response.tax.breakdown!.shipping = undefined;
response.tax.freight_taxable = false;
const result = transformer.transform(payload, response);
expect(result).toEqual({
shipping_price_net_amount: 59.17,
shipping_price_gross_amount: 59.17,
shipping_tax_rate: 0,
});
});
it("returns gross amount reduced by tax when pricesEnteredWithTax = true", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const response = mockGenerator.generateResponse();
const payload = mockGenerator.generateTaxBase();
const result = transformer.transform(payload, response);
expect(result).toEqual({
shipping_price_gross_amount: 59.17,
shipping_price_net_amount: 54.21,
shipping_tax_rate: 0.08375,
});
});
it("returns gross amount when pricesEnteredWithTax = false", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_excluded");
const response = mockGenerator.generateResponse();
const payload = mockGenerator.generateTaxBase();
const result = transformer.transform(payload, response);
expect(result).toEqual({
shipping_price_gross_amount: 64.13,
shipping_price_net_amount: 59.17,
shipping_tax_rate: 0.08375,
});
});
});

View file

@ -0,0 +1,47 @@
import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
import { numbers } from "../../taxes/numbers";
import { Payload, Response } from "./taxjar-calculate-taxes-adapter";
export class TaxJarCalculateTaxesResponseShippingTransformer {
transform(
taxBase: Payload["taxBase"],
res: TaxForOrderRes
): Pick<
Response,
"shipping_price_gross_amount" | "shipping_price_net_amount" | "shipping_tax_rate"
> {
const { tax } = res;
/*
* If the shipping is not taxable, we return the same values as in the payload.
* If freight_taxable = true, tax.breakdown.shipping exists
*/
if (!tax.freight_taxable) {
return {
shipping_price_gross_amount: tax.shipping,
shipping_price_net_amount: tax.shipping,
shipping_tax_rate: 0,
};
}
const isTaxIncluded = taxBase.pricesEnteredWithTax;
const shippingDetails = tax.breakdown!.shipping!;
const shippingTaxableAmount = shippingDetails.taxable_amount;
const shippingTaxCollectable = shippingDetails.tax_collectable;
const shippingPriceGross = isTaxIncluded
? shippingTaxableAmount
: shippingTaxableAmount + shippingTaxCollectable;
const shippingPriceNet = isTaxIncluded
? shippingTaxableAmount - shippingTaxCollectable
: shippingTaxableAmount;
const shippingTaxRate = shippingDetails.combined_tax_rate;
return {
shipping_price_gross_amount: numbers.roundFloatToTwoDecimals(shippingPriceGross),
shipping_price_net_amount: numbers.roundFloatToTwoDecimals(shippingPriceNet),
shipping_tax_rate: shippingTaxRate,
};
}
}

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { TaxJarCalculateTaxesMockGenerator } from "./taxjar-calculate-taxes-mock-generator";
import { TaxJarCalculateTaxesResponseTransformer } from "./taxjar-calculate-taxes-response-transformer";
const transformer = new TaxJarCalculateTaxesResponseTransformer();
describe("TaxJarCalculateTaxesResponseTransformer", () => {
it("returns values from payload if no nexus", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_no_nexus_tax_included");
const noNexusResponseMock = mockGenerator.generateResponse();
const payloadMock = {
taxBase: mockGenerator.generateTaxBase(),
channelConfig: mockGenerator.generateChannelConfig(),
};
const result = transformer.transform(payloadMock, noNexusResponseMock);
expect(result).toEqual({
shipping_price_net_amount: 59.17,
shipping_price_gross_amount: 59.17,
shipping_tax_rate: 0,
lines: [
{
total_gross_amount: 60,
total_net_amount: 60,
tax_rate: 0,
},
{
total_gross_amount: 20,
total_net_amount: 20,
tax_rate: 0,
},
{
total_gross_amount: 100,
total_net_amount: 100,
tax_rate: 0,
},
],
});
});
it("transforms response when nexus is found", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const nexusResponse = mockGenerator.generateResponse();
const payloadMock = {
taxBase: mockGenerator.generateTaxBase(),
channelConfig: mockGenerator.generateChannelConfig(),
};
const result = transformer.transform(payloadMock, nexusResponse);
expect(result).toEqual({
shipping_price_gross_amount: 59.17,
shipping_price_net_amount: 54.21,
shipping_tax_rate: 0.08375,
lines: [
{
total_gross_amount: 20,
total_net_amount: 18.32,
tax_rate: 0.08375,
},
{
total_gross_amount: 100,
total_net_amount: 91.62,
tax_rate: 0.08375,
},
{
total_gross_amount: 60,
total_net_amount: 54.97,
tax_rate: 0.08375,
},
],
});
});
});

View file

@ -0,0 +1,46 @@
import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
import { Logger, createLogger } from "../../../lib/logger";
import { Payload, Response } from "./taxjar-calculate-taxes-adapter";
import { TaxJarCalculateTaxesResponseLinesTransformer } from "./taxjar-calculate-taxes-response-lines-transformer";
import { TaxJarCalculateTaxesResponseShippingTransformer } from "./taxjar-calculate-taxes-response-shipping-transformer";
export class TaxJarCalculateTaxesResponseTransformer {
private logger: Logger;
constructor() {
this.logger = createLogger({ name: "TaxJarCalculateTaxesResponseTransformer" });
}
transform(payload: Payload, response: TaxForOrderRes): Response {
/*
* TaxJar operates on the idea of sales tax nexus. Nexus is a place where the company has a physical presence.
* If the company has no nexus in the state where the customer is located, the company is not required to collect sales tax.
* Therefore, if has_nexus = false, we don't calculate taxes and return the same values as in the payload.
* See: https://www.taxjar.com/sales-tax/nexus
*/
if (!response.tax.has_nexus) {
this.logger.warn("The company has no nexus in the state where the customer is located");
return {
shipping_price_net_amount: payload.taxBase.shippingPrice.amount,
shipping_price_gross_amount: payload.taxBase.shippingPrice.amount,
shipping_tax_rate: 0,
lines: payload.taxBase.lines.map((line) => ({
total_gross_amount: line.totalPrice.amount,
total_net_amount: line.totalPrice.amount,
tax_rate: 0,
})),
};
}
const shippingTransformer = new TaxJarCalculateTaxesResponseShippingTransformer();
const linesTransformer = new TaxJarCalculateTaxesResponseLinesTransformer();
const shipping = shippingTransformer.transform(payload.taxBase, response);
const lines = linesTransformer.transform(payload, response);
return {
...shipping,
lines,
};
}
}

View file

@ -1,32 +0,0 @@
import { ChannelAddress } from "../../channels-configuration/channels-config";
import { AddressFragment as SaleorAddress } from "../../../../generated/graphql";
import { AddressParams as TaxJarAddress } from "taxjar/dist/types/paramTypes";
function joinAddresses(address1: string, address2: string): string {
return `${address1}${address2.length > 0 ? " " + address2 : ""}`;
}
function mapSaleorAddressToTaxJarAddress(address: SaleorAddress): TaxJarAddress {
return {
street: joinAddresses(address.streetAddress1, address.streetAddress2),
city: address.city,
zip: address.postalCode,
state: address.countryArea,
country: address.country.code,
};
}
function mapChannelAddressToTaxJarAddress(address: ChannelAddress): TaxJarAddress {
return {
city: address.city,
country: address.country,
state: address.state,
street: address.street,
zip: address.zip,
};
}
export const taxJarAddressFactory = {
fromSaleorAddress: mapSaleorAddressToTaxJarAddress,
fromChannelAddress: mapChannelAddressToTaxJarAddress,
};

View file

@ -1,14 +0,0 @@
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,151 +0,0 @@
import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
import { TaxBaseFragment, TaxBaseLineFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { FetchTaxForOrderArgs } from "../taxjar-client";
function getTaxBaseLineDiscount(
line: TaxBaseLineFragment,
totalDiscount: number,
allLinesTotal: number
) {
if (totalDiscount === 0 || allLinesTotal === 0) {
return 0;
}
const lineTotalAmount = Number(line.totalPrice.amount);
const discountAmount = (lineTotalAmount / allLinesTotal) * totalDiscount;
if (discountAmount > lineTotalAmount) {
return lineTotalAmount;
}
return discountAmount;
}
const formatCalculatedAmount = (amount: number) => {
return Number(amount.toFixed(2));
};
// * This type is related to `TaxLineItem` from TaxJar. It should be unified.
type FetchTaxesLinePayload = {
id: string;
quantity: number;
taxCode?: string | null;
discount: number;
chargeTaxes: boolean;
unitAmount: number;
totalAmount: number;
};
const prepareLinesWithDiscountPayload = (
taxBase: TaxBaseFragment
): Array<FetchTaxesLinePayload> => {
const { lines, discounts } = taxBase;
const allLinesTotal = lines.reduce(
(total, current) => total + Number(current.totalPrice.amount),
0
);
const discountsSum =
discounts?.reduce((total, current) => total + Number(current.amount.amount), 0) || 0;
// Make sure that totalDiscount doesn't exceed a sum of all lines
const totalDiscount = discountsSum <= allLinesTotal ? discountsSum : allLinesTotal;
return lines.map((line) => {
const discountAmount = getTaxBaseLineDiscount(line, totalDiscount, allLinesTotal);
return {
id: line.sourceLine.id,
chargeTaxes: taxBase.pricesEnteredWithTax,
// todo: get from tax code matcher
taxCode: "",
quantity: line.quantity,
totalAmount: Number(line.totalPrice.amount),
unitAmount: Number(line.unitPrice.amount),
discount: discountAmount,
};
});
};
const mapResponse = (
payload: TaxBaseFragment,
response: TaxForOrderRes
): CalculateTaxesResponse => {
const linesWithDiscount = prepareLinesWithDiscountPayload(payload);
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.
*/
const shippingDetails = taxDetails?.shipping;
const shippingPriceGross = shippingDetails
? shippingDetails.taxable_amount + shippingDetails.tax_collectable
: payload.shippingPrice.amount;
const shippingPriceNet = shippingDetails
? shippingDetails.taxable_amount
: 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 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
? lineTax.taxable_amount + lineTax.tax_collectable
: 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),
tax_rate: taxRate ?? 0,
};
}),
};
};
const mapPayload = (taxBase: TaxBaseFragment, channel: ChannelConfig): FetchTaxForOrderArgs => {
const linesWithDiscount = prepareLinesWithDiscountPayload(taxBase);
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,
from_city: channel.address.city,
from_street: channel.address.street,
to_country: taxBase.address!.country.code,
to_zip: taxBase.address!.postalCode,
to_state: taxBase.address!.countryArea,
to_city: taxBase.address!.city,
to_street: `${taxBase.address!.streetAddress1} ${taxBase.address!.streetAddress2}`,
shipping: taxBase.shippingPrice.amount,
line_items: linesWithChargeTaxes.map((line) => ({
id: line.id,
quantity: line.quantity,
product_tax_code: line.taxCode || undefined,
unit_price: line.unitAmount,
discount: line.discount,
})),
},
};
return taxParams;
};
export const taxJarCalculateTaxesMaps = {
mapPayload,
mapResponse,
};

View file

@ -1,175 +0,0 @@
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: {
gross: {
amount: 48.33,
},
net: {
amount: 43.74,
},
},
lines: [
{
productSku: "328223581",
productName: "Monospace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 90,
},
},
totalPrice: {
net: {
amount: 90,
},
tax: {
amount: 8.55,
},
},
},
{
productSku: "328223580",
productName: "Monospace Tee",
quantity: 1,
unitPrice: {
net: {
amount: 45,
},
},
totalPrice: {
net: {
amount: 45,
},
tax: {
amount: 4.28,
},
},
},
],
discounts: [
{
amount: {
amount: 10,
},
id: "RGlzY291bnREaXNjb3VudDox",
},
],
},
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);
});
});
});

View file

@ -1,90 +0,0 @@
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";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
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,
description: line.productName,
}));
}
function sumLines(lines: LineItem[]): number {
return numbers.roundFloatToTwoDecimals(
lines.reduce(
(prev, line) =>
prev +
taxProviderUtils.resolveOptionalOrThrow(
line.unit_price,
new Error("line.unit_price is undefined")
) *
taxProviderUtils.resolveOptionalOrThrow(
line.quantity,
new Error("line.quantity is undefined")
),
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.gross.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

@ -0,0 +1,3 @@
import { describe } from "vitest";
describe.todo("TaxJarOrderCreatedAdapter", () => {});

View file

@ -0,0 +1,39 @@
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { TaxJarOrderCreatedPayloadTransformer } from "./taxjar-order-created-payload-transformer";
import { CreateOrderArgs, TaxJarClient } from "../taxjar-client";
import { TaxJarConfig } from "../taxjar-config";
import { TaxJarOrderCreatedResponseTransformer } from "./taxjar-order-created-response-transformer";
import { Logger, createLogger } from "../../../lib/logger";
export type Payload = { order: OrderCreatedSubscriptionFragment; channelConfig: ChannelConfig };
export type Target = CreateOrderArgs;
type Response = CreateOrderResponse;
export class TaxJarOrderCreatedAdapter implements WebhookAdapter<Payload, Response> {
private logger: Logger;
constructor(private readonly config: TaxJarConfig) {
this.logger = createLogger({ service: "TaxJarOrderCreatedAdapter" });
}
async send(payload: Payload): Promise<Response> {
this.logger.debug({ payload }, "send called with:");
const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer();
const target = payloadTransformer.transform(payload);
const client = new TaxJarClient(this.config);
const response = await client.createOrder(target);
this.logger.debug({ response }, "TaxJar createOrder response:");
const responseTransformer = new TaxJarOrderCreatedResponseTransformer();
const transformedResponse = responseTransformer.transform(response);
this.logger.debug({ transformedResponse }, "Transformed TaxJar createOrder response to:");
return transformedResponse;
}
}

View file

@ -0,0 +1,105 @@
import { CreateOrderRes } from "taxjar/dist/types/returnTypes";
import { OrderCreatedSubscriptionFragment, OrderStatus } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { defaultOrder } from "../../../mocks";
type Order = OrderCreatedSubscriptionFragment;
const defaultChannelConfig: ChannelConfig = {
providerInstanceId: "aa5293e5-7f5d-4782-a619-222ead918e50",
enabled: false,
address: {
country: "US",
zip: "95008",
state: "CA",
city: "Campbell",
street: "33 N. First Street",
},
};
const defaultOrderCreatedResponse: CreateOrderRes = {
order: {
user_id: 314973,
transaction_reference_id: null,
transaction_id: "T3JkZXI6ZTUzZTBlM2MtMjk5Yi00OWYxLWIyZDItY2Q4NWExYTgxYjY2",
transaction_date: "2023-05-25T09:18:55.203Z",
to_zip: "94111",
to_street: "600 Montgomery St",
to_state: "CA",
to_country: "US",
to_city: "SAN FRANCISCO",
shipping: 59.17,
sales_tax: 0.0,
provider: "api",
line_items: [
{
unit_price: 20.0,
sales_tax: 5.18,
quantity: 3,
product_tax_code: "",
product_identifier: "328223580",
id: "0",
discount: 0.0,
description: "Monospace Tee",
},
{
unit_price: 20.0,
sales_tax: 1.73,
quantity: 1,
product_tax_code: "",
product_identifier: "328223581",
id: "1",
discount: 0.0,
description: "Monospace Tee",
},
{
unit_price: 50.0,
sales_tax: 8.63,
quantity: 2,
product_tax_code: "",
product_identifier: "118223581",
id: "2",
discount: 0.0,
description: "Paul's Balance 420",
},
],
from_zip: "95008",
from_street: "33 N. First Street",
from_state: "CA",
from_country: "US",
from_city: "CAMPBELL",
exemption_type: null,
amount: 239.17,
},
};
const testingScenariosMap = {
default: {
order: defaultOrder,
channelConfig: defaultChannelConfig,
response: defaultOrderCreatedResponse,
},
};
type TestingScenario = keyof typeof testingScenariosMap;
export class TaxJarOrderCreatedMockGenerator {
constructor(private scenario: TestingScenario = "default") {}
generateOrder = (overrides: Partial<Order> = {}): Order =>
structuredClone({
...testingScenariosMap[this.scenario].order,
...overrides,
});
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig =>
structuredClone({
...testingScenariosMap[this.scenario].channelConfig,
...overrides,
});
generateResponse = (overrides: Partial<CreateOrderRes> = {}): CreateOrderRes =>
structuredClone({
...testingScenariosMap[this.scenario].response,
...overrides,
});
}

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { TaxJarOrderCreatedMockGenerator } from "./taxjar-order-created-mock-generator";
import {
TaxJarOrderCreatedPayloadTransformer,
sumPayloadLines,
} from "./taxjar-order-created-payload-transformer";
const mockGenerator = new TaxJarOrderCreatedMockGenerator();
describe("TaxJarOrderCreatedPayloadTransformer", () => {
it("returns the correct order amount", () => {
const payloadMock = {
order: mockGenerator.generateOrder(),
channelConfig: mockGenerator.generateChannelConfig(),
};
const transformer = new TaxJarOrderCreatedPayloadTransformer();
const transformedPayload = transformer.transform(payloadMock);
expect(transformedPayload.params.amount).toBe(239.17);
});
});
describe("sumPayloadLines", () => {
it("returns the sum of all line items when items quantity = 1", () => {
const result = sumPayloadLines([
{
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 = sumPayloadLines([
{
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);
});
});

View file

@ -0,0 +1,73 @@
import { LineItem } from "taxjar/dist/util/types";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { numbers } from "../../taxes/numbers";
import { Payload, Target } from "./taxjar-order-created-adapter";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
export function sumPayloadLines(lines: LineItem[]): number {
return numbers.roundFloatToTwoDecimals(
lines.reduce(
(prev, line) =>
prev +
taxProviderUtils.resolveOptionalOrThrow(
line.unit_price,
new Error("Line unit_price is required to calculate order taxes")
) *
taxProviderUtils.resolveOptionalOrThrow(
line.quantity,
new Error("Line quantity is required to calculate order taxes")
),
0
)
);
}
export class TaxJarOrderCreatedPayloadTransformer {
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, channelConfig }: Payload): Target {
const lineItems = this.mapLines(order.lines);
const lineSum = sumPayloadLines(lineItems);
const shippingAmount = order.shippingPrice.gross.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: channelConfig.address.country,
from_zip: channelConfig.address.zip,
from_state: channelConfig.address.state,
from_city: channelConfig.address.city,
from_street: channelConfig.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,
// todo: add sales_tax
sales_tax: 0,
},
};
}
}

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { TaxJarOrderCreatedMockGenerator } from "./taxjar-order-created-mock-generator";
import { TaxJarOrderCreatedResponseTransformer } from "./taxjar-order-created-response-transformer";
describe("TaxJarOrderCreatedResponseTransformer", () => {
it("returns orded id in response", () => {
const mockGenerator = new TaxJarOrderCreatedMockGenerator();
const responseMock = mockGenerator.generateResponse();
const transformer = new TaxJarOrderCreatedResponseTransformer();
const result = transformer.transform(responseMock);
expect(result).toEqual({
id: "T3JkZXI6ZTUzZTBlM2MtMjk5Yi00OWYxLWIyZDItY2Q4NWExYTgxYjY2",
});
});
});

View file

@ -0,0 +1,10 @@
import { CreateOrderRes } from "taxjar/dist/types/returnTypes";
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
export class TaxJarOrderCreatedResponseTransformer {
transform(response: CreateOrderRes): CreateOrderResponse {
return {
id: response.order.transaction_id,
};
}
}

View file

@ -39,14 +39,14 @@ export class TaxJarClient {
} }
async fetchTaxForOrder({ params }: FetchTaxForOrderArgs) { async fetchTaxForOrder({ params }: FetchTaxForOrderArgs) {
this.logger.debug({ params }, "fetchTaxForOrder called with:"); this.logger.trace({ params }, "fetchTaxForOrder called with:");
const response = await this.client.taxForOrder(params); const response = await this.client.taxForOrder(params);
return response; return response;
} }
async ping() { async ping() {
this.logger.debug("ping called"); this.logger.trace("ping called");
try { try {
await this.client.categories(); await this.client.categories();
return { authenticated: true }; return { authenticated: true };
@ -59,13 +59,13 @@ export class TaxJarClient {
} }
async createOrder({ params }: CreateOrderArgs) { async createOrder({ params }: CreateOrderArgs) {
this.logger.debug({ params }, "createOrder called with:"); this.logger.trace({ params }, "createOrder called with:");
return this.client.createOrder(params); return this.client.createOrder(params);
} }
async validateAddress({ params }: ValidateAddressArgs) { async validateAddress({ params }: ValidateAddressArgs) {
this.logger.debug({ params }, "validateAddress called with:"); this.logger.trace({ params }, "validateAddress called with:");
return this.client.validateAddress(params); return this.client.validateAddress(params);
} }

View file

@ -1,43 +1,46 @@
import { OrderCreatedSubscriptionFragment, TaxBaseFragment } from "../../../generated/graphql"; import { OrderCreatedSubscriptionFragment, TaxBaseFragment } from "../../../generated/graphql";
import { createLogger, Logger } from "../../lib/logger"; import { Logger, createLogger } from "../../lib/logger";
import { ChannelConfig } from "../channels-configuration/channels-config"; import { ChannelConfig } from "../channels-configuration/channels-config";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { TaxJarCalculateTaxesAdapter } from "./calculate-taxes/taxjar-calculate-taxes-adapter";
import { TaxJarClient } from "./taxjar-client"; import { TaxJarClient } from "./taxjar-client";
import { TaxJarConfig } from "./taxjar-config"; import { TaxJarConfig } from "./taxjar-config";
import { taxJarCalculateTaxesMaps } from "./maps/taxjar-calculate-taxes-map"; import { TaxJarOrderCreatedAdapter } from "./order-created/taxjar-order-created-adapter";
import { taxJarOrderCreatedMaps } from "./maps/taxjar-order-created-map";
export class TaxJarWebhookService implements ProviderWebhookService { export class TaxJarWebhookService implements ProviderWebhookService {
client: TaxJarClient; client: TaxJarClient;
config: TaxJarConfig;
private logger: Logger; private logger: Logger;
constructor(config: TaxJarConfig) { constructor(config: TaxJarConfig) {
const avataxClient = new TaxJarClient(config); const taxJarClient = new TaxJarClient(config);
this.client = avataxClient; this.client = taxJarClient;
this.config = config;
this.logger = createLogger({ this.logger = createLogger({
service: "TaxJarProvider", service: "TaxJarWebhookService",
}); });
} }
async calculateTaxes(payload: TaxBaseFragment, channel: ChannelConfig) { async calculateTaxes(taxBase: TaxBaseFragment, channelConfig: ChannelConfig) {
this.logger.debug({ payload, channel }, "calculateTaxes called with:"); this.logger.debug({ taxBase, channelConfig }, "calculateTaxes called with:");
const args = taxJarCalculateTaxesMaps.mapPayload(payload, channel); const adapter = new TaxJarCalculateTaxesAdapter(this.config);
const fetchedTaxes = await this.client.fetchTaxForOrder(args);
this.logger.debug({ fetchedTaxes }, "fetchTaxForOrder response"); const response = await adapter.send({ channelConfig, taxBase });
return taxJarCalculateTaxesMaps.mapResponse(payload, fetchedTaxes); this.logger.debug({ response }, "calculateTaxes response:");
return response;
} }
async createOrder(order: OrderCreatedSubscriptionFragment, channel: ChannelConfig) { async createOrder(order: OrderCreatedSubscriptionFragment, channelConfig: ChannelConfig) {
this.logger.debug({ order, channel }, "createOrder called with:"); this.logger.debug({ order, channelConfig }, "createOrder called with:");
const args = taxJarOrderCreatedMaps.mapPayload({ order, channel });
const result = await this.client.createOrder(args);
this.logger.debug({ createOrder: result }, "createOrder response"); const adapter = new TaxJarOrderCreatedAdapter(this.config);
return taxJarOrderCreatedMaps.mapResponse(result); const response = await adapter.send({ channelConfig, order });
this.logger.debug({ response }, "createOrder response:");
return response;
} }
// * TaxJar doesn't require any action on order fulfillment // * TaxJar doesn't require any action on order fulfillment

View file

@ -28,15 +28,6 @@ function verifyCalculateTaxesPayload(payload: CalculateTaxesPayload) {
return payload; return payload;
} }
// ? maybe make it a part of WebhookResponse?
function handleWebhookError(error: unknown) {
const logger = createLogger({ service: "checkout-calculate-taxes", name: "handleWebhookError" });
if (error instanceof Error) {
logger.error(error.stack);
}
}
export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<CalculateTaxesPayload>({ export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<CalculateTaxesPayload>({
name: "CheckoutCalculateTaxes", name: "CheckoutCalculateTaxes",
apl: saleorApp.apl, apl: saleorApp.apl,
@ -57,27 +48,20 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res,
logger.info("Payload validated succesfully"); logger.info("Payload validated succesfully");
} catch (error) { } catch (error) {
logger.info("Returning no data"); logger.info("Returning no data");
return webhookResponse.failure("Payload is invalid"); return webhookResponse.error(error);
} }
try { try {
const appMetadata = payload.recipient?.privateMetadata ?? []; const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.taxBase.channel.slug; const channelSlug = payload.taxBase.channel.slug;
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata); const taxProvider = getActiveTaxProvider(channelSlug, appMetadata);
if (!activeTaxProvider.ok) { logger.info({ taxProvider }, "Fetched taxProvider");
logger.info("Returning no data");
return webhookResponse.failure(activeTaxProvider.error);
}
logger.info({ activeTaxProvider }, "Fetched activeTaxProvider");
const taxProvider = activeTaxProvider.data;
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase); const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
logger.info({ calculatedTaxes }, "Taxes calculated"); logger.info({ calculatedTaxes }, "Taxes calculated");
return webhookResponse.success(ctx.buildResponse(calculatedTaxes)); return webhookResponse.success(ctx.buildResponse(calculatedTaxes));
} catch (error) { } catch (error) {
handleWebhookError(error); return webhookResponse.error(error);
return webhookResponse.failure("Error while calculating taxes");
} }
}); });

View file

@ -28,15 +28,6 @@ function verifyCalculateTaxesPayload(payload: CalculateTaxesPayload) {
return payload; return payload;
} }
// ? maybe make it a part of WebhookResponse?
function handleWebhookError(error: unknown) {
const logger = createLogger({ service: "order-calculate-taxes", name: "handleWebhookError" });
if (error instanceof Error) {
logger.error(error.stack);
}
}
export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<CalculateTaxesPayload>({ export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<CalculateTaxesPayload>({
name: "OrderCalculateTaxes", name: "OrderCalculateTaxes",
apl: saleorApp.apl, apl: saleorApp.apl,
@ -56,28 +47,21 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx
verifyCalculateTaxesPayload(payload); verifyCalculateTaxesPayload(payload);
logger.info("Payload validated succesfully"); logger.info("Payload validated succesfully");
} catch (error) { } catch (error) {
logger.info("Returning no data"); logger.info("Payload is invalid. Returning no data");
return webhookResponse.failure("Payload is invalid"); return webhookResponse.error(error);
} }
try { try {
const appMetadata = payload.recipient?.privateMetadata ?? []; const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.taxBase.channel.slug; const channelSlug = payload.taxBase.channel.slug;
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata); const taxProvider = getActiveTaxProvider(channelSlug, appMetadata);
if (!activeTaxProvider.ok) { logger.info({ taxProvider }, "Fetched taxProvider");
logger.info("Returning no data");
return webhookResponse.failure(activeTaxProvider.error);
}
logger.info({ activeTaxProvider }, "Fetched activeTaxProvider");
const taxProvider = activeTaxProvider.data;
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase); const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);
logger.info({ calculatedTaxes }, "Taxes calculated"); logger.info({ calculatedTaxes }, "Taxes calculated");
return webhookResponse.success(ctx.buildResponse(calculatedTaxes)); return webhookResponse.success(ctx.buildResponse(calculatedTaxes));
} catch (error) { } catch (error) {
handleWebhookError(error); return webhookResponse.error(error);
return webhookResponse.failure("Error while calculating taxes");
} }
}); });

View file

@ -13,7 +13,7 @@ import { getActiveTaxProvider } from "../../../modules/taxes/active-tax-provider
import { createClient } from "../../../lib/graphql"; import { createClient } from "../../../lib/graphql";
import { Client } from "urql"; import { Client } from "urql";
import { WebhookResponse } from "../../../modules/app/webhook-response"; import { WebhookResponse } from "../../../modules/app/webhook-response";
import { PROVIDER_ORDER_ID_KEY } from "../../../modules/avatax/maps/avatax-order-fulfilled-map"; import { PROVIDER_ORDER_ID_KEY } from "../../../modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer";
export const config = { export const config = {
api: { api: {
@ -74,23 +74,17 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => {
try { try {
const appMetadata = payload.recipient?.privateMetadata ?? []; const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.order?.channel.slug; const channelSlug = payload.order?.channel.slug;
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata); const taxProvider = getActiveTaxProvider(channelSlug, appMetadata);
if (!activeTaxProvider.ok) { logger.info({ taxProvider }, "Fetched taxProvider");
logger.info("Returning no data");
return webhookResponse.failure(activeTaxProvider.error);
}
logger.info({ activeTaxProvider }, "Fetched activeTaxProvider");
const taxProvider = activeTaxProvider.data;
// todo: figure out what fields are needed and add validation // todo: figure out what fields are needed and add validation
if (!payload.order) { if (!payload.order) {
return webhookResponse.failure("Insufficient order data"); return webhookResponse.error(new Error("Insufficient order data"));
} }
if (payload.order.status === OrderStatus.Fulfilled) { if (payload.order.status === OrderStatus.Fulfilled) {
return webhookResponse.failure("Skipping fulfilled order to prevent duplication"); return webhookResponse.error(new Error("Skipping fulfilled order to prevent duplication"));
} }
const createdOrder = await taxProvider.createOrder(payload.order); const createdOrder = await taxProvider.createOrder(payload.order);
@ -104,6 +98,6 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => {
return webhookResponse.success(); return webhookResponse.success();
} catch (error) { } catch (error) {
logger.error({ error }); logger.error({ error });
return webhookResponse.failure("Error while creating order in tax provider"); return webhookResponse.error(new Error("Error while creating order in tax provider"));
} }
}); });

View file

@ -36,19 +36,13 @@ export default orderFulfilledAsyncWebhook.createHandler(async (req, res, ctx) =>
try { try {
const appMetadata = payload.recipient?.privateMetadata ?? []; const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.order?.channel.slug; const channelSlug = payload.order?.channel.slug;
const activeTaxProvider = getActiveTaxProvider(channelSlug, appMetadata); const taxProvider = getActiveTaxProvider(channelSlug, appMetadata);
if (!activeTaxProvider.ok) { logger.info({ taxProvider }, "Fetched taxProvider");
logger.info("Returning no data");
return webhookResponse.failure(activeTaxProvider.error);
}
logger.info({ activeTaxProvider }, "Fetched activeTaxProvider");
const taxProvider = activeTaxProvider.data;
// todo: figure out what fields are needed and add validation // todo: figure out what fields are needed and add validation
if (!payload.order) { if (!payload.order) {
return webhookResponse.failure("Insufficient order data"); return webhookResponse.error(new Error("Insufficient order data"));
} }
const fulfilledOrder = await taxProvider.fulfillOrder(payload.order); const fulfilledOrder = await taxProvider.fulfillOrder(payload.order);
@ -56,6 +50,6 @@ export default orderFulfilledAsyncWebhook.createHandler(async (req, res, ctx) =>
return webhookResponse.success(); return webhookResponse.success();
} catch (error) { } catch (error) {
return webhookResponse.failure("Error while fulfilling tax provider order"); return webhookResponse.error(new Error("Error while fulfilling tax provider order"));
} }
}); });