diff --git a/account_payment_disperse/__init__.py b/account_payment_disperse/__init__.py new file mode 100644 index 00000000..9b429614 --- /dev/null +++ b/account_payment_disperse/__init__.py @@ -0,0 +1,2 @@ +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..cb5c5463 --- /dev/null +++ b/account_payment_disperse/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Payment Disperse', + 'version': '11.0.1.0.0', + 'author': 'Hibou Corp. ', + '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_payment', + ], + '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..33f37402 --- /dev/null +++ b/account_payment_disperse/models/__init__.py @@ -0,0 +1 @@ +from . import account \ No newline at end of file diff --git a/account_payment_disperse/models/account.py b/account_payment_disperse/models/account.py new file mode 100644 index 00000000..896ee9e2 --- /dev/null +++ b/account_payment_disperse/models/account.py @@ -0,0 +1,66 @@ +from odoo import api, fields, 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: + return self._create_payment_entry_manual_disperse( + -sum(wizard.invoice_line_ids.filtered(lambda p: p.partner_id == self.partner_id).mapped('amount')), + wizard) + + return super(AccountPayment, self)._create_payment_entry(amount) + + def _create_payment_entry_manual_disperse(self, amount, wizard): + 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) + invoice_currency = False + if self.invoice_ids and all([x.currency_id == self.invoice_ids[0].currency_id for x in self.invoice_ids]): + # if all the invoices selected share the same currency, record the paiement in that currency too + invoice_currency = self.invoice_ids[0].currency_id + 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, invoice_currency) + + 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): + 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, + invoice_currency) + 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) + # capture writeoff account etc. + 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)) + + # Create Payment side (payment journal default accounts) + 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)) + aml_obj.create(liquidity_aml_dict) + + # validate the payment + move.post() + + # reconcile the invoice receivable/payable line(s) with the payment + for inv_lines, writeoff_acc_id in inv_lines: + # _logger.warn('pair: ') + # for l in inv_lines: + # _logger.warn(' ' + str(l) + ' credit: ' + str(l.credit) + ' debit: ' + str(l.debit)) + inv_lines.reconcile(writeoff_acc_id, wizard.writeoff_journal_id) + + return move diff --git a/account_payment_disperse/tests/__init__.py b/account_payment_disperse/tests/__init__.py new file mode 100644 index 00000000..de54650c --- /dev/null +++ b/account_payment_disperse/tests/__init__.py @@ -0,0 +1 @@ +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..d62c8c05 --- /dev/null +++ b/account_payment_disperse/tests/test_payment_multi.py @@ -0,0 +1,124 @@ +from odoo.addons.account.tests.test_payment import TestPayment +from odoo.exceptions import ValidationError +import time + + +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] + 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 + + with self.assertRaises(ValidationError): + register_payments.create_payments() + + 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 + + register_payments.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) + + 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 + + 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) + + 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 + + with self.assertRaises(ValidationError): + register_payments.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() + + 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) + + 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) + 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 + + 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 + + register_payments.create_payments() + + payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc") + self.assertEqual(len(payment_ids), 2, 'Need two payments.') + # 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) diff --git a/account_payment_disperse/wizard/__init__.py b/account_payment_disperse/wizard/__init__.py new file mode 100644 index 00000000..8b631b8b --- /dev/null +++ b/account_payment_disperse/wizard/__init__.py @@ -0,0 +1 @@ +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..c81cd47a --- /dev/null +++ b/account_payment_disperse/wizard/register_payment_wizard.py @@ -0,0 +1,87 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class AccountRegisterPayments(models.TransientModel): + _inherit = 'account.register.payments' + + 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') + due_date_cutoff = fields.Date(string='Due Date Cutoff', default=fields.Date.today) + + @api.model + def default_get(self, fields): + rec = super(AccountRegisterPayments, self).default_get(fields) + invoice_ids = rec['invoice_ids'][0][2] + rec['invoice_line_ids'] = [(0, 0, {'invoice_id': i, 'amount': 0.0}) for i in invoice_ids] + return rec + + @api.multi + def create_payments(self): + for payment in self.filtered(lambda p: p.is_manual_disperse): + line_amount = sum(payment.invoice_line_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.') + new_self = self.with_context(payment_wizard_id=self.id) + return super(AccountRegisterPayments, new_self).create_payments() + + @api.multi + def action_fill_residual(self): + for payment in self: + for line in payment.invoice_line_ids: + line.amount = line.residual + action = self.env.ref('account.action_account_payment_from_invoices').read()[0] + action['res_id'] = payment.id + return action + + @api.multi + def action_fill_residual_due(self): + for payment in self: + for line in payment.invoice_line_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 + + +class AccountRegisterPaymentsInvoiceLine(models.TransientModel): + _name = 'account.register.payments.invoice.line' + + 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') + residual = fields.Float(string='Remaining', compute='_compute_balances') + residual_due = fields.Float(string='Due', compute='_compute_balances') + difference = fields.Float(string='Difference', default=0.0) + amount = fields.Float(string='Amount') + writeoff_acc_id = fields.Many2one('account.account', string='Write-off Account') + + @api.depends('invoice_id.residual', 'wizard_id.due_date_cutoff', 'invoice_id.partner_id') + def _compute_balances(self): + for line in self: + line.residual = line.invoice_id.residual + + cutoff_date = line.wizard_id.due_date_cutoff + total_amount = 0.0 + total_reconciled = 0.0 + for move_line in line.invoice_id.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 + move_line.matched_credit_ids): + total_reconciled += partial_line.amount + line.residual_due = total_amount - total_reconciled + line.difference = line.residual - line.amount + line.partner_id = line.invoice_id.partner_id + + @api.onchange('amount') + def _onchange_amount(self): + for line in self: + line.difference = line.residual - line.amount 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..0a0c3da9 --- /dev/null +++ b/account_payment_disperse/wizard/register_payment_wizard_views.xml @@ -0,0 +1,45 @@ + + + + account.register.payments.wizard.inherited + account.register.payments + + + + {'readonly': [('multi', '=', True), ('is_manual_disperse', '!=', True)]} + + + + + + + +