@labdigital/commercetools-mock 2.57.1 → 2.59.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,9 +1,10 @@
1
1
  import type {
2
+ Cart,
2
3
  CartDraft,
3
4
  CustomLineItemDraft,
4
5
  LineItem,
5
6
  } from "@commercetools/platform-sdk";
6
- import { describe, expect, test } from "vitest";
7
+ import { beforeEach, describe, expect, test } from "vitest";
7
8
  import type { Config } from "~src/config";
8
9
  import { getBaseResourceProperties } from "~src/helpers";
9
10
  import { InMemoryStorage } from "~src/storage";
@@ -92,7 +93,70 @@ describe("Cart repository", () => {
92
93
  id: "tax-category-id",
93
94
  key: "standard-tax",
94
95
  name: "Standard Tax",
95
- rates: [],
96
+ rates: [
97
+ {
98
+ id: "nl-rate",
99
+ name: "Standard VAT",
100
+ amount: 0.21,
101
+ includedInPrice: false,
102
+ country: "NL",
103
+ },
104
+ ],
105
+ });
106
+
107
+ storage.add("dummy", "zone", {
108
+ ...getBaseResourceProperties(),
109
+ id: "nl-zone-id",
110
+ key: "nl-zone",
111
+ name: "Netherlands Zone",
112
+ locations: [
113
+ {
114
+ country: "NL",
115
+ },
116
+ ],
117
+ });
118
+
119
+ storage.add("dummy", "shipping-method", {
120
+ ...getBaseResourceProperties(),
121
+ id: "shipping-method-id",
122
+ key: "standard-shipping",
123
+ name: "Standard Shipping",
124
+ taxCategory: {
125
+ typeId: "tax-category",
126
+ id: "tax-category-id",
127
+ },
128
+ zoneRates: [
129
+ {
130
+ zone: {
131
+ typeId: "zone",
132
+ id: "nl-zone-id",
133
+ obj: {
134
+ ...getBaseResourceProperties(),
135
+ id: "nl-zone-id",
136
+ key: "nl-zone",
137
+ name: "Netherlands Zone",
138
+ locations: [
139
+ {
140
+ country: "NL",
141
+ },
142
+ ],
143
+ },
144
+ },
145
+ shippingRates: [
146
+ {
147
+ price: {
148
+ currencyCode: "EUR",
149
+ centAmount: 500,
150
+ type: "centPrecision",
151
+ fractionDigits: 2,
152
+ },
153
+ tiers: [],
154
+ },
155
+ ],
156
+ },
157
+ ],
158
+ active: true,
159
+ isDefault: false,
96
160
  });
97
161
 
