@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/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +338 -172
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/tax.test.ts +119 -0
- package/src/lib/tax.ts +186 -0
- package/src/repositories/cart/actions.ts +67 -0
- package/src/repositories/cart/helpers.ts +1 -80
- package/src/repositories/cart/index.test.ts +48 -0
- package/src/repositories/cart/index.ts +16 -141
- package/src/repositories/order/index.test.ts +490 -0
- package/src/repositories/order/index.ts +145 -7
- package/src/services/cart.test.ts +211 -0
- package/src/shipping.ts +157 -0
|
@@ -2,21 +2,36 @@ import assert from "node:assert";
|
|
|
2
2
|
import type {
|
|
3
3
|
Cart,
|
|
4
4
|
CartReference,
|
|
5
|
+
CentPrecisionMoney,
|
|
5
6
|
CustomLineItem,
|
|
6
7
|
CustomLineItemImportDraft,
|
|
7
8
|
GeneralError,
|
|
9
|
+
InvalidOperationError,
|
|
8
10
|
LineItem,
|
|
9
11
|
LineItemImportDraft,
|
|
12
|
+
MissingTaxRateForCountryError,
|
|
10
13
|
Order,
|
|
11
14
|
OrderFromCartDraft,
|
|
12
15
|
OrderImportDraft,
|
|
13
16
|
Product,
|
|
14
17
|
ProductPagedQueryResponse,
|
|
15
18
|
ProductVariant,
|
|
19
|
+
ShippingInfo,
|
|
20
|
+
ShippingMethodDoesNotMatchCartError,
|
|
21
|
+
ShippingMethodReference,
|
|
22
|
+
TaxPortion,
|
|
23
|
+
TaxedItemPrice,
|
|
16
24
|
} from "@commercetools/platform-sdk";
|
|
25
|
+
import { Decimal } from "decimal.js/decimal";
|
|
17
26
|
import type { Config } from "~src/config";
|
|
18
27
|
import { CommercetoolsError } from "~src/exceptions";
|
|
19
28
|
import { generateRandomString, getBaseResourceProperties } from "~src/helpers";
|
|
29
|
+
import { calculateTaxTotals, calculateTaxedPriceFromRate } from "~src/lib/tax";
|
|
30
|
+
import {
|
|
31
|
+
createShippingInfoFromMethod,
|
|
32
|
+
getShippingMethodsMatchingCart,
|
|
33
|
+
} from "~src/shipping";
|
|
34
|
+
import type { Writable } from "~src/types";
|
|
20
35
|
import type { RepositoryContext } from "../abstract";
|
|
21
36
|
import { AbstractResourceRepository, type QueryParams } from "../abstract";
|
|
22
37
|
import {
|
|
@@ -26,6 +41,7 @@ import {
|
|
|
26
41
|
createPrice,
|
|
27
42
|
createTypedMoney,
|
|
28
43
|
resolveStoreReference,
|
|
44
|
+
roundDecimal,
|
|
29
45
|
} from "../helpers";
|
|
30
46
|
import { OrderUpdateHandler } from "./actions";
|
|
31
47
|
|
|
@@ -60,7 +76,7 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
60
76
|
throw new Error("Cannot find cart");
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
const resource: Order = {
|
|
79
|
+
const resource: Writable<Order> = {
|
|
64
80
|
...getBaseResourceProperties(),
|
|
65
81
|
anonymousId: cart.anonymousId,
|
|
66
82
|
billingAddress: cart.billingAddress,
|
|
@@ -84,6 +100,7 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
84
100
|
refusedGifts: [],
|
|
85
101
|
shipping: cart.shipping,
|
|
86
102
|
shippingAddress: cart.shippingAddress,
|
|
103
|
+
shippingInfo: cart.shippingInfo,
|
|
87
104
|
shippingMode: cart.shippingMode,
|
|
88
105
|
syncInfo: [],
|
|
89
106
|
taxCalculationMode: cart.taxCalculationMode,
|
|
@@ -94,13 +111,23 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
94
111
|
totalPrice: cart.totalPrice,
|
|
95
112
|
store: cart.store,
|
|
96
113
|
};
|
|
114
|
+
|
|
115
|
+
const { taxedPrice, taxedShippingPrice } = calculateTaxTotals({
|
|
116
|
+
lineItems: cart.lineItems,
|
|
117
|
+
customLineItems: cart.customLineItems,
|
|
118
|
+
shippingInfo: cart.shippingInfo,
|
|
119
|
+
totalPrice: cart.totalPrice,
|
|
120
|
+
});
|
|
121
|
+
resource.taxedPrice = resource.taxedPrice ?? taxedPrice;
|
|
122
|
+
resource.taxedShippingPrice =
|
|
123
|
+
resource.taxedShippingPrice ?? taxedShippingPrice;
|
|
97
124
|
return this.saveNew(context, resource);
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
import(context: RepositoryContext, draft: OrderImportDraft): Order {
|
|
101
128
|
// TODO: Check if order with given orderNumber already exists
|
|
102
129
|
assert(this, "OrderRepository not valid");
|
|
103
|
-
const resource: Order = {
|
|
130
|
+
const resource: Writable<Order> = {
|
|
104
131
|
...getBaseResourceProperties(),
|
|
105
132
|
|
|
106
133
|
billingAddress: createAddress(
|
|
@@ -132,7 +159,7 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
132
159
|
refusedGifts: [],
|
|
133
160
|
shippingMode: "Single",
|
|
134
161
|
shipping: [],
|
|
135
|
-
|
|
162
|
+
shippingInfo: undefined,
|
|
136
163
|
store: resolveStoreReference(
|
|
137
164
|
draft.store,
|
|
138
165
|
context.projectKey,
|
|
@@ -151,6 +178,43 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
151
178
|
|
|
152
179
|
totalPrice: createCentPrecisionMoney(draft.totalPrice),
|
|
153
180
|
};
|
|
181
|
+
|
|
182
|
+
// Set shipping info after resource is created
|
|
183
|
+
if (draft.shippingInfo?.shippingMethod) {
|
|
184
|
+
const { ...shippingMethodRef } = draft.shippingInfo.shippingMethod;
|
|
185
|
+
|
|
186
|
+
// get id when reference is by key only
|
|
187
|
+
if (shippingMethodRef.key && !shippingMethodRef.id) {
|
|
188
|
+
const shippingMethod =
|
|
189
|
+
this._storage.getByResourceIdentifier<"shipping-method">(
|
|
190
|
+
context.projectKey,
|
|
191
|
+
shippingMethodRef,
|
|
192
|
+
);
|
|
193
|
+
if (!shippingMethod) {
|
|
194
|
+
throw new CommercetoolsError<GeneralError>({
|
|
195
|
+
code: "General",
|
|
196
|
+
message: `A shipping method with key '${shippingMethodRef.key}' does not exist.`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
shippingMethodRef.id = shippingMethod.id;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
resource.shippingInfo = this.createShippingInfo(context, resource, {
|
|
203
|
+
typeId: "shipping-method",
|
|
204
|
+
id: shippingMethodRef.id as string,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { taxedPrice, taxedShippingPrice } = calculateTaxTotals({
|
|
209
|
+
lineItems: resource.lineItems,
|
|
210
|
+
customLineItems: resource.customLineItems,
|
|
211
|
+
shippingInfo: resource.shippingInfo,
|
|
212
|
+
totalPrice: resource.totalPrice,
|
|
213
|
+
});
|
|
214
|
+
resource.taxedPrice = resource.taxedPrice ?? taxedPrice;
|
|
215
|
+
resource.taxedShippingPrice =
|
|
216
|
+
resource.taxedShippingPrice ?? taxedShippingPrice;
|
|
217
|
+
|
|
154
218
|
return this.saveNew(context, resource);
|
|
155
219
|
}
|
|
156
220
|
|
|
@@ -195,6 +259,12 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
195
259
|
throw new Error("No product found");
|
|
196
260
|
}
|
|
197
261
|
|
|
262
|
+
const quantity = draft.quantity ?? 1;
|
|
263
|
+
const totalPrice = createCentPrecisionMoney({
|
|
264
|
+
...draft.price.value,
|
|
265
|
+
centAmount: (draft.price.value.centAmount ?? 0) * quantity,
|
|
266
|
+
});
|
|
267
|
+
|
|
198
268
|
const lineItem: LineItem = {
|
|
199
269
|
...getBaseResourceProperties(),
|
|
200
270
|
custom: createCustomFields(
|
|
@@ -209,12 +279,17 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
209
279
|
priceMode: "Platform",
|
|
210
280
|
productId: product.id,
|
|
211
281
|
productType: product.productType,
|
|
212
|
-
quantity
|
|
282
|
+
quantity,
|
|
213
283
|
state: draft.state || [],
|
|
214
284
|
taxRate: draft.taxRate,
|
|
285
|
+
taxedPrice: calculateTaxedPriceFromRate(
|
|
286
|
+
totalPrice.centAmount,
|
|
287
|
+
totalPrice.currencyCode,
|
|
288
|
+
draft.taxRate,
|
|
289
|
+
),
|
|
215
290
|
taxedPricePortions: [],
|
|
216
291
|
perMethodTaxRate: [],
|
|
217
|
-
totalPrice
|
|
292
|
+
totalPrice,
|
|
218
293
|
variant: {
|
|
219
294
|
id: variant.id,
|
|
220
295
|
sku: variant.sku,
|
|
@@ -230,6 +305,12 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
230
305
|
context: RepositoryContext,
|
|
231
306
|
draft: CustomLineItemImportDraft,
|
|
232
307
|
): CustomLineItem {
|
|
308
|
+
const quantity = draft.quantity ?? 1;
|
|
309
|
+
const totalPrice = createCentPrecisionMoney({
|
|
310
|
+
...draft.money,
|
|
311
|
+
centAmount: (draft.money.centAmount ?? 0) * quantity,
|
|
312
|
+
});
|
|
313
|
+
|
|
233
314
|
const lineItem: CustomLineItem = {
|
|
234
315
|
...getBaseResourceProperties(),
|
|
235
316
|
custom: createCustomFields(
|
|
@@ -240,12 +321,17 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
240
321
|
discountedPricePerQuantity: [],
|
|
241
322
|
money: createTypedMoney(draft.money),
|
|
242
323
|
name: draft.name,
|
|
243
|
-
quantity
|
|
324
|
+
quantity,
|
|
244
325
|
perMethodTaxRate: [],
|
|
245
326
|
priceMode: draft.priceMode ?? "Standard",
|
|
246
327
|
slug: draft.slug,
|
|
247
328
|
state: [],
|
|
248
|
-
totalPrice
|
|
329
|
+
totalPrice,
|
|
330
|
+
taxedPrice: calculateTaxedPriceFromRate(
|
|
331
|
+
totalPrice.centAmount,
|
|
332
|
+
totalPrice.currencyCode,
|
|
333
|
+
draft.taxRate,
|
|
334
|
+
),
|
|
249
335
|
taxedPricePortions: [],
|
|
250
336
|
};
|
|
251
337
|
|
|
@@ -272,4 +358,56 @@ export class OrderRepository extends AbstractResourceRepository<"order"> {
|
|
|
272
358
|
|
|
273
359
|
return;
|
|
274
360
|
}
|
|
361
|
+
|
|
362
|
+
createShippingInfo(
|
|
363
|
+
context: RepositoryContext,
|
|
364
|
+
resource: Writable<Order>,
|
|
365
|
+
shippingMethodRef: ShippingMethodReference,
|
|
366
|
+
): ShippingInfo {
|
|
367
|
+
const cartLikeForMatching: Writable<Cart> = {
|
|
368
|
+
...resource,
|
|
369
|
+
cartState: "Active" as const,
|
|
370
|
+
inventoryMode: "None" as const,
|
|
371
|
+
itemShippingAddresses: [],
|
|
372
|
+
priceRoundingMode: resource.taxRoundingMode || "HalfEven",
|
|
373
|
+
taxMode: resource.taxMode || "Platform",
|
|
374
|
+
taxCalculationMode: resource.taxCalculationMode || "LineItemLevel",
|
|
375
|
+
taxRoundingMode: resource.taxRoundingMode || "HalfEven",
|
|
376
|
+
discountCodes: resource.discountCodes || [],
|
|
377
|
+
directDiscounts: resource.directDiscounts || [],
|
|
378
|
+
shippingInfo: undefined,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const shippingMethods = getShippingMethodsMatchingCart(
|
|
382
|
+
context,
|
|
383
|
+
this._storage,
|
|
384
|
+
cartLikeForMatching,
|
|
385
|
+
{
|
|
386
|
+
expand: ["zoneRates[*].zone"],
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const method = shippingMethods.results.find(
|
|
391
|
+
(candidate) => candidate.id === shippingMethodRef.id,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
if (!method) {
|
|
395
|
+
throw new CommercetoolsError<ShippingMethodDoesNotMatchCartError>({
|
|
396
|
+
code: "ShippingMethodDoesNotMatchCart",
|
|
397
|
+
message: `The shipping method with ID '${shippingMethodRef.id}' is not allowed for the order with ID '${resource.id}'.`,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const baseShippingInfo = createShippingInfoFromMethod(
|
|
402
|
+
context,
|
|
403
|
+
this._storage,
|
|
404
|
+
resource,
|
|
405
|
+
method,
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
...baseShippingInfo,
|
|
410
|
+
deliveries: [],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
275
413
|
}
|
|
@@ -711,6 +711,217 @@ describe("Cart Update Actions", () => {
|
|
|
711
711
|
]);
|
|
712
712
|
});
|
|
713
713
|
|
|
714
|
+
test("setLineItemPrice sets an external price for a line item", async () => {
|
|
715
|
+
const product = await supertest(ctMock.app)
|
|
716
|
+
.post("/dummy/products")
|
|
717
|
+
.send(productDraft)
|
|
718
|
+
.then((x) => x.body);
|
|
719
|
+
|
|
720
|
+
assert(product, "product not created");
|
|
721
|
+
|
|
722
|
+
const baseCartResponse = await supertest(ctMock.app)
|
|
723
|
+
.post("/dummy/carts")
|
|
724
|
+
.send({ currency: "EUR" });
|
|
725
|
+
expect(baseCartResponse.status).toBe(201);
|
|
726
|
+
const baseCart = baseCartResponse.body as Cart;
|
|
727
|
+
|
|
728
|
+
const addLineItemResponse = await supertest(ctMock.app)
|
|
729
|
+
.post(`/dummy/carts/${baseCart.id}`)
|
|
730
|
+
.send({
|
|
731
|
+
version: baseCart.version,
|
|
732
|
+
actions: [
|
|
733
|
+
{
|
|
734
|
+
action: "addLineItem",
|
|
735
|
+
sku: product.masterData.current.masterVariant.sku,
|
|
736
|
+
quantity: 2,
|
|
737
|
+
key: "line-item-key",
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
});
|
|
741
|
+
expect(addLineItemResponse.status).toBe(200);
|
|
742
|
+
const cartWithLineItem = addLineItemResponse.body as Cart;
|
|
743
|
+
const lineItem = cartWithLineItem.lineItems[0];
|
|
744
|
+
assert(lineItem, "lineItem not created");
|
|
745
|
+
|
|
746
|
+
const externalPrice: CentPrecisionMoney = {
|
|
747
|
+
type: "centPrecision",
|
|
748
|
+
currencyCode: "EUR",
|
|
749
|
+
centAmount: 2500,
|
|
750
|
+
fractionDigits: 2,
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const response = await supertest(ctMock.app)
|
|
754
|
+
.post(`/dummy/carts/${cartWithLineItem.id}`)
|
|
755
|
+
.send({
|
|
756
|
+
version: cartWithLineItem.version,
|
|
757
|
+
actions: [
|
|
758
|
+
{
|
|
759
|
+
action: "setLineItemPrice",
|
|
760
|
+
lineItemKey: lineItem.key,
|
|
761
|
+
externalPrice,
|
|
762
|
+
},
|
|
763
|
+
],
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
expect(response.status).toBe(200);
|
|
767
|
+
expect(response.body.version).toBe(cartWithLineItem.version + 1);
|
|
768
|
+
expect(response.body.lineItems).toHaveLength(1);
|
|
769
|
+
|
|
770
|
+
const updatedLineItem = response.body.lineItems[0];
|
|
771
|
+
expect(updatedLineItem.priceMode).toBe("ExternalPrice");
|
|
772
|
+
expect(updatedLineItem.price.value.centAmount).toBe(
|
|
773
|
+
externalPrice.centAmount,
|
|
774
|
+
);
|
|
775
|
+
expect(updatedLineItem.price.value.currencyCode).toBe(
|
|
776
|
+
externalPrice.currencyCode,
|
|
777
|
+
);
|
|
778
|
+
expect(updatedLineItem.totalPrice.centAmount).toBe(
|
|
779
|
+
externalPrice.centAmount * updatedLineItem.quantity,
|
|
780
|
+
);
|
|
781
|
+
expect(response.body.totalPrice.centAmount).toBe(
|
|
782
|
+
externalPrice.centAmount * updatedLineItem.quantity,
|
|
783
|
+
);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("setLineItemPrice fails when the money uses another currency", async () => {
|
|
787
|
+
const product = await supertest(ctMock.app)
|
|
788
|
+
.post("/dummy/products")
|
|
789
|
+
.send(productDraft)
|
|
790
|
+
.then((x) => x.body);
|
|
791
|
+
|
|
792
|
+
assert(product, "product not created");
|
|
793
|
+
|
|
794
|
+
const baseCartResponse = await supertest(ctMock.app)
|
|
795
|
+
.post("/dummy/carts")
|
|
796
|
+
.send({ currency: "EUR" });
|
|
797
|
+
expect(baseCartResponse.status).toBe(201);
|
|
798
|
+
const baseCart = baseCartResponse.body as Cart;
|
|
799
|
+
|
|
800
|
+
const addLineItemResponse = await supertest(ctMock.app)
|
|
801
|
+
.post(`/dummy/carts/${baseCart.id}`)
|
|
802
|
+
.send({
|
|
803
|
+
version: baseCart.version,
|
|
804
|
+
actions: [
|
|
805
|
+
{
|
|
806
|
+
action: "addLineItem",
|
|
807
|
+
sku: product.masterData.current.masterVariant.sku,
|
|
808
|
+
quantity: 1,
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
});
|
|
812
|
+
expect(addLineItemResponse.status).toBe(200);
|
|
813
|
+
const cartWithLineItem = addLineItemResponse.body as Cart;
|
|
814
|
+
const lineItem = cartWithLineItem.lineItems[0];
|
|
815
|
+
assert(lineItem, "lineItem not created");
|
|
816
|
+
|
|
817
|
+
const response = await supertest(ctMock.app)
|
|
818
|
+
.post(`/dummy/carts/${cartWithLineItem.id}`)
|
|
819
|
+
.send({
|
|
820
|
+
version: cartWithLineItem.version,
|
|
821
|
+
actions: [
|
|
822
|
+
{
|
|
823
|
+
action: "setLineItemPrice",
|
|
824
|
+
lineItemId: lineItem.id,
|
|
825
|
+
externalPrice: {
|
|
826
|
+
type: "centPrecision",
|
|
827
|
+
currencyCode: "USD",
|
|
828
|
+
centAmount: 5000,
|
|
829
|
+
fractionDigits: 2,
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
expect(response.status).toBe(400);
|
|
836
|
+
expect(response.body.message).toContain("Currency mismatch");
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("setLineItemPrice removes external price when no value is provided", async () => {
|
|
840
|
+
const product = await supertest(ctMock.app)
|
|
841
|
+
.post("/dummy/products")
|
|
842
|
+
.send(productDraft)
|
|
843
|
+
.then((x) => x.body);
|
|
844
|
+
|
|
845
|
+
assert(product, "product not created");
|
|
846
|
+
|
|
847
|
+
const baseCartResponse = await supertest(ctMock.app)
|
|
848
|
+
.post("/dummy/carts")
|
|
849
|
+
.send({ currency: "EUR" });
|
|
850
|
+
expect(baseCartResponse.status).toBe(201);
|
|
851
|
+
const baseCart = baseCartResponse.body as Cart;
|
|
852
|
+
|
|
853
|
+
const addLineItemResponse = await supertest(ctMock.app)
|
|
854
|
+
.post(`/dummy/carts/${baseCart.id}`)
|
|
855
|
+
.send({
|
|
856
|
+
version: baseCart.version,
|
|
857
|
+
actions: [
|
|
858
|
+
{
|
|
859
|
+
action: "addLineItem",
|
|
860
|
+
sku: product.masterData.current.masterVariant.sku,
|
|
861
|
+
quantity: 1,
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
});
|
|
865
|
+
expect(addLineItemResponse.status).toBe(200);
|
|
866
|
+
const cartWithLineItem = addLineItemResponse.body as Cart;
|
|
867
|
+
const lineItem = cartWithLineItem.lineItems[0];
|
|
868
|
+
assert(lineItem, "lineItem not created");
|
|
869
|
+
|
|
870
|
+
const externalPrice: CentPrecisionMoney = {
|
|
871
|
+
type: "centPrecision",
|
|
872
|
+
currencyCode: "EUR",
|
|
873
|
+
centAmount: 1000,
|
|
874
|
+
fractionDigits: 2,
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
const setExternalPriceResponse = await supertest(ctMock.app)
|
|
878
|
+
.post(`/dummy/carts/${cartWithLineItem.id}`)
|
|
879
|
+
.send({
|
|
880
|
+
version: cartWithLineItem.version,
|
|
881
|
+
actions: [
|
|
882
|
+
{
|
|
883
|
+
action: "setLineItemPrice",
|
|
884
|
+
lineItemId: lineItem.id,
|
|
885
|
+
externalPrice,
|
|
886
|
+
},
|
|
887
|
+
],
|
|
888
|
+
});
|
|
889
|
+
expect(setExternalPriceResponse.status).toBe(200);
|
|
890
|
+
const cartWithExternalPrice = setExternalPriceResponse.body as Cart;
|
|
891
|
+
expect(cartWithExternalPrice.lineItems[0].priceMode).toBe("ExternalPrice");
|
|
892
|
+
|
|
893
|
+
const resetResponse = await supertest(ctMock.app)
|
|
894
|
+
.post(`/dummy/carts/${cartWithExternalPrice.id}`)
|
|
895
|
+
.send({
|
|
896
|
+
version: cartWithExternalPrice.version,
|
|
897
|
+
actions: [
|
|
898
|
+
{
|
|
899
|
+
action: "setLineItemPrice",
|
|
900
|
+
lineItemId: lineItem.id,
|
|
901
|
+
},
|
|
902
|
+
],
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
expect(resetResponse.status).toBe(200);
|
|
906
|
+
expect(resetResponse.body.version).toBe(cartWithExternalPrice.version + 1);
|
|
907
|
+
expect(resetResponse.body.lineItems).toHaveLength(1);
|
|
908
|
+
|
|
909
|
+
const revertedLineItem = resetResponse.body.lineItems[0];
|
|
910
|
+
const expectedCentAmount =
|
|
911
|
+
product.masterData.current.masterVariant.prices?.[0].value.centAmount;
|
|
912
|
+
if (typeof expectedCentAmount !== "number") {
|
|
913
|
+
throw new Error("product price not found");
|
|
914
|
+
}
|
|
915
|
+
expect(revertedLineItem.priceMode).toBe("Platform");
|
|
916
|
+
expect(revertedLineItem.price.value.centAmount).toBe(expectedCentAmount);
|
|
917
|
+
expect(revertedLineItem.totalPrice.centAmount).toBe(
|
|
918
|
+
expectedCentAmount * revertedLineItem.quantity,
|
|
919
|
+
);
|
|
920
|
+
expect(resetResponse.body.totalPrice.centAmount).toBe(
|
|
921
|
+
expectedCentAmount * revertedLineItem.quantity,
|
|
922
|
+
);
|
|
923
|
+
});
|
|
924
|
+
|
|
714
925
|
test("setLineItemCustomField", async () => {
|
|
715
926
|
const product = await supertest(ctMock.app)
|
|
716
927
|
.post("/dummy/products")
|
package/src/shipping.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Cart,
|
|
3
3
|
CartValueTier,
|
|
4
|
+
CentPrecisionMoney,
|
|
4
5
|
InvalidOperationError,
|
|
6
|
+
MissingTaxRateForCountryError,
|
|
7
|
+
Order,
|
|
8
|
+
ShippingInfo,
|
|
9
|
+
ShippingMethod,
|
|
10
|
+
ShippingMethodDoesNotMatchCartError,
|
|
5
11
|
ShippingRate,
|
|
6
12
|
ShippingRatePriceTier,
|
|
13
|
+
TaxPortion,
|
|
14
|
+
TaxRate,
|
|
15
|
+
TaxedItemPrice,
|
|
7
16
|
} from "@commercetools/platform-sdk";
|
|
17
|
+
import { Decimal } from "decimal.js";
|
|
8
18
|
import { CommercetoolsError } from "./exceptions";
|
|
9
19
|
import type { GetParams, RepositoryContext } from "./repositories/abstract";
|
|
20
|
+
import { createCentPrecisionMoney, roundDecimal } from "./repositories/helpers";
|
|
10
21
|
import type { AbstractStorage } from "./storage/abstract";
|
|
11
22
|
|
|
12
23
|
export const markMatchingShippingRate = (
|
|
@@ -145,3 +156,149 @@ export const getShippingMethodsMatchingCart = (
|
|
|
145
156
|
results: results,
|
|
146
157
|
};
|
|
147
158
|
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Interface for cart-like objects that can be used for shipping calculations
|
|
162
|
+
*/
|
|
163
|
+
interface ShippingCalculationSource {
|
|
164
|
+
id: string;
|
|
165
|
+
totalPrice: CentPrecisionMoney;
|
|
166
|
+
shippingAddress?: {
|
|
167
|
+
country: string;
|
|
168
|
+
[key: string]: any;
|
|
169
|
+
};
|
|
170
|
+
taxRoundingMode?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Creates shipping info from a shipping method, handling all tax calculations and pricing logic.
|
|
175
|
+
*/
|
|
176
|
+
export const createShippingInfoFromMethod = (
|
|
177
|
+
context: RepositoryContext,
|
|
178
|
+
storage: AbstractStorage,
|
|
179
|
+
resource: ShippingCalculationSource,
|
|
180
|
+
method: ShippingMethod,
|
|
181
|
+
): Omit<ShippingInfo, "deliveries"> => {
|
|
182
|
+
const country = resource.shippingAddress!.country;
|
|
183
|
+
|
|
184
|
+
// There should only be one zone rate matching the address, since
|
|
185
|
+
// Locations cannot be assigned to more than one zone.
|
|
186
|
+
// See https://docs.commercetools.com/api/projects/zones#location
|
|
187
|
+
const zoneRate = method.zoneRates.find((rate) =>
|
|
188
|
+
rate.zone.obj?.locations.some((loc) => loc.country === country),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (!zoneRate) {
|
|
192
|
+
// This shouldn't happen because getShippingMethodsMatchingCart already
|
|
193
|
+
// filtered out shipping methods without any zones matching the address
|
|
194
|
+
throw new Error("Zone rate not found");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Shipping rates are defined by currency, and getShippingMethodsMatchingCart
|
|
198
|
+
// also matches on currency, so there should only be one in the array.
|
|
199
|
+
// See https://docs.commercetools.com/api/projects/shippingMethods#zonerate
|
|
200
|
+
const shippingRate = zoneRate.shippingRates[0];
|
|
201
|
+
if (!shippingRate) {
|
|
202
|
+
// This shouldn't happen because getShippingMethodsMatchingCart already
|
|
203
|
+
// filtered out shipping methods without any matching rates
|
|
204
|
+
throw new Error("Shipping rate not found");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const taxCategory = storage.getByResourceIdentifier<"tax-category">(
|
|
208
|
+
context.projectKey,
|
|
209
|
+
method.taxCategory,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// TODO: match state in addition to country
|
|
213
|
+
const taxRate = taxCategory.rates.find((rate) => rate.country === country);
|
|
214
|
+
|
|
215
|
+
if (!taxRate) {
|
|
216
|
+
throw new CommercetoolsError<MissingTaxRateForCountryError>({
|
|
217
|
+
code: "MissingTaxRateForCountry",
|
|
218
|
+
message: `Tax category '${taxCategory.id}' is missing a tax rate for country '${country}'.`,
|
|
219
|
+
taxCategoryId: taxCategory.id,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const shippingRateTier = shippingRate.tiers.find((tier) => tier.isMatching);
|
|
224
|
+
if (shippingRateTier && shippingRateTier.type !== "CartValue") {
|
|
225
|
+
throw new Error("Non-CartValue shipping rate tier is not supported");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let shippingPrice = shippingRateTier
|
|
229
|
+
? createCentPrecisionMoney(shippingRateTier.price)
|
|
230
|
+
: shippingRate.price;
|
|
231
|
+
|
|
232
|
+
// Handle freeAbove: if total is above the freeAbove threshold, shipping is free
|
|
233
|
+
if (
|
|
234
|
+
shippingRate.freeAbove &&
|
|
235
|
+
shippingRate.freeAbove.currencyCode === resource.totalPrice.currencyCode &&
|
|
236
|
+
resource.totalPrice.centAmount >= shippingRate.freeAbove.centAmount
|
|
237
|
+
) {
|
|
238
|
+
shippingPrice = {
|
|
239
|
+
...shippingPrice,
|
|
240
|
+
centAmount: 0,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Calculate tax amounts
|
|
245
|
+
const totalGross: CentPrecisionMoney = taxRate.includedInPrice
|
|
246
|
+
? shippingPrice
|
|
247
|
+
: {
|
|
248
|
+
...shippingPrice,
|
|
249
|
+
centAmount: roundDecimal(
|
|
250
|
+
new Decimal(shippingPrice.centAmount).mul(1 + taxRate.amount),
|
|
251
|
+
resource.taxRoundingMode || "HalfEven",
|
|
252
|
+
).toNumber(),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const totalNet: CentPrecisionMoney = taxRate.includedInPrice
|
|
256
|
+
? {
|
|
257
|
+
...shippingPrice,
|
|
258
|
+
centAmount: roundDecimal(
|
|
259
|
+
new Decimal(shippingPrice.centAmount).div(1 + taxRate.amount),
|
|
260
|
+
resource.taxRoundingMode || "HalfEven",
|
|
261
|
+
).toNumber(),
|
|
262
|
+
}
|
|
263
|
+
: shippingPrice;
|
|
264
|
+
|
|
265
|
+
const taxPortions: TaxPortion[] = [
|
|
266
|
+
{
|
|
267
|
+
name: taxRate.name,
|
|
268
|
+
rate: taxRate.amount,
|
|
269
|
+
amount: {
|
|
270
|
+
...shippingPrice,
|
|
271
|
+
centAmount: totalGross.centAmount - totalNet.centAmount,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
const totalTax: CentPrecisionMoney = {
|
|
277
|
+
...shippingPrice,
|
|
278
|
+
centAmount: taxPortions.reduce(
|
|
279
|
+
(acc, portion) => acc + portion.amount.centAmount,
|
|
280
|
+
0,
|
|
281
|
+
),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const taxedPrice: TaxedItemPrice = {
|
|
285
|
+
totalNet,
|
|
286
|
+
totalGross,
|
|
287
|
+
taxPortions,
|
|
288
|
+
totalTax,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
shippingMethod: {
|
|
293
|
+
typeId: "shipping-method" as const,
|
|
294
|
+
id: method.id,
|
|
295
|
+
},
|
|
296
|
+
shippingMethodName: method.name,
|
|
297
|
+
price: shippingPrice,
|
|
298
|
+
shippingRate,
|
|
299
|
+
taxedPrice,
|
|
300
|
+
taxRate,
|
|
301
|
+
taxCategory: method.taxCategory,
|
|
302
|
+
shippingMethodState: "MatchesCart",
|
|
303
|
+
};
|
|
304
|
+
};
|