diff --git a/account_payment_disperse/README.rst b/account_payment_disperse/README.rst new file mode 100644 index 00000000..756a14fa --- /dev/null +++ b/account_payment_disperse/README.rst @@ -0,0 +1,39 @@ +********************************** +Hibou - Manually Disperse Payments +********************************** + +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. + +For more information and add-ons, visit `Hibou.io `_. + + +============= +Main Features +============= + +* Ability to disperse payment manually. +* 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 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. + +.. image:: https://user-images.githubusercontent.com/15882954/39149575-62a0a1d6-46f4-11e8-8e59-b315cf8f9277.png + :alt: 'Register Payment Detail' + :width: 988 + :align: left + + +======= +License +======= + +Please see `LICENSE `_. + +Copyright Hibou Corp. 2021 + diff --git a/account_payment_disperse/__init__.py b/account_payment_disperse/__init__.py new file mode 100644 index 00000000..c7120225 --- /dev/null +++ b/account_payment_disperse/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000..27272533 --- /dev/null +++ b/account_payment_disperse/__manifest__.py @@ -0,0 +1,22 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'Payment Disperse', + 'version': '13.0.1.0.0', + 'author': 'Hibou Corp.', + 'license': 'OPL-1', + 'category': 'Accounting', + 'summary': 'Pay multiple invoices with one Payment', + 'description': """ +Pay multiple invoices with one Payment, and manually disperse the amount per invoice. +""", + 'website': 'https://hibou.io/', + 'depends': [ + 'account', + ], + 'data': [ + 'wizard/register_payment_wizard_views.xml', + ], + 'installable': True, + 'auto_install': False, +} diff --git a/account_payment_disperse/models/__init__.py b/account_payment_disperse/models/__init__.py new file mode 100644 index 00000000..e62b0928 --- /dev/null +++ b/account_payment_disperse/models/__init__.py @@ -0,0 +1,4 @@ +# 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 new file mode 100644 index 00000000..08dd58d8 --- /dev/null +++ b/account_payment_disperse/models/account.py @@ -0,0 +1,19 @@ +# 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 _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 new file mode 100644 index 00000000..62f85f93 --- /dev/null +++ b/account_payment_disperse/tests/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000..ab2b75a4 --- /dev/null +++ b/account_payment_disperse/tests/test_payment_multi.py @@ -0,0 +1,163 @@ +# 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): + """ Create test to pay several vendor bills/invoices at once """ + # One payment for inv_1 and inv_2 (same partner) + 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] + 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 = 99.0 + f.save() + with payment_register.payment_invoice_ids.edit(1) as f: + f.amount = 300.0 + f.save() + + self.assertEqual(payment_register.amount, 399.0, 'Amount isn\'t the amount from lines') + + # 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.amount_residual_signed, 1.0) + self.assertEqual(inv_2.amount_residual_signed, 200.0) + + 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 = 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] + 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 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() + + self.assertEqual(payment_register.amount, 400.0, 'Amount isn\'t the amount from lines') + + 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.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 (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] + 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, '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.') + self.assertEqual(sum(payment_ids.mapped('amount')), 400.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] + 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): + payment_register.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.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 new file mode 100644 index 00000000..196910d6 --- /dev/null +++ b/account_payment_disperse/wizard/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000..6622d1fa --- /dev/null +++ b/account_payment_disperse/wizard/register_payment_wizard.py @@ -0,0 +1,200 @@ +# 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.payment.register' + + is_manual_disperse = fields.Boolean(string='Disperse Manually') + 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['payment_invoice_ids'] = [(0, 0, {'invoice_id': i}) for i in invoice_ids] + return rec + + 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.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_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.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.payment_invoice_ids: + line.amount = line.residual + return payment._reopen_action() + + def action_fill_residual_due(self): + for payment in self: + for line in payment.payment_invoice_ids: + line.amount = line.residual_due + 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.payment.register.payment_invoice' + + 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') + close_balance = fields.Boolean(string='Close Balance', help='Write off remaining balance.') + + @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: + invoice = line.invoice_id.browse(line.invoice_id.id) + 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 + # 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 (not r.date_maturity or 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': 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.depends('amount', 'residual', 'residual_due', 'invoice_id') + def _compute_difference(self): + for line in self: + 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 new file mode 100644 index 00000000..0371539c --- /dev/null +++ b/account_payment_disperse/wizard/register_payment_wizard_views.xml @@ -0,0 +1,46 @@ + + + + account.payment.form.multi.inherit + account.payment.register + + + + + + + + + + + + + + + + + + + + + + + + + + + +