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)