washday-sdk 1.6.70 → 1.6.72

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,7 +1,7 @@
1
1
  import { DiscountCodeTypes } from "../../enum";
2
2
  import { calculateTotalTaxesIncluded } from "./calculateTotalTaxesIncluded";
3
3
  import { calculateTotalTaxesOverPrice } from "./calculateTotalTaxesOverPrice";
4
- import { getCreditApplied, getProductLineTotals, getShippingCost } from "./helpers";
4
+ import { applyDiscountToProducts, getCreditApplied, getProductLineTotals, getShippingCost, } from "./helpers";
5
5
  const getNormalizedId = (value) => {
6
6
  if (value === null || value === undefined) {
7
7
  return "";
@@ -41,38 +41,80 @@ export const calculateOrderTotal = (order, selectedCustomer, storeSettings, hasS
41
41
  var _a, _b, _c;
42
42
  try {
43
43
  const appliedOrderDiscounts = {};
44
+ // For NUMBER coupons (fixed-amount) we must bake `discountAmount` per
45
+ // line so the tax calculators can reduce the taxable base BEFORE
46
+ // computing tax. `applyDiscountToProducts` handles both per-unit
47
+ // (`applyOnceOnOrder=false`) and proportional allocation
48
+ // (`applyOnceOnOrder=true`). For other coupon types, the tax calc derives
49
+ // the discount via `discPercentageInteger` so we can skip the pre-pass.
50
+ let processedOrder = order;
51
+ if (discountCodeObj && discountCodeObj.type === DiscountCodeTypes.NUMBER) {
52
+ const { newOrderProds, buyAndGetProds } = applyDiscountToProducts(discountCodeObj, (_a = order.products) !== null && _a !== void 0 ? _a : [], Boolean(order.express), storeDiscounts, selectedCustomer === null || selectedCustomer === void 0 ? void 0 : selectedCustomer.customer);
53
+ processedOrder = Object.assign(Object.assign({}, order), { products: newOrderProds, buyAndGetProducts: buyAndGetProds && buyAndGetProds.length > 0
54
+ ? buyAndGetProds
55
+ : (_b = order.buyAndGetProducts) !== null && _b !== void 0 ? _b : [] });
56
+ }
44
57
  const productTableCalculator = (storeSettings === null || storeSettings === void 0 ? void 0 : storeSettings.taxesType) === 'over_price'
45
58
  ? calculateTotalTaxesOverPrice
46
59
  : calculateTotalTaxesIncluded;
47
- const productTableImports = productTableCalculator(order, selectedCustomer, storeSettings, storeDiscounts, appliedOrderDiscounts, discountCodeObj);
60
+ const productTableImports = productTableCalculator(processedOrder, selectedCustomer, storeSettings, storeDiscounts, appliedOrderDiscounts, discountCodeObj);
48
61
  // === PRODUCT LINE TOTALS ===
49
- const { totalQuantity, totalImportWithDiscount, totalImportWithoutDiscount, totalImporTaxes, totalDiscountAmount, totalSubtotalAmount, taxBreakdown } = getProductLineTotals(productTableImports);
50
- // === DISCOUNT CODE (monetario tipo NUMBER que se aplica una sola vez) ===
51
- let discountCodeAmount = 0;
52
- if (discountCodeObj &&
53
- discountCodeObj.type === DiscountCodeTypes.NUMBER &&
54
- discountCodeObj.applyOnceOnOrder) {
55
- const includesProducts = discountCodeObj.applyToAllProducts ||
56
- order.products.some((curr) => hasMatchingDiscountTarget(discountCodeObj.products, curr));
57
- const minimumOrderSubtotal = Number((_a = discountCodeObj.minimumOrderSubtotal) !== null && _a !== void 0 ? _a : 0);
58
- const meetsMinimum = !Number.isFinite(minimumOrderSubtotal) ||
59
- minimumOrderSubtotal <= 0 ||
60
- Number((_b = totalImportWithoutDiscount !== null && totalImportWithoutDiscount !== void 0 ? totalImportWithoutDiscount : totalSubtotalAmount) !== null && _b !== void 0 ? _b : 0) >= minimumOrderSubtotal;
61
- discountCodeAmount = includesProducts && meetsMinimum ? discountCodeObj.value : 0;
62
- }
62
+ const { totalQuantity, totalImportWithDiscount, totalImportWithoutDiscount, totalImporTaxes, totalDiscountAmount, totalSubtotalAmount, taxBreakdown, totalNetAfterDiscount, totalGrossBeforeDiscount, } = getProductLineTotals(productTableImports);
63
+ // NOTE: the old post-tax "discountCodeAmount" subtraction was removed.
64
+ // NUMBER coupons (both `applyOnceOnOrder=true` and `=false`) are now
65
+ // applied pre-tax via per-line `discountAmount` baked above, so
66
+ // `totalImportWithDiscount` already reflects the coupon AND its tax
67
+ // reduction effect. `totalDiscountAmount` from the line totals is the
68
+ // nominal coupon value (user-facing "Descuento").
63
69
  // === SHIPPING COST ===
64
70
  const shippingCost = getShippingCost(discountCodeObj, hasShippingCost, storeSettings);
65
71
  // === TOTAL ANTES DE CRÉDITOS/PUNTOS ===
66
72
  const orderTotalWithOutCredit = +(totalImportWithDiscount +
67
- shippingCost -
68
- discountCodeAmount).toFixed(2);
73
+ shippingCost).toFixed(2);
69
74
  // === APLICAR CRÉDITO Y PUNTOS REDIMIDOS ===
70
75
  const creditApplied = getCreditApplied(selectedCustomer, orderTotalWithOutCredit);
71
76
  const orderTotal = +(orderTotalWithOutCredit -
72
77
  creditApplied -
73
78
  redeemPointsDiscount).toFixed(2);
79
+ // === PRICING BREAKDOWN ===
80
+ // For included: totalImportWithoutDiscount=gross-before-discount, totalSubtotalAmount=net-before-discount
81
+ // For over_price: totalImportWithoutDiscount=net-before-discount, totalSubtotalAmount=net-after-discount
82
+ const taxMode = (storeSettings === null || storeSettings === void 0 ? void 0 : storeSettings.taxesType) === 'over_price' ? 'over_price' : 'included';
83
+ const subtotalBeforeDiscountNet = taxMode === 'included' ? totalSubtotalAmount : totalImportWithoutDiscount;
84
+ const subtotalAfterDiscountNet = taxMode === 'included' ? totalNetAfterDiscount : totalSubtotalAmount;
85
+ const subtotalBeforeDiscountGross = taxMode === 'included' ? totalImportWithoutDiscount : totalGrossBeforeDiscount;
86
+ const subtotalAfterDiscountGross = totalImportWithDiscount;
87
+ const pricingBreakdown = {
88
+ subtotalBeforeDiscountNet,
89
+ subtotalAfterDiscountNet,
90
+ subtotalBeforeDiscountGross,
91
+ subtotalAfterDiscountGross,
92
+ discountTotalNet: +(subtotalBeforeDiscountNet - subtotalAfterDiscountNet).toFixed(2),
93
+ discountTotalGross: +(subtotalBeforeDiscountGross - subtotalAfterDiscountGross).toFixed(2),
94
+ taxesTotal: totalImporTaxes,
95
+ shippingServiceTotal: shippingCost,
96
+ creditApplied,
97
+ redeemPointsApplied: redeemPointsDiscount,
98
+ totalBeforePaymentsGross: orderTotalWithOutCredit,
99
+ total: orderTotal,
100
+ };
101
+ // For included: non-NUMBER lineDiscountAmount is net (discountAmountPerUnit*qty), so
102
+ // totalDiscountAmount is mixed-semantic. Use discountTotalGross (gross-before minus gross-after)
103
+ // which is unambiguously user-facing in both modes.
104
+ const displayDiscount = taxMode === 'included' ? pricingBreakdown.discountTotalGross : pricingBreakdown.discountTotalNet;
105
+ const displayTotals = {
106
+ subtotal: totalImportWithoutDiscount, // display-safe: gross (included) or net (over_price) — both equal the price-tag amount
107
+ discount: displayDiscount,
108
+ taxesTotal: totalImporTaxes,
109
+ taxesLabel: taxMode === 'included' ? 'taxesIncluded' : 'taxesTotal',
110
+ shippingServiceTotal: shippingCost,
111
+ creditApplied,
112
+ redeemPointsApplied: redeemPointsDiscount,
113
+ total: orderTotal,
114
+ };
74
115
  // === RETURN FINAL OBJECT ===
75
- return Object.assign(Object.assign({}, order), { totalQuantity, customerDiscount: order.discountCode ? 0 : (_c = selectedCustomer === null || selectedCustomer === void 0 ? void 0 : selectedCustomer.customer) === null || _c === void 0 ? void 0 : _c.discount, productTotal: totalImportWithDiscount, taxesTotal: totalImporTaxes, taxBreakdown, total: orderTotal, creditApplied, redeemPointsApplied: redeemPointsDiscount, productTotalWithoutDiscount: totalImportWithoutDiscount, totalDiscountAmount: totalDiscountAmount + discountCodeAmount, shippingServiceTotal: shippingCost, appliedOrderDiscounts, subtotal: totalSubtotalAmount });
116
+ return Object.assign(Object.assign({}, order), { totalQuantity, customerDiscount: order.discountCode ? 0 : (_c = selectedCustomer === null || selectedCustomer === void 0 ? void 0 : selectedCustomer.customer) === null || _c === void 0 ? void 0 : _c.discount, productTotal: totalImportWithDiscount, taxesTotal: totalImporTaxes, taxBreakdown, total: orderTotal, creditApplied, redeemPointsApplied: redeemPointsDiscount, productTotalWithoutDiscount: totalImportWithoutDiscount, totalDiscountAmount: totalDiscountAmount, shippingServiceTotal: shippingCost, appliedOrderDiscounts, subtotal: totalSubtotalAmount, pricingBreakdown,
117
+ displayTotals });
76
118
  }
77
119
  catch (error) {
78
120
  throw error;
@@ -92,10 +92,23 @@ export const calculateTotalTaxesIncluded = (order, selectedCustomer, storeSettin
92
92
  const unitExtraNet = qty > 0 ? extraAmountNet / qty : 0;
93
93
  // Precio neto ajustado: precio base + extra (por unidad)
94
94
  const adjustedUnitNetPrice = unitNetPrice + unitExtraNet;
95
- // Calcular el descuento sobre el precio ajustado
96
- const discountAmountPerUnit = discPercentageInteger ? adjustedUnitNetPrice * discPercentageInteger : 0;
95
+ // Calcular el descuento por unidad.
96
+ // - PERCENTAGE/BUY_X_GET_Y/FREE_ITEM: derive from `discPercentageInteger`
97
+ // (already applied as percentage on adjusted net unit price).
98
+ // - NUMBER (fixed): `current.discountAmount` is the per-line GROSS
99
+ // discount in pesos, baked by `applyDiscountToProducts`. Convert to
100
+ // per-unit net so the existing pipeline computes tax post-discount.
101
+ let discountAmountPerUnit = discPercentageInteger ? adjustedUnitNetPrice * discPercentageInteger : 0;
102
+ const isFixedNumberCoupon = !!discountCodeObj &&
103
+ discountCodeObj.type === DiscountCodeTypes.NUMBER &&
104
+ Number(current.discountAmount) > 0;
105
+ if (isFixedNumberCoupon && qty > 0) {
106
+ const perUnitGrossDiscount = Number(current.discountAmount) / qty;
107
+ const perUnitNetDiscount = perUnitGrossDiscount / taxFactor;
108
+ discountAmountPerUnit = Math.min(adjustedUnitNetPrice, perUnitNetDiscount);
109
+ }
97
110
  // Precio neto con descuento aplicado por unidad
98
- const discountedUnitNetPrice = adjustedUnitNetPrice - discountAmountPerUnit;
111
+ const discountedUnitNetPrice = Math.max(0, adjustedUnitNetPrice - discountAmountPerUnit);
99
112
  // === Totales de la línea ===
100
113
  // Total neto sin descuento: precio base total + extra (ya prorrateado en el total)
101
114
  const productNetTotalWithoutDiscount = (unitNetPrice * qty) + extraAmountNet;
@@ -111,7 +124,12 @@ export const calculateTotalTaxesIncluded = (order, selectedCustomer, storeSettin
111
124
  store: storeSettings,
112
125
  lineTaxAmount: totalTaxesApplied,
113
126
  });
114
- const lineDiscount = +(discountAmountPerUnit * qty).toFixed(2);
127
+ // For NUMBER coupons the user-facing "Descuento" is the gross slice
128
+ // (which is what was baked into `current.discountAmount`). For other
129
+ // discount types, line discount = per-unit net × qty (existing).
130
+ const lineDiscount = isFixedNumberCoupon
131
+ ? Number(current.discountAmount)
132
+ : +(discountAmountPerUnit * qty).toFixed(2);
115
133
  return {
116
134
  product: current,
117
135
  qty,
@@ -120,7 +138,8 @@ export const calculateTotalTaxesIncluded = (order, selectedCustomer, storeSettin
120
138
  productLineTaxesTotal: totalTaxesApplied,
121
139
  taxBreakdown,
122
140
  lineDiscountAmount: lineDiscount,
123
- productLineSubtotal: productNetTotalWithoutDiscount // Subtotal neto (sin IVA)
141
+ productLineSubtotal: productNetTotalWithoutDiscount, // Subtotal neto (sin IVA) — for included this is NET before discount
142
+ lineNetAfterDiscount: productNetTotalWithDiscount, // NET after discount — needed to derive pricingBreakdown
124
143
  };
125
144
  });
126
145
  return productTableImports;
@@ -68,6 +68,12 @@ export const calculateTotalTaxesOverPrice = (order, selectedCustomer, storeSetti
68
68
  : 0;
69
69
  }
70
70
  }
71
+ else if (discountCodeObj.type === DiscountCodeTypes.NUMBER) {
72
+ // Fixed-amount discount baked by `applyDiscountToProducts` as
73
+ // `current.discountAmount` (line-total, NET pesos in over_price).
74
+ // Per-unit treatment: derive `productDiscount = discountAmount / qty`
75
+ // so the existing pipeline below subtracts pre-tax.
76
+ }
71
77
  else if (discountCodeObj.type === DiscountCodeTypes.BUY_X_GET_Y) {
72
78
  const condition = discountCodeObj.buyAndGetConditions[0];
73
79
  if (condition.getDiscountType === BuyAndGetConditionsTypes.PERCENTAGE && current.isBuyAndGetProduct) {
@@ -81,9 +87,19 @@ export const calculateTotalTaxesOverPrice = (order, selectedCustomer, storeSetti
81
87
  discPercentageInteger = 1;
82
88
  }
83
89
  }
84
- // Calcular el descuento por unidad sobre el precio ajustado
85
- const productDiscount = adjustedPrice * discPercentageInteger;
86
- const discountedAdjustedPrice = adjustedPrice - productDiscount;
90
+ // Calcular el descuento por unidad sobre el precio ajustado.
91
+ // - PERCENTAGE/BUY_X_GET_Y/FREE_ITEM: derive from `discPercentageInteger`.
92
+ // - NUMBER (fixed): `current.discountAmount` is the per-line NET discount
93
+ // in pesos, baked by `applyDiscountToProducts`. Convert to per-unit.
94
+ let productDiscount = adjustedPrice * discPercentageInteger;
95
+ const isFixedNumberCoupon = !!discountCodeObj &&
96
+ discountCodeObj.type === DiscountCodeTypes.NUMBER &&
97
+ Number(current.discountAmount) > 0;
98
+ if (isFixedNumberCoupon && qty > 0) {
99
+ const perUnitDiscount = Number(current.discountAmount) / qty;
100
+ productDiscount = Math.min(adjustedPrice, perUnitDiscount);
101
+ }
102
+ const discountedAdjustedPrice = Math.max(0, adjustedPrice - productDiscount);
87
103
  // Subtotal para la línea (precio descontado por unidad * cantidad) SIN impuestos
88
104
  const subtotal = discountedAdjustedPrice * qty;
89
105
  // Calcular impuestos sobre el subtotal
@@ -99,18 +115,21 @@ export const calculateTotalTaxesOverPrice = (order, selectedCustomer, storeSetti
99
115
  const lineDiscount = +(productDiscount * qty).toFixed(2);
100
116
  // Precio total con descuento, reaplicando impuestos
101
117
  const prodLinePriceWithDiscount = +((subtotal * (1 + taxPercentage))).toFixed(2);
118
+ // GROSS before discount: net-before-discount * (1 + tax). Needed to derive pricingBreakdown.
119
+ const lineGrossBeforeDiscount = +((productBasePrice * qty + extraAmount) * (1 + taxPercentage)).toFixed(2);
102
120
  return {
103
121
  product: current,
104
122
  qty,
105
- // Total bruto original sin descuento: (precio base * cantidad) + extraAmount
123
+ // Total neto original sin descuento: (precio base * cantidad) + extraAmount
106
124
  productLineImportTotal: (productBasePrice * qty) + extraAmount,
107
125
  // Total final con descuento, con impuestos incluidos
108
126
  productLineTotalWithDiscount: prodLinePriceWithDiscount,
109
127
  productLineTaxesTotal: totalTaxesApplied,
110
128
  taxBreakdown,
111
129
  lineDiscountAmount: lineDiscount,
112
- // Subtotal neto (sin impuestos) con descuento aplicado
113
- productLineSubtotal: subtotal
130
+ // Subtotal neto (sin impuestos) con descuento aplicado — for over_price this is NET after discount
131
+ productLineSubtotal: subtotal,
132
+ lineGrossBeforeDiscount, // GROSS before discount — needed to derive pricingBreakdown
114
133
  };
115
134
  });
116
135
  return productTableImports;
@@ -110,7 +110,7 @@ const getApplicableTaxesForProduct = (productObj, store) => {
110
110
  return acc;
111
111
  }, []);
