# -*- coding: utf-8 -*- from odoo import models, fields, api, _ from odoo.exceptions import ValidationError class AccountPayment(models.Model): _inherit = 'account.payment' # Flag to prevent infinite recursion during synchronization _skip_amount_sync = False deduction_line_ids = fields.One2many( 'payment.deduction.line', 'payment_id', string='Deductions', help='Payment deductions (e.g., withholding tax, fees)', readonly=False, ) amount_substract = fields.Monetary( string='Total Deductions', currency_field='currency_id', compute='_compute_amount_substract', store=True, help='Total amount to be deducted from the payment', ) final_payment_amount = fields.Monetary( string='Final Payment Amount', currency_field='currency_id', compute='_compute_final_payment_amount', store=True, help='Actual amount to be paid after deductions', ) @api.depends('deduction_line_ids.amount_substract') def _compute_amount_substract(self): for payment in self: payment.amount_substract = sum(payment.deduction_line_ids.mapped('amount_substract')) @api.depends('amount', 'amount_substract', 'currency_id') def _compute_final_payment_amount(self): for payment in self: amount_substract = payment.amount_substract or 0.0 currency = payment.currency_id or payment.company_id.currency_id calculated_final = payment.amount - amount_substract # Debug logging import logging _logger = logging.getLogger(__name__) _logger.info(f"Computing final payment amount for payment {payment.id}: amount={payment.amount}, substract={amount_substract}, final={calculated_final}") payment.final_payment_amount = currency.round(calculated_final) @api.constrains('amount', 'amount_substract', 'expense_account_id', 'deduction_line_ids') def _check_amount_substract(self): for payment in self: if payment.amount_substract and payment.amount_substract < 0: raise ValidationError(_("Total deductions cannot be negative.")) if payment.amount_substract and payment.amount_substract > payment.amount: raise ValidationError(_("Total deductions cannot be greater than the payment amount.")) # Require expense account when using deductions if payment.deduction_line_ids and not payment.expense_account_id: raise ValidationError(_( "Expense Account is required when using payment deductions.\n\n" "Please set the Expense Account field before adding deductions." )) def write(self, vals): """ Override write to handle amount field changes when we have deductions. This prevents Odoo's synchronization from incorrectly changing the amount based on journal entries. """ # If amount is being changed and we have deductions, we need to be careful if 'amount' in vals: for payment in self: if payment.amount_substract and payment.amount_substract > 0: # Log the attempted change for debugging import logging _logger = logging.getLogger(__name__) _logger.info(f"Amount change attempted from {payment.amount} to {vals['amount']} for payment {payment.id} with deductions {payment.amount_substract}") # If the payment is posted, don't allow amount changes # This prevents synchronization from messing up the amount if payment.state == 'posted': _logger.info(f"Preventing amount change for posted payment {payment.id}") vals = vals.copy() del vals['amount'] break return super().write(vals) def action_fix_amount_calculation(self): """ Action to fix amount calculation for payments with deductions. This can be used to correct payments that have incorrect amounts due to synchronization issues. """ for payment in self: if payment.amount_substract and payment.amount_substract > 0: # Get the correct amount from the journal entry if payment.move_id: # Find the counterpart line (payable/expense line) counterpart_lines = payment.move_id.line_ids.filtered( lambda l: l.account_id.account_type in ('liability_payable', 'expense') and l.debit > 0 ) if counterpart_lines: correct_amount = counterpart_lines[0].debit if abs(payment.amount - correct_amount) > 0.01: # Allow for rounding differences import logging _logger = logging.getLogger(__name__) _logger.info(f"Fixing amount for payment {payment.id}: {payment.amount} -> {correct_amount}") # Use SQL to update directly to avoid triggering computed fields payment.env.cr.execute( "UPDATE account_payment SET amount = %s WHERE id = %s", (correct_amount, payment.id) ) payment.invalidate_recordset(['amount']) payment._compute_final_payment_amount() @api.model def fix_all_payment_amounts(self): """ Model method to fix all payments with deduction amount calculation issues. Can be called from code or through RPC. """ payments = self.search([ ('amount_substract', '>', 0), ('state', '=', 'posted'), ]) fixed_count = 0 for payment in payments: if payment.move_id: # Find the counterpart line (payable/expense line with debit) counterpart_lines = payment.move_id.line_ids.filtered( lambda l: l.debit > 0 and l.account_id.account_type in ('liability_payable', 'expense') ) if counterpart_lines: correct_amount = counterpart_lines[0].debit if abs(payment.amount - correct_amount) > 0.01: # Fix using SQL to avoid sync issues payment.env.cr.execute( "UPDATE account_payment SET amount = %s WHERE id = %s", (correct_amount, payment.id) ) payment.invalidate_recordset(['amount']) payment._compute_final_payment_amount() fixed_count += 1 return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Amount Fix Complete'), 'message': _('Fixed %d payments with incorrect amounts.') % fixed_count, 'type': 'success', } } def _synchronize_from_moves(self, changed_fields): """ Override to handle synchronization when we have deductions. When we have a substract amount, the bank credit line is reduced to final_payment_amount, but we want to keep the payment amount at the original value (not sync it down). """ # For payments with deductions, we need to handle synchronization carefully for payment in self: if payment.amount_substract and payment.amount_substract > 0: # Store the original amount before any synchronization original_amount = payment.amount original_substract = payment.amount_substract # Try to call parent sync but handle any errors try: super(AccountPayment, payment)._synchronize_from_moves(changed_fields) except Exception as e: # If there's an error (like missing payable account when using expense_account_id), # that's expected, so we just continue import logging _logger = logging.getLogger(__name__) _logger.info(f"Sync error for payment {payment.id} (expected with deductions): {e}") # After sync, ensure the amount is still correct # The sync might have changed it based on journal entry lines if payment.amount != original_amount: import logging _logger = logging.getLogger(__name__) _logger.info(f"Restoring amount for payment {payment.id}: {payment.amount} -> {original_amount}") # Use SQL to restore the original amount without triggering more syncs payment.env.cr.execute( "UPDATE account_payment SET amount = %s WHERE id = %s", (original_amount, payment.id) ) payment.invalidate_recordset(['amount']) # Ensure final_payment_amount is recalculated correctly payment._compute_final_payment_amount() else: # No deductions - use standard synchronization super(AccountPayment, payment)._synchronize_from_moves(changed_fields) def _prepare_move_line_default_vals(self, write_off_line_vals=None, force_balance=None): """ Override to add deduction lines when deductions exist. This method modifies the journal entry to: 1. Keep the payable/expense debit line at the original amount 2. Add credit lines for each deduction account 3. Reduce the bank credit line to final_payment_amount The resulting entry for outbound payment (amount=2000, deductions=150): - Expense/Payable: debit 2000 (original amount) - PPh 21: credit 100 (deduction) - PPh 29: credit 50 (deduction) - Bank: credit 1850 (final_payment_amount) Total: debit 2000 = credit 2000 (balanced) """ # Get standard line values from parent line_vals_list = super()._prepare_move_line_default_vals(write_off_line_vals, force_balance) # Only modify if we have deductions if self.amount_substract and self.amount_substract > 0 and self.deduction_line_ids: if self.payment_type == 'outbound': # Get existing deduction account IDs to prevent duplicates existing_deduction_accounts = { line.get('account_id') for line in line_vals_list if line.get('account_id') in self.deduction_line_ids.mapped('substract_account_id').ids } if not existing_deduction_accounts: # The liquidity line is the first line (index 0) - this is the bank account # The counterpart line is the second line (index 1) - this is the payable/expense account # Adjust the bank (liquidity) line - reduce the credit to final_payment_amount liquidity_line = line_vals_list[0] final_balance = self.currency_id._convert( self.final_payment_amount, self.company_id.currency_id, self.company_id, self.date, ) liquidity_line['amount_currency'] = -self.final_payment_amount liquidity_line['credit'] = final_balance # The counterpart line should remain at the original amount # Ensure it has all required fields (partner_id, account_id, etc.) if len(line_vals_list) < 2: # Something is wrong with the parent method, skip modifications return line_vals_list counterpart_line = line_vals_list[1] # CRITICAL: Ensure the counterpart line has proper account and partner # Get the account to check its type account_id = counterpart_line.get('account_id') if account_id: account = self.env['account.account'].browse(account_id) # Only set partner_id if the account requires it (payable/receivable) if account.account_type in ('asset_receivable', 'liability_payable'): counterpart_line['partner_id'] = self.partner_id.id else: # No account_id set, use destination_account_id counterpart_line['account_id'] = self.destination_account_id.id # Check if destination account requires partner if self.destination_account_id.account_type in ('asset_receivable', 'liability_payable'): counterpart_line['partner_id'] = self.partner_id.id # Create a deduction line for each deduction (CREDIT) for deduction in self.deduction_line_ids: # Convert deduction amount to company currency deduction_balance = self.currency_id._convert( deduction.amount_substract, self.company_id.currency_id, self.company_id, self.date, ) # Create the deduction line (CREDIT) # Note: Deduction accounts should be tax/expense accounts, not payable/receivable # Therefore, we don't add partner_id to these lines deduction_line_name = deduction.name or _('Payment Deduction: %s') % deduction.substract_account_id.name deduction_line = { 'name': deduction_line_name, 'date_maturity': self.date, 'amount_currency': -deduction.amount_substract, # Negative for credit 'currency_id': self.currency_id.id, 'debit': 0.0, 'credit': deduction_balance, 'account_id': deduction.substract_account_id.id, # No partner_id - deduction accounts are typically tax/expense accounts } # Add the deduction line to the list line_vals_list.append(deduction_line) return line_vals_list