diff --git a/account_payment_line/README.rst b/account_payment_line/README.rst new file mode 100644 index 00000000000..8a82296e677 --- /dev/null +++ b/account_payment_line/README.rst @@ -0,0 +1,98 @@ +========================= +Payment Counterpart Lines +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:255742abf8b18f2210925ddcb12a12a065e1c601d70058f9f45d56df5ff0f6fb + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--payment-lightgray.png?logo=github + :target: https://github.com/OCA/account-payment/tree/14.0/account_payment_line + :alt: OCA/account-payment +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-payment-14-0/account-payment-14-0-account_payment_line + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/account-payment&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module is an utility module to add lines in payment, allowing users make +more complicated cases when processing payments, split on many invoices, +set up specific write-off and adding some analytic information + +Add tool to proposal of payment distributions, ordering by due date + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +You can use payment distribution suggestion, and if system found moves +pending to reconcile related with partner selected, system will create +all lines trying to pay all invoices until amount remain + +You can add manually lines, if payment don't detect lines specified, payment +works as a normal payment + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ForgeFlow S.L. + +Contributors +~~~~~~~~~~~~ + +* Christopher Ormaza. + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +.. |maintainer-ChrisOForgeFlow| image:: https://github.com/ChrisOForgeFlow.png?size=40px + :target: https://github.com/ChrisOForgeFlow + :alt: ChrisOForgeFlow + +Current `maintainer `__: + +|maintainer-ChrisOForgeFlow| + +This module is part of the `OCA/account-payment `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_payment_line/__init__.py b/account_payment_line/__init__.py new file mode 100644 index 00000000000..e2b838b2208 --- /dev/null +++ b/account_payment_line/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2022 ForgeFlow, S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import models +from .hooks import post_load_hook diff --git a/account_payment_line/__manifest__.py b/account_payment_line/__manifest__.py new file mode 100644 index 00000000000..e39b5e864d2 --- /dev/null +++ b/account_payment_line/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2022 ForgeFlow, S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "Payment Counterpart Lines", + "summary": """Payment Counterpart Lines""", + "author": "ForgeFlow S.L., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-payment", + "category": "Account", + "version": "14.0.1.0.0", + "license": "AGPL-3", + "depends": ["account"], + "data": [ + "security/ir.model.access.csv", + "views/account_payment_views.xml", + ], + "maintainers": ["ChrisOForgeFlow"], + "installable": True, + "post_load": "post_load_hook", + "auto_install": False, +} diff --git a/account_payment_line/hooks.py b/account_payment_line/hooks.py new file mode 100644 index 00000000000..6db7dc17b25 --- /dev/null +++ b/account_payment_line/hooks.py @@ -0,0 +1,92 @@ +# Copyright 2022 ForgeFlow, S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _ +from odoo.exceptions import UserError + +from odoo.addons.account.models.account_payment import ( + AccountPayment as AccountPaymentClass, +) + + +def post_load_hook(): # noqa: C901 + def _synchronize_from_moves_new(self, changed_fields): + if not self.line_payment_counterpart_ids: + return self._synchronize_from_moves_original(changed_fields) + if self._context.get("skip_account_move_synchronization"): + return + + for pay in self.with_context(skip_account_move_synchronization=True): + + if pay.move_id.statement_line_id: + continue + + move = pay.move_id + move_vals_to_write = {} + payment_vals_to_write = {} + + if "journal_id" in changed_fields: + if pay.journal_id.type not in ("bank", "cash"): + raise UserError( + _("A payment must always belongs to a bank or cash journal.") + ) + + if "line_ids" in changed_fields: + all_lines = move.line_ids + ( + liquidity_lines, + counterpart_lines, + writeoff_lines, + ) = pay._seek_for_lines() + + if any( + line.currency_id != all_lines[0].currency_id for line in all_lines + ): + raise UserError( + _( + "Journal Entry %s is not valid. " + "In order to proceed, " + "the journal items must " + "share the same currency.", + move.display_name, + ) + ) + + if "receivable" in counterpart_lines.mapped( + "account_id.user_type_id.type" + ): + partner_type = "customer" + else: + partner_type = "supplier" + + liquidity_amount = liquidity_lines.amount_currency + + move_vals_to_write.update( + { + "currency_id": liquidity_lines.currency_id.id, + "partner_id": liquidity_lines.partner_id.id, + } + ) + destination_account_id = counterpart_lines.mapped("account_id")[0].id + payment_vals_to_write.update( + { + "amount": abs(liquidity_amount), + "partner_type": partner_type, + "currency_id": liquidity_lines.currency_id.id, + "destination_account_id": destination_account_id, + "partner_id": liquidity_lines.partner_id.id, + } + ) + if liquidity_amount > 0.0: + payment_vals_to_write.update({"payment_type": "inbound"}) + elif liquidity_amount < 0.0: + payment_vals_to_write.update({"payment_type": "outbound"}) + + move.write(move._cleanup_write_orm_values(move, move_vals_to_write)) + pay.write(move._cleanup_write_orm_values(pay, payment_vals_to_write)) + + if not hasattr(AccountPaymentClass, "_synchronize_from_moves_original"): + AccountPaymentClass._synchronize_from_moves_original = ( + AccountPaymentClass._synchronize_from_moves + ) + AccountPaymentClass._synchronize_from_moves = _synchronize_from_moves_new diff --git a/account_payment_line/models/__init__.py b/account_payment_line/models/__init__.py new file mode 100644 index 00000000000..a41b4214589 --- /dev/null +++ b/account_payment_line/models/__init__.py @@ -0,0 +1,3 @@ +from . import counterpart_line +from . import account_payment +from . import account_move diff --git a/account_payment_line/models/account_move.py b/account_payment_line/models/account_move.py new file mode 100644 index 00000000000..dbfb5428c46 --- /dev/null +++ b/account_payment_line/models/account_move.py @@ -0,0 +1,12 @@ +# Copyright 2022 ForgeFlow, S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + payment_line_id = fields.Many2one( + "account.payment.counterpart.line", string="Payment line", ondelete="set null" + ) diff --git a/account_payment_line/models/account_payment.py b/account_payment_line/models/account_payment.py new file mode 100644 index 00000000000..eb9b7053684 --- /dev/null +++ b/account_payment_line/models/account_payment.py @@ -0,0 +1,294 @@ +# Copyright 2022 ForgeFlow, S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import float_is_zero + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + line_payment_counterpart_ids = fields.One2many( + "account.payment.counterpart.line", + "payment_id", + string="Counterpart Lines", + readonly=True, + states={"draft": [("readonly", False)]}, + help="Use these lines to add matching lines, for example in a credit" + "card payment, financing interest or commission is added", + ) + writeoff_account_id = fields.Many2one( + "account.account", + string="Write-off Account", + domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", + ) + + def _process_post_reconcile(self): + for rec in self: + for line in rec.line_payment_counterpart_ids: + if line.aml_id: + to_reconcile = (line.aml_id + line.move_ids).filtered( + lambda x: not x.reconciled and x.account_id.reconcile + ) + if to_reconcile: + to_reconcile.reconcile() + return True + + def _get_moves_domain(self): + domain = [ + ("amount_residual", "!=", 0.0), + ("state", "=", "posted"), + ("company_id", "=", self.company_id.id), + ( + "commercial_partner_id", + "=", + self.partner_id.commercial_partner_id.id, + ), + ] + if self.partner_type == "supplier": + if self.payment_type == "outbound": + domain.append(("move_type", "in", ("in_invoice", "in_receipt"))) + if self.payment_type == "inbound": + domain.append(("move_type", "=", "in_refund")) + elif self.partner_type == "customer": + if self.payment_type == "outbound": + domain.append(("move_type", "=", "out_refund")) + if self.payment_type == "inbound": + domain.append(("move_type", "in", ("out_invoice", "out_receipt"))) + return domain + + def _filter_amls(self, amls): + return amls.filtered( + lambda x: x.partner_id.commercial_partner_id.id + == self.partner_id.commercial_partner_id.id + and x.amount_residual != 0 + and x.account_id.internal_type in ("receivable", "payable") + ) + + def _hook_create_new_line(self, invoice, aml, amount_to_apply): + line_model = self.env["account.payment.counterpart.line"] + self.ensure_one() + return line_model.create( + { + "payment_id": self.id, + "name": "/", + "move_id": invoice.id, + "aml_id": aml.id, + "account_id": aml.account_id.id, + "partner_id": self.partner_id.commercial_partner_id.id, + "amount": amount_to_apply, + } + ) + + def action_propose_payment_distribution(self): + move_model = self.env["account.move"] + for rec in self: + if self.is_internal_transfer: + continue + domain = self._get_moves_domain() + pending_invoices = move_model.search(domain, order="invoice_date_due ASC") + pending_amount = rec.amount + rec.line_payment_counterpart_ids.unlink() + for invoice in pending_invoices: + for aml in self._filter_amls(invoice.line_ids): + amount_to_apply = 0 + amount_residual = rec.company_id.currency_id._convert( + aml.amount_residual, + rec.currency_id, + rec.company_id, + date=rec.date, + ) + if pending_amount >= 0: + amount_to_apply = min(abs(amount_residual), pending_amount) + pending_amount -= abs(amount_residual) + rec._hook_create_new_line(invoice, aml, amount_to_apply) + + def action_delete_counterpart_lines(self): + if self.line_payment_counterpart_ids and self.state == "draft": + self.line_payment_counterpart_ids = [(5, 0, 0)] + + def _prepare_move_line_default_vals(self, write_off_line_vals=False): + res = super(AccountPayment, self)._prepare_move_line_default_vals( + write_off_line_vals + ) + write_off_amount_currency = ( + write_off_line_vals and write_off_line_vals.get("amount", 0.0) or 0.0 + ) + if self.payment_type == "outbound": + write_off_amount_currency *= -1 + write_off_balance = self.currency_id._convert( + write_off_amount_currency, + self.company_id.currency_id, + self.company_id, + self.date, + ) + new_aml_lines = [] + for line in self.line_payment_counterpart_ids.filtered( + lambda x: not float_is_zero( + x.amount, precision_digits=self.currency_id.decimal_places + ) + ): + line_balance = ( + line.amount if self.payment_type == "outbound" else line.amount * -1 + ) + line_balance_currency = ( + line.amount_currency + if self.payment_type == "outbound" + else line.amount_currency * -1 + ) + aml_value = line_balance_currency + write_off_balance + aml_value_currency = line_balance + write_off_amount_currency + if line.fully_paid and not float_is_zero( + line.writeoff_amount, precision_digits=self.currency_id.decimal_places + ): + write_off_account = ( + line.writeoff_account_id.id or self.writeoff_account_id.id + ) + if not write_off_account: + raise ValidationError( + _( + "Write-off account is not set for payment %s" + % self.display_name + ) + ) + # Fully Paid line + new_aml_lines.append( + { + "name": line.display_name, + "debit": line.aml_amount_residual < 0.0 + and abs(line.aml_amount_residual) + or 0.0, + "credit": line.aml_amount_residual > 0.0 + and abs(line.aml_amount_residual) + or 0.0, + "amount_currency": abs(line.aml_amount_residual_currency) + * (line.aml_amount_residual > 0.0 and -1 or 1), + "date_maturity": self.date, + "partner_id": line.partner_id.commercial_partner_id.id, + "account_id": line.account_id.id, + "currency_id": line.payment_id.currency_id.id, + "payment_id": self.id, + "payment_line_id": line.id, + "analytic_account_id": line.analytic_account_id.id, + "analytic_tag_ids": line.analytic_tag_ids + and [(6, 0, line.analytic_tag_ids.ids)] + or [], + } + ) + # write-off line + new_aml_lines.append( + { + "name": _("Write-off"), + "debit": line.writeoff_amount > 0.0 + and line.writeoff_amount + or 0.0, + "credit": line.writeoff_amount < 0.0 + and -line.writeoff_amount + or 0.0, + "amount_currency": abs(line.writeoff_amount_currency) + * (line.writeoff_amount < 0.0 and -1 or 1), + "date_maturity": self.date, + "partner_id": line.partner_id.commercial_partner_id.id, + "account_id": write_off_account, + "currency_id": line.payment_id.currency_id.id, + "payment_id": self.id, + "payment_line_id": line.id, + "analytic_account_id": line.analytic_account_id.id, + "analytic_tag_ids": line.analytic_tag_ids + and [(6, 0, line.analytic_tag_ids.ids)] + or [], + } + ) + + else: + new_aml_lines.append( + { + "name": line.display_name, + "debit": aml_value > 0.0 and aml_value or 0.0, + "credit": aml_value < 0.0 and -aml_value or 0.0, + "amount_currency": abs(aml_value_currency) + * (aml_value < 0.0 and -1 or 1), + "date_maturity": self.date, + "partner_id": line.partner_id.commercial_partner_id.id, + "account_id": line.account_id.id, + "currency_id": line.payment_id.currency_id.id, + "payment_id": self.id, + "payment_line_id": line.id, + "analytic_account_id": line.analytic_account_id.id, + "analytic_tag_ids": line.analytic_tag_ids + and [(6, 0, line.analytic_tag_ids.ids)] + or [], + } + ) + if len(res) >= 2 and new_aml_lines: + res.pop(1) + res += new_aml_lines + return res + + def _check_writeoff_lines(self): + for rec in self: + writeoff_lines = rec.line_payment_counterpart_ids.filtered( + lambda x: x.fully_paid + and not float_is_zero( + x.writeoff_amount, precision_digits=rec.currency_id.decimal_places + ) + ) + if not rec.writeoff_account_id and not all( + line.writeoff_account_id for line in writeoff_lines + ): + raise ValidationError( + _( + "You should set up write-off account on lines or in header to continue" + ) + ) + + def action_post(self): + self._check_writeoff_lines() + for rec in self.filtered(lambda x: x.line_payment_counterpart_ids): + if rec.move_id.line_ids: + rec.move_id.line_ids.unlink() + rec.move_id.line_ids = [ + (0, 0, line_vals) for line_vals in rec._prepare_move_line_default_vals() + ] + res = super(AccountPayment, self).action_post() + self._process_post_reconcile() + return res + + def action_draft(self): + res = super().action_draft() + for rec in self.filtered(lambda x: x.line_payment_counterpart_ids): + # CHECK ME: force to recreate lines + # if document back to draft state, + # because we can change counterpart lines, + # but change will not be propagated properly + rec.move_id.line_ids.unlink() + return res + + +class AccountPaymentCounterLine(models.Model): + _name = "account.payment.counterpart.line" + _inherit = "account.payment.counterpart.line.abstract" + _description = "Counterpart line payment" + + payment_id = fields.Many2one( + "account.payment", string="Payment", required=False, ondelete="cascade" + ) + analytic_tag_ids = fields.Many2many( + comodel_name="account.analytic.tag", + relation="counterpart_line_analytic_tag_rel", + column1="line_id", + column2="tag_id", + string="Analytic Tags", + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + check_company=True, + ) + + def _get_onchange_fields(self): + return ( + "aml_id.amount_residual", + "amount", + "payment_id.currency_id", + "payment_id.date", + "fully_paid", + ) diff --git a/account_payment_line/models/counterpart_line.py b/account_payment_line/models/counterpart_line.py new file mode 100644 index 00000000000..bbf7016d876 --- /dev/null +++ b/account_payment_line/models/counterpart_line.py @@ -0,0 +1,189 @@ +# Copyright 2022 ForgeFlow, S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +dict_payment_type = dict( + inbound=["out_invoice", "in_refund", "out_receipt"], + outbound=["in_invoice", "out_refund", "in_receipt"], +) + + +class AccountPaymentCounterLinesAbstract(models.AbstractModel): + _name = "account.payment.counterpart.line.abstract" + _description = "Counterpart line payment Abstract" + + company_id = fields.Many2one( + comodel_name="res.company", + compute="_compute_company_fields", + default=lambda self: self.env.company, + ) + name = fields.Char(string="Description", required=True, default="/") + account_id = fields.Many2one( + "account.account", + string="Account", + required=True, + ondelete="restrict", + check_company=True, + ) + analytic_account_id = fields.Many2one( + comodel_name="account.analytic.account", + string="Analytic Account", + ondelete="restrict", + check_company=True, + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + string="Currency", + compute="_compute_company_fields", + default=lambda self: self.env.company.currency_id, + ) + + fully_paid = fields.Boolean(string="Fully Paid?") + writeoff_account_id = fields.Many2one( + comodel_name="account.account", + string="Write-off account", + domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", + ) + writeoff_amount = fields.Monetary( + required=False, + compute="_compute_amounts", + ) + writeoff_amount_currency = fields.Monetary( + required=False, + compute="_compute_amounts", + ) + + def _compute_company_fields(self): + for rec in self: + rec.company_id = self.env.company.id + rec.currency_id = self.env.company.currency_id.id + + amount = fields.Monetary(string="Amount", required=True) + amount_currency = fields.Monetary( + string="Amount in Company Currency", compute="_compute_amounts" + ) + aml_amount_residual = fields.Monetary( + string="Amount Residual", + compute="_compute_amounts", + ) + residual_after_payment = fields.Monetary( + compute="_compute_amounts", + ) + aml_amount_residual_currency = fields.Monetary( + string="Amount Residual Currency", + compute="_compute_amounts", + ) + residual_after_payment_currency = fields.Monetary( + compute="_compute_amounts", + ) + + def _get_onchange_fields(self): + return "aml_id.amount_residual", "amount", "fully_paid" + + @api.depends(lambda x: x._get_onchange_fields()) + def _compute_amounts(self): + for rec in self: + payment_date = ( + hasattr(rec.payment_id, "payment_date") + and rec.payment_id.payment_date + or rec.payment_id.date + ) + rec.amount_currency = rec.payment_id.currency_id._convert( + rec.amount, + rec.payment_id.company_id.currency_id, + rec.payment_id.company_id, + date=payment_date, + ) + rec.aml_amount_residual = rec.aml_id.amount_residual + rec.residual_after_payment = ( + not rec.fully_paid + and max(abs(rec.aml_id.amount_residual) - rec.amount_currency, 0) + or 0.0 + ) + rec.writeoff_amount = ( + rec.fully_paid and (rec.aml_id.amount_residual - rec.amount) or 0.0 + ) + rec.aml_amount_residual_currency = rec.aml_id.amount_residual_currency + rec.residual_after_payment_currency = ( + not rec.fully_paid + and max( + abs(rec.aml_id.amount_residual_currency) - rec.amount_currency, 0 + ) + or 0.0 + ) + rec.writeoff_amount_currency = ( + rec.fully_paid + and (rec.aml_id.amount_residual_currency - rec.amount_currency) + or 0.0 + ) + + partner_id = fields.Many2one("res.partner", string="Partner", ondelete="restrict") + commercial_partner_id = fields.Many2one(related="partner_id.commercial_partner_id") + move_id = fields.Many2one( + "account.move", string="Journal Entry", ondelete="set null" + ) + move_ids = fields.One2many( + "account.move.line", + "payment_line_id", + string="Journal Entries Created", + ) + aml_id = fields.Many2one( + "account.move.line", string="Journal Item to Reconcile", ondelete="set null" + ) + aml_date_maturity = fields.Date( + string="Date Maturity", required=False, related="aml_id.date_maturity" + ) + + @api.onchange("move_id", "aml_id") + def _onchange_move_id(self): + aml_model = self.env["account.move.line"] + for rec in self: + type_move = dict_payment_type.get(rec.payment_id.payment_type, []) + if rec.move_id and not rec.aml_id: + domain = [ + ("move_id", "=", rec.move_id.id), + ("amount_residual", "!=", 0.0), + ] + lines_ordered = aml_model.search( + domain, order="date_maturity ASC", limit=1 + ) + if lines_ordered: + rec.aml_id = lines_ordered.id + if rec.aml_id: + rec.move_id = rec.aml_id.move_id.id + rec.account_id = rec.aml_id.account_id.id + rec.amount = abs(rec.aml_id.amount_residual) + rec.partner_id = rec.aml_id.partner_id.id + if rec.move_id.move_type == "entry": + if rec.payment_id.partner_type == "supplier": + if rec.payment_id.payment_type == "outbound": + rec.amount = -rec.aml_id.amount_residual + else: + rec.amount = rec.aml_id.amount_residual + else: + if rec.payment_id.payment_type == "outbound": + rec.amount = -rec.aml_id.amount_residual + else: + rec.amount = rec.aml_id.amount_residual + else: + if ( + type_move + and rec.move_id.move_type not in type_move + and rec.amount + ): + rec.amount *= -1 + + @api.constrains("amount", "aml_amount_residual") + def constrains_amount_residual(self): + for rec in self: + if ( + rec.aml_id + and 0 < rec.aml_amount_residual_currency < rec.amount_currency + ): + raise ValidationError( + _( + "the amount exceeds the residual amount, please check the invoice %s" + ) + % (rec.aml_id.move_id.name or rec.aml_id.name), + ) diff --git a/account_payment_line/readme/CONTRIBUTORS.rst b/account_payment_line/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..9e071bc4c7d --- /dev/null +++ b/account_payment_line/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Christopher Ormaza. diff --git a/account_payment_line/readme/DESCRIPTION.rst b/account_payment_line/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..eaa373004a9 --- /dev/null +++ b/account_payment_line/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +This module is an utility module to add lines in payment, allowing users make +more complicated cases when processing payments, split on many invoices, +set up specific write-off and adding some analytic information + +Add tool to proposal of payment distributions, ordering by due date diff --git a/account_payment_line/readme/USAGE.rst b/account_payment_line/readme/USAGE.rst new file mode 100644 index 00000000000..f6c186260e1 --- /dev/null +++ b/account_payment_line/readme/USAGE.rst @@ -0,0 +1,6 @@ +You can use payment distribution suggestion, and if system found moves +pending to reconcile related with partner selected, system will create +all lines trying to pay all invoices until amount remain + +You can add manually lines, if payment don't detect lines specified, payment +works as a normal payment diff --git a/account_payment_line/security/ir.model.access.csv b/account_payment_line/security/ir.model.access.csv new file mode 100644 index 00000000000..1d8229944fe --- /dev/null +++ b/account_payment_line/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_payment_counterpart_line_all,access_account_payment_counterpart_line_all,model_account_payment_counterpart_line,,1,0,0,0 +access_account_payment_counterpart_line_group_account_invoice,access_account_payment_counterpart_line_group_account_invoice,model_account_payment_counterpart_line,account.group_account_invoice,1,1,1,1 +access_account_payment_counterpart_line_group_account_user,access_account_payment_counterpart_line_group_account_user,model_account_payment_counterpart_line,account.group_account_user,1,1,1,1 +access_account_payment_counterpart_line_group_account_manager,access_account_payment_counterpart_line_group_account_manager,model_account_payment_counterpart_line,account.group_account_manager,1,1,1,1 diff --git a/account_payment_line/static/description/index.html b/account_payment_line/static/description/index.html new file mode 100644 index 00000000000..4323f5eecdc --- /dev/null +++ b/account_payment_line/static/description/index.html @@ -0,0 +1,435 @@ + + + + + + +Payment Counterpart Lines + + + +
+

