diff --git a/openupgrade_scripts/scripts/account/18.0.1.3/post-migration.py b/openupgrade_scripts/scripts/account/18.0.1.3/post-migration.py index 7e0e6a125c57..2e2915a410ac 100644 --- a/openupgrade_scripts/scripts/account/18.0.1.3/post-migration.py +++ b/openupgrade_scripts/scripts/account/18.0.1.3/post-migration.py @@ -208,6 +208,69 @@ def _handle_outstanding_accounts(env): ) +def fix_payment_paid_state(env): + """Correct payments wrongly left in 'in_process' by fill_account_payment. + + ``fill_account_payment`` (pre-migration) derives account.payment.state only + from the payment's own journal entry: ``am.state`` / ``am.payment_state``. + But a payment move is an 'entry', and account.move._compute_payment_state + only computes a real payment_state for invoices -- so a payment move always + has payment_state = 'not_paid' on <=17. The ``WHEN am.payment_state = 'paid'`` + branch is therefore dead for payments, and *every* posted payment is mapped + to 'in_process', including payments that Odoo 18 would classify as 'paid'. + + On v18 a payment is 'paid' (see account.payment._compute_state / action_post) + when its liquidity line is fully reconciled OR its liquidity account is not + reconcilable (money posted straight to a bank/cash account, account_type + 'asset_cash', instead of to a reconcilable outstanding account). The latter + is invisible on setups that use reconcilable outstanding accounts -- where + 'in_process' is in fact correct -- which is why the issue only reproduces on + databases that post payments directly to the bank/cash account. + + This reproduces that classification in bulk SQL (no per-record ORM cost of + _seek_for_lines): liquidity accounts = the journal default account plus every + payment-method-line payment account on that journal, a complete cover of + _get_valid_liquidity_accounts() (outstanding_account_id is itself just + payment_method_line_id.payment_account_id on v18). Runs after + _handle_outstanding_accounts so those payment accounts are populated. + """ + openupgrade.logged_query( + env.cr, + """ + WITH liq AS ( + SELECT ap.id AS payment_id, + SUM(aml.amount_residual) AS residual, + BOOL_OR(aa.reconcile) AS any_reconcile, + cur.rounding AS rounding + FROM account_payment ap + JOIN account_move am ON am.id = ap.move_id + JOIN res_company comp ON comp.id = am.company_id + JOIN res_currency cur ON cur.id = comp.currency_id + JOIN account_journal aj ON aj.id = ap.journal_id + JOIN account_move_line aml ON aml.move_id = am.id + JOIN account_account aa ON aa.id = aml.account_id + WHERE ap.state = 'in_process' + AND ( + aml.account_id = aj.default_account_id + OR aml.account_id IN ( + SELECT apml.payment_account_id + FROM account_payment_method_line apml + WHERE apml.journal_id = ap.journal_id + AND apml.payment_account_id IS NOT NULL + ) + ) + GROUP BY ap.id, cur.rounding + ) + UPDATE account_payment ap + SET state = 'paid', + is_matched = TRUE + FROM liq + WHERE ap.id = liq.payment_id + AND (NOT liq.any_reconcile OR ABS(liq.residual) < liq.rounding / 2.0) + """, + ) + + @openupgrade.migrate() def migrate(env, version): handle_lock_dates(env) @@ -219,6 +282,7 @@ def migrate(env, version): ) convert_company_dependent(env) fill_res_partner_property_x_payment_method_line_id(env) + fix_payment_paid_state(env) openupgrade.load_data(env, "account", "18.0.1.3/noupdate_changes.xml") openupgrade.delete_record_translations( env.cr, "account", ["email_template_edi_invoice"] diff --git a/openupgrade_scripts/scripts/account/tests/data.py b/openupgrade_scripts/scripts/account/tests/data.py index 9b088680aa9a..5544ec371f44 100644 --- a/openupgrade_scripts/scripts/account/tests/data.py +++ b/openupgrade_scripts/scripts/account/tests/data.py @@ -17,3 +17,163 @@ } ).action_send_and_print() env.cr.commit() + +# --------------------------------------------------------------------------- +# Payment state migration scenarios. +# +# fill_account_payment (pre-migration) maps every posted payment to +# 'in_process', because a payment's own move always has payment_state +# 'not_paid' on <=17. That is wrong for payments Odoo 18 classifies as 'paid' +# (liquidity account not reconcilable, or liquidity line fully reconciled). +# We build the four reconciliation shapes in two journal configurations so the +# post-migration fix can be asserted: +# - DIRECT: liquidity posts to a non-reconcilable bank/cash account +# (account_type 'asset_cash') -> must migrate to 'paid'. +# - OUTSTANDING: liquidity posts to a reconcilable outstanding account +# (account_type 'asset_current'), never bank-reconciled +# -> must stay 'in_process'. +# --------------------------------------------------------------------------- +company = env.ref("base.main_company") + +recv = env["account.account"].search( + [("account_type", "=", "asset_receivable"), ("company_id", "=", company.id)], + limit=1, +) +income = env["account.account"].search( + [("account_type", "=", "income"), ("company_id", "=", company.id)], limit=1 +) + +bank_acc = env["account.account"].create( + { + "name": "OU test direct bank", + "code": "OUDIR", + "account_type": "asset_cash", + "reconcile": False, + "company_id": company.id, + } +) +outstanding_acc = env["account.account"].create( + { + "name": "OU test outstanding", + "code": "OUOUT", + "account_type": "asset_current", + "reconcile": True, + "company_id": company.id, + } +) +outstanding_bank_acc = env["account.account"].create( + { + "name": "OU test outstanding bank", + "code": "OUOBK", + "account_type": "asset_cash", + "reconcile": False, + "company_id": company.id, + } +) + +direct_journal = env["account.journal"].create( + { + "name": "OU test direct bank", + "code": "OUDBK", + "type": "bank", + "company_id": company.id, + "default_account_id": bank_acc.id, + } +) +# liquidity posts straight to the (non-reconcilable) bank account +direct_journal.inbound_payment_method_line_ids.payment_account_id = bank_acc.id +direct_journal.outbound_payment_method_line_ids.payment_account_id = bank_acc.id + +outstanding_journal = env["account.journal"].create( + { + "name": "OU test outstanding bank", + "code": "OUOBK", + "type": "bank", + "company_id": company.id, + "default_account_id": outstanding_bank_acc.id, + } +) +# liquidity posts to the reconcilable outstanding account +outstanding_journal.inbound_payment_method_line_ids.payment_account_id = ( + outstanding_acc.id +) +outstanding_journal.outbound_payment_method_line_ids.payment_account_id = ( + outstanding_acc.id +) + +sale_journal = env["account.journal"].search( + [("type", "=", "sale"), ("company_id", "=", company.id)], limit=1 +) +ou_partner = env["res.partner"].create( + { + "name": "OU test payment partner", + "property_account_receivable_id": recv.id, + } +) + + +def _ou_invoice(amount): + inv = env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": ou_partner.id, + "journal_id": sale_journal.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "ou test", + "quantity": 1, + "price_unit": amount, + "account_id": income.id, + "tax_ids": [(6, 0, [])], + }, + ) + ], + } + ) + inv.action_post() + return inv + + +def _ou_pay(invoices, journal, amount): + wizard = ( + env["account.payment.register"] + .with_context( + active_model="account.move", + active_ids=invoices.ids, + ) + .create({"journal_id": journal.id}) + ) + if len(invoices) > 1: + wizard.group_payment = True + wizard.amount = amount + return wizard._create_payments() + + +def _ou_build(prefix, journal): + # 1) partial payment of one invoice + p1 = _ou_pay(_ou_invoice(1000), journal, 400) + # 2) one payment paying multiple invoices partially + p2 = _ou_pay(_ou_invoice(1000) | _ou_invoice(1000), journal, 800) + # 3) a payment paying an invoice fully + p3 = _ou_pay(_ou_invoice(1000), journal, 1000) + # 4) two payments paying one invoice fully + inv = _ou_invoice(1000) + p4a = _ou_pay(inv, journal, 600) + p4b = _ou_pay(inv, journal, 400) + env["ir.model.data"]._update_xmlids( + [ + {"xml_id": "openupgrade_test_account.%s_s1" % prefix, "record": p1}, + {"xml_id": "openupgrade_test_account.%s_s2" % prefix, "record": p2}, + {"xml_id": "openupgrade_test_account.%s_s3" % prefix, "record": p3}, + {"xml_id": "openupgrade_test_account.%s_s4a" % prefix, "record": p4a}, + {"xml_id": "openupgrade_test_account.%s_s4b" % prefix, "record": p4b}, + ] + ) + + +_ou_build("ou_direct", direct_journal) +_ou_build("ou_outstanding", outstanding_journal) +env.cr.commit() diff --git a/openupgrade_scripts/scripts/account/tests/test_migration.py b/openupgrade_scripts/scripts/account/tests/test_migration.py index e9d14568d468..ba7363a78ec4 100644 --- a/openupgrade_scripts/scripts/account/tests/test_migration.py +++ b/openupgrade_scripts/scripts/account/tests/test_migration.py @@ -23,3 +23,32 @@ def test_sending_data(self): moves_with_sending_data[0].sending_data["author_partner_id"], self.env.user.partner_id.id, ) + + def test_payment_state_direct_to_bank(self): + """Payments posting straight to a non-reconcilable bank/cash account + (account_type 'asset_cash') must migrate to 'paid' for every + reconciliation shape: a partial payment of one invoice, a payment paying + several invoices partially, a payment paying an invoice fully, and two + payments paying one invoice fully. + """ + for name in ("s1", "s2", "s3", "s4a", "s4b"): + payment = self.env.ref("openupgrade_test_account.ou_direct_%s" % name) + self.assertEqual( + payment.state, + "paid", + "ou_direct_%s should have migrated to 'paid'" % name, + ) + + def test_payment_state_outstanding(self): + """The same four reconciliation shapes, but posting to a reconcilable + outstanding account (account_type 'asset_current') that was never + bank-reconciled, must stay 'in_process' -- the fix must not over-promote + them. + """ + for name in ("s1", "s2", "s3", "s4a", "s4b"): + payment = self.env.ref("openupgrade_test_account.ou_outstanding_%s" % name) + self.assertEqual( + payment.state, + "in_process", + "ou_outstanding_%s should have stayed 'in_process'" % name, + )