From 06b8f058cdf9602ceaab56f82c95ff484c01a2aa Mon Sep 17 00:00:00 2001 From: Akash Raval Date: Thu, 2 Jul 2026 10:48:21 +0530 Subject: [PATCH 1/5] [18.0][OU-FIX] account: correct payment state for migrated payments [MIG] account: recompute payment states post-migration During the 18.0 migration, `fill_account_payment` maps all <=17 payments to 'in_process' because a payment's own journal entry defaults to `payment_state = 'not_paid'`. Consequently, fully settled payments fail to migrate to the 'paid' state. This introduces a post-migration script to re-trigger `_compute_state()` for in-process payments. This ensures fully reconciled payments are promoted to 'paid', accurately reflecting their true status. --- .../account/18.0.1.3/post-migration.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) 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..d7f70007babf 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 @@ -1,8 +1,12 @@ # Copyright 2025 ForgeFlow S.L. (https://www.forgeflow.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + from openupgradelib import openupgrade, openupgrade_180 +_logger = logging.getLogger("odoo.upgrade.account.18.0.1.3") + def handle_lock_dates(env): openupgrade.logged_query( @@ -208,6 +212,53 @@ def _handle_outstanding_accounts(env): ) +def recompute_payment_paid_state(env): + """Re-derive account.payment.state for migrated payments. + + ``fill_account_payment`` (pre-migration) maps *every* posted <=17 payment to + 'in_process', because a payment's own journal entry always has + ``payment_state = 'not_paid'`` on <=17: account.move._compute_payment_state + only computes a real payment_state for invoices, and a payment move is an + 'entry'. So the ``WHEN am.payment_state = 'paid' THEN 'paid'`` branch of that + CASE is dead for payments, and fully settled/bank-reconciled payments are + wrongly left in 'in_process' instead of 'paid'. + + Re-run the native ``_compute_state`` so payments whose liquidity line is fully + reconciled (``amount_residual == 0``) or whose liquidity account is not + reconcilable get promoted to 'paid'; genuinely pending payments stay + 'in_process'. Must run after ``link_payments_to_moves`` and + ``_handle_outstanding_accounts`` so that ``_seek_for_lines`` can resolve the + liquidity lines and outstanding accounts. + """ + env.cr.execute( + "SELECT id FROM account_payment WHERE state = 'in_process' ORDER BY id" + ) + payment_ids = [row[0] for row in env.cr.fetchall()] + if not payment_ids: + return + _logger.info( + "Re-deriving state for %s payment(s) migrated as 'in_process'", + len(payment_ids), + ) + Payment = env["account.payment"] + for index in range(0, len(payment_ids), 1000): + batch = Payment.browse(payment_ids[index : index + 1000]) + batch.invalidate_recordset(["state"]) + batch._compute_state() + batch.flush_recordset(["state", "is_matched"]) + env.invalidate_all() + env.cr.execute( + "SELECT COUNT(*) FROM account_payment WHERE id = ANY(%s) AND state = 'paid'", + (payment_ids,), + ) + promoted = env.cr.fetchone()[0] + _logger.info( + "Promoted %s/%s migrated payment(s) from 'in_process' to 'paid'", + promoted, + len(payment_ids), + ) + + @openupgrade.migrate() def migrate(env, version): handle_lock_dates(env) @@ -219,6 +270,7 @@ def migrate(env, version): ) convert_company_dependent(env) fill_res_partner_property_x_payment_method_line_id(env) + recompute_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"] From efc0ed03754c35ebe23a2054e23e0ae309ccee46 Mon Sep 17 00:00:00 2001 From: Akash Raval Date: Fri, 3 Jul 2026 09:51:45 +0530 Subject: [PATCH 2/5] [18.0][IMP] account: correct payment state for migrated payments --- .../account/18.0.1.3/post-migration.py | 104 ++++++++++-------- 1 file changed, 58 insertions(+), 46 deletions(-) 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 d7f70007babf..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 @@ -1,12 +1,8 @@ # Copyright 2025 ForgeFlow S.L. (https://www.forgeflow.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -import logging - from openupgradelib import openupgrade, openupgrade_180 -_logger = logging.getLogger("odoo.upgrade.account.18.0.1.3") - def handle_lock_dates(env): openupgrade.logged_query( @@ -212,50 +208,66 @@ def _handle_outstanding_accounts(env): ) -def recompute_payment_paid_state(env): - """Re-derive account.payment.state for migrated payments. +def fix_payment_paid_state(env): + """Correct payments wrongly left in 'in_process' by fill_account_payment. - ``fill_account_payment`` (pre-migration) maps *every* posted <=17 payment to - 'in_process', because a payment's own journal entry always has - ``payment_state = 'not_paid'`` on <=17: account.move._compute_payment_state - only computes a real payment_state for invoices, and a payment move is an - 'entry'. So the ``WHEN am.payment_state = 'paid' THEN 'paid'`` branch of that - CASE is dead for payments, and fully settled/bank-reconciled payments are - wrongly left in 'in_process' instead of 'paid'. + ``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'. - Re-run the native ``_compute_state`` so payments whose liquidity line is fully - reconciled (``amount_residual == 0``) or whose liquidity account is not - reconcilable get promoted to 'paid'; genuinely pending payments stay - 'in_process'. Must run after ``link_payments_to_moves`` and - ``_handle_outstanding_accounts`` so that ``_seek_for_lines`` can resolve the - liquidity lines and outstanding accounts. + 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. """ - env.cr.execute( - "SELECT id FROM account_payment WHERE state = 'in_process' ORDER BY id" - ) - payment_ids = [row[0] for row in env.cr.fetchall()] - if not payment_ids: - return - _logger.info( - "Re-deriving state for %s payment(s) migrated as 'in_process'", - len(payment_ids), - ) - Payment = env["account.payment"] - for index in range(0, len(payment_ids), 1000): - batch = Payment.browse(payment_ids[index : index + 1000]) - batch.invalidate_recordset(["state"]) - batch._compute_state() - batch.flush_recordset(["state", "is_matched"]) - env.invalidate_all() - env.cr.execute( - "SELECT COUNT(*) FROM account_payment WHERE id = ANY(%s) AND state = 'paid'", - (payment_ids,), - ) - promoted = env.cr.fetchone()[0] - _logger.info( - "Promoted %s/%s migrated payment(s) from 'in_process' to 'paid'", - promoted, - len(payment_ids), + 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) + """, ) @@ -270,7 +282,7 @@ def migrate(env, version): ) convert_company_dependent(env) fill_res_partner_property_x_payment_method_line_id(env) - recompute_payment_paid_state(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"] From 6981e442a8d27e6f1962f1d2f5ef1ebc8b49c4cc Mon Sep 17 00:00:00 2001 From: Akash Raval Date: Wed, 8 Jul 2026 12:46:58 +0530 Subject: [PATCH 3/5] update test data --- .../scripts/account/tests/data.py | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/openupgrade_scripts/scripts/account/tests/data.py b/openupgrade_scripts/scripts/account/tests/data.py index 9b088680aa9a..f190c62574c2 100644 --- a/openupgrade_scripts/scripts/account/tests/data.py +++ b/openupgrade_scripts/scripts/account/tests/data.py @@ -17,3 +17,114 @@ } ).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() From f6db1ddb3f046584de085f54275c63b07be573d5 Mon Sep 17 00:00:00 2001 From: Akash Raval Date: Wed, 8 Jul 2026 12:47:31 +0530 Subject: [PATCH 4/5] update test migration script --- .../scripts/account/tests/test_migration.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/openupgrade_scripts/scripts/account/tests/test_migration.py b/openupgrade_scripts/scripts/account/tests/test_migration.py index e9d14568d468..1e64b2618869 100644 --- a/openupgrade_scripts/scripts/account/tests/test_migration.py +++ b/openupgrade_scripts/scripts/account/tests/test_migration.py @@ -23,3 +23,34 @@ 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, + ) From fba7472065032b57951293fcb274c6e93d2aab7e Mon Sep 17 00:00:00 2001 From: Akash Raval Date: Fri, 10 Jul 2026 10:32:02 +0530 Subject: [PATCH 5/5] [FIX] account: pre-commit fixes --- .../scripts/account/tests/data.py | 139 ++++++++++++------ .../scripts/account/tests/test_migration.py | 4 +- 2 files changed, 95 insertions(+), 48 deletions(-) diff --git a/openupgrade_scripts/scripts/account/tests/data.py b/openupgrade_scripts/scripts/account/tests/data.py index f190c62574c2..5544ec371f44 100644 --- a/openupgrade_scripts/scripts/account/tests/data.py +++ b/openupgrade_scripts/scripts/account/tests/data.py @@ -43,62 +43,109 @@ [("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, -}) +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, -}) +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, -}) +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 +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, -}) +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 = 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}) + 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 @@ -116,13 +163,15 @@ def _ou_build(prefix, journal): 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}, - ]) + 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) diff --git a/openupgrade_scripts/scripts/account/tests/test_migration.py b/openupgrade_scripts/scripts/account/tests/test_migration.py index 1e64b2618869..ba7363a78ec4 100644 --- a/openupgrade_scripts/scripts/account/tests/test_migration.py +++ b/openupgrade_scripts/scripts/account/tests/test_migration.py @@ -46,9 +46,7 @@ def test_payment_state_outstanding(self): them. """ for name in ("s1", "s2", "s3", "s4a", "s4b"): - payment = self.env.ref( - "openupgrade_test_account.ou_outstanding_%s" % name - ) + payment = self.env.ref("openupgrade_test_account.ou_outstanding_%s" % name) self.assertEqual( payment.state, "in_process",