feat: Enhance loyalty discount application to prevent rounding errors for 100% discounts by generating individual reward lines.

This commit is contained in:
Suherdy Yacob 2026-02-03 14:23:04 +07:00
parent 4c6b4cec58
commit 3fed893293
5 changed files with 45 additions and 4 deletions

0
README.md Normal file → Executable file
View File

0
__init__.py Normal file → Executable file
View File

0
__manifest__.py Normal file → Executable file
View File

Binary file not shown.

49
static/src/overrides/models/loyalty.js Normal file → Executable file
View File

@ -13,6 +13,8 @@ patch(Order.prototype, {
// For all rewards, we calculate discounts without tax // For all rewards, we calculate discounts without tax
let discountable = 0; let discountable = 0;
const discountablePerTax = {}; const discountablePerTax = {};
const discountableWithTaxPerTax = {};
const formattedLines = [];
const orderLines = this.get_orderlines(); const orderLines = this.get_orderlines();
for (const line of orderLines) { for (const line of orderLines) {
@ -35,10 +37,13 @@ patch(Order.prototype, {
discountable += line_total_without_tax; discountable += line_total_without_tax;
if (!discountablePerTax[taxKey]) { if (!discountablePerTax[taxKey]) {
discountablePerTax[taxKey] = 0; discountablePerTax[taxKey] = 0;
discountableWithTaxPerTax[taxKey] = 0;
} }
discountablePerTax[taxKey] += line_total_without_tax; discountablePerTax[taxKey] += line_total_without_tax;
discountableWithTaxPerTax[taxKey] += line.get_price_with_tax();
formattedLines.push(line);
} }
return { discountable, discountablePerTax }; return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines };
}, },
/** /**
@ -53,11 +58,14 @@ patch(Order.prototype, {
// Use price without tax for discount calculation // Use price without tax for discount calculation
const discountableWithoutTax = cheapestLine.get_price_without_tax(); const discountableWithoutTax = cheapestLine.get_price_without_tax();
const discountableWithTax = cheapestLine.get_price_with_tax();
const taxKey = cheapestLine.get_taxes().map((t) => t.id); const taxKey = cheapestLine.get_taxes().map((t) => t.id);
return { return {
discountable: discountableWithoutTax, discountable: discountableWithoutTax,
discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]), discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]),
discountableWithTaxPerTax: Object.fromEntries([[taxKey, discountableWithTax]]),
formattedLines: [cheapestLine],
}; };
}, },
@ -71,13 +79,16 @@ patch(Order.prototype, {
const discountLinesPerReward = {}; const discountLinesPerReward = {};
const orderLines = this.get_orderlines(); const orderLines = this.get_orderlines();
const remainingAmountPerLine = {}; const remainingAmountPerLine = {};
const remainingAmountWithTaxPerLine = {};
for (const line of orderLines) { for (const line of orderLines) {
if (!line.get_quantity() || !line.price) { if (!line.get_quantity() || !line.price) {
continue; continue;
} }
const product_id = line.get_product().id; const product_id = line.get_product().id;
remainingAmountPerLine[line.cid] = line.get_price_without_tax(); remainingAmountPerLine[line.cid] = line.get_price_without_tax();
remainingAmountWithTaxPerLine[line.cid] = line.get_price_with_tax();
let included = false; let included = false;
@ -111,16 +122,20 @@ patch(Order.prototype, {
let discountable = 0; let discountable = 0;
const discountablePerTax = {}; const discountablePerTax = {};
const discountableWithTaxPerTax = {};
for (const line of linesToDiscount) { for (const line of linesToDiscount) {
discountable += remainingAmountPerLine[line.cid]; discountable += remainingAmountPerLine[line.cid];
const taxKey = line.get_taxes().map((t) => t.id); const taxKey = line.get_taxes().map((t) => t.id);
if (!discountablePerTax[taxKey]) { if (!discountablePerTax[taxKey]) {
discountablePerTax[taxKey] = 0; discountablePerTax[taxKey] = 0;
discountableWithTaxPerTax[taxKey] = 0;
} }
discountablePerTax[taxKey] += discountablePerTax[taxKey] +=
remainingAmountPerLine[line.cid]; remainingAmountPerLine[line.cid];
discountableWithTaxPerTax[taxKey] +=
remainingAmountWithTaxPerLine[line.cid];
} }
return { discountable, discountablePerTax }; return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines: linesToDiscount };
}, },
/** /**
@ -145,7 +160,7 @@ patch(Order.prototype, {
return "Unknown discount type"; return "Unknown discount type";
} }
let { discountable, discountablePerTax } = getDiscountable(reward); let { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines } = getDiscountable(reward);
// For all rewards, we should use total without tax for comparison // For all rewards, we should use total without tax for comparison
const totalForComparison = this.get_total_without_tax(); const totalForComparison = this.get_total_without_tax();
discountable = Math.min(totalForComparison, discountable); discountable = Math.min(totalForComparison, discountable);
@ -191,13 +206,39 @@ patch(Order.prototype, {
const discountFactor = totalDiscountableWithoutTax ? Math.min(1, maxDiscount / totalDiscountableWithoutTax) : 1; const discountFactor = totalDiscountableWithoutTax ? Math.min(1, maxDiscount / totalDiscountableWithoutTax) : 1;
if ((1 - discountFactor) < 0.00001) {
// 100% Discount: Generate one reward line per original line to prevent rounding aggregation errors
return formattedLines.map(line => {
const taxKey = ['ewallet', 'gift_card'].includes(reward.program_id.program_type)
? line.get_taxes().map((t) => t.id)
: line.get_taxes().filter((t) => t.amount_type !== 'fixed').map((t) => t.id);
return {
product: reward.discount_line_product_id,
price: -line.get_price_without_tax(),
quantity: 1,
reward_id: reward.id,
is_reward_line: true,
coupon_id: coupon_id,
points_cost: 0,
reward_identifier_code: rewardCode,
tax_ids: taxKey, // Use IDs, not tax objects
merge: false,
};
});
}
const result = Object.entries(discountablePerTax).reduce((lst, entry) => { const result = Object.entries(discountablePerTax).reduce((lst, entry) => {
if (!entry[1]) { if (!entry[1]) {
return lst; return lst;
} }
const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str)); const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str));
// Calculate tax-exclusive discount value for display // Calculate tax-exclusive discount value for display
const preliminaryAmount = entry[1] * discountFactor; let preliminaryAmount = entry[1] * discountFactor;
// Back-calculate logic removed as line-by-line strategy handles 100% case.
// Keeping partial discount logic standard for now.
const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding); const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding);
lst.push({ lst.push({
product: reward.discount_line_product_id, product: reward.discount_line_product_id,