@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.
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  BusinessUnit,
3
- CentPrecisionMoney,
4
3
  InvalidOperationError,
5
4
  MissingTaxRateForCountryError,
6
5
  ShippingMethodDoesNotMatchCartError,
@@ -15,27 +14,22 @@ import type {
15
14
  LineItemDraft,
16
15
  Product,
17
16
  ProductPagedQueryResponse,
18
- TaxPortion,
19
- TaxedItemPrice,
20
17
  } from "@commercetools/platform-sdk";
21
- import { Decimal } from "decimal.js/decimal";
22
18
  import { v4 as uuidv4 } from "uuid";
23
19
  import type { Config } from "~src/config";
24
20
  import { CommercetoolsError } from "~src/exceptions";
25
21
  import { getBaseResourceProperties } from "~src/helpers";
26
- import { getShippingMethodsMatchingCart } from "~src/shipping";
22
+ import { calculateTaxTotals } from "~src/lib/tax";
23
+ import {
24
+ createShippingInfoFromMethod,
25
+ getShippingMethodsMatchingCart,
26
+ } from "~src/shipping";
27
27
  import type { Writable } from "~src/types";
28
28
  import {
29
29
  AbstractResourceRepository,
30
30
  type RepositoryContext,
31
31
  } from "../abstract";
32
- import {
33
- createAddress,
34
- createCentPrecisionMoney,
35
- createCustomFields,
36
- createTypedMoney,
37
- roundDecimal,
38
- } from "../helpers";
32
+ import { createAddress, createCustomFields } from "../helpers";
39
33
  import { CartUpdateHandler } from "./actions";
40
34
  import {
41
35
  calculateCartTotalPrice,
@@ -166,6 +160,10 @@ export class CartRepository extends AbstractResourceRepository<"cart"> {
166
160
  );
167
161
  }
168
162
 
163
+ const { taxedPrice, taxedShippingPrice } = calculateTaxTotals(resource);
164
+ resource.taxedPrice = taxedPrice;
165
+ resource.taxedShippingPrice = taxedShippingPrice;
166
+
169
167
  return this.saveNew(context, resource);
170
168
  }
171
169
 
@@ -284,15 +282,6 @@ export class CartRepository extends AbstractResourceRepository<"cart"> {
284
282
  throw new Error("External tax rate is not supported");
285
283
  }
286
284
 
287
- const country = resource.shippingAddress?.country;
288
-
289
- if (!country) {
290
- throw new CommercetoolsError<InvalidOperationError>({
291
- code: "InvalidOperation",
292
- message: `The cart with ID '${resource.id}' does not have a shipping address set.`,
293
- });
294
- }
295
-
296
285
  // Bit of a hack: calling this checks that the resource identifier is
297
286
  // valid (i.e. id xor key) and that the shipping method exists.
298
287
  this._storage.getByResourceIdentifier<"shipping-method">(
@@ -327,126 +316,12 @@ export class CartRepository extends AbstractResourceRepository<"cart"> {
327
316
  });
328
317
  }
329
318
 
