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.
- package/dist/utils/orders/calculateOrderTotal.js +23 -18
- package/dist/utils/orders/calculateTotalTaxesIncluded.js +22 -4
- package/dist/utils/orders/calculateTotalTaxesOverPrice.js +19 -3
- package/dist/utils/orders/helpers.js +83 -13
- package/package.json +1 -1
- package/src/utils/orders/calculateOrderTotal.ts +40 -26
- package/src/utils/orders/calculateTotalTaxesIncluded.ts +25 -4
- package/src/utils/orders/calculateTotalTaxesOverPrice.ts +19 -3
- package/src/utils/orders/helpers.ts +90 -12
- package/test/orders.allocator.test.ts +58 -0
- package/test/orders.discountCodeNumberPreTax.test.ts +217 -0
- package/test/orders.discountCodeOnceOnOrderAndMinimum.test.ts +9 -7
|
@@ -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(
|
|
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
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
110
|
-
|
|
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
|
-
|
|
135
|
+
// Pre-tax fix: same as the above minimum-met case → 232.
|
|
136
|
+
expect(result.total).toBe(232);
|
|
135
137
|
});
|
|
136
138
|
});
|
|
137
139
|
|