@labdigital/commercetools-mock 2.59.0 → 2.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@labdigital/commercetools-mock",
3
- "version": "2.59.0",
3
+ "version": "2.60.0",
4
4
  "license": "MIT",
5
5
  "author": "Michael van Tellingen",
6
6
  "type": "module",
@@ -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
+ });
package/src/lib/tax.ts ADDED
@@ -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
+ };
@@ -28,6 +28,7 @@ import type {
28
28
  CartSetDirectDiscountsAction,
29
29
  CartSetLineItemCustomFieldAction,
30
30
  CartSetLineItemCustomTypeAction,
31
+ CartSetLineItemPriceAction,
31
32
  CartSetLineItemShippingDetailsAction,
32
33
  CartSetLocaleAction,
33
34
  CartSetShippingAddressAction,
@@ -740,6 +741,72 @@ export class CartUpdateHandler
740
741
  }
741
742
  }
742
743
 
744
+ setLineItemPrice(
745
+ context: RepositoryContext,
746
+ resource: Writable<Cart>,
747
+ { lineItemId, lineItemKey, externalPrice }: CartSetLineItemPriceAction,
748
+ ) {
749
+ const lineItem = resource.lineItems.find(
750
+ (x) =>
751
+ (lineItemId && x.id === lineItemId) ||
752
+ (lineItemKey && x.key === lineItemKey),
753
+ );
754
+
755
+ if (!lineItem) {
756
+ throw new CommercetoolsError<GeneralError>({
757
+ code: "General",
758
+ message: lineItemKey
759
+ ? `A line item with key '${lineItemKey}' not found.`
760
+ : `A line item with ID '${lineItemId}' not found.`,
761
+ });
762
+ }
763
+
764
+ if (!externalPrice && lineItem.priceMode !== "ExternalPrice") {
765
+ return;
766
+ }
767
+
768
+ if (
769
+ externalPrice &&
770
+ externalPrice.currencyCode !== resource.totalPrice.currencyCode
771
+ ) {
772
+ throw new CommercetoolsError<GeneralError>({
773
+ code: "General",
774
+ message: `Currency mismatch. Expected '${resource.totalPrice.currencyCode}' but got '${externalPrice.currencyCode}'.`,
775
+ });
776
+ }
777
+
778
+ if (externalPrice) {
779
+ lineItem.priceMode = "ExternalPrice";
780
+ const priceValue = createTypedMoney(externalPrice);
781
+
782
+ lineItem.price = lineItem.price ?? { id: uuidv4() };
783
+ lineItem.price.value = priceValue;
784
+ } else {
785
+ lineItem.priceMode = "Platform";
786
+
787
+ const price = selectPrice({
788
+ prices: lineItem.variant.prices,
789
+ currency: resource.totalPrice.currencyCode,
790
+ country: resource.country,
791
+ });
792
+
793
+ if (!price) {
794
+ throw new Error(
795
+ `No valid price found for ${lineItem.productId} for country ${resource.country} and currency ${resource.totalPrice.currencyCode}`,
796
+ );
797
+ }
798
+
799
+ lineItem.price = price;
800
+ }
801
+
802
+ const lineItemTotal = calculateLineItemTotalPrice(lineItem);
803
+ lineItem.totalPrice = createCentPrecisionMoney({
804
+ ...lineItem.price!.value,
805
+ centAmount: lineItemTotal,
806
+ });
807
+ resource.totalPrice.centAmount = calculateCartTotalPrice(resource);
808
+ }
809
+
743
810
  setLineItemShippingDetails(
744
811
  context: RepositoryContext,
745
812
  resource: Writable<Cart>,
@@ -1,15 +1,14 @@
1
1
  import type {
2
2
  Cart,
3
- CentPrecisionMoney,
4
3
  CustomLineItem,
5
4
  CustomLineItemDraft,
6
5
  LineItem,
7
6
  Price,
8
7
  TaxCategory,
9
8
  TaxCategoryReference,
10
- TaxedPrice,
11
9
  } from "@commercetools/platform-sdk";
12
10
  import { v4 as uuidv4 } from "uuid";
11
+ import { calculateTaxedPrice } from "~src/lib/tax";
13
12
  import type { AbstractStorage } from "~src/storage/abstract";
14
13
  import {
15
14
  createCentPrecisionMoney,
@@ -56,84 +55,6 @@ export const calculateCartTotalPrice = (cart: Cart): number => {
56
55
  return lineItemsTotal + customLineItemsTotal;
57
56
  };
58
57
 
59
- export const calculateTaxedPrice = (
60
- amount: number,
61
- taxCategory: TaxCategory | undefined,
62
- currency: string,
63
- country: string | undefined,
64
- ): TaxedPrice | undefined => {
65
- if (!taxCategory || !taxCategory.rates.length) {
66
- return undefined;
67
- }
68
-
69
- // Find the appropriate tax rate for the country
70
- const taxRate =
71
- taxCategory.rates.find(
72
- (rate) => !rate.country || rate.country === country,
73
- ) || taxCategory.rates[0]; // Fallback to first rate if no country-specific rate found
74
-
75
- if (!taxRate) {
76
- return undefined;
77
- }
78
-
79
- let netAmount: number;
80
- let grossAmount: number;
81
- let taxAmount: number;
82
-
83
- if (taxRate.includedInPrice) {
84
- // Amount is gross, calculate net
85
- grossAmount = amount;
86
- taxAmount = Math.round(
87
- (grossAmount * taxRate.amount) / (1 + taxRate.amount),
88
- );
89
- netAmount = grossAmount - taxAmount;
90
- } else {
91
- // Amount is net, calculate gross
92
- netAmount = amount;
93
- taxAmount = Math.round(netAmount * taxRate.amount);
94
- grossAmount = netAmount + taxAmount;
95
- }
96
-
97
- return {
98
- totalNet: {
99
- type: "centPrecision",
100
- currencyCode: currency,
101
- centAmount: netAmount,
102
- fractionDigits: 2,
103
- },
104
- totalGross: {
105
- type: "centPrecision",
106
- currencyCode: currency,
107
- centAmount: grossAmount,
108
- fractionDigits: 2,
109
- },
110
- taxPortions:
111
- taxAmount > 0
112
- ? [
113
- {
114
- rate: taxRate.amount,
115
- amount: {
116
- type: "centPrecision",
117
- currencyCode: currency,
118
- centAmount: taxAmount,
119
- fractionDigits: 2,
120
- },
121
- name: taxRate.name,
122
- },
123
- ]
124
- : [],
125
- totalTax:
126
- taxAmount > 0
127
- ? {
128
- type: "centPrecision",
129
- currencyCode: currency,
130
- centAmount: taxAmount,
131
- fractionDigits: 2,
132
- }
133
- : undefined,
134
- };
135
- };
136
-
137
58
  export const createCustomLineItemFromDraft = (
138
59
  projectKey: string,
139
60
  draft: CustomLineItemDraft,
@@ -389,6 +389,54 @@ describe("Cart repository", () => {
389
389
  expect(customLineItem.taxRate?.includedInPrice).toBe(false);
390
390
  expect(customLineItem.taxRate?.country).toBe("NL");
391
391
  });
392
+
393
+ test("should calculate taxed price for the cart", () => {
394
+ storage.add("dummy", "tax-category", {
395
+ ...getBaseResourceProperties(),
396
+ id: "cart-tax-category",
397
+ key: "cart-vat-tax",
398
+ name: "Cart VAT Tax",
399
+ rates: [
400
+ {
401
+ id: "cart-rate-1",
402
+ name: "Standard VAT",
403
+ amount: 0.21,
404
+ includedInPrice: false,
405
+ country: "NL",
406
+ },
407
+ ],
408
+ });
409
+
410
+ const cart: CartDraft = {
411
+ currency: "EUR",
412
+ country: "NL",
413
+ customLineItems: [
414
+ {
415
+ name: { en: "Gift Wrap" },
416
+ slug: "gift-wrap",
417
+ money: {
418
+ currencyCode: "EUR",
419
+ centAmount: 1000,
420
+ },
421
+ quantity: 1,
422
+ taxCategory: {
423
+ typeId: "tax-category" as const,
424
+ id: "cart-tax-category",
425
+ },
426
+ },
427
+ ],
428
+ };
429
+
430
+ const ctx = { projectKey: "dummy", storeKey: "dummyStore" };
431
+ const result = repository.create(ctx, cart);
432
+
433
+ expect(result.taxedPrice).toBeDefined();
434
+ expect(result.taxedPrice?.totalNet.centAmount).toBe(1000);
435
+ expect(result.taxedPrice?.totalGross.centAmount).toBe(1210);
436
+ expect(result.taxedPrice?.totalTax?.centAmount).toBe(210);
437
+ expect(result.taxedPrice?.taxPortions).toHaveLength(1);
438
+ expect(result.taxedPrice?.taxPortions?.[0].rate).toBe(0.21);
439
+ });
392
440
  });
393
441
 
394
442
  describe("createShippingInfo", () => {