From c3ddec84f3150e858df6e7e5cc22bdb06912d2fe Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 3 Feb 2026 16:13:45 +0700 Subject: [PATCH] feat: Add configurable accounting entries for 100% discounted Point of Sale orders. --- .gitignore | 12 ++++ README.md | 24 +++++--- __init__.py | 3 +- __manifest__.py | 2 +- __pycache__/__init__.cpython-312.pyc | Bin 185 -> 0 bytes models/__init__.py | 3 + models/pos_config.py | 17 ++++++ models/pos_session.py | 79 +++++++++++++++++++++++++++ models/res_config_settings.py | 13 +++++ views/res_config_settings_views.xml | 22 ++++++++ 10 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 .gitignore delete mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 models/__init__.py create mode 100644 models/pos_config.py create mode 100644 models/pos_session.py create mode 100644 models/res_config_settings.py create mode 100644 views/res_config_settings_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b44e18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python +*.py[cod] +*$py.class +__pycache__/ + +# Odoo +*.log +.env +.venv/ +venv/ +filestore/ +session/ diff --git a/README.md b/README.md index dbd4911..66d2dc6 100755 --- a/README.md +++ b/README.md @@ -10,19 +10,21 @@ 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 -- Ensured discount line values are tax-exclusive for display purposes +- 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. ### 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 +- 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. ## Installation @@ -32,7 +34,15 @@ This module modifies the loyalty reward discount calculation in POS to apply dis ## Configuration -No additional configuration is required. The module automatically applies the changes to all loyalty programs in POS. +### 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. ## Usage diff --git a/__init__.py b/__init__.py index 7c68785..f5ba686 100755 --- a/__init__.py +++ b/__init__.py @@ -1 +1,2 @@ -# -*- coding: utf-8 -*- \ No newline at end of file +# -*- coding: utf-8 -*- +from . import models \ No newline at end of file diff --git a/__manifest__.py b/__manifest__.py index f360004..dd3a540 100755 --- a/__manifest__.py +++ b/__manifest__.py @@ -9,7 +9,7 @@ """, "depends": ["point_of_sale", "pos_loyalty"], "data": [ - + "views/res_config_settings_views.xml", ], "assets": { "point_of_sale._assets_pos": [ diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index bc03e2e9e3351f43d944266fb6ed3502b20ca44a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 185 zcmZ8aI|{-;6x=lmBBb^VHa@Iu#KHrJmDoLE-bxmdePKV~p2RbF7LVZxB%PHntpme+ z7?@XCE|lQR2fGw~kKrG~RGNp%YNd})zGn&_I@TuS{O$2DYVwU G5cdOyWillI diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..55380fe --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from . import pos_config +from . import pos_session +from . import res_config_settings diff --git a/models/pos_config.py b/models/pos_config.py new file mode 100644 index 0000000..3d2c4c0 --- /dev/null +++ b/models/pos_config.py @@ -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)]" + ) diff --git a/models/pos_session.py b/models/pos_session.py new file mode 100644 index 0000000..1bb33ae --- /dev/null +++ b/models/pos_session.py @@ -0,0 +1,79 @@ +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 diff --git a/models/res_config_settings.py b/models/res_config_settings.py new file mode 100644 index 0000000..8765b2e --- /dev/null +++ b/models/res_config_settings.py @@ -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, + ) diff --git a/views/res_config_settings_views.xml b/views/res_config_settings_views.xml new file mode 100644 index 0000000..638194a --- /dev/null +++ b/views/res_config_settings_views.xml @@ -0,0 +1,22 @@ + + + + res.config.settings.view.form.inherit.pos.loyalty.discount.100 + res.config.settings + + + + +
+
+
+
+
+
+
+
+