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.
- package/dist/utils/orders/calculateOrderTotal.js +61 -19
- package/dist/utils/orders/calculateTotalTaxesIncluded.js +24 -5
- package/dist/utils/orders/calculateTotalTaxesOverPrice.js +25 -6
- package/dist/utils/orders/helpers.js +94 -14
- package/package.json +1 -1
- package/src/interfaces/Product.ts +4 -0
- package/src/utils/orders/calculateOrderTotal.test.js +164 -0
- package/src/utils/orders/calculateOrderTotal.ts +91 -29
- package/src/utils/orders/calculateTotalTaxesIncluded.ts +27 -5
- package/src/utils/orders/calculateTotalTaxesOverPrice.ts +26 -6
- package/src/utils/orders/helpers.ts +102 -13
- package/test/orders.allocator.test.ts +58 -0
- package/test/orders.discountCodeNumberPreTax.test.ts +217 -0
- package/test/orders.discountCodeOnceOnOrderAndMinimum.test.ts +9 -7
- package/test/orders.pricingBreakdown.test.ts +294 -0
|
@@ -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(
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
@@ -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
|
});
|