feat: Adjust loyalty discount calculations to apply before tax and introduce accounting for 100% loyalty discounts.

This commit is contained in:
Suherdy Yacob 2026-03-12 08:58:58 +07:00
parent 064edd2c3a
commit 8d756a0e6f
9 changed files with 487 additions and 224 deletions

View File

@ -1 +1,2 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8 -*-
from . import models

View File

@ -9,11 +9,12 @@
""",
"depends": ["point_of_sale", "pos_loyalty"],
"data": [
"views/res_config_settings_views.xml",
],
"assets": {
"point_of_sale._assets_pos": [
'pos_loyalty_discount_before_tax/static/src/overrides/models/loyalty.js',
'pos_loyalty_discount_before_tax/static/src/overrides/models/orderline.js',
]
},
"installable": True,

3
models/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from . import pos_config
from . import pos_session
from . import res_config_settings

17
models/pos_config.py Normal file
View File

@ -0,0 +1,17 @@
from odoo import fields, models
class PosConfig(models.Model):
_inherit = 'pos.config'
discount_100_income_account_id = fields.Many2one(
'account.account',
string='100% Discount Income Account',
help='Account used for Income when an order has a 100% discount (0 total).',
domain="[('deprecated', '=', False)]"
)
discount_100_expense_account_id = fields.Many2one(
'account.account',
string='100% Discount Expense Account',
help='Account used for Expense/Discount when an order has a 100% discount (0 total).',
domain="[('deprecated', '=', False)]"
)

74
models/pos_session.py Normal file
View File

@ -0,0 +1,74 @@
from odoo import models, _
from odoo.tools import float_is_zero
class PosSession(models.Model):
_inherit = 'pos.session'
def _create_account_move(self, balancing_account=False, amount_to_balance=0, bank_payment_method_diffs=None):
"""
Extend _create_account_move to generate additional journal entry lines
for 100% discount orders (where total amount is 0).
"""
# Call super to generate the standard move
data = super(PosSession, self)._create_account_move(balancing_account, amount_to_balance, bank_payment_method_diffs)
if not self.config_id.discount_100_income_account_id or not self.config_id.discount_100_expense_account_id:
return data
# Identify orders with 0 absolute paid amount but non-zero gross amount
# We look for orders where amount_total is near zero.
# Note: 100% discount orders have amount_total = 0.
MoveLine = data.get('MoveLine')
if not MoveLine:
# Depending on Odoo 19's internal structure changes, fallback / ensure MoveLine exists. Odoo 17 returns a dict with 'MoveLine'.
MoveLine = self.env['account.move.line']
income_account = self.config_id.discount_100_income_account_id
expense_account = self.config_id.discount_100_expense_account_id
# Helper to convert amount to company currency if needed, similar to Odoo's internals
def _get_amounts(amount, date):
return self._update_amounts({'amount': 0, 'amount_converted': 0}, {'amount': amount}, date)
zero_value_moves = []
for order in self._get_closed_orders():
if float_is_zero(order.amount_total, precision_rounding=self.currency_id.rounding):
# Calculate the exact discount amount applied by loyalty rewards
discount_amount = sum(
abs(line.price_subtotal)
for line in order.lines
if line.price_subtotal < 0 and (getattr(line, 'is_reward_line', False) or getattr(line, 'reward_id', False))
)
if float_is_zero(discount_amount, precision_rounding=self.currency_id.rounding):
continue
amounts = _get_amounts(discount_amount, order.date_order)
# Create Credit Line (Income)
# We use _credit_amounts helper logic style manually
credit_vals = {
'name': _('100%% Discount Income: %s') % order.name,
'account_id': income_account.id,
'move_id': self.move_id.id,
'partner_id': order.partner_id.id or False,
}
credit_vals.update(self._credit_amounts(credit_vals, amounts['amount'], amounts['amount_converted']))
zero_value_moves.append(credit_vals)
# Create Debit Line (Expense/Discount)
debit_vals = {
'name': _('100%% Discount Expense: %s') % order.name,
'account_id': expense_account.id,
'move_id': self.move_id.id,
'partner_id': order.partner_id.id or False,
}
debit_vals.update(self._debit_amounts(debit_vals, amounts['amount'], amounts['amount_converted']))
zero_value_moves.append(debit_vals)
if zero_value_moves and self.move_id:
MoveLine.with_context(check_move_validity=False).create(zero_value_moves)
return data

View File

@ -0,0 +1,13 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
pos_discount_100_income_account_id = fields.Many2one(
related='pos_config_id.discount_100_income_account_id',
readonly=False,
)
pos_discount_100_expense_account_id = fields.Many2one(
related='pos_config_id.discount_100_expense_account_id',
readonly=False,
)

View File

@ -1,222 +1,278 @@
/** @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;
}
});
/** @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 discountableWithTaxPerTax = {};
const formattedLines = [];
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;
discountableWithTaxPerTax[taxKey] = 0;
}
discountablePerTax[taxKey] += line_total_without_tax;
discountableWithTaxPerTax[taxKey] += line.get_price_with_tax();
formattedLines.push(line);
}
return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines };
},
/**
* 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 discountableWithTax = cheapestLine.get_price_with_tax();
const taxKey = cheapestLine.get_taxes().map((t) => t.id);
return {
discountable: discountableWithoutTax,
discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]),
discountableWithTaxPerTax: Object.fromEntries([[taxKey, discountableWithTax]]),
formattedLines: [cheapestLine],
};
},
/**
* 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 = {};
const remainingAmountWithTaxPerLine = {};
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();
remainingAmountWithTaxPerLine[line.cid] = line.get_price_with_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 = {};
const discountableWithTaxPerTax = {};
for (const line of linesToDiscount) {
discountable += remainingAmountPerLine[line.cid];
const taxKey = line.get_taxes().map((t) => t.id);
if (!discountablePerTax[taxKey]) {
discountablePerTax[taxKey] = 0;
discountableWithTaxPerTax[taxKey] = 0;
}
discountablePerTax[taxKey] +=
remainingAmountPerLine[line.cid];
discountableWithTaxPerTax[taxKey] +=
remainingAmountWithTaxPerLine[line.cid];
}
return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines: linesToDiscount };
},
/**
* 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, discountableWithTaxPerTax, formattedLines } = 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;
if ((1 - discountFactor) < 0.00001) {
// 100% Discount: Generate one reward line per original line to prevent rounding aggregation errors (0.01 issues)
// Tags added for UI-level grouping
return formattedLines.map((line, index) => {
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);
return {
product: reward.discount_line_product_id,
price: -line.get_price_without_tax(),
quantity: 1,
reward_id: reward.id,
is_reward_line: true,
coupon_id: coupon_id,
points_cost: 0,
reward_identifier_code: rewardCode,
tax_ids: taxKey, // Use IDs, not tax objects
merge: false,
is_reward_group_member: true,
reward_group_id: rewardCode,
is_reward_group_head: index === 0,
reward_group_count: formattedLines.length,
};
});
}
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
let preliminaryAmount = entry[1] * discountFactor;
// Back-calculate logic removed as line-by-line strategy handles 100% case.
// Keeping partial discount logic standard for now.
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;
},
set_orderline_options(line, options) {
super.set_orderline_options(...arguments);
if (options && options.is_reward_group_member) {
line.is_reward_group_member = options.is_reward_group_member;
line.reward_group_id = options.reward_group_id;
line.is_reward_group_head = options.is_reward_group_head;
line.reward_group_count = options.reward_group_count;
}
},
});

View File

@ -0,0 +1,76 @@
/** @odoo-module **/
import { Order, Orderline } from "@point_of_sale/app/store/models";
import { patch } from "@web/core/utils/patch";
patch(Order.prototype, {
export_for_printing() {
const result = super.export_for_printing(...arguments);
if (result.orderlines) {
result.orderlines = result.orderlines.filter((lineData) => {
const line = this.orderlines.find((l) => l.cid === lineData.cid);
if (line && line.is_reward_group_member && !line.is_reward_group_head) {
return false;
}
return true;
});
}
return result;
},
});
patch(Orderline.prototype, {
getDisplayClasses() {
// Need to ensure we call the original function, might not exist in some versions or might be different
let classes = {};
if (typeof super.getDisplayClasses === 'function') {
classes = super.getDisplayClasses();
}
if (this.is_reward_group_member && !this.is_reward_group_head) {
classes['d-none'] = true;
}
return classes;
},
getDisplayData() {
const data = super.getDisplayData();
data.cid = this.cid;
if (this.is_reward_group_head) {
// Group and sum all lines in this reward group for display
const groupLines = this.order.get_orderlines().filter(
line => line.reward_group_id === this.reward_group_id
);
let totalDisplayPrice = 0;
let totalQty = 0;
for (const line of groupLines) {
totalDisplayPrice += line.get_display_price();
totalQty += line.get_quantity();
}
// this.env might not exist in Odoo 19 this way, we'll try to use this.pos.env if this.env is absent.
const env = this.env || this.pos.env;
data.price = env.utils.formatCurrency(totalDisplayPrice, this.pos.currency);
data.qty = totalQty.toString();
}
return data;
},
export_as_JSON() {
const result = super.export_as_JSON(...arguments);
if (this.is_reward_group_member) {
result.is_reward_group_member = this.is_reward_group_member;
result.reward_group_id = this.reward_group_id;
result.is_reward_group_head = this.is_reward_group_head;
result.reward_group_count = this.reward_group_count;
}
return result;
},
init_from_JSON(json) {
if (json.is_reward_group_member) {
this.is_reward_group_member = json.is_reward_group_member;
this.reward_group_id = json.reward_group_id;
this.is_reward_group_head = json.is_reward_group_head;
this.reward_group_count = json.reward_group_count;
}
super.init_from_JSON(...arguments);
}
});

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.pos.loyalty.discount.100</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@id='pos_accounting_section']" position="inside">
<setting id="discount_100_account" title="Accounting entries for 100% discount orders" string="100% Discount Accounting">
<div class="row">
<label string="Income Account" for="pos_discount_100_income_account_id" class="col-lg-3 o_light_label"/>
<field name="pos_discount_100_income_account_id"/>
</div>
<div class="row">
<label string="Expense/Discount Account" for="pos_discount_100_expense_account_id" class="col-lg-3 o_light_label"/>
<field name="pos_discount_100_expense_account_id"/>
</div>
</setting>
</xpath>
</field>
</record>
</odoo>