Payment Counterpart Lines

+ + +

Beta License: AGPL-3 OCA/account-payment Translate me on Weblate Try me on Runboat

+

This module is an utility module to add lines in payment, allowing users make +more complicated cases when processing payments, split on many invoices, +set up specific write-off and adding some analytic information

+

Add tool to proposal of payment distributions, ordering by due date

+

Table of contents

+ +
+

Usage

+

You can use payment distribution suggestion, and if system found moves +pending to reconcile related with partner selected, system will create +all lines trying to pay all invoices until amount remain

+

You can add manually lines, if payment don’t detect lines specified, payment +works as a normal payment

+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow S.L.
  • +
+
+
+

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.

+

Current maintainer:

+

ChrisOForgeFlow

+

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

+

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

+
+
+
+ + diff --git a/account_payment_line/tests/__init__.py b/account_payment_line/tests/__init__.py new file mode 100644 index 00000000000..7a4851544fa --- /dev/null +++ b/account_payment_line/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_account_payment_line diff --git a/account_payment_line/tests/test_account_payment_line.py b/account_payment_line/tests/test_account_payment_line.py new file mode 100644 index 00000000000..5b248bc021f --- /dev/null +++ b/account_payment_line/tests/test_account_payment_line.py @@ -0,0 +1,1014 @@ +# Copyright 2022 ForgeFlow, S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json + +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.tests.common import Form, TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestAccountPaymentLines(TransactionCase): + def setUp(self): + super().setUp() + self.test_product = self.env["product.product"].create( + { + "name": "test_product", + "type": "service", + } + ) + self.customer = self.env["res.partner"].create( + { + "name": "test_customer", + } + ) + self.customer2 = self.env["res.partner"].create( + { + "name": "test_customer", + } + ) + self.supplier = self.env["res.partner"].create( + { + "name": "test_vendor", + } + ) + self.supplier2 = self.env["res.partner"].create( + { + "name": "test_vendor", + } + ) + self.bank_journal = self.env["account.journal"].search( + [("type", "=", "bank")], limit=1 + ) + self.account_receivable = self.env["account.account"].search( + [ + ("company_id", "=", self.env.company.id), + ("user_type_id.type", "=", "receivable"), + ], + limit=1, + ) + self.account_payable = self.env["account.account"].search( + [ + ("company_id", "=", self.env.company.id), + ("user_type_id.type", "=", "payable"), + ], + limit=1, + ) + self.account_expense = self.env["account.account"].search( + [ + ("company_id", "=", self.env.company.id), + ("user_type_id.type", "=", "other"), + ], + limit=1, + ) + self.currency_2x = self.env["res.currency"].create( + { + "name": "2X", # Foreign currency, 2 time + "symbol": "X", + "rate_ids": [ + ( + 0, + 0, + { + "name": fields.Date.today(), + "rate": self.env.company.currency_id.rate * 2, + }, + ) + ], + } + ) + self.payment_terms_split = self.env["account.payment.term"].create( + { + "name": "50% Advance End of Following Month", + "note": "Payment terms: 30% Advance End of Following Month", + "line_ids": [ + ( + 0, + 0, + { + "value": "percent", + "value_amount": 50.0, + "sequence": 400, + "days": 0, + "option": "day_after_invoice_date", + }, + ), + ( + 0, + 0, + { + "value": "balance", + "value_amount": 0.0, + "sequence": 500, + "days": 31, + "option": "day_following_month", + }, + ), + ], + } + ) + + def _create_invoice( + self, move_type, partner, amount, currency=False, payment_term=False + ): + move_form = Form( + self.env["account.move"].with_context( + default_move_type=move_type, + account_predictive_bills_disable_prediction=True, + ) + ) + if not currency: + currency = self.env.company.currency_id + move_form.invoice_date = fields.Date.today() + move_form.date = move_form.invoice_date + move_form.partner_id = partner + move_form.currency_id = currency + if payment_term: + move_form.invoice_payment_term_id = payment_term + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.test_product + line_form.price_unit = amount + line_form.tax_ids.clear() + move = move_form.save() + move.action_post() + return move + + def _create_refund(self, invoice, refund_method="cancel"): + ctx = {"active_model": "account.move", "active_ids": [invoice.id]} + move_reversal = ( + self.env["account.move.reversal"] + .with_context(ctx) + .create( + { + "date": fields.Date.today(), + "reason": "no reason", + "refund_method": refund_method, + } + ) + ) + reversal = move_reversal.reverse_moves() + reverse_move = self.env["account.move"].browse(reversal["res_id"]) + return reverse_move + + def _create_payment( + self, + main_partner, + total_amount, + payment_type, + partner_type, + lines=False, + currency=False, + post=False, + suggest_payment_distribution=False, + writeoff_account=False, + ): + payment_form = Form( + self.env["account.payment"].with_context( + default_journal_id=self.bank_journal.id + ) + ) + payment_form.partner_id = main_partner + payment_form.payment_type = payment_type + payment_form.partner_type = partner_type + payment_form.amount = total_amount + account = ( + partner_type == "customer" + and self.account_receivable + or partner_type == "supplier" + and self.account_payable + ) + payment_form.destination_account_id = account + if writeoff_account: + payment_form.writeoff_account_id = writeoff_account + if not currency: + currency = self.env.company.currency_id + payment_form.currency_id = currency + if not lines: + lines = [] + for line in lines: + with payment_form.line_payment_counterpart_ids.new() as line_form: + if line.get("move_id", False): + line_form.move_id = line.get("move_id", False) + if line.get("account_id", False): + line_form.account_id = line.get("account_id", False) + if line.get("amount", False): + line_form.amount = line.get("amount", False) + if line.get("fully_paid", False): + line_form.fully_paid = line.get("fully_paid", False) + if line.get("writeoff_account_id", False): + line_form.writeoff_account_id = line.get( + "writeoff_account_id", False + ) + payment = payment_form.save() + if suggest_payment_distribution: + payment.action_propose_payment_distribution() + if post: + payment.action_post() + return payment + + def test_01_customer_payment(self): + new_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + new_payment = self._create_payment( + self.customer, + 100.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice, + } + ], + post=True, + ) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual(new_payment.reconciled_invoice_ids, new_invoice) + + new_invoice2 = self._create_invoice("out_invoice", self.customer, 100.0) + self.assertEqual(new_invoice2.payment_state, "not_paid") + self.assertEqual(new_invoice2.amount_total, 100.0) + new_payment2 = self._create_payment( + self.customer, + 50.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice2, + "amount": 50.0, + } + ], + post=True, + ) + self.assertEqual(new_payment2.state, "posted") + self.assertTrue(new_payment2.is_reconciled) + self.assertEqual(new_payment2.reconciled_invoice_ids, new_invoice2) + self.assertEqual(new_invoice2.amount_residual, 50.0) + + new_payment3 = self._create_payment( + self.customer, + 50.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice2, + } + ], + post=True, + ) + self.assertEqual(new_payment3.state, "posted") + self.assertTrue(new_payment3.is_reconciled) + self.assertEqual(new_payment3.reconciled_invoice_ids, new_invoice2) + self.assertEqual(new_invoice2.amount_residual, 0.0) + + new_payment2.action_draft() + new_payment2.action_cancel() + + self.assertEqual(new_payment2.state, "cancel") + self.assertEqual(new_payment3.state, "posted") + self.assertTrue(new_payment3.is_reconciled) + self.assertEqual(new_payment3.reconciled_invoice_ids, new_invoice2) + self.assertEqual(new_invoice2.amount_residual, 50.0) + + def test_02_customer_refund_payment(self): + new_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + new_invoice2 = self._create_invoice("out_invoice", self.customer, 100.0) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + self.assertEqual(new_invoice2.payment_state, "not_paid") + self.assertEqual(new_invoice2.amount_total, 100.0) + new_payment = self._create_payment( + self.customer, + 200.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice, + }, + { + "move_id": new_invoice2, + }, + ], + post=True, + ) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual(new_payment.reconciled_invoice_ids, new_invoice + new_invoice2) + self.assertIn(new_invoice.payment_state, ["paid", "in_payment"]) + self.assertIn(new_invoice2.payment_state, ["paid", "in_payment"]) + + new_refund = self._create_refund(new_invoice, "refund") + new_refund.action_post() + self.assertEqual(new_refund.payment_state, "not_paid") + self.assertEqual(new_refund.amount_total, 100.0) + new_payment_refund = self._create_payment( + self.customer, + 100.0, + "outbound", + "customer", + [ + { + "move_id": new_refund, + }, + ], + post=True, + ) + self.assertEqual(new_payment_refund.state, "posted") + self.assertTrue(new_payment_refund.is_reconciled) + self.assertEqual(new_payment_refund.reconciled_invoice_ids, new_refund) + self.assertIn(new_refund.payment_state, ["paid", "in_payment"]) + + def test_03_supplier_payment(self): + new_invoice = self._create_invoice("in_invoice", self.supplier, 100.0) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + new_payment = self._create_payment( + self.supplier, + 100.0, + "outbound", + "supplier", + [ + { + "move_id": new_invoice, + } + ], + post=True, + ) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual(new_payment.reconciled_bill_ids, new_invoice) + + new_invoice2 = self._create_invoice("in_invoice", self.supplier, 100.0) + self.assertEqual(new_invoice2.payment_state, "not_paid") + self.assertEqual(new_invoice2.amount_total, 100.0) + new_payment2 = self._create_payment( + self.supplier, + 50.0, + "outbound", + "supplier", + [ + { + "move_id": new_invoice2, + "amount": 50.0, + } + ], + post=True, + ) + self.assertEqual(new_payment2.state, "posted") + self.assertTrue(new_payment2.is_reconciled) + self.assertEqual(new_payment2.reconciled_bill_ids, new_invoice2) + self.assertEqual(new_invoice2.amount_residual, 50.0) + + new_payment3 = self._create_payment( + self.supplier, + 50.0, + "outbound", + "supplier", + [ + { + "move_id": new_invoice2, + } + ], + post=True, + ) + self.assertEqual(new_payment3.state, "posted") + self.assertTrue(new_payment3.is_reconciled) + self.assertEqual(new_payment3.reconciled_bill_ids, new_invoice2) + self.assertEqual(new_invoice2.amount_residual, 0.0) + + new_payment2.action_draft() + new_payment2.action_cancel() + + self.assertEqual(new_payment2.state, "cancel") + self.assertEqual(new_payment3.state, "posted") + self.assertTrue(new_payment3.is_reconciled) + self.assertEqual(new_payment3.reconciled_bill_ids, new_invoice2) + self.assertEqual(new_invoice2.amount_residual, 50.0) + + def test_04_supplier_refund_payment(self): + new_invoice = self._create_invoice("in_invoice", self.supplier, 100.0) + new_invoice2 = self._create_invoice("in_invoice", self.supplier, 100.0) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + self.assertEqual(new_invoice2.payment_state, "not_paid") + self.assertEqual(new_invoice2.amount_total, 100.0) + new_payment = self._create_payment( + self.supplier, + 200.0, + "outbound", + "supplier", + [ + { + "move_id": new_invoice, + }, + { + "move_id": new_invoice2, + }, + ], + post=True, + ) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual(new_payment.reconciled_bill_ids, new_invoice + new_invoice2) + self.assertIn(new_invoice.payment_state, ["paid", "in_payment"]) + self.assertIn(new_invoice2.payment_state, ["paid", "in_payment"]) + + new_refund = self._create_refund(new_invoice, "refund") + new_refund.action_post() + self.assertEqual(new_refund.payment_state, "not_paid") + self.assertEqual(new_refund.amount_total, 100.0) + new_payment_refund = self._create_payment( + self.supplier, + 100.0, + "inbound", + "supplier", + [ + { + "move_id": new_refund, + }, + ], + post=True, + ) + self.assertEqual(new_payment_refund.state, "posted") + self.assertTrue(new_payment_refund.is_reconciled) + self.assertEqual(new_payment_refund.reconciled_bill_ids, new_refund) + self.assertIn(new_refund.payment_state, ["paid", "in_payment"]) + + def test_05_partial_payments(self): + new_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + new_invoice2 = self._create_invoice("out_invoice", self.customer, 100.0) + new_invoice3 = self._create_invoice( + "out_invoice", self.customer, 100.0, payment_term=self.payment_terms_split + ) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + self.assertEqual(new_invoice2.payment_state, "not_paid") + self.assertEqual(new_invoice2.amount_total, 100.0) + self.assertEqual(new_invoice3.payment_state, "not_paid") + self.assertEqual(new_invoice3.amount_total, 100.0) + self.assertEqual( + len( + new_invoice3.line_ids.filtered( + lambda x: x.partner_id.id == new_invoice3.partner_id.id + and x.account_id.user_type_id.type == "receivable" + ) + ), + 2, + ) + new_payment = self._create_payment( + self.customer, + 150.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice, + "amount": 50.0, + }, + { + "move_id": new_invoice2, + "amount": 50.0, + }, + { + "move_id": new_invoice3, + "amount": 50.0, + }, + ], + post=True, + ) + self.assertEqual(new_invoice.payment_state, "partial") + self.assertEqual(new_invoice.amount_residual, 50.0) + self.assertEqual(new_invoice2.payment_state, "partial") + self.assertEqual(new_invoice2.amount_residual, 50.0) + self.assertEqual(new_invoice3.payment_state, "partial") + self.assertEqual(new_invoice3.amount_residual, 50.0) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual( + new_payment.reconciled_invoice_ids, + new_invoice + new_invoice2 + new_invoice3, + ) + + new_payment2 = self._create_payment( + self.customer, + 100.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice, + "amount": 50.0, + }, + { + "move_id": new_invoice2, + "amount": 50.0, + }, + ], + post=True, + ) + self.assertIn(new_invoice.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice.amount_residual, 0.0) + self.assertIn(new_invoice2.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice2.amount_residual, 0.0) + self.assertEqual(new_invoice3.payment_state, "partial") + self.assertEqual(new_invoice3.amount_residual, 50.0) + self.assertEqual(new_payment2.state, "posted") + self.assertTrue(new_payment2.is_reconciled) + self.assertEqual( + new_payment2.reconciled_invoice_ids, + new_invoice + new_invoice2, + ) + new_payment3 = self._create_payment( + self.customer, + 50.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice3, + "amount": 50.0, + }, + ], + post=True, + ) + self.assertIn(new_invoice3.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice3.amount_residual, 0.0) + self.assertEqual(new_payment3.state, "posted") + self.assertTrue(new_payment3.is_reconciled) + self.assertEqual( + new_payment3.reconciled_invoice_ids, + new_invoice3, + ) + + def test_06_payments_without_invoices(self): + new_payment = self._create_payment( + self.customer, + 100.0, + "inbound", + "customer", + [ + { + "account_id": self.account_expense, + "amount": 50.0, + }, + { + "account_id": self.customer.property_account_receivable_id, + "amount": 50.0, + }, + ], + post=True, + ) + self.assertEqual(new_payment.state, "posted") + self.assertFalse(new_payment.is_reconciled) + self.assertFalse(bool(new_payment.reconciled_invoice_ids)) + new_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + payments = json.loads(new_invoice.invoice_outstanding_credits_debits_widget) + self.assertEqual(sum(p.get("amount") for p in payments.get("content")), 50) + new_invoice.js_assign_outstanding_line(payments.get("content", [])[0].get("id")) + self.assertEqual(new_invoice.payment_state, "partial") + self.assertEqual(new_invoice.amount_residual, 50.0) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual( + new_payment.reconciled_invoice_ids, + new_invoice, + ) + + def test_07_payment_multi_currency(self): + new_invoice = self._create_invoice( + "out_invoice", self.customer, 100.0, currency=self.currency_2x + ) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + new_payment = self._create_payment( + self.customer, + 50.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice, + "amount": 50.0, + }, + ], + post=True, + ) + self.assertIn(new_invoice.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice.amount_residual, 0.0) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual( + new_payment.reconciled_invoice_ids, + new_invoice, + ) + + new_invoice2 = self._create_invoice( + "out_invoice", self.customer, 100.0, currency=self.currency_2x + ) + self.assertEqual(new_invoice2.payment_state, "not_paid") + self.assertEqual(new_invoice2.amount_total, 100.0) + new_payment2 = self._create_payment( + self.customer, + 100.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice2, + "amount": 100.0, + }, + ], + post=True, + currency=self.currency_2x, + ) + self.assertIn(new_invoice2.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice2.amount_residual, 0.0) + self.assertEqual(new_payment2.state, "posted") + self.assertTrue(new_payment2.is_reconciled) + self.assertEqual( + new_payment2.reconciled_invoice_ids, + new_invoice2, + ) + + new_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + new_payment = self._create_payment( + self.customer, + 200.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice, + "amount": 200.0, + }, + ], + post=True, + currency=self.currency_2x, + ) + self.assertIn(new_invoice.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice.amount_residual, 0.0) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual( + new_payment.reconciled_invoice_ids, + new_invoice, + ) + + def test_08_payment_term_split(self): + new_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + new_invoice2 = self._create_invoice("out_invoice", self.customer, 100.0) + new_invoice3 = self._create_invoice( + "out_invoice", self.customer, 100.0, payment_term=self.payment_terms_split + ) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + self.assertEqual(new_invoice2.payment_state, "not_paid") + self.assertEqual(new_invoice2.amount_total, 100.0) + self.assertEqual(new_invoice3.payment_state, "not_paid") + self.assertEqual(new_invoice3.amount_total, 100.0) + new_payment = self._create_payment( + self.customer, + 300.0, + "inbound", + "customer", + suggest_payment_distribution=True, + ) + self.assertEqual(len(new_payment.line_payment_counterpart_ids), 4) + self.assertEqual( + len( + new_payment.line_payment_counterpart_ids.filtered( + lambda x: x.amount == 50.0 + ) + ), + 2, + ) + self.assertEqual( + len( + new_payment.line_payment_counterpart_ids.filtered( + lambda x: x.amount == 100.0 + ) + ), + 2, + ) + new_payment.action_post() + self.assertIn(new_invoice.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice.amount_residual, 0.0) + self.assertIn(new_invoice2.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice2.amount_residual, 0.0) + self.assertIn(new_invoice3.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice3.amount_residual, 0.0) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual( + new_payment.reconciled_invoice_ids, + new_invoice + new_invoice2 + new_invoice3, + ) + + def test_09_offset_payment(self): + new_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + self.assertEqual(new_invoice.payment_state, "not_paid") + self.assertEqual(new_invoice.amount_total, 100.0) + new_payment = self._create_payment( + self.customer, + 50.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice, + "amount": 50.0, + "fully_paid": True, + }, + ], + writeoff_account=self.account_expense, + post=True, + ) + self.assertIn(new_invoice.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice.amount_residual, 0.0) + self.assertEqual(new_payment.state, "posted") + self.assertTrue(new_payment.is_reconciled) + self.assertEqual( + new_payment.reconciled_invoice_ids, + new_invoice, + ) + + new_invoice2 = self._create_invoice("out_invoice", self.customer, 100.0) + new_invoice3 = self._create_invoice("out_invoice", self.customer, 100.0) + self.assertEqual(new_invoice2.payment_state, "not_paid") + self.assertEqual(new_invoice2.amount_total, 100.0) + self.assertEqual(new_invoice3.payment_state, "not_paid") + self.assertEqual(new_invoice3.amount_total, 100.0) + new_payment2 = self._create_payment( + self.customer, + 150.0, + "inbound", + "customer", + [ + { + "move_id": new_invoice2, + "amount": 50.0, + "writeoff_account_id": self.account_expense, + "fully_paid": True, + }, + { + "move_id": new_invoice3, + "amount": 100.0, + }, + ], + ) + writeoff_line = new_payment2.line_payment_counterpart_ids.filtered( + lambda x: x.move_id == new_invoice2 + ) + self.assertEqual(writeoff_line.writeoff_amount, 50.0) + self.assertEqual(writeoff_line.residual_after_payment, 0.0) + new_payment2.action_post() + self.assertIn(new_invoice2.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice2.amount_residual, 0.0) + self.assertIn(new_invoice3.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_invoice3.amount_residual, 0.0) + self.assertEqual(new_payment2.state, "posted") + self.assertTrue(new_payment2.is_reconciled) + self.assertEqual( + new_payment2.reconciled_invoice_ids, + new_invoice2 + new_invoice3, + ) + + def test_10_payment_distribution_proposition(self): + new_out_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + new_out_refund = self._create_invoice("out_refund", self.customer, 100.0) + new_in_invoice = self._create_invoice("in_invoice", self.customer, 100.0) + new_in_refund = self._create_invoice("in_refund", self.customer, 100.0) + new_out_invoice2 = self._create_invoice("out_invoice", self.customer2, 100.0) + new_out_refund2 = self._create_invoice("out_refund", self.customer2, 100.0) + new_in_invoice2 = self._create_invoice("in_invoice", self.customer2, 100.0) + new_in_refund2 = self._create_invoice("in_refund", self.customer2, 100.0) + + payment_form = Form( + self.env["account.payment"].with_context( + default_journal_id=self.bank_journal.id + ) + ) + payment_form.partner_id = self.customer + payment_form.payment_type = "inbound" + payment_form.partner_type = "customer" + payment_form.amount = 50.0 + payment = payment_form.save() + payment.action_propose_payment_distribution() + self.assertEqual(len(payment.line_payment_counterpart_ids), 1) + self.assertEqual( + sum(payment.line_payment_counterpart_ids.mapped("amount")), 50.0 + ) + self.assertEqual( + payment.line_payment_counterpart_ids.mapped("move_id"), new_out_invoice + ) + + payment.action_delete_counterpart_lines() + self.assertEqual(len(payment.line_payment_counterpart_ids), 0) + + payment.action_propose_payment_distribution() + payment.action_post() + self.assertEqual(new_out_invoice.payment_state, "partial") + self.assertEqual(new_out_invoice.amount_residual, 50.0) + self.assertEqual(payment.state, "posted") + self.assertTrue(payment.is_reconciled) + self.assertEqual( + payment.reconciled_invoice_ids, + new_out_invoice, + ) + + new_payment2 = self._create_payment( + self.customer, + 50.0, + "outbound", + "customer", + [ + { + "move_id": new_out_invoice, + "amount": -50.0, + }, + { + "move_id": new_out_refund, + "amount": 100.0, + }, + ], + post=True, + ) + self.assertIn(new_out_invoice.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_out_invoice.amount_residual, 0.0) + self.assertIn(new_out_refund.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_out_refund.amount_residual, 0.0) + self.assertEqual(new_payment2.state, "posted") + self.assertTrue(new_payment2.is_reconciled) + self.assertEqual( + new_payment2.reconciled_invoice_ids, + new_out_invoice + new_out_refund, + ) + + payment_form = Form( + self.env["account.payment"].with_context( + default_journal_id=self.bank_journal.id + ) + ) + payment_form.partner_id = self.customer2 + payment_form.payment_type = "outbound" + payment_form.partner_type = "customer" + payment_form.amount = 100.0 + payment = payment_form.save() + payment.action_propose_payment_distribution() + self.assertEqual(len(payment.line_payment_counterpart_ids), 1) + self.assertEqual( + sum(payment.line_payment_counterpart_ids.mapped("amount")), 100.0 + ) + self.assertEqual( + payment.line_payment_counterpart_ids.mapped("move_id"), new_out_refund2 + ) + + payment.action_delete_counterpart_lines() + self.assertEqual(len(payment.line_payment_counterpart_ids), 0) + payment.write( + { + "payment_type": "outbound", + "partner_type": "supplier", + } + ) + payment.action_propose_payment_distribution() + self.assertEqual(len(payment.line_payment_counterpart_ids), 1) + self.assertEqual( + sum(payment.line_payment_counterpart_ids.mapped("amount")), 100.0 + ) + self.assertEqual( + payment.line_payment_counterpart_ids.mapped("move_id"), new_in_invoice2 + ) + + payment.action_delete_counterpart_lines() + self.assertEqual(len(payment.line_payment_counterpart_ids), 0) + payment.write( + { + "payment_type": "inbound", + "partner_type": "supplier", + } + ) + payment.action_propose_payment_distribution() + self.assertEqual(len(payment.line_payment_counterpart_ids), 1) + self.assertEqual( + sum(payment.line_payment_counterpart_ids.mapped("amount")), 100.0 + ) + self.assertEqual( + payment.line_payment_counterpart_ids.mapped("move_id"), new_in_refund2 + ) + payment.unlink() + + new_payment3 = self._create_payment( + self.customer, + 0.0, + "outbound", + "supplier", + [ + { + "move_id": new_in_refund, + "amount": -100.0, + }, + { + "move_id": new_in_invoice, + "amount": 100.0, + }, + ], + post=True, + ) + self.assertIn(new_in_refund.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_in_refund.amount_residual, 0.0) + self.assertIn(new_in_invoice.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_in_invoice.amount_residual, 0.0) + self.assertEqual(new_payment3.state, "posted") + self.assertTrue(new_payment3.is_reconciled) + self.assertEqual( + new_payment3.reconciled_bill_ids, + new_in_refund + new_in_invoice, + ) + + new_payment4 = self._create_payment( + self.customer2, + 0.0, + "inbound", + "customer", + [ + { + "move_id": new_out_refund2, + "amount": -100.0, + }, + { + "move_id": new_out_invoice2, + "amount": 100.0, + }, + { + "move_id": new_in_refund2, + "amount": 100.0, + }, + { + "move_id": new_in_invoice2, + "amount": -100.0, + }, + ], + post=True, + ) + self.assertIn(new_out_refund2.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_out_refund2.amount_residual, 0.0) + self.assertIn(new_out_invoice2.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_out_invoice2.amount_residual, 0.0) + self.assertIn(new_in_refund2.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_in_refund2.amount_residual, 0.0) + self.assertIn(new_in_invoice2.payment_state, ["paid", "in_payment"]) + self.assertEqual(new_in_invoice2.amount_residual, 0.0) + self.assertEqual(new_payment4.state, "posted") + self.assertTrue(new_payment4.is_reconciled) + self.assertEqual( + new_payment4.reconciled_invoice_ids, + new_out_invoice2 + new_out_refund2, + ) + self.assertEqual( + new_payment4.reconciled_bill_ids, + new_in_refund2 + new_in_invoice2, + ) + + def test_11_exceptions(self): + new_out_invoice = self._create_invoice("out_invoice", self.customer, 100.0) + + # Should select a writeoff_account + with self.assertRaises(ValidationError): + self._create_payment( + self.customer, + 50.0, + "inbound", + "customer", + [ + { + "move_id": new_out_invoice, + "amount": 50.0, + "fully_paid": True, + }, + ], + post=True, + ) + + # Should input lower or equal amount than invoice selected in line + with self.assertRaises(ValidationError): + self._create_payment( + self.customer, + 150.0, + "inbound", + "customer", + [ + { + "move_id": new_out_invoice, + "amount": 150.0, + }, + ], + post=True, + ) diff --git a/account_payment_line/views/account_payment_views.xml b/account_payment_line/views/account_payment_views.xml new file mode 100644 index 00000000000..3f04e3982df --- /dev/null +++ b/account_payment_line/views/account_payment_views.xml @@ -0,0 +1,147 @@ + + + + + view.account.payment.line.tree + account.payment.counterpart.line + + + + + + + + + + + + + + + + + + + + + + + + + + + + view.inherit.account.payment.form + account.payment + + + +