Compare commits

..

2 Commits
main ... 19.0

10 changed files with 23 additions and 206 deletions

17
.gitignore vendored
View File

@ -1,12 +1,15 @@
# Python
__pycache__/
*.py[cod]
*$py.class
__pycache__/
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
# Odoo
*.log
.env
.venv/
venv/
filestore/
session/
*.pot

View File

@ -10,21 +10,19 @@ This module modifies the loyalty reward discount calculation in POS to apply dis
3. **Proper Accounting Entries**: Accounting entries are created with tax only on the credit side, ensuring compliance with accounting standards.
4. **100% Discount Rounding Fix**: Ensures exact zero-total for 100% discounts by generating individual reward lines for each item, preventing tax rounding discrepancies.
5. **Zero-Value Journal Entries**: Automatically creates journal entries for orders with a 0.00 total (e.g., 100% discount) to track the "Foregone Income" and "Discount Expense". This is configurable via settings.
## Technical Changes
### JavaScript Changes
- Modified `static/src/overrides/models/loyalty.js` to calculate discounts on tax-exclusive amounts.
- Implemented "Line-by-Line Cancellation" for 100% discounts to fix rounding issues.
- 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
- Extended `pos.session` (`models/pos_session.py`) to generate additional journal entry lines for 0-value orders.
- Extended `res.config.settings` to allow configuration of Income and Expense accounts for 100% discounts.
- 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
@ -34,15 +32,7 @@ This module modifies the loyalty reward discount calculation in POS to apply dis
## Configuration
### Zero-Value Journal Entries (100% Discount)
To enable journal entries for 0.00 total orders:
1. Go to **Point of Sale > Configuration > Settings**.
2. Scroll to the **100% Discount Accounting** section.
3. Select the **Income Account** (Credit) to track the gross sales value.
4. Select the **Expense/Discount Account** (Debit) to track the discount cost.
5. Save the settings.
If these accounts are not set, no journal entry will be created for 0.00 total orders.
No additional configuration is required. The module automatically applies the changes to all loyalty programs in POS.
## Usage

View File

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

View File

@ -9,7 +9,7 @@
""",
"depends": ["point_of_sale", "pos_loyalty"],
"data": [
"views/res_config_settings_views.xml",
],
"assets": {
"point_of_sale._assets_pos": [

View File

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

View File

@ -1,17 +0,0 @@
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)]"
)

View File

@ -1,79 +0,0 @@
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')
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 gross amount (price before discount logic applied, or sum of positive lines)
# Since it's 100% discount, the "Gross Value" we want to record is roughly the sum
# of the products' prices *before* they were wiped out.
# However, in our new line-by-line strategy, we have:
# Item A: $100
# Discount A: -$100
# Net: $0
# We want to record: Credit Income $100, Debit Expense $100.
# We can sum the positive lines to get the "Sale Value".
gross_amount = sum(
line.price_subtotal_incl
for line in order.lines
if line.price_subtotal_incl > 0
)
if float_is_zero(gross_amount, precision_rounding=self.currency_id.rounding):
continue
amounts = _get_amounts(gross_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:
MoveLine.create(zero_value_moves)
return data

View File

@ -1,13 +0,0 @@
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

@ -13,8 +13,6 @@ patch(Order.prototype, {
// 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) {
@ -37,13 +35,10 @@ patch(Order.prototype, {
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 };
return { discountable, discountablePerTax };
},
/**
@ -58,14 +53,11 @@ patch(Order.prototype, {
// 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],
};
},
@ -79,16 +71,13 @@ patch(Order.prototype, {
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;
@ -122,20 +111,16 @@ patch(Order.prototype, {
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 };
return { discountable, discountablePerTax };
},
/**
@ -160,7 +145,7 @@ patch(Order.prototype, {
return "Unknown discount type";
}
let { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines } = getDiscountable(reward);
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);
@ -206,39 +191,13 @@ patch(Order.prototype, {
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
return formattedLines.map(line => {
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,
};
});
}
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 preliminaryAmount = entry[1] * discountFactor;
const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding);
lst.push({
product: reward.discount_line_product_id,

View File

@ -1,22 +0,0 @@
<?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>