first commit
This commit is contained in:
commit
4c6b4cec58
49
README.md
Normal file
49
README.md
Normal file
@ -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
|
||||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
22
__manifest__.py
Normal file
22
__manifest__.py
Normal file
@ -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"
|
||||||
|
}
|
||||||
222
static/src/overrides/models/loyalty.js
Normal file
222
static/src/overrides/models/loyalty.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user