330
- const taxCategory = this._storage.getByResourceIdentifier<"tax-category">(
331
- context.projectKey,
332
- method.taxCategory,
333
- );
334
-
335
- // TODO: match state in addition to country
336
- const taxRate = taxCategory.rates.find((rate) => rate.country === country);
337
-
338
- if (!taxRate) {
339
- throw new CommercetoolsError<MissingTaxRateForCountryError>({
340
- code: "MissingTaxRateForCountry",
341
- message: `Tax category '${taxCategory.id}' is missing a tax rate for country '${country}'.`,
342
- taxCategoryId: taxCategory.id,
343
- });
344
- }
345
-
346
- // There should only be one zone rate matching the address, since
347
- // Locations cannot be assigned to more than one zone.
348
- // See https://docs.commercetools.com/api/projects/zones#location
349
- const zoneRate = method.zoneRates.find((rate) =>
350
- rate.zone.obj?.locations.some((loc) => loc.country === country),
319
+ // Use the shared shipping info creation logic
320
+ return createShippingInfoFromMethod(
321
+ context,
322
+ this._storage,
323
+ resource,
324
+ method,
351
325
  );
352
-
353
- if (!zoneRate) {
354
- // This shouldn't happen because getShippingMethodsMatchingCart already
355
- // filtered out shipping methods without any zones matching the address
356
- throw new Error("Zone rate not found");
357
- }
358
-
359
- // Shipping rates are defined by currency, and getShippingMethodsMatchingCart
360
- // also matches on currency, so there should only be one in the array.
361
- // See https://docs.commercetools.com/api/projects/shippingMethods#zonerate
362
- const shippingRate = zoneRate.shippingRates[0];
363
- if (!shippingRate) {
364
- // This shouldn't happen because getShippingMethodsMatchingCart already
365
- // filtered out shipping methods without any matching rates
366
- throw new Error("Shipping rate not found");
367
- }
368
-
369
- const shippingRateTier = shippingRate.tiers.find((tier) => tier.isMatching);
370
- if (shippingRateTier && shippingRateTier.type !== "CartValue") {
371
- throw new Error("Non-CartValue shipping rate tier is not supported");
372
- }
373
-
374
- let shippingPrice = shippingRateTier
375
- ? createCentPrecisionMoney(shippingRateTier.price)
376
- : shippingRate.price;
377
-
378
- // Handle freeAbove: if cart total is above the freeAbove threshold, shipping is free
379
- if (
380
- shippingRate.freeAbove &&
381
- shippingRate.freeAbove.currencyCode ===
382
- resource.totalPrice.currencyCode &&
383
- resource.totalPrice.centAmount >= shippingRate.freeAbove.centAmount
384
- ) {
385
- shippingPrice = {
386
- ...shippingPrice,
387
- centAmount: 0,
388
- };
389
- }
390
-
391
- // Calculate tax amounts
392
- const totalGross: CentPrecisionMoney = taxRate.includedInPrice
393
- ? shippingPrice
394
- : {
395
- ...shippingPrice,
396
- centAmount: roundDecimal(
397
- new Decimal(shippingPrice.centAmount).mul(1 + taxRate.amount),
398
- resource.taxRoundingMode,
399
- ).toNumber(),
400
- };
401
-
402
- const totalNet: CentPrecisionMoney = taxRate.includedInPrice
403
- ? {
404
- ...shippingPrice,
405
- centAmount: roundDecimal(
406
- new Decimal(shippingPrice.centAmount).div(1 + taxRate.amount),
407
- resource.taxRoundingMode,
408
- ).toNumber(),
409
- }
410
- : shippingPrice;
411
-
412
- const taxPortions: TaxPortion[] = [
413
- {
414
- name: taxRate.name,
415
- rate: taxRate.amount,
416
- amount: {
417
- ...shippingPrice,
418
- centAmount: totalGross.centAmount - totalNet.centAmount,
419
- },
420
- },
421
- ];
422
-
423
- const totalTax: CentPrecisionMoney = {
424
- ...shippingPrice,
425
- centAmount: taxPortions.reduce(
426
- (acc, portion) => acc + portion.amount.centAmount,
427
- 0,
428
- ),
429
- };
430
-
431
- const taxedPrice: TaxedItemPrice = {
432
- totalNet,
433
- totalGross,
434
- taxPortions,
435
- totalTax,
436
- };
437
-
438
- return {
439
- shippingMethod: {
440
- typeId: "shipping-method" as const,
441
- id: method.id,
442
- },
443
- shippingMethodName: method.name,
444
- price: shippingPrice,
445
- shippingRate,
446
- taxedPrice,
447
- taxRate,
448
- taxCategory: method.taxCategory,
449
- shippingMethodState: "MatchesCart",
450
- };
451
326
  }
452
327
  }
@@ -2,6 +2,7 @@ import { beforeEach } from "node:test";
2
2
  import type {
3
3
  Cart,
4
4
  LineItem,
5
+ Order,
5
6
  OrderImportDraft,
6
7
  } from "@commercetools/platform-sdk";
7
8
  import { describe, expect, test } from "vitest";