98
162
  const cart: CartDraft = {
@@ -109,6 +173,10 @@ describe("Cart repository", () => {
109
173
  country: "NL",
110
174
  currency: "EUR",
111
175
  customerEmail: "john.doe@example.com",
176
+ shippingMethod: {
177
+ typeId: "shipping-method",
178
+ id: "shipping-method-id",
179
+ },
112
180
  customLineItems: [
113
181
  {
114
182
  name: { "nl-NL": "Douane kosten" },
@@ -204,6 +272,28 @@ describe("Cart repository", () => {
204
272
  cart.customLineItems?.[0].name,
205
273
  );
206
274
  expect(result.totalPrice.centAmount).toBe(3500);
275
+
276
+ expect(result.shippingInfo).toBeDefined();
277
+ expect(result.shippingInfo!.shippingMethod!.id).toBe("shipping-method-id");
278
+ expect(result.shippingInfo!.shippingMethodName).toBe("Standard Shipping");
279
+ expect(result.shippingInfo?.price).toBeDefined();
280
+ expect(result.shippingInfo?.price.centAmount).toBe(500);
281
+ expect(result.shippingInfo?.price.currencyCode).toBe("EUR");
282
+ expect(result.shippingInfo?.taxedPrice).toBeDefined();
283
+ expect(result.shippingInfo?.taxedPrice?.totalGross.centAmount).toBe(605);
284
+ expect(result.shippingInfo?.taxedPrice?.totalNet.centAmount).toBe(500);
285
+ expect(result.shippingInfo?.taxRate?.amount).toBe(0.21);
286
+ expect(result.shippingInfo?.taxRate?.name).toBe("Standard VAT");
287
+ });
288
+
289
+ test("create start with store from draft", () => {
290
+ const ctx = { projectKey: "dummy" };
291
+ const draft: CartDraft = {
292
+ currency: "USD",
293
+ store: { key: "draftStore", typeId: "store" },
294
+ };
295
+ const result = repository.create(ctx, draft);
296
+ expect(result.store).toEqual({ typeId: "store", key: "draftStore" });
207
297
  });
208
298
 
209
299
  test("create cart with business unit", async () => {
@@ -300,3 +390,287 @@ describe("Cart repository", () => {
300
390
  expect(customLineItem.taxRate?.country).toBe("NL");
301
391
  });
302
392
  });
393
+
394
+ describe("createShippingInfo", () => {
395
+ const storage = new InMemoryStorage();
396
+ const config: Config = { storage, strict: false };
397
+ const repository = new CartRepository(config);
398
+
399
+ beforeEach(() => {
400
+ storage.add("dummy", "tax-category", {
401
+ ...getBaseResourceProperties(),
402
+ id: "shipping-tax-category-id",
403
+ key: "shipping-tax",
404
+ name: "Shipping Tax",
405
+ rates: [
406
+ {
407
+ id: "nl-shipping-rate",
408
+ name: "Standard VAT",
409
+ amount: 0.21,
410
+ includedInPrice: false,
411
+ country: "NL",
412
+ },
413
+ ],
414
+ });
415
+
416
+ storage.add("dummy", "zone", {
417
+ ...getBaseResourceProperties(),
418
+ id: "test-zone-id",
419
+ name: "Test Zone",
420
+ locations: [
421
+ {
422
+ country: "NL",
423
+ },
424
+ ],
425
+ });
426
+ });
427
+
428
+ test("should calculate shipping info", () => {
429
+ storage.add("dummy", "shipping-method", {
430
+ ...getBaseResourceProperties(),
431
+ id: "basic-shipping-id",
432
+ name: "Standard Shipping",
433
+ taxCategory: {
434
+ typeId: "tax-category",
435
+ id: "shipping-tax-category-id",
436
+ },
437
+ zoneRates: [
438
+ {
439
+ zone: {
440
+ typeId: "zone",
441
+ id: "test-zone-id",
442
+ obj: {
443
+ ...getBaseResourceProperties(),
444
+ id: "test-zone-id",
445
+ name: "Test Zone",
446
+ locations: [
447
+ {
448
+ country: "NL",
449
+ },
450
+ ],
451
+ },
452
+ },
453
+ shippingRates: [
454
+ {
455
+ price: {
456
+ currencyCode: "EUR",
457
+ centAmount: 595,
458
+ type: "centPrecision",
459
+ fractionDigits: 2,
460
+ },
461
+ tiers: [],
462
+ },
463
+ ],
464
+ },
465
+ ],
466
+ active: true,
467
+ isDefault: false,
468
+ });
469
+
470
+ const cart: any = {
471
+ ...getBaseResourceProperties(),
472
+ id: "basic-cart-id",
473
+ version: 1,
474
+ cartState: "Active",
475
+ totalPrice: {
476
+ currencyCode: "EUR",
477
+ centAmount: 3000,
478
+ type: "centPrecision",
479
+ fractionDigits: 2,
480
+ },
481
+ shippingAddress: {
482
+ country: "NL",
483
+ },
484
+ taxRoundingMode: "HalfEven",
485
+ };
486
+
487
+ const context = { projectKey: "dummy", storeKey: "testStore" };
488
+ const shippingMethodRef = {
489
+ typeId: "shipping-method" as const,
490
+ id: "basic-shipping-id",
491
+ };
492
+
493
+ const result = repository.createShippingInfo(
494
+ context,
495
+ cart,
496
+ shippingMethodRef,
497
+ );
498
+
499
+ expect(result.price.centAmount).toBe(595);
500
+ expect(result.shippingMethodName).toBe("Standard Shipping");
501
+ expect(result.shippingMethod!.id).toBe("basic-shipping-id");
502
+ expect(result.taxRate?.amount).toBe(0.21);
503
+ expect(result.taxedPrice!.totalNet.centAmount).toBe(595);
504
+ expect(result.taxedPrice!.totalGross.centAmount).toBe(720);
505
+ });
506
+
507
+ test("should apply free shipping when cart total is above freeAbove threshold", () => {
508
+ storage.add("dummy", "shipping-method", {
509
+ ...getBaseResourceProperties(),
510
+ id: "free-above-shipping-id",
511
+ key: "free-above-shipping",
512
+ name: "Free Above €50",
513
+ taxCategory: {
514
+ typeId: "tax-category",
515
+ id: "shipping-tax-category-id",
516
+ },
517
+ zoneRates: [
518
+ {
519
+ zone: {
520
+ typeId: "zone",
521
+ id: "test-zone-id",
522
+ obj: {
523
+ ...getBaseResourceProperties(),
524
+ id: "test-zone-id",
525
+ key: "test-zone",
526
+ name: "Test Zone",
527
+ locations: [
528
+ {
529
+ country: "NL",
530
+ },
531
+ ],
532
+ },
533
+ },
534
+ shippingRates: [
535
+ {
536
+ price: {
537
+ currencyCode: "EUR",
538
+ centAmount: 995,
539
+ type: "centPrecision",
540
+ fractionDigits: 2,
541
+ },
542
+ freeAbove: {
543
+ currencyCode: "EUR",
544
+ centAmount: 5000,
545
+ type: "centPrecision",
546
+ fractionDigits: 2,
547
+ },
548
+ tiers: [],
549
+ },
550
+ ],
551
+ },
552
+ ],
553
+ active: true,
554
+ isDefault: false,
555
+ });
556
+
557
+ const cart: any = {
558
+ ...getBaseResourceProperties(),
559
+ id: "test-cart-id",
560
+ version: 1,
561
+ cartState: "Active",
562
+ totalPrice: {
563
+ currencyCode: "EUR",
564
+ centAmount: 6000,
565
+ type: "centPrecision",
566
+ fractionDigits: 2,
567
+ },
568
+ shippingAddress: {
569
+ country: "NL",
570
+ },
571
+ taxRoundingMode: "HalfEven",
572
+ };
573
+
574
+ const context = { projectKey: "dummy", storeKey: "testStore" };
575
+ const shippingMethodRef = {
576
+ typeId: "shipping-method" as const,
577
+ id: "free-above-shipping-id",
578
+ };
579
+
580
+ const result = repository.createShippingInfo(
581
+ context,
582
+ cart,
583
+ shippingMethodRef,
584
+ );
585
+
586
+ expect(result.price.centAmount).toBe(0);
587
+ expect(result.shippingMethodName).toBe("Free Above €50");
588
+ expect(result.taxedPrice!.totalGross.centAmount).toBe(0);
589
+ expect(result.taxedPrice!.totalNet.centAmount).toBe(0);
590
+ });
591
+
592
+ test("should charge normal shipping when cart total is below freeAbove threshold", () => {
593
+ storage.add("dummy", "shipping-method", {
594
+ ...getBaseResourceProperties(),
595
+ id: "free-above-shipping-id-2",
596
+ key: "free-above-shipping-2",
597
+ name: "Free Above €50",
598
+ taxCategory: {
599
+ typeId: "tax-category",
600
+ id: "shipping-tax-category-id",
601
+ },
602
+ zoneRates: [
603
+ {
604
+ zone: {
605
+ typeId: "zone",
606
+ id: "test-zone-id",
607
+ obj: {
608
+ ...getBaseResourceProperties(),
609
+ id: "test-zone-id",
610
+ key: "test-zone",
611
+ name: "Test Zone",
612
+ locations: [
613
+ {
614
+ country: "NL",
615
+ },
616
+ ],
617
+ },
618
+ },
619
+ shippingRates: [
620
+ {
621
+ price: {
622
+ currencyCode: "EUR",
623
+ centAmount: 995,
624
+ type: "centPrecision",
625
+ fractionDigits: 2,
626
+ },
627
+ freeAbove: {
628
+ currencyCode: "EUR",
629
+ centAmount: 5000,
630
+ type: "centPrecision",
631
+ fractionDigits: 2,
632
+ },
633
+ tiers: [],
634
+ },
635
+ ],
636
+ },
637
+ ],
638
+ active: true,
639
+ isDefault: false,
640
+ });
641
+
642
+ const cart: any = {
643
+ ...getBaseResourceProperties(),
644
+ id: "test-cart-id-2",
645
+ version: 1,
646
+ cartState: "Active",
647
+ totalPrice: {
648
+ currencyCode: "EUR",
649
+ centAmount: 2000,
650
+ type: "centPrecision",
651
+ fractionDigits: 2,
652
+ },
653
+ shippingAddress: {
654
+ country: "NL",
655
+ },
656
+ taxRoundingMode: "HalfEven",
657
+ };
658
+
659
+ const context = { projectKey: "dummy", storeKey: "testStore" };
660
+ const shippingMethodRef = {
661
+ typeId: "shipping-method" as const,
662
+ id: "free-above-shipping-id-2",
663
+ };
664
+
665
+ const result = repository.createShippingInfo(
666
+ context,
667
+ cart,
668
+ shippingMethodRef,
669
+ );
670
+
671
+ expect(result.price.centAmount).toBe(995);
672
+ expect(result.shippingMethodName).toBe("Free Above €50");
673
+ expect(result.taxedPrice!.totalGross.centAmount).toBe(1204);
674
+ expect(result.taxedPrice!.totalNet.centAmount).toBe(995);
675
+ });
676
+ });
@@ -1,6 +1,9 @@
1
1
  import type {
2
2
  BusinessUnit,
3
+ CentPrecisionMoney,
3
4
  InvalidOperationError,
5
+ MissingTaxRateForCountryError,
6
+ ShippingMethodDoesNotMatchCartError,
4
7
  } from "@commercetools/platform-sdk";
5
8
  import type {
6
9
  Cart,
@@ -12,17 +15,27 @@ import type {
12
15
  LineItemDraft,
13
16
  Product,
14
17
  ProductPagedQueryResponse,
18
+ TaxPortion,
19
+ TaxedItemPrice,
15
20
  } from "@commercetools/platform-sdk";
21
+ import { Decimal } from "decimal.js/decimal";
16
22
  import { v4 as uuidv4 } from "uuid";
17
23
  import type { Config } from "~src/config";
18
24
  import { CommercetoolsError } from "~src/exceptions";
19
25
  import { getBaseResourceProperties } from "~src/helpers";
26
+ import { getShippingMethodsMatchingCart } from "~src/shipping";
20
27
  import type { Writable } from "~src/types";
21
28
  import {
22
29
  AbstractResourceRepository,
23
30
  type RepositoryContext,
24
31
  } from "../abstract";
25
- import { createAddress, createCustomFields } from "../helpers";
32
+ import {
33
+ createAddress,
34
+ createCentPrecisionMoney,
35
+ createCustomFields,
36
+ createTypedMoney,
37
+ roundDecimal,
38
+ } from "../helpers";
26
39
  import { CartUpdateHandler } from "./actions";
27
40
  import {
28
41
  calculateCartTotalPrice,
@@ -33,7 +46,7 @@ import {
33
46
  export class CartRepository extends AbstractResourceRepository<"cart"> {
34
47
  constructor(config: Config) {
35
48
  super("cart", config);
36
- this.actions = new CartUpdateHandler(this._storage);
49
+ this.actions = new CartUpdateHandler(this._storage, this);
37
50
  }
38
51
 
39
52
  create(context: RepositoryContext, draft: CartDraft): Cart {
@@ -128,6 +141,7 @@ export class CartRepository extends AbstractResourceRepository<"cart"> {
128
141
  )
129
142
  : undefined,
130
143
  shipping: [],
144
+ shippingInfo: undefined,
131
145
  origin: draft.origin ?? "Customer",
132
146
  refusedGifts: [],
133
147
  custom: createCustomFields(
@@ -139,7 +153,18 @@ export class CartRepository extends AbstractResourceRepository<"cart"> {
139
153
  resource.totalPrice.centAmount = calculateCartTotalPrice(resource);
140
154
  resource.store = context.storeKey
141
155
  ? { typeId: "store", key: context.storeKey }
142
- : undefined;
156
+ : draft.store?.key
157
+ ? { typeId: "store", key: draft.store.key }
158
+ : undefined;
159
+
160
+ // Set shipping info after resource is created
161
+ if (draft.shippingMethod) {
162
+ resource.shippingInfo = this.createShippingInfo(
163
+ context,
164
+ resource,
165
+ draft.shippingMethod,
166
+ );
167
+ }
143
168
 
144
169
  return this.saveNew(context, resource);
145
170
  }
@@ -249,4 +274,179 @@ export class CartRepository extends AbstractResourceRepository<"cart"> {
249
274
  ),
250
275
  };
251
276
  };
277
+
278
+ createShippingInfo(
279
+ context: RepositoryContext,
280
+ resource: Writable<Cart>,
281
+ shippingMethodRef: NonNullable<CartDraft["shippingMethod"]>,
282
+ ): NonNullable<Cart["shippingInfo"]> {
283
+ if (resource.taxMode === "External") {
284
+ throw new Error("External tax rate is not supported");
285
+ }
286
+
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
+ // Bit of a hack: calling this checks that the resource identifier is
297
+ // valid (i.e. id xor key) and that the shipping method exists.
298
+ this._storage.getByResourceIdentifier<"shipping-method">(
299
+ context.projectKey,
300
+ shippingMethodRef,
301
+ );
302
+
303
+ // getShippingMethodsMatchingCart does the work of determining whether the
304
+ // shipping method is allowed for the cart, and which shipping rate to use
305
+ const shippingMethods = getShippingMethodsMatchingCart(
306
+ context,
307
+ this._storage,
308
+ resource,
309
+ {
310
+ expand: ["zoneRates[*].zone"],
311
+ },
312
+ );
313
+
314
+ const method = shippingMethods.results.find((candidate) =>
315
+ shippingMethodRef.id
316
+ ? candidate.id === shippingMethodRef.id
317
+ : candidate.key === shippingMethodRef.key,
318
+ );
319
+
320
+ // Not finding the method in the results means it's not allowed, since
321
+ // getShippingMethodsMatchingCart only returns allowed methods and we
322
+ // already checked that the method exists.
323
+ if (!method) {
324
+ throw new CommercetoolsError<ShippingMethodDoesNotMatchCartError>({
325
+ code: "ShippingMethodDoesNotMatchCart",
326
+ message: `The shipping method with ${shippingMethodRef.id ? `ID '${shippingMethodRef.id}'` : `key '${shippingMethodRef.key}'`} is not allowed for the cart with ID '${resource.id}'.`,
327
+ });
328
+ }
329
+
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),
351
+ );
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
+ }
252
452
  }