Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/honest-cars-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@labdigital/commercetools-mock": minor
---

Add taxedPrice to cart and order
119 changes: 119 additions & 0 deletions src/lib/tax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type {
TaxCategory,
TaxRate,
TaxedItemPrice,
} from "@commercetools/platform-sdk";
import { describe, expect, test } from "vitest";
import {
calculateTaxTotals,
calculateTaxedPrice,
calculateTaxedPriceFromRate,
} from "~src/lib/tax";

const money = (centAmount: number) => ({
type: "centPrecision" as const,
currencyCode: "EUR",
centAmount,
fractionDigits: 2,
});

const createTaxedItemPrice = (net: number, gross: number, rate = 0.21) => ({
totalNet: money(net),
totalGross: money(gross),
totalTax: money(gross - net),
taxPortions: [
{
rate,
amount: money(gross - net),
},
],
});

describe("tax helpers", () => {
test("calculateTaxTotals aggregates line, custom, and shipping taxes", () => {
const lineTaxed = createTaxedItemPrice(1000, 1210);
const customTaxed = createTaxedItemPrice(500, 605);
const shippingTaxed: TaxedItemPrice = createTaxedItemPrice(300, 363);

const resource = {
lineItems: [{ taxedPrice: lineTaxed }] as any,
customLineItems: [{ taxedPrice: customTaxed }] as any,
shippingInfo: { taxedPrice: shippingTaxed } as any,
totalPrice: money(0),
};

const { taxedPrice, taxedShippingPrice } = calculateTaxTotals(resource);

expect(taxedPrice).toBeDefined();
expect(taxedPrice?.totalNet.centAmount).toBe(1000 + 500 + 300);
expect(taxedPrice?.totalGross.centAmount).toBe(1210 + 605 + 363);
expect(taxedPrice?.totalTax?.centAmount).toBe(210 + 105 + 63);
expect(taxedPrice?.taxPortions).toHaveLength(1);
expect(taxedPrice?.taxPortions?.[0].amount.centAmount).toBe(378);
expect(taxedShippingPrice).toEqual(shippingTaxed);
});

test("calculateTaxedPriceFromRate handles net amounts", () => {
const rate: TaxRate = {
amount: 0.2,
includedInPrice: false,
name: "Standard",
country: "NL",
id: "rate",
subRates: [],
};

const taxed = calculateTaxedPriceFromRate(1000, "EUR", rate)!;
expect(taxed.totalNet.centAmount).toBe(1000);
expect(taxed.totalGross.centAmount).toBe(1200);
expect(taxed.totalTax?.centAmount).toBe(200);
});

test("calculateTaxedPriceFromRate handles gross amounts", () => {
const rate: TaxRate = {
amount: 0.25,
includedInPrice: true,
name: "Gross",
id: "gross",
country: "BE",
subRates: [],
};

const taxed = calculateTaxedPriceFromRate(1250, "EUR", rate)!;
expect(taxed.totalGross.centAmount).toBe(1250);
expect(taxed.totalNet.centAmount).toBe(1000);
expect(taxed.totalTax?.centAmount).toBe(250);
});

test("calculateTaxedPrice selects matching tax rate from category", () => {
const taxCategory: TaxCategory = {
id: "tax-cat",
version: 1,
createdAt: "2024-01-01T00:00:00.000Z",
lastModifiedAt: "2024-01-01T00:00:00.000Z",
name: "Standard",
rates: [
{
id: "default",
amount: 0.1,
includedInPrice: false,
country: "DE",
name: "DE",
subRates: [],
},
{
id: "nl",
amount: 0.21,
includedInPrice: false,
country: "NL",
name: "NL",
subRates: [],
},
],
};

const taxed = calculateTaxedPrice(1000, taxCategory, "EUR", "NL")!;
expect(taxed.totalGross.centAmount).toBe(1210);
expect(taxed.totalTax?.centAmount).toBe(210);
});
});
186 changes: 186 additions & 0 deletions src/lib/tax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import type {
Cart,
TaxCategory,
TaxPortion,
TaxRate,
TaxedItemPrice,
TaxedPrice,
} from "@commercetools/platform-sdk";
import { createCentPrecisionMoney } from "~src/repositories/helpers";

type TaxableResource = Pick<
Cart,
"lineItems" | "customLineItems" | "shippingInfo" | "totalPrice"
>;

