initial commit
This commit is contained in:
commit
a5ce2d04bd
1
__init__.py
Normal file
1
__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
31
__manifest__.py
Normal file
31
__manifest__.py
Normal 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',
|
||||
}
|
||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
1
models/__init__.py
Normal file
1
models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import loyalty_rule
|
||||
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/loyalty_rule.cpython-312.pyc
Normal file
BIN
models/__pycache__/loyalty_rule.cpython-312.pyc
Normal file
Binary file not shown.
22
models/loyalty_rule.py
Normal file
22
models/loyalty_rule.py
Normal 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
80
readme.md
Normal 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
|
||||
227
static/src/app/pos_order_patch.js
Normal file
227
static/src/app/pos_order_patch.js
Normal 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;
|
||||
}
|
||||
});
|
||||
29
views/loyalty_rule_views.xml
Normal file
29
views/loyalty_rule_views.xml
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user