feat: Adjust loyalty discount calculations to apply before tax and introduce accounting for 100% loyalty discounts.
This commit is contained in:
parent
064edd2c3a
commit
8d756a0e6f
@ -1 +1,2 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
@ -9,11 +9,12 @@
|
|||||||
""",
|
""",
|
||||||
"depends": ["point_of_sale", "pos_loyalty"],
|
"depends": ["point_of_sale", "pos_loyalty"],
|
||||||
"data": [
|
"data": [
|
||||||
|
"views/res_config_settings_views.xml",
|
||||||
],
|
],
|
||||||
"assets": {
|
"assets": {
|
||||||
"point_of_sale._assets_pos": [
|
"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/loyalty.js',
|
||||||
|
'pos_loyalty_discount_before_tax/static/src/overrides/models/orderline.js',
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"installable": True,
|
"installable": True,
|
||||||
|
|||||||
3
models/__init__.py
Normal file
3
models/__init__.py
Normal 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
17
models/pos_config.py
Normal 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
74
models/pos_session.py
Normal 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
|
||||||
13
models/res_config_settings.py
Normal file
13
models/res_config_settings.py
Normal 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,
|
||||||
|
)
|
||||||
@ -13,6 +13,8 @@ patch(Order.prototype, {
|
|||||||
// For all rewards, we calculate discounts without tax
|
// For all rewards, we calculate discounts without tax
|
||||||
let discountable = 0;
|
let discountable = 0;
|
||||||
const discountablePerTax = {};
|
const discountablePerTax = {};
|
||||||
|
const discountableWithTaxPerTax = {};
|
||||||
|
const formattedLines = [];
|
||||||
const orderLines = this.get_orderlines();
|
const orderLines = this.get_orderlines();
|
||||||
|
|
||||||
for (const line of orderLines) {
|
for (const line of orderLines) {
|
||||||
@ -35,10 +37,13 @@ patch(Order.prototype, {
|
|||||||
discountable += line_total_without_tax;
|
discountable += line_total_without_tax;
|
||||||
if (!discountablePerTax[taxKey]) {
|
if (!discountablePerTax[taxKey]) {
|
||||||
discountablePerTax[taxKey] = 0;
|
discountablePerTax[taxKey] = 0;
|
||||||
|
discountableWithTaxPerTax[taxKey] = 0;
|
||||||
}
|
}
|
||||||
discountablePerTax[taxKey] += line_total_without_tax;
|
discountablePerTax[taxKey] += line_total_without_tax;
|
||||||
|
discountableWithTaxPerTax[taxKey] += line.get_price_with_tax();
|
||||||
|
formattedLines.push(line);
|
||||||
}
|
}
|
||||||
return { discountable, discountablePerTax };
|
return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,11 +58,14 @@ patch(Order.prototype, {
|
|||||||
|
|
||||||
// Use price without tax for discount calculation
|
// Use price without tax for discount calculation
|
||||||
const discountableWithoutTax = cheapestLine.get_price_without_tax();
|
const discountableWithoutTax = cheapestLine.get_price_without_tax();
|
||||||
|
const discountableWithTax = cheapestLine.get_price_with_tax();
|
||||||
const taxKey = cheapestLine.get_taxes().map((t) => t.id);
|
const taxKey = cheapestLine.get_taxes().map((t) => t.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
discountable: discountableWithoutTax,
|
discountable: discountableWithoutTax,
|
||||||
discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]),
|
discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]),
|
||||||
|
discountableWithTaxPerTax: Object.fromEntries([[taxKey, discountableWithTax]]),
|
||||||
|
formattedLines: [cheapestLine],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -71,13 +79,16 @@ patch(Order.prototype, {
|
|||||||
const discountLinesPerReward = {};
|
const discountLinesPerReward = {};
|
||||||
const orderLines = this.get_orderlines();
|
const orderLines = this.get_orderlines();
|
||||||
const remainingAmountPerLine = {};
|
const remainingAmountPerLine = {};
|
||||||
|
const remainingAmountWithTaxPerLine = {};
|
||||||
|
|
||||||
for (const line of orderLines) {
|
for (const line of orderLines) {
|
||||||
if (!line.get_quantity() || !line.price) {
|
if (!line.get_quantity() || !line.price) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const product_id = line.get_product().id;
|
const product_id = line.get_product().id;
|
||||||
remainingAmountPerLine[line.cid] = line.get_price_without_tax();
|
remainingAmountPerLine[line.cid] = line.get_price_without_tax();
|
||||||
|
remainingAmountWithTaxPerLine[line.cid] = line.get_price_with_tax();
|
||||||
|
|
||||||
let included = false;
|
let included = false;
|
||||||
|
|
||||||
@ -111,16 +122,20 @@ patch(Order.prototype, {
|
|||||||
|
|
||||||
let discountable = 0;
|
let discountable = 0;
|
||||||
const discountablePerTax = {};
|
const discountablePerTax = {};
|
||||||
|
const discountableWithTaxPerTax = {};
|
||||||
for (const line of linesToDiscount) {
|
for (const line of linesToDiscount) {
|
||||||
discountable += remainingAmountPerLine[line.cid];
|
discountable += remainingAmountPerLine[line.cid];
|
||||||
const taxKey = line.get_taxes().map((t) => t.id);
|
const taxKey = line.get_taxes().map((t) => t.id);
|
||||||
if (!discountablePerTax[taxKey]) {
|
if (!discountablePerTax[taxKey]) {
|
||||||
discountablePerTax[taxKey] = 0;
|
discountablePerTax[taxKey] = 0;
|
||||||
|
discountableWithTaxPerTax[taxKey] = 0;
|
||||||
}
|
}
|
||||||
discountablePerTax[taxKey] +=
|
discountablePerTax[taxKey] +=
|
||||||
remainingAmountPerLine[line.cid];
|
remainingAmountPerLine[line.cid];
|
||||||
|
discountableWithTaxPerTax[taxKey] +=
|
||||||
|
remainingAmountWithTaxPerLine[line.cid];
|
||||||
}
|
}
|
||||||
return { discountable, discountablePerTax };
|
return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines: linesToDiscount };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -145,7 +160,7 @@ patch(Order.prototype, {
|
|||||||
return "Unknown discount type";
|
return "Unknown discount type";
|
||||||
}
|
}
|
||||||
|
|
||||||
let { discountable, discountablePerTax } = getDiscountable(reward);
|
let { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines } = getDiscountable(reward);
|
||||||
// For all rewards, we should use total without tax for comparison
|
// For all rewards, we should use total without tax for comparison
|
||||||
const totalForComparison = this.get_total_without_tax();
|
const totalForComparison = this.get_total_without_tax();
|
||||||
discountable = Math.min(totalForComparison, discountable);
|
discountable = Math.min(totalForComparison, discountable);
|
||||||
@ -191,13 +206,44 @@ patch(Order.prototype, {
|
|||||||
|
|
||||||
const discountFactor = totalDiscountableWithoutTax ? Math.min(1, maxDiscount / totalDiscountableWithoutTax) : 1;
|
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) => {
|
const result = Object.entries(discountablePerTax).reduce((lst, entry) => {
|
||||||
if (!entry[1]) {
|
if (!entry[1]) {
|
||||||
return lst;
|
return lst;
|
||||||
}
|
}
|
||||||
const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str));
|
const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str));
|
||||||
// Calculate tax-exclusive discount value for display
|
// Calculate tax-exclusive discount value for display
|
||||||
const preliminaryAmount = entry[1] * discountFactor;
|
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);
|
const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding);
|
||||||
lst.push({
|
lst.push({
|
||||||
product: reward.discount_line_product_id,
|
product: reward.discount_line_product_id,
|
||||||
@ -218,5 +264,15 @@ patch(Order.prototype, {
|
|||||||
result[0]["points_cost"] = pointCost;
|
result[0]["points_cost"] = pointCost;
|
||||||
}
|
}
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
76
static/src/overrides/models/orderline.js
Normal file
76
static/src/overrides/models/orderline.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
22
views/res_config_settings_views.xml
Normal file
22
views/res_config_settings_views.xml
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user