washday-sdk 1.6.70 → 1.6.71

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,43 @@ 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
62
  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
- }
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);
74
79
  // === 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 });
80
+ 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 });
76
81
  }
77
82
  catch (error) {
78
83
  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,
@@ -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
@@ -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) => {
@@ -296,21 +333,54 @@ export const applyDiscountToProducts = (discountCode, productsArr, isExpress, st
296
333
  }
297
334
  }
298
335
  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 });
336
+ const couponValue = Number(discountCode.value) || 0;
337
+ const eligibilityIds = discountCode.applyToAllProducts
338
+ ? null
339
+ : getTargetProductIdSet(discountCode.products);
340
+ const isEligibleProd = (prod) => eligibilityIds === null || eligibilityIds.has(getProductIdentity(prod));
341
+ if (discountCode.applyOnceOnOrder) {
342
+ // applyOnceOnOrder=true: allocate the fixed amount proportionally
343
+ // across eligible lines so each line carries its share as a pre-tax
344
+ // discountAmount. The downstream tax calc reads this and reduces the
345
+ // taxable base before computing tax.
346
+ const eligibleEntries = [];
347
+ newOrderProds.forEach((prod, idx) => {
348
+ var _a, _b;
349
+ if (!isEligibleProd(prod))
350
+ return;
351
+ const prodPrice = isExpress
352
+ ? (_a = prod.expressPrice) !== null && _a !== void 0 ? _a : prod.price
353
+ : prod.price;
354
+ const qty = Number((_b = prod.qty) !== null && _b !== void 0 ? _b : prod.quantity) || 0;
355
+ const extra = Number(prod.extraAmount) || 0;
356
+ const baseCents = Math.round(((Number(prodPrice) || 0) * qty + extra) * 100);
357
+ if (baseCents > 0) {
358
+ eligibleEntries.push({ idx, baseCents });
359
+ }
360
+ });
361
+ if (eligibleEntries.length > 0) {
362
+ const couponCents = Math.max(0, Math.round(couponValue * 100));
363
+ const sliceCents = allocateFixedDiscountAcrossLines(couponCents, eligibleEntries.map((e) => e.baseCents));
364
+ const newProdsCopy = newOrderProds.slice();
365
+ eligibleEntries.forEach((e, i) => {
366
+ const slicePesos = +(sliceCents[i] / 100).toFixed(2);
367
+ newProdsCopy[e.idx] = Object.assign(Object.assign({}, newProdsCopy[e.idx]), { discountAmount: slicePesos });
311
368
  });
369
+ newOrderProds = newProdsCopy;
312
370
  }
313
371
  }
372
+ else {
373
+ // applyOnceOnOrder=false: per-unit fixed discount. Total per eligible
374
+ // line is value * qty (was a flat `value` previously — silent bug).
375
+ newOrderProds = newOrderProds.map((prod) => {
376
+ var _a;
377
+ if (!isEligibleProd(prod))
378
+ return prod;
379
+ const qty = Number((_a = prod.qty) !== null && _a !== void 0 ? _a : prod.quantity) || 0;
380
+ const lineDiscount = +(couponValue * qty).toFixed(2);
381
+ return Object.assign(Object.assign({}, prod), { discountAmount: lineDiscount });
382
+ });
383
+ }
314
384
  }
