diff --git a/account_move_last_payment/README.md b/account_move_last_payment/README.md new file mode 100644 index 0000000..3f829f1 --- /dev/null +++ b/account_move_last_payment/README.md @@ -0,0 +1,33 @@ +# Account Move Last Payment + +Expose on `account.move` the date and method of the most recent real payment reconciled +with an invoice, as two stored, exportable fields shown in the invoice list view. + +## Features + +- `last_payment_date` (Date): date of the most recent payment reconciled with the + invoice — partial payments included. +- `last_payment_method` (Char): method of that most recent payment: the payment method + line name for explicit payments, "Bank Transfer" when the invoice was settled through + a direct bank statement line reconciliation. +- Covers both Community reconciliation paths: explicit payment ("Register Payment" + wizard) and direct bank statement reconciliation. Manual reconciliations without + payment or statement line are ignored, as are exchange difference moves. +- Recomputes automatically on reconcile / unreconcile and when a payment or statement + line date changes. +- Both fields are stored and displayed as optional columns of the invoice list view, so + they are exportable. + +## Installation + +Depends on the `account` module only. + +## Credits + +### Contributors + +- Elabore - Assisted by AI + +### Maintainer + +- Elabore diff --git a/account_move_last_payment/__init__.py b/account_move_last_payment/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/account_move_last_payment/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_move_last_payment/__manifest__.py b/account_move_last_payment/__manifest__.py new file mode 100644 index 0000000..c93921b --- /dev/null +++ b/account_move_last_payment/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Account Move Last Payment", + "version": "18.0.1.0.0", + "summary": "Last real payment date and method on invoices", + "author": "Elabore", + "website": "https://git.elabore.coop/elabore/account-tools", + "license": "AGPL-3", + "category": "Accounting", + "depends": [ + "account", + ], + "data": [ + "views/account_move_views.xml", + ], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/account_move_last_payment/i18n/fr.po b/account_move_last_payment/i18n/fr.po new file mode 100644 index 0000000..f24c5cc --- /dev/null +++ b/account_move_last_payment/i18n/fr.po @@ -0,0 +1,54 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_move_last_payment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-20 15:23+0000\n" +"PO-Revision-Date: 2026-10-20 15:23+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_move_last_payment +#: code:addons/account_move_last_payment/models/account_move.py:0 +msgid "Bank Transfer" +msgstr "Virement" + +#. module: account_move_last_payment +#: model:ir.model.fields,field_description:account_move_last_payment.field_account_move__last_payment_date +msgid "Last Payment Date" +msgstr "Date du dernier paiement" + +#. module: account_move_last_payment +#: model:ir.model.fields,field_description:account_move_last_payment.field_account_move__last_payment_method +msgid "Last Payment Method" +msgstr "Mode de paiement" + +#. module: account_move_last_payment +#: model:ir.model.fields,help:account_move_last_payment.field_account_move__last_payment_date +msgid "" +"Date of the most recent payment reconciled with this invoice, whatever " +"the reconciliation path (explicit payment or direct bank statement line " +"reconciliation)." +msgstr "" +"Date du paiement le plus récent lettré avec cette facture, quel que soit " +"le chemin de lettrage (paiement explicite ou rapprochement direct d'une " +"ligne de relevé bancaire)." + +#. module: account_move_last_payment +#: model:ir.model.fields,help:account_move_last_payment.field_account_move__last_payment_method +msgid "" +"Payment method of the most recent payment reconciled with this invoice." +msgstr "" +"Mode de paiement du paiement le plus récent lettré avec cette facture." + +#. module: account_move_last_payment +#: model:ir.model,name:account_move_last_payment.model_account_move +msgid "Journal Entry" +msgstr "Écriture comptable" diff --git a/account_move_last_payment/models/__init__.py b/account_move_last_payment/models/__init__.py new file mode 100644 index 0000000..9c0a421 --- /dev/null +++ b/account_move_last_payment/models/__init__.py @@ -0,0 +1 @@ +from . import account_move diff --git a/account_move_last_payment/models/account_move.py b/account_move_last_payment/models/account_move.py new file mode 100644 index 0000000..973ab44 --- /dev/null +++ b/account_move_last_payment/models/account_move.py @@ -0,0 +1,84 @@ +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + last_payment_date = fields.Date( + compute="_compute_last_payment_info", + store=True, + copy=False, + help="Date of the most recent payment reconciled with this " + "invoice, whatever the reconciliation path (explicit payment " + "or direct bank statement line reconciliation).", + ) + last_payment_method = fields.Char( + compute="_compute_last_payment_info", + store=True, + copy=False, + help="Payment method of the most recent payment reconciled with this invoice.", + ) + + @api.depends( + "line_ids.matched_debit_ids.debit_move_id.date", + "line_ids.matched_debit_ids.debit_move_id.move_id.origin_payment_id.payment_method_line_id.name", + "line_ids.matched_debit_ids.debit_move_id.move_id.statement_line_id", + "line_ids.matched_credit_ids.credit_move_id.date", + "line_ids.matched_credit_ids.credit_move_id.move_id.origin_payment_id.payment_method_line_id.name", + "line_ids.matched_credit_ids.credit_move_id.move_id.statement_line_id", + ) + def _compute_last_payment_info(self): + """Compute the last real payment date and method. + + Only customer and vendor invoices carry the information; every + other move type gets both fields cleared. Candidates are the + reconciliation counterpart lines of the receivable/payable + lines (exchange difference moves excluded). The most recent + candidate wins; on equal dates the first candidate found wins. + """ + for move in self: + if move.move_type not in ("in_invoice", "out_invoice"): + move.last_payment_date = False + move.last_payment_method = False + continue + candidates = move._get_last_payment_candidates() + if not candidates: + move.last_payment_date = False + move.last_payment_method = False + else: + latest_date, latest_method = max( + candidates, key=lambda candidate: candidate[0] + ) + move.last_payment_date = latest_date + move.last_payment_method = latest_method + + def _get_last_payment_candidates(self): + """Collect the candidate real payments for this invoice. + + A candidate is a ``(date, method)`` tuple built from each + reconciliation counterpart line: + + - counterpart move with ``origin_payment_id``: explicit + payment, dated with the payment date, method from the + payment method line; + - counterpart move with ``statement_line_id``: direct bank + statement reconciliation, dated with the counterpart move + date, method fixed to the bank transfer label; + - any other manual reconciliation is ignored. + + :return: list of ``(date, method)`` tuples, possibly empty. + """ + self.ensure_one() + invoice_partials, exchange_diff_moves = self._get_reconciled_invoices_partials() + candidates = [] + for _partial, _amount, counterpart_line in invoice_partials: + if counterpart_line.move_id.id in exchange_diff_moves: + continue + payment = counterpart_line.move_id.origin_payment_id + if payment: + candidates.append((payment.date, payment.payment_method_line_id.name)) + elif counterpart_line.move_id.statement_line_id: + candidates.append( + (counterpart_line.move_id.date, self.env._("Bank Transfer")) + ) + return candidates diff --git a/account_move_last_payment/tests/__init__.py b/account_move_last_payment/tests/__init__.py new file mode 100644 index 0000000..c92af5f --- /dev/null +++ b/account_move_last_payment/tests/__init__.py @@ -0,0 +1 @@ +from . import test_last_payment diff --git a/account_move_last_payment/tests/test_last_payment.py b/account_move_last_payment/tests/test_last_payment.py new file mode 100644 index 0000000..0246940 --- /dev/null +++ b/account_move_last_payment/tests/test_last_payment.py @@ -0,0 +1,239 @@ +from odoo import fields +from odoo.tests import tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +@tagged("post_install", "-at_install") +class TestAccountMoveLastPayment(AccountTestInvoicingCommon): + """Test the last payment date and method stored on invoices.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.inbound_method_line = cls.company_data[ + "default_journal_bank" + ].inbound_payment_method_line_ids[0] + + @classmethod + def _create_posted_invoice(cls, move_type="out_invoice"): + """Create and post a simple invoice without taxes. + + :param str move_type: the invoice type to create. + :return: the posted account.move record. + """ + return cls.init_invoice(move_type, post=True, amounts=[1000.0], taxes=[]) + + @classmethod + def _register_payment(cls, invoice, date=None, method_line=None, amount=None): + """Register a payment on an invoice through the payment wizard. + + :param invoice: the account.move to pay. + :param date: the payment date (defaults to today). + :param method_line: the account.payment.method.line to use + (defaults to the bank journal inbound method line). + :param amount: the payment amount (defaults to the invoice + residual). + :return: the created account.payment record. + """ + return ( + cls.env["account.payment.register"] + .with_context( + active_model="account.move", + active_ids=invoice.ids, + ) + .create( + { + "payment_date": date or fields.Date.today(), + "payment_method_line_id": ( + method_line or cls.inbound_method_line + ).id, + "amount": amount if amount is not None else invoice.amount_residual, + } + ) + ._create_payments() + ) + + def _reconcile_statement_line(self, invoice, amount, date=None): + """Create a bank statement line and reconcile it with the invoice. + + Mirrors the core reconciliation path: the statement line suspense + counterpart is re-accounted to the invoice receivable account, + then both lines are reconciled together. + + :param invoice: the account.move to settle. + :param float amount: the statement line amount. + :param date: the statement line date (defaults to today). + :return: the created account.bank.statement.line record. + """ + statement_line = self.env["account.bank.statement.line"].create( + { + "payment_ref": invoice.name, + "journal_id": self.company_data["default_journal_bank"].id, + "partner_id": invoice.partner_id.id, + "amount": amount, + "date": date or fields.Date.today(), + } + ) + st_liquidity_lines, st_suspense_lines, _ = statement_line._seek_for_lines() + receivable_lines = invoice.line_ids.filtered( + lambda line: line.account_type == "asset_receivable" + ) + st_suspense_lines.account_id = receivable_lines.account_id + (st_suspense_lines + receivable_lines).reconcile() + return statement_line + + def test_register_payment(self): + """Explicit payment: fields match the payment date and method.""" + invoice = self._create_posted_invoice() + payment = self._register_payment(invoice) + self.assertEqual(invoice.last_payment_date, payment.date) + self.assertEqual( + invoice.last_payment_method, payment.payment_method_line_id.name + ) + + def test_bank_statement_reconciliation(self): + """Direct statement reconciliation: statement date + Bank Transfer.""" + invoice = self._create_posted_invoice() + yesterday = fields.Date.subtract(fields.Date.today(), days=1) + statement_line = self._reconcile_statement_line( + invoice, invoice.amount_total, date=yesterday + ) + self.assertEqual(invoice.last_payment_date, statement_line.date) + self.assertEqual(invoice.last_payment_method, "Bank Transfer") + + def test_list_view_and_export(self): + """Both fields are stored and shown in the invoice list view.""" + self.assertTrue(self.env["account.move"]._fields["last_payment_date"].store) + self.assertTrue(self.env["account.move"]._fields["last_payment_method"].store) + view = self.env["ir.ui.view"]._get_view_id("account.view_invoice_tree") + arch = self.env["account.move"].get_view(view_id=view, view_type="list")["arch"] + self.assertIn('name="last_payment_date"', arch) + self.assertIn('name="last_payment_method"', arch) + + def test_multiple_payments_keeps_latest(self): + """With several payments, the most recent candidate wins.""" + invoice = self._create_posted_invoice() + older_date = fields.Date.subtract(fields.Date.today(), days=10) + newer_date = fields.Date.subtract(fields.Date.today(), days=2) + self._register_payment(invoice, date=older_date, amount=400.0) + self._register_payment(invoice, date=newer_date, amount=600.0) + self.assertEqual(invoice.last_payment_date, newer_date) + self.assertEqual(invoice.last_payment_method, self.inbound_method_line.name) + + def test_statement_line_date_change_recompute(self): + """Changing the statement line date updates the stored fields.""" + invoice = self._create_posted_invoice() + yesterday = fields.Date.subtract(fields.Date.today(), days=1) + statement_line = self._reconcile_statement_line( + invoice, invoice.amount_total, date=yesterday + ) + self.assertEqual(invoice.last_payment_date, yesterday) + older_date = fields.Date.subtract(fields.Date.today(), days=30) + statement_line.date = older_date + self.assertEqual(invoice.last_payment_date, older_date) + + def test_unreconcile_clears_fields(self): + """Unreconciling the invoice clears both fields.""" + invoice = self._create_posted_invoice() + self._register_payment(invoice) + invoice.line_ids.remove_move_reconcile() + self.assertFalse(invoice.last_payment_date) + self.assertFalse(invoice.last_payment_method) + + def _setup_eur_rates(self, invoice_date, payment_date): + """Set deterministic EUR rates so the payment creates an + exchange difference move. + + :param invoice_date: the invoice posting date. + :param payment_date: the payment date. + :return: the EUR res.currency record. + """ + eur = self.env.ref("base.EUR") + if not eur.active: + eur.active = True + self.env["res.currency.rate"].search( + [ + ("currency_id", "=", eur.id), + ("name", "in", [invoice_date, payment_date]), + ] + ).unlink() + self.env["res.currency.rate"].create( + [ + {"currency_id": eur.id, "name": invoice_date, "rate": 1.0}, + {"currency_id": eur.id, "name": payment_date, "rate": 1.1}, + ] + ) + return eur + + def test_multicurrency_invoice(self): + """A foreign currency payment keeps its real date and method, + and the generated exchange difference move is not taken as a + payment candidate.""" + payment_date = fields.Date.subtract(fields.Date.today(), days=5) + invoice_date = fields.Date.from_string("2019-01-01") + eur = self._setup_eur_rates(invoice_date, payment_date) + invoice = self.init_invoice("out_invoice", post=False, amounts=[1000.0]) + invoice.currency_id = eur + invoice.action_post() + payment = self._register_payment( + invoice, + date=payment_date, + amount=invoice.amount_residual, + ) + self.assertTrue(invoice.line_ids.mapped("matched_credit_ids.exchange_move_id")) + candidates = invoice._get_last_payment_candidates() + self.assertEqual(len(candidates), 1) + self.assertEqual(invoice.last_payment_date, payment.date) + self.assertEqual( + invoice.last_payment_method, payment.payment_method_line_id.name + ) + + def test_draft_invoice_is_empty(self): + """A draft invoice has never been reconciled, so both fields + stay empty.""" + invoice = self.init_invoice("out_invoice", post=False, amounts=[1000.0]) + self.assertFalse(invoice.last_payment_date) + self.assertFalse(invoice.last_payment_method) + + def test_non_invoice_moves_are_empty(self): + """Journal entries and credit notes never carry the fields.""" + invoice = self._create_posted_invoice() + self._register_payment(invoice) + refund = self._create_posted_invoice(move_type="out_refund") + entry = self.env["account.move"].create( + { + "move_type": "entry", + "journal_id": self.company_data["default_journal_misc"].id, + "date": fields.Date.today(), + "line_ids": [ + ( + 0, + 0, + { + "account_id": self.company_data[ + "default_account_revenue" + ].id, + "debit": 100.0, + }, + ), + ( + 0, + 0, + { + "account_id": self.company_data[ + "default_account_receivable" + ].id, + "credit": 100.0, + }, + ), + ], + } + ) + entry.action_post() + self.assertFalse(refund.last_payment_date) + self.assertFalse(refund.last_payment_method) + self.assertFalse(entry.last_payment_date) + self.assertFalse(entry.last_payment_method) + self.assertTrue(invoice.last_payment_date) diff --git a/account_move_last_payment/views/account_move_views.xml b/account_move_last_payment/views/account_move_views.xml new file mode 100644 index 0000000..92ed765 --- /dev/null +++ b/account_move_last_payment/views/account_move_views.xml @@ -0,0 +1,14 @@ + + + + account.invoice.list.last.payment + account.move + + + + + + + + +