Skip to content

Commit 8e72961

Browse files
committed
feat: add taxed price to cart and order
1 parent 043acdf commit 8e72961

File tree

8 files changed

+595
-89
lines changed

8 files changed

+595
-89
lines changed

.changeset/honest-cars-admire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@labdigital/commercetools-mock": minor
3+
---
4+
5+
Add taxedPrice to cart and order

src/lib/tax.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type {
2+
TaxCategory,
3+
TaxRate,
4+
TaxedItemPrice,
5+
} from "@commercetools/platform-sdk";
6+
import { describe, expect, test } from "vitest";
7+
import {
8+
calculateTaxTotals,
9+
calculateTaxedPrice,
10+
calculateTaxedPriceFromRate,
11+
} from "~src/lib/tax";
12+
13+
const money = (centAmount: number) => ({
14+
type: "centPrecision" as const,
15+
currencyCode: "EUR",
16+
centAmount,
17+
fractionDigits: 2,
18+
});
19+
20+
const createTaxedItemPrice = (net: number, gross: number, rate = 0.21) => ({
21+
totalNet: money(net),
22+
totalGross: money(gross),
23+
totalTax: money(gross - net),
24+
taxPortions: [
25+
{
26+
rate,
27+
amount: money(gross - net),
28+
},
29+
],
30+
});
31+
32+
describe("tax helpers", () => {
33+
test("calculateTaxTotals aggregates line, custom, and shipping taxes", () => {
34+
const lineTaxed = createTaxedItemPrice(1000, 1210);
35+
const customTaxed = createTaxedItemPrice(500, 605);
36+
const shippingTaxed: TaxedItemPrice = createTaxedItemPrice(300, 363);
37+
38+
const resource = {
39+
lineItems: [{ taxedPrice: lineTaxed }] as any,
40+
customLineItems: [{ taxedPrice: customTaxed }] as any,
41+
shippingInfo: { taxedPrice: shippingTaxed } as any,
42+
totalPrice: money(0),
43+
};
44+
45+
const { taxedPrice, taxedShippingPrice } = calculateTaxTotals(resource);
46+
47+
expect(taxedPrice).toBeDefined();
48+
expect(taxedPrice?.totalNet.centAmount).toBe(1000 + 500 + 300);
49+
expect(taxedPrice?.totalGross.centAmount).toBe(1210 + 605 + 363);
50+
expect(taxedPrice?.totalTax?.centAmount).toBe(210 + 105 + 63);
51+
expect(taxedPrice?.taxPortions).toHaveLength(1);
52+
expect(taxedPrice?.taxPortions?.[0].amount.centAmount).toBe(378);
53+
expect(taxedShippingPrice).toEqual(shippingTaxed);
54+
});
55+
56+
test("calculateTaxedPriceFromRate handles net amounts", () => {
57+
const rate: TaxRate = {
58+
amount: 0.2,
59+
includedInPrice: false,
60+
name: "Standard",
61+
country: "NL",
62+
id: "rate",
63+
subRates: [],
64+
};
65+
66+
const taxed = calculateTaxedPriceFromRate(1000, "EUR", rate)!;
67+
expect(taxed.totalNet.centAmount).toBe(1000);
68+
expect(taxed.totalGross.centAmount).toBe(1200);
69+
expect(taxed.totalTax?.centAmount).toBe(200);
70+
});
71+
72+
test("calculateTaxedPriceFromRate handles gross amounts", () => {
73+
const rate: TaxRate = {
74+
amount: 0.25,
75+
includedInPrice: true,
76+
name: "Gross",
77+
id: "gross",
78+
country: "BE",
79+
subRates: [],
80+
};
81+
82+
const taxed = calculateTaxedPriceFromRate(1250, "EUR", rate)!;
83+
expect(taxed.totalGross.centAmount).toBe(1250);
84+
expect(taxed.totalNet.centAmount).toBe(1000);
85+
expect(taxed.totalTax?.centAmount).toBe(250);
86+
});
87+
88+
test("calculateTaxedPrice selects matching tax rate from category", () => {
89+
const taxCategory: TaxCategory = {
90+
id: "tax-cat",
91+
version: 1,
92+
createdAt: "2024-01-01T00:00:00.000Z",
93+
lastModifiedAt: "2024-01-01T00:00:00.000Z",
94+
name: "Standard",
95+
rates: [
96+
{
97+
id: "default",
98+
amount: 0.1,
99+
includedInPrice: false,
100+
country: "DE",
101+
name: "DE",
102+
subRates: [],
103+
},
104+
{
105+
id: "nl",
106+
amount: 0.21,
107+
includedInPrice: false,
108+
country: "NL",
109+
name: "NL",
110+
subRates: [],
111+
},
112+
],
113+
};
114+
115+
const taxed = calculateTaxedPrice(1000, taxCategory, "EUR", "NL")!;
116+
expect(taxed.totalGross.centAmount).toBe(1210);
117+
expect(taxed.totalTax?.centAmount).toBe(210);
118+
});
119+
});

