@labdigital/commercetools-mock 2.34.3 → 2.35.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.
@@ -3,6 +3,12 @@ import type {
3
3
  Cart,
4
4
  CentPrecisionMoney,
5
5
  ProductDraft,
6
+ ShippingMethod,
7
+ ShippingMethodDraft,
8
+ ShippingMethodResourceIdentifier,
9
+ TaxCategory,
10
+ TaxCategoryDraft,
11
+ Zone,
6
12
  } from "@commercetools/platform-sdk";
7
13
  import assert from "assert";
8
14
  import supertest from "supertest";
@@ -89,6 +95,41 @@ describe("Cart Update Actions", () => {
89
95
  cart = response.body;
90
96
  };
91
97
 
98
+ const createZone = async (country: string): Promise<Zone> => {
99
+ const response = await supertest(ctMock.app)
100
+ .post("/dummy/zones")
101
+ .send({
102
+ name: country,
103
+ locations: [
104
+ {
105
+ country,
106
+ },
107
+ ],
108
+ });
109
+ expect(response.status).toBe(201);
110
+ return response.body;
111
+ };
112
+
113
+ const createTaxCategory = async (
114
+ draft: TaxCategoryDraft,
115
+ ): Promise<TaxCategory> => {
116
+ const response = await supertest(ctMock.app)
117
+ .post("/dummy/tax-categories")
118
+ .send(draft);
119
+ expect(response.status).toBe(201);
120
+ return response.body;
121
+ };
122
+
123
+ const createShippingMethod = async (
124
+ draft: ShippingMethodDraft,
125
+ ): Promise<ShippingMethod> => {
126
+ const response = await supertest(ctMock.app)
127
+ .post("/dummy/shipping-methods")
128
+ .send(draft);
129
+ expect(response.status).toBe(201);
130
+ return response.body;
131
+ };
132
+
92
133
  const productDraft: ProductDraft = {
93
134
  name: {
94
135
  "nl-NL": "test product",
@@ -672,6 +713,7 @@ describe("Cart Update Actions", () => {
672
713
  },
673
714
  });
674
715
  });
716
+
675
717
  test("setShippingAddressCustomType", async () => {
676
718
  assert(cart, "cart not created");
677
719
 
@@ -739,6 +781,381 @@ describe("Cart Update Actions", () => {
739
781
  },
740
782
  });
741
783
  });