@@ -187,6 +188,108 @@ describe("Order repository", () => {
187
188
  expect(result.taxRoundingMode).toEqual(cart.taxRoundingMode);
188
189
  expect(result.totalPrice).toEqual(cart.totalPrice);
189
190
  expect(result.store).toEqual(cart.store);
191
+ // Test that shippingInfo is copied from cart to order
192
+ expect(result.shippingInfo).toEqual(cart.shippingInfo);
193
+ });
194
+
195
+ test("should calculate taxed price when creating order from cart", () => {
196
+ const cart: Cart = {
197
+ ...getBaseResourceProperties(),
198
+ id: "cart-with-taxed-items",
199
+ version: 1,
200
+ cartState: "Active",
201
+ lineItems: [
202
+ {
203
+ id: "li-1",
204
+ productId: "product-1",
205
+ variantId: 1,
206
+ quantity: 1,
207
+ name: { en: "Test" },
208
+ variant: {
209
+ id: 1,
210
+ sku: "TEST",
211
+ },
212
+ price: {
213
+ value: {
214
+ type: "centPrecision",
215
+ currencyCode: "EUR",
216
+ centAmount: 1000,
217
+ fractionDigits: 2,
218
+ },
219
+ },
220
+ totalPrice: {
221
+ type: "centPrecision",
222
+ currencyCode: "EUR",
223
+ centAmount: 1000,
224
+ fractionDigits: 2,
225
+ },
226
+ taxedPrice: {
227
+ totalNet: {
228
+ type: "centPrecision",
229
+ currencyCode: "EUR",
230
+ centAmount: 1000,
231
+ fractionDigits: 2,
232
+ },
233
+ totalGross: {
234
+ type: "centPrecision",
235
+ currencyCode: "EUR",
236
+ centAmount: 1210,
237
+ fractionDigits: 2,
238
+ },
239
+ taxPortions: [
240
+ {
241
+ rate: 0.21,
242
+ amount: {
243
+ type: "centPrecision",
244
+ currencyCode: "EUR",
245
+ centAmount: 210,
246
+ fractionDigits: 2,
247
+ },
248
+ },
249
+ ],
250
+ totalTax: {
251
+ type: "centPrecision",
252
+ currencyCode: "EUR",
253
+ centAmount: 210,
254
+ fractionDigits: 2,
255
+ },
256
+ },
257
+ } as unknown as LineItem,
258
+ ],
259
+ customLineItems: [],
260
+ totalPrice: {
261
+ type: "centPrecision",
262
+ currencyCode: "EUR",
263
+ centAmount: 1000,
264
+ fractionDigits: 2,
265
+ },
266
+ priceRoundingMode: "HalfEven",
267
+ refusedGifts: [],
268
+ shippingMode: "Single",
269
+ shipping: [],
270
+ shippingAddress: { country: "NL" },
271
+ taxMode: "Platform",
272
+ taxRoundingMode: "HalfEven",
273
+ taxCalculationMode: "LineItemLevel",
274
+ origin: "Customer",
275
+ itemShippingAddresses: [],
276
+ directDiscounts: [],
277
+ discountCodes: [],
278
+ discountOnTotalPrice: undefined,
279
+ inventoryMode: "None",
280
+ };
281
+
282
+ storage.add("dummy", "cart", cart);
283
+ const ctx = { projectKey: "dummy" };
284
+ const result = repository.create(ctx, {
285
+ cart: { id: cart.id, typeId: "cart" },
286
+ version: cart.version,
287
+ });
288
+
289
+ expect(result.taxedPrice).toBeDefined();
290
+ expect(result.taxedPrice?.totalNet.centAmount).toBe(1000);
291
+ expect(result.taxedPrice?.totalGross.centAmount).toBe(1210);
292
+ expect(result.taxedPrice?.totalTax?.centAmount).toBe(210);
190
293
  });
191
294
 