src/lib/tax.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import type {
2+
Cart,
3+
TaxCategory,
4+
TaxPortion,
5+
TaxRate,
6+
TaxedItemPrice,
7+
TaxedPrice,
8+
} from "@commercetools/platform-sdk";
9+
import { createCentPrecisionMoney } from "~src/repositories/helpers";
10+
11+
type TaxableResource = Pick<
12+
Cart,
13+
"lineItems" | "customLineItems" | "shippingInfo" | "totalPrice"
14+
>;
15+
16+
export const calculateTaxTotals = (
17+
resource: TaxableResource,
18+
): {
19+
taxedPrice?: TaxedPrice;
20+
taxedShippingPrice?: TaxedItemPrice;
21+
} => {
22+
const taxedItemPrices: TaxedItemPrice[] = [];
23+
24+
resource.lineItems.forEach((item) => {
25+
if (item.taxedPrice) {
26+
taxedItemPrices.push(item.taxedPrice);
27+
}
28+
});
29+
30+
resource.customLineItems.forEach((item) => {
31+
if (item.taxedPrice) {
32+
taxedItemPrices.push(item.taxedPrice);
33+
}
34+
});
35+
36+
let taxedShippingPrice: TaxedItemPrice | undefined;
37+
if (resource.shippingInfo?.taxedPrice) {
38+
taxedShippingPrice = resource.shippingInfo.taxedPrice;
39+
taxedItemPrices.push(resource.shippingInfo.taxedPrice);
40+
}
41+
42+
if (!taxedItemPrices.length) {
43+
return {
44+
taxedPrice: undefined,
45+
taxedShippingPrice,
46+
};
47+
}
48+
49+
const currencyCode = resource.totalPrice.currencyCode;
50+
const toMoney = (centAmount: number) =>
51+
createCentPrecisionMoney({
52+
currencyCode,
53+
centAmount,
54+
});
55+
56+
let totalNet = 0;
57+
let totalGross = 0;
58+
let totalTax = 0;
59+
60+
const taxPortionsByRate = new Map<
61+
string,
62+
{ rate: number; name?: string; centAmount: number }
63+
>();
64+
65+
taxedItemPrices.forEach((price) => {
66+
totalNet += price.totalNet.centAmount;
67+
totalGross += price.totalGross.centAmount;
68+
const priceTax = price.totalTax
69+
? price.totalTax.centAmount
70+
: price.totalGross.centAmount - price.totalNet.centAmount;
71+
totalTax += Math.max(priceTax, 0);
72+
73+
price.taxPortions?.forEach((portion) => {
74+
const key = `${portion.rate}-${portion.name ?? ""}`;
75+
const existing = taxPortionsByRate.get(key) ?? {
76+
rate: portion.rate,
77+
name: portion.name,
78+
centAmount: 0,
79+
};
80+
existing.centAmount += portion.amount.centAmount;
81+
taxPortionsByRate.set(key, existing);
82+
});
83+
});
84+
85+
const taxPortions: TaxPortion[] = Array.from(taxPortionsByRate.values()).map(
86+
(portion) => ({
87+
rate: portion.rate,
88+
name: portion.name,
89+
amount: toMoney(portion.centAmount),
90+
}),
91+
);
92+
93+
return {
94+
taxedPrice: {
95+
totalNet: toMoney(totalNet),
96+
totalGross: toMoney(totalGross),
97+
taxPortions,
98+
totalTax: totalTax > 0 ? toMoney(totalTax) : undefined,
99+
},
100+
taxedShippingPrice,
101+
};
102+
};
103+
104+
export const buildTaxedPriceFromRate = (
105+
amount: number,
106+
currencyCode: string,
107+
taxRate?: TaxRate,
108+
): TaxedItemPrice | undefined => {
109+
if (!taxRate) {
110+
return undefined;
111+
}
112+
113+
const toMoney = (centAmount: number) =>
114+
createCentPrecisionMoney({
115+
type: "centPrecision",
116+
currencyCode,
117+
centAmount,
118+
});
119+
120+
let netAmount: number;
121+
let grossAmount: number;
122+
let taxAmount: number;
123+
124+
if (taxRate.includedInPrice) {
125+
grossAmount = amount;
126+
taxAmount = Math.round(
127+
(grossAmount * taxRate.amount) / (1 + taxRate.amount),
128+
);
129+
netAmount = grossAmount - taxAmount;
130+
} else {
131+
netAmount = amount;
132+
taxAmount = Math.round(netAmount * taxRate.amount);
133+
grossAmount = netAmount + taxAmount;
134+
}
135+
136+
return {
137+
totalNet: toMoney(netAmount),
138+
totalGross: toMoney(grossAmount),
139+
totalTax: taxAmount > 0 ? toMoney(taxAmount) : undefined,
140+
taxPortions:
141+
taxAmount > 0
142+
? [
143+
{
144+
rate: taxRate.amount,
145+
name: taxRate.name,
146+
amount: toMoney(taxAmount),
147+
},
148+
]
149+
: [],
150+
};
151+
};
152+
153+
export const calculateTaxedPriceFromRate = (
154+
amount: number,
155+
currencyCode: string,
156+
taxRate?: TaxRate,
157+
): TaxedItemPrice | undefined =>
158+
buildTaxedPriceFromRate(amount, currencyCode, taxRate);
159+
160+
export const calculateTaxedPrice = (
161+
amount: number,
162+
taxCategory: TaxCategory | undefined,
163+
currency: string,
164+
country: string | undefined,
165+
): TaxedPrice | undefined => {
166+
if (!taxCategory || !taxCategory.rates.length) {
167+
return undefined;
168+
}
169+
170+
const taxRate =
171+
taxCategory.rates.find(
172+
(rate) => !rate.country || rate.country === country,
173+
) || taxCategory.rates[0];
174+
175+
const taxedItemPrice = buildTaxedPriceFromRate(amount, currency, taxRate);
176+
if (!taxedItemPrice) {
177+
return undefined;
178+
}
179+
180+
return {
181+
totalNet: taxedItemPrice.totalNet,
182+
totalGross: taxedItemPrice.totalGross,
183+
taxPortions: taxedItemPrice.taxPortions,
184+
totalTax: taxedItemPrice.totalTax,
185+
};
186+
};

0 commit comments

Comments
 (0)