diff --git a/account_payment_line/README.rst b/account_payment_line/README.rst new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/account_payment_line/__init__.py b/account_payment_line/__init__.py new file mode 100644 index 000000000000..10e16283e0d3 --- /dev/null +++ b/account_payment_line/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2022 ForgeFlow, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +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 000000000000..fbd28952123e --- /dev/null +++ b/account_payment_line/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2022 ForgeFlow, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +{ + "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 000000000000..511cea2377ca --- /dev/null +++ b/account_payment_line/hooks.py @@ -0,0 +1,92 @@ +# Copyright 2022 ForgeFlow, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +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 000000000000..a41b4214589a --- /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 000000000000..4de1af150477 --- /dev/null +++ b/account_payment_line/models/account_move.py @@ -0,0 +1,12 @@ +# Copyright 2022 ForgeFlow, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +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 000000000000..08f5cb2b1200 --- /dev/null +++ b/account_payment_line/models/account_payment.py @@ -0,0 +1,286 @@ +# Copyright 2022 ForgeFlow, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +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 + ): + # 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": line.writeoff_account_id.id + or self.writeoff_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 [], + } + ) + + 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 000000000000..25611be520be --- /dev/null +++ b/account_payment_line/models/counterpart_line.py @@ -0,0 +1,189 @@ +# Copyright 2022 ForgeFlow, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). +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 000000000000..9e071bc4c7dc --- /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 000000000000..09aac724e286 --- /dev/null +++ b/account_payment_line/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +This module extend invoice(s)'s register payment feature, +from "Mark invoice as fully paid" with a single writeoff amount, +to "Mark invoice as fully paid (multi deduct)" which allow multiple deduction amounts. + +**Note:** We use the word "Deduction", as the diff amount can be anything not only to writeoff. diff --git a/account_payment_line/readme/USAGE.rst b/account_payment_line/readme/USAGE.rst new file mode 100644 index 000000000000..b663f2b34488 --- /dev/null +++ b/account_payment_line/readme/USAGE.rst @@ -0,0 +1,8 @@ +- Select 1 invoice, either on form view or tree view +- Click to Register Payment, a payment wizard will open +- Reduce the amount to pay and payment difference amount will appear +- Choose "Mark invoice as fully paid (multi deduct)", and a new deduction table will appear +- Add deduction amount, make sure total deduction amount is equal to the payment difference +- Click validate to finish the payment + +Note: this feature only works for 1 invoice 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 000000000000..1d8229944fe4 --- /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/tests/__init__.py b/account_payment_line/tests/__init__.py new file mode 100644 index 000000000000..7a4851544fad --- /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 000000000000..366167a956fb --- /dev/null +++ b/account_payment_line/tests/test_account_payment_line.py @@ -0,0 +1,703 @@ +# Copyright 2022 ForgeFlow, S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +import json + +from odoo import fields +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.supplier = 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, + ): + 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 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) + 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, + ) + 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, + ) + + # TODO: These cases and variants combined + # Customer + # Customer Refund + # Supplier + # Supplier Refund + # Partial Payments + # Lines only with account + # Multi-Currency + # Multi-Company + # Payment-Terms-Split + # Analytic 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 000000000000..3f04e3982df8 --- /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 + + + +