@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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=getCartCost.test.d.ts.map
@@ -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
- export declare function getCartCost({ cart, discounts, deliveryPrice, freeDeliveryPrice, freeShipping, isVatIncludedInPrice, }: {
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,EAAE,SAAS,EAAY,MAAM,aAAa,CAAC;AAmCzD,wBAAgB,WAAW,CAAC,EAC3B,IAAI,EACJ,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwFA"}
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"}
@@ -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,