315
385
  if (discountCode.buyAndGetConditions && discountCode.type === DiscountCodeTypes.BUY_X_GET_Y) {
316
386
  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.71",
4
4
  "description": "Washday utilities functions and API",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -3,7 +3,12 @@ import { ICustomer } from "../../interfaces/Customer";
3
3
  import { IStore } from "../../interfaces/Store";
4
4
  import { calculateTotalTaxesIncluded } from "./calculateTotalTaxesIncluded";
5
5
  import { calculateTotalTaxesOverPrice } from "./calculateTotalTaxesOverPrice";
6
- import { getCreditApplied, getProductLineTotals, getShippingCost } from "./helpers";
6
+ import {
7
+ applyDiscountToProducts,
8
+ getCreditApplied,
9
+ getProductLineTotals,
10
+ getShippingCost,
11
+ } from "./helpers";
7
12
 
8
13
  const getNormalizedId = (value: any): string => {
9
14
  if (value === null || value === undefined) {
@@ -54,13 +59,38 @@ export const calculateOrderTotal = (
54
59
  try {
55
60
  const appliedOrderDiscounts: any = {};
56
61
 
62
+ // For NUMBER coupons (fixed-amount) we must bake `discountAmount` per
63
+ // line so the tax calculators can reduce the taxable base BEFORE
64
+ // computing tax. `applyDiscountToProducts` handles both per-unit
65
+ // (`applyOnceOnOrder=false`) and proportional allocation
66
+ // (`applyOnceOnOrder=true`). For other coupon types, the tax calc derives
67
+ // the discount via `discPercentageInteger` so we can skip the pre-pass.
68
+ let processedOrder = order;
69
+ if (discountCodeObj && discountCodeObj.type === DiscountCodeTypes.NUMBER) {
70
+ const { newOrderProds, buyAndGetProds } = applyDiscountToProducts(
71
+ discountCodeObj,
72
+ order.products ?? [],
73
+ Boolean(order.express),
74
+ storeDiscounts,
75
+ selectedCustomer?.customer
76
+ );
77
+ processedOrder = {
78
+ ...order,
79
+ products: newOrderProds,
80
+ buyAndGetProducts:
81
+ buyAndGetProds && buyAndGetProds.length > 0
82
+ ? buyAndGetProds
83
+ : order.buyAndGetProducts ?? [],
84
+ };
85
+ }
86
+
57
87
  const productTableCalculator =
58
88
  storeSettings?.taxesType === 'over_price'
59
89
  ? calculateTotalTaxesOverPrice
60
90
  : calculateTotalTaxesIncluded;
61
91
 
62
92
  const productTableImports = productTableCalculator(
63
- order,
93
+ processedOrder,
64
94
  selectedCustomer,
65
95
  storeSettings,
66
96
  storeDiscounts,
@@ -79,27 +109,12 @@ export const calculateOrderTotal = (
79
109
  taxBreakdown
80
110
  } = getProductLineTotals(productTableImports);
81
111
 
82
- // === DISCOUNT CODE (monetario tipo NUMBER que se aplica una sola vez) ===
83
- let discountCodeAmount = 0;
84
- if (
85
- discountCodeObj &&
86
- discountCodeObj.type === DiscountCodeTypes.NUMBER &&
87
- discountCodeObj.applyOnceOnOrder
88
- ) {
89
- const includesProducts =
90
- discountCodeObj.applyToAllProducts ||
91
- order.products.some((curr: any) =>
92
- hasMatchingDiscountTarget(discountCodeObj.products, curr)
93
- );
94
-
95
- const minimumOrderSubtotal = Number(discountCodeObj.minimumOrderSubtotal ?? 0);
96
- const meetsMinimum =
97
- !Number.isFinite(minimumOrderSubtotal) ||
98
- minimumOrderSubtotal <= 0 ||
99
- Number(totalImportWithoutDiscount ?? totalSubtotalAmount ?? 0) >= minimumOrderSubtotal;
100
-
101
- discountCodeAmount = includesProducts && meetsMinimum ? discountCodeObj.value : 0;
102
- }
112
+ // NOTE: the old post-tax "discountCodeAmount" subtraction was removed.
113
+ // NUMBER coupons (both `applyOnceOnOrder=true` and `=false`) are now
114
+ // applied pre-tax via per-line `discountAmount` baked above, so
115
+ // `totalImportWithDiscount` already reflects the coupon AND its tax
116
+ // reduction effect. `totalDiscountAmount` from the line totals is the
117
+ // nominal coupon value (user-facing "Descuento").
103
118
 
104
119
  // === SHIPPING COST ===
105
120
  const shippingCost = getShippingCost(
@@ -111,8 +126,7 @@ export const calculateOrderTotal = (
111
126
  // === TOTAL ANTES DE CRÉDITOS/PUNTOS ===
112
127
  const orderTotalWithOutCredit = +(
113
128
  totalImportWithDiscount +
114
- shippingCost -
115
- discountCodeAmount
129
+ shippingCost
116
130
  ).toFixed(2);
117
131
 
118
132
  // === APLICAR CRÉDITO Y PUNTOS REDIMIDOS ===
@@ -139,7 +153,7 @@ export const calculateOrderTotal = (
139
153
  creditApplied,
140
154
  redeemPointsApplied: redeemPointsDiscount, // ✅ NUEVO CAMPO
141
155
  productTotalWithoutDiscount: totalImportWithoutDiscount,
142
- totalDiscountAmount: totalDiscountAmount + discountCodeAmount,
156
+ totalDiscountAmount: totalDiscountAmount,
143
157
  shippingServiceTotal: shippingCost,
144
158
  appliedOrderDiscounts,
145
159
  subtotal: totalSubtotalAmount
@@ -107,10 +107,26 @@ export const calculateTotalTaxesIncluded = (
107
107
  const unitExtraNet = qty > 0 ? extraAmountNet / qty : 0;
108
108
  // Precio neto ajustado: precio base + extra (por unidad)
109
109
  const adjustedUnitNetPrice = unitNetPrice + unitExtraNet;
110
- // Calcular el descuento sobre el precio ajustado
111
- const discountAmountPerUnit = discPercentageInteger ? adjustedUnitNetPrice * discPercentageInteger : 0;
110
+
111
+ // Calcular el descuento por unidad.
112
+ // - PERCENTAGE/BUY_X_GET_Y/FREE_ITEM: derive from `discPercentageInteger`
113
+ // (already applied as percentage on adjusted net unit price).
114
+ // - NUMBER (fixed): `current.discountAmount` is the per-line GROSS
115
+ // discount in pesos, baked by `applyDiscountToProducts`. Convert to
116
+ // per-unit net so the existing pipeline computes tax post-discount.
117
+ let discountAmountPerUnit = discPercentageInteger ? adjustedUnitNetPrice * discPercentageInteger : 0;
118
+ const isFixedNumberCoupon =
119
+ !!discountCodeObj &&
120
+ discountCodeObj.type === DiscountCodeTypes.NUMBER &&
121
+ Number(current.discountAmount) > 0;
122
+ if (isFixedNumberCoupon && qty > 0) {
123
+ const perUnitGrossDiscount = Number(current.discountAmount) / qty;
124
+ const perUnitNetDiscount = perUnitGrossDiscount / taxFactor;
125
+ discountAmountPerUnit = Math.min(adjustedUnitNetPrice, perUnitNetDiscount);
126
+ }
127
+
112
128
  // Precio neto con descuento aplicado por unidad
113
- const discountedUnitNetPrice = adjustedUnitNetPrice - discountAmountPerUnit;
129
+ const discountedUnitNetPrice = Math.max(0, adjustedUnitNetPrice - discountAmountPerUnit);
114
130
 
115
131
  // === Totales de la línea ===
116
132
  // Total neto sin descuento: precio base total + extra (ya prorrateado en el total)
@@ -130,7 +146,12 @@ export const calculateTotalTaxesIncluded = (
130
146
  lineTaxAmount: totalTaxesApplied,
131
147
  });
132
148
 
133
- const lineDiscount = +(discountAmountPerUnit * qty).toFixed(2);
149
+ // For NUMBER coupons the user-facing "Descuento" is the gross slice
150
+ // (which is what was baked into `current.discountAmount`). For other
151
+ // discount types, line discount = per-unit net × qty (existing).
152
+ const lineDiscount = isFixedNumberCoupon
153
+ ? Number(current.discountAmount)
154
+ : +(discountAmountPerUnit * qty).toFixed(2);
134
155
 
135
156
  return {
136
157
  product: current,
@@ -81,6 +81,11 @@ export const calculateTotalTaxesOverPrice = (
81
81
  ? +(discountCodeObj.value / 100).toFixed(2)
82
82
  : 0;
83
83
  }
84
+ } else if (discountCodeObj.type === DiscountCodeTypes.NUMBER) {
85
+ // Fixed-amount discount baked by `applyDiscountToProducts` as
86
+ // `current.discountAmount` (line-total, NET pesos in over_price).
87
+ // Per-unit treatment: derive `productDiscount = discountAmount / qty`
88
+ // so the existing pipeline below subtracts pre-tax.
84
89
  } else if (discountCodeObj.type === DiscountCodeTypes.BUY_X_GET_Y) {
85
90
  const condition = discountCodeObj.buyAndGetConditions[0];
86
91
  if (condition.getDiscountType === BuyAndGetConditionsTypes.PERCENTAGE && current.isBuyAndGetProduct) {
@@ -94,9 +99,20 @@ export const calculateTotalTaxesOverPrice = (
94
99
  }
95
100
  }
96
101
 
97
- // Calcular el descuento por unidad sobre el precio ajustado
98
- const productDiscount = adjustedPrice * discPercentageInteger;
99
- const discountedAdjustedPrice = adjustedPrice - productDiscount;
102
+ // Calcular el descuento por unidad sobre el precio ajustado.
103
+ // - PERCENTAGE/BUY_X_GET_Y/FREE_ITEM: derive from `discPercentageInteger`.
104
+ // - NUMBER (fixed): `current.discountAmount` is the per-line NET discount
105
+ // in pesos, baked by `applyDiscountToProducts`. Convert to per-unit.
106
+ let productDiscount = adjustedPrice * discPercentageInteger;
107
+ const isFixedNumberCoupon =
108
+ !!discountCodeObj &&
109
+ discountCodeObj.type === DiscountCodeTypes.NUMBER &&
110
+ Number(current.discountAmount) > 0;
111
+ if (isFixedNumberCoupon && qty > 0) {
112
+ const perUnitDiscount = Number(current.discountAmount) / qty;
113
+ productDiscount = Math.min(adjustedPrice, perUnitDiscount);
114
+ }
115
+ const discountedAdjustedPrice = Math.max(0, adjustedPrice - productDiscount);
100
116
 
101
117
  // Subtotal para la línea (precio descontado por unidad * cantidad) SIN impuestos
102
118
  const subtotal = discountedAdjustedPrice * qty;
@@ -151,7 +151,7 @@ const getApplicableTaxesForProduct = (
151
151
  }, []);
152
152
  };
153
153
 
154
- const allocateByWeights = (total: number, weights: number[]): number[] => {
154
+ export const allocateByWeights = (total: number, weights: number[]): number[] => {
155
155
  if (weights.length === 0) return [];
156
156
  if (total <= 0) return new Array(weights.length).fill(0);
157
157
 
@@ -174,6 +174,47 @@ const allocateByWeights = (total: number, weights: number[]): number[] => {
174
174
  return out;
175
175
  };
176
176
 
177
+ // Allocate a fixed order-level discount across eligible line bases (in cents).
178
+ // Each entry is capped at its corresponding base. Overflow caused by the cap
179
+ // is rerouted to the next-largest base. The result sums to
180
+ // min(totalCents, sum(baseCents)).
181
+ export const allocateFixedDiscountAcrossLines = (
182
+ totalCents: number,
183
+ baseCents: number[]
184
+ ): number[] => {
185
+ const len = baseCents.length;
186
+ if (len === 0) return [];
187
+ const safeBases = baseCents.map((b) => Math.max(0, Math.trunc(b)));
188
+ const sumBases = safeBases.reduce((a, b) => a + b, 0);
189
+ if (sumBases <= 0 || totalCents <= 0) {
190
+ return new Array(len).fill(0);
191
+ }
192
+
193
+ const cappedTotal = Math.min(Math.trunc(totalCents), sumBases);
194
+ const slices = allocateByWeights(cappedTotal, safeBases);
195
+
196
+ let overflow = 0;
197
+ for (let i = 0; i < len; i += 1) {
198
+ if (slices[i] > safeBases[i]) {
199
+ overflow += slices[i] - safeBases[i];
200
+ slices[i] = safeBases[i];
201
+ }
202
+ }
203
+ if (overflow === 0) return slices;
204
+
205
+ const headroomOrder = safeBases
206
+ .map((b, i) => ({ i, headroom: b - slices[i] }))
207
+ .filter((entry) => entry.headroom > 0)
208
+ .sort((a, b) => b.headroom - a.headroom);
209
+ for (const entry of headroomOrder) {
210
+ if (overflow <= 0) break;
211
+ const take = Math.min(entry.headroom, overflow);
212
+ slices[entry.i] += take;
213
+ overflow -= take;
214
+ }
215
+ return slices;
216
+ };
217
+
177
218
  export const aggregateTaxBreakdown = (
178
219
  items: ITaxBreakdownItem[]
179
220
  ): ITaxBreakdownItem[] => {
@@ -400,19 +441,56 @@ export const applyDiscountToProducts = (
400
441
  }
401
442
 
402
443
  if (discountCode && discountCode.type === DiscountCodeTypes.NUMBER) {
403
- const discountAmount = discountCode.value;
404
- if (!discountCode.applyOnceOnOrder) {
405
- if (discountCode.applyToAllProducts) {
406
- newOrderProds = newOrderProds.map((prod: any) => ({ ...prod, discountAmount: discountAmount }));
407
- } else {
408
- const discountProductIds = getTargetProductIdSet(discountCode.products);
409
- newOrderProds = newOrderProds.map((prod: any) => {
410
- if (!discountProductIds.has(getProductIdentity(prod))) {
411
- return prod;
412
- }
413
- return { ...prod, discountAmount };
444
+ const couponValue = Number(discountCode.value) || 0;
445
+ const eligibilityIds: Set<string> | null = discountCode.applyToAllProducts
446
+ ? null
447
+ : getTargetProductIdSet(discountCode.products);
448
+ const isEligibleProd = (prod: any) =>
449
+ eligibilityIds === null || eligibilityIds.has(getProductIdentity(prod));
450
+
451
+ if (discountCode.applyOnceOnOrder) {
452
+ // applyOnceOnOrder=true: allocate the fixed amount proportionally
453
+ // across eligible lines so each line carries its share as a pre-tax
454
+ // discountAmount. The downstream tax calc reads this and reduces the
455
+ // taxable base before computing tax.
456
+ const eligibleEntries: { idx: number; baseCents: number }[] = [];
457
+ newOrderProds.forEach((prod: any, idx: number) => {
458
+ if (!isEligibleProd(prod)) return;
459
+ const prodPrice = isExpress
460
+ ? prod.expressPrice ?? prod.price
461
+ : prod.price;
462
+ const qty = Number(prod.qty ?? prod.quantity) || 0;
463
+ const extra = Number(prod.extraAmount) || 0;
464
+ const baseCents = Math.round(
465
+ ((Number(prodPrice) || 0) * qty + extra) * 100
466
+ );
467
+ if (baseCents > 0) {
468
+ eligibleEntries.push({ idx, baseCents });
469
+ }
470
+ });
471
+
472
+ if (eligibleEntries.length > 0) {
473
+ const couponCents = Math.max(0, Math.round(couponValue * 100));
474
+ const sliceCents = allocateFixedDiscountAcrossLines(
475
+ couponCents,
476
+ eligibleEntries.map((e) => e.baseCents)
477
+ );
478
+ const newProdsCopy = newOrderProds.slice();
479
+ eligibleEntries.forEach((e, i) => {
480
+ const slicePesos = +(sliceCents[i] / 100).toFixed(2);
481
+ newProdsCopy[e.idx] = { ...newProdsCopy[e.idx], discountAmount: slicePesos };
414
482
  });
483
+ newOrderProds = newProdsCopy;
415
484
  }
485
+ } else {
486
+ // applyOnceOnOrder=false: per-unit fixed discount. Total per eligible
487
+ // line is value * qty (was a flat `value` previously — silent bug).
488
+ newOrderProds = newOrderProds.map((prod: any) => {
489
+ if (!isEligibleProd(prod)) return prod;
490
+ const qty = Number(prod.qty ?? prod.quantity) || 0;
491
+ const lineDiscount = +(couponValue * qty).toFixed(2);
492
+ return { ...prod, discountAmount: lineDiscount };
493
+ });
416
494
  }
417
495
  }
418
496
 
@@ -0,0 +1,58 @@
1
+ import {
2
+ allocateByWeights,
3
+ allocateFixedDiscountAcrossLines
4
+ } from "../src/utils/orders/helpers";
5
+
6
+ describe("allocateByWeights (SDK)", () => {
7
+ it("returns empty for empty weights", () => {
8
+ expect(allocateByWeights(100, [])).toEqual([]);
9
+ });
10
+
11
+ it("returns zeros when total is non-positive", () => {
12
+ expect(allocateByWeights(0, [10, 20])).toEqual([0, 0]);
13
+ });
14
+
15
+ it("returns zeros when all weights are zero", () => {
16
+ expect(allocateByWeights(100, [0, 0, 0])).toEqual([0, 0, 0]);
17
+ });
18
+
19
+ it("distributes evenly when weights are equal", () => {
20
+ expect(allocateByWeights(100, [10, 10])).toEqual([50, 50]);
21
+ });
22
+
23
+ it("sums to total across uneven weights", () => {
24
+ const total = 1000;
25
+ const result = allocateByWeights(total, [3, 5, 7, 11]);
26
+ expect(result.reduce((a, b) => a + b, 0)).toBe(total);
27
+ });
28
+ });
29
+
30
+ describe("allocateFixedDiscountAcrossLines (SDK)", () => {
31
+ it("returns empty for no bases", () => {
32
+ expect(allocateFixedDiscountAcrossLines(500, [])).toEqual([]);
33
+ });
34
+
35
+ it("returns zeros when total is zero", () => {
36
+ expect(allocateFixedDiscountAcrossLines(0, [100, 200])).toEqual([0, 0]);
37
+ });
38
+
39
+ it("caps at sum of bases when total exceeds it", () => {
40
+ expect(allocateFixedDiscountAcrossLines(10000, [3000, 2000])).toEqual([3000, 2000]);
41
+ });
42
+
43
+ it("never exceeds a single line's base, reroutes overflow", () => {
44
+ const result = allocateFixedDiscountAcrossLines(5000, [100, 100000]);
45
+ expect(result[0]).toBeLessThanOrEqual(100);
46
+ expect(result[1]).toBeLessThanOrEqual(100000);
47
+ expect(result.reduce((a, b) => a + b, 0)).toBe(5000);
48
+ });
49
+
50
+ it("distributes proportionally to bases", () => {
51
+ expect(allocateFixedDiscountAcrossLines(1000, [3000, 7000])).toEqual([300, 700]);
52
+ });
53
+
54
+ it("handles a single line", () => {
55
+ expect(allocateFixedDiscountAcrossLines(500, [1000])).toEqual([500]);
56
+ expect(allocateFixedDiscountAcrossLines(2000, [1000])).toEqual([1000]);
57
+ });
58
+ });
@@ -0,0 +1,217 @@
1
+ import { utils } from "../src";
2
+
3
+ const baseSelectedCustomer = {
4
+ customer: { credit: 0, discount: 0 },
5
+ };
6
+
7
+ const buildStore = (taxesType: "over_price" | "included") => ({
8
+ taxesType,
9
+ taxOne: { name: "IVA", value: 16 },
10
+ taxTwo: null,
11
+ taxThree: null,
12
+ orderPageConfig: { shippingServiceCost: 0 },
13
+ });
14
+
15
+ describe("SDK calculateOrderTotal — NUMBER discount codes apply pre-tax", () => {
16
+ describe("over_price", () => {
17
+ const store = buildStore("over_price");
18
+
19
+ it("canonical case: $50 + $30 once-on-order + 16% → $23.20 total", () => {
20
+ const order = {
21
+ express: false,
22
+ products: [
23
+ { _id: "A", qty: 1, quantity: 1, price: 50, expressPrice: 50, extraAmount: 0 },
24
+ ],
25
+ buyAndGetProducts: [],
26
+ discountCode: "code-123",
27
+ };
28
+ const discountCode = {
29
+ type: "number",
30
+ value: 30,
31
+ applyToAllProducts: true,
32
+ applyOnceOnOrder: true,
33
+ products: [],
34
+ };
35
+
36
+ const result = utils.calculateOrderTotal(
37
+ order as any,
38
+ baseSelectedCustomer as any,
39
+ store as any,
40
+ false,
41
+ [],
42
+ discountCode,
43
+ 0
44
+ );
45
+
46
+ expect(result.total).toBe(23.2);
47
+ expect(result.totalDiscountAmount).toBe(30);
48
+ expect(result.taxesTotal).toBeCloseTo(3.2, 2);
49
+ });
50
+
51
+ it("caps the discount at the eligible base", () => {
52
+ const order = {
53
+ express: false,
54
+ products: [
55
+ { _id: "A", qty: 1, quantity: 1, price: 20, expressPrice: 20, extraAmount: 0 },
56
+ ],
57
+ buyAndGetProducts: [],
58
+ discountCode: "code-123",
59
+ };
60
+ const discountCode = {
61
+ type: "number",
62
+ value: 50,
63
+ applyToAllProducts: true,
64
+ applyOnceOnOrder: true,
65
+ products: [],
66
+ };
67
+
68
+ const result = utils.calculateOrderTotal(
69
+ order as any,
70
+ baseSelectedCustomer as any,
71
+ store as any,
72
+ false,
73
+ [],
74
+ discountCode,
75
+ 0
76
+ );
77
+
78
+ expect(result.total).toBe(0);
79
+ });
80
+
81
+ it("applyOnceOnOrder=false: per-unit fixed discount", () => {
82
+ const order = {
83
+ express: false,
84
+ products: [
85
+ { _id: "A", qty: 2, quantity: 2, price: 50, expressPrice: 50, extraAmount: 0 },
86
+ ],
87
+ buyAndGetProducts: [],
88
+ discountCode: "code-123",
89
+ };
90
+ const discountCode = {
91
+ type: "number",
92
+ value: 10,
93
+ applyToAllProducts: true,
94
+ applyOnceOnOrder: false,
95
+ products: [],
96
+ };
97
+
98
+ const result = utils.calculateOrderTotal(
99
+ order as any,
100
+ baseSelectedCustomer as any,
101
+ store as any,
102
+ false,
103
+ [],
104
+ discountCode,
105
+ 0
106
+ );
107
+
108
+ // line base net: 100; per-unit discount $10 × qty 2 = $20 line discount
109
+ // net after: 80; tax 16% = 12.80; total = 92.80
110
+ expect(result.total).toBe(92.8);
111
+ expect(result.totalDiscountAmount).toBe(20);
112
+ });
113
+
114
+ it("distributes proportionally across multiple eligible lines (once-on-order)", () => {
115
+ const order = {
116
+ express: false,
117
+ products: [
118
+ { _id: "A", qty: 1, quantity: 1, price: 30, expressPrice: 30, extraAmount: 0 },
119
+ { _id: "B", qty: 1, quantity: 1, price: 70, expressPrice: 70, extraAmount: 0 },
120
+ ],
121
+ buyAndGetProducts: [],
122
+ discountCode: "code-123",
123
+ };
124
+ const discountCode = {
125
+ type: "number",
126
+ value: 30,
127
+ applyToAllProducts: true,
128
+ applyOnceOnOrder: true,
129
+ products: [],
130
+ };
131
+
132
+ const result = utils.calculateOrderTotal(
133
+ order as any,
134
+ baseSelectedCustomer as any,
135
+ store as any,
136
+ false,
137
+ [],
138
+ discountCode,
139
+ 0
140
+ );
141
+
142
+ // 30%/70% slice → [9, 21]. Net after: 21, 49. Tax: 3.36, 7.84 → 11.20.
143
+ // Total = 21 + 49 + 11.20 = 81.20.
144
+ expect(result.total).toBe(81.2);
145
+ expect(result.totalDiscountAmount).toBe(30);
146
+ });
147
+ });
148
+
149
+ describe("included", () => {
150
+ const store = buildStore("included");
151
+
152
+ it("canonical case: $50 (gross) + $30 once-on-order + 16% included → $20 total", () => {
153
+ const order = {
154
+ express: false,
155
+ products: [
156
+ { _id: "A", qty: 1, quantity: 1, price: 50, expressPrice: 50, extraAmount: 0 },
157
+ ],
158
+ buyAndGetProducts: [],
159
+ discountCode: "code-123",
160
+ };
161
+ const discountCode = {
162
+ type: "number",
163
+ value: 30,
164
+ applyToAllProducts: true,
165
+ applyOnceOnOrder: true,
166
+ products: [],
167
+ };
168
+
169
+ const result = utils.calculateOrderTotal(
170
+ order as any,
171
+ baseSelectedCustomer as any,
172
+ store as any,
173
+ false,
174
+ [],
175
+ discountCode,
176
+ 0
177
+ );
178
+
179
+ expect(result.total).toBe(20);
180
+ expect(result.totalDiscountAmount).toBe(30);
181
+ });
182
+ });
183
+
184
+ describe("regression: percentage coupons untouched", () => {
185
+ const store = buildStore("over_price");
186
+
187
+ it("PERCENTAGE 10% on $100 + 16% → $104.40", () => {
188
+ const order = {
189
+ express: false,
190
+ products: [
191
+ { _id: "A", qty: 1, quantity: 1, price: 100, expressPrice: 100, extraAmount: 0 },
192
+ ],
193
+ buyAndGetProducts: [],
194
+ discountCode: "code-123",
195
+ };
196
+ const discountCode = {
197
+ type: "percentage",
198
+ value: 10,
199
+ applyToAllProducts: true,
200
+ applyOnceOnOrder: false,
201
+ products: [],
202
+ };
203
+
204
+ const result = utils.calculateOrderTotal(
205
+ order as any,
206
+ baseSelectedCustomer as any,
207
+ store as any,
208
+ false,
209
+ [],
210
+ discountCode,
211
+ 0
212
+ );
213
+
214
+ expect(result.total).toBe(104.4);
215
+ });
216
+ });
217
+ });
@@ -52,10 +52,10 @@ describe("calculateOrderTotal — fixed-amount coupons", () => {
52
52
  0
53
53
  );
54
54
 
55
- // baseline (no coupon): 5*100=500 subtotal, +16% taxes over_price = 580.
56
- // once-on-order applies a flat $20 reduction to the order total only.
57
- expect(result.total).toBe(560);
58
- // discount is order-level, not per-line, so totalDiscountAmount keeps the once-on-order $20.
55
+ // Pre-tax fix: coupon $20 reduces the taxable base BEFORE taxes are
56
+ // computed. net 500 - 20 = 480, tax 16% = 76.80, total = 556.80.
57
+ // The user-facing "Descuento" remains $20 (the nominal coupon value).
58
+ expect(result.total).toBe(556.8);
59
59
  expect(result.totalDiscountAmount).toBe(20);
60
60
  });
61
61
 
@@ -106,8 +106,9 @@ describe("calculateOrderTotal — fixed-amount coupons", () => {
106
106
  0
107
107
  );
108
108
 
109
- // 250 == 250 → discount applied. Baseline 290, minus $50 = 240.
110
- expect(result.total).toBe(240);
109
+ // 250 == 250 → discount applied. Pre-tax fix: net 250 - 50 = 200,
110
+ // tax 16% = 32, total = 232.
111
+ expect(result.total).toBe(232);
111
112
  });
112
113
 
113
114
  it("zero/undefined minimumOrderSubtotal keeps current behavior (backwards compatible)", () => {
@@ -131,7 +132,8 @@ describe("calculateOrderTotal — fixed-amount coupons", () => {
131
132
  0
132
133
  );
133
134
 
134
- expect(result.total).toBe(240);
135
+ // Pre-tax fix: same as the above minimum-met case → 232.
136
+ expect(result.total).toBe(232);
135
137
  });
136
138
  });
137
139