192
295
  test("create order in store", async () => {
@@ -271,6 +374,89 @@ describe("Order repository", () => {
271
374
  expect(result.paymentState).toBe("Paid");
272
375
  });
273
376
 
377
+ test("should calculate taxed price when importing order", () => {
378
+ storage.add("dummy", "product", {
379
+ ...getBaseResourceProperties(),
380
+ id: "product-import",
381
+ productType: {
382
+ typeId: "product-type",
383
+ id: "product-type-id",
384
+ },
385
+ masterData: {
386
+ current: {
387
+ name: { en: "Imported" },
388
+ slug: { en: "imported" },
389
+ categories: [],
390
+ masterVariant: {
391
+ id: 1,
392
+ sku: "IMPORT-SKU",
393
+ prices: [],
394
+ attributes: [],
395
+ },
396
+ variants: [],
397
+ searchKeywords: {},
398
+ attributes: [],
399
+ },
400
+ staged: {
401
+ name: { en: "Imported" },
402
+ slug: { en: "imported" },
403
+ categories: [],
404
+ masterVariant: {
405
+ id: 1,
406
+ sku: "IMPORT-SKU",
407
+ prices: [],
408
+ attributes: [],
409
+ },
410
+ variants: [],
411
+ searchKeywords: {},
412
+ attributes: [],
413
+ },
414
+ published: false,
415
+ hasStagedChanges: false,
416
+ },
417
+ });
418
+
419
+ const draft: OrderImportDraft = {
420
+ orderNumber: "IMPORT-ORDER-1",
421
+ totalPrice: {
422
+ centAmount: 1000,
423
+ currencyCode: "EUR",
424
+ },
425
+ lineItems: [
426
+ {
427
+ name: { en: "Imported" },
428
+ variant: {
429
+ sku: "IMPORT-SKU",
430
+ },
431
+ price: {
432
+ value: {
433
+ type: "centPrecision",
434
+ currencyCode: "EUR",
435
+ centAmount: 1000,
436
+ fractionDigits: 2,
437
+ },
438
+ },
439
+ quantity: 1,
440
+ taxRate: {
441
+ name: "Standard VAT",
442
+ amount: 0.21,
443
+ includedInPrice: false,
444
+ country: "NL",
445
+ id: "import-tax-rate",
446
+ subRates: [],
447
+ },
448
+ },
449
+ ],
450
+ customLineItems: [],
451
+ };
452
+
453
+ const result = repository.import({ projectKey: "dummy" }, draft);
454
+ expect(result.taxedPrice).toBeDefined();
455
+ expect(result.taxedPrice?.totalNet.centAmount).toBe(1000);
456
+ expect(result.taxedPrice?.totalGross.centAmount).toBe(1210);
457
+ expect(result.taxedPrice?.totalTax?.centAmount).toBe(210);
458
+ });
459
+
274
460
  test("import exiting product", async () => {
275
461
  storage.add("dummy", "product", {
276
462
  id: "15fc56ba-a74e-4cf8-b4b0-bada5c101541",
@@ -510,4 +696,308 @@ describe("Order repository", () => {
510
696
  repository.import('dummy', draft)
511
697
  })
512
698
  */
699
+
700
+ describe("shippingInfo functionality", () => {
701
+ test("createShippingInfo creates basic shipping info", () => {
702
+ // Create a zone for Netherlands
703
+ const zone = {
704
+ ...getBaseResourceProperties(),
705
+ id: "zone-nl",
706
+ name: "Netherlands Zone",
707
+ locations: [
708
+ {
709
+ country: "NL",
710
+ },
711
+ ],
712
+ };
713
+
714
+ // Create a shipping method first
715
+ const shippingMethod = {
716
+ ...getBaseResourceProperties(),
717
+ id: "shipping-method-123",
718
+ name: "Express Shipping",
719
+ active: true,
720
+ isDefault: false,
721
+ taxCategory: {
722
+ typeId: "tax-category" as const,
723
+ id: "tax-category-123",
724
+ },
725
+ zoneRates: [
726
+ {
727
+ zone: {
728
+ typeId: "zone" as const,
729
+ id: "zone-nl",
730
+ obj: zone,
731
+ },
732
+ shippingRates: [
733
+ {
734
+ price: {
735
+ type: "centPrecision" as const,
736
+ currencyCode: "EUR",
737
+ centAmount: 500,
738
+ fractionDigits: 2,
739
+ },
740
+ tiers: [],
741
+ },
742
+ ],
743
+ },
744
+ ],
745
+ };
746
+
747
+ const taxCategory = {
748
+ ...getBaseResourceProperties(),
749
+ id: "tax-category-123",
750
+ name: "Standard Tax",
751
+ rates: [
752
+ {
753
+ name: "Standard VAT",
754
+ amount: 0.21,
755
+ country: "NL",
756
+ includedInPrice: true,
757
+ },
758
+ ],
759
+ };
760
+
761
+ storage.add("dummy", "zone", zone);
762
+ storage.add("dummy", "shipping-method", shippingMethod);
763
+ storage.add("dummy", "tax-category", taxCategory);
764
+
765
+ const order: Order = {
766
+ ...getBaseResourceProperties(),
767
+ orderNumber: "order-123",
768
+ orderState: "Open",
769
+ origin: "Customer",
770
+ customLineItems: [],
771
+ lineItems: [],
772
+ totalPrice: {
773
+ type: "centPrecision",
774
+ currencyCode: "EUR",
775
+ centAmount: 1000,
776
+ fractionDigits: 2,
777
+ },
778
+ lastMessageSequenceNumber: 0,
779
+ refusedGifts: [],
780
+ shipping: [],
781
+ shippingMode: "Single",
782
+ shippingAddress: {
783
+ id: "address-123",
784
+ country: "NL",
785
+ firstName: "John",
786
+ lastName: "Doe",
787
+ streetName: "Main Street",
788
+ streetNumber: "123",
789
+ postalCode: "1234AB",
790
+ city: "Amsterdam",
791
+ },
792
+ syncInfo: [],
793
+ taxCalculationMode: "UnitPriceLevel",
794
+ taxMode: "Platform",
795
+ taxRoundingMode: "HalfEven",
796
+ };
797
+
798
+ const ctx = { projectKey: "dummy" };
799
+ const result = repository.createShippingInfo(ctx, order, {
800
+ typeId: "shipping-method",
801
+ id: "shipping-method-123",
802
+ });
803
+
804
+ expect(result).toBeDefined();
805
+ expect(result.shippingMethod?.id).toBe("shipping-method-123");
806
+ expect(result.shippingMethodName).toBe("Express Shipping");
807
+ expect(result.price.currencyCode).toBe("EUR");
808
+ expect(result.price.centAmount).toBe(500);
809
+ expect(result.shippingMethodState).toBe("MatchesCart");
810
+ expect(result.deliveries).toEqual([]);
811
+ expect(result.taxCategory?.id).toBe("tax-category-123");
812
+ });
813
+
814
+ test("import order with shippingInfo", () => {
815
+ // Create required resources
816
+ const zone = {
817
+ ...getBaseResourceProperties(),
818
+ id: "zone-de",
819
+ name: "Germany Zone",
820
+ locations: [
821
+ {
822
+ country: "DE",
823
+ },
824
+ ],
825
+ };
826
+
827
+ const shippingMethod = {
828
+ ...getBaseResourceProperties(),
829
+ id: "shipping-method-456",
830
+ name: "Standard Shipping",
831
+ active: true,
832
+ isDefault: false,
833
+ taxCategory: {
834
+ typeId: "tax-category" as const,
835
+ id: "tax-category-456",
836
+ },
837
+ zoneRates: [
838
+ {
839
+ zone: {
840
+ typeId: "zone" as const,
841
+ id: "zone-de",
842
+ obj: zone,
843
+ },
844
+ shippingRates: [
845
+ {
846
+ price: {
847
+ type: "centPrecision" as const,
848
+ currencyCode: "EUR",
849
+ centAmount: 500,
850
+ fractionDigits: 2,
851
+ },
852
+ tiers: [],
853
+ },
854
+ ],
855
+ },
856
+ ],
857
+ };
858
+
859
+ const taxCategory = {
860
+ ...getBaseResourceProperties(),
861
+ id: "tax-category-456",
862
+ name: "Standard Tax",
863
+ rates: [
864
+ {
865
+ name: "Standard VAT",
866
+ amount: 0.19,
867
+ country: "DE",
868
+ includedInPrice: true,
869
+ },
870
+ ],
871
+ };
872
+
873
+ storage.add("dummy", "zone", zone);
874
+ storage.add("dummy", "shipping-method", shippingMethod);
875
+ storage.add("dummy", "tax-category", taxCategory);
876
+
877
+ const draft: OrderImportDraft = {
878
+ orderNumber: "imported-order-123",
879
+ totalPrice: {
880
+ currencyCode: "EUR",
881
+ centAmount: 2000,
882
+ },
883
+ shippingAddress: {
884
+ country: "DE",
885
+ firstName: "Max",
886
+ lastName: "Mustermann",
887
+ streetName: "Hauptstraße",
888
+ streetNumber: "1",
889
+ postalCode: "10115",
890
+ city: "Berlin",
891
+ },
892
+ shippingInfo: {
893
+ shippingMethodName: "Standard Shipping",
894
+ price: {
895
+ currencyCode: "EUR",
896
+ centAmount: 500,
897
+ },
898
+ shippingRate: {
899
+ price: {
900
+ currencyCode: "EUR",
901
+ centAmount: 500,
902
+ },
903
+ tiers: [],
904
+ },
905
+ shippingMethod: {
906
+ typeId: "shipping-method",
907
+ id: "shipping-method-456",
908
+ },
909
+ taxCategory: {
910
+ typeId: "tax-category",
911
+ id: "tax-category-456",
912
+ },
913
+ taxRate: {
914
+ name: "Standard VAT",
915
+ amount: 0.19,
916
+ country: "DE",
917
+ includedInPrice: true,
918
+ },
919
+ shippingMethodState: "MatchesCart",
920
+ deliveries: [
921
+ {
922
+ key: "delivery-1",
923
+ items: [],
924
+ parcels: [
925
+ {
926
+ key: "parcel-1",
927
+ measurements: {
928
+ heightInMillimeter: 100,
929
+ lengthInMillimeter: 200,
930
+ widthInMillimeter: 150,
931
+ weightInGram: 500,
932
+ },
933
+ items: [],
934
+ },
935
+ ],
936
+ },
937
+ ],
938
+ },
939
+ };
940
+
941
+ const ctx = { projectKey: "dummy" };
942
+ const result = repository.import(ctx, draft);
943
+
944
+ expect(result.shippingInfo).toBeDefined();
945
+ expect(result.shippingInfo?.shippingMethodName).toBe("Standard Shipping");
946
+ expect(result.shippingInfo?.price.centAmount).toBe(500);
947
+ expect(result.shippingInfo?.shippingMethod?.id).toBe(
948
+ "shipping-method-456",
949
+ );
950
+ expect(result.shippingInfo?.taxCategory?.id).toBe("tax-category-456");
951
+ expect(result.shippingInfo?.taxRate?.amount).toBe(0.19);
952
+ // Note: deliveries from import drafts are not currently supported in native implementation
953
+ expect(result.shippingInfo?.deliveries).toEqual([]);
954
+ });
955
+
956
+ test("createShippingInfo throws error for non-existent shipping method", () => {
957
+ const order: Order = {
958
+ ...getBaseResourceProperties(),
959
+ orderNumber: "order-456",
960
+ orderState: "Open",
961
+ origin: "Customer",
962
+ customLineItems: [],
963
+ lineItems: [],
964
+ totalPrice: {
965
+ type: "centPrecision",
966
+ currencyCode: "USD",
967
+ centAmount: 1500,
968
+ fractionDigits: 2,
969
+ },
970
+ lastMessageSequenceNumber: 0,
971
+ refusedGifts: [],
972
+ shipping: [],
973
+ shippingMode: "Single",
974
+ shippingAddress: {
975
+ id: "address-456",
976
+ country: "US",
977
+ firstName: "Jane",
978
+ lastName: "Smith",
979
+ streetName: "Broadway",
980
+ streetNumber: "456",
981
+ postalCode: "10001",
982
+ city: "New York",
983
+ state: "NY",
984
+ },
985
+ syncInfo: [],
986
+ taxCalculationMode: "UnitPriceLevel",
987
+ taxMode: "Platform",
988
+ taxRoundingMode: "HalfEven",
989
+ };
990
+
991
+ const ctx = { projectKey: "dummy" };
992
+
993
+ expect(() => {
994
+ repository.createShippingInfo(ctx, order, {
995
+ typeId: "shipping-method",
996
+ id: "non-existent-shipping-method",
997
+ });
998
+ }).toThrow(
999
+ /The shipping method with ID 'non-existent-shipping-method' is not allowed/,
1000
+ );
1001
+ });
1002
+ });
513
1003
  });