Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 064edd2c3a | |||
| 5bcc6a19ad |
17
.gitignore
vendored
17
.gitignore
vendored
@ -1,12 +1,15 @@
|
|||||||
# Python
|
# Python
|
||||||
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
__pycache__/
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
# Odoo
|
# Odoo
|
||||||
*.log
|
*.pot
|
||||||
.env
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
filestore/
|
|
||||||
session/
|
|
||||||
|
|||||||
24
README.md
24
README.md
@ -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.
|
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
|
## Technical Changes
|
||||||
|
|
||||||
### JavaScript Changes
|
### JavaScript Changes
|
||||||
|
|
||||||
- Modified `static/src/overrides/models/loyalty.js` to calculate discounts on tax-exclusive amounts.
|
- 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.
|
- Ensured discount line values are tax-exclusive for display purposes
|
||||||
|
|
||||||
### Backend Changes
|
### Backend Changes
|
||||||
|
|
||||||
- Extended `pos.session` (`models/pos_session.py`) to generate additional journal entry lines for 0-value orders.
|
- Created `models/pos_order.py` to handle accounting entries for loyalty discounts
|
||||||
- Extended `res.config.settings` to allow configuration of Income and Expense accounts for 100% 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
|
## Installation
|
||||||
|
|
||||||
@ -34,15 +32,7 @@ This module modifies the loyalty reward discount calculation in POS to apply dis
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Zero-Value Journal Entries (100% Discount)
|
No additional configuration is required. The module automatically applies the changes to all loyalty programs in POS.
|
||||||
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.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
40
__init__.py
40
__init__.py
@ -1,41 +1 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import models
|
|
||||||
from odoo import api, SUPERUSER_ID
|
|
||||||
|
|
||||||
def pre_init_clean_constraint(env):
|
|
||||||
"""
|
|
||||||
Drop the zombie table/constraint causing installation issues.
|
|
||||||
The constraint 'product_pricelist_res_config_settin_res_config_settings_id_fkey'
|
|
||||||
indicates a table 'product_pricelist_res_config_settings_rel' (or similar)
|
|
||||||
linking Pricelist to Config Settings, which prevents settings deletion.
|
|
||||||
"""
|
|
||||||
cr = env.cr
|
|
||||||
# Attempt to drop the specific table that likely holds the constraint
|
|
||||||
cr.execute("DROP TABLE IF EXISTS product_pricelist_res_config_settings_rel CASCADE")
|
|
||||||
|
|
||||||
# Also try to specifically drop the constraint if it lingers on another table (unlikely but safe)
|
|
||||||
# We find the table first to avoid errors.
|
|
||||||
cr.execute("""
|
|
||||||
SELECT conrelid::regclass::text
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conname = 'product_pricelist_res_config_settin_res_config_settings_id_fkey'
|
|
||||||
""")
|
|
||||||
result = cr.fetchone()
|
|
||||||
|
|
||||||
# Force commit to ensure the drop is saved even if we raise an error?
|
|
||||||
# No, that breaks atomicity. But we want to prove it works.
|
|
||||||
|
|
||||||
# Verification:
|
|
||||||
cr.execute("""
|
|
||||||
SELECT count(*)
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conname = 'product_pricelist_res_config_settin_res_config_settings_id_fkey'
|
|
||||||
""")
|
|
||||||
if cr.fetchone()[0] > 0:
|
|
||||||
import logging
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
_logger.warning("Constraint persisting despite drop attempt. This is unexpected.")
|
|
||||||
else:
|
|
||||||
import logging
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
_logger.info("Constraints successfully removed (or were already gone).")
|
|
||||||
@ -9,7 +9,7 @@
|
|||||||
""",
|
""",
|
||||||
"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": [
|
||||||
@ -18,6 +18,5 @@
|
|||||||
},
|
},
|
||||||
"installable": True,
|
"installable": True,
|
||||||
"auto_install": False,
|
"auto_install": False,
|
||||||
"license": "LGPL-3",
|
"license": "LGPL-3"
|
||||||
"pre_init_hook": "pre_init_clean_constraint",
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
from . import pos_config
|
|
||||||
from . import pos_session
|
|
||||||
from . import res_config_settings
|
|
||||||
@ -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)]"
|
|
||||||
)
|
|
||||||
@ -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
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -13,8 +13,6 @@ 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) {
|
||||||
@ -37,13 +35,10 @@ 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, discountableWithTaxPerTax, formattedLines };
|
return { discountable, discountablePerTax };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -58,14 +53,11 @@ 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],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -79,16 +71,13 @@ 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;
|
||||||
|
|
||||||
@ -122,20 +111,16 @@ 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, discountableWithTaxPerTax, formattedLines: linesToDiscount };
|
return { discountable, discountablePerTax };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -160,7 +145,7 @@ patch(Order.prototype, {
|
|||||||
return "Unknown discount type";
|
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
|
// 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);
|
||||||
@ -206,39 +191,13 @@ 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
|
|
||||||
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) => {
|
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
|
||||||
let preliminaryAmount = entry[1] * discountFactor;
|
const 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,
|
||||||
|
|||||||
@ -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>
|
|
||||||
Loading…
Reference in New Issue
Block a user