784
+
785
+ describe("setShippingMethod", () => {
786
+ let standardShippingMethod: ShippingMethod;
787
+ let standardExcludedShippingMethod: ShippingMethod;
788
+ beforeEach(async () => {
789
+ assert(cart, "cart not created");
790
+ const nlZone = await createZone("NL");
791
+ const frZone = await createZone("FR");
792
+ const standardTax = await createTaxCategory({
793
+ name: "Standard tax category",
794
+ key: "standard",
795
+ rates: [
796
+ {
797
+ name: "FR standard tax rate",
798
+ amount: 0.2,
799
+ includedInPrice: true,
800
+ country: "FR",
801
+ },
802
+ {
803
+ name: "NL standard tax rate",
804
+ amount: 0.21,
805
+ includedInPrice: true,
806
+ country: "NL",
807
+ },
808
+ ],
809
+ });
810
+ await createTaxCategory({
811
+ name: "Reduced tax category",
812
+ key: "reduced",
813
+ rates: [
814
+ {
815
+ name: "FR reduced tax rate",
816
+ amount: 0.1,
817
+ includedInPrice: true,
818
+ country: "FR",
819
+ },
820
+ {
821
+ name: "NL reduced tax rate",
822
+ amount: 0.09,
823
+ includedInPrice: true,
824
+ country: "NL",
825
+ },
826
+ ],
827
+ });
828
+ const standardExcludedTax = await createTaxCategory({
829
+ name: "Tax category that is excluded from price",
830
+ key: "standard-excluded",
831
+ rates: [
832
+ {
833
+ name: "FR standard-excluded tax rate",
834
+ amount: 0.2,
835
+ includedInPrice: false,
836
+ country: "FR",
837
+ },
838
+ {
839
+ name: "NL standard-excluded tax rate",
840
+ amount: 0.21,
841
+ includedInPrice: false,
842
+ country: "NL",
843
+ },
844
+ ],
845
+ });
846
+ standardShippingMethod = await createShippingMethod({
847
+ isDefault: false,
848
+ key: "standard",
849
+ name: "Standard shipping",
850
+ taxCategory: {
851
+ typeId: "tax-category",
852
+ id: standardTax.id,
853
+ },
854
+ zoneRates: [
855
+ {
856
+ zone: {
857
+ typeId: "zone",
858
+ id: nlZone.id,
859
+ },
860
+ shippingRates: [
861
+ {
862
+ price: {
863
+ type: "centPrecision",
864
+ currencyCode: "EUR",
865
+ centAmount: 499,
866
+ fractionDigits: 2,
867
+ },
868
+ },
869
+ ],
870
+ },
871
+ {
872
+ zone: {
873
+ typeId: "zone",
874
+ id: frZone.id,
875
+ },
876
+ shippingRates: [
877
+ {
878
+ price: {
879
+ type: "centPrecision",
880
+ currencyCode: "EUR",
881
+ centAmount: 699,
882
+ fractionDigits: 2,
883
+ },
884
+ },
885
+ ],
886
+ },
887
+ ],
888
+ });
889
+
890
+ standardExcludedShippingMethod = await createShippingMethod({
891
+ isDefault: false,
892
+ key: "standard-excluded",
893
+ name: "Standard shipping with tax excluded from price",
894
+ taxCategory: {
895
+ typeId: "tax-category",
896
+ id: standardExcludedTax.id,
897
+ },
898
+ zoneRates: [
899
+ {
900
+ zone: {
901
+ typeId: "zone",
902
+ id: nlZone.id,
903
+ },
904
+ shippingRates: [
905
+ {
906
+ price: {
907
+ type: "centPrecision",
908
+ currencyCode: "EUR",
909
+ centAmount: 499,
910
+ fractionDigits: 2,
911
+ },
912
+ },
913
+ ],
914
+ },
915
+ {
916
+ zone: {
917
+ typeId: "zone",
918
+ id: frZone.id,
919
+ },
920
+ shippingRates: [
921
+ {
922
+ price: {
923
+ type: "centPrecision",
924
+ currencyCode: "EUR",
925
+ centAmount: 699,
926
+ fractionDigits: 2,
927
+ },
928
+ },
929
+ ],
930
+ },
931
+ ],
932
+ });
933
+ await createShippingMethod({
934
+ isDefault: false,
935
+ key: "express",
936
+ name: "Express shipping",
937
+ taxCategory: {
938
+ typeId: "tax-category",
939
+ id: standardTax.id,
940
+ },
941
+ zoneRates: [
942
+ {
943
+ zone: {
944
+ typeId: "zone",
945
+ id: nlZone.id,
946
+ },
947
+ shippingRates: [
948
+ {
949
+ price: {
950
+ type: "centPrecision",
951
+ currencyCode: "EUR",
952
+ centAmount: 899,
953
+ fractionDigits: 2,
954
+ },
955
+ },
956
+ ],
957
+ },
958
+ {
959
+ zone: {
960
+ typeId: "zone",
961
+ id: frZone.id,
962
+ },
963
+ shippingRates: [
964
+ {
965
+ price: {
966
+ type: "centPrecision",
967
+ currencyCode: "EUR",
968
+ centAmount: 1099,
969
+ fractionDigits: 2,
970
+ },
971
+ },
972
+ ],
973
+ },
974
+ ],
975
+ });
976
+
977
+ expect(
978
+ (
979
+ await supertest(ctMock.app)
980
+ .post(`/dummy/carts/${cart.id}`)
981
+ .send({
982
+ version: 1,
983
+ actions: [
984
+ {
985
+ action: "setShippingAddress",
986
+ address: {
987
+ streetName: "Street name",
988
+ city: "Utrecht",
989
+ country: "NL",
990
+ },
991
+ },
992
+ ],
993
+ })
994
+ ).status,
995
+ ).toBe(200);
996
+ });
997
+
998
+ test("correctly sets shipping method", async () => {
999
+ assert(cart, "cart not created");
1000
+
1001
+ const shippingMethod: ShippingMethodResourceIdentifier = {
1002
+ typeId: "shipping-method",
1003
+ id: standardShippingMethod.id,
1004
+ };
1005
+
1006
+ const response = await supertest(ctMock.app)
1007
+ .post(`/dummy/carts/${cart.id}`)
1008
+ .send({
1009
+ version: 2,
1010
+ actions: [{ action: "setShippingMethod", shippingMethod }],
1011
+ });
1012
+ expect(response.status).toBe(200);
1013
+ expect(response.body.version).toBe(3);
1014
+ expect(response.body.shippingInfo.shippingMethod.id).toEqual(
1015
+ standardShippingMethod.id,
1016
+ );
1017
+ });
1018
+
1019
+ test("correctly sets shippingInfo rates + tax when includedInPrice: true", async () => {
1020
+ assert(cart, "cart not created");
1021
+ assert(standardShippingMethod, "shipping method not created");
1022
+
1023
+ const shippingMethod: ShippingMethodResourceIdentifier = {
1024
+ typeId: "shipping-method",
1025
+ id: standardShippingMethod.id,
1026
+ };
1027
+
1028
+ const response = await supertest(ctMock.app)
1029
+ .post(`/dummy/carts/${cart.id}`)
1030
+ .send({
1031
+ version: 2,
1032
+ actions: [{ action: "setShippingMethod", shippingMethod }],
1033
+ });
1034
+ expect(response.status).toBe(200);
1035
+ expect(response.body.version).toBe(3);
1036
+ expect(response.body.shippingInfo.shippingRate.price).toMatchObject({
1037
+ centAmount: 499,
1038
+ currencyCode: "EUR",
1039
+ fractionDigits: 2,
1040
+ type: "centPrecision",
1041
+ });
1042
+ expect(response.body.shippingInfo.price).toMatchObject({
1043
+ centAmount: 499,
1044
+ currencyCode: "EUR",
1045
+ fractionDigits: 2,
1046
+ type: "centPrecision",
1047
+ });
1048
+ expect(response.body.shippingInfo.taxRate).toMatchObject({
1049
+ name: "NL standard tax rate",
1050
+ amount: 0.21,
1051
+ includedInPrice: true,
1052
+ country: "NL",
1053
+ });
1054
+ expect(response.body.shippingInfo.taxedPrice).toMatchObject({
1055
+ totalNet: {
1056
+ type: "centPrecision",
1057
+ centAmount: 412,
1058
+ currencyCode: "EUR",
1059
+ fractionDigits: 2,
1060
+ },
1061
+ totalGross: {
1062
+ type: "centPrecision",
1063
+ centAmount: 499,
1064
+ currencyCode: "EUR",
1065
+ fractionDigits: 2,
1066
+ },
1067
+ taxPortions: [
1068
+ {
1069
+ name: "NL standard tax rate",
1070
+ rate: 0.21,
1071
+ amount: {
1072
+ type: "centPrecision",
1073
+ centAmount: 87,
1074
+ currencyCode: "EUR",
1075
+ fractionDigits: 2,
1076
+ },
1077
+ },
1078
+ ],
1079
+ totalTax: {
1080
+ type: "centPrecision",
1081
+ centAmount: 87,
1082
+ currencyCode: "EUR",
1083
+ fractionDigits: 2,
1084
+ },
1085
+ });
1086
+ });
1087
+
1088
+ test("correctly sets shippingInfo rates + tax when includedInPrice: false", async () => {
1089
+ assert(cart, "cart not created");
1090
+ assert(standardExcludedShippingMethod, "shipping method not created");
1091
+
1092
+ const shippingMethod: ShippingMethodResourceIdentifier = {
1093
+ typeId: "shipping-method",
1094
+ id: standardExcludedShippingMethod.id,
1095
+ };
1096
+
1097
+ const response = await supertest(ctMock.app)
1098
+ .post(`/dummy/carts/${cart.id}`)
1099
+ .send({
1100
+ version: 2,
1101
+ actions: [{ action: "setShippingMethod", shippingMethod }],
1102
+ });
1103
+ expect(response.status).toBe(200);
1104
+ expect(response.body.version).toBe(3);
1105
+ expect(response.body.shippingInfo.shippingRate.price).toMatchObject({
1106
+ centAmount: 499,
1107
+ currencyCode: "EUR",
1108
+ fractionDigits: 2,
1109
+ type: "centPrecision",
1110
+ });
1111
+ // TODO: should this be gross or net? docs unclear (currently always just returns the shipping rate (tier) price)
1112
+ expect(response.body.shippingInfo.price).toMatchObject({
1113
+ centAmount: 499,
1114
+ currencyCode: "EUR",
1115
+ fractionDigits: 2,
1116
+ type: "centPrecision",
1117
+ });
1118
+ expect(response.body.shippingInfo.taxRate).toMatchObject({
1119
+ name: "NL standard-excluded tax rate",
1120
+ amount: 0.21,
1121
+ includedInPrice: false,
1122
+ country: "NL",
1123
+ });
1124
+ expect(response.body.shippingInfo.taxedPrice).toMatchObject({
1125
+ totalNet: {
1126
+ type: "centPrecision",
1127
+ centAmount: 499,
1128
+ currencyCode: "EUR",
1129
+ fractionDigits: 2,
1130
+ },
1131
+ totalGross: {
1132
+ type: "centPrecision",
1133
+ centAmount: 604,
1134
+ currencyCode: "EUR",
1135
+ fractionDigits: 2,
1136
+ },
1137
+ taxPortions: [
1138
+ {
1139
+ name: "NL standard-excluded tax rate",
1140
+ rate: 0.21,
1141
+ amount: {
1142
+ type: "centPrecision",
1143
+ centAmount: 105,
1144
+ currencyCode: "EUR",
1145
+ fractionDigits: 2,
1146
+ },
1147
+ },
1148
+ ],
1149
+ totalTax: {
1150
+ type: "centPrecision",
1151
+ centAmount: 105,
1152
+ currencyCode: "EUR",
1153
+ fractionDigits: 2,
1154
+ },
1155
+ });
1156
+ });
1157
+ });
1158
+
742
1159
  test("setLineItemShippingDetails", async () => {
743
1160
  const product = await supertest(ctMock.app)
744
1161
  .post(`/dummy/products`)
@@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest";
7
7
  import {
8
8
  markMatchingShippingRate,
9
9
  markMatchingShippingRatePriceTiers,
10
- } from "./shippingCalculator";
10
+ } from "./shipping";
11
11
 
12
12
  // describe('markMatchingShippingMethods', () => {
13
13
  // const zones: Record<string, Zone> = {
@@ -0,0 +1,147 @@
1
+ import {
2
+ Cart,
3
+ CartValueTier,
4
+ InvalidOperationError,
5
+ ShippingRate,
6
+ ShippingRatePriceTier,
7
+ } from "@commercetools/platform-sdk";
8
+ import { CommercetoolsError } from "./exceptions";
9
+ import { GetParams, RepositoryContext } from "./repositories/abstract";
10
+ import { AbstractStorage } from "./storage/abstract";
11
+
12
+ export const markMatchingShippingRate = (
13
+ cart: Cart,
14
+ shippingRate: ShippingRate,
15
+ ): ShippingRate => {
16
+ const isMatching =
17
+ shippingRate.price.currencyCode === cart.totalPrice.currencyCode;
18
+ return {
19
+ ...shippingRate,
20
+ tiers: markMatchingShippingRatePriceTiers(cart, shippingRate.tiers),
21
+ isMatching: isMatching,
22
+ };
23
+ };
24
+
25
+ export const markMatchingShippingRatePriceTiers = (
26
+ cart: Cart,
27
+ tiers: ShippingRatePriceTier[],
28
+ ): ShippingRatePriceTier[] => {
29
+ if (tiers.length === 0) {
30
+ return [];
31
+ }
32
+
33
+ if (new Set(tiers.map((tier) => tier.type)).size > 1) {
34
+ throw new Error("Can't handle multiple types of tiers");
35
+ }
36
+
37
+ const tierType = tiers[0].type;
38
+ switch (tierType) {
39
+ case "CartValue":
40
+ return markMatchingCartValueTiers(cart, tiers as CartValueTier[]);
41
+ // case 'CartClassification':
42
+ // return markMatchingCartClassificationTiers(cart, tiers)
43
+ // case 'CartScore':
44
+ // return markMatchingCartScoreTiers(cart, tiers)
45
+ default:
46
+ throw new Error(`Unsupported tier type: ${tierType}`);
47
+ }
48
+ };
49
+
50
+ const markMatchingCartValueTiers = (
51
+ cart: Cart,
52
+ tiers: readonly CartValueTier[],
53
+ ): ShippingRatePriceTier[] => {
54
+ // Sort tiers from high to low since we only want to match the highest tier
55
+ const sortedTiers = [...tiers].sort(
56
+ (a, b) => b.minimumCentAmount - a.minimumCentAmount,
57
+ );
58
+
59
+ // Find the first tier that matches the cart and set the flag. We push
60
+ // the results into a map so that we can output the tiers in the same order as
61
+ // we received them.
62
+ const result: Record<number, ShippingRatePriceTier> = {};
63
+ let hasMatchingTier = false;
64
+ for (const tier of sortedTiers) {
65
+ const isMatching =
66
+ !hasMatchingTier &&
67
+ cart.totalPrice.currencyCode === tier.price.currencyCode &&
68
+ cart.totalPrice.centAmount >= tier.minimumCentAmount;
69
+
70
+ if (isMatching) hasMatchingTier = true;
71
+ result[tier.minimumCentAmount] = {
72
+ ...tier,
73
+ isMatching: isMatching,
74
+ };
75
+ }
76
+
77
+ return tiers.map((tier) => result[tier.minimumCentAmount]);
78
+ };
79
+
80
+ /*
81
+ * Retrieves all the ShippingMethods that can ship to the shipping address of
82
+ * the given Cart. Each ShippingMethod contains exactly one ShippingRate with
83
+ * the flag isMatching set to true. This ShippingRate is used when the
84
+ * ShippingMethod is added to the Cart.
85
+ */
86
+ export const getShippingMethodsMatchingCart = (
87
+ context: RepositoryContext,
88
+ storage: AbstractStorage,
89
+ cart: Cart,
90
+ params: GetParams = {},
91
+ ) => {
92
+ if (!cart.shippingAddress?.country) {
93
+ throw new CommercetoolsError<InvalidOperationError>({
94
+ code: "InvalidOperation",
95
+ message: `The cart with ID '${cart.id}' does not have a shipping address set.`,
96
+ });
97
+ }
98
+
99
+ // Get all shipping methods that have a zone that matches the shipping address
100
+ const zones = storage.query<"zone">(context.projectKey, "zone", {
101
+ where: [`locations(country="${cart.shippingAddress.country}"))`],
102
+ limit: 100,
103
+ });
104
+ const zoneIds = zones.results.map((zone) => zone.id);
105
+ const shippingMethods = storage.query<"shipping-method">(
106
+ context.projectKey,
107
+ "shipping-method",
108
+ {
109
+ "where": [
110
+ `zoneRates(zone(id in (:zoneIds)))`,
111
+ `zoneRates(shippingRates(price(currencyCode="${cart.totalPrice.currencyCode}")))`,
112
+ ],
113
+ "var.zoneIds": zoneIds,
114
+ "expand": params.expand,
115
+ },
116
+ );
117
+
118
+ // Make sure that each shipping method has exactly one shipping rate and
119
+ // that the shipping rate is marked as matching
120
+ const results = shippingMethods.results
121
+ .map((shippingMethod) => {
122
+ // Iterate through the zoneRates, process the shipping rates and filter
123
+ // out all zoneRates which have no matching shipping rates left
124
+ const rates = shippingMethod.zoneRates
125
+ .map((zoneRate) => ({
126
+ zone: zoneRate.zone,
127
+
128
+ // Iterate through the shippingRates and mark the matching ones
129
+ // then we filter out the non-matching ones
130
+ shippingRates: zoneRate.shippingRates
131
+ .map((rate) => markMatchingShippingRate(cart, rate))
132
+ .filter((rate) => rate.isMatching),
133
+ }))
134
+ .filter((zoneRate) => zoneRate.shippingRates.length > 0);
135
+
136
+ return {
137
+ ...shippingMethod,
138
+ zoneRates: rates,
139
+ };
140
+ })
141
+ .filter((shippingMethod) => shippingMethod.zoneRates.length > 0);
142
+
143
+ return {
144
+ ...shippingMethods,
145
+ results: results,
146
+ };
147
+ };
@@ -1,74 +0,0 @@
1
- import {
2
- Cart,
3
- CartValueTier,
4
- ShippingRate,
5
- ShippingRatePriceTier,
6
- } from "@commercetools/platform-sdk";
7
-
8
- export const markMatchingShippingRate = (
9
- cart: Cart,
10
- shippingRate: ShippingRate,
11
- ): ShippingRate => {
12
- const isMatching =
13
- shippingRate.price.currencyCode === cart.totalPrice.currencyCode;
14
- return {
15
- ...shippingRate,
16
- tiers: markMatchingShippingRatePriceTiers(cart, shippingRate.tiers),
17
- isMatching: isMatching,
18
- };
19
- };
20
-
21
- export const markMatchingShippingRatePriceTiers = (
22
- cart: Cart,
23
- tiers: ShippingRatePriceTier[],
24
- ): ShippingRatePriceTier[] => {
25
- if (tiers.length === 0) {
26
- return [];
27
- }
28
-
29
- if (new Set(tiers.map((tier) => tier.type)).size > 1) {
30
- throw new Error("Can't handle multiple types of tiers");
31
- }
32
-
33
- const tierType = tiers[0].type;
34
- switch (tierType) {
35
- case "CartValue":
36
- return markMatchingCartValueTiers(cart, tiers as CartValueTier[]);
37
- // case 'CartClassification':
38
- // return markMatchingCartClassificationTiers(cart, tiers)
39
- // case 'CartScore':
40
- // return markMatchingCartScoreTiers(cart, tiers)
41
- default:
42
- throw new Error(`Unsupported tier type: ${tierType}`);
43
- }
44
- };
45
-
46
- const markMatchingCartValueTiers = (
47
- cart: Cart,
48
- tiers: readonly CartValueTier[],
49
- ): ShippingRatePriceTier[] => {
50
- // Sort tiers from high to low since we only want to match the highest tier
51
- const sortedTiers = [...tiers].sort(
52
- (a, b) => b.minimumCentAmount - a.minimumCentAmount,
53
- );
54
-
55
- // Find the first tier that matches the cart and set the flag. We push
56
- // the results into a map so that we can output the tiers in the same order as
57
- // we received them.
58
- const result: Record<number, ShippingRatePriceTier> = {};
59
- let hasMatchingTier = false;
60
- for (const tier of sortedTiers) {
61
- const isMatching =
62
- !hasMatchingTier &&
63
- cart.totalPrice.currencyCode === tier.price.currencyCode &&
64
- cart.totalPrice.centAmount >= tier.minimumCentAmount;
65
-
66
- if (isMatching) hasMatchingTier = true;
67
- result[tier.minimumCentAmount] = {
68
- ...tier,
69
- isMatching: isMatching,
70
- };
71
- }
72
-
73
- return tiers.map((tier) => result[tier.minimumCentAmount]);
74
- };