From 06b8f058cdf9602ceaab56f82c95ff484c01a2aa Mon Sep 17 00:00:00 2001 From: Akash Raval Date: Thu, 2 Jul 2026 10:48:21 +0530 Subject: [PATCH 1/7] [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/7] [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/7] 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/7] 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/7] [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", From 4e37df6813747a21fc79d0be71617c412295019f Mon Sep 17 00:00:00 2001 From: Akash Raval Date: Tue, 14 Jul 2026 18:28:45 +0530 Subject: [PATCH 6/7] [IMP] account: recompute payment state via _compute_state instead of SQL --- .../account/18.0.1.3/post-migration.py | 86 ++++++------------- .../scripts/account/tests/test_migration.py | 29 +++++-- 2 files changed, 50 insertions(+), 65 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 2e2915a410ac..9849f962f915 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,67 +208,35 @@ def _handle_outstanding_accounts(env): ) -def fix_payment_paid_state(env): - """Correct payments wrongly left in 'in_process' by fill_account_payment. +def recompute_payment_state(env): + """Recompute account.payment.state for payments migrated to 'in_process'. - ``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'. + ``fill_account_payment`` (pre-migration) derives the new 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 written + as 'in_process' -- including payments Odoo 18 classifies as 'paid' (money + posted straight to a non-reconcilable bank/cash account, a fully reconciled + liquidity line, or all reconciled invoices/bills already 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. + That state was written with raw SQL, so the stored compute never ran. Rather + than reimplement every branch of account.payment._compute_state here, just + re-trigger it: it is authoritative for the liquidity cases *and* the + reconciled-invoices/bills-all-paid case, resolves the liquidity lines through + _seek_for_lines (so every account in _get_valid_liquidity_accounts is + covered), and only recomputes is_matched where the core method itself does. + Batched to bound memory; runs after _handle_outstanding_accounts so the + payment-method-line accounts read by _seek_for_lines are already 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) - """, - ) + payment_ids = env["account.payment"].search([("state", "=", "in_process")]).ids + model = env["account.payment"] + for index in range(0, len(payment_ids), 1000): + batch = model.browse(payment_ids[index : index + 1000]) + batch.invalidate_recordset() + batch._compute_state() + batch.flush_recordset() @openupgrade.migrate() @@ -282,7 +250,7 @@ def migrate(env, version): ) convert_company_dependent(env) fill_res_partner_property_x_payment_method_line_id(env) - fix_payment_paid_state(env) + recompute_payment_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/test_migration.py b/openupgrade_scripts/scripts/account/tests/test_migration.py index ba7363a78ec4..1e26dd1c2075 100644 --- a/openupgrade_scripts/scripts/account/tests/test_migration.py +++ b/openupgrade_scripts/scripts/account/tests/test_migration.py @@ -39,16 +39,33 @@ def test_payment_state_direct_to_bank(self): "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. + def test_payment_state_outstanding_not_fully_paid(self): + """Posting to a reconcilable outstanding account (account_type + 'asset_current') that was never bank-reconciled, when the reconciled + invoice is not fully paid: neither _compute_state branch applies, so the + payment must stay 'in_process'. Covers a partial payment of one invoice + and a payment paying several invoices partially. """ - for name in ("s1", "s2", "s3", "s4a", "s4b"): + for name in ("s1", "s2"): 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, ) + + def test_payment_state_outstanding_fully_paid(self): + """Posting to a reconcilable outstanding account, but where all the + reconciled invoices are fully paid: the second branch of _compute_state + (reconciled invoices/bills all 'paid') must promote the payment to + 'paid'. Covers a payment paying an invoice fully and two payments paying + one invoice fully. (On community, a fully paid invoice is 'paid', so the + branch applies.) + """ + for name in ("s3", "s4a", "s4b"): + payment = self.env.ref("openupgrade_test_account.ou_outstanding_%s" % name) + self.assertEqual( + payment.state, + "paid", + "ou_outstanding_%s should have been promoted to 'paid'" % name, + ) From 8ae5918beb39d295b019fed0cd1d999ae08a8c94 Mon Sep 17 00:00:00 2001 From: Akash Raval Date: Wed, 15 Jul 2026 09:57:52 +0530 Subject: [PATCH 7/7] [IMP] account: cover all liquidity accounts and reconciled invoices/bills --- .../account/18.0.1.3/post-migration.py | 126 ++++++++++++++---- .../scripts/account/tests/data.py | 18 +-- .../scripts/account/tests/test_migration.py | 22 +-- 3 files changed, 107 insertions(+), 59 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 9849f962f915..eff692902d92 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,35 +208,105 @@ def _handle_outstanding_accounts(env): ) -def recompute_payment_state(env): - """Recompute account.payment.state for payments migrated to 'in_process'. +def fix_payment_paid_state(env): + """Set 'paid' on the payments that fill_account_payment left as 'in_process'. - ``fill_account_payment`` (pre-migration) derives the new 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 written - as 'in_process' -- including payments Odoo 18 classifies as 'paid' (money - posted straight to a non-reconcilable bank/cash account, a fully reconciled - liquidity line, or all reconciled invoices/bills already paid). - - That state was written with raw SQL, so the stored compute never ran. Rather - than reimplement every branch of account.payment._compute_state here, just - re-trigger it: it is authoritative for the liquidity cases *and* the - reconciled-invoices/bills-all-paid case, resolves the liquidity lines through - _seek_for_lines (so every account in _get_valid_liquidity_accounts is - covered), and only recomputes is_matched where the core method itself does. - Batched to bound memory; runs after _handle_outstanding_accounts so the - payment-method-line accounts read by _seek_for_lines are already populated. + It reads the payment's own move, whose payment_state is always 'not_paid' + below v18 as it is only computed for invoices, so every posted payment ends + up 'in_process'. Apply the two conditions of account.payment._compute_state. """ - payment_ids = env["account.payment"].search([("state", "=", "in_process")]).ids - model = env["account.payment"] - for index in range(0, len(payment_ids), 1000): - batch = model.browse(payment_ids[index : index + 1000]) - batch.invalidate_recordset() - batch._compute_state() - batch.flush_recordset() + # liquidity line fully reconciled, or on a non reconcilable account + openupgrade.logged_query( + env.cr, + """ + WITH liquidity AS ( + SELECT ap.id AS payment_id, + SUM(aml.amount_residual) AS residual, + BOOL_OR(aa.reconcile) AS reconcilable, + cur.rounding AS rounding + FROM account_payment ap + JOIN account_move am ON am.id = ap.move_id + JOIN res_company rc ON rc.id = am.company_id + JOIN res_currency cur ON cur.id = rc.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 + LEFT JOIN account_payment_method_line apml + ON apml.id = ap.payment_method_line_id + WHERE ap.state = 'in_process' + AND ( + aml.account_id = aj.default_account_id + OR aml.account_id = ap.outstanding_account_id + OR aml.account_id = apml.payment_account_id + OR aml.account_id IN ( + SELECT payment_account_id + FROM account_payment_method_line + WHERE journal_id = ap.journal_id + AND payment_account_id IS NOT NULL + ) + ) + GROUP BY ap.id, cur.rounding + ) + UPDATE account_payment ap + SET state = 'paid' + FROM liquidity + WHERE ap.id = liquidity.payment_id + AND ( + NOT liquidity.reconcilable + OR ABS(liquidity.residual) < liquidity.rounding / 2 + ) + """, + ) + # all the reconciled invoices, or all the reconciled bills, are paid + openupgrade.logged_query( + env.cr, + """ + WITH reconciled AS ( + SELECT DISTINCT ap.id AS payment_id, inv.id AS invoice_id, + inv.move_type, inv.payment_state + FROM account_payment ap + JOIN account_move am ON am.id = ap.move_id + JOIN account_move_line aml ON aml.move_id = am.id + JOIN account_account aa ON aa.id = aml.account_id + JOIN account_partial_reconcile apr + ON apr.debit_move_id = aml.id OR apr.credit_move_id = aml.id + JOIN account_move_line counterpart + ON (apr.debit_move_id = counterpart.id + OR apr.credit_move_id = counterpart.id) + AND counterpart.id != aml.id + JOIN account_move inv ON inv.id = counterpart.move_id + WHERE ap.state = 'in_process' + AND aa.account_type IN ('asset_receivable', 'liability_payable') + AND inv.move_type IN ( + 'out_invoice', 'out_refund', 'out_receipt', + 'in_invoice', 'in_refund', 'in_receipt' + ) + ), counts AS ( + SELECT payment_id, + COUNT(*) FILTER (WHERE move_type IN ( + 'out_invoice', 'out_refund', 'out_receipt')) AS invoices, + COUNT(*) FILTER (WHERE move_type IN ( + 'out_invoice', 'out_refund', 'out_receipt') + AND payment_state = 'paid') AS paid_invoices, + COUNT(*) FILTER (WHERE move_type IN ( + 'in_invoice', 'in_refund', 'in_receipt')) AS bills, + COUNT(*) FILTER (WHERE move_type IN ( + 'in_invoice', 'in_refund', 'in_receipt') + AND payment_state = 'paid') AS paid_bills + FROM reconciled + GROUP BY payment_id + ) + UPDATE account_payment ap + SET state = 'paid' + FROM counts + WHERE ap.id = counts.payment_id + AND ap.state = 'in_process' + AND ( + (counts.invoices > 0 AND counts.invoices = counts.paid_invoices) + OR (counts.bills > 0 AND counts.bills = counts.paid_bills) + ) + """, + ) @openupgrade.migrate() @@ -250,7 +320,7 @@ def migrate(env, version): ) convert_company_dependent(env) fill_res_partner_property_x_payment_method_line_id(env) - recompute_payment_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"] diff --git a/openupgrade_scripts/scripts/account/tests/data.py b/openupgrade_scripts/scripts/account/tests/data.py index 5544ec371f44..bb685fedc8db 100644 --- a/openupgrade_scripts/scripts/account/tests/data.py +++ b/openupgrade_scripts/scripts/account/tests/data.py @@ -18,21 +18,9 @@ ).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'. -# --------------------------------------------------------------------------- +# Payments for the state fix in post-migration, in the four reconciliation +# shapes: once on a journal paying from a plain bank account, once on a journal +# paying from a reconcilable outstanding account. company = env.ref("base.main_company") recv = env["account.account"].search( diff --git a/openupgrade_scripts/scripts/account/tests/test_migration.py b/openupgrade_scripts/scripts/account/tests/test_migration.py index 1e26dd1c2075..c2132b60ef11 100644 --- a/openupgrade_scripts/scripts/account/tests/test_migration.py +++ b/openupgrade_scripts/scripts/account/tests/test_migration.py @@ -25,11 +25,8 @@ def test_sending_data(self): ) 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. + """Payments from a non reconcilable bank account are paid, whatever the + reconciliation shape. """ for name in ("s1", "s2", "s3", "s4a", "s4b"): payment = self.env.ref("openupgrade_test_account.ou_direct_%s" % name) @@ -40,11 +37,8 @@ def test_payment_state_direct_to_bank(self): ) def test_payment_state_outstanding_not_fully_paid(self): - """Posting to a reconcilable outstanding account (account_type - 'asset_current') that was never bank-reconciled, when the reconciled - invoice is not fully paid: neither _compute_state branch applies, so the - payment must stay 'in_process'. Covers a partial payment of one invoice - and a payment paying several invoices partially. + """Payments from an outstanding account stay in process while the + invoices they are reconciled with are not paid. """ for name in ("s1", "s2"): payment = self.env.ref("openupgrade_test_account.ou_outstanding_%s" % name) @@ -55,12 +49,8 @@ def test_payment_state_outstanding_not_fully_paid(self): ) def test_payment_state_outstanding_fully_paid(self): - """Posting to a reconcilable outstanding account, but where all the - reconciled invoices are fully paid: the second branch of _compute_state - (reconciled invoices/bills all 'paid') must promote the payment to - 'paid'. Covers a payment paying an invoice fully and two payments paying - one invoice fully. (On community, a fully paid invoice is 'paid', so the - branch applies.) + """Payments from an outstanding account are paid once all the invoices + they are reconciled with are. """ for name in ("s3", "s4a", "s4b"): payment = self.env.ref("openupgrade_test_account.ou_outstanding_%s" % name)