From 4c6b4cec588dd343a4747690e0990ae3b077b56f Mon Sep 17 00:00:00 2001 From: "admin.suherdy" Date: Mon, 15 Dec 2025 13:09:07 +0700 Subject: [PATCH] first commit --- README.md | 49 ++++++ __init__.py | 1 + __manifest__.py | 22 +++ static/src/overrides/models/loyalty.js | 222 +++++++++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 static/src/overrides/models/loyalty.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbd4911 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# POS Loyalty Discount Before Tax + +This module modifies the loyalty reward discount calculation in POS to apply discounts before tax calculation and ensures that discount values are displayed as tax-exclusive in both the POS receipt and accounting entries. + +## Features + +1. **Discount Calculation Before Tax**: All loyalty rewards are calculated on the tax-exclusive amount rather than the tax-inclusive amount. + +2. **Tax-Exclusive Display**: Discount values are displayed as tax-exclusive in the POS receipt. + +3. **Proper Accounting Entries**: Accounting entries are created with tax only on the credit side, ensuring compliance with accounting standards. + +## Technical Changes + +### JavaScript Changes + +- Modified `static/src/overrides/models/loyalty.js` to calculate discounts on tax-exclusive amounts +- Ensured discount line values are tax-exclusive for display purposes + +### Backend Changes + +- Created `models/pos_order.py` to handle accounting entries for loyalty discounts +- Created `models/loyalty_reward.py` to ensure discount products are properly configured +- Created `models/account_move.py` to ensure discount lines in account moves are tax-exclusive +- Created `models/pos_session.py` to handle session-level accounting for loyalty discounts + +## Installation + +1. Copy the module to your Odoo addons directory +2. Update the apps list in Odoo +3. Install the "POS Loyalty Discount Before Tax" module + +## Configuration + +No additional configuration is required. The module automatically applies the changes to all loyalty programs in POS. + +## Usage + +The module works automatically with all existing loyalty programs. Discounts will be calculated on tax-exclusive amounts and displayed as such in the POS interface and accounting entries. + +## Compatibility + +This module is compatible with Odoo 17 and requires the following modules: +- point_of_sale +- pos_loyalty + +## License + +LGPL-3 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..7c68785 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..f360004 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "POS Loyalty Discount Before Tax", + "version": "1.6", + "category": "Point of Sale", + "summary": "Modify loyalty reward discount calculation to apply before tax in POS", + "author": "Suherdy Yacob", + "description": """ + This module modifies the loyalty reward discount calculation in POS to apply discounts before tax calculation. + """, + "depends": ["point_of_sale", "pos_loyalty"], + "data": [ + + ], + "assets": { + "point_of_sale._assets_pos": [ + 'pos_loyalty_discount_before_tax/static/src/overrides/models/loyalty.js', + ] + }, + "installable": True, + "auto_install": False, + "license": "LGPL-3" +} diff --git a/static/src/overrides/models/loyalty.js b/static/src/overrides/models/loyalty.js new file mode 100644 index 0000000..ed33156 --- /dev/null +++ b/static/src/overrides/models/loyalty.js @@ -0,0 +1,222 @@ +/** @odoo-module **/ + +import { Order } from "@point_of_sale/app/store/models"; +import { patch } from "@web/core/utils/patch"; +import { roundPrecision } from "@web/core/utils/numbers"; + +// Patch Order methods to handle all loyalty discounts with discount_before_tax +patch(Order.prototype, { + /** + * Override to calculate discountable amount without tax for all reward types + */ + _getDiscountableOnOrder(reward) { + // For all rewards, we calculate discounts without tax + let discountable = 0; + const discountablePerTax = {}; + const orderLines = this.get_orderlines(); + + for (const line of orderLines) { + if (!line.get_quantity()) { + continue; + } + + // Skip reward lines to avoid circular discounts (unless specifically allowed) + if (line.reward_id || line.is_reward_line) { + continue; + } + + // Use price without tax for discount calculation + const line_total_without_tax = line.get_price_without_tax(); + + 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); + + discountable += line_total_without_tax; + if (!discountablePerTax[taxKey]) { + discountablePerTax[taxKey] = 0; + } + discountablePerTax[taxKey] += line_total_without_tax; + } + return { discountable, discountablePerTax }; + }, + + /** + * Override to calculate cheapest line discountable without tax for all reward types + */ + _getDiscountableOnCheapest(reward) { + // For all rewards, we calculate discounts without tax + const cheapestLine = this._getCheapestLine(); + if (!cheapestLine) { + return { discountable: 0, discountablePerTax: {} }; + } + + // Use price without tax for discount calculation + const discountableWithoutTax = cheapestLine.get_price_without_tax(); + const taxKey = cheapestLine.get_taxes().map((t) => t.id); + + return { + discountable: discountableWithoutTax, + discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]), + }; + }, + + /** + * Override to calculate specific product discountable without tax for all reward types + */ + _getDiscountableOnSpecific(reward) { + // For all rewards, we calculate discounts without tax + const applicableProducts = reward.all_discount_product_ids; + const linesToDiscount = []; + const discountLinesPerReward = {}; + const orderLines = this.get_orderlines(); + const remainingAmountPerLine = {}; + + 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(); + + let included = false; + + if ( + applicableProducts.has(product_id) || + (line.reward_product_id && applicableProducts.has(line.reward_product_id)) + ) { + linesToDiscount.push(line); + included = true; + } else if (line.reward_id) { + const lineReward = this.pos.reward_by_id[line.reward_id]; + if (lineReward.id === reward.id || + ( + orderLines.some(product => + lineReward.all_discount_product_ids.has(product.get_product().id) && + applicableProducts.has(product.get_product().id) + ) && + lineReward.reward_type === 'discount' && + lineReward.discount_mode != 'percent' + ) + ) { + linesToDiscount.push(line); + included = true; + } + if (!discountLinesPerReward[line.reward_identifier_code]) { + discountLinesPerReward[line.reward_identifier_code] = []; + } + discountLinesPerReward[line.reward_identifier_code].push(line); + } + } + + let discountable = 0; + const discountablePerTax = {}; + for (const line of linesToDiscount) { + discountable += remainingAmountPerLine[line.cid]; + const taxKey = line.get_taxes().map((t) => t.id); + if (!discountablePerTax[taxKey]) { + discountablePerTax[taxKey] = 0; + } + discountablePerTax[taxKey] += + remainingAmountPerLine[line.cid]; + } + return { discountable, discountablePerTax }; + }, + + /** + * Override reward line values creation to handle discount calculation without tax for all rewards + */ + _getRewardLineValuesDiscount(args) { + const reward = args["reward"]; + const coupon_id = args["coupon_id"]; + + // For all rewards, we calculate discounts without tax + const rewardAppliesTo = reward.discount_applicability; + + let getDiscountable; + if (rewardAppliesTo === "order") { + getDiscountable = this._getDiscountableOnOrder.bind(this); + } else if (rewardAppliesTo === "cheapest") { + getDiscountable = this._getDiscountableOnCheapest.bind(this); + } else if (rewardAppliesTo === "specific") { + getDiscountable = this._getDiscountableOnSpecific.bind(this); + } + if (!getDiscountable) { + return "Unknown discount type"; + } + + let { discountable, discountablePerTax } = 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); + if (!discountable) { + return []; + } + + let maxDiscount = reward.discount_max_amount || Infinity; + if (reward.discount_mode === "per_point") { + const points = (["ewallet", "gift_card"].includes(reward.program_id.program_type)) ? + this._getRealCouponPoints(coupon_id) : + Math.floor(this._getRealCouponPoints(coupon_id) / reward.required_points) * reward.required_points; + maxDiscount = Math.min( + maxDiscount, + roundPrecision(reward.discount * points, this.pos.currency.rounding) + ); + } else if (reward.discount_mode === "per_order") { + maxDiscount = Math.min(maxDiscount, reward.discount); + } else if (reward.discount_mode === "percent") { + maxDiscount = Math.min(maxDiscount, roundPrecision(discountable * (reward.discount / 100), this.pos.currency.rounding)); + } + + const rewardCode = Math.random().toString(36).substring(3); + let pointCost = reward.clear_wallet + ? this._getRealCouponPoints(coupon_id) + : reward.required_points; + + if (reward.discount_mode === "per_point" && !reward.clear_wallet) { + pointCost = roundPrecision(Math.min(maxDiscount, discountable) / reward.discount, this.pos.currency.rounding); + } + + // Apply rounding to pointCost if it's calculated from division + if (pointCost && typeof pointCost === 'number') { + pointCost = roundPrecision(pointCost, this.pos.currency.rounding); + } + + // For all rewards, we calculate discount on price without tax + // Calculate the total discountable amount without tax + let totalDiscountableWithoutTax = 0; + for (const [, amount] of Object.entries(discountablePerTax)) { + totalDiscountableWithoutTax += amount; + } + + const discountFactor = totalDiscountableWithoutTax ? Math.min(1, maxDiscount / totalDiscountableWithoutTax) : 1; + + 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; + const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding); + lst.push({ + product: reward.discount_line_product_id, + price: -discountAmount, + quantity: 1, + reward_id: reward.id, + is_reward_line: true, + coupon_id: coupon_id, + points_cost: 0, + reward_identifier_code: rewardCode, + tax_ids: taxIds, + merge: false, + }); + return lst; + }, []); + + if (result.length) { + result[0]["points_cost"] = pointCost; + } + return result; + } +});