diff --git a/account_payment_disperse/README.rst b/account_payment_disperse/README.rst index c25ff27a..756a14fa 100644 --- a/account_payment_disperse/README.rst +++ b/account_payment_disperse/README.rst @@ -2,7 +2,7 @@ Hibou - Manually Disperse Payments ********************************** -What happens when a vendor pays you and the payment needs to be split +What happens when a customer pays you and the payment needs to be split between multiple invoices? What happens when partial payment needs to be applied to some or all of those invoices? We've got you covered. @@ -17,7 +17,8 @@ Main Features * Choose to leave an open balance or write-off the difference. * Easily pay only the portion of the balance that is currently due (or due before a specific date). * **Remaining Column** that displays the residual balance. -* **Due Date** cutoff field (defaults to current date). +* **Due Date Cutoff** field (defaults to current date). +* **Due Date Behavior** field to pay the 'next due' amount (e.g. to take a vendor terms discount) * **Due Column** which displays current amount due. This recalculates when the due date cutoff is changed. * New buttons to easily fill or pre-populate amount remaining or amount due. * Tests to ensure that all the stock payment mechanisms work, and that we make the right reconciliations. @@ -34,5 +35,5 @@ License Please see `LICENSE `_. -Copyright Hibou Corp. 2018 +Copyright Hibou Corp. 2021 diff --git a/account_payment_disperse/__init__.py b/account_payment_disperse/__init__.py index 9b429614..c7120225 100644 --- a/account_payment_disperse/__init__.py +++ b/account_payment_disperse/__init__.py @@ -1,2 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from . import models from . import wizard diff --git a/account_payment_disperse/__manifest__.py b/account_payment_disperse/__manifest__.py index a66efe60..27272533 100644 --- a/account_payment_disperse/__manifest__.py +++ b/account_payment_disperse/__manifest__.py @@ -1,7 +1,10 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + { 'name': 'Payment Disperse', - 'version': '12.0.1.0.0', - 'author': 'Hibou Corp. ', + 'version': '13.0.1.0.0', + 'author': 'Hibou Corp.', + 'license': 'OPL-1', 'category': 'Accounting', 'summary': 'Pay multiple invoices with one Payment', 'description': """ diff --git a/account_payment_disperse/models/__init__.py b/account_payment_disperse/models/__init__.py index 33f37402..e62b0928 100644 --- a/account_payment_disperse/models/__init__.py +++ b/account_payment_disperse/models/__init__.py @@ -1 +1,4 @@ -from . import account \ No newline at end of file +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import account +from . import account_patch diff --git a/account_payment_disperse/models/account.py b/account_payment_disperse/models/account.py index e39340a5..08dd58d8 100644 --- a/account_payment_disperse/models/account.py +++ b/account_payment_disperse/models/account.py @@ -1,68 +1,19 @@ -from odoo import api, fields, models +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import models, _ class AccountPayment(models.Model): _inherit = 'account.payment' - def _create_payment_entry(self, amount): - wizard_id = self.env.context.get('payment_wizard_id') - if wizard_id: - wizard = self.env['account.register.payments'].browse(wizard_id) - assert wizard - if wizard.is_manual_disperse: - payment_amount = sum(wizard.invoice_line_ids.filtered(lambda p: p.partner_id == self.partner_id).mapped('amount')) - if amount < 0: - payment_amount = -payment_amount - return self._create_payment_entry_manual_disperse(payment_amount, wizard) - - return super(AccountPayment, self)._create_payment_entry(amount) - - def _create_payment_entry_manual_disperse(self, amount, wizard): - # When registering payments for multiple partners at the same time, without setting - # the amount again, then the payment will not match the accounting. - self.amount = abs(amount) - if hasattr(self, 'check_amount_in_words'): - self.check_amount_in_words = self.currency_id.amount_to_text(self.amount) - - aml_obj = self.env['account.move.line'].with_context(check_move_validity=False) - debit, credit, amount_currency, currency_id = aml_obj.with_context(date=self.payment_date)._compute_amount_fields(amount, self.currency_id, self.company_id.currency_id) - - move = self.env['account.move'].create(self._get_move_vals()) - - inv_lines = [] - for partial_invoice in wizard.invoice_line_ids.filtered(lambda p: p.amount and p.partner_id == self.partner_id): - # Note that for customer payments, the amount will be reversed. - inv_amount = partial_invoice.amount if amount > 0 else -partial_invoice.amount - i_debit, i_credit, i_amount_currency, i_currency_id = aml_obj.with_context(date=self.payment_date)._compute_amount_fields(inv_amount, self.currency_id, self.company_id.currency_id) - counterpart_aml_dict = self._get_shared_move_line_vals(i_debit, i_credit, i_amount_currency, move.id, False) - counterpart_aml_dict.update(self._get_counterpart_move_line_vals(partial_invoice.invoice_id)) - counterpart_aml_dict.update({'currency_id': currency_id}) - counterpart_aml = aml_obj.create(counterpart_aml_dict) - counterpart_aml |= partial_invoice.invoice_id.move_id.line_ids.filtered( - lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable')) - inv_lines.append((counterpart_aml, partial_invoice.writeoff_acc_id)) - - # Useful for debugging - # for aml_ids, writeoff_acc_id in inv_lines: - # _logger.warn('pair:' + (' writeoff: ' + str(writeoff_acc_id)) if writeoff_acc_id else '') - # for l in aml_ids: - # _logger.warn(' ' + str(l) + ' debit: ' + str(l.debit) + ' credit: ' + str(l.credit)) - - # Write counterpart lines - if not self.currency_id.is_zero(self.amount): - if not self.currency_id != self.company_id.currency_id: - amount_currency = 0 - liquidity_aml_dict = self._get_shared_move_line_vals(credit, debit, -amount_currency, move.id, False) - liquidity_aml_dict.update(self._get_liquidity_move_line_vals(-amount)) - other = aml_obj.create(liquidity_aml_dict) - #_logger.warn('other line -- debit: ' + str(other.debit) + ' credit: ' + str(other.credit)) - - # validate the payment - if not self.journal_id.post_at_bank_rec: - move.post() - - # reconcile the invoice receivable/payable line(s) with the payment - for aml_ids, writeoff_acc_id in inv_lines: - aml_ids.reconcile(writeoff_acc_id, wizard.writeoff_journal_id) - - return move + def _values_for_payment_invoice_line(self, line, currency_id): + sign = 1.0 if self.payment_type == 'outbound' else -1.0 + i_write_off_amount = -line.difference if line.difference and line.close_balance else 0.0 + i_amount_currency = (line.amount + i_write_off_amount) if currency_id else 0.0 + i_amount = sign * (line.amount + i_write_off_amount) + return { + 'amount_currency': i_amount_currency, + 'currency_id': currency_id, + 'debit': i_amount if i_amount > 0.0 else 0.0, + 'credit': -i_amount if i_amount < 0.0 else 0.0, + } diff --git a/account_payment_disperse/models/account_patch.py b/account_payment_disperse/models/account_patch.py new file mode 100644 index 00000000..3c2e3520 --- /dev/null +++ b/account_payment_disperse/models/account_patch.py @@ -0,0 +1,352 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_is_zero + +""" +Patched because other modules may extend original functions. +""" + +from odoo.addons.account.models import account_payment + + +def post(self): + """ Create the journal items for the payment and update the payment's state to 'posted'. + A journal entry is created containing an item in the source liquidity account (selected journal's default_debit or default_credit) + and another in the destination reconcilable account (see _compute_destination_account_id). + If invoice_ids is not empty, there will be one reconcilable move line per invoice to reconcile with. + If the payment is a transfer, a second journal entry is created in the destination journal to receive money from the transfer account. + """ + """ + Manual Payment Disperse functionality: + During reconciliation, reconcile specific lines to specific invoices so that the invoices + are paid down in the desired way. + """ + register_wizard = None + if self._context.get('payment_wizard_id'): + register_wizard = self.env['account.payment.register'].browse(self._context.get('payment_wizard_id')).exists() + is_manual_disperse = bool(register_wizard and register_wizard.is_manual_disperse) + + AccountMove = self.env['account.move'].with_context(default_type='entry') + for rec in self: + + if rec.state != 'draft': + raise UserError(_("Only a draft payment can be posted.")) + + if any(inv.state != 'posted' for inv in rec.invoice_ids): + raise ValidationError(_("The payment cannot be processed because the invoice is not open!")) + + # keep the name in case of a payment reset to draft + if not rec.name: + # Use the right sequence to set the name + if rec.payment_type == 'transfer': + sequence_code = 'account.payment.transfer' + else: + if rec.partner_type == 'customer': + if rec.payment_type == 'inbound': + sequence_code = 'account.payment.customer.invoice' + if rec.payment_type == 'outbound': + sequence_code = 'account.payment.customer.refund' + if rec.partner_type == 'supplier': + if rec.payment_type == 'inbound': + sequence_code = 'account.payment.supplier.refund' + if rec.payment_type == 'outbound': + sequence_code = 'account.payment.supplier.invoice' + rec.name = self.env['ir.sequence'].next_by_code(sequence_code, sequence_date=rec.payment_date) + if not rec.name and rec.payment_type != 'transfer': + raise UserError(_("You have to define a sequence for %s in your company.") % (sequence_code,)) + + moves = AccountMove.create(rec._prepare_payment_moves()) + moves.filtered(lambda move: move.journal_id.post_at != 'bank_rec').post() + + # Update the state / move before performing any reconciliation. + move_name = self._get_move_name_transfer_separator().join(moves.mapped('name')) + rec.write({'state': 'posted', 'move_name': move_name}) + + if rec.payment_type in ('inbound', 'outbound'): + # ==== 'inbound' / 'outbound' ==== + if is_manual_disperse: + move = moves[0] + digits_rounding_precision = move.company_id.currency_id.rounding + # pick out lines... + for i in register_wizard.payment_invoice_ids: + i_values = rec._values_for_payment_invoice_line(i, None) + if not any((i_values['debit'], i_values['credit'])): + continue + move_line = move.line_ids.filtered(lambda l: not l.reconciled and l.account_id == rec.destination_account_id and l.account_id != l.payment_id.writeoff_account_id and + float_is_zero(l.debit - i_values['debit'], precision_rounding=digits_rounding_precision) and + float_is_zero(l.credit - i_values['credit'], precision_rounding=digits_rounding_precision)) + i_lines = i.invoice_id.line_ids.filtered(lambda l: not l.reconciled and l.account_id == rec.destination_account_id) + if move_line and i_lines: + (move_line + i_lines).reconcile() + else: + if rec.invoice_ids: + (moves[0] + rec.invoice_ids).line_ids \ + .filtered(lambda line: not line.reconciled and line.account_id == rec.destination_account_id and not (line.account_id == line.payment_id.writeoff_account_id and line.name == line.payment_id.writeoff_label))\ + .reconcile() + elif rec.payment_type == 'transfer': + # ==== 'transfer' ==== + moves.mapped('line_ids')\ + .filtered(lambda line: line.account_id == rec.company_id.transfer_account_id)\ + .reconcile() + + return True + + +def _prepare_payment_moves(self): + ''' Prepare the creation of journal entries (account.move) by creating a list of python dictionary to be passed + to the 'create' method. + + Example 1: outbound with write-off: + + Account | Debit | Credit + --------------------------------------------------------- + BANK | 900.0 | + RECEIVABLE | | 1000.0 + WRITE-OFF ACCOUNT | 100.0 | + + Example 2: internal transfer from BANK to CASH: + + Account | Debit | Credit + --------------------------------------------------------- + BANK | | 1000.0 + TRANSFER | 1000.0 | + CASH | 1000.0 | + TRANSFER | | 1000.0 + + :return: A list of Python dictionary to be passed to env['account.move'].create. + ''' + """ + Manual Payment Disperse functionality: + During journal entry creation, create a single line per invoice for the amount + to be paid on that invoice. + However, all write-offs are accumulated on a single line. + """ + register_wizard = None + if self._context.get('payment_wizard_id'): + register_wizard = self.env['account.payment.register'].browse( + self._context.get('payment_wizard_id')).exists() + is_manual_disperse = bool(register_wizard and register_wizard.is_manual_disperse) + + all_move_vals = [] + for payment in self: + company_currency = payment.company_id.currency_id + move_names = payment.move_name.split(payment._get_move_name_transfer_separator()) if payment.move_name else None + + # Compute amounts. + write_off_amount = payment.payment_difference_handling == 'reconcile' and -payment.payment_difference or 0.0 + # Manual Disperse + if is_manual_disperse: + write_off_amount = sum(register_wizard.payment_invoice_ids.filtered( + lambda l: l.close_balance and l.difference and l.invoice_id in payment.invoice_ids).mapped('difference')) + if payment.payment_type == 'outbound': + write_off_amount *= -1.0 + + if payment.payment_type in ('outbound', 'transfer'): + counterpart_amount = payment.amount + liquidity_line_account = payment.journal_id.default_debit_account_id + else: + counterpart_amount = -payment.amount + liquidity_line_account = payment.journal_id.default_credit_account_id + + # Manage currency. + if payment.currency_id == company_currency: + # Single-currency. + balance = counterpart_amount + write_off_balance = write_off_amount + counterpart_amount = write_off_amount = 0.0 + currency_id = False + else: + # Multi-currencies. + balance = payment.currency_id._convert(counterpart_amount, company_currency, payment.company_id, payment.payment_date) + write_off_balance = payment.currency_id._convert(write_off_amount, company_currency, payment.company_id, payment.payment_date) + currency_id = payment.currency_id.id + + # Manage custom currency on journal for liquidity line. + if payment.journal_id.currency_id and payment.currency_id != payment.journal_id.currency_id: + # Custom currency on journal. + if payment.journal_id.currency_id == company_currency: + # Single-currency + liquidity_line_currency_id = False + else: + liquidity_line_currency_id = payment.journal_id.currency_id.id + liquidity_amount = company_currency._convert( + balance, payment.journal_id.currency_id, payment.company_id, payment.payment_date) + else: + # Use the payment currency. + liquidity_line_currency_id = currency_id + liquidity_amount = counterpart_amount + + # Compute 'name' to be used in receivable/payable line. + rec_pay_line_name = '' + if payment.payment_type == 'transfer': + rec_pay_line_name = payment.name + else: + if payment.partner_type == 'customer': + if payment.payment_type == 'inbound': + rec_pay_line_name += _("Customer Payment") + elif payment.payment_type == 'outbound': + rec_pay_line_name += _("Customer Credit Note") + elif payment.partner_type == 'supplier': + if payment.payment_type == 'inbound': + rec_pay_line_name += _("Vendor Credit Note") + elif payment.payment_type == 'outbound': + rec_pay_line_name += _("Vendor Payment") + if payment.invoice_ids: + rec_pay_line_name += ': %s' % ', '.join(payment.invoice_ids.mapped('name')) + + # Compute 'name' to be used in liquidity line. + if payment.payment_type == 'transfer': + liquidity_line_name = _('Transfer to %s') % payment.destination_journal_id.name + else: + liquidity_line_name = payment.name + + # ==== 'inbound' / 'outbound' ==== + + move_vals = { + 'date': payment.payment_date, + 'ref': payment.communication, + 'journal_id': payment.journal_id.id, + 'currency_id': payment.journal_id.currency_id.id or payment.company_id.currency_id.id, + 'partner_id': payment.partner_id.id, + 'line_ids': [ + # Receivable / Payable / Transfer line. + # (0, 0, { + # 'name': rec_pay_line_name, + # 'amount_currency': counterpart_amount + write_off_amount if currency_id else 0.0, + # 'currency_id': currency_id, + # 'debit': balance + write_off_balance > 0.0 and balance + write_off_balance or 0.0, + # 'credit': balance + write_off_balance < 0.0 and -balance - write_off_balance or 0.0, + # 'date_maturity': payment.payment_date, + # 'partner_id': payment.partner_id.commercial_partner_id.id, + # 'account_id': payment.destination_account_id.id, + # 'payment_id': payment.id, + # }), + # Liquidity line. + (0, 0, { + 'name': liquidity_line_name, + 'amount_currency': -liquidity_amount if liquidity_line_currency_id else 0.0, + 'currency_id': liquidity_line_currency_id, + 'debit': balance < 0.0 and -balance or 0.0, + 'credit': balance > 0.0 and balance or 0.0, + 'date_maturity': payment.payment_date, + 'partner_id': payment.partner_id.commercial_partner_id.id, + 'account_id': liquidity_line_account.id, + 'payment_id': payment.id, + }), + ], + } + + if is_manual_disperse: + for i in register_wizard.payment_invoice_ids.filtered(lambda l: l.invoice_id in payment.invoice_ids): + i_rec_pay_line_name = rec_pay_line_name + i_values = payment._values_for_payment_invoice_line(i, currency_id) + if not any((i_values['debit'], i_values['credit'])): + # do not make useless lines + continue + move_vals['line_ids'].insert(0, (0, 0, { + 'name': i_rec_pay_line_name, + # 'amount_currency': counterpart_amount + write_off_amount if currency_id else 0.0, + # 'amount_currency': i_amount_currency, + # 'currency_id': currency_id, + # # 'debit': balance + write_off_balance > 0.0 and balance + write_off_balance or 0.0, + # 'debit': i_amount if i_amount > 0.0 else 0.0, + # # 'credit': balance + write_off_balance < 0.0 and -balance - write_off_balance or 0.0, + # 'credit': -i_amount if i_amount < 0.0 else 0.0, + 'date_maturity': payment.payment_date, + 'partner_id': payment.partner_id.commercial_partner_id.id, + 'account_id': payment.destination_account_id.id, + 'payment_id': payment.id, + **i_values, + })) + else: + # insert because existing tests expect them in a set order. + move_vals['line_ids'].insert(0, (0, 0, { + 'name': rec_pay_line_name, + 'amount_currency': counterpart_amount + write_off_amount if currency_id else 0.0, + 'currency_id': currency_id, + 'debit': balance + write_off_balance > 0.0 and balance + write_off_balance or 0.0, + 'credit': balance + write_off_balance < 0.0 and -balance - write_off_balance or 0.0, + 'date_maturity': payment.payment_date, + 'partner_id': payment.partner_id.commercial_partner_id.id, + 'account_id': payment.destination_account_id.id, + 'payment_id': payment.id, + })) + + if write_off_balance: + # Write-off line. + move_vals['line_ids'].append((0, 0, { + 'name': payment.writeoff_label, + 'amount_currency': -write_off_amount, + 'currency_id': currency_id, + 'debit': write_off_balance < 0.0 and -write_off_balance or 0.0, + 'credit': write_off_balance > 0.0 and write_off_balance or 0.0, + 'date_maturity': payment.payment_date, + 'partner_id': payment.partner_id.commercial_partner_id.id, + 'account_id': payment.writeoff_account_id.id, + 'payment_id': payment.id, + })) + + if move_names: + move_vals['name'] = move_names[0] + + all_move_vals.append(move_vals) + + # ==== 'transfer' ==== + if payment.payment_type == 'transfer': + journal = payment.destination_journal_id + + # Manage custom currency on journal for liquidity line. + if journal.currency_id and payment.currency_id != journal.currency_id: + # Custom currency on journal. + liquidity_line_currency_id = journal.currency_id.id + transfer_amount = company_currency._convert(balance, journal.currency_id, payment.company_id, payment.payment_date) + else: + # Use the payment currency. + liquidity_line_currency_id = currency_id + transfer_amount = counterpart_amount + + transfer_move_vals = { + 'date': payment.payment_date, + 'ref': payment.communication, + 'partner_id': payment.partner_id.id, + 'journal_id': payment.destination_journal_id.id, + 'line_ids': [ + # Transfer debit line. + (0, 0, { + 'name': payment.name, + 'amount_currency': -counterpart_amount if currency_id else 0.0, + 'currency_id': currency_id, + 'debit': balance < 0.0 and -balance or 0.0, + 'credit': balance > 0.0 and balance or 0.0, + 'date_maturity': payment.payment_date, + 'partner_id': payment.partner_id.commercial_partner_id.id, + 'account_id': payment.company_id.transfer_account_id.id, + 'payment_id': payment.id, + }), + # Liquidity credit line. + (0, 0, { + 'name': _('Transfer from %s') % payment.journal_id.name, + 'amount_currency': transfer_amount if liquidity_line_currency_id else 0.0, + 'currency_id': liquidity_line_currency_id, + 'debit': balance > 0.0 and balance or 0.0, + 'credit': balance < 0.0 and -balance or 0.0, + 'date_maturity': payment.payment_date, + 'partner_id': payment.partner_id.commercial_partner_id.id, + 'account_id': payment.destination_journal_id.default_credit_account_id.id, + 'payment_id': payment.id, + }), + ], + } + + if move_names and len(move_names) == 2: + transfer_move_vals['name'] = move_names[1] + + all_move_vals.append(transfer_move_vals) + + return all_move_vals + + +account_payment.account_payment.post = post +account_payment.account_payment._prepare_payment_moves = _prepare_payment_moves diff --git a/account_payment_disperse/tests/__init__.py b/account_payment_disperse/tests/__init__.py index de54650c..62f85f93 100644 --- a/account_payment_disperse/tests/__init__.py +++ b/account_payment_disperse/tests/__init__.py @@ -1 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from . import test_payment_multi diff --git a/account_payment_disperse/tests/test_payment_multi.py b/account_payment_disperse/tests/test_payment_multi.py index e07fcabb..ab2b75a4 100644 --- a/account_payment_disperse/tests/test_payment_multi.py +++ b/account_payment_disperse/tests/test_payment_multi.py @@ -1,8 +1,17 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from odoo.addons.account.tests.test_payment import TestPayment +from odoo.tests.common import Form +from odoo.tests import tagged from odoo.exceptions import ValidationError import time +# Fun fact... if you install enterprise accounting, you'll get errors +# due to required fields being missing... +# The classic fix would be the following @tagged, but for some reason this makes +# odoo.tests.common.Form suddenly not calculate the amount on the register payment wizard +# @tagged('post_install', '-at_install') class PaymentMultiTest(TestPayment): def test_multiple_payments_partial(self): @@ -12,146 +21,143 @@ class PaymentMultiTest(TestPayment): inv_2 = self.create_invoice(amount=500, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) ids = [inv_1.id, inv_2.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - }) - register_payments.amount = 399.0 - register_payments.is_manual_disperse = True + payment_register = Form(self.register_payments_model.with_context(active_ids=ids)) + payment_register.journal_id = self.bank_journal_euro + payment_register.payment_date = time.strftime('%Y') + '-07-15' + payment_register.is_manual_disperse = True - with self.assertRaises(ValidationError): - register_payments.create_payments() + with payment_register.payment_invoice_ids.edit(0) as f: + f.amount = 99.0 + f.save() + with payment_register.payment_invoice_ids.edit(1) as f: + f.amount = 300.0 + f.save() - for line in register_payments.invoice_line_ids: - if line.invoice_id == inv_1: - line.amount = 99.0 - if line.invoice_id == inv_2: - line.amount = 300.0 + self.assertEqual(payment_register.amount, 399.0, 'Amount isn\'t the amount from lines') - register_payments.create_payments() + # Persist object + payment_register = payment_register.save() + payment_register.create_payments() payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") self.assertEqual(len(payment_ids), 1, 'Need only one payment.') self.assertEqual(payment_ids.amount, 399.0) - self.assertEqual(inv_1.residual_signed, 1.0) - self.assertEqual(inv_2.residual_signed, 200.0) + self.assertEqual(inv_1.amount_residual_signed, 1.0) + self.assertEqual(inv_2.amount_residual_signed, 200.0) - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - }) - register_payments.amount = 200.0 - register_payments.is_manual_disperse = True + payment_register = Form(self.register_payments_model.with_context(active_ids=ids)) + payment_register.journal_id = self.bank_journal_euro + payment_register.payment_date = time.strftime('%Y') + '-07-15' + payment_register.is_manual_disperse = True - for line in register_payments.invoice_line_ids: - if line.invoice_id == inv_2: - line.amount = 200.0 - - register_payments.create_payments() - self.assertEqual(inv_1.residual_signed, 1.0) - self.assertEqual(inv_2.residual_signed, 0.0) + with payment_register.payment_invoice_ids.edit(0) as f: + f.amount = 0.0 + f.save() + with payment_register.payment_invoice_ids.edit(1) as f: + f.amount = 200.0 + f.save() + payment_register = payment_register.save() + payment_register.create_payments() + self.assertEqual(inv_1.amount_residual_signed, 1.0) + self.assertEqual(inv_2.amount_residual_signed, 0.0) def test_multiple_payments_write_off(self): inv_1 = self.create_invoice(amount=100, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) inv_2 = self.create_invoice(amount=500, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) ids = [inv_1.id, inv_2.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - }) - register_payments.amount = 400.0 - register_payments.is_manual_disperse = True - register_payments.writeoff_journal_id = inv_1.journal_id + payment_register = Form(self.register_payments_model.with_context(active_ids=ids)) + payment_register.journal_id = self.bank_journal_euro + payment_register.payment_date = time.strftime('%Y') + '-07-15' + payment_register.is_manual_disperse = True + payment_register.writeoff_account_id = self.transfer_account - with self.assertRaises(ValidationError): - register_payments.create_payments() + with payment_register.payment_invoice_ids.edit(0) as f: + f.amount = 100.0 + f.close_balance = True + f.save() + with payment_register.payment_invoice_ids.edit(1) as f: + f.amount = 300.0 + f.close_balance = True + f.save() - for line in register_payments.invoice_line_ids: - if line.invoice_id == inv_1: - line.amount = 100.0 - if line.invoice_id == inv_2: - line.amount = 300.0 - line.writeoff_acc_id = self.account_revenue + self.assertEqual(payment_register.amount, 400.0, 'Amount isn\'t the amount from lines') - register_payments.create_payments() + payment_register = payment_register.save() + self.assertEqual(sum(payment_register.mapped('payment_invoice_ids.difference')), -200.0) + payment_register.create_payments() payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") self.assertEqual(len(payment_ids), 1, 'Need only one payment.') self.assertEqual(payment_ids.amount, 400.0) - self.assertEqual(inv_1.residual_signed, 0.0) - self.assertEqual(inv_2.residual_signed, 0.0) + self.assertEqual(inv_1.amount_residual_signed, 0.0) + self.assertEqual(inv_2.amount_residual_signed, 0.0) def test_multiple_payments_partial_multi(self): """ Create test to pay several vendor bills/invoices at once """ - # One payment for inv_1 and inv_2 (same partner) + # One payment for inv_1 and inv_2 (different partners) inv_1 = self.create_invoice(amount=100, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) inv_2 = self.create_invoice(amount=500, currency_id=self.currency_eur_id, partner=self.partner_china_exp.id) ids = [inv_1.id, inv_2.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_in.id, - }) - register_payments.amount = 400.0 - register_payments.is_manual_disperse = True + payment_register = Form(self.register_payments_model.with_context(active_ids=ids)) + payment_register.journal_id = self.bank_journal_euro + payment_register.payment_date = time.strftime('%Y') + '-07-15' + payment_register.is_manual_disperse = True - for line in register_payments.invoice_line_ids: - if line.invoice_id == inv_1: - line.amount = 100.0 - if line.invoice_id == inv_2: - line.amount = 300.0 + with payment_register.payment_invoice_ids.edit(0) as f: + f.amount = 100.0 + f.save() + with payment_register.payment_invoice_ids.edit(1) as f: + f.amount = 300.0 + f.save() - register_payments.create_payments() + self.assertEqual(payment_register.amount, 400.0, 'Amount isn\'t the amount from lines') + + payment_register = payment_register.save() + payment_register.create_payments() payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") self.assertEqual(len(payment_ids), 2, 'Need two payments.') - # Useful for logging amounts of payments and their accounting - # for pay in payment_ids: - # _logger.warn(str(pay) + ' amount: ' + str(pay.amount)) - # for line in pay.move_line_ids: - # _logger.warn(' ' + - # str(line) + ' name: ' + str(line.name) + ' :: credit: ' + str(line.credit) + ' debit: ' + - # str(line.debit)) self.assertEqual(sum(payment_ids.mapped('amount')), 400.0) - self.assertEqual(inv_1.residual_signed, 0.0) - self.assertEqual(inv_2.residual_signed, 200.0) + self.assertEqual(inv_1.amount_residual_signed, 0.0) + self.assertEqual(inv_2.amount_residual_signed, 200.0) def test_vendor_multiple_payments_write_off(self): inv_1 = self.create_invoice(amount=100, type='in_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) inv_2 = self.create_invoice(amount=500, type='in_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id) ids = [inv_1.id, inv_2.id] - register_payments = self.register_payments_model.with_context(active_ids=ids).create({ - 'payment_date': time.strftime('%Y') + '-07-15', - 'journal_id': self.bank_journal_euro.id, - 'payment_method_id': self.payment_method_manual_out.id, - }) - register_payments.amount = 400.0 - register_payments.is_manual_disperse = True - register_payments.writeoff_journal_id = inv_1.journal_id + payment_register = Form(self.register_payments_model.with_context(active_ids=ids)) + payment_register.journal_id = self.bank_journal_euro + payment_register.payment_date = time.strftime('%Y') + '-07-15' + payment_register.is_manual_disperse = True + + with payment_register.payment_invoice_ids.edit(0) as f: + f.amount = 100.0 + f.save() + with payment_register.payment_invoice_ids.edit(1) as f: + f.amount = 300.0 + f.save() + + self.assertEqual(payment_register.amount, 400.0) + + # Cannot have close balance in form because it becomes required + payment_register = payment_register.save() + payment_register.action_toggle_close_balance() with self.assertRaises(ValidationError): - register_payments.create_payments() + payment_register.create_payments() - for line in register_payments.invoice_line_ids: - if line.invoice_id == inv_1: - line.amount = 100.0 - if line.invoice_id == inv_2: - line.amount = 300.0 - line.writeoff_acc_id = self.account_revenue - - register_payments.create_payments() + # Need the writeoff account + payment_register.writeoff_account_id = self.transfer_account + payment_register.create_payments() payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") self.assertEqual(len(payment_ids), 1, 'Need only one payment.') self.assertEqual(payment_ids.amount, 400.0) - self.assertEqual(inv_1.residual_signed, 0.0) - self.assertEqual(inv_2.residual_signed, 0.0) + self.assertEqual(inv_1.amount_residual_signed, 0.0) + self.assertEqual(inv_2.amount_residual_signed, 0.0) diff --git a/account_payment_disperse/wizard/__init__.py b/account_payment_disperse/wizard/__init__.py index 8b631b8b..196910d6 100644 --- a/account_payment_disperse/wizard/__init__.py +++ b/account_payment_disperse/wizard/__init__.py @@ -1 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from . import register_payment_wizard diff --git a/account_payment_disperse/wizard/register_payment_wizard.py b/account_payment_disperse/wizard/register_payment_wizard.py index 3153045a..e2055a7c 100644 --- a/account_payment_disperse/wizard/register_payment_wizard.py +++ b/account_payment_disperse/wizard/register_payment_wizard.py @@ -1,97 +1,200 @@ -from odoo import api, fields, models +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from datetime import datetime +from odoo import api, fields, models, _ from odoo.exceptions import ValidationError +from odoo.addons.account.models.account_payment import MAP_INVOICE_TYPE_PARTNER_TYPE class AccountRegisterPayments(models.TransientModel): - _inherit = 'account.register.payments' + _inherit = 'account.payment.register' is_manual_disperse = fields.Boolean(string='Disperse Manually') - invoice_line_ids = fields.One2many('account.register.payments.invoice.line', 'wizard_id', string='Invoices') - writeoff_journal_id = fields.Many2one('account.journal', string='Write-off Journal') + payment_invoice_ids = fields.One2many('account.payment.register.payment_invoice', 'wizard_id', string='Invoices') + writeoff_account_id = fields.Many2one('account.account', string="Difference Account", domain="[('deprecated', '=', False)]", copy=False) + requires_writeoff_account = fields.Boolean(compute='_compute_requires_writeoff_account') + amount = fields.Float(string='Amount', compute='_compute_amount') due_date_cutoff = fields.Date(string='Due Date Cutoff', default=fields.Date.today) + due_date_behavior = fields.Selection([ + ('due', 'Due'), + ('next_due', 'Next Due'), + ], string='Due Date Behavior', default='due') @api.model def default_get(self, fields): rec = super(AccountRegisterPayments, self).default_get(fields) if 'invoice_ids' in rec: invoice_ids = rec['invoice_ids'][0][2] - rec['invoice_line_ids'] = [(0, 0, {'invoice_id': i, 'amount': 0.0}) for i in invoice_ids] + rec['payment_invoice_ids'] = [(0, 0, {'invoice_id': i}) for i in invoice_ids] return rec - @api.multi def create_payments(self): + for payment in self.filtered(lambda p: not p.amount and p.invoice_ids): + payment._compute_amount() for payment in self.filtered(lambda p: p.is_manual_disperse): - line_amount = sum(payment.invoice_line_ids.mapped('amount')) + line_amount = sum(payment.payment_invoice_ids.mapped('amount')) if abs(line_amount - payment.amount) >= 0.01: raise ValidationError('Cannot pay for %0.2f worth of invoices with %0.2f total.' % (line_amount, payment.amount)) - if not payment.writeoff_journal_id and payment.invoice_line_ids.filtered(lambda l: l.writeoff_acc_id): - raise ValidationError('Cannot write off without a write off journal.') + if not payment.writeoff_account_id and payment.payment_invoice_ids.filtered(lambda l: l.close_balance and l.difference): + raise ValidationError('Closing balance will require a difference account.') new_self = self.with_context(payment_wizard_id=self.id) return super(AccountRegisterPayments, new_self).create_payments() - @api.multi + @api.onchange('is_manual_disperse') + def _ensure_is_manual_disperse(self): + for payment in self: + payment.group_payment = payment.is_manual_disperse + + @api.depends('payment_invoice_ids.amount', 'invoice_ids', 'journal_id', 'payment_date', 'is_manual_disperse') + def _compute_amount(self): + for payment in self.filtered(lambda p: p.is_manual_disperse): + payment.amount = sum(payment.mapped('payment_invoice_ids.amount') or [0.0]) + for payment in self.filtered(lambda p: not p.is_manual_disperse): + if payment.invoice_ids: + payment.amount = self.env['account.payment']._compute_payment_amount( + payment.invoice_ids, + payment.invoice_ids[0].currency_id, + payment.journal_id, + payment.payment_date) + else: + payment.amount = 0.0 + + @api.depends('payment_invoice_ids.difference', 'payment_invoice_ids.close_balance') + def _compute_requires_writeoff_account(self): + for payment in self: + if payment.is_manual_disperse: + payment.requires_writeoff_account = bool(payment.payment_invoice_ids.filtered( + lambda l: l.difference and l.close_balance)) + else: + payment.requires_writeoff_account = False + + def _prepare_payment_vals(self, invoices): + '''Create the payment values. + + :param invoices: The invoices/bills to pay. In case of multiple + documents, they need to be grouped by partner, bank, journal and + currency. + :return: The payment values as a dictionary. + ''' + if self.is_manual_disperse: + # this will already be positive for both invoice types unlink the below amount (abs) + amount = sum(self.payment_invoice_ids.filtered(lambda l: l.invoice_id in invoices).mapped('amount')) + sign = 1.0 + if invoices: + invoice = invoices[0] + sign = 1.0 if invoice.type in ('out_invoice', 'out_refund') else -1.0 + else: + amount = self.env['account.payment']._compute_payment_amount(invoices, invoices[0].currency_id, + self.journal_id, self.payment_date) + sign = 1.0 if amount > 0.0 else -1.0 + values = { + 'journal_id': self.journal_id.id, + 'payment_method_id': self.payment_method_id.id, + 'payment_date': self.payment_date, + 'communication': self._prepare_communication(invoices), + 'invoice_ids': [(6, 0, invoices.ids)], + 'payment_type': 'inbound' if sign > 0 else 'outbound', + 'amount': abs(amount), + 'currency_id': invoices[0].currency_id.id, + 'partner_id': invoices[0].commercial_partner_id.id, + 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type], + 'partner_bank_account_id': invoices[0].invoice_partner_bank_id.id, + 'writeoff_account_id': self.writeoff_account_id.id, + } + return values + def action_fill_residual(self): for payment in self: - for line in payment.invoice_line_ids: + for line in payment.payment_invoice_ids: line.amount = line.residual - action = self.env.ref('account.action_account_payment_from_invoices').read()[0] - action['res_id'] = payment.id - return action + return payment._reopen_action() - @api.multi def action_fill_residual_due(self): for payment in self: - for line in payment.invoice_line_ids: + for line in payment.payment_invoice_ids: line.amount = line.residual_due - action = self.env.ref('account.action_account_payment_from_invoices').read()[0] - action['res_id'] = payment.id - return action + return payment._reopen_action() + + def action_toggle_close_balance(self): + for payment in self: + for line in payment.payment_invoice_ids: + line.close_balance = not line.close_balance + return payment._reopen_action() + + def _reopen_action(self): + return { + 'name': _('Register Payment'), + 'res_model': 'account.payment.register', + 'view_mode': 'form', + 'view_id': self.env.ref('account.view_account_payment_form_multi').id, + 'context': self.env.context, + 'target': 'new', + 'res_id': self.id, + 'type': 'ir.actions.act_window', + } class AccountRegisterPaymentsInvoiceLine(models.TransientModel): - _name = 'account.register.payments.invoice.line' + _name = 'account.payment.register.payment_invoice' - wizard_id = fields.Many2one('account.register.payments') - invoice_id = fields.Many2one('account.invoice', string='Invoice', required=True) - partner_id = fields.Many2one('res.partner', string='Partner', compute='_compute_balances', compute_sudo=True) - residual = fields.Float(string='Remaining', compute='_compute_balances', compute_sudo=True) - residual_due = fields.Float(string='Due', compute='_compute_balances', compute_sudo=True) - difference = fields.Float(string='Difference', default=0.0) + wizard_id = fields.Many2one('account.payment.register') + invoice_id = fields.Many2one('account.move', string='Invoice', required=True) + partner_id = fields.Many2one('res.partner', string='Partner', compute='_compute_invoice_balances', compute_sudo=True) + residual = fields.Float(string='Remaining', compute='_compute_invoice_balances', compute_sudo=True) + residual_due = fields.Float(string='Due', compute='_compute_invoice_balances', compute_sudo=True) + difference = fields.Float(string='Difference', compute='_compute_difference') amount = fields.Float(string='Amount') - writeoff_acc_id = fields.Many2one('account.account', string='Write-off Account') + close_balance = fields.Boolean(string='Close Balance', help='Write off remaining balance.') - @api.depends('invoice_id', 'wizard_id.due_date_cutoff', 'invoice_id.partner_id') - def _compute_balances(self): + @api.depends('invoice_id', 'wizard_id.due_date_cutoff', 'wizard_id.due_date_behavior', 'invoice_id.partner_id') + def _compute_invoice_balances(self): + dummy_date = datetime(1980, 1, 1) for line in self: - # Bug in the ORM 12.0? The invoice is set, but there is no residual - # on anything other than the first invoice/line processed. invoice = line.invoice_id.browse(line.invoice_id.id) - residual = invoice.residual + sign = 1.0 if invoice.type in ('out_invoice', 'out_refund') else -1.0 + residual = sign * invoice.amount_residual_signed cutoff_date = line.wizard_id.due_date_cutoff + due_behavior = line.wizard_id.due_date_behavior total_amount = 0.0 total_reconciled = 0.0 - for move_line in invoice.move_id.line_ids.filtered(lambda r: ( - not r.reconciled - and r.account_id.internal_type in ('payable', 'receivable') - and r.date_maturity <= cutoff_date - )): - amount = abs(move_line.debit - move_line.credit) - total_amount += amount - for partial_line in move_line.matched_debit_ids: - total_reconciled += partial_line.amount - for partial_line in move_line.matched_credit_ids: - total_reconciled += partial_line.amount + # TODO partial reconcile will need sign check + if due_behavior == 'due': + for move_line in invoice.line_ids.filtered(lambda r: ( + not r.reconciled + and r.account_id.internal_type in ('payable', 'receivable') + and r.date_maturity <= cutoff_date + )): + amount = move_line.debit - move_line.credit + total_amount += amount + for partial_line in move_line.matched_debit_ids: + total_reconciled += partial_line.amount + for partial_line in move_line.matched_credit_ids: + total_reconciled += partial_line.amount + else: + move_lines = invoice.line_ids.filtered(lambda r: ( + not r.reconciled + and r.account_id.internal_type in ('payable', 'receivable') + )).sorted(key=lambda r: r.date_maturity or dummy_date) + if move_lines: + move_line = move_lines[0] + amount = move_line.debit - move_line.credit + total_amount += amount + for partial_line in move_line.matched_debit_ids: + total_reconciled += partial_line.amount + for partial_line in move_line.matched_credit_ids: + total_reconciled += partial_line.amount values = { 'residual': residual, - 'residual_due': total_amount - total_reconciled, - 'difference': residual - (line.amount or 0.0), + 'residual_due': sign * (total_amount - total_reconciled), + # 'difference': sign * ((line.amount or 0.0) - residual), + 'difference': (line.amount or 0.0) - residual, 'partner_id': invoice.partner_id.id, } line.update(values) - @api.onchange('amount') - def _onchange_amount(self): + @api.depends('amount', 'residual', 'residual_due', 'invoice_id') + def _compute_difference(self): for line in self: - line.difference = line.residual - line.amount + line.difference = line.amount - line.residual diff --git a/account_payment_disperse/wizard/register_payment_wizard_views.xml b/account_payment_disperse/wizard/register_payment_wizard_views.xml index 3e428048..0371539c 100644 --- a/account_payment_disperse/wizard/register_payment_wizard_views.xml +++ b/account_payment_disperse/wizard/register_payment_wizard_views.xml @@ -1,28 +1,17 @@ - - account.register.payments.wizard.inherited - account.register.payments - + + account.payment.form.multi.inherit + account.payment.register + - - {'readonly': [('multi', '=', True), ('is_manual_disperse', '!=', True)]} - - + - - - {'invisible': ['|', ('payment_difference', '=', 0.0), ('is_manual_disperse', '=', True)]} + - -