From 8c03029f2847b11846b6bb40f4b5c254e35e77a1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:25:19 +0530 Subject: [PATCH 01/24] fix(controllers): guard return-rate division against a zero stock qty (Postgres) get_rate_for_return builds Abs(stock_value_difference / actual_qty) for Sales/Delivery returns and passes it to get_value with no actual_qty filter. A matched Stock Ledger Entry with actual_qty=0 (a zero-qty repost / serial-batch row) makes Postgres raise 'division by zero' while MariaDB returns NULL. Wrap the divisor in NullIf(actual_qty, 0) so both engines return NULL. MariaDB output unchanged. Sibling of the already-fixed /actual_qty sites in stock_ledger.py and incorrect_serial_no_valuation.py. --- erpnext/controllers/sales_and_purchase_return.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index db1227e29b2..9cdc0a07cd5 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -7,7 +7,7 @@ import frappe from frappe import _, bold from frappe.model.meta import get_field_precision from frappe.query_builder import DocType -from frappe.query_builder.functions import Abs, Sum +from frappe.query_builder.functions import Abs, NullIf, Sum from frappe.utils import cint, flt, format_datetime, get_datetime import erpnext @@ -766,7 +766,7 @@ def get_rate_for_return( select_field = "incoming_rate" else: StockLedgerEntry = frappe.qb.DocType("Stock Ledger Entry") - select_field = Abs(StockLedgerEntry.stock_value_difference / StockLedgerEntry.actual_qty) + select_field = Abs(StockLedgerEntry.stock_value_difference / NullIf(StockLedgerEntry.actual_qty, 0)) item_details = frappe.get_cached_value("Item", item_code, ["has_batch_no", "has_expiry_date"], as_dict=1) set_zero_rate_for_expired_batch = frappe.db.get_single_value( From 91dae917690c721bedcae7ba7b7631e29bd23de7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:25:39 +0530 Subject: [PATCH 02/24] fix(setup): deterministic tiebreaker in get_exchange_rate Currency Exchange lookup (Postgres) get_exchange_rate orders Currency Exchange by 'date desc' LIMIT 1 with no unique tiebreaker. Currency Exchange autoname {date}-{from}-{to}-{purpose} allows multiple same-date rows (different purpose) for one currency pair; on the no-purpose-filter path all match, so MariaDB and Postgres can return a different exchange_rate for the same inputs. Add 'name desc' so both engines pick the same row. MariaDB row count unchanged. --- erpnext/setup/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index 2fbddcec948..3e0decd8f16 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -95,7 +95,11 @@ def get_exchange_rate( # cksgb 19/09/2016: get last entry in Currency Exchange with from_currency and to_currency. entries = frappe.get_all( - "Currency Exchange", fields=["exchange_rate"], filters=filters, order_by="date desc", limit=1 + "Currency Exchange", + fields=["exchange_rate"], + filters=filters, + order_by="date desc, name desc", + limit=1, ) if entries: return flt(entries[0].exchange_rate) From d7a81affc22c35738ea79f019dea34d696892c1f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:37:59 +0530 Subject: [PATCH 03/24] fix(stock): savepoint repost loop in Stock and Account Value Comparison (Postgres) The item/warehouse loop submits a Repost Item Valuation; a DuplicateEntryError poisons the Postgres transaction, so the next iteration's .submit() raises InFailedSqlTransaction. MariaDB continues. Savepoint per iteration + rollback(save_point=) on the caught duplicate (mirrors repost_item_valuation:782). No-op on MariaDB. --- .../stock_and_account_value_comparison.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index e295c0cb659..28308609f2f 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -202,6 +202,7 @@ def create_reposting_entries(rows: str | list, company: str): for key, sle in item_wh.items(): item_code, warehouse = key + frappe.db.savepoint("repost_value_comparison") try: doc = frappe.get_doc( { @@ -219,7 +220,7 @@ def create_reposting_entries(rows: str | list, company: str): entries.append(get_link_to_form("Repost Item Valuation", doc.name)) except frappe.DuplicateEntryError: - pass + frappe.db.rollback(save_point="repost_value_comparison") if entries: entries = ", ".join(entries) From 1dde2b5f1e0e6b4d2a4303c601d8f686367cafb8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:38:00 +0530 Subject: [PATCH 04/24] fix(stock): savepoint repost loop in Stock Ledger Invariant Check (Postgres) Same shape: the rows loop submits a Repost Item Valuation; a caught DuplicateEntryError poisons the Postgres txn so the next iteration's submit raises InFailedSqlTransaction. Savepoint + rollback(save_point=) before continue. No-op on MariaDB. --- .../stock_ledger_invariant_check.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index edfcde2de2c..137feb5a34c 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -304,6 +304,7 @@ def create_reposting_entries(rows: str | list, item_code: str | None = None, war for row in rows: row = frappe._dict(row) + frappe.db.savepoint("repost_invariant_check") try: doc = frappe.get_doc( { @@ -320,6 +321,7 @@ def create_reposting_entries(rows: str | list, item_code: str | None = None, war entries.append(get_link_to_form("Repost Item Valuation", doc.name)) except frappe.DuplicateEntryError: + frappe.db.rollback(save_point="repost_invariant_check") continue if entries: From 4a572311bc836f835b8ec52fa5046abe3cd7d149 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:38:01 +0530 Subject: [PATCH 05/24] fix(buying): insert default Supplier Scorecard records with ignore_if_duplicate (Postgres) make_default_records inserted Scorecard Variable/Standing rows in a loop and swallowed DuplicateEntryError (frappe.NameError). On Postgres the failed insert poisons the txn so the next iteration's insert raises InFailedSqlTransaction. insert(ignore_if_duplicate=True) emits ON CONFLICT DO NOTHING, never poisoning the txn. No-op on MariaDB. --- .../supplier_scorecard/supplier_scorecard.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py index 7a1db02082e..8c835a29912 100644 --- a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py @@ -405,16 +405,10 @@ def get_default_scorecard_standing(): def make_default_records(): install_variable_docs = get_default_scorecard_variables() for d in install_variable_docs: - try: - d["doctype"] = "Supplier Scorecard Variable" - frappe.get_doc(d).insert() - except frappe.NameError: - pass + d["doctype"] = "Supplier Scorecard Variable" + frappe.get_doc(d).insert(ignore_if_duplicate=True) install_standing_docs = get_default_scorecard_standing() for d in install_standing_docs: - try: - d["doctype"] = "Supplier Scorecard Standing" - frappe.get_doc(d).insert() - except frappe.NameError: - pass + d["doctype"] = "Supplier Scorecard Standing" + frappe.get_doc(d).insert(ignore_if_duplicate=True) From c97eac34bfac1ecf4a6dcb9c33069645d7b29d09 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:38:02 +0530 Subject: [PATCH 06/24] fix(accounts): savepoint per-row bank entry in Bank Transaction upload (Postgres) create_bank_entries loops rows inserting+submitting a Bank Transaction; on failure the except calls bank_transaction.log_error (INSERT) with no rollback, raising InFailedSqlTransaction on Postgres, and the next row runs in the poisoned txn. Savepoint per row + rollback(save_point=) before log_error. No-op on MariaDB. --- .../doctype/bank_transaction/bank_transaction_upload.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py index c2bac737a78..d38d9df6ca0 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py @@ -47,6 +47,7 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str): for key, value in header_map.items(): fields.update({key: d[int(value) - 1]}) + frappe.db.savepoint("bank_entry") try: bank_transaction = frappe.get_doc({"doctype": "Bank Transaction"}) bank_transaction.update(fields) @@ -56,6 +57,7 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str): bank_transaction.submit() success += 1 except Exception: + frappe.db.rollback(save_point="bank_entry") bank_transaction.log_error("Bank entry creation failed") errors += 1 From 298df4d3aa7255bfb900f7ce60d2a794fbf071b0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:38:02 +0530 Subject: [PATCH 07/24] fix(stock): savepoint per-company Material Request creation in reorder (Postgres) create_material_request loops companies inserting+submitting a Material Request; the except calls mr.log_error (INSERT) with no rollback, raising InFailedSqlTransaction on Postgres in the scheduled reorder job, and the next company runs in the poisoned txn. Savepoint per iteration + rollback(save_point=) before log_error. No-op on MariaDB. --- erpnext/stock/reorder_item.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index 5c668fa8a8d..8955c7f46e6 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -216,6 +216,7 @@ def create_material_request(material_requests): company_wise_mr = frappe._dict({}) for request_type in material_requests: for company in material_requests[request_type]: + frappe.db.savepoint("reorder_mr") try: items = material_requests[request_type][company] if not items: @@ -287,6 +288,7 @@ def create_material_request(material_requests): company_wise_mr.setdefault(company, []).append(mr) except Exception as exception: + frappe.db.rollback(save_point="reorder_mr") exceptions_list.append(exception) mr.log_error("Unable to create material request") From 09a3eb8509179e97761bc48a1a66520b41b9bba0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:42:47 +0530 Subject: [PATCH 08/24] fix(integrations): savepoint the Plaid bank-account update branch + rollback add_institution (Postgres) add_bank_accounts hardened only the INSERT branch with savepoint('plaid_bank_account'); the parallel else/UPDATE branch ran log_error+throw after a failed existing_account.save() with no rollback -> InFailedSqlTransaction on Postgres (masking the friendly throw). Mirror the insert branch with savepoint('plaid_update_account')+rollback. Also add_institution's except log_error after a failed bank.insert() now rolls back first. No-op on MariaDB. --- .../doctype/plaid_settings/plaid_settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index a4113dfcab4..25d5a861a4b 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -69,6 +69,7 @@ def add_institution(token: str, response: str | dict): ) bank.insert() except Exception: + frappe.db.rollback() frappe.log_error("Plaid Link Error") else: bank = frappe.get_doc("Bank", response["institution"]["name"]) @@ -154,6 +155,7 @@ def add_bank_accounts(response: str | dict, bank: str | dict, company: str): ) else: + frappe.db.savepoint("plaid_update_account") try: existing_account = frappe.get_doc("Bank Account", existing_bank_account) existing_account.update( @@ -169,6 +171,7 @@ def add_bank_accounts(response: str | dict, bank: str | dict, company: str): existing_account.save() result.append(existing_bank_account) except Exception: + frappe.db.rollback(save_point="plaid_update_account") frappe.log_error("Plaid Link Error") frappe.throw( _("There was an error updating Bank Account {0} while linking with Plaid.").format( From 01811ccf8527ee2c632a72fe20f9f55c3c98aef3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:42:48 +0530 Subject: [PATCH 09/24] fix(telephony): rollback before logging in call_log link_existing_conversations (Postgres) The hook saves Call Logs in a loop; on failure the except calls frappe.log_error (INSERT) with no rollback, raising InFailedSqlTransaction on Postgres (it runs on every Contact create/update). Full frappe.db.rollback() before log_error. No-op on MariaDB. --- erpnext/telephony/doctype/call_log/call_log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/telephony/doctype/call_log/call_log.py b/erpnext/telephony/doctype/call_log/call_log.py index 9c00e5aeb6d..2a6660c101f 100644 --- a/erpnext/telephony/doctype/call_log/call_log.py +++ b/erpnext/telephony/doctype/call_log/call_log.py @@ -196,6 +196,7 @@ def link_existing_conversations(doc, state): if not frappe.in_test: frappe.db.commit() except Exception: + frappe.db.rollback() frappe.log_error(title=_("Error during caller information update")) From 8c0b4a99cf80f84ab727c8763402eab7566a6774 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:42:48 +0530 Subject: [PATCH 10/24] fix(setup): rollback before logging in install_country_fixtures (Postgres) Regional fixture setup writes docs; on failure the except calls frappe.log_error before frappe.throw with no rollback -> InFailedSqlTransaction on Postgres. Full rollback before log_error. No-op on MariaDB. --- erpnext/setup/doctype/company/company.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 59064847173..5bd0ee104f0 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -856,6 +856,7 @@ def install_country_fixtures(company, country): except ImportError: pass except Exception: + frappe.db.rollback() frappe.log_error("Unable to set country fixtures") frappe.throw( _("Failed to setup defaults for country {0}. Please contact support.").format( From 44458b0ba5d345794560e7aad4594afcb93e00f1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:42:49 +0530 Subject: [PATCH 11/24] fix(setup): rollback before logging in update_regional_tax_settings (Postgres) Regional tax-template setup writes docs; on failure the except calls frappe.log_error with no rollback -> InFailedSqlTransaction on Postgres. Full rollback before log_error. No-op on MariaDB. --- erpnext/setup/setup_wizard/operations/taxes_setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py index 5de54ddf53f..2e5e2c0c092 100644 --- a/erpnext/setup/setup_wizard/operations/taxes_setup.py +++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py @@ -127,6 +127,7 @@ def update_regional_tax_settings(country, company): pass except Exception: # Log error and ignore if failed to setup regional tax settings + frappe.db.rollback() frappe.log_error("Unable to setup regional tax settings") From 790560ebf8caf4ffab4e94afa8a3426083f9a10a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:45:19 +0530 Subject: [PATCH 12/24] fix(stock): rollback before marking Stock Closing Entry failed (Postgres) prepare_closing_stock_balance (background job) saves Stock Closing Balance rows + db_set status; on failure the except runs db_set('Failed')+log_error with no rollback, raising InFailedSqlTransaction on Postgres so the doc is never marked Failed and the job dies. Full frappe.db.rollback() before the handler's db_set. No-op on MariaDB. --- erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py index 106983efc9d..00a3b0204c4 100644 --- a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py @@ -152,6 +152,7 @@ def prepare_closing_stock_balance(name): doc.create_stock_closing_balance_entries() doc.db_set("status", "Completed") except Exception: + frappe.db.rollback() doc.db_set("status", "Failed") doc.log_error(title="Stock Closing Entry Failed") From c643fe5274ffe6c529b241a662431b7365e50628 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:45:20 +0530 Subject: [PATCH 13/24] fix(manufacturing): rollback before marking BOM Creator failed (Postgres) create_production_plan_bom (background job) save+submits BOMs in a loop; on failure the except runs self.db_set(status=Failed, error_log) with no rollback, raising InFailedSqlTransaction on Postgres so status is never set. Full frappe.db.rollback() at the top of the except. No-op on MariaDB. --- erpnext/manufacturing/doctype/bom_creator/bom_creator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index 531fe9826f9..84f10f1c1ee 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -313,6 +313,7 @@ class BOMCreator(Document): frappe.msgprint(_("BOMs created successfully")) except Exception: + frappe.db.rollback() traceback = frappe.get_traceback(with_context=True) self.db_set( { From 5110e7f0fd2c966bb4f00c65ab8ee0585052ecd9 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:45:21 +0530 Subject: [PATCH 14/24] fix(accounts): rollback before log_error in deferred-accounting in_test branch (Postgres) book_deferred_entries' make_gl_entries failure path: the else branch already rolls back before log_error, but the frappe.in_test branch ran doc.log_error then re-raised with no rollback -> on Postgres log_error hits InFailedSqlTransaction and masks the original error. Rollback before log_error in the in_test branch too. No-op on MariaDB. --- erpnext/accounts/deferred_revenue.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py index 83ab5badb50..ab4ee51eb6f 100644 --- a/erpnext/accounts/deferred_revenue.py +++ b/erpnext/accounts/deferred_revenue.py @@ -582,6 +582,7 @@ def make_gl_entries( frappe.db.commit() except Exception as e: if frappe.in_test: + frappe.db.rollback() doc.log_error(f"Error while processing deferred accounting for Invoice {doc.name}") raise e else: From 6b0f3cd24301280ac031c6a7b54f2cf71d20d5e0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:45:22 +0530 Subject: [PATCH 15/24] fix(crm): rollback before logging in Frappe CRM webhook handlers (Postgres) create_prospect/create_address/create_customer insert docs and on failure call frappe.log_error with no rollback; on Postgres (untrusted external CRM webhook input) a failed insert poisons the txn so log_error raises InFailedSqlTransaction. Full frappe.db.rollback() before each log_error. No-op on MariaDB. --- erpnext/crm/frappe_crm_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 0230dda7925..9b1b77755a8 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -26,6 +26,7 @@ def create_prospect_against_crm_deal(): prospect.insert() prospect_name = prospect.name except Exception: + frappe.db.rollback() frappe.log_error( frappe.get_traceback(), f"Error while creating prospect against CRM Deal: {frappe.form_dict.get('crm_deal_id')}", @@ -97,6 +98,7 @@ def create_address(doctype, docname, address): address.save(ignore_permissions=True) return address.name except Exception: + frappe.db.rollback() frappe.log_error(frappe.get_traceback(), f"Error while creating address for {docname}") @@ -157,6 +159,7 @@ def create_customer(customer_data: dict | None = None): create_address("Customer", customer_name, customer_data.get("address")) return customer_name except Exception: + frappe.db.rollback() frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal") pass From f3785f10a283890c10c4bc76fe201ed5a98fa99a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:48:25 +0530 Subject: [PATCH 16/24] fix(accounts): savepoint per-row merge in Ledger Merge (Postgres) start_merge merges accounts in a loop; on failure it only rolled back when not in_test, so in tests a failed merge_account left the Postgres txn poisoned and the except log_error + the finally db_set(status) raised InFailedSqlTransaction. Wrap each row in savepoint('ledger_merge_row') and rollback to it unconditionally before log_error - this recovers the txn in both paths without the full rollback discarding the rest of the test transaction. Production still commits per successful merge, so the per-iteration savepoint rollback is equivalent to the prior full rollback. No-op on MariaDB. --- erpnext/accounts/doctype/ledger_merge/ledger_merge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py index a219e21526d..cd574eafaa3 100644 --- a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py +++ b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py @@ -65,6 +65,7 @@ def start_merge(docname): total = len(ledger_merge.merge_accounts) for row in ledger_merge.merge_accounts: if not row.merged: + frappe.db.savepoint("ledger_merge_row") try: merge_account( row.account, @@ -79,8 +80,7 @@ def start_merge(docname): {"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total}, ) except Exception: - if not frappe.in_test: - frappe.db.rollback() + frappe.db.rollback(save_point="ledger_merge_row") ledger_merge.log_error("Ledger merge failed") finally: if successful_merges == total: From 944eeb5921ff8477c5d7656b75b26513cf2c311f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:48:26 +0530 Subject: [PATCH 17/24] fix(accounts): savepoint subscription-status update loop in Payment Entry (Postgres) trigger_invoice_update_for_subscriptions loops invoices calling refresh_subscription_status (db_set/save); on failure the except calls frappe.log_error with no rollback, raising InFailedSqlTransaction on Postgres, and the next invoice runs in the poisoned txn. Savepoint per iteration + rollback(save_point=) before log_error. No-op on MariaDB. --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 3b6cb7920b9..b4005436ec0 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -514,10 +514,12 @@ class PaymentEntry(AccountsController): invoice_names.add((ref.reference_doctype, ref.reference_name)) for doctype, name in invoice_names: + frappe.db.savepoint("subscription_update") try: doc = frappe.get_doc(doctype, name) doc.refresh_subscription_status() except Exception: + frappe.db.rollback(save_point="subscription_update") frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name)) def set_missing_values(self): From 4feb9f9910da45d0110172829400611e951e24fe Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:48:27 +0530 Subject: [PATCH 18/24] fix(assets): savepoint per-entry depreciation posting (Postgres) make_depreciation_entry posts a Journal Entry per schedule row in a loop; the except only stored the error, so the next row's je.save()/submit() ran on the Postgres-poisoned txn (InFailedSqlTransaction). Savepoint per iteration + rollback(save_point=) before storing the error; the final raise of the collected error is unchanged. No-op on MariaDB. --- erpnext/assets/doctype/asset/depreciation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index 762ed796056..a954d1c9981 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -187,6 +187,7 @@ def make_depreciation_entry( for d in depr_schedule_doc.get("depreciation_schedule")[ (sch_start_idx or 0) : (sch_end_idx or len(depr_schedule_doc.get("depreciation_schedule"))) ]: + frappe.db.savepoint("depr_entry") try: _make_journal_entry_for_depreciation( depr_schedule_doc, @@ -202,6 +203,7 @@ def make_depreciation_entry( accounting_dimensions, ) except Exception as e: + frappe.db.rollback(save_point="depr_entry") depr_posting_error = e asset.reload() From f41e8208d850de7f98ca5928c774b409ce3294fb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 22:48:28 +0530 Subject: [PATCH 19/24] fix(crm): savepoint Email Campaign send loop (Postgres) send_mail (called per campaign schedule in a loop) inserts a Communication via make(); on failure the except calls frappe.log_error with no rollback, raising InFailedSqlTransaction on Postgres and poisoning subsequent sends. Savepoint before make() + rollback(save_point=) before log_error. No-op on MariaDB. --- erpnext/crm/doctype/email_campaign/email_campaign.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.py b/erpnext/crm/doctype/email_campaign/email_campaign.py index 4454ede5310..bf0379b8e32 100644 --- a/erpnext/crm/doctype/email_campaign/email_campaign.py +++ b/erpnext/crm/doctype/email_campaign/email_campaign.py @@ -174,6 +174,7 @@ def send_mail(entry, email_campaign): subject = frappe.render_template(email_template.get("subject"), context) content = frappe.render_template(email_template.response_, context) + frappe.db.savepoint("email_campaign_send") try: comm = make( doctype="Email Campaign", @@ -197,6 +198,7 @@ def send_mail(entry, email_campaign): queue_separately=True, ) except Exception: + frappe.db.rollback(save_point="email_campaign_send") frappe.log_error(title="Email Campaign Failed.") return comm From b0331f13f1efda7a6b2d8c62ee14947d55bb2447 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 12:17:36 +0530 Subject: [PATCH 20/24] fix(accounts): log bank-entry failure without the rolled-back doc (review) The savepoint rollback erases the just-inserted Bank Transaction row, so bank_transaction.log_error() created an Error Log pointing at a row that no longer exists. Use frappe.log_error(title=...) with no doc reference. --- .../doctype/bank_transaction/bank_transaction_upload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py index d38d9df6ca0..2f88410fc26 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction_upload.py @@ -58,7 +58,7 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str): success += 1 except Exception: frappe.db.rollback(save_point="bank_entry") - bank_transaction.log_error("Bank entry creation failed") + frappe.log_error(title="Bank entry creation failed") errors += 1 return {"success": success, "errors": errors} From 16a6a4913eeaad4378fda4a4a8465bf5dd03e002 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 12:17:37 +0530 Subject: [PATCH 21/24] fix(stock): log Material Request failure without the rolled-back doc (review) After rollback(save_point=reorder_mr) discards the just-inserted Material Request, mr.log_error() left a dangling Error Log reference. Use frappe.log_error(title=...). --- erpnext/stock/reorder_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index 8955c7f46e6..dc6168f52ac 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -290,7 +290,7 @@ def create_material_request(material_requests): except Exception as exception: frappe.db.rollback(save_point="reorder_mr") exceptions_list.append(exception) - mr.log_error("Unable to create material request") + frappe.log_error(title="Unable to create material request") if company_wise_mr: if getattr(frappe.local, "reorder_email_notify", None) is None: From bd57e43446c624c4bf6644e3de16ccb2ab8f9e6a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 12:17:38 +0530 Subject: [PATCH 22/24] fix(setup): scope regional-tax-settings rollback to a savepoint (review) from_detailed_data inserts tax templates/accounts before update_regional_tax_settings in the same transaction; a full frappe.db.rollback() on regional-setup failure discarded those templates while the wizard continued. Take a savepoint before the regional call and roll back only to it. --- erpnext/setup/setup_wizard/operations/taxes_setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py index 2e5e2c0c092..d3b7e2a03fd 100644 --- a/erpnext/setup/setup_wizard/operations/taxes_setup.py +++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py @@ -120,6 +120,7 @@ def from_detailed_data(company_name, data): def update_regional_tax_settings(country, company): path = frappe.get_app_path("erpnext", "regional", frappe.scrub(country)) if os.path.exists(path.encode("utf-8")): + frappe.db.savepoint("regional_tax_settings") try: module_name = f"erpnext.regional.{frappe.scrub(country)}.setup.update_regional_tax_settings" frappe.get_attr(module_name)(country, company) @@ -127,7 +128,7 @@ def update_regional_tax_settings(country, company): pass except Exception: # Log error and ignore if failed to setup regional tax settings - frappe.db.rollback() + frappe.db.rollback(save_point="regional_tax_settings") frappe.log_error("Unable to setup regional tax settings") From a36065931d13db59b5210876fff91e6826c32480 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 12:17:39 +0530 Subject: [PATCH 23/24] fix(telephony): scope link_existing_conversations rollback to a savepoint (review) link_existing_conversations is the Contact after_insert hook; a full frappe.db.rollback() on a failed call_log.save() would discard the triggering Contact insert itself (and, in test mode, the whole unit of work). Savepoint the hook's DB work and roll back only to it. --- erpnext/telephony/doctype/call_log/call_log.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/telephony/doctype/call_log/call_log.py b/erpnext/telephony/doctype/call_log/call_log.py index 2a6660c101f..c932eb515db 100644 --- a/erpnext/telephony/doctype/call_log/call_log.py +++ b/erpnext/telephony/doctype/call_log/call_log.py @@ -163,6 +163,7 @@ def link_existing_conversations(doc, state): return if doc.doctype != "Contact": return + frappe.db.savepoint("link_call_logs") try: numbers = [d.phone for d in doc.phone_nos] @@ -196,7 +197,7 @@ def link_existing_conversations(doc, state): if not frappe.in_test: frappe.db.commit() except Exception: - frappe.db.rollback() + frappe.db.rollback(save_point="link_call_logs") frappe.log_error(title=_("Error during caller information update")) From 460bb9e5d0563444f49f143b720bae8034d60ebd Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 13:01:32 +0530 Subject: [PATCH 24/24] fix(crm): scope create_address rollback to a savepoint (review) create_address is a helper called by create_prospect/create_customer AFTER they insert the Prospect/Customer. Its full frappe.db.rollback() on an address-save failure rolled back the caller's just-inserted parent doc, then swallowed the exception, so the caller returned a Prospect/Customer name that no longer existed. Scope the rollback to savepoint('crm_create_address') so only the address work is undone; the parent doc survives and the failed address is just logged. --- erpnext/crm/frappe_crm_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 9b1b77755a8..a13109181a1 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -71,6 +71,7 @@ def create_address(doctype, docname, address): if not address: return address = frappe.parse_json(address) + frappe.db.savepoint("crm_create_address") try: _address = frappe.db.exists("Address", address.get("name")) if not _address: @@ -98,7 +99,7 @@ def create_address(doctype, docname, address): address.save(ignore_permissions=True) return address.name except Exception: - frappe.db.rollback() + frappe.db.rollback(save_point="crm_create_address") frappe.log_error(frappe.get_traceback(), f"Error while creating address for {docname}")