diff --git a/account_payment_netting/__init__.py b/account_payment_netting/__init__.py new file mode 100644 index 000000000..13bd5e861 --- /dev/null +++ b/account_payment_netting/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from . import models diff --git a/account_payment_netting/__manifest__.py b/account_payment_netting/__manifest__.py new file mode 100644 index 000000000..d09df369a --- /dev/null +++ b/account_payment_netting/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +{ + 'name': 'Account Payment Netting', + 'version': '12.0.1.0.0', + 'summary': 'Net Payment on AR/AP invoice from the same partner', + 'category': 'Accounting & Finance', + 'author': 'Ecosoft, ' + 'Odoo Community Association (OCA)', + 'license': 'AGPL-3', + 'website': 'https://github.com/OCA/account-financial-tools/', + 'depends': [ + 'account', + ], + 'data': [ + 'views/account_invoice_view.xml', + 'views/account_payment_view.xml', + ], + 'installable': True, + 'development_status': 'beta', + 'maintainers': ['kittiu'], +} diff --git a/account_payment_netting/models/__init__.py b/account_payment_netting/models/__init__.py new file mode 100644 index 000000000..d948651cd --- /dev/null +++ b/account_payment_netting/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from . import account_payment +from . import account_invoice diff --git a/account_payment_netting/models/account_invoice.py b/account_payment_netting/models/account_invoice.py new file mode 100644 index 000000000..22fcac305 --- /dev/null +++ b/account_payment_netting/models/account_invoice.py @@ -0,0 +1,109 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from odoo import models, api, fields + + +class AccountInvoice(models.Model): + _inherit = 'account.invoice' + + unpaid_move_lines = fields.One2many( + comodel_name='account.move.line', + compute='_compute_unpaid_move_lines', + help="Compute unpaid AR/AP move lines of this invoice", + ) + + @api.multi + def _compute_unpaid_move_lines(self): + for inv in self: + inv.unpaid_move_lines = inv.move_id.line_ids.filtered( + lambda r: not r.reconciled + and r.account_id.internal_type in ('payable', 'receivable')) + + @api.model + def _get_netting_groups(self, account_groups): + debtors = [] + creditors = [] + total_debtors = 0 + total_creditors = 0 + for account_group in account_groups: + balance = account_group['debit'] - account_group['credit'] + group_vals = { + 'account_id': account_group['account_id'][0], + 'balance': abs(balance), + } + if balance > 0: + debtors.append(group_vals) + total_debtors += balance + else: + creditors.append(group_vals) + total_creditors += abs(balance) + return (debtors, total_debtors, creditors, total_creditors) + + @api.model + def _get_netting_move_lines(self, payment_line, partner, + debtors, total_debtors, + creditors, total_creditors): + netting_amount = min(total_creditors, total_debtors) + field_map = {1: 'debit', 0: 'credit'} + move_lines = [] + for i, group in enumerate([debtors, creditors]): + available_amount = netting_amount + for account_group in group: + if account_group['balance'] > available_amount: + amount = available_amount + else: + amount = account_group['balance'] + move_line_vals = { + field_map[i]: amount, + 'partner_id': partner.id, + 'name': payment_line.move_id.ref, + 'account_id': account_group['account_id'], + 'payment_id': payment_line.payment_id.id, + } + move_lines.append((0, 0, move_line_vals)) + available_amount -= account_group['balance'] + if available_amount <= 0: + break + return move_lines + + @api.multi + def register_payment(self, payment_line, writeoff_acc_id=False, + writeoff_journal_id=False): + """ Attempt to reconcile netting first, + and leave the remaining for normal reconcile """ + if not payment_line.payment_id.netting: + return super().register_payment( + payment_line, writeoff_acc_id=writeoff_acc_id, + writeoff_journal_id=writeoff_journal_id) + # Case netting payment: + # 1. create netting lines dr/cr + # 2. do initial reconcile + line_to_netting = self.mapped('unpaid_move_lines') + payment_move = payment_line.move_id + # Group amounts by account + account_groups = line_to_netting.read_group( + [('id', 'in', line_to_netting.ids)], + ['account_id', 'debit', 'credit'], + ['account_id'], + ) + (debtors, total_debtors, creditors, total_creditors) = \ + self._get_netting_groups(account_groups) + # Create move lines + move_lines = self._get_netting_move_lines( + payment_line, line_to_netting[0].partner_id, + debtors, total_debtors, creditors, total_creditors) + if move_lines: + payment_move.write({'line_ids': move_lines}) + # Make reconciliation + for move_line in payment_move.line_ids: + if move_line == payment_line: # Keep this for super() + continue + to_reconcile = move_line + line_to_netting.filtered( + lambda x: x.account_id == move_line.account_id) + to_reconcile.filtered('account_id.reconcile').\ + filtered(lambda r: not r.reconciled).reconcile() + return super().register_payment( + payment_line.filtered(lambda l: not l.reconciled), + writeoff_acc_id=writeoff_acc_id, + writeoff_journal_id=writeoff_journal_id) diff --git a/account_payment_netting/models/account_payment.py b/account_payment_netting/models/account_payment.py new file mode 100644 index 000000000..f1c55eea8 --- /dev/null +++ b/account_payment_netting/models/account_payment.py @@ -0,0 +1,101 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class AccountAbstractPayment(models.AbstractModel): + _inherit = 'account.abstract.payment' + + netting = fields.Boolean( + string='Netting', + help="Technical field, as user select invoice that are both AR and AP", + ) + + @api.model + def default_get(self, fields): + rec = super().default_get(fields) + if not rec.get('multi'): + return rec + active_ids = self._context.get('active_ids') + invoices = self.env['account.invoice'].browse(active_ids) + types = invoices.mapped('type') + ap = any(set(['in_invoice', 'in_refund']).intersection(types)) + ar = any(set(['out_invoice', 'out_refund']).intersection(types)) + if ap and ar: # Both AP and AR -> Netting + rec.update({'netting': True, + 'multi': False, # With netting, allow edit amount + 'communication': ', '.join(invoices.mapped('number')), + }) + return rec + + def _compute_journal_domain_and_types(self): + if not self.netting: + return super()._compute_journal_domain_and_types() + # For case netting, it is possible to have net amount = 0.0 + # without forcing new journal type and payment diff handling + domain = [] + if self.payment_type == 'inbound': + domain.append(('at_least_one_inbound', '=', True)) + else: + domain.append(('at_least_one_outbound', '=', True)) + return {'domain': domain, 'journal_types': set(['bank', 'cash'])} + + +class AccountRegisterPayments(models.TransientModel): + _inherit = 'account.register.payments' + + @api.multi + def get_payments_vals(self): + """ When doing netting, combine all invoices """ + if self.netting: + return [self._prepare_payment_vals(self.invoice_ids)] + return super().get_payments_vals() + + @api.multi + def _prepare_payment_vals(self, invoices): + """ When doing netting, partner_type follow payment type """ + values = super()._prepare_payment_vals(invoices) + if self.netting: + values['netting'] = self.netting + values['communication'] = self.communication + if self.payment_type == 'inbound': + values['partner_type'] = 'customer' + elif self.payment_type == 'outbound': + values['partner_type'] = 'supplier' + return values + + @api.multi + def create_payments(self): + if self.netting: + self._validate_invoice_netting(self.invoice_ids) + return super().create_payments() + + @api.model + def _validate_invoice_netting(self, invoices): + """ Ensure valid selection of invoice for netting process """ + # All invoice must be of the same partner + if len(invoices.mapped('commercial_partner_id')) > 1: + raise UserError(_('All invoices must belong to same partner')) + # All invoice must have residual + paid_invoices = invoices.filtered(lambda l: not l.residual) + if paid_invoices: + raise UserError(_('Some selected invoices are already paid: %s') % + paid_invoices.mapped('number')) + + +class AccountPayments(models.Model): + _inherit = 'account.payment' + + @api.one + @api.depends('invoice_ids', 'payment_type', 'partner_type', 'partner_id') + def _compute_destination_account_id(self): + super()._compute_destination_account_id() + if self.netting: + if self.partner_type == 'customer': + self.destination_account_id = \ + self.partner_id.property_account_receivable_id.id + else: + self.destination_account_id = \ + self.partner_id.property_account_payable_id.id diff --git a/account_payment_netting/readme/CONTRIBUTORS.rst b/account_payment_netting/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..033e67f43 --- /dev/null +++ b/account_payment_netting/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Kitti Upariphutthiphong diff --git a/account_payment_netting/readme/DESCRIPTION.rst b/account_payment_netting/readme/DESCRIPTION.rst new file mode 100644 index 000000000..2daf0affd --- /dev/null +++ b/account_payment_netting/readme/DESCRIPTION.rst @@ -0,0 +1,7 @@ +This module allow net payment on AR/AP invoice from the same business partner. + +**NOTE**: This module is influenced by account_netting, +but make it more user friendly when netting invoices. +While account netting require user to select manually the journal items to do netting +(which create netting journal entry), this module has a new menu "Invoices to netting" +allowing user to select both customer/supplier invoice to register payment. diff --git a/account_payment_netting/readme/USAGE.rst b/account_payment_netting/readme/USAGE.rst new file mode 100644 index 000000000..4bd9c9986 --- /dev/null +++ b/account_payment_netting/readme/USAGE.rst @@ -0,0 +1,9 @@ +Given there are open invoices both receivable and payable, +and user decide to make payment on the diff. + +- Open menu Accounting > Invoices to Netting +- Select multiple open invoices from the same partner +- Click on action "Register Payment", the wizard will show the diff amount +- Make payment as normal + +This create Customer Payment if AR > AP, Supplier Payment otherwise. diff --git a/account_payment_netting/static/description/icon.png b/account_payment_netting/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/account_payment_netting/static/description/icon.png differ diff --git a/account_payment_netting/static/description/index.html b/account_payment_netting/static/description/index.html new file mode 100644 index 000000000..48ad04ff5 --- /dev/null +++ b/account_payment_netting/static/description/index.html @@ -0,0 +1,437 @@ + + + + + + +Account Payment Netting + + + +
+

