initial commit

This commit is contained in:
Abdul Aziz Amrullah 2026-05-04 09:44:06 +07:00
commit a5ce2d04bd
10 changed files with 391 additions and 0 deletions

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

31
__manifest__.py Normal file
View File

@ -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',
}

Binary file not shown.

1
models/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import loyalty_rule

Binary file not shown.

Binary file not shown.

22
models/loyalty_rule.py Normal file
View File

@ -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

80
readme.md Normal file
View File

@ -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

View File

@ -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;
}
});

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_rule_view_form_inherit_tax_mode" model="ir.ui.view">
<field name="name">loyalty.rule.view.form.inherit.tax.mode</field>
<field name="model">loyalty.rule</field>
<field name="inherit_id" ref="loyalty.loyalty_rule_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='reward_point_mode']" position="after">
<field
name="money_reward_point_mode"
invisible="reward_point_mode != 'money'"
/>
</xpath>
</field>
</record>
<record id="loyalty_rule_view_kanban_inherit_tax_mode" model="ir.ui.view">
<field name="name">loyalty.rule.view.kanban.inherit.tax.mode</field>
<field name="model">loyalty.rule</field>
<field name="inherit_id" ref="loyalty.loyalty_rule_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='reward_point_mode']" position="after">
<t t-if="record.reward_point_mode.raw_value === 'money'">
<field name="money_reward_point_mode" class="ms-1"/>
</t>
</xpath>
</field>
</record>
</odoo>