From a5ce2d04bdef2dc6b832e95e53b9fd220294b651 Mon Sep 17 00:00:00 2001 From: Abdul Aziz Amrullah Date: Mon, 4 May 2026 09:44:06 +0700 Subject: [PATCH] initial commit --- __init__.py | 1 + __manifest__.py | 31 +++ __pycache__/__init__.cpython-312.pyc | Bin 0 -> 202 bytes models/__init__.py | 1 + models/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 215 bytes .../__pycache__/loyalty_rule.cpython-312.pyc | Bin 0 -> 1116 bytes models/loyalty_rule.py | 22 ++ readme.md | 80 ++++++ static/src/app/pos_order_patch.js | 227 ++++++++++++++++++ views/loyalty_rule_views.xml | 29 +++ 10 files changed, 391 insertions(+) create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 models/__init__.py create mode 100644 models/__pycache__/__init__.cpython-312.pyc create mode 100644 models/__pycache__/loyalty_rule.cpython-312.pyc create mode 100644 models/loyalty_rule.py create mode 100644 readme.md create mode 100644 static/src/app/pos_order_patch.js create mode 100644 views/loyalty_rule_views.xml diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..c008c9e --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,31 @@ +{ + 'name': 'POS Loyalty Tax Mode', + 'version': '1.0', + 'category': 'Point of Sale', + 'summary': 'Configure reward point mode before or after tax', + 'author': 'Abdul Aziz Amrullah', + 'description': """ +POS Loyalty Tax Mode +==================== + +Adds a **Tax Option** dropdown to the loyalty rule's "per money spent" reward point mode. + +When a loyalty rule is configured to grant points per currency spent, this module allows +you to choose whether the point calculation should be based on: + +- **Before Tax**: Points are calculated on the subtotal (price excluding tax) +- **After Tax**: Points are calculated on the total (price including tax) — default Odoo behavior + """, + 'author': 'Abdul Aziz Amrullah', + 'depends': ['pos_loyalty', 'loyalty'], + 'data': [ + 'views/loyalty_rule_views.xml', + ], + 'assets': { + 'point_of_sale._assets_pos': [ + 'pos_loyalty_tax_mode/static/src/app/pos_order_patch.js', + ], + }, + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a9ed8a312413d7b496d1b8efd2755a908abd37a GIT binary patch literal 202 zcmX@j%ge<81iT-gW{Lvo#~=<2FhLog1%Qm{3@HpLj5!Rsj8Tk?43$ip%r6;%!kUb? z*mCnzQge#^G?{KO6fpzERx*4B>HOuMVil8{Sdf^fkd~Q~8dFe|pOu60FD5=dGcU6wK3=b&@)m~;P^dH~)vkyG XXfDXwVi4m4Gb1D8JqD2?HXsK8)WkH& literal 0 HcmV?d00001 diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..a1128c3 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +from . import loyalty_rule diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..387327734268077904a93e3fc593e16f792b5172 GIT binary patch literal 215 zcmX@j%ge<81d<=0W=aF;#~=<2FhLog1%Qm{3@HpLj5!Rsj8Tk?43$ip%r6;%!kUb? zcyjV96LU%`PGGu_8VlIYq;;;b< d0&TJ@;s6>CazinQ@qw9IRLrYUgY>i^8ffA7F!4E`xs6A+f)=QVbux4iChRyCeyU`{@ zs8DD<2=*ZLUyyq6|M1eF&@iWhdhixW#p21C%||OZuy5bzzTbPl*-y!29Pl&q^Sf1) z0Qf45!BP53cY!Or00RtdP=z|IO1e~)br}K)+yhwJ1Xw0=MH>{Jy!Ygut}FFOUil%6 zyv$_7vzTgH#Kt~Tt1c$CUpgrQVA$E=-Ywpn03BkWOBm|11W2S3qCQ;$I1VhAB^<%Z z2X)w2CDBUi-#lS$0~6xtrnc7)>9K9F4)ypd$ZKrmzFRl!puSSGNh4wRi~{9WUSjdb z#B?b^fwA6*FNAvTk+IGrF~bZ94Fj>eLKXf-oQvE;FR)yP#&|bAe#|E}DG3i#{eW6d znME-%jhY>lI0wTcb|;?h5*KgbE(nI9S8@})=38pQJ=k-;keX6uu(x3z-^wrpf~Lgh zla|?J)pbZ6QS#iN7*(2h zu~hQ|w^|ep`wJI~3{e$(7o<4w3nB_$oeEkT=TC7}_%G-&p5|ZT;&=mmOJ{e=t@QlI z#n0)R+aqml{6NdJw9J0?>KE-=do;7JWx6~SkYiK{LM)C14&IgP351^347(rE zkmWof)C#DWl*LvD#L_d|rh+`Cgz!VOEZJX8iP$zmc}ORChA$YrL(`%nIuNP|EOK$Y z3EHDm8%u}Ng?Hw`bfGm}I80n_CuTc}JRa|8Aa%B#${r=7=hXd3wlfCQ*w)(1wXP&9 z*{%%K(IcVJ$-#%0^D2voy@%*R+*Di}raTb;VTj;8=;aGYOuN+^{x-e9O@Y;);-Vu% c2!E>p&a}YABQ*uHZ)SJrcIJKou7>4*0j+-?FaQ7m literal 0 HcmV?d00001 diff --git a/models/loyalty_rule.py b/models/loyalty_rule.py new file mode 100644 index 0000000..3a86925 --- /dev/null +++ b/models/loyalty_rule.py @@ -0,0 +1,22 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class LoyaltyRule(models.Model): + _inherit = 'loyalty.rule' + + money_reward_point_mode = fields.Selection( + selection=[ + ('before_tax', 'Before Tax'), + ('after_tax', 'After Tax') + ], + string='Tax Option', + default='after_tax' + ) + + @api.model + def _load_pos_data_fields(self, config): + fields_list = super()._load_pos_data_fields(config) + fields_list.append('money_reward_point_mode') + return fields_list diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..84fa151 --- /dev/null +++ b/readme.md @@ -0,0 +1,80 @@ +# POS Loyalty Tax Mode + +Custom module for Odoo 19 that adds a **Before Tax / After Tax** option to the loyalty rule's "per money spent" reward point mode. + +## Overview + +By default, Odoo calculates loyalty reward points based on the **total price including tax** when the rule is set to "per money spent". This module introduces a configurable dropdown that lets you choose whether points should be calculated on the price **before tax** or **after tax**. + +## Features + +- Adds a **Tax Option** dropdown field on the `loyalty.rule` model +- The dropdown only appears when the reward point mode is set to **"per money spent"** +- Two options available: + - **Before Tax** — points are calculated based on the subtotal (tax excluded) + - **After Tax** — points are calculated based on the total (tax included, default behavior) +- Fully integrated with the POS frontend — the setting syncs to the Point of Sale session automatically + +## Dependencies + +| Module | Technical Name | +|--------------|----------------| +| POS Loyalty | `pos_loyalty` | +| Loyalty | `loyalty` | + +## Installation + +1. Place the `pos_loyalty_tax_mode` folder into your `custom` addons directory +2. Restart the Odoo server +3. Go to **Apps** → Update Apps List +4. Search for **"POS Loyalty Tax Mode"** and click **Install** + +## Configuration + +1. Navigate to **Point of Sale** → **Configuration** → **Discount & Loyalty** +2. Open or create a loyalty program +3. In the **Rules** section, set the grant mode to **"per [currency] spent"** +4. A new **Tax Option** dropdown will appear — select either **Before Tax** or **After Tax** +5. Save and close/reopen your POS session to apply the changes + +## Module Structure + +``` +pos_loyalty_tax_mode/ +├── __init__.py +├── __manifest__.py +├── readme.md +├── models/ +│ ├── __init__.py +│ └── loyalty_rule.py # Extends loyalty.rule with money_reward_point_mode field +├── views/ +│ └── loyalty_rule_views.xml # Inherits form & kanban views to show the new field +└── static/ + └── src/ + └── app/ + └── pos_order_patch.js # Patches POS point calculation logic +``` + +## Technical Details + +### Backend (`models/loyalty_rule.py`) + +Adds a `money_reward_point_mode` selection field to `loyalty.rule`: + +| Value | Label | Description | +|--------------|------------|------------------------------------------| +| `before_tax` | Before Tax | Points based on price excluding tax | +| `after_tax` | After Tax | Points based on price including tax (default) | + +The field is also registered in `_load_pos_data_fields` so it is available in the POS frontend. + +### Frontend (`static/src/app/pos_order_patch.js`) + +Patches two methods on `PosOrder.prototype`: + +- **`pointsForPrograms(programs)`** — Modified to use `total_excluded` or `total_included` based on the rule's `money_reward_point_mode` when calculating `orderedProductPaid` and `pointsPerUnit` +- **`_getPointsCorrection(program)`** — Modified to use the correct price (before/after tax) when computing point corrections for free-product rewards + +## License + +LGPL-3 diff --git a/static/src/app/pos_order_patch.js b/static/src/app/pos_order_patch.js new file mode 100644 index 0000000..2992912 --- /dev/null +++ b/static/src/app/pos_order_patch.js @@ -0,0 +1,227 @@ +/** @odoo-module **/ + +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { patch } from "@web/core/utils/patch"; + +let pointsForProgramsCountedRules = {}; + +patch(PosOrder.prototype, { + _getPointsCorrection(program) { + const rewardLines = this.lines.filter((line) => line.is_reward_line); + if (!this._canGenerateRewards(program, this.priceIncl, this.priceExcl)) { + return 0; + } + let res = 0; + const ProductPrice = this.models["decimal.precision"].find( + (dp) => dp.name === "Product Price" + ); + for (const rule of program.rule_ids) { + for (const line of rewardLines) { + const reward = line.reward_id; + if (this._validForPointsCorrection(reward, line, rule)) { + if (rule.reward_point_mode === "money") { + const priceToUse = rule.money_reward_point_mode === "before_tax" + ? line.prices.total_excluded + : line.prices.total_included; + res -= ProductPrice.round( + rule.reward_point_amount * priceToUse + ); + } else if (rule.reward_point_mode === "unit") { + res += rule.reward_point_amount * line.getQuantity(); + } + } + } + } + return res; + }, + + pointsForPrograms(programs) { + const ProductPrice = this.models["decimal.precision"].find( + (dp) => dp.name === "Product Price" + ); + pointsForProgramsCountedRules = {}; + const orderLines = this.getOrderlines().filter((line) => !line.combo_parent_id); + + const linesPerRule = {}; + for (const line of orderLines) { + const reward = line.reward_id; + const isDiscount = reward && reward.reward_type === "discount"; + const rewardProgram = reward && reward.program_id; + // Skip lines for automatic discounts. + if (isDiscount && rewardProgram.trigger === "auto") { + continue; + } + + if (!this.isLineValidForLoyaltyPoints(line)) { + continue; + } + for (const program of programs) { + // Skip lines for the current program's discounts. + if (isDiscount && rewardProgram.id === program.id) { + continue; + } + for (const rule of program.rule_ids) { + // Skip lines to which the rule doesn't apply. + if (rule.any_product || rule.validProductIds.has(line.product_id.id)) { + if (!linesPerRule[rule.id]) { + linesPerRule[rule.id] = []; + } + linesPerRule[rule.id].push(line); + } + } + } + } + const result = {}; + for (const program of programs) { + let points = 0; + const splitPoints = []; + for (const rule of program.rule_ids) { + if ( + rule.mode === "with_code" && + !this.uiState.codeActivatedProgramRules.includes(rule.id) + ) { + continue; + } + const linesForRule = linesPerRule[rule.id] ? linesPerRule[rule.id] : []; + const amountWithTax = linesForRule.reduce( + (sum, line) => + sum + + (line.combo_line_ids.length > 0 + ? line.comboTotalPrice + : line.prices.total_included), + 0 + ); + const amountWithoutTax = linesForRule.reduce( + (sum, line) => + sum + + (line.combo_line_ids.length > 0 + ? line.comboTotalPriceWithoutTax + : line.prices.total_excluded), + 0 + ); + const amountCheck = + (rule.minimum_amount_tax_mode === "incl" && amountWithTax) || amountWithoutTax; + if (rule.minimum_amount > amountCheck) { + continue; + } + let totalProductQty = 0; + // Only count points for paid lines. + const qtyPerProduct = {}; + let orderedProductPaid = 0; + for (const line of orderLines) { + if ( + ((!line.reward_product_id && + (rule.any_product || rule.validProductIds.has(line.product_id.id))) || + (line.reward_product_id && + (rule.any_product || + rule.validProductIds.has(line._reward_product_id?.id)))) && + !line.ignoreLoyaltyPoints({ program }) + ) { + // We only count reward products from the same program to avoid unwanted feedback loops + if (line.is_reward_line) { + const reward = line.reward_id; + if ( + program.id === reward.program_id.id || + ["gift_card", "ewallet"].includes(reward.program_id.program_type) + ) { + continue; + } + } + const lineQty = line._reward_product_id + ? -line.getQuantity() + : line.getQuantity(); + if (qtyPerProduct[line._reward_product_id || line.getProduct().id]) { + qtyPerProduct[line._reward_product_id || line.getProduct().id] += + lineQty; + } else { + qtyPerProduct[line._reward_product_id?.id || line.getProduct().id] = + lineQty; + } + + const priceToUse = rule.money_reward_point_mode === "before_tax" + ? (line.combo_line_ids.length > 0 ? line.comboTotalPriceWithoutTax : line.prices.total_excluded) + : (line.combo_line_ids.length > 0 ? line.comboTotalPrice : line.prices.total_included); + orderedProductPaid += priceToUse; + + if (!line.is_reward_line) { + totalProductQty += lineQty; + } + } + } + if (totalProductQty < rule.minimum_qty) { + // Should also count the points from negative quantities. + // For example, when refunding an ewallet payment. See TicketScreen override in this addon. + continue; + } + if (!(program.id in pointsForProgramsCountedRules)) { + pointsForProgramsCountedRules[program.id] = []; + } + pointsForProgramsCountedRules[program.id].push(rule.id); + if ( + program.applies_on === "future" && + rule.reward_point_split && + rule.reward_point_mode !== "order" + ) { + // In this case we count the points per rule + if (rule.reward_point_mode === "unit") { + splitPoints.push( + ...Array.apply(null, Array(totalProductQty)).map((_) => ({ + points: rule.reward_point_amount, + })) + ); + } else if (rule.reward_point_mode === "money") { + for (const line of orderLines) { + if ( + line.is_reward_line || + !rule.validProductIds.has(line.product_id.id) || + line.getQuantity() <= 0 || + line.ignoreLoyaltyPoints({ program }) + ) { + continue; + } + + const priceToUse = rule.money_reward_point_mode === "before_tax" + ? line.prices.total_excluded + : line.prices.total_included; + + const pointsPerUnit = ProductPrice.round( + (rule.reward_point_amount * priceToUse) / + line.getQuantity() + ); + if (pointsPerUnit > 0) { + splitPoints.push( + ...Array.apply(null, Array(line.getQuantity())).map(() => { + if (line._gift_barcode && line.getQuantity() == 1) { + return { + points: pointsPerUnit, + barcode: line._gift_barcode, + giftCardId: line._gift_card_id.id, + }; + } + return { points: pointsPerUnit }; + }) + ); + } + } + } + } else { + // In this case we add on to the global point count + if (rule.reward_point_mode === "order") { + points += rule.reward_point_amount; + } else if (rule.reward_point_mode === "money") { + // NOTE: unlike in sale_loyalty this performs a round half-up instead of round down + points += ProductPrice.round(rule.reward_point_amount * orderedProductPaid); + } else if (rule.reward_point_mode === "unit") { + points += rule.reward_point_amount * totalProductQty; + } + } + } + const res = points || program.program_type === "coupons" ? [{ points }] : []; + if (splitPoints.length) { + res.push(...splitPoints); + } + result[program.id] = res; + } + return result; + } +}); diff --git a/views/loyalty_rule_views.xml b/views/loyalty_rule_views.xml new file mode 100644 index 0000000..c372973 --- /dev/null +++ b/views/loyalty_rule_views.xml @@ -0,0 +1,29 @@ + + + + loyalty.rule.view.form.inherit.tax.mode + loyalty.rule + + + + + + + + + + loyalty.rule.view.kanban.inherit.tax.mode + loyalty.rule + + + + + + + + + +