From 116b132b30b6627bb761c5dcd74f53ab86eb2d24 Mon Sep 17 00:00:00 2001 From: "admin.suherdy" Date: Fri, 7 Nov 2025 09:50:30 +0700 Subject: [PATCH] fix the splitting journal entry if there are discount product --- .gitignore | 278 +++++++++--------- CONFIGURATION.md | 80 ++++++ README.md | 102 +++---- __init__.py | 6 +- __manifest__.py | 74 ++--- models/__init__.py | 8 +- models/pos_payment_method.py | 40 +-- models/pos_session.py | 435 +++++++++++++++++++---------- views/pos_payment_method_views.xml | 28 +- views/pos_session_views.xml | 8 +- 10 files changed, 632 insertions(+), 427 deletions(-) create mode 100644 CONFIGURATION.md diff --git a/.gitignore b/.gitignore index 71f52ea..a6db1f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,140 +1,140 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ -build/ -temp/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -Pipfile.lock - -# poetry -poetry.lock - -# PEP 582; used by python-next -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# VS Code settings +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +build/ +temp/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# poetry +poetry.lock + +# PEP 582; used by python-next +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# VS Code settings .vscode/ \ No newline at end of file diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 0000000..e44976c --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,80 @@ +# Split Pendapatan Payment - Configuration Guide + +## Issue Fixed + +The module had a logic issue where it wasn't properly applying income accounts from payment methods for regular (non-discount) products when a discount product was configured in the POS. + +## Root Cause + +The original code used `elif` logic which meant: +- IF discount product exists AND current sale is for discount product → use discount_account_id +- ELIF discount product exists BUT current sale is NOT discount → use income_account_id +- However, the ELIF was actually inside the discount check, causing it to skip for regular products + +## Solution Applied + +Fixed the logic flow to properly check: +1. Is this a discount product sale? → Use `discount_account_id` from payment method (or product default) +2. Is this a regular product sale? → Use `income_account_id` from payment method (or product default) +3. No discount product configured? → Use `income_account_id` from payment method (or product default) + +## Required Configuration + +To use this module properly, you MUST configure the income accounts in your payment methods: + +### Step 1: Configure Payment Method Income Accounts + +1. Go to **Point of Sale → Configuration → Payment Methods** +2. For each payment method (Cash, Bank, Credit Card, etc.), open the form +3. Fill in the following fields: + - **Income Account**: The account to use for regular product sales with this payment method + - **Discount Account**: The account to use for discount product sales with this payment method (if you use POS discounts) + +### Step 2: Important Notes + +- If you don't configure these fields, the module will fall back to using the product's default income account +- For discount products, if `discount_account_id` is not set in the payment method, it will use the discount product's configured account +- For regular products, if `income_account_id` is not set in the payment method, it will use the product's configured income account (from product or product category) + +### Example Configuration + +**Cash Payment Method:** +- Income Account: 4000 - Sales Revenue (Cash) +- Discount Account: 4100 - Sales Discounts (Cash) + +**Bank Payment Method:** +- Income Account: 4010 - Sales Revenue (Bank) +- Discount Account: 4110 - Sales Discounts (Bank) + +**Credit Card Payment Method:** +- Income Account: 4020 - Sales Revenue (Credit Card) +- Discount Account: 4120 - Sales Discounts (Credit Card) + +## Error Messages + +If you see an error saying "need to define the income account at product level", it means: +1. The payment method doesn't have `income_account_id` configured, AND +2. The product doesn't have a valid income account defined + +**Solution**: Configure the income account in either: +- The payment method form (recommended), OR +- The product form or product category + +## Diagnostic Logging + +The module now includes detailed logging to help diagnose issues. Check your Odoo logs for entries starting with `=== SPLIT PENDAPATAN DEBUG ===` to see: +- Which payment method is being processed +- What accounts are configured +- Which account is being used for each sale line + +## Module Behavior + +This module splits income journal entries by payment method. For example, if you have a sale with: +- Total: 100,000 +- Payment: 60,000 Cash + 40,000 Bank + +Instead of one income line of 100,000, you'll get: +- Income (Cash): 60,000 +- Income (Bank): 40,000 + +This allows you to track income separately by payment method for better financial reporting. \ No newline at end of file diff --git a/README.md b/README.md index 31deecd..dd1250b 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,51 @@ -# Split Pendapatan Payment - -This module modifies Point of Sale transactions to split income/pendapatan journal entries per payment method used in the transaction. - -## Overview - -In standard Odoo Point of Sale, all sales income is recorded in a single journal entry line regardless of the payment methods used. This module changes that behavior by creating separate income lines for each payment method used in the transactions. - -For example, if a session has sales paid with both cash and credit card, instead of creating one income line for all sales, the module will create separate income lines: -- Sales - Cash -- Sales - Credit Card - -## Features - -- Splits income journal entries by payment method -- Maintains proper accounting reconciliation -- Works with all payment methods (cash, bank transfers, credit cards, etc.) -- Preserves tax calculations and reporting -- Allows linking specific income accounts to payment methods - -## Configuration - -### Payment Method Configuration -1. Go to Point of Sale > Configuration > Payment Methods -2. Edit a payment method -3. In the Accounting section, set the "Income Account" field to specify which account should be used for sales paid with this method -4. If left empty, the default product income account will be used - -## How it works - -The module inherits from the `pos.session` model and overrides two key methods: - -1. `_accumulate_amounts()` - Modifies how sales amounts are accumulated to include payment method information and use specific income accounts -2. `_get_sale_vals()` - Customizes the journal entry line descriptions to include payment method names - -The module also extends the `pos.payment.method` model to add an "Income Account" field that allows you to specify which account should be used for sales paid with each payment method. - -## Installation - -1. Place the module in your Odoo addons directory -2. Update the Apps list in Odoo -3. Install the "Split Pendapatan Payment" module - -## Compatibility - -This module is designed for Odoo 17.0 and is compatible with the standard Point of Sale module. - -## Limitations - -- Only affects non-invoiced POS orders (invoiced orders follow standard Odoo accounting) -- May require adjustments if used with other POS customization modules that also modify journal entries +# Split Pendapatan Payment + +This module modifies Point of Sale transactions to split income/pendapatan journal entries per payment method used in the transaction. + +## Overview + +In standard Odoo Point of Sale, all sales income is recorded in a single journal entry line regardless of the payment methods used. This module changes that behavior by creating separate income lines for each payment method used in the transactions. + +For example, if a session has sales paid with both cash and credit card, instead of creating one income line for all sales, the module will create separate income lines: +- Sales - Cash +- Sales - Credit Card + +## Features + +- Splits income journal entries by payment method +- Maintains proper accounting reconciliation +- Works with all payment methods (cash, bank transfers, credit cards, etc.) +- Preserves tax calculations and reporting +- Allows linking specific income accounts to payment methods + +## Configuration + +### Payment Method Configuration +1. Go to Point of Sale > Configuration > Payment Methods +2. Edit a payment method +3. In the Accounting section, set the "Income Account" field to specify which account should be used for sales paid with this method +4. If left empty, the default product income account will be used + +## How it works + +The module inherits from the `pos.session` model and overrides two key methods: + +1. `_accumulate_amounts()` - Modifies how sales amounts are accumulated to include payment method information and use specific income accounts +2. `_get_sale_vals()` - Customizes the journal entry line descriptions to include payment method names + +The module also extends the `pos.payment.method` model to add an "Income Account" field that allows you to specify which account should be used for sales paid with each payment method. + +## Installation + +1. Place the module in your Odoo addons directory +2. Update the Apps list in Odoo +3. Install the "Split Pendapatan Payment" module + +## Compatibility + +This module is designed for Odoo 17.0 and is compatible with the standard Point of Sale module. + +## Limitations + +- Only affects non-invoiced POS orders (invoiced orders follow standard Odoo accounting) +- May require adjustments if used with other POS customization modules that also modify journal entries diff --git a/__init__.py b/__init__.py index cde864b..03c48c1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,3 @@ -# -*- coding: utf-8 -*- - -from . import models +# -*- coding: utf-8 -*- + +from . import models diff --git a/__manifest__.py b/__manifest__.py index 660442f..b316548 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -1,37 +1,37 @@ -# -*- coding: utf-8 -*- -{ - 'name': "Split Pendapatan Payment", - - 'summary': "Split income journal entries per payment method in Point of Sale transactions", - - 'description': """ - This module modifies Point of Sale transactions to split income/pendapatan - journal entries per payment method used in the transaction. Instead of - creating a single income line for all sales, separate income lines are - created for each payment method. - """, - - 'author': "Suherdy Yacob", - - # Categories can be used to filter modules in modules listing - 'category': 'Point of Sale', - 'version': '17.0.1.0.0', - - # any module necessary for this one to work correctly - 'depends': ['point_of_sale', 'pos_discount'], - - # always loaded - 'data': [ - # 'security/ir.model.access.csv', - 'views/pos_session_views.xml', - 'views/pos_payment_method_views.xml', - ], - # only loaded in demonstration mode - 'demo': [ - # 'demo/demo.xml', - ], - 'installable': True, - 'application': False, - 'auto_install': False, - 'license': 'LGPL-3', -} +# -*- coding: utf-8 -*- +{ + 'name': "Split Pendapatan Payment", + + 'summary': "Split income journal entries per payment method in Point of Sale transactions", + + 'description': """ + This module modifies Point of Sale transactions to split income/pendapatan + journal entries per payment method used in the transaction. Instead of + creating a single income line for all sales, separate income lines are + created for each payment method. + """, + + 'author': "Suherdy Yacob", + + # Categories can be used to filter modules in modules listing + 'category': 'Point of Sale', + 'version': '17.0.1.0.0', + + # any module necessary for this one to work correctly + 'depends': ['point_of_sale', 'pos_discount'], + + # always loaded + 'data': [ + # 'security/ir.model.access.csv', + 'views/pos_session_views.xml', + 'views/pos_payment_method_views.xml', + ], + # only loaded in demonstration mode + 'demo': [ + # 'demo/demo.xml', + ], + 'installable': True, + 'application': False, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/models/__init__.py b/models/__init__.py index f27bfcb..12f93ab 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- - -from . import pos_session -from . import pos_payment_method +# -*- coding: utf-8 -*- + +from . import pos_session +from . import pos_payment_method diff --git a/models/pos_payment_method.py b/models/pos_payment_method.py index fec32d2..6868e25 100644 --- a/models/pos_payment_method.py +++ b/models/pos_payment_method.py @@ -1,20 +1,20 @@ -# -*- coding: utf-8 -*- -from odoo import models, fields, api - - -class PosPaymentMethod(models.Model): - _inherit = 'pos.payment.method' - - income_account_id = fields.Many2one( - 'account.account', - string='Income Account', - domain=[('deprecated', '=', False)], - help='Account used for income lines when splitting by payment method. ' - 'If empty, the default income account from the product will be used.') - - discount_account_id = fields.Many2one( - 'account.account', - string='Discount Account', - domain=[('deprecated', '=', False)], - help='Account used for discount product lines when splitting by payment method. ' - 'If empty, the default discount account from the product will be used.') +# -*- coding: utf-8 -*- +from odoo import models, fields, api + + +class PosPaymentMethod(models.Model): + _inherit = 'pos.payment.method' + + income_account_id = fields.Many2one( + 'account.account', + string='Income Account', + domain=[('deprecated', '=', False)], + help='Account used for income lines when splitting by payment method. ' + 'If empty, the default income account from the product will be used.') + + discount_account_id = fields.Many2one( + 'account.account', + string='Discount Account', + domain=[('deprecated', '=', False)], + help='Account used for discount product lines when splitting by payment method. ' + 'If empty, the default discount account from the product will be used.') diff --git a/models/pos_session.py b/models/pos_session.py index c6684bc..a229a9c 100644 --- a/models/pos_session.py +++ b/models/pos_session.py @@ -1,155 +1,280 @@ -# -*- coding: utf-8 -*- -from collections import defaultdict -from odoo import models, fields, api, _ -from odoo.exceptions import UserError -from odoo.tools import float_is_zero, float_compare - - -class PosSession(models.Model): - _inherit = 'pos.session' - - def _accumulate_amounts(self, data): - # Call the original method to get all the standard accumulations - data = super(PosSession, self)._accumulate_amounts(data) - - # Get all orders in this session - closed_orders = self._get_closed_orders() - - # If no orders, return original data - if not closed_orders: - return data - - # Get the original sales data - sales = data.get('sales', {}) - if not sales: - return data - - # Create new sales data structure split by payment method - split_sales = defaultdict(lambda: {'amount': 0.0, 'amount_converted': 0.0, 'tax_amount': 0.0}) - - # Get discount product ID from config - discount_product_id = self.config_id.discount_product_id.id if self.config_id.discount_product_id else None - - # For each sale entry, we need to distribute it across payment methods - for sale_key, sale_amounts in sales.items(): - # Skip if this is a tax key (we only want to split actual sales) - if len(sale_key) < 4: # Not a standard sales key - continue - - total_amount = sale_amounts['amount'] - total_amount_converted = sale_amounts['amount_converted'] - tax_amount = sale_amounts.get('tax_amount', 0.0) # Get tax amount if it exists - - if float_is_zero(total_amount, precision_rounding=self.currency_id.rounding): - continue - - # Distribute this sales amount across all orders based on their payment methods - total_payment_amount = sum(sum(payment.amount for payment in order.payment_ids) for order in closed_orders) - - if float_is_zero(total_payment_amount, precision_rounding=self.currency_id.rounding): - continue - - # Distribute the sales amount across all orders proportionally to their payments - for order in closed_orders: - if order.is_invoiced: - continue # Skip invoiced orders - - order_payments = order.payment_ids - order_payment_total = sum(payment.amount for payment in order_payments) - - if float_is_zero(order_payment_total, precision_rounding=order.currency_id.rounding): - continue - - # For each payment in this order, create a split sales entry - for payment in order_payments: - # Calculate the proportion of this payment relative to all payments - payment_proportion = payment.amount / total_payment_amount - payment_amount = total_amount * payment_proportion - payment_amount_converted = total_amount_converted * payment_proportion - payment_tax_amount = tax_amount * payment_proportion - - if float_is_zero(payment_amount, precision_rounding=self.currency_id.rounding): - continue - - # Determine the account to use based on whether this is a discount product - income_account_id = sale_key[0] # default account - - # Check if this sale key corresponds to a discount product - # We need to check if the account in the sale_key matches the discount product account - if discount_product_id: - # Get the discount product's income account - discount_product = self.env['product.product'].browse(discount_product_id) - discount_account = discount_product._get_product_accounts()['income'] - if discount_account and discount_account.id == sale_key[0]: - # This is a discount product, use discount account if configured - if payment.payment_method_id.discount_account_id: - income_account_id = payment.payment_method_id.discount_account_id.id - elif payment.payment_method_id.income_account_id: - # This is a regular product, use income account if configured - income_account_id = payment.payment_method_id.income_account_id.id - - # Ensure we have a valid account ID - if not income_account_id: - continue - - # Create a new key that includes the payment method - new_sale_key = ( - # account (use payment method account if specified) - income_account_id, - # sign (same as original) - sale_key[1], - # payment method - payment.payment_method_id.id, - # for taxes (same as original) - sale_key[2], - # base tags (same as original) - sale_key[3], - ) - - # Update the split sales data - split_sales[new_sale_key]['amount'] += payment_amount - split_sales[new_sale_key]['amount_converted'] += payment_amount_converted - split_sales[new_sale_key]['tax_amount'] += payment_tax_amount - - # Replace the original sales data with our split sales data - data['sales'] = split_sales - return data - - def _get_sale_vals(self, key, amount, amount_converted): - """ Override to add payment method information to the sales line description """ - # Check if this key includes payment method information - if len(key) >= 5 and isinstance(key[2], int): # Has payment method ID - account_id, sign, payment_method_id, tax_keys, base_tag_ids = key - # Try to get the payment method name - try: - payment_method = self.env['pos.payment.method'].browse(payment_method_id) - payment_method_name = payment_method.name - except: - payment_method_name = "Unknown Payment" - else: - # Original format - account_id, sign, tax_keys, base_tag_ids = key - payment_method_name = None - - tax_ids = set(tax[0] for tax in tax_keys) if tax_keys else set() - applied_taxes = self.env['account.tax'].browse(tax_ids) - title = _('Sales') if sign == 1 else _('Refund') - - # Create name with payment method information - if payment_method_name: - name = _('%s - %s', title, payment_method_name) - if applied_taxes: - name = _('%s with %s - %s', title, ', '.join([tax.name for tax in applied_taxes]), payment_method_name) - else: - name = _('%s untaxed', title) - if applied_taxes: - name = _('%s with %s', title, ', '.join([tax.name for tax in applied_taxes])) - - partial_vals = { - 'name': name, - 'account_id': account_id, - 'move_id': self.move_id.id, - 'tax_ids': [(6, 0, tax_ids)], - 'tax_tag_ids': [(6, 0, base_tag_ids)] if base_tag_ids else [], - } - return self._credit_amounts(partial_vals, amount, amount_converted) +# -*- coding: utf-8 -*- +from collections import defaultdict +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from odoo.tools import float_is_zero, float_compare +import logging + +_logger = logging.getLogger(__name__) + + +class PosSession(models.Model): + _inherit = 'pos.session' + + def _prepare_line(self, order_line): + """Override to allow products without income accounts when payment methods have them configured""" + def get_income_account(order_line): + product = order_line.product_id + income_account = product.with_company(order_line.company_id)._get_product_accounts()['income'] or self.config_id.journal_id.default_account_id + + # NEW: If no income account is found on the product, check if payment methods have income accounts configured + if not income_account: + # First look at payment methods actually used on the order + payment_methods_used = order_line.order_id.payment_ids.mapped('payment_method_id').filtered('income_account_id') + payment_methods_with_income = payment_methods_used or self.payment_method_ids.filtered('income_account_id') + payment_income_account = payment_methods_with_income[:1].income_account_id + + if payment_income_account: + _logger.info( + "Product '%s' (id:%s) has no income account. " + "Using payment method '%s' income account %s as fallback.", + product.name, product.id, + payment_methods_with_income[:1].name, + payment_income_account.display_name + ) + income_account = payment_income_account + else: + _logger.warning( + "Product '%s' (id:%s) has no income account and no payment methods provide an income account.", + product.name, product.id + ) + raise UserError(_('Please define income account for this product: "%s" (id:%d).\n' + 'Or configure the Income Account in your payment methods.', + product.name, product.id)) + + return order_line.order_id.fiscal_position_id.map_account(income_account) + + company_domain = self.env['account.tax']._check_company_domain(order_line.order_id.company_id) + tax_ids = order_line.tax_ids_after_fiscal_position.filtered_domain(company_domain) + sign = -1 if order_line.qty >= 0 else 1 + price = sign * order_line.price_unit * (1 - (order_line.discount or 0.0) / 100.0) + check_refund = lambda x: x.qty * x.price_unit < 0 + is_refund = check_refund(order_line) + tax_data = tax_ids.compute_all(price_unit=price, quantity=abs(order_line.qty), currency=self.currency_id, is_refund=is_refund, fixed_multiplicator=sign, include_caba_tags=True) + date_order = order_line.order_id.date_order + taxes = [{'date_order': date_order, **tax} for tax in tax_data['taxes']] + return { + 'date_order': order_line.order_id.date_order, + 'income_account_id': get_income_account(order_line).id, + 'amount': order_line.price_subtotal, + 'taxes': taxes, + 'base_tags': tuple(tax_data['base_tags']), + } + + def _accumulate_amounts(self, data): + """Distribute sales amounts per payment method while respecting discount accounts.""" + data = super(PosSession, self)._accumulate_amounts(data) + + closed_orders = self._get_closed_orders() + if not closed_orders: + return data + + sales = data.get('sales', {}) + if not sales: + return data + + discount_product_id = self.config_id.discount_product_id.id if self.config_id.discount_product_id else None + + # Build per-order breakdown of sales keyed by (account, sign, tax tuple, tags) + order_payment_totals = {} + order_sales_breakdown = {} + for order in closed_orders: + order_payment_totals[order.id] = sum(payment.amount for payment in order.payment_ids) + if order.is_invoiced: + continue + + sale_map = {} + for order_line in order.lines: + line_vals = self._prepare_line(order_line) + sale_key = ( + line_vals['income_account_id'], + -1 if line_vals['amount'] < 0 else 1, + tuple((tax['id'], tax['account_id'], tax['tax_repartition_line_id']) for tax in line_vals['taxes']), + line_vals['base_tags'], + ) + entry = sale_map.setdefault(sale_key, { + 'amount': 0.0, + 'amount_converted': 0.0, + 'tax_amount': 0.0, + 'regular_amount': 0.0, + 'regular_amount_converted': 0.0, + 'regular_tax_amount': 0.0, + 'discount_amount': 0.0, + 'discount_amount_converted': 0.0, + 'discount_tax_amount': 0.0, + }) + + prev_amount = entry['amount'] + prev_amount_converted = entry['amount_converted'] + updated_amounts = self._update_amounts( + {'amount': prev_amount, 'amount_converted': prev_amount_converted}, + {'amount': line_vals['amount']}, + order.date_order, + round=False, + ) + line_amount_converted = updated_amounts['amount_converted'] - prev_amount_converted + line_tax_amount = sum(tax['amount'] for tax in line_vals['taxes']) + + entry['amount'] = updated_amounts['amount'] + entry['amount_converted'] = updated_amounts['amount_converted'] + entry['tax_amount'] += line_tax_amount + + is_reward_line = bool(getattr(order_line, 'is_reward_line', False)) + has_reward = bool(getattr(order_line, 'reward_id', False)) + is_discount_product = bool(discount_product_id and order_line.product_id.id == discount_product_id) + is_discount_line = is_reward_line or has_reward or is_discount_product + + if is_discount_line: + entry['discount_amount'] += line_vals['amount'] + entry['discount_amount_converted'] += line_amount_converted + entry['discount_tax_amount'] += line_tax_amount + else: + entry['regular_amount'] += line_vals['amount'] + entry['regular_amount_converted'] += line_amount_converted + entry['regular_tax_amount'] += line_tax_amount + + if sale_map: + order_sales_breakdown[order.id] = sale_map + + split_sales = defaultdict(lambda: {'amount': 0.0, 'amount_converted': 0.0, 'tax_amount': 0.0}) + + for sale_key, sale_amounts in sales.items(): + if len(sale_key) < 4: + continue + + total_amount = sale_amounts['amount'] + if float_is_zero(total_amount, precision_rounding=self.currency_id.rounding): + continue + + for order in closed_orders: + if order.is_invoiced: + continue + + order_sale_map = order_sales_breakdown.get(order.id) + if not order_sale_map: + continue + + order_sale_amounts = order_sale_map.get(sale_key) + if not order_sale_amounts: + continue + + order_payment_total = order_payment_totals.get(order.id, 0.0) + if float_is_zero(order_payment_total, precision_rounding=order.currency_id.rounding): + continue + + order_amount = order_sale_amounts['amount'] + order_amount_converted = order_sale_amounts['amount_converted'] + order_tax_amount = order_sale_amounts['tax_amount'] + if float_is_zero(order_amount, precision_rounding=self.currency_id.rounding): + continue + + for payment in order.payment_ids: + if float_is_zero(payment.amount, precision_rounding=order.currency_id.rounding): + continue + + payment_proportion = payment.amount / order_payment_total + + net_amount = order_amount * payment_proportion + net_amount_converted = order_amount_converted * payment_proportion + net_tax_amount = order_tax_amount * payment_proportion + + regular_part_amount = order_sale_amounts['regular_amount'] * payment_proportion + discount_part_amount = order_sale_amounts['discount_amount'] * payment_proportion + regular_part_amount_converted = order_sale_amounts['regular_amount_converted'] * payment_proportion + discount_part_amount_converted = order_sale_amounts['discount_amount_converted'] * payment_proportion + regular_part_tax = order_sale_amounts['regular_tax_amount'] * payment_proportion + discount_part_tax = order_sale_amounts['discount_tax_amount'] * payment_proportion + + residual_amount = net_amount - (regular_part_amount + discount_part_amount) + residual_amount_converted = net_amount_converted - (regular_part_amount_converted + discount_part_amount_converted) + residual_tax = net_tax_amount - (regular_part_tax + discount_part_tax) + + if abs(regular_part_amount) >= abs(discount_part_amount): + regular_part_amount += residual_amount + regular_part_amount_converted += residual_amount_converted + regular_part_tax += residual_tax + else: + discount_part_amount += residual_amount + discount_part_amount_converted += residual_amount_converted + discount_part_tax += residual_tax + + def add_split_entry(part_amount, part_amount_converted, part_tax_amount, is_discount_part): + if float_is_zero(part_amount, precision_rounding=self.currency_id.rounding): + return + + target_account_id = sale_key[0] + if is_discount_part: + if payment.payment_method_id.discount_account_id: + target_account_id = payment.payment_method_id.discount_account_id.id + elif payment.payment_method_id.income_account_id: + target_account_id = payment.payment_method_id.income_account_id.id + _logger.warning( + "Payment method %s has no discount account configured; using income account instead for discount lines.", + payment.payment_method_id.name, + ) + else: + if payment.payment_method_id.income_account_id: + target_account_id = payment.payment_method_id.income_account_id.id + + if not target_account_id: + raise UserError(_( + 'No income account found for payment method "%s".\n' + 'Please configure the Income Account in the payment method settings, ' + 'or ensure the product has a valid income account defined.' + ) % payment.payment_method_id.name) + + new_sale_key = ( + target_account_id, + sale_key[1], + payment.payment_method_id.id, + sale_key[2], + sale_key[3], + ) + split_sales[new_sale_key]['amount'] += part_amount + split_sales[new_sale_key]['amount_converted'] += part_amount_converted + split_sales[new_sale_key]['tax_amount'] += part_tax_amount + + add_split_entry(regular_part_amount, regular_part_amount_converted, regular_part_tax, False) + add_split_entry(discount_part_amount, discount_part_amount_converted, discount_part_tax, True) + + data['sales'] = split_sales + return data + + def _get_sale_vals(self, key, amount, amount_converted): + """ Override to add payment method information to the sales line description """ + # Check if this key includes payment method information + if len(key) >= 5 and isinstance(key[2], int): # Has payment method ID + account_id, sign, payment_method_id, tax_keys, base_tag_ids = key + # Try to get the payment method name + try: + payment_method = self.env['pos.payment.method'].browse(payment_method_id) + payment_method_name = payment_method.name + except: + payment_method_name = "Unknown Payment" + else: + # Original format + account_id, sign, tax_keys, base_tag_ids = key + payment_method_name = None + + tax_ids = set(tax[0] for tax in tax_keys) if tax_keys else set() + applied_taxes = self.env['account.tax'].browse(tax_ids) + title = _('Sales') if sign == 1 else _('Refund') + + # Create name with payment method information + if payment_method_name: + name = _('%s - %s', title, payment_method_name) + if applied_taxes: + name = _('%s with %s - %s', title, ', '.join([tax.name for tax in applied_taxes]), payment_method_name) + else: + name = _('%s untaxed', title) + if applied_taxes: + name = _('%s with %s', title, ', '.join([tax.name for tax in applied_taxes])) + + partial_vals = { + 'name': name, + 'account_id': account_id, + 'move_id': self.move_id.id, + 'tax_ids': [(6, 0, tax_ids)], + 'tax_tag_ids': [(6, 0, base_tag_ids)] if base_tag_ids else [], + } + return self._credit_amounts(partial_vals, amount, amount_converted) diff --git a/views/pos_payment_method_views.xml b/views/pos_payment_method_views.xml index 233e18d..7716363 100644 --- a/views/pos_payment_method_views.xml +++ b/views/pos_payment_method_views.xml @@ -1,14 +1,14 @@ - - - - pos.payment.method.form.inherit - pos.payment.method - - - - - - - - - + + + + pos.payment.method.form.inherit + pos.payment.method + + + + + + + + + diff --git a/views/pos_session_views.xml b/views/pos_session_views.xml index 8b82d66..6c61f5e 100644 --- a/views/pos_session_views.xml +++ b/views/pos_session_views.xml @@ -1,4 +1,4 @@ - - - - + + + +