Account Payment Netting

+ + +

Beta License: AGPL-3 OCA/account-financial-tools Translate me on Weblate Try me on Runbot

+

This module allow net payment on AR/AP invoice from the same business partner.

+

NOTE: This module is influenced by account_netting, +but make it more user friendly when netting invoices. +While account netting require user to select manually the journal items to do netting +(which create netting journal entry), this module has a new menu “Invoices to netting” +allowing user to select both customer/supplier invoice to register payment.

+

Table of contents

+ +
+

Usage

+

Given there are open invoices both receivable and payable, +and user decide to make payment on the diff.

+
    +
  • Open menu Accounting > Invoices to Netting
  • +
  • Select multiple open invoices from the same partner
  • +
  • Click on action “Register Payment”, the wizard will show the diff amount
  • +
  • Make payment as normal
  • +
+

This create Customer Payment if AR > AP, Supplier Payment otherwise.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ecosoft
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/account-financial-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/account_payment_netting/tests/__init__.py b/account_payment_netting/tests/__init__.py new file mode 100644 index 000000000..873c84a91 --- /dev/null +++ b/account_payment_netting/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2019 Ecosoft Co., Ltd. +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from . import test_account_payment_netting diff --git a/account_payment_netting/tests/test_account_payment_netting.py b/account_payment_netting/tests/test_account_payment_netting.py new file mode 100644 index 000000000..bb138d817 --- /dev/null +++ b/account_payment_netting/tests/test_account_payment_netting.py @@ -0,0 +1,179 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from odoo.tests.common import SavepointCase, Form +from odoo.exceptions import UserError + + +class TestAccountNetting(SavepointCase): + + @classmethod + def setUpClass(cls): + super(TestAccountNetting, cls).setUpClass() + cls.invoice_model = cls.env['account.invoice'] + cls.payment_model = cls.env['account.payment'] + cls.register_payment_model = cls.env['account.register.payments'] + cls.account_receivable = cls.env['account.account'].create({ + 'code': 'AR', + 'name': 'Account Receivable', + 'user_type_id': cls.env.ref( + 'account.data_account_type_receivable').id, + 'reconcile': True, + }) + cls.account_payable = cls.env['account.account'].create({ + 'code': 'AP', + 'name': 'Account Payable', + 'user_type_id': cls.env.ref( + 'account.data_account_type_payable').id, + 'reconcile': True, + }) + cls.account_revenue = cls.env['account.account'].search([ + ('user_type_id', '=', cls.env.ref( + 'account.data_account_type_revenue').id) + ], limit=1) + cls.account_expense = cls.env['account.account'].search([ + ('user_type_id', '=', cls.env.ref( + 'account.data_account_type_expenses').id) + ], limit=1) + cls.partner1 = cls.env['res.partner'].create({ + 'supplier': True, + 'customer': True, + 'name': 'Supplier/Customer 1', + 'property_account_receivable_id': cls.account_receivable.id, + 'property_account_payable_id': cls.account_payable.id, + }) + cls.partner2 = cls.env['res.partner'].create({ + 'supplier': True, + 'customer': True, + 'name': 'Supplier/Customer 2', + 'property_account_receivable_id': cls.account_receivable.id, + 'property_account_payable_id': cls.account_payable.id, + }) + + cls.sale_journal = cls.env['account.journal'].create({ + 'name': 'Test sale journal', + 'type': 'sale', + 'code': 'INV', + }) + cls.purchase_journal = cls.env['account.journal'].create({ + 'name': 'Test expense journal', + 'type': 'purchase', + 'code': 'BIL', + }) + cls.bank_journal = cls.env['account.journal'].create({ + 'name': 'Test bank journal', + 'type': 'bank', + 'code': 'BNK', + }) + cls.bank_journal.inbound_payment_method_ids |= cls.env.ref( + 'account.account_payment_method_manual_in') + cls.bank_journal.outbound_payment_method_ids |= cls.env.ref( + 'account.account_payment_method_manual_out') + + def create_invoice(self, inv_type, partner, amount): + """ Returns an open invoice """ + journal = inv_type == 'in_invoice' and \ + self.purchase_journal or self.sale_journal + arap_account = inv_type == 'in_invoice' and \ + self.account_payable or self.account_receivable + account = inv_type == 'in_invoice' and \ + self.account_expense or self.account_revenue + invoice = self.invoice_model.create({ + 'journal_id': journal.id, + 'type': inv_type, + 'partner_id': partner.id, + 'account_id': arap_account.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'Test', + 'price_unit': amount, + 'account_id': account.id, + })], + }) + return invoice + + def do_test_register_payment(self, invoices, expected_type, expected_diff): + """ Test create customer/supplier invoices. Then, select all invoices + and make neting payment. I expect: + - Payment Type (inbound or outbound) = expected_type + - Payment amont = expected_diff + - Payment can link to all invoices + - All 4 invoices are in paid status """ + # Select all invoices, and register payment + ctx = {'active_ids': invoices.ids, + 'active_model': 'account.invoice'} + view_id = 'account_payment_netting.view_account_payment_from_invoices' + with Form(self.register_payment_model.with_context(ctx), + view=view_id) as f: + f.journal_id = self.bank_journal + payment_wizard = f.save() + # Diff amount = expected_diff, payment_type = expected_type + self.assertEqual(payment_wizard.amount, expected_diff) + self.assertEqual(payment_wizard.payment_type, expected_type) + # Create payments + res = payment_wizard.create_payments() + payment = self.payment_model.browse(res['res_id']) + # Payment can link to all invoices + self.assertEqual(set(payment.invoice_ids.ids), set(invoices.ids)) + invoices = self.invoice_model.browse(invoices.ids) + # Test that all 4 invoices are paid + self.assertEqual(list(set(invoices.mapped('state'))), ['paid']) + + def test_1_payment_netting_neutral(self): + """ Test AR = AP """ + # Create 2 AR Invoice, total amount = 200.0 + ar_inv_p1_1 = self.create_invoice('out_invoice', self.partner1, 100.0) + ar_inv_p1_2 = self.create_invoice('out_invoice', self.partner1, 100.0) + # Create 2 AP Invoice, total amount = 200.0 + ap_inv_p1_1 = self.create_invoice('in_invoice', self.partner1, 100.0) + ap_inv_p1_2 = self.create_invoice('in_invoice', self.partner1, 100.0) + # Test Register Payment + invoices = ar_inv_p1_1 | ar_inv_p1_2 | ap_inv_p1_1 | ap_inv_p1_2 + invoices.action_invoice_open() + self.do_test_register_payment(invoices, 'outbound', 0.0) + + def test_2_payment_netting_inbound(self): + """ Test AR > AP """ + # Create 2 AR Invoice, total amount = 200.0 + ar_inv_p1_1 = self.create_invoice('out_invoice', self.partner1, 100.0) + ar_inv_p1_2 = self.create_invoice('out_invoice', self.partner1, 100.0) + # Create 2 AP Invoice, total amount = 160.0 + ap_inv_p1_1 = self.create_invoice('in_invoice', self.partner1, 80.0) + ap_inv_p1_2 = self.create_invoice('in_invoice', self.partner1, 80.0) + # Test Register Payment + invoices = ar_inv_p1_1 | ar_inv_p1_2 | ap_inv_p1_1 | ap_inv_p1_2 + invoices.action_invoice_open() + self.do_test_register_payment(invoices, 'inbound', 40.0) + + def test_3_payment_netting_outbound(self): + """ Test AR < AP """ + # Create 2 AR Invoice, total amount = 160.0 + ar_inv_p1_1 = self.create_invoice('out_invoice', self.partner1, 80.0) + ar_inv_p1_2 = self.create_invoice('out_invoice', self.partner1, 80.0) + # Create 2 AP Invoice, total amount = 200.0 + ap_inv_p1_1 = self.create_invoice('in_invoice', self.partner1, 100.0) + ap_inv_p1_2 = self.create_invoice('in_invoice', self.partner1, 100.0) + # Test Register Payment + invoices = ar_inv_p1_1 | ar_inv_p1_2 | ap_inv_p1_1 | ap_inv_p1_2 + invoices.action_invoice_open() + self.do_test_register_payment(invoices, 'outbound', 40.0) + + def test_4_payment_netting_for_one_invoice(self): + """ Test only 1 customer invoice, should also pass test """ + invoices = self.create_invoice('out_invoice', self.partner1, 80.0) + invoices.action_invoice_open() + self.do_test_register_payment(invoices, 'inbound', 80.0) + + def test_5_payment_netting_wrong_partner_exception(self): + """ Test when not invoices on same partner, show warning """ + # Create 2 AR Invoice, total amount = 160.0 + ar_inv_p1_1 = self.create_invoice('out_invoice', self.partner1, 80.0) + ar_inv_p1_2 = self.create_invoice('out_invoice', self.partner1, 80.0) + # Create 1 AP Invoice, amount = 200.0, using different partner 2 + ap_inv_p2 = self.create_invoice('in_invoice', self.partner2, 200.0) + # Test Register Payment + invoices = ar_inv_p1_1 | ar_inv_p1_2 | ap_inv_p2 + invoices.action_invoice_open() + with self.assertRaises(UserError) as e: + self.do_test_register_payment(invoices, 'outbound', 40.0) + self.assertEqual(e.exception.name, + 'All invoices must belong to same partner') diff --git a/account_payment_netting/views/account_invoice_view.xml b/account_payment_netting/views/account_invoice_view.xml new file mode 100644 index 000000000..7a4be0978 --- /dev/null +++ b/account_payment_netting/views/account_invoice_view.xml @@ -0,0 +1,38 @@ + + + + Invoices for Netting + account.invoice + form + tree,form + + [('state', '=', 'open')] + {'type':'out_invoice', 'journal_type': 'sale'} + + +

+ Create a customer invoice +

+ Create invoices, register payments and keep track of the discussions with your customers. +

+
+
+ + + + tree + + + + + + + form + + + + + + +
diff --git a/account_payment_netting/views/account_payment_view.xml b/account_payment_netting/views/account_payment_view.xml new file mode 100644 index 000000000..8d49b941e --- /dev/null +++ b/account_payment_netting/views/account_payment_view.xml @@ -0,0 +1,20 @@ + + + + view.account.payment.from.invoices + account.register.payments + + + + + + + {'invisible': [('amount', '=', 0), ('netting', '=', False)]} + + + {'invisible': [('netting', '=', True)]} + + + +