export const calculateTaxTotals = (
resource: TaxableResource,
): {
taxedPrice?: TaxedPrice;
taxedShippingPrice?: TaxedItemPrice;
} => {
const taxedItemPrices: TaxedItemPrice[] = [];

resource.lineItems.forEach((item) => {
if (item.taxedPrice) {
taxedItemPrices.push(item.taxedPrice);
}
});

resource.customLineItems.forEach((item) => {
if (item.taxedPrice) {
taxedItemPrices.push(item.taxedPrice);
}
});

let taxedShippingPrice: TaxedItemPrice | undefined;
if (resource.shippingInfo?.taxedPrice) {
taxedShippingPrice = resource.shippingInfo.taxedPrice;
taxedItemPrices.push(resource.shippingInfo.taxedPrice);
}

if (!taxedItemPrices.length) {
return {
taxedPrice: undefined,
taxedShippingPrice,
};
}

const currencyCode = resource.totalPrice.currencyCode;
const toMoney = (centAmount: number) =>
createCentPrecisionMoney({
currencyCode,
centAmount,
});

let totalNet = 0;
let totalGross = 0;
let totalTax = 0;

const taxPortionsByRate = new Map<
string,
{ rate: number; name?: string; centAmount: number }
>();

taxedItemPrices.forEach((price) => {
totalNet += price.totalNet.centAmount;
totalGross += price.totalGross.centAmount;
const priceTax = price.totalTax
? price.totalTax.centAmount
: price.totalGross.centAmount - price.totalNet.centAmount;
totalTax += Math.max(priceTax, 0);

price.taxPortions?.forEach((portion) => {
const key = `${portion.rate}-${portion.name ?? ""}`;
const existing = taxPortionsByRate.get(key) ?? {
rate: portion.rate,
name: portion.name,
centAmount: 0,
};
existing.centAmount += portion.amount.centAmount;
taxPortionsByRate.set(key, existing);
});
});

const taxPortions: TaxPortion[] = Array.from(taxPortionsByRate.values()).map(
(portion) => ({
rate: portion.rate,
name: portion.name,
amount: toMoney(portion.centAmount),
}),
);

return {
taxedPrice: {
totalNet: toMoney(totalNet),
totalGross: toMoney(totalGross),
taxPortions,
totalTax: totalTax > 0 ? toMoney(totalTax) : undefined,
},
taxedShippingPrice,
};
};

export const buildTaxedPriceFromRate = (
amount: number,
currencyCode: string,
taxRate?: TaxRate,
): TaxedItemPrice | undefined => {
if (!taxRate) {
return undefined;
}

const toMoney = (centAmount: number) =>
createCentPrecisionMoney({
type: "centPrecision",
currencyCode,
centAmount,
});

let netAmount: number;
let grossAmount: number;
let taxAmount: number;

if (taxRate.includedInPrice) {
grossAmount = amount;
taxAmount = Math.round(
(grossAmount * taxRate.amount) / (1 + taxRate.amount),
);
netAmount = grossAmount - taxAmount;
} else {
netAmount = amount;
taxAmount = Math.round(netAmount * taxRate.amount);
grossAmount = netAmount + taxAmount;
}

return {
totalNet: toMoney(netAmount),
totalGross: toMoney(grossAmount),
totalTax: taxAmount > 0 ? toMoney(taxAmount) : undefined,
taxPortions:
taxAmount > 0
? [
{
rate: taxRate.amount,
name: taxRate.name,
amount: toMoney(taxAmount),
},
]
: [],
};
};

export const calculateTaxedPriceFromRate = (
amount: number,
currencyCode: string,
taxRate?: TaxRate,
): TaxedItemPrice | undefined =>
buildTaxedPriceFromRate(amount, currencyCode, taxRate);

export const calculateTaxedPrice = (
amount: number,
taxCategory: TaxCategory | undefined,
currency: string,
country: string | undefined,
): TaxedPrice | undefined => {
if (!taxCategory || !taxCategory.rates.length) {
return undefined;
}

const taxRate =
taxCategory.rates.find(
(rate) => !rate.country || rate.country === country,
) || taxCategory.rates[0];

const taxedItemPrice = buildTaxedPriceFromRate(amount, currency, taxRate);
if (!taxedItemPrice) {
return undefined;
}

return {
totalNet: taxedItemPrice.totalNet,
totalGross: taxedItemPrice.totalGross,
taxPortions: taxedItemPrice.taxPortions,
totalTax: taxedItemPrice.totalTax,
};
};
Loading