112
112
  };
113
- const allocateByWeights = (total, weights) => {
113
+ export const allocateByWeights = (total, weights) => {
114
114
  if (weights.length === 0)
115
115
  return [];
116
116
  if (total <= 0)
@@ -132,6 +132,43 @@ const allocateByWeights = (total, weights) => {
132
132
  }
133
133
  return out;
134
134
  };
135
+ // Allocate a fixed order-level discount across eligible line bases (in cents).
136
+ // Each entry is capped at its corresponding base. Overflow caused by the cap
137
+ // is rerouted to the next-largest base. The result sums to
138
+ // min(totalCents, sum(baseCents)).
139
+ export const allocateFixedDiscountAcrossLines = (totalCents, baseCents) => {
140
+ const len = baseCents.length;
141
+ if (len === 0)
142
+ return [];
143
+ const safeBases = baseCents.map((b) => Math.max(0, Math.trunc(b)));
144
+ const sumBases = safeBases.reduce((a, b) => a + b, 0);
145
+ if (sumBases <= 0 || totalCents <= 0) {
146
+ return new Array(len).fill(0);
147
+ }
148
+ const cappedTotal = Math.min(Math.trunc(totalCents), sumBases);
149
+ const slices = allocateByWeights(cappedTotal, safeBases);
150
+ let overflow = 0;
151
+ for (let i = 0; i < len; i += 1) {
152
+ if (slices[i] > safeBases[i]) {
153
+ overflow += slices[i] - safeBases[i];
154
+ slices[i] = safeBases[i];
155
+ }
156
+ }
157
+ if (overflow === 0)
158
+ return slices;
159
+ const headroomOrder = safeBases
160
+ .map((b, i) => ({ i, headroom: b - slices[i] }))
161
+ .filter((entry) => entry.headroom > 0)
162
+ .sort((a, b) => b.headroom - a.headroom);
163
+ for (const entry of headroomOrder) {
164
+ if (overflow <= 0)
165
+ break;
166
+ const take = Math.min(entry.headroom, overflow);
167
+ slices[entry.i] += take;
168
+ overflow -= take;
169
+ }
170
+ return slices;
171
+ };
135
172
  export const aggregateTaxBreakdown = (items) => {
136
173
  const byKey = new Map();
137
174
  items.forEach((item) => {
@@ -199,6 +236,14 @@ export const getProductLineTotals = (productLinesTotals) => {
199
236
  .reduce((acum, line) => acum + line.productLineSubtotal, 0)
200
237
  .toFixed(2);
201
238
  const taxBreakdown = normalizeTaxBreakdownToTotal(productLinesTotals.flatMap((line) => line.taxBreakdown || []), totalImporTaxes);
239
+ // lineNetAfterDiscount is set by the included calculator (for over_price, productLineSubtotal IS net-after-discount)
240
+ // lineGrossBeforeDiscount is set by the over_price calculator (for included, productLineImportTotal IS gross-before-discount)
241
+ const totalNetAfterDiscount = +productLinesTotals
242
+ .reduce((acum, line) => { var _a; return acum + ((_a = line.lineNetAfterDiscount) !== null && _a !== void 0 ? _a : line.productLineSubtotal); }, 0)
243
+ .toFixed(2);
244
+ const totalGrossBeforeDiscount = +productLinesTotals
245
+ .reduce((acum, line) => { var _a; return acum + ((_a = line.lineGrossBeforeDiscount) !== null && _a !== void 0 ? _a : line.productLineImportTotal); }, 0)
246
+ .toFixed(2);
202
247
  return {
203
248
  totalQuantity,
204
249
  totalImportWithDiscount,
@@ -206,7 +251,9 @@ export const getProductLineTotals = (productLinesTotals) => {
206
251
  totalImporTaxes,
207
252
  totalDiscountAmount,
208
253
  totalSubtotalAmount,
209
- taxBreakdown
254
+ taxBreakdown,
255
+ totalNetAfterDiscount,
256
+ totalGrossBeforeDiscount,
210
257
  };
211
258
  };
212
259
  export const getShippingCost = (discountObject, requireShippingService, store) => {
@@ -296,21 +343,54 @@ export const applyDiscountToProducts = (discountCode, productsArr, isExpress, st
296
343
  }
297
344
  }
298
345
  if (discountCode && discountCode.type === DiscountCodeTypes.NUMBER) {
299
- const discountAmount = discountCode.value;
300
- if (!discountCode.applyOnceOnOrder) {
301
- if (discountCode.applyToAllProducts) {
302
- newOrderProds = newOrderProds.map((prod) => (Object.assign(Object.assign({}, prod), { discountAmount: discountAmount })));
303
- }
304
- else {
305
- const discountProductIds = getTargetProductIdSet(discountCode.products);
306
- newOrderProds = newOrderProds.map((prod) => {
307
- if (!discountProductIds.has(getProductIdentity(prod))) {
308
- return prod;
309
- }
310
- return Object.assign(Object.assign({}, prod), { discountAmount });
346
+ const couponValue = Number(discountCode.value) || 0;
347
+ const eligibilityIds = discountCode.applyToAllProducts
348
+ ? null
349
+ : getTargetProductIdSet(discountCode.products);
350
+ const isEligibleProd = (prod) => eligibilityIds === null || eligibilityIds.has(getProductIdentity(prod));
351
+ if (discountCode.applyOnceOnOrder) {
352
+ // applyOnceOnOrder=true: allocate the fixed amount proportionally
353
+ // across eligible lines so each line carries its share as a pre-tax
354
+ // discountAmount. The downstream tax calc reads this and reduces the
355
+ // taxable base before computing tax.
356
+ const eligibleEntries = [];
357
+ newOrderProds.forEach((prod, idx) => {
358
+ var _a, _b;
359
+ if (!isEligibleProd(prod))
360
+ return;
361
+ const prodPrice = isExpress
362
+ ? (_a = prod.expressPrice) !== null && _a !== void 0 ? _a : prod.price
363
+ : prod.price;
364
+ const qty = Number((_b = prod.qty) !== null && _b !== void 0 ? _b : prod.quantity) || 0;
365
+ const extra = Number(prod.extraAmount) || 0;
366
+ const baseCents = Math.round(((Number(prodPrice) || 0) * qty + extra) * 100);
367
+ if (baseCents > 0) {
368
+ eligibleEntries.push({ idx, baseCents });
369
+ }
370
+ });
371
+ if (eligibleEntries.length > 0) {
372
+ const couponCents = Math.max(0, Math.round(couponValue * 100));
373
+ const sliceCents = allocateFixedDiscountAcrossLines(couponCents, eligibleEntries.map((e) => e.baseCents));
374
+ const newProdsCopy = newOrderProds.slice();
375
+ eligibleEntries.forEach((e, i) => {
376
+ const slicePesos = +(sliceCents[i] / 100).toFixed(2);
377
+ newProdsCopy[e.idx] = Object.assign(Object.assign({}, newProdsCopy[e.idx]), { discountAmount: slicePesos });
311
378
  });
379
+ newOrderProds = newProdsCopy;
312
380
  }
313
381
  }
382
+ else {
383
+ // applyOnceOnOrder=false: per-unit fixed discount. Total per eligible
384
+ // line is value * qty (was a flat `value` previously — silent bug).
385
+ newOrderProds = newOrderProds.map((prod) => {
386
+ var _a;
387
+ if (!isEligibleProd(prod))
388
+ return prod;
389
+ const qty = Number((_a = prod.qty) !== null && _a !== void 0 ? _a : prod.quantity) || 0;
390
+ const lineDiscount = +(couponValue * qty).toFixed(2);
391
+ return Object.assign(Object.assign({}, prod), { discountAmount: lineDiscount });
392
+ });
393
+ }
314
394
  }
315
395
  if (discountCode.buyAndGetConditions && discountCode.type === DiscountCodeTypes.BUY_X_GET_Y) {
316
396
  const buyConditions = discountCode.buyAndGetConditions[0].buyConditions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "washday-sdk",
3
- "version": "1.6.70",
3
+ "version": "1.6.72",
4
4
  "description": "Washday utilities functions and API",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -13,6 +13,10 @@ export interface ProductLineTotals {
13
13
  taxBreakdown?: ITaxBreakdownItem[],
14
14
  lineDiscountAmount: number
15
15
  productLineSubtotal: number
16
+ // Additive semantic fields. Only the calculator that can compute these natively sets them;
17
+ // the other mode's fallback is encoded in getProductLineTotals.
18
+ lineNetAfterDiscount?: number // set by included calculator; for over_price productLineSubtotal already IS net-after-discount
19
+ lineGrossBeforeDiscount?: number // set by over_price calculator; for included productLineImportTotal already IS gross-before-discount
16
20
  }
17
21
 
18
22
  export interface IPriceOption {
@@ -926,5 +926,169 @@ describe("Calculate Order Total tests", () => {
926
926
  });
927
927
 
928
928
 
929
+ // ─── pricingBreakdown ───────────────────────────────────────────────────
930
+
931
+ it("pricingBreakdown: included, no discount — legacy fields unchanged, breakdown correct", () => {
932
+ ORDER_DEMO.store.taxesType = "included";
933
+ const { orderDto, customer, store, storeDiscounts, discountCodeObj } = ORDER_DEMO;
934
+ const result = calculateOrderTotal(orderDto, { customer }, store, false, storeDiscounts, discountCodeObj);
935
+
936
+ // Legacy fields must be preserved
937
+ expect(result.productTotal).toBe(54.22);
938
+ expect(result.productTotalWithoutDiscount).toBe(54.22);
939
+ expect(result.taxesTotal).toBe(8.68);
940
+ expect(result.subtotal).toBeCloseTo(46.74, 1); // net before discount for included
941
+
942
+ const bd = result.pricingBreakdown;
943
+ expect(bd.subtotalBeforeDiscountGross).toBe(54.22);
944
+ expect(bd.subtotalAfterDiscountGross).toBe(54.22);
945
+ expect(bd.discountTotalGross).toBe(0);
946
+ expect(bd.discountTotalNet).toBeCloseTo(0, 1);
947
+ expect(bd.taxesTotal).toBe(8.68);
948
+ expect(bd.totalBeforePaymentsGross).toBe(54.22);
949
+ expect(bd.total).toBeCloseTo(result.total, 2);
950
+
951
+ const dt = result.displayTotals;
952
+ expect(dt.subtotal).toBe(54.22);
953
+ expect(dt.taxesLabel).toBe("taxesIncluded");
954
+ expect(dt.total).toBeCloseTo(result.total, 2);
955
+ });
956
+
957
+ it("pricingBreakdown: over_price, no discount — legacy fields unchanged, breakdown correct", () => {
958
+ ORDER_DEMO.store.taxesType = "over_price";
959
+ const { orderDto, customer, store, storeDiscounts, discountCodeObj } = ORDER_DEMO;
960
+ const result = calculateOrderTotal(orderDto, { customer }, store, false, storeDiscounts, discountCodeObj);
961
+
962
+ // product: price=17, qty=3.7, tax=16% over_price
963
+ // net before discount = 17 * 3.7 = 62.9
964
+ // gross before discount = 62.9 * 1.16 = 72.964 ≈ 72.96
965
+ expect(result.productTotal).toBeCloseTo(72.96, 1);
966
+ expect(result.productTotalWithoutDiscount).toBeCloseTo(62.9, 2);
967
+
968
+ const bd = result.pricingBreakdown;
969
+ expect(bd.subtotalBeforeDiscountNet).toBeCloseTo(62.9, 2);
970
+ expect(bd.subtotalAfterDiscountNet).toBeCloseTo(62.9, 2);
971
+ expect(bd.subtotalBeforeDiscountGross).toBeCloseTo(72.96, 1);
972
+ expect(bd.subtotalAfterDiscountGross).toBeCloseTo(72.96, 1);
973
+ expect(bd.discountTotalNet).toBe(0);
974
+ expect(bd.discountTotalGross).toBeCloseTo(0, 1);
975
+
976
+ const dt = result.displayTotals;
977
+ expect(dt.subtotal).toBeCloseTo(62.9, 2); // net (= price tag for over_price)
978
+ expect(dt.taxesLabel).toBe("taxesTotal");
979
+ });
980
+
981
+ it("pricingBreakdown: included + NUMBER coupon $30 on $50 product (acceptance criteria issue#40)", () => {
982
+ // Setup: product price=$50 gross (tax=16% included), qty=1, NUMBER coupon=$30
983
+ ORDER_DEMO.store.taxesType = "included";
984
+ ORDER_DEMO.orderDto.products = [{
985
+ _id: "prod1",
986
+ name: "Test",
987
+ price: 50,
988
+ expressPrice: 50,
989
+ qty: 1,
990
+ quantity: 1,
991
+ extraAmount: 0,
992
+ taxExemptOne: false, taxExemptTwo: false, taxExemptThree: false,
993
+ discountAmount: 0,
994
+ }];
995
+ ORDER_DEMO.orderDto.buyAndGetProducts = [];
996
+ ORDER_DEMO.orderDto.discountCode = "THIRTY";
997
+ ORDER_DEMO.discountCodeObj = {
998
+ _id: "dc1",
999
+ type: "number",
1000
+ value: 30,
1001
+ applyToAllProducts: true,
1002
+ applyOnceOnOrder: true,
1003
+ products: [],
1004
+ freeProductSetting: [],
1005
+ buyAndGetConditions: [],
1006
+ };
1007
+
1008
+ const { orderDto, customer, store, storeDiscounts } = ORDER_DEMO;
1009
+ const result = calculateOrderTotal(orderDto, { customer }, store, false, storeDiscounts, ORDER_DEMO.discountCodeObj);
1010
+
1011
+ // gross before discount = 50, NUMBER $30 applied pre-tax
1012
+ // net before discount = 50/1.16 ≈ 43.10
1013
+ // gross after discount = 50 - 30 = 20
1014
+ // tax on $20 gross = 20 - 20/1.16 ≈ 2.76
1015
+ expect(result.productTotalWithoutDiscount).toBe(50); // gross-before-discount (display subtotal)
1016
+ expect(result.productTotal).toBeCloseTo(20, 1); // gross-after-discount
1017
+ expect(result.totalDiscountAmount).toBe(30); // user-facing discount
1018
+ expect(result.taxesTotal).toBeCloseTo(2.76, 1);
1019
+ expect(result.total).toBeCloseTo(20, 1);
1020
+
1021
+ const bd = result.pricingBreakdown;
1022
+ expect(bd.subtotalBeforeDiscountGross).toBe(50);
1023
+ expect(bd.subtotalAfterDiscountGross).toBeCloseTo(20, 1);
1024
+ expect(bd.discountTotalGross).toBeCloseTo(30, 1);
1025
+ expect(bd.subtotalBeforeDiscountNet).toBeCloseTo(43.1, 1);
1026
+ expect(bd.subtotalAfterDiscountNet).toBeCloseTo(17.24, 1);
1027
+ expect(bd.discountTotalNet).toBeCloseTo(25.86, 1);
1028
+ expect(bd.taxesTotal).toBeCloseTo(2.76, 1);
1029
+ expect(bd.totalBeforePaymentsGross).toBeCloseTo(20, 1);
1030
+ expect(bd.total).toBeCloseTo(20, 1);
1031
+
1032
+ const dt = result.displayTotals;
1033
+ expect(dt.subtotal).toBe(50);
1034
+ expect(dt.discount).toBe(30);
1035
+ expect(dt.taxesLabel).toBe("taxesIncluded");
1036
+ expect(dt.total).toBeCloseTo(20, 1);
1037
+ });
1038
+
1039
+ it("pricingBreakdown: over_price + NUMBER coupon $30 on $50 product (acceptance criteria issue#40)", () => {
1040
+ ORDER_DEMO.store.taxesType = "over_price";
1041
+ ORDER_DEMO.orderDto.products = [{
1042
+ _id: "prod1",
1043
+ name: "Test",
1044
+ price: 50,
1045
+ expressPrice: 50,
1046
+ qty: 1,
1047
+ quantity: 1,
1048
+ extraAmount: 0,
1049
+ taxExemptOne: false, taxExemptTwo: false, taxExemptThree: false,
1050
+ discountAmount: 0,
1051
+ }];
1052
+ ORDER_DEMO.orderDto.buyAndGetProducts = [];
1053
+ ORDER_DEMO.orderDto.discountCode = "THIRTY";
1054
+ ORDER_DEMO.discountCodeObj = {
1055
+ _id: "dc1",
1056
+ type: "number",
1057
+ value: 30,
1058
+ applyToAllProducts: true,
1059
+ applyOnceOnOrder: true,
1060
+ products: [],
1061
+ freeProductSetting: [],
1062
+ buyAndGetConditions: [],
1063
+ };
1064
+
1065
+ const { orderDto, customer, store, storeDiscounts } = ORDER_DEMO;
1066
+ const result = calculateOrderTotal(orderDto, { customer }, store, false, storeDiscounts, ORDER_DEMO.discountCodeObj);
1067
+
1068
+ // net before discount = 50; NUMBER $30 net; net after discount = 20
1069
+ // tax = 20 * 0.16 = 3.20; total = 23.20
1070
+ expect(result.productTotalWithoutDiscount).toBe(50); // net-before-discount (display subtotal for over_price)
1071
+ expect(result.totalDiscountAmount).toBe(30);
1072
+ expect(result.taxesTotal).toBeCloseTo(3.2, 1);
1073
+ expect(result.total).toBeCloseTo(23.2, 1);
1074
+
1075
+ const bd = result.pricingBreakdown;
1076
+ expect(bd.subtotalBeforeDiscountNet).toBe(50);
1077
+ expect(bd.subtotalAfterDiscountNet).toBeCloseTo(20, 1);
1078
+ expect(bd.discountTotalNet).toBeCloseTo(30, 1);
1079
+ expect(bd.subtotalBeforeDiscountGross).toBeCloseTo(58, 1); // 50 * 1.16
1080
+ expect(bd.subtotalAfterDiscountGross).toBeCloseTo(23.2, 1); // 20 * 1.16
1081
+ expect(bd.discountTotalGross).toBeCloseTo(34.8, 1); // 30 * 1.16
1082
+ expect(bd.taxesTotal).toBeCloseTo(3.2, 1);
1083
+ expect(bd.totalBeforePaymentsGross).toBeCloseTo(23.2, 1);
1084
+ expect(bd.total).toBeCloseTo(23.2, 1);
1085
+
1086
+ const dt = result.displayTotals;
1087
+ expect(dt.subtotal).toBe(50); // net = display price for over_price
1088
+ expect(dt.discount).toBe(30);
1089
+ expect(dt.taxesLabel).toBe("taxesTotal");
1090
+ expect(dt.total).toBeCloseTo(23.2, 1);
1091
+ });
1092
+
929
1093
  // Add more test cases as needed
930
1094
  });