@jsdev_ninja/core 0.22.0 → 0.23.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/README.md +3 -1
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.es.js +1039 -1021
- package/dist/core.es.js.map +1 -1
- package/dist/core.umd.js +1 -1
- package/dist/core.umd.js.map +1 -1
- package/dist/lib/utils/getCartCost.test.d.ts +2 -0
- package/dist/lib/utils/getCartCost.test.d.ts.map +1 -0
- package/dist/lib/utils/getCartCost.test.js +236 -0
- package/dist/lib/utils/index.d.ts +20 -1
- package/dist/lib/utils/index.d.ts.map +1 -1
- package/dist/lib/utils/index.js +43 -1
- package/dist/tsconfig.app.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"getCartCost.test.d.ts","sourceRoot":"","sources":["../../../lib/utils/getCartCost.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { getCartCost, getFulfilledCartItems } from "./index";
|
|
3
|
+
/** Minimal product fixture — only the fields getCartCost actually reads. */
|
|
4
|
+
function makeProduct(overrides) {
|
|
5
|
+
return {
|
|
6
|
+
type: "Product",
|
|
7
|
+
storeId: "store-1",
|
|
8
|
+
companyId: "company-1",
|
|
9
|
+
id: "prod-1",
|
|
10
|
+
objectID: "prod-1",
|
|
11
|
+
sku: "SKU-1",
|
|
12
|
+
name: [{ locale: "he", value: "מוצר" }],
|
|
13
|
+
description: [],
|
|
14
|
+
isPublished: true,
|
|
15
|
+
vat: false,
|
|
16
|
+
priceType: { type: "unit", value: 1 },
|
|
17
|
+
currency: "ILS",
|
|
18
|
+
discount: { type: "none", value: 0 },
|
|
19
|
+
weight: { value: 0, unit: "none" },
|
|
20
|
+
volume: { value: 0, unit: "none" },
|
|
21
|
+
images: [],
|
|
22
|
+
manufacturer: "",
|
|
23
|
+
brand: "",
|
|
24
|
+
importer: "",
|
|
25
|
+
supplier: "",
|
|
26
|
+
ingredients: [],
|
|
27
|
+
created_at: 0,
|
|
28
|
+
updated_at: 0,
|
|
29
|
+
categoryIds: [],
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Minimal cart item fixture. */
|
|
34
|
+
function makeItem(product, amount, extra) {
|
|
35
|
+
return { product, amount, ...extra };
|
|
36
|
+
}
|
|
37
|
+
describe("getFulfilledCartItems — shape normalisation only", () => {
|
|
38
|
+
test("passes through items with no status unchanged", () => {
|
|
39
|
+
const product = makeProduct({ price: 10 });
|
|
40
|
+
const items = [makeItem(product, 2)];
|
|
41
|
+
const result = getFulfilledCartItems(items);
|
|
42
|
+
expect(result).toHaveLength(1);
|
|
43
|
+
expect(result[0].amount).toBe(2);
|
|
44
|
+
expect(result[0].product.price).toBe(10);
|
|
45
|
+
});
|
|
46
|
+
test("drops missing items", () => {
|
|
47
|
+
const product = makeProduct({ price: 10 });
|
|
48
|
+
const items = [
|
|
49
|
+
makeItem(product, 1, { status: "missing" }),
|
|
50
|
+
makeItem(product, 3),
|
|
51
|
+
];
|
|
52
|
+
const result = getFulfilledCartItems(items);
|
|
53
|
+
expect(result).toHaveLength(1);
|
|
54
|
+
expect(result[0].amount).toBe(3);
|
|
55
|
+
});
|
|
56
|
+
test("rewrites substituted item: product pinned to sub.price, discount neutralised", () => {
|
|
57
|
+
const original = makeProduct({ price: 20 });
|
|
58
|
+
const subProduct = makeProduct({
|
|
59
|
+
id: "sub-prod",
|
|
60
|
+
price: 15,
|
|
61
|
+
discount: { type: "percent", value: 10 }, // would reduce to 13.5 if applied
|
|
62
|
+
});
|
|
63
|
+
const items = [
|
|
64
|
+
makeItem(original, 2, {
|
|
65
|
+
status: "substituted",
|
|
66
|
+
substitutedWith: { product: subProduct, amount: 3, price: 15 },
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
const result = getFulfilledCartItems(items);
|
|
70
|
+
expect(result).toHaveLength(1);
|
|
71
|
+
// product.price pinned to sub.price
|
|
72
|
+
expect(result[0].product.price).toBe(15);
|
|
73
|
+
// discount neutralised (type:"none") so getCartCost won't re-discount
|
|
74
|
+
expect(result[0].product.discount).toEqual({ type: "none", value: 0 });
|
|
75
|
+
// amount from sub
|
|
76
|
+
expect(result[0].amount).toBe(3);
|
|
77
|
+
// getFulfilledCartItems does NOT set finalPrice — pricing is getCartCost's job
|
|
78
|
+
expect(result[0].finalPrice).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
test("is idempotent for substituted items", () => {
|
|
81
|
+
const original = makeProduct({ price: 20 });
|
|
82
|
+
const subProduct = makeProduct({
|
|
83
|
+
id: "sub-prod",
|
|
84
|
+
price: 15,
|
|
85
|
+
discount: { type: "percent", value: 10 },
|
|
86
|
+
});
|
|
87
|
+
const item = makeItem(original, 2, {
|
|
88
|
+
status: "substituted",
|
|
89
|
+
substitutedWith: { product: subProduct, amount: 3, price: 15 },
|
|
90
|
+
});
|
|
91
|
+
const once = getFulfilledCartItems([item]);
|
|
92
|
+
const twice = getFulfilledCartItems(once);
|
|
93
|
+
// second pass re-reads substitutedWith from the original item (spread intact)
|
|
94
|
+
// and re-pins to the same values — identical result
|
|
95
|
+
expect(twice[0].product.price).toBe(15);
|
|
96
|
+
expect(twice[0].product.discount).toEqual({ type: "none", value: 0 });
|
|
97
|
+
expect(twice[0].amount).toBe(3);
|
|
98
|
+
expect(twice[0].finalPrice).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe("getCartCost — picking-aware pricing", () => {
|
|
102
|
+
test("non-picked cart prices exactly as before (no status fields)", () => {
|
|
103
|
+
const product = makeProduct({ price: 100, vat: false });
|
|
104
|
+
const items = [makeItem(product, 2)];
|
|
105
|
+
const result = getCartCost({ cart: items, discounts: [] });
|
|
106
|
+
expect(result.items).toHaveLength(1);
|
|
107
|
+
expect(result.items[0].finalPrice).toBe(100);
|
|
108
|
+
expect(result.cost).toBe(200);
|
|
109
|
+
expect(result.finalCost).toBe(200);
|
|
110
|
+
});
|
|
111
|
+
test("non-picked cart with product discount is unchanged", () => {
|
|
112
|
+
const product = makeProduct({
|
|
113
|
+
price: 100,
|
|
114
|
+
discount: { type: "percent", value: 10 }, // 10% off → finalPrice 90
|
|
115
|
+
});
|
|
116
|
+
const items = [makeItem(product, 2)];
|
|
117
|
+
const result = getCartCost({ cart: items, discounts: [] });
|
|
118
|
+
expect(result.items[0].finalPrice).toBe(90);
|
|
119
|
+
expect(result.cost).toBe(180);
|
|
120
|
+
});
|
|
121
|
+
test("missing items are excluded from cost", () => {
|
|
122
|
+
const product = makeProduct({ price: 50 });
|
|
123
|
+
const items = [
|
|
124
|
+
makeItem(product, 1, { status: "missing" }),
|
|
125
|
+
makeItem(product, 2),
|
|
126
|
+
];
|
|
127
|
+
const result = getCartCost({ cart: items, discounts: [] });
|
|
128
|
+
expect(result.items).toHaveLength(1);
|
|
129
|
+
expect(result.cost).toBe(100); // only the 2-unit line
|
|
130
|
+
});
|
|
131
|
+
test("substituted line: finalPrice = sub.price regardless of substitute product's own discount (isVatIncludedInPrice: false)", () => {
|
|
132
|
+
const original = makeProduct({ price: 80 });
|
|
133
|
+
const subProduct = makeProduct({
|
|
134
|
+
id: "sub",
|
|
135
|
+
price: 60,
|
|
136
|
+
discount: { type: "percent", value: 20 }, // would reduce to 48 if applied
|
|
137
|
+
});
|
|
138
|
+
const items = [
|
|
139
|
+
makeItem(original, 1, {
|
|
140
|
+
status: "substituted",
|
|
141
|
+
substitutedWith: { product: subProduct, amount: 2, price: 60 },
|
|
142
|
+
}),
|
|
143
|
+
];
|
|
144
|
+
const result = getCartCost({ cart: items, discounts: [], isVatIncludedInPrice: false });
|
|
145
|
+
// finalPrice must be sub.price (60), NOT the re-discounted 48
|
|
146
|
+
expect(result.items).toHaveLength(1);
|
|
147
|
+
expect(result.items[0].finalPrice).toBe(60);
|
|
148
|
+
expect(result.items[0].amount).toBe(2);
|
|
149
|
+
expect(result.cost).toBe(120); // 60 × 2
|
|
150
|
+
expect(result.finalCost).toBe(120);
|
|
151
|
+
});
|
|
152
|
+
test("mixed cart: one missing, one substituted with discounted sub, one normal", () => {
|
|
153
|
+
const productA = makeProduct({ id: "a", price: 100 });
|
|
154
|
+
const productB = makeProduct({ id: "b", price: 50 });
|
|
155
|
+
const productC = makeProduct({ id: "c", price: 30 });
|
|
156
|
+
const subProduct = makeProduct({
|
|
157
|
+
id: "sub-b",
|
|
158
|
+
price: 45,
|
|
159
|
+
discount: { type: "number", value: 5 }, // would reduce to 40 if applied
|
|
160
|
+
});
|
|
161
|
+
const items = [
|
|
162
|
+
makeItem(productA, 1, { status: "missing" }),
|
|
163
|
+
makeItem(productB, 2, {
|
|
164
|
+
status: "substituted",
|
|
165
|
+
substitutedWith: { product: subProduct, amount: 3, price: 45 },
|
|
166
|
+
}),
|
|
167
|
+
makeItem(productC, 4),
|
|
168
|
+
];
|
|
169
|
+
const result = getCartCost({ cart: items, discounts: [] });
|
|
170
|
+
// missing dropped, 2 lines remain
|
|
171
|
+
expect(result.items).toHaveLength(2);
|
|
172
|
+
// substituted line: finalPrice = sub.price = 45, amount = 3, total = 135
|
|
173
|
+
const subLine = result.items.find((i) => i.product.id === "sub-b");
|
|
174
|
+
expect(subLine.finalPrice).toBe(45);
|
|
175
|
+
expect(subLine.amount).toBe(3);
|
|
176
|
+
// normal line: finalPrice = 30, amount = 4, total = 120
|
|
177
|
+
const normalLine = result.items.find((i) => i.product.id === "c");
|
|
178
|
+
expect(normalLine.finalPrice).toBe(30);
|
|
179
|
+
expect(normalLine.amount).toBe(4);
|
|
180
|
+
// total cost = 135 + 120 = 255
|
|
181
|
+
expect(result.cost).toBe(255);
|
|
182
|
+
expect(result.finalCost).toBe(255);
|
|
183
|
+
});
|
|
184
|
+
test("substituted line: VAT added on top of sub.price when isVatIncludedInPrice: false", () => {
|
|
185
|
+
const original = makeProduct({ price: 80, vat: false });
|
|
186
|
+
const subProduct = makeProduct({
|
|
187
|
+
id: "sub",
|
|
188
|
+
price: 60,
|
|
189
|
+
vat: true, // has VAT
|
|
190
|
+
discount: { type: "percent", value: 20 }, // discount must be ignored
|
|
191
|
+
});
|
|
192
|
+
const items = [
|
|
193
|
+
makeItem(original, 1, {
|
|
194
|
+
status: "substituted",
|
|
195
|
+
substitutedWith: { product: subProduct, amount: 1, price: 60 },
|
|
196
|
+
}),
|
|
197
|
+
];
|
|
198
|
+
const result = getCartCost({ cart: items, discounts: [], isVatIncludedInPrice: false });
|
|
199
|
+
// finalPrice = 60, VAT = 60 × 0.18 = 10.8, finalCost = 70.8
|
|
200
|
+
expect(result.items[0].finalPrice).toBe(60);
|
|
201
|
+
expect(result.vat).toBe(10.8);
|
|
202
|
+
expect(result.finalCost).toBe(70.8);
|
|
203
|
+
// Delivery-note lines (finalPrice × amount) reconcile to finalCost
|
|
204
|
+
const lineTotal = result.items.reduce((sum, i) => sum + i.finalPrice * i.amount, 0);
|
|
205
|
+
expect(lineTotal).toBe(60); // pre-VAT sum; finalCost includes VAT added on top
|
|
206
|
+
});
|
|
207
|
+
test("substituted line: Σ finalPrice × amount reconciles to finalCost when isVatIncludedInPrice: true", () => {
|
|
208
|
+
// Core invariant for delivery-note correctness: when line items are priced via
|
|
209
|
+
// getCartCost, their sum (finalPrice × amount) equals finalCost. This holds
|
|
210
|
+
// for substituted lines only if their pricing goes through getCartCost (not
|
|
211
|
+
// getFulfilledCartItems, which returns raw sub.price without VAT adjustment).
|
|
212
|
+
const original = makeProduct({ price: 80, vat: false });
|
|
213
|
+
const subProduct = makeProduct({
|
|
214
|
+
id: "sub",
|
|
215
|
+
price: 60,
|
|
216
|
+
vat: true,
|
|
217
|
+
discount: { type: "percent", value: 20 }, // must be stripped — sub.price is authoritative
|
|
218
|
+
});
|
|
219
|
+
const items = [
|
|
220
|
+
makeItem(original, 1, {
|
|
221
|
+
status: "substituted",
|
|
222
|
+
substitutedWith: { product: subProduct, amount: 2, price: 60 },
|
|
223
|
+
}),
|
|
224
|
+
];
|
|
225
|
+
const result = getCartCost({ cart: items, discounts: [], isVatIncludedInPrice: true });
|
|
226
|
+
// Whatever finalPrice getCartCost computes for the line, the sum of
|
|
227
|
+
// (finalPrice × amount) across all items must equal finalCost (no delivery here),
|
|
228
|
+
// so that a delivery note built from getCartCost(...).items sums to cartTotal.
|
|
229
|
+
const lineTotal = Number(result.items.reduce((sum, i) => sum + i.finalPrice * i.amount, 0).toFixed(2));
|
|
230
|
+
expect(lineTotal).toBe(result.finalCost);
|
|
231
|
+
// The discount must not have been re-applied: the substitute's discounted price
|
|
232
|
+
// (60 × 0.8 = 48) must NOT appear as finalPrice.
|
|
233
|
+
expect(result.items[0].finalPrice).not.toBe(48);
|
|
234
|
+
expect(result.items[0].finalPrice).not.toBe(48 * 1.18); // discounted + VAT
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { TCart, TDiscount } from "../entities";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Internal normaliser for getCartCost — not a pricer.
|
|
4
|
+
*
|
|
5
|
+
* Rewrite the raw items array so it only contains lines that should be charged:
|
|
6
|
+
* - "missing" → dropped
|
|
7
|
+
* - "substituted" (with a truthy substitutedWith) → product and amount replaced
|
|
8
|
+
* with the substitute's. The substitute product's own discount is neutralised
|
|
9
|
+
* (discount: { type:"none", value:0 }) so getCartCost's getPriceAfterDiscount
|
|
10
|
+
* returns sub.price unchanged, keeping all callers (cartTotal, EZcount doc,
|
|
11
|
+
* delivery-note PDF, HYP charge) consistent.
|
|
12
|
+
* - everything else → passed through unchanged
|
|
13
|
+
*
|
|
14
|
+
* Pricing (finalPrice, VAT, discounts) is owned entirely by getCartCost — callers
|
|
15
|
+
* that need priced line items must use getCartCost(...).items, not this function.
|
|
16
|
+
*
|
|
17
|
+
* Pure and idempotent: a second pass re-reads substitutedWith intact and
|
|
18
|
+
* re-pins to the same values.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getFulfilledCartItems(items: TCart["items"]): TCart["items"];
|
|
21
|
+
export declare function getCartCost({ cart: rawCart, discounts, deliveryPrice, freeDeliveryPrice, freeShipping, isVatIncludedInPrice, }: {
|
|
3
22
|
cart: TCart["items"];
|
|
4
23
|
discounts: TDiscount[];
|
|
5
24
|
deliveryPrice?: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../lib/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../lib/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAoB,SAAS,EAAY,MAAM,aAAa,CAAC;AAO3E;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,CAmB3E;AA8BD,wBAAgB,WAAW,CAAC,EAC3B,IAAI,EAAE,OAAO,EACb,SAAS,EACT,aAAiB,EACjB,iBAAqB,EACrB,YAAoB,EACpB,oBAA4B,GAC5B,EAAE;IACF,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;IACrB,SAAS,EAAE,SAAS,EAAE,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAC/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6FA"}
|
package/dist/lib/utils/index.js
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
const CONFIG = {
|
|
3
3
|
VAT: 18,
|
|
4
4
|
};
|
|
5
|
+
/**
|
|
6
|
+
* Internal normaliser for getCartCost — not a pricer.
|
|
7
|
+
*
|
|
8
|
+
* Rewrite the raw items array so it only contains lines that should be charged:
|
|
9
|
+
* - "missing" → dropped
|
|
10
|
+
* - "substituted" (with a truthy substitutedWith) → product and amount replaced
|
|
11
|
+
* with the substitute's. The substitute product's own discount is neutralised
|
|
12
|
+
* (discount: { type:"none", value:0 }) so getCartCost's getPriceAfterDiscount
|
|
13
|
+
* returns sub.price unchanged, keeping all callers (cartTotal, EZcount doc,
|
|
14
|
+
* delivery-note PDF, HYP charge) consistent.
|
|
15
|
+
* - everything else → passed through unchanged
|
|
16
|
+
*
|
|
17
|
+
* Pricing (finalPrice, VAT, discounts) is owned entirely by getCartCost — callers
|
|
18
|
+
* that need priced line items must use getCartCost(...).items, not this function.
|
|
19
|
+
*
|
|
20
|
+
* Pure and idempotent: a second pass re-reads substitutedWith intact and
|
|
21
|
+
* re-pins to the same values.
|
|
22
|
+
*/
|
|
23
|
+
export function getFulfilledCartItems(items) {
|
|
24
|
+
return items.reduce((acc, item) => {
|
|
25
|
+
if (item.status === "missing") {
|
|
26
|
+
return acc;
|
|
27
|
+
}
|
|
28
|
+
if (item.status === "substituted" && item.substitutedWith) {
|
|
29
|
+
const sub = item.substitutedWith;
|
|
30
|
+
acc.push({
|
|
31
|
+
...item,
|
|
32
|
+
// Pin price to sub.price and neutralise the substitute product's own
|
|
33
|
+
// discount so getPriceAfterDiscount returns sub.price with no reduction.
|
|
34
|
+
product: { ...sub.product, price: sub.price, discount: { type: "none", value: 0 } },
|
|
35
|
+
amount: sub.amount,
|
|
36
|
+
});
|
|
37
|
+
return acc;
|
|
38
|
+
}
|
|
39
|
+
acc.push(item);
|
|
40
|
+
return acc;
|
|
41
|
+
}, []);
|
|
42
|
+
}
|
|
5
43
|
function calculateDiscount(product, isVatIncludedInPrice) {
|
|
6
44
|
if (product.discount?.type === "percent") {
|
|
7
45
|
return (product.price * (product.discount.value ?? 100)) / 100;
|
|
@@ -26,7 +64,11 @@ function getPriceAfterDiscount(product, isVatIncludedInPrice) {
|
|
|
26
64
|
return price;
|
|
27
65
|
}
|
|
28
66
|
// main
|
|
29
|
-
export function getCartCost({ cart, discounts, deliveryPrice = 0, freeDeliveryPrice = 0, freeShipping = false, isVatIncludedInPrice = false, }) {
|
|
67
|
+
export function getCartCost({ cart: rawCart, discounts, deliveryPrice = 0, freeDeliveryPrice = 0, freeShipping = false, isVatIncludedInPrice = false, }) {
|
|
68
|
+
// Normalise for picking: drop missing lines, replace substituted lines with
|
|
69
|
+
// the substitute product/amount/price. This is a no-op for regular (live)
|
|
70
|
+
// carts where no item has a status field.
|
|
71
|
+
const cart = getFulfilledCartItems(rawCart);
|
|
30
72
|
// Convert cart items to the format expected by the discount engine
|
|
31
73
|
// const cartForEngine = cart.map((item) => ({
|
|
32
74
|
// amount: item.amount,
|