From b926b846b14947ea3963ed126455f02a21571fe2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 1 Jul 2026 11:55:40 +0530 Subject: [PATCH 1/4] fix(accounts): savepoint auto_create_fiscal_year loop to survive a duplicate year on Postgres The daily scheduler loops creating next-year Fiscal Years (autoname=field:year). A duplicate-year INSERT aborts the statement; on Postgres that poisons the whole transaction, so the next iteration's get_doc/insert dies with InFailedSqlTransaction. MariaDB statement-rolls-back and continues. Wrap each iteration in a savepoint + rollback(save_point=) in the DuplicateEntryError branch -- a strict no-op on MariaDB (same INSERT, same skip), recovers the txn on Postgres. Co-Authored-By: Claude Opus 4.8 --- erpnext/accounts/doctype/fiscal_year/fiscal_year.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py index 553f8d8d9d5..f376047c37b 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py @@ -107,6 +107,9 @@ def auto_create_fiscal_year(): ) for d in fiscal_year: + # savepoint so a duplicate-year INSERT (Fiscal Year autoname=field:year) that aborts the + # statement doesn't poison the whole scheduler transaction on Postgres and kill the next iteration + frappe.db.savepoint("auto_create_fiscal_year") try: current_fy = frappe.get_doc("Fiscal Year", d[0]) @@ -127,7 +130,7 @@ def auto_create_fiscal_year(): new_fy.insert(ignore_permissions=True) except frappe.NameError: - pass + frappe.db.rollback(save_point="auto_create_fiscal_year") def get_from_and_to_date(fiscal_year): From c58a4026a74855b134913b01846813128490eaba Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 1 Jul 2026 11:55:41 +0530 Subject: [PATCH 2/4] fix(integrations): per-transaction savepoint in Plaid sync_transactions new_bank_transaction inserts+submits Bank Transactions in a loop within one transaction. On a failed insert/submit, Postgres poisons the transaction so the except's log_error dies with InFailedSqlTransaction; MariaDB keeps the Bank Transactions synced before the failure. A full rollback would discard those on MariaDB too, so wrap each iteration in a savepoint + rollback(save_point=) and re-raise -- preserves MariaDB's partial-sync behaviour and heals the Postgres txn. The sibling handlers add_institution / add_bank_accounts were already fixed; this closes the third. Co-Authored-By: Claude Opus 4.8 --- .../doctype/plaid_settings/plaid_settings.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 25d5a861a4b..bcab065bba4 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -215,7 +215,14 @@ def sync_transactions(bank, bank_account): result = [] if transactions: for transaction in reversed(transactions): - result += new_bank_transaction(transaction) + # per-transaction savepoint: a failed insert/submit must not discard the Bank + # Transactions already synced this run (MariaDB keeps them) nor poison the txn on Postgres + frappe.db.savepoint("plaid_sync_txn") + try: + result += new_bank_transaction(transaction) + except Exception: + frappe.db.rollback(save_point="plaid_sync_txn") + raise if result: last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date") From 76b31d9269f0ba123563ad2d62b0afabb6e42c84 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 1 Jul 2026 11:55:42 +0530 Subject: [PATCH 3/4] fix(crm): scope create_customer rollback so a contact/address failure keeps the Customer create_customer wrapped customer.insert() + create_contacts() + create_address() in one try whose except did a full frappe.db.rollback(), so a failure while linking contacts/address discarded the Customer just created (MariaDB kept it pre-migration). Split the try: the customer insert keeps its full rollback (safe -- nothing precedes it), and contact/address linking runs under a savepoint so its failure rolls back only the links, preserving the Customer and healing the Postgres txn. Co-Authored-By: Claude Opus 4.8 --- erpnext/crm/frappe_crm_api.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index a13109181a1..ca7dc56b556 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -154,15 +154,23 @@ def create_customer(customer_data: dict | None = None): customer.set(field, customer_data.get(field)) customer.insert(ignore_permissions=True) customer_name = customer.name - - contacts = frappe.parse_json(customer_data.get("contacts")) - create_contacts(contacts, customer_name, "Customer", customer_name) - 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 + return + + # Link contacts/address under a savepoint so a failure here does NOT discard the Customer just + # created (a full rollback would; MariaDB kept it pre-migration). Linking is best-effort. + frappe.db.savepoint("crm_customer_links") + try: + contacts = frappe.parse_json(customer_data.get("contacts")) + create_contacts(contacts, customer_name, "Customer", customer_name) + create_address("Customer", customer_name, customer_data.get("address")) + except Exception: + frappe.db.rollback(save_point="crm_customer_links") + frappe.log_error(frappe.get_traceback(), "Error while linking contacts/address to new Customer") + + return customer_name def validate_frappe_crm_sync(): From 59b49120b782e6b3f59116bcb4c873ddb66d9a40 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 1 Jul 2026 12:28:36 +0530 Subject: [PATCH 4/4] fix(crm): keep returning None from create_customer on a linking failure Preserve the pre-existing contract: create_customer returned None when contact/address linking failed. The savepoint fix kept the Customer (good) but started returning its name in that case, so a CRM caller treating a non-None return as full success could skip its retry/error handling. Return None on a linking failure while still keeping the Customer. (greptile #56683) Co-Authored-By: Claude Opus 4.8 --- 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 ca7dc56b556..bbb0b8e5215 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -169,6 +169,9 @@ def create_customer(customer_data: dict | None = None): except Exception: frappe.db.rollback(save_point="crm_customer_links") frappe.log_error(frappe.get_traceback(), "Error while linking contacts/address to new Customer") + # keep the Customer, but preserve the pre-existing contract of returning None on a linking failure + # so CRM callers still see the failure signal + return return customer_name