From 3fed893293d88523ef79b81b8bdacb363753fe0f Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 3 Feb 2026 14:23:04 +0700 Subject: [PATCH] feat: Enhance loyalty discount application to prevent rounding errors for 100% discounts by generating individual reward lines. --- README.md | 0 __init__.py | 0 __manifest__.py | 0 __pycache__/__init__.cpython-312.pyc | Bin 0 -> 185 bytes static/src/overrides/models/loyalty.js | 49 +++++++++++++++++++++++-- 5 files changed, 45 insertions(+), 4 deletions(-) mode change 100644 => 100755 README.md mode change 100644 => 100755 __init__.py mode change 100644 => 100755 __manifest__.py create mode 100644 __pycache__/__init__.cpython-312.pyc mode change 100644 => 100755 static/src/overrides/models/loyalty.js diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/__init__.py b/__init__.py old mode 100644 new mode 100755 diff --git a/__manifest__.py b/__manifest__.py old mode 100644 new mode 100755 diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc03e2e9e3351f43d944266fb6ed3502b20ca44a GIT binary patch literal 185 zcmZ8aI|{-;6x=lmBBb^VHa@Iu#KHrJmDoLE-bxmdePKV~p2RbF7LVZxB%PHntpme+ z7?@XCE|lQR2fGw~kKrG~RGNp%YNd})zGn&_I@TuS{O$2DYVwU G5cdOyWillI literal 0 HcmV?d00001 diff --git a/static/src/overrides/models/loyalty.js b/static/src/overrides/models/loyalty.js old mode 100644 new mode 100755 index ed33156..c417dca --- a/static/src/overrides/models/loyalty.js +++ b/static/src/overrides/models/loyalty.js @@ -13,6 +13,8 @@ patch(Order.prototype, { // For all rewards, we calculate discounts without tax let discountable = 0; const discountablePerTax = {}; + const discountableWithTaxPerTax = {}; + const formattedLines = []; const orderLines = this.get_orderlines(); for (const line of orderLines) { @@ -35,10 +37,13 @@ patch(Order.prototype, { discountable += line_total_without_tax; if (!discountablePerTax[taxKey]) { discountablePerTax[taxKey] = 0; + discountableWithTaxPerTax[taxKey] = 0; } 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 const discountableWithoutTax = cheapestLine.get_price_without_tax(); + const discountableWithTax = cheapestLine.get_price_with_tax(); const taxKey = cheapestLine.get_taxes().map((t) => t.id); return { discountable: discountableWithoutTax, discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]), + discountableWithTaxPerTax: Object.fromEntries([[taxKey, discountableWithTax]]), + formattedLines: [cheapestLine], }; }, @@ -71,13 +79,16 @@ patch(Order.prototype, { const discountLinesPerReward = {}; const orderLines = this.get_orderlines(); const remainingAmountPerLine = {}; + const remainingAmountWithTaxPerLine = {}; for (const line of orderLines) { if (!line.get_quantity() || !line.price) { continue; } + const product_id = line.get_product().id; remainingAmountPerLine[line.cid] = line.get_price_without_tax(); + remainingAmountWithTaxPerLine[line.cid] = line.get_price_with_tax(); let included = false; @@ -111,16 +122,20 @@ patch(Order.prototype, { let discountable = 0; const discountablePerTax = {}; + const discountableWithTaxPerTax = {}; for (const line of linesToDiscount) { discountable += remainingAmountPerLine[line.cid]; const taxKey = line.get_taxes().map((t) => t.id); if (!discountablePerTax[taxKey]) { discountablePerTax[taxKey] = 0; + discountableWithTaxPerTax[taxKey] = 0; } discountablePerTax[taxKey] += 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"; } - let { discountable, discountablePerTax } = getDiscountable(reward); + let { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines } = getDiscountable(reward); // For all rewards, we should use total without tax for comparison const totalForComparison = this.get_total_without_tax(); discountable = Math.min(totalForComparison, discountable); @@ -191,13 +206,39 @@ patch(Order.prototype, { 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) => { if (!entry[1]) { return lst; } const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str)); // 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); lst.push({ product: reward.discount_line_product_id,