From 44e402cf939d376847d75acc879591fe45a1d367 Mon Sep 17 00:00:00 2001 From: akeenkarkare Date: Thu, 26 Jun 2025 11:24:06 +0000 Subject: [PATCH 01/13] Added working reconciliation --- .../easebuzz_settlement_log.py | 250 ++++++++++-------- 1 file changed, 133 insertions(+), 117 deletions(-) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index ea2b407..6b21531 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -9,121 +9,137 @@ class EasebuzzSettlementLog(Document): pass def process_log(doc,method=None): - try: - data = doc.data[25:-73] - data = json.loads(data) - for split in data.get('split_payouts'): - label = split.get("account_label") - company = frappe.db.get_value("Bank Account",{'account_name':label},'company') - company = frappe.get_doc("Company",company) - je = frappe.new_doc("Journal Entry") - amount = split.get('payout_amount') - account = frappe.db.get_value("Bank Account",{'account_name':label},'account') - je.update({ - "is_system_generated": 1, - "title": "Easebuzz Settlement", - "voucher_type": "Bank Entry", - "naming_series": "ACC-JV-.YYYY.-", - "company": company.name, - "posting_date": split.get("payout_date"), - "cheque_no": split.get("bank_transaction_id"), - "cheque_date": split.get("payout_date"), - "user_remark": "Easebuzz Settlement", - "total_debit": amount, - "total_credit": amount, - "write_off_based_on": "Accounts Receivable", - "write_off_amount": 0, - "letter_head": "Default letter head", - "mode_of_payment": "Online", - "is_opening": "No", - "repost_required": 0, - "doctype": "Journal Entry", - }) - je.append("accounts", - { - "account": account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": amount, - "debit": amount, - "credit_in_account_currency": 0, - "credit": 0, - "is_advance": "No", - "against_account": company.default_easebuzz_account - }) - je.append("accounts", - { - "account": company.default_easebuzz_account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": 0, - "debit": 0, - "credit_in_account_currency": amount, - "credit": amount, - "is_advance": "No", - "against_account": account - }) - je.save(ignore_permissions=True) - je.submit() - for settled_transaction in data.get('settled_transactions'): - if settled_transaction.get('transaction_type') == 'Netbanking': - for split_transaction in settled_transaction.get('split_transactions'): - label = split_transaction.get("account_label") - company = frappe.db.get_value("Bank Account",{'account_name':label},'company') - company = frappe.get_doc("Company",company) - je = frappe.new_doc("Journal Entry") - amount = split_transaction.get('service_charge') + split_transaction.get("service_tax") - je.update({ - "is_system_generated": 1, - "title": "Easebuzz Settlement Charges", - "voucher_type": "Bank Entry", - "naming_series": "ACC-JV-.YYYY.-", - "company": company.name, - "posting_date": frappe.utils.nowdate(), - "cheque_no": settled_transaction.get("txnid"), - "cheque_date": frappe.utils.nowdate(), - "user_remark": "Easebuzz charges - easepayid:" + settled_transaction.get("easepayid"), - "total_debit": amount, - "total_credit": amount, - "write_off_based_on": "Accounts Receivable", - "write_off_amount": 0, - "letter_head": "Default letter head", - "mode_of_payment": "Online", - "is_opening": "No", - "repost_required": 0, - "doctype": "Journal Entry", + #try: + #data = doc.data[25:-73] + data = json.loads(doc.data) + for split in data.get('split_payouts'): + label = split.get("bank_account_no") + company = frappe.db.get_value( + "Bank Account", + {'bank_account_no':label}, + 'company' + ) + if not company: + frappe.throw(f"No Bank Account found with Easebuzz account number {label}") + company = frappe.get_doc("Company",company) + je = frappe.new_doc("Journal Entry") + amount = split.get('payout_amount') + account = frappe.db.get_value("Bank Account",{'bank_account_no':label},'account') + je.update({ + "is_system_generated": 1, + "title": "Easebuzz Settlement", + "voucher_type": "Bank Entry", + "naming_series": "ACC-JV-.YYYY.-", + "company": company.name, + "posting_date": split.get("payout_date"), + "cheque_no": split.get("bank_transaction_id"), + "cheque_date": split.get("payout_date"), + "user_remark": "Easebuzz Settlement", + "total_debit": amount, + "total_credit": amount, + "write_off_based_on": "Accounts Receivable", + "write_off_amount": 0, + "letter_head": "Default letter head", + "mode_of_payment": "Online", + "is_opening": "No", + "repost_required": 0, + "doctype": "Journal Entry", + }) + je.append("accounts", + { + "account": account, + "account_type": "", + "cost_center": company.cost_center, + "account_currency": "INR", + "exchange_rate": 1, + "debit_in_account_currency": amount, + "debit": amount, + "credit_in_account_currency": 0, + "credit": 0, + "is_advance": "No", + "against_account": company.default_easebuzz_account }) - je.append("accounts", - { - "account": company.custom_easebuzz_charges, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": amount, - "debit": amount, - "credit_in_account_currency": 0, - "credit": 0, - "is_advance": "No", - "against_account": company.default_easebuzz_account - }) - je.append("accounts", - { - "account": company.default_easebuzz_account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": 0, - "debit": 0, - "credit_in_account_currency": amount, - "credit": amount, - "is_advance": "No", - "against_account": company.custom_easebuzz_charges - }) - except Exception as e: - frappe.logger('ease').exception(e) \ No newline at end of file + je.append("accounts", + { + "account": company.default_easebuzz_account, + "account_type": "", + "cost_center": company.cost_center, + "account_currency": "INR", + "exchange_rate": 1, + "debit_in_account_currency": 0, + "debit": 0, + "credit_in_account_currency": amount, + "credit": amount, + "is_advance": "No", + "against_account": account + }) + je.insert(ignore_permissions=True) + je.submit() + for settled_transaction in data.get('settled_transactions'): + if settled_transaction.get('transaction_type') in ('Netbanking','UPI'): + for split_transaction in settled_transaction.get('split_transactions'): + label = split_transaction.get("bank_account_no") + company = frappe.db.get_value( + "Bank Account", + {'bank_account_no':label}, + 'company' + ) + if not company: + frappe.throw(f"No Bank Account found with Easebuzz account number {label}") + company = frappe.get_doc("Company",company) + je = frappe.new_doc("Journal Entry") + fee_amount = split_transaction.get('service_charge', 0) + split_transaction.get("service_tax", 0) + if not fee_amount: + continue + je.update({ + "is_system_generated": 1, + "title": "Easebuzz Settlement Charges", + "voucher_type": "Bank Entry", + "naming_series": "ACC-JV-.YYYY.-", + "company": company.name, + "posting_date": frappe.utils.nowdate(), + "cheque_no": settled_transaction.get("txnid"), + "cheque_date": frappe.utils.nowdate(), + "user_remark": "Easebuzz charges - easepayid:" + settled_transaction.get("easepayid"), + "total_debit": fee_amount, + "total_credit": fee_amount, + "write_off_based_on": "Accounts Receivable", + "write_off_amount": 0, + "letter_head": "Default letter head", + "mode_of_payment": "Online", + "is_opening": "No", + "repost_required": 0, + "doctype": "Journal Entry", + }) + je.append("accounts", + { + "account": company.custom_easebuzz_charges, + "account_type": "", + "cost_center": company.cost_center, + "account_currency": "INR", + "exchange_rate": 1, + "debit_in_account_currency": fee_amount, + "debit": fee_amount, + "credit_in_account_currency": 0, + "credit": 0, + "is_advance": "No", + "against_account": company.default_easebuzz_account + }) + je.append("accounts", + { + "account": company.default_easebuzz_account, + "account_type": "", + "cost_center": company.cost_center, + "account_currency": "INR", + "exchange_rate": 1, + "debit_in_account_currency": 0, + "debit": 0, + "credit_in_account_currency": fee_amount, + "credit": fee_amount, + "is_advance": "No", + "against_account": company.custom_easebuzz_charges + }) + je.insert(ignore_permissions=True) + je.submit() + #except Exception as e: + #frappe.logger('ease').exception(e) \ No newline at end of file From 3fb81c1b63084d0a332c95d797f748c0ab1f57f8 Mon Sep 17 00:00:00 2001 From: akeenkarkare Date: Thu, 26 Jun 2025 11:29:37 +0000 Subject: [PATCH 02/13] Re added try except loop for error handling --- .../easebuzz_settlement_log.py | 286 ++++++++++-------- 1 file changed, 154 insertions(+), 132 deletions(-) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index 6b21531..60dfb06 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -9,137 +9,159 @@ class EasebuzzSettlementLog(Document): pass def process_log(doc,method=None): - #try: - #data = doc.data[25:-73] - data = json.loads(doc.data) - for split in data.get('split_payouts'): - label = split.get("bank_account_no") - company = frappe.db.get_value( - "Bank Account", - {'bank_account_no':label}, - 'company' - ) - if not company: - frappe.throw(f"No Bank Account found with Easebuzz account number {label}") - company = frappe.get_doc("Company",company) - je = frappe.new_doc("Journal Entry") - amount = split.get('payout_amount') - account = frappe.db.get_value("Bank Account",{'bank_account_no':label},'account') - je.update({ - "is_system_generated": 1, - "title": "Easebuzz Settlement", - "voucher_type": "Bank Entry", - "naming_series": "ACC-JV-.YYYY.-", - "company": company.name, - "posting_date": split.get("payout_date"), - "cheque_no": split.get("bank_transaction_id"), - "cheque_date": split.get("payout_date"), - "user_remark": "Easebuzz Settlement", - "total_debit": amount, - "total_credit": amount, - "write_off_based_on": "Accounts Receivable", - "write_off_amount": 0, - "letter_head": "Default letter head", - "mode_of_payment": "Online", - "is_opening": "No", - "repost_required": 0, - "doctype": "Journal Entry", - }) - je.append("accounts", - { - "account": account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": amount, - "debit": amount, - "credit_in_account_currency": 0, - "credit": 0, - "is_advance": "No", - "against_account": company.default_easebuzz_account + try: + #data = doc.data[25:-73] + data = json.loads(doc.data) + for split in data.get('split_payouts'): + label = split.get("account_number") + company_name = frappe.db.get_value( + "Bank Account", + {'bank_account_no': label}, + 'company' + ) + if not company_name: + frappe.throw(f"No Bank Account found with Easebuzz account number {label}") + company = frappe.get_doc("Company", company_name) + + je = frappe.new_doc("Journal Entry") + amount = split.get('payout_amount') + bank_account = frappe.db.get_value( + "Bank Account", + {'bank_account_no': label}, + 'account' + ) + + je.update({ + "is_system_generated": 1, + "title": "Easebuzz Settlement", + "voucher_type": "Bank Entry", + "naming_series": "ACC-JV-.YYYY.-", + "company": company.name, + "posting_date": split.get("payout_date"), + "cheque_no": split.get("bank_transaction_id"), + "cheque_date": split.get("payout_date"), + "user_remark": "Easebuzz Settlement", + "total_debit": amount, + "total_credit": amount, + "write_off_based_on": "Accounts Receivable", + "write_off_amount": 0, + "letter_head": "Default letter head", + "mode_of_payment": "Online", + "is_opening": "No", + "repost_required": 0, + "doctype": "Journal Entry", + }) + + je.append("accounts", { + "account": bank_account, + "account_type": "", + "cost_center": company.cost_center, + "account_currency": "INR", + "exchange_rate": 1, + "debit_in_account_currency": amount, + "debit": amount, + "credit_in_account_currency": 0, + "credit": 0, + "is_advance": "No", + "against_account": company.default_easebuzz_account + }) + je.append("accounts", { + "account": company.default_easebuzz_account, + "account_type": "", + "cost_center": company.cost_center, + "account_currency": "INR", + "exchange_rate": 1, + "debit_in_account_currency": 0, + "debit": 0, + "credit_in_account_currency": amount, + "credit": amount, + "is_advance": "No", + "against_account": bank_account + }) + + je.insert(ignore_permissions=True) + je.submit() + + # 2) Charges entries + for settled_transaction in data.get('settled_transactions'): + if settled_transaction.get('transaction_type') in ('Netbanking', 'UPI'): + for split_transaction in settled_transaction.get('split_transactions'): + label = split_transaction.get("account_number") + bank_account = frappe.db.get_value( + "Bank Account", + {'bank_account_no': label}, + 'account' + ) + company_name = frappe.db.get_value( + "Bank Account", + {'bank_account_no': label}, + 'company' + ) + if not company_name: + frappe.throw(f"No Bank Account found with Easebuzz account number {label}") + company = frappe.get_doc("Company", company_name) + + fee_amount = ( + split_transaction.get('service_charge', 0) + + split_transaction.get("service_tax", 0) + ) + if not fee_amount: + continue + + je = frappe.new_doc("Journal Entry") + je.update({ + "is_system_generated": 1, + "title": "Easebuzz Settlement Charges", + "voucher_type": "Bank Entry", + "naming_series": "ACC-JV-.YYYY.-", + "company": company.name, + "posting_date": frappe.utils.nowdate(), + "cheque_no": settled_transaction.get("txnid"), + "cheque_date": frappe.utils.nowdate(), + "user_remark": f"Easebuzz charges - easepayid:{settled_transaction.get('easepayid')}", + "total_debit": fee_amount, + "total_credit": fee_amount, + "write_off_based_on": "Accounts Receivable", + "write_off_amount": 0, + "letter_head": "Default letter head", + "mode_of_payment": "Online", + "is_opening": "No", + "repost_required": 0, + "doctype": "Journal Entry", + }) + + # Debit the charges expense account + je.append("accounts", { + "account": company.custom_easebuzz_charges, + "account_type": "", + "cost_center": company.cost_center, + "account_currency": "INR", + "exchange_rate": 1, + "debit_in_account_currency": fee_amount, + "debit": fee_amount, + "credit_in_account_currency": 0, + "credit": 0, + "is_advance": "No", + "against_account": bank_account }) - je.append("accounts", - { - "account": company.default_easebuzz_account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": 0, - "debit": 0, - "credit_in_account_currency": amount, - "credit": amount, - "is_advance": "No", - "against_account": account + + # Credit the actual bank account you fetched + je.append("accounts", { + "account": bank_account, + "account_type": "", + "cost_center": company.cost_center, + "account_currency": "INR", + "exchange_rate": 1, + "debit": 0, + "debit_in_account_currency": 0, + "credit": fee_amount, + "credit_in_account_currency": fee_amount, + "is_advance": "No", + "against_account": company.custom_easebuzz_charges }) - je.insert(ignore_permissions=True) - je.submit() - for settled_transaction in data.get('settled_transactions'): - if settled_transaction.get('transaction_type') in ('Netbanking','UPI'): - for split_transaction in settled_transaction.get('split_transactions'): - label = split_transaction.get("bank_account_no") - company = frappe.db.get_value( - "Bank Account", - {'bank_account_no':label}, - 'company' - ) - if not company: - frappe.throw(f"No Bank Account found with Easebuzz account number {label}") - company = frappe.get_doc("Company",company) - je = frappe.new_doc("Journal Entry") - fee_amount = split_transaction.get('service_charge', 0) + split_transaction.get("service_tax", 0) - if not fee_amount: - continue - je.update({ - "is_system_generated": 1, - "title": "Easebuzz Settlement Charges", - "voucher_type": "Bank Entry", - "naming_series": "ACC-JV-.YYYY.-", - "company": company.name, - "posting_date": frappe.utils.nowdate(), - "cheque_no": settled_transaction.get("txnid"), - "cheque_date": frappe.utils.nowdate(), - "user_remark": "Easebuzz charges - easepayid:" + settled_transaction.get("easepayid"), - "total_debit": fee_amount, - "total_credit": fee_amount, - "write_off_based_on": "Accounts Receivable", - "write_off_amount": 0, - "letter_head": "Default letter head", - "mode_of_payment": "Online", - "is_opening": "No", - "repost_required": 0, - "doctype": "Journal Entry", - }) - je.append("accounts", - { - "account": company.custom_easebuzz_charges, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": fee_amount, - "debit": fee_amount, - "credit_in_account_currency": 0, - "credit": 0, - "is_advance": "No", - "against_account": company.default_easebuzz_account - }) - je.append("accounts", - { - "account": company.default_easebuzz_account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": 0, - "debit": 0, - "credit_in_account_currency": fee_amount, - "credit": fee_amount, - "is_advance": "No", - "against_account": company.custom_easebuzz_charges - }) - je.insert(ignore_permissions=True) - je.submit() - #except Exception as e: - #frappe.logger('ease').exception(e) \ No newline at end of file + + je.insert(ignore_permissions=True) + je.submit() + + except Exception as e: + frappe.logger('ease').exception(e) \ No newline at end of file From a4261051db1f738d9bbad2185ed2b703f95cd6d7 Mon Sep 17 00:00:00 2001 From: akeenkarkare Date: Thu, 26 Jun 2025 11:53:47 +0000 Subject: [PATCH 03/13] improvement: optimised code --- .../easebuzz_settlement_log.py | 271 ++++++++---------- 1 file changed, 121 insertions(+), 150 deletions(-) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index 60dfb06..0392b37 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -1,167 +1,138 @@ -# Copyright (c) 2024, Hybrowlabs and contributors -# For license information, please see license.txt - import frappe from frappe.model.document import Document import json class EasebuzzSettlementLog(Document): - pass + pass -def process_log(doc,method=None): - try: - #data = doc.data[25:-73] - data = json.loads(doc.data) - for split in data.get('split_payouts'): - label = split.get("account_number") - company_name = frappe.db.get_value( - "Bank Account", - {'bank_account_no': label}, - 'company' - ) - if not company_name: - frappe.throw(f"No Bank Account found with Easebuzz account number {label}") - company = frappe.get_doc("Company", company_name) +def make_account_entry(account, debit, credit, against_account, cost_center, + currency="INR", exchange_rate=1): + return { + "account": account, + "account_type": "", + "cost_center": cost_center, + "account_currency": currency, + "exchange_rate": exchange_rate, + "debit_in_account_currency": debit, + "debit": debit, + "credit_in_account_currency": credit, + "credit": credit, + "is_advance": "No", + "against_account": against_account + } - je = frappe.new_doc("Journal Entry") - amount = split.get('payout_amount') - bank_account = frappe.db.get_value( - "Bank Account", - {'bank_account_no': label}, - 'account' - ) +def get_account_and_company(label): + account = frappe.db.get_value( + "Bank Account", {'bank_account_no': label}, 'account' + ) + company_name = frappe.db.get_value( + "Bank Account", {'bank_account_no': label}, 'company' + ) + if not company_name: + frappe.throw(f"No Bank Account found with Easebuzz account number {label}") + company = frappe.get_doc("Company", company_name) + return account, company - je.update({ - "is_system_generated": 1, - "title": "Easebuzz Settlement", - "voucher_type": "Bank Entry", - "naming_series": "ACC-JV-.YYYY.-", - "company": company.name, - "posting_date": split.get("payout_date"), - "cheque_no": split.get("bank_transaction_id"), - "cheque_date": split.get("payout_date"), - "user_remark": "Easebuzz Settlement", - "total_debit": amount, - "total_credit": amount, - "write_off_based_on": "Accounts Receivable", - "write_off_amount": 0, - "letter_head": "Default letter head", - "mode_of_payment": "Online", - "is_opening": "No", - "repost_required": 0, - "doctype": "Journal Entry", - }) +def create_journal_entry(title, company, posting_date, cheque_no, cheque_date, + remark, total_amount, accounts_data): + je = frappe.new_doc("Journal Entry") + je.update({ + "is_system_generated": 1, + "title": title, + "voucher_type": "Bank Entry", + "naming_series": "ACC-JV-.YYYY.-", + "company": company.name, + "posting_date": posting_date, + "cheque_no": cheque_no, + "cheque_date": cheque_date, + "user_remark": remark, + "total_debit": total_amount, + "total_credit": total_amount, + "write_off_based_on": "Accounts Receivable", + "write_off_amount": 0, + "letter_head": "Default letter head", + "mode_of_payment": "Online", + "is_opening": "No", + "repost_required": 0, + "doctype": "Journal Entry", + }) + for acc in accounts_data: + je.append("accounts", acc) + je.insert(ignore_permissions=True) + je.submit() - je.append("accounts", { - "account": bank_account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": amount, - "debit": amount, - "credit_in_account_currency": 0, - "credit": 0, - "is_advance": "No", - "against_account": company.default_easebuzz_account - }) - je.append("accounts", { - "account": company.default_easebuzz_account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": 0, - "debit": 0, - "credit_in_account_currency": amount, - "credit": amount, - "is_advance": "No", - "against_account": bank_account - }) +def process_log(doc, method=None): + try: + data = json.loads(doc.data) - je.insert(ignore_permissions=True) - je.submit() + # 1) Settlement payouts + for split in data.get('split_payouts', []): + label = split.get('account_number') + amount = split.get('payout_amount', 0) + bank_acc, company = get_account_and_company(label) - # 2) Charges entries - for settled_transaction in data.get('settled_transactions'): - if settled_transaction.get('transaction_type') in ('Netbanking', 'UPI'): - for split_transaction in settled_transaction.get('split_transactions'): - label = split_transaction.get("account_number") - bank_account = frappe.db.get_value( - "Bank Account", - {'bank_account_no': label}, - 'account' - ) - company_name = frappe.db.get_value( - "Bank Account", - {'bank_account_no': label}, - 'company' - ) - if not company_name: - frappe.throw(f"No Bank Account found with Easebuzz account number {label}") - company = frappe.get_doc("Company", company_name) + accounts = [ + make_account_entry( + bank_acc, amount, 0, + company.default_easebuzz_account, + company.cost_center + ), + make_account_entry( + company.default_easebuzz_account, 0, amount, + bank_acc, + company.cost_center + ) + ] - fee_amount = ( - split_transaction.get('service_charge', 0) - + split_transaction.get("service_tax", 0) - ) - if not fee_amount: - continue + create_journal_entry( + title="Easebuzz Settlement", + company=company, + posting_date=split.get('payout_date'), + cheque_no=split.get('bank_transaction_id'), + cheque_date=split.get('payout_date'), + remark="Easebuzz Settlement", + total_amount=amount, + accounts_data=accounts + ) - je = frappe.new_doc("Journal Entry") - je.update({ - "is_system_generated": 1, - "title": "Easebuzz Settlement Charges", - "voucher_type": "Bank Entry", - "naming_series": "ACC-JV-.YYYY.-", - "company": company.name, - "posting_date": frappe.utils.nowdate(), - "cheque_no": settled_transaction.get("txnid"), - "cheque_date": frappe.utils.nowdate(), - "user_remark": f"Easebuzz charges - easepayid:{settled_transaction.get('easepayid')}", - "total_debit": fee_amount, - "total_credit": fee_amount, - "write_off_based_on": "Accounts Receivable", - "write_off_amount": 0, - "letter_head": "Default letter head", - "mode_of_payment": "Online", - "is_opening": "No", - "repost_required": 0, - "doctype": "Journal Entry", - }) + # 2) Charges entries + for txn in data.get('settled_transactions', []): + if txn.get('transaction_type') in ('Netbanking', 'UPI'): + fee_amount = 0 + for st in txn.get('split_transactions', []): + fee_amount += st.get('service_charge', 0) + st.get('service_tax', 0) + if not fee_amount: + continue - # Debit the charges expense account - je.append("accounts", { - "account": company.custom_easebuzz_charges, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit_in_account_currency": fee_amount, - "debit": fee_amount, - "credit_in_account_currency": 0, - "credit": 0, - "is_advance": "No", - "against_account": bank_account - }) + for split in txn.get('split_transactions', []): + label = split.get('account_number') + bank_acc, company = get_account_and_company(label) - # Credit the actual bank account you fetched - je.append("accounts", { - "account": bank_account, - "account_type": "", - "cost_center": company.cost_center, - "account_currency": "INR", - "exchange_rate": 1, - "debit": 0, - "debit_in_account_currency": 0, - "credit": fee_amount, - "credit_in_account_currency": fee_amount, - "is_advance": "No", - "against_account": company.custom_easebuzz_charges - }) + accounts = [ + make_account_entry( + company.custom_easebuzz_charges, + fee_amount, 0, + bank_acc, + company.cost_center + ), + make_account_entry( + bank_acc, 0, + fee_amount, + company.custom_easebuzz_charges, + company.cost_center + ) + ] - je.insert(ignore_permissions=True) - je.submit() + create_journal_entry( + title="Easebuzz Settlement Charges", + company=company, + posting_date=frappe.utils.nowdate(), + cheque_no=txn.get('txnid'), + cheque_date=frappe.utils.nowdate(), + remark=f"Easebuzz charges - easepayid:{txn.get('easepayid')}", + total_amount=fee_amount, + accounts_data=accounts + ) - except Exception as e: - frappe.logger('ease').exception(e) \ No newline at end of file + except Exception as e: + frappe.logger('ease').exception(e) From 42c66e4a91c31ac58819ba7e13933ca2dfe1f88b Mon Sep 17 00:00:00 2001 From: akeenkarkare Date: Mon, 28 Jul 2025 13:41:07 +0530 Subject: [PATCH 04/13] Whitelisted process_log --- .../doctype/easebuzz_settlement_log/easebuzz_settlement_log.py | 1 + easebuzz/www/__pycache__/__init__.py | 0 easebuzz/www/easebuzz/__pycache__/__init__.py | 0 3 files changed, 1 insertion(+) delete mode 100644 easebuzz/www/__pycache__/__init__.py delete mode 100644 easebuzz/www/easebuzz/__pycache__/__init__.py diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index 0392b37..a605507 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -61,6 +61,7 @@ def create_journal_entry(title, company, posting_date, cheque_no, cheque_date, je.insert(ignore_permissions=True) je.submit() +@frappe.whitelist() def process_log(doc, method=None): try: data = json.loads(doc.data) diff --git a/easebuzz/www/__pycache__/__init__.py b/easebuzz/www/__pycache__/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/easebuzz/www/easebuzz/__pycache__/__init__.py b/easebuzz/www/easebuzz/__pycache__/__init__.py deleted file mode 100644 index e69de29..0000000 From 93c5dc672b698ad2fbdbb4b2a02e74572d3ab3ec Mon Sep 17 00:00:00 2001 From: akeenkarkare Date: Mon, 28 Jul 2025 13:43:48 +0530 Subject: [PATCH 05/13] Whitelisted process_log --- easebuzz/www/__pycache__/__init__.py | 0 easebuzz/www/easebuzz/__pycache__/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 easebuzz/www/__pycache__/__init__.py create mode 100644 easebuzz/www/easebuzz/__pycache__/__init__.py diff --git a/easebuzz/www/__pycache__/__init__.py b/easebuzz/www/__pycache__/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easebuzz/www/easebuzz/__pycache__/__init__.py b/easebuzz/www/easebuzz/__pycache__/__init__.py new file mode 100644 index 0000000..e69de29 From 947e813c2eba7c486b0cda9b7cebb3e393d7e548 Mon Sep 17 00:00:00 2001 From: badal8381 Date: Tue, 29 Jul 2025 05:17:56 +0000 Subject: [PATCH 06/13] feat: add process log as doc method --- .../easebuzz_settlement_log/easebuzz_settlement_log.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index a605507..8d44a85 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -3,7 +3,11 @@ import json class EasebuzzSettlementLog(Document): - pass + def process_log(self): + """ + Process the settlement log data and create necessary journal entries. + """ + process_log(self, method=None) def make_account_entry(account, debit, credit, against_account, cost_center, currency="INR", exchange_rate=1): From 8eda3cdd7670a84d92609bc60a68bb47db8267e5 Mon Sep 17 00:00:00 2001 From: rohitkumarthongar Date: Wed, 19 Aug 2026 21:10:43 +0530 Subject: [PATCH 07/13] feat(reconciliation): post settlement JEs per company, driven by payment-mode rules Easebuzz settlement logs were stored but never turned into accounting entries, so the Easebuzz PG suspense account never cleared. Build step 3 of the flow -- Dr Bank / Cr Easebuzz suspense -- as an automatic, idempotent posting pipeline. Parse, preflight, segregate, validate, post, reconcile: * Payload parsing. `settlement_hook` stores a Python repr of {'status', 'data', 'cmd'} and the settlement body lives in the *inner* JSON string. The old code parsed the outer wrapper, so split_payouts, total_amount and service_charge_amount were never reachable. `parse_settlement_payload` unwraps the envelope and also accepts bare JSON and already-parsed dicts. * One Journal Entry per company, not per split payout. 364 of 400 production payloads span two companies (UESF and RESPL) and a Journal Entry belongs to exactly one company, so a single JE for the whole log is not representable in ERPNext. Each JE carries one debit row per split payout, one charges row, and one suspense credit; the credit is the sum of the rounded debits, so it balances exactly rather than to within a rounding error. * Charge attribution. split_transactions[] carries no account_number -- only split_payout_id -- which is why the old charges loop resolved a Bank Account from None and never posted a single charge JE. Charges now join back to a company through split_payout_id, which resolves cleanly in all 400 payloads. * Payment-mode rules. New `Easebuzz Payment Mode Rule` child table on Easebuzz Settings replaces the hardcoded transaction_type filter. A mode with Debit Charges unticked has its service charge and tax excluded from the charges line. Bank debits are never filtered -- that money physically reached the bank, so removing it would produce a JE that disagrees with the statement. Matching is on the raw Easebuzz string rather than the Mode of Payment link name, so renaming the ERPNext master cannot silently break a rule. Duplicate rows are rejected on save. * Idempotency. Easebuzz redelivers settlements (payout PTOBWKJAUF arrives twice in the production log set), so an already-posted (payout_id, company) pair is recorded as Already Posted instead of posting again. A redelivery arrives as a new log with its own job_id, so `deduplicate` does not apply -- posting is serialised on the payout with a filelock. * Trigger moved from before_save to an enqueued after_insert. before_save re-posted the whole settlement on every subsequent save of the log. * Failures are visible. Status (Pending / Processing / Processed / Needs Review / Failed / Skipped), the error text, and a per-company reconciliation row recording the JE, bank total, charges debited, charges skipped and the modes responsible. The bare except that logged to `logs/ease.log` -- a file no other easebuzz component writes to -- is gone. * Preflight fails loudly with the company named when default_easebuzz_account or custom_easebuzz_charges is unset, rather than posting `account = None` and swallowing the validation error. * First JE per company is held as Draft with the log at Needs Review so the mapping can be confirmed before anything reaches the ledger; auto-submit unlocks per company once one has been submitted. Settlements older than Reconcile Settlements On or After are marked Skipped and never posted. Verified against 400 production payloads: 764 JEs, all balanced, bank totals identical with rules on and off, included + skipped reconciles to the payload header, and JEs whose charges are entirely skipped get no charges line rather than a 0.00 one. Verified end to end against real ERPNext for payout PT1J0YM50V: RESPL 1,018,437.87 + UESF 3,275,405.49 = 4,293,843.36 = total_amount, difference 0.0, both submitted, then rolled back. Note: split-level charge sums drift from the payload header by up to 0.13, so the reconciliation tolerance defaults to 0.50 rather than 0.05. Co-Authored-By: Claude Opus 5 (1M context) --- .../easebuzz_payment_mode_rule/__init__.py | 0 .../easebuzz_payment_mode_rule.json | 62 ++ .../easebuzz_payment_mode_rule.py | 8 + .../easebuzz_settings/easebuzz_settings.js | 52 +- .../easebuzz_settings/easebuzz_settings.json | 83 ++- .../easebuzz_settings/easebuzz_settings.py | 58 +- .../easebuzz_settlement_log.js | 43 +- .../easebuzz_settlement_log.json | 97 ++- .../easebuzz_settlement_log.py | 193 ++--- .../test_easebuzz_settlement_log.py | 156 +++- .../__init__.py | 0 .../easebuzz_settlement_reconciliation.json | 109 +++ .../easebuzz_settlement_reconciliation.py | 8 + easebuzz/easebuzz/utils/settlement.py | 681 ++++++++++++++++++ easebuzz/hooks.py | 9 +- 15 files changed, 1406 insertions(+), 153 deletions(-) create mode 100644 easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/__init__.py create mode 100644 easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/easebuzz_payment_mode_rule.json create mode 100644 easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/easebuzz_payment_mode_rule.py create mode 100644 easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/__init__.py create mode 100644 easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/easebuzz_settlement_reconciliation.json create mode 100644 easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/easebuzz_settlement_reconciliation.py create mode 100644 easebuzz/easebuzz/utils/settlement.py diff --git a/easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/__init__.py b/easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/easebuzz_payment_mode_rule.json b/easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/easebuzz_payment_mode_rule.json new file mode 100644 index 0000000..3c1e12b --- /dev/null +++ b/easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/easebuzz_payment_mode_rule.json @@ -0,0 +1,62 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-08-19 10:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "easebuzz_transaction_type", + "mode_of_payment", + "debit_charges", + "notes" + ], + "fields": [ + { + "columns": 3, + "description": "Must match the transaction_type string Easebuzz sends, exactly and case-sensitively. Known values: UPI, Credit Card, Netbanking, Debit Card.", + "fieldname": "easebuzz_transaction_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Easebuzz Transaction Type", + "reqd": 1 + }, + { + "columns": 3, + "description": "Optional cross-reference to the ERPNext master. Matching is done on Easebuzz Transaction Type, so renaming this record is safe.", + "fieldname": "mode_of_payment", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Mode of Payment", + "options": "Mode of Payment" + }, + { + "columns": 2, + "default": "1", + "description": "Unchecked = this mode's service charge and tax are excluded from the charges debit line. Bank debits are never affected.", + "fieldname": "debit_charges", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Debit Charges to Expense" + }, + { + "columns": 2, + "fieldname": "notes", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Notes" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-08-19 10:00:00.000000", + "modified_by": "Administrator", + "module": "Easebuzz", + "name": "Easebuzz Payment Mode Rule", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/easebuzz_payment_mode_rule.py b/easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/easebuzz_payment_mode_rule.py new file mode 100644 index 0000000..0886ffe --- /dev/null +++ b/easebuzz/easebuzz/doctype/easebuzz_payment_mode_rule/easebuzz_payment_mode_rule.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Hybrowlabs and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class EasebuzzPaymentModeRule(Document): + pass diff --git a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js index 2dd8f4b..d0b5823 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js @@ -1,8 +1,50 @@ -// Copyright (c) 2023, Hybrowlabs and contributors +// Copyright (c) 2026, Hybrowlabs and contributors // For license information, please see license.txt -// frappe.ui.form.on("Easebuzz Settings", { -// refresh(frm) { +const KNOWN_MODES_METHOD = + "easebuzz.easebuzz.doctype.easebuzz_settings.easebuzz_settings.get_known_transaction_types"; -// }, -// }); +frappe.ui.form.on("Easebuzz Settings", { + refresh(frm) { + if (frm.is_new() || !frm.doc.enable_payment_mode_rules) return; + + // Rows are appended client-side and left for the user to save, so the + // button never persists unrelated unsaved edits on the form. + frm.add_custom_button(__("Add Default Payment Modes"), () => { + frappe.call(KNOWN_MODES_METHOD).then((r) => { + const existing = new Set( + (frm.doc.allowed_mode_of_payment || []).map((row) => + (row.easebuzz_transaction_type || "").trim() + ) + ); + const added = []; + (r.message || []).forEach((mode) => { + if (existing.has(mode.easebuzz_transaction_type)) return; + const row = frm.add_child("allowed_mode_of_payment"); + row.easebuzz_transaction_type = mode.easebuzz_transaction_type; + row.mode_of_payment = mode.mode_of_payment; + row.debit_charges = 1; + added.push(mode.easebuzz_transaction_type); + }); + frm.refresh_field("allowed_mode_of_payment"); + frappe.show_alert({ + message: added.length + ? __("Added: {0}. Save to apply.", [added.join(", ")]) + : __("All known payment modes are already configured."), + indicator: added.length ? "green" : "blue", + }); + }); + }); + }, +}); + +frappe.ui.form.on("Easebuzz Payment Mode Rule", { + mode_of_payment(frm, cdt, cdn) { + // Pre-fill the matching key, which the user can then correct to whatever + // Easebuzz actually sends (e.g. "Netbanking", not "Net Banking"). + const row = locals[cdt][cdn]; + if (row.mode_of_payment && !row.easebuzz_transaction_type) { + frappe.model.set_value(cdt, cdn, "easebuzz_transaction_type", row.mode_of_payment); + } + }, +}); diff --git a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json index fc41a7c..191f93b 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json @@ -12,7 +12,17 @@ "merchant_key", "salt", "env", - "surcharge" + "surcharge", + "reconciliation_section", + "auto_create_journal_entry", + "auto_submit_journal_entry", + "reconciliation_start_date", + "column_break_recon", + "charge_tolerance", + "enable_payment_mode_rules", + "unknown_mode_behaviour", + "payment_mode_rules_section", + "allowed_mode_of_payment" ], "fields": [ { @@ -42,11 +52,78 @@ "fieldtype": "Data", "label": "Easebuzz Account", "unique": 1 + }, + { + "fieldname": "reconciliation_section", + "fieldtype": "Section Break", + "label": "Settlement Reconciliation" + }, + { + "default": "0", + "fieldname": "auto_create_journal_entry", + "fieldtype": "Check", + "label": "Auto Create Journal Entry", + "description": "Create the settlement Journal Entry automatically when an Easebuzz Settlement Log is inserted. Off = logs are stored with status Pending and can be processed manually from the log." + }, + { + "default": "0", + "fieldname": "auto_submit_journal_entry", + "fieldtype": "Check", + "label": "Auto Submit Journal Entry", + "depends_on": "auto_create_journal_entry", + "description": "Off = every Journal Entry is left as Draft and the log is marked Needs Review. The first settlement for a company is ALWAYS left as Draft regardless of this setting." + }, + { + "fieldname": "reconciliation_start_date", + "fieldtype": "Date", + "label": "Reconcile Settlements On or After", + "depends_on": "auto_create_journal_entry", + "description": "Settlements with an earlier payout date are marked Skipped and never posted. Leave blank to process every incoming settlement." + }, + { + "fieldname": "column_break_recon", + "fieldtype": "Column Break" + }, + { + "default": "0.50", + "fieldname": "charge_tolerance", + "fieldtype": "Currency", + "label": "Charge Reconciliation Tolerance", + "description": "Maximum allowed difference between the charges derived from split_transactions[] and the payload header total. Easebuzz rounding drift of up to ~0.15 has been observed." + }, + { + "default": "0", + "fieldname": "enable_payment_mode_rules", + "fieldtype": "Check", + "label": "Enable Payment Mode Rules", + "description": "Off = every payment mode's charges are debited to the charges expense account." + }, + { + "fieldname": "unknown_mode_behaviour", + "fieldtype": "Select", + "label": "Unknown Mode Behaviour", + "options": "Debit\nSkip\nFail", + "default": "Debit", + "depends_on": "enable_payment_mode_rules", + "description": "Applied when Easebuzz reports a transaction_type that has no row in the table below." + }, + { + "fieldname": "payment_mode_rules_section", + "fieldtype": "Section Break", + "label": "Payment Mode Rules", + "depends_on": "enable_payment_mode_rules" + }, + { + "fieldname": "allowed_mode_of_payment", + "fieldtype": "Table", + "label": "Allowed Mode of Payment", + "options": "Easebuzz Payment Mode Rule", + "description": "Only the charges of modes with Debit Charges to Expense ticked are added to the charges debit line. Bank payout lines are never filtered \u2014 that money physically reached the bank." } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-07-27 15:11:17.802667", + "modified": "2026-08-19 10:00:00.000000", "modified_by": "Administrator", "module": "Easebuzz", "name": "Easebuzz Settings", @@ -67,4 +144,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py index 59d133b..97f61a0 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py @@ -10,6 +10,17 @@ from payments.utils.utils import create_payment_gateway +# The transaction_type strings Easebuzz sends, paired with the ERPNext Mode of +# Payment they correspond to. Matching is done on the string, not the link, so +# a missing or renamed Mode of Payment record cannot break a rule. +EASEBUZZ_TRANSACTION_TYPES = ( + ("UPI", "UPI"), + ("Credit Card", "Credit Card"), + ("Debit Card", "Debit Card"), + ("Netbanking", "Netbanking"), +) + + class EasebuzzSettings(Document): supported_currencies = ["INR"] @@ -21,7 +32,34 @@ def init_client(self, surcharge): def after_insert(self): create_payment_gateway("Easebuzz", "Easebuzz Settings", self.name) call_hook_method("payment_gateway_enabled", gateway="Easebuzz") - + + def validate(self): + self.validate_payment_mode_rules() + + def validate_payment_mode_rules(self): + """Reject duplicate or blank transaction types in the charge rule table. + + Frappe does not enforce uniqueness on child rows, and two rows for the + same mode with opposite Debit Charges settings would make the applied + rule depend on row order. + """ + seen = {} + for row in self.get("allowed_mode_of_payment") or []: + key = (row.easebuzz_transaction_type or "").strip() + if not key: + frappe.throw( + frappe._("Row #{0}: Easebuzz Transaction Type is required.").format(row.idx) + ) + row.easebuzz_transaction_type = key + if key in seen: + frappe.throw( + frappe._( + "Rows #{0} and #{1} both configure the Easebuzz transaction type " + "'{2}'. Each mode may appear only once." + ).format(seen[key], row.idx, key) + ) + seen[key] = row.idx + def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: frappe.throw( @@ -223,6 +261,24 @@ def initiateRefund(self, data): return response +@frappe.whitelist() +def get_known_transaction_types(): + """The transaction types Easebuzz is known to report, for the settings form. + + Read-only on purpose: the rows are appended client-side so seeding cannot + persist unrelated unsaved edits on the form. + """ + return [ + { + "easebuzz_transaction_type": transaction_type, + "mode_of_payment": ( + mode_of_payment if frappe.db.exists("Mode of Payment", mode_of_payment) else None + ), + } + for transaction_type, mode_of_payment in EASEBUZZ_TRANSACTION_TYPES + ] + + @frappe.whitelist(allow_guest=True) def get_merchant_key(): controller = frappe.get_doc("Easebuzz Settings") diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js index b15792f..beb2528 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js @@ -1,8 +1,43 @@ -// Copyright (c) 2024, Hybrowlabs and contributors +// Copyright (c) 2026, Hybrowlabs and contributors // For license information, please see license.txt -frappe.ui.form.on('Easebuzz Settlement Log', { - // refresh: function(frm) { +const STATUS_COLOUR = { + Pending: "orange", + Processing: "blue", + Processed: "green", + "Needs Review": "orange", + Failed: "red", + Skipped: "gray", +}; - // } +frappe.ui.form.on("Easebuzz Settlement Log", { + refresh(frm) { + if (frm.is_new()) return; + + if (frm.doc.status) { + frm.page.set_indicator(__(frm.doc.status), STATUS_COLOUR[frm.doc.status] || "gray"); + } + + const processed = frm.doc.status === "Processed"; + frm.add_custom_button(processed ? __("Re-process") : __("Process"), () => { + const run = () => + frm + .call({ + method: "easebuzz.easebuzz.doctype.easebuzz_settlement_log.easebuzz_settlement_log.process_log", + args: { docname: frm.doc.name, force: processed }, + freeze: true, + freeze_message: __("Reconciling settlement..."), + }) + .then(() => frm.reload_doc()); + + if (!processed) return run(); + + frappe.confirm( + __( + "This log is already Processed. Journal Entries that already exist will not be created again." + ), + run + ); + }); + }, }); diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.json b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.json index 51ee13e..a5d7f30 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.json +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.json @@ -7,9 +7,102 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "payout_id", + "payout_date", + "status", + "column_break_header", + "total_amount", + "payout_amount", + "charges_amount", + "reconciliation_section", + "reconciliation", + "error_section", + "error_message", + "payload_section", "data" ], "fields": [ + { + "fieldname": "payout_id", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Payout ID", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "payout_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Payout Date", + "read_only": 1 + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Pending\nProcessing\nProcessed\nNeeds Review\nFailed\nSkipped", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "column_break_header", + "fieldtype": "Column Break" + }, + { + "fieldname": "total_amount", + "fieldtype": "Currency", + "label": "Total Amount", + "read_only": 1 + }, + { + "fieldname": "payout_amount", + "fieldtype": "Currency", + "label": "Payout Amount", + "read_only": 1 + }, + { + "description": "service_charge_amount + service_tax_amount from the payload header.", + "fieldname": "charges_amount", + "fieldtype": "Currency", + "label": "Charges Amount", + "read_only": 1 + }, + { + "fieldname": "reconciliation_section", + "fieldtype": "Section Break", + "label": "Reconciliation" + }, + { + "fieldname": "reconciliation", + "fieldtype": "Table", + "label": "Journal Entries", + "options": "Easebuzz Settlement Reconciliation", + "read_only": 1 + }, + { + "collapsible": 1, + "depends_on": "error_message", + "fieldname": "error_section", + "fieldtype": "Section Break", + "label": "Error" + }, + { + "fieldname": "error_message", + "fieldtype": "Small Text", + "label": "Error Message", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "payload_section", + "fieldtype": "Section Break", + "label": "Payload" + }, { "fieldname": "data", "fieldtype": "Code", @@ -18,7 +111,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-01-19 13:57:57.534226", + "modified": "2026-08-19 10:00:00.000000", "modified_by": "Administrator", "module": "Easebuzz", "name": "Easebuzz Settlement Log", @@ -40,4 +133,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index 8d44a85..13af4e7 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -1,143 +1,80 @@ +# Copyright (c) 2026, Hybrowlabs and contributors +# For license information, please see license.txt + import frappe +from frappe import _ from frappe.model.document import Document -import json - -class EasebuzzSettlementLog(Document): - def process_log(self): - """ - Process the settlement log data and create necessary journal entries. - """ - process_log(self, method=None) -def make_account_entry(account, debit, credit, against_account, cost_center, - currency="INR", exchange_rate=1): - return { - "account": account, - "account_type": "", - "cost_center": cost_center, - "account_currency": currency, - "exchange_rate": exchange_rate, - "debit_in_account_currency": debit, - "debit": debit, - "credit_in_account_currency": credit, - "credit": credit, - "is_advance": "No", - "against_account": against_account - } +from easebuzz.easebuzz.utils.settlement import ( + get_reconciliation_settings, + parse_settlement_payload, + process_settlement_log, +) -def get_account_and_company(label): - account = frappe.db.get_value( - "Bank Account", {'bank_account_no': label}, 'account' - ) - company_name = frappe.db.get_value( - "Bank Account", {'bank_account_no': label}, 'company' - ) - if not company_name: - frappe.throw(f"No Bank Account found with Easebuzz account number {label}") - company = frappe.get_doc("Company", company_name) - return account, company -def create_journal_entry(title, company, posting_date, cheque_no, cheque_date, - remark, total_amount, accounts_data): - je = frappe.new_doc("Journal Entry") - je.update({ - "is_system_generated": 1, - "title": title, - "voucher_type": "Bank Entry", - "naming_series": "ACC-JV-.YYYY.-", - "company": company.name, - "posting_date": posting_date, - "cheque_no": cheque_no, - "cheque_date": cheque_date, - "user_remark": remark, - "total_debit": total_amount, - "total_credit": total_amount, - "write_off_based_on": "Accounts Receivable", - "write_off_amount": 0, - "letter_head": "Default letter head", - "mode_of_payment": "Online", - "is_opening": "No", - "repost_required": 0, - "doctype": "Journal Entry", - }) - for acc in accounts_data: - je.append("accounts", acc) - je.insert(ignore_permissions=True) - je.submit() +class EasebuzzSettlementLog(Document): + def before_insert(self): + """Stamp the payout header so the list view is usable even if posting fails.""" + if self.payout_id: + return + try: + payload = parse_settlement_payload(self.data) + except Exception: + return + self.payout_id = payload.get("payout_id") + if payload.get("payout_date"): + self.payout_date = frappe.utils.getdate(payload.get("payout_date")) -@frappe.whitelist() -def process_log(doc, method=None): - try: - data = json.loads(doc.data) + @frappe.whitelist() + def process_log(self, force=False): + """Reconcile this log now, from the form or from the console. - # 1) Settlement payouts - for split in data.get('split_payouts', []): - label = split.get('account_number') - amount = split.get('payout_amount', 0) - bank_acc, company = get_account_and_company(label) + Kept as a document method because the reconciliation branch exposes it + that way; the real work lives in ``utils.settlement``. + """ + self.check_permission("write") + return process_settlement_log(self.name, force=force) - accounts = [ - make_account_entry( - bank_acc, amount, 0, - company.default_easebuzz_account, - company.cost_center - ), - make_account_entry( - company.default_easebuzz_account, 0, amount, - bank_acc, - company.cost_center - ) - ] + def after_insert(self): + """Queue reconciliation. - create_journal_entry( - title="Easebuzz Settlement", - company=company, - posting_date=split.get('payout_date'), - cheque_no=split.get('bank_transaction_id'), - cheque_date=split.get('payout_date'), - remark="Easebuzz Settlement", - total_amount=amount, - accounts_data=accounts + Enqueued rather than run inline: a settlement carries up to 17 bank + splits and ~90 transactions, which is too much for the webhook request. + Runs on insert only -- on ``before_save`` it would re-post on every + subsequent save of the log. + """ + try: + settings = get_reconciliation_settings() + except Exception as exc: + # The gateway posts this payload once. An ambiguous or broken + # settings record must leave a Pending log to retry from, never + # abort the insert and lose the settlement. + frappe.logger("easebuzz", allow_site=True).error( + f"Easebuzz Settlement Log {self.name}: cannot resolve settings: {exc}" ) + return - # 2) Charges entries - for txn in data.get('settled_transactions', []): - if txn.get('transaction_type') in ('Netbanking', 'UPI'): - fee_amount = 0 - for st in txn.get('split_transactions', []): - fee_amount += st.get('service_charge', 0) + st.get('service_tax', 0) - if not fee_amount: - continue + if not settings or not settings.get("auto_create_journal_entry"): + return - for split in txn.get('split_transactions', []): - label = split.get('account_number') - bank_acc, company = get_account_and_company(label) + frappe.enqueue( + process_settlement_log, + queue="long", + enqueue_after_commit=True, + job_id=f"easebuzz-settlement-{self.name}", + deduplicate=True, + name=self.name, + ) - accounts = [ - make_account_entry( - company.custom_easebuzz_charges, - fee_amount, 0, - bank_acc, - company.cost_center - ), - make_account_entry( - bank_acc, 0, - fee_amount, - company.custom_easebuzz_charges, - company.cost_center - ) - ] - create_journal_entry( - title="Easebuzz Settlement Charges", - company=company, - posting_date=frappe.utils.nowdate(), - cheque_no=txn.get('txnid'), - cheque_date=frappe.utils.nowdate(), - remark=f"Easebuzz charges - easepayid:{txn.get('easepayid')}", - total_amount=fee_amount, - accounts_data=accounts - ) +@frappe.whitelist() +def process_log(docname=None, doc=None, method=None, force=False): + """Process a settlement log on demand, from the form or from the console.""" + name = docname + if not name and doc is not None: + name = doc if isinstance(doc, str) else doc.name + if not name: + frappe.throw(_("No Easebuzz Settlement Log specified.")) - except Exception as e: - frappe.logger('ease').exception(e) + frappe.has_permission("Easebuzz Settlement Log", "write", doc=name, throw=True) + return process_settlement_log(name, force=force) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py index eff14cb..9c3a9e8 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py @@ -1,9 +1,155 @@ -# Copyright (c) 2024, Hybrowlabs and Contributors +# Copyright (c) 2026, Hybrowlabs and contributors # See license.txt -# import frappe -from frappe.tests.utils import FrappeTestCase +import json +import unittest +from easebuzz.easebuzz.utils.settlement import ( + SettlementError, + parse_settlement_payload, + resolve_rule, + segregate, + validate_segregation, +) -class TestEasebuzzSettlementLog(FrappeTestCase): - pass +SETTLEMENT = { + "payout_id": "PTTEST0001", + "payout_date": "2026-08-18 12:55:06.618976", + "total_amount": 1118.88, + "payout_amount": 1100.00, + "service_charge_amount": 16.00, + "service_tax_amount": 2.88, + "split_payouts": [ + { + "account_number": "50200011443440", + "payout_amount": 700.00, + "split_payout_id": "SPA", + "account_label": "PrimaryTuitionFeeShivane", + "bank_transaction_id": "YESB0001", + }, + { + "account_number": "50200080706351", + "payout_amount": 400.00, + "split_payout_id": "SPB", + "account_label": "CurriculamMaterialFee", + "bank_transaction_id": "YESB0002", + }, + ], + "settled_transactions": [ + { + "transaction_type": "Netbanking", + "split_transactions": [ + {"split_payout_id": "SPA", "service_charge": 8.00, "service_tax": 1.44}, + ], + }, + { + "transaction_type": "Credit Card", + "split_transactions": [ + {"split_payout_id": "SPB", "service_charge": 8.00, "service_tax": 1.44}, + ], + }, + ], +} + + +class TestSettlementPayloadParsing(unittest.TestCase): + """``Settlement Log.data`` arrives in several shapes; all must reach the inner body.""" + + def test_unwraps_python_repr_envelope(self): + raw = repr( + { + "status": "1", + "data": json.dumps(SETTLEMENT), + "cmd": "edu_quality.edu_quality.server_scripts.utils.settlement_hook", + } + ) + self.assertEqual(parse_settlement_payload(raw)["payout_id"], "PTTEST0001") + + def test_unwraps_json_envelope(self): + raw = json.dumps({"status": "1", "data": json.dumps(SETTLEMENT)}) + self.assertEqual(parse_settlement_payload(raw)["payout_id"], "PTTEST0001") + + def test_accepts_bare_settlement_json(self): + parsed = parse_settlement_payload(json.dumps(SETTLEMENT)) + self.assertEqual(len(parsed["split_payouts"]), 2) + + def test_accepts_already_parsed_dict(self): + self.assertEqual(parse_settlement_payload(SETTLEMENT)["payout_id"], "PTTEST0001") + + def test_rejects_unparseable_payload(self): + for bad in (None, "", " ", "not a payload"): + with self.assertRaises(SettlementError): + parse_settlement_payload(bad) + + +class TestModeRules(unittest.TestCase): + def test_rules_off_debits_every_mode(self): + self.assertTrue(resolve_rule("Credit Card", None, "Debit", set())) + + def test_configured_mode_is_honoured(self): + rules = {"UPI": True, "Credit Card": False} + self.assertTrue(resolve_rule("UPI", rules, "Debit", set())) + self.assertFalse(resolve_rule("Credit Card", rules, "Debit", set())) + + def test_matching_is_case_sensitive(self): + """'credit card' is not 'Credit Card' -- it is an unrecognised mode.""" + unknown = set() + resolve_rule("credit card", {"Credit Card": False}, "Debit", unknown) + self.assertIn("credit card", unknown) + + def test_unknown_mode_behaviour(self): + self.assertTrue(resolve_rule("Wallet", {}, "Debit", set())) + self.assertFalse(resolve_rule("Wallet", {}, "Skip", set())) + with self.assertRaises(SettlementError): + resolve_rule("Wallet", {}, "Fail", set()) + + +class TestSegregation(unittest.TestCase): + """Needs Bank Accounts for the two account numbers above; skipped otherwise.""" + + @classmethod + def setUpClass(cls): + import frappe + + cls.frappe = frappe + cls.have_fixtures = all( + frappe.db.exists("Bank Account", {"bank_account_no": number}) + for number in ("50200011443440", "50200080706351") + ) + + def setUp(self): + if not self.have_fixtures: + self.skipTest("Bank Accounts for the sample account numbers are not set up") + + def test_bank_totals_ignore_payment_mode_rules(self): + """A mode rule must never remove money that physically reached the bank.""" + all_on = segregate(SETTLEMENT, None, "Debit") + cards_off = segregate(SETTLEMENT, {"Netbanking": True, "Credit Card": False}, "Debit") + self.assertEqual( + {c: b["bank_total"] for c, b in all_on["companies"].items()}, + {c: b["bank_total"] for c, b in cards_off["companies"].items()}, + ) + + def test_charges_split_by_mode(self): + result = segregate(SETTLEMENT, {"Netbanking": True, "Credit Card": False}, "Debit") + included = sum(b["included"] for b in result["companies"].values()) + skipped = sum(b["skipped"] for b in result["companies"].values()) + self.assertAlmostEqual(included, 9.44, places=2) + self.assertAlmostEqual(skipped, 9.44, places=2) + self.assertAlmostEqual(included + skipped, 18.88, places=2) + + def test_included_plus_skipped_reconciles_to_header(self): + result = segregate(SETTLEMENT, {"Netbanking": True, "Credit Card": False}, "Debit") + validate_segregation(SETTLEMENT, result, 0.50) + + def test_orphan_charge_fails_validation(self): + """A charge whose split_payout_id has no bank split cannot be booked.""" + payload = json.loads(json.dumps(SETTLEMENT)) + payload["settled_transactions"][0]["split_transactions"][0]["split_payout_id"] = "SPZ" + result = segregate(payload, None, "Debit") + with self.assertRaises(SettlementError): + validate_segregation(payload, result, 0.50) + + def test_payload_without_split_payouts_is_rejected(self): + with self.assertRaises(SettlementError): + segregate({"payout_id": "X", "split_payouts": []}, None, "Debit") diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/__init__.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/easebuzz_settlement_reconciliation.json b/easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/easebuzz_settlement_reconciliation.json new file mode 100644 index 0000000..4dc41ca --- /dev/null +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/easebuzz_settlement_reconciliation.json @@ -0,0 +1,109 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-08-19 10:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "journal_entry", + "status", + "payout_id", + "column_break_amounts", + "bank_total", + "charges_debited", + "charges_skipped", + "skipped_modes", + "error" + ], + "fields": [ + { + "columns": 2, + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "journal_entry", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Journal Entry", + "options": "Journal Entry", + "read_only": 1 + }, + { + "columns": 1, + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Draft\nSubmitted\nAlready Posted\nFailed", + "read_only": 1 + }, + { + "description": "Copied from the settlement payload so idempotency can be checked across logs.", + "fieldname": "payout_id", + "fieldtype": "Data", + "label": "Payout ID", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "column_break_amounts", + "fieldtype": "Column Break" + }, + { + "columns": 2, + "fieldname": "bank_total", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Bank Total", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "charges_debited", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Charges Debited", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "charges_skipped", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Charges Skipped", + "read_only": 1 + }, + { + "fieldname": "skipped_modes", + "fieldtype": "Data", + "label": "Skipped Modes", + "read_only": 1 + }, + { + "fieldname": "error", + "fieldtype": "Small Text", + "label": "Error", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-08-19 10:00:00.000000", + "modified_by": "Administrator", + "module": "Easebuzz", + "name": "Easebuzz Settlement Reconciliation", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/easebuzz_settlement_reconciliation.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/easebuzz_settlement_reconciliation.py new file mode 100644 index 0000000..991bb1d --- /dev/null +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_reconciliation/easebuzz_settlement_reconciliation.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Hybrowlabs and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class EasebuzzSettlementReconciliation(Document): + pass diff --git a/easebuzz/easebuzz/utils/settlement.py b/easebuzz/easebuzz/utils/settlement.py new file mode 100644 index 0000000..5bb58fb --- /dev/null +++ b/easebuzz/easebuzz/utils/settlement.py @@ -0,0 +1,681 @@ +# Copyright (c) 2026, Hybrowlabs and contributors +# For license information, please see license.txt +"""Turn an Easebuzz Settlement Log into balanced Journal Entries. + +The gateway settles fee payments to the school bank accounts and posts a +settlement payload back to us. Step 3 of the accounting flow -- +``Dr Bank / Cr Easebuzz suspense`` -- is what this module builds, so the +Easebuzz (PG suspense) account actually clears. + +One Journal Entry is created **per company per settlement**. A settlement +payload routinely spans more than one company (UESF and RESPL share a payout in +~91% of observed logs) and a Journal Entry belongs to exactly one company, so a +single JE for the whole log is not representable in ERPNext. +""" + +import ast +import json + +import frappe +from frappe import _ +from frappe.utils import flt, fmt_money, getdate +from frappe.utils.synchronization import filelock + +LOGGER = "easebuzz" + +# Easebuzz rounds per-split charges independently of the header total; drift of +# up to ~0.15 has been observed across 400 production payloads. +DEFAULT_CHARGE_TOLERANCE = 0.50 + + +def logger(): + return frappe.logger(LOGGER, allow_site=True, file_count=50) + + +class SettlementError(frappe.ValidationError): + """Raised for anything that must stop the settlement before it posts.""" + + +# --------------------------------------------------------------------------- # +# Payload parsing +# --------------------------------------------------------------------------- # + +def parse_settlement_payload(raw): + """Return the inner settlement dict from a stored ``Settlement Log.data``. + + ``settlement_hook`` stores ``frappe.parse_json(kwargs)``, so the field holds + a Python ``repr`` of ``{'status': '1', 'data': '', 'cmd': ...}``. + The settlement body -- ``split_payouts``, ``settled_transactions``, + ``total_amount`` -- lives in the *inner* JSON string, not the outer wrapper. + + Accepts, in order: an already-parsed dict, a JSON string, a Python repr + string. Unwraps the ``{'status', 'data'}`` envelope when present. + """ + if raw is None: + raise SettlementError(_("Settlement Log has no payload.")) + + payload = raw + if isinstance(payload, str): + payload = payload.strip() + if not payload: + raise SettlementError(_("Settlement Log has an empty payload.")) + payload = _loads(payload) + + if not isinstance(payload, dict): + raise SettlementError( + _("Settlement payload parsed to {0}, expected an object.").format(type(payload).__name__) + ) + + # Unwrap the {'status': '1', 'data': ''} envelope. Only treat it as an + # envelope when the outer object does not itself carry settlement keys. + inner = payload.get("data") + if inner is not None and "split_payouts" not in payload and "payout_id" not in payload: + payload = _loads(inner) if isinstance(inner, str) else inner + + if not isinstance(payload, dict): + raise SettlementError(_("Inner settlement payload is not an object.")) + + return payload + + +def _loads(text): + try: + return json.loads(text) + except (ValueError, TypeError): + pass + try: + return ast.literal_eval(text) + except (ValueError, SyntaxError, TypeError) as exc: + raise SettlementError(_("Could not parse settlement payload: {0}").format(exc)) + + +# --------------------------------------------------------------------------- # +# Settings resolution +# --------------------------------------------------------------------------- # + +def get_reconciliation_settings(): + """Resolve the Easebuzz Settings record that owns the reconciliation config. + + ``Easebuzz Settings`` is not a Single -- it is named by ``easebuzz_account`` + and a site may hold several. ``frappe.get_last_doc`` would pick whichever + was created last, which makes the applied rules invisible. Resolve + deterministically instead: prefer the single record that has reconciliation + switched on, and refuse to guess when that is ambiguous. + """ + names = frappe.get_all("Easebuzz Settings", pluck="name", order_by="name") + if not names: + return None + if len(names) == 1: + return frappe.get_cached_doc("Easebuzz Settings", names[0]) + + enabled = frappe.get_all( + "Easebuzz Settings", + filters={"auto_create_journal_entry": 1}, + pluck="name", + order_by="name", + ) + if len(enabled) == 1: + return frappe.get_cached_doc("Easebuzz Settings", enabled[0]) + if not enabled: + return None + + raise SettlementError( + _( + "{0} Easebuzz Settings records have Auto Create Journal Entry enabled ({1}). " + "Enable it on exactly one record so the applied charge rules are traceable." + ).format(len(enabled), ", ".join(enabled)) + ) + + +def load_mode_rules(settings): + """Return ``(rules, unknown_behaviour)``. + + ``rules`` maps an Easebuzz ``transaction_type`` to whether that mode's + charges are debited. Returns ``(None, "Debit")`` when the rules are off, + which means every mode's charges are debited -- the base behaviour. + """ + if not settings or not settings.get("enable_payment_mode_rules"): + return None, "Debit" + + rules = {} + for row in settings.get("allowed_mode_of_payment") or []: + key = (row.easebuzz_transaction_type or "").strip() + if not key: + continue + rules[key] = bool(row.debit_charges) + + return rules, (settings.get("unknown_mode_behaviour") or "Debit") + + +def resolve_rule(transaction_type, rules, unknown_behaviour, unknown_modes): + """Decide whether ``transaction_type``'s charges are debited. + + Matching is exact and case-sensitive against the raw payload value -- lower + or strip it silently and a genuinely new Easebuzz mode looks like a + configured one. + """ + if rules is None: + return True + + if transaction_type in rules: + return rules[transaction_type] + + unknown_modes.add(transaction_type or "") + if unknown_behaviour == "Fail": + raise SettlementError( + _("Unknown Easebuzz transaction_type '{0}' has no charge rule.").format(transaction_type) + ) + return unknown_behaviour == "Debit" + + +# --------------------------------------------------------------------------- # +# Bank account resolution +# --------------------------------------------------------------------------- # + +def resolve_bank_account(account_number): + """Map an Easebuzz split account number onto a Bank Account and company.""" + if not account_number: + raise SettlementError(_("A split payout carries no account_number.")) + + rows = frappe.get_all( + "Bank Account", + filters={"bank_account_no": account_number}, + fields=["name", "account", "company", "disabled"], + ) + if not rows: + raise SettlementError( + _("No Bank Account found with Easebuzz account number {0}.").format(account_number) + ) + + # A retired account and its replacement can share an account number, so match + # on the enabled one rather than treating the pair as ambiguous. + enabled = [row for row in rows if not row.disabled] + if not enabled: + raise SettlementError( + _("Bank Account {0} for Easebuzz account number {1} is disabled.").format( + rows[0].name, account_number + ) + ) + if len(enabled) > 1: + raise SettlementError( + _("Easebuzz account number {0} matches more than one enabled Bank Account: {1}.").format( + account_number, ", ".join(sorted(row.name for row in enabled)) + ) + ) + + row = enabled[0] + if not row.company: + raise SettlementError( + _("Bank Account {0} ({1}) has no company.").format(row.name, account_number) + ) + if not row.account: + raise SettlementError( + _("No GL Account linked to Bank Account {0} ({1}).").format(row.name, account_number) + ) + return row + + +def get_company_accounts(company): + doc = frappe.get_cached_doc("Company", company) + if not doc.get("default_easebuzz_account"): + raise SettlementError( + _("Company {0} has no Default Easebuzz Account (PG suspense) set.").format(company) + ) + return doc + + +# --------------------------------------------------------------------------- # +# Segregation +# --------------------------------------------------------------------------- # + +def segregate(payload, rules, unknown_behaviour): + """Group a settlement payload into per-company bank and charge buckets. + + Bank amounts come from ``split_payouts[]`` and are **never** filtered by + payment mode -- that money physically arrived in the school's account, so + excluding it would produce a JE that disagrees with the bank statement. + + Charges come from ``settled_transactions[].split_transactions[]`` and are + attributed to a company through ``split_payout_id``, which is the only join + key back to a bank split (``split_transactions[]`` carries no account + number of its own). + """ + splits = payload.get("split_payouts") or [] + if not splits: + raise SettlementError(_("Settlement payload carries no split_payouts.")) + + by_company = {} + payout_to_company = {} + unknown_modes = set() + unallocated_charges = 0.0 + + for split in splits: + bank = resolve_bank_account(split.get("account_number")) + company = bank.company + get_company_accounts(company) + + bucket = by_company.setdefault( + company, + { + "company": company, + "bank_lines": [], + "bank_total": 0.0, + "included": 0.0, + "skipped": 0.0, + "skipped_modes": set(), + }, + ) + amount = flt(split.get("payout_amount")) + bucket["bank_lines"].append( + { + "account": bank.account, + "bank_account": bank.name, + "amount": amount, + "account_label": split.get("account_label"), + "split_payout_id": split.get("split_payout_id"), + "bank_transaction_id": split.get("bank_transaction_id"), + } + ) + bucket["bank_total"] += amount + + split_payout_id = split.get("split_payout_id") + if split_payout_id: + payout_to_company[split_payout_id] = company + + for txn in payload.get("settled_transactions") or []: + mode = txn.get("transaction_type") + debit_charges = resolve_rule(mode, rules, unknown_behaviour, unknown_modes) + + for split_txn in txn.get("split_transactions") or []: + charge = flt(split_txn.get("service_charge")) + flt(split_txn.get("service_tax")) + if not charge: + continue + + company = payout_to_company.get(split_txn.get("split_payout_id")) + if not company: + # No route back to a bank split, so no company to book it against. + # Surfaced by the tolerance check below rather than silently dropped. + unallocated_charges += charge + continue + + bucket = by_company[company] + if debit_charges: + bucket["included"] += charge + else: + bucket["skipped"] += charge + bucket["skipped_modes"].add(mode or _("Unknown")) + + return { + "companies": by_company, + "unknown_modes": unknown_modes, + "unallocated_charges": unallocated_charges, + } + + +def validate_segregation(payload, result, tolerance): + """Check the split-level figures reconcile to the payload header.""" + companies = result["companies"] + header_charges = flt(payload.get("service_charge_amount")) + flt(payload.get("service_tax_amount")) + header_payout = flt(payload.get("payout_amount")) + + bank_total = sum(b["bank_total"] for b in companies.values()) + charge_total = ( + sum(b["included"] + b["skipped"] for b in companies.values()) + + result["unallocated_charges"] + ) + + problems = [] + if abs(flt(bank_total - header_payout, 2)) > tolerance: + problems.append( + _("Bank splits total {0} but the payload header reports payout_amount {1}.").format( + flt(bank_total, 2), header_payout + ) + ) + if abs(flt(charge_total - header_charges, 2)) > tolerance: + problems.append( + _( + "Charges derived from split_transactions total {0} but the payload header " + "reports {1} (service_charge_amount + service_tax_amount)." + ).format(flt(charge_total, 2), header_charges) + ) + if abs(flt(result["unallocated_charges"], 2)) > tolerance: + problems.append( + _("{0} of charges could not be attributed to a company via split_payout_id.").format( + flt(result["unallocated_charges"], 2) + ) + ) + + if problems: + raise SettlementError(" ".join(problems)) + + +# --------------------------------------------------------------------------- # +# Posting +# --------------------------------------------------------------------------- # + +def already_posted(payout_id, company): + """Return an existing Journal Entry for this payout and company, if any. + + Easebuzz redelivers settlements -- payout PTOBWKJAUF arrives twice in the + production log set -- so this is what keeps reruns from double-posting. + Cancelled entries do not count, which lets a cancel-and-repost correction + go through. + """ + rows = frappe.get_all( + "Easebuzz Settlement Reconciliation", + filters={"payout_id": payout_id, "company": company, "journal_entry": ("is", "set")}, + pluck="journal_entry", + ) + for name in rows: + if frappe.db.get_value("Journal Entry", name, "docstatus") in (0, 1): + return name + return None + + +def is_first_settlement_for_company(company): + """True until a settlement JE for this company has been submitted. + + The first JE per company is held as Draft so the mapping can be confirmed + before anything posts to the ledger. + """ + rows = frappe.get_all( + "Easebuzz Settlement Reconciliation", + filters={"company": company, "journal_entry": ("is", "set")}, + pluck="journal_entry", + ) + for name in rows: + if frappe.db.get_value("Journal Entry", name, "docstatus") == 1: + return False + return True + + +def build_remark(payload, log_name, bucket, currency=None): + lines = [ + _("Easebuzz Settlement {0}").format(payload.get("payout_id")), + _("Settlement Log: {0}").format(log_name), + _("Company: {0}").format(bucket["company"]), + _("Payout Date: {0}").format(getdate(payload.get("payout_date"))), + _("Charges debited: {0}").format(fmt_money(bucket["included"], currency=currency)), + ] + if bucket["skipped"]: + lines.append( + _("Charges skipped: {0} (modes: {1})").format( + fmt_money(bucket["skipped"], currency=currency), + ", ".join(sorted(bucket["skipped_modes"])), + ) + ) + return "\n".join(lines) + + +def build_journal_entry(payload, log_name, bucket, precision): + """Build one balanced Journal Entry for a single company. + + Every line is rounded to currency precision first and the suspense credit is + then computed as the sum of the rounded debits, so the entry balances + exactly rather than to within a rounding error. + """ + company_doc = get_company_accounts(bucket["company"]) + charges = flt(bucket["included"], precision) + + if charges and not company_doc.get("custom_easebuzz_charges"): + raise SettlementError( + _( + "Company {0} has no Easebuzz Charges account (custom_easebuzz_charges) set, " + "but this settlement carries {1} of charges to debit." + ).format(bucket["company"], charges) + ) + + suspense = company_doc.default_easebuzz_account + cost_center = company_doc.get("cost_center") + accounts = [] + total_debit = 0.0 + + for line in bucket["bank_lines"]: + amount = flt(line["amount"], precision) + if not amount: + continue + total_debit = flt(total_debit + amount, precision) + accounts.append( + { + "account": line["account"], + "bank_account": line["bank_account"], + "cost_center": cost_center, + "debit_in_account_currency": amount, + "debit": amount, + "credit_in_account_currency": 0, + "credit": 0, + "is_advance": "No", + "against_account": suspense, + # Journal Entry Account has no cheque_no field and ERPNext bank + # reconciliation matches on the JE header's cheque_no, so the + # per-split bank reference is carried here for traceability. + "user_remark": " | ".join( + str(part) + for part in ( + line.get("account_label"), + line.get("split_payout_id"), + line.get("bank_transaction_id"), + ) + if part + ), + } + ) + + # A settlement whose charges all come from disabled modes gets no charges + # line at all, rather than a 0.00 line. + if charges: + total_debit = flt(total_debit + charges, precision) + accounts.append( + { + "account": company_doc.custom_easebuzz_charges, + "cost_center": cost_center, + "debit_in_account_currency": charges, + "debit": charges, + "credit_in_account_currency": 0, + "credit": 0, + "is_advance": "No", + "against_account": suspense, + "user_remark": _("Easebuzz service charge and tax"), + } + ) + + if not accounts: + raise SettlementError( + _("Nothing to post for company {0} -- every line is zero.").format(bucket["company"]) + ) + + accounts.append( + { + "account": suspense, + "cost_center": cost_center, + "debit_in_account_currency": 0, + "debit": 0, + "credit_in_account_currency": total_debit, + "credit": total_debit, + "is_advance": "No", + "against_account": ", ".join(sorted({a["account"] for a in accounts})), + } + ) + + header_txn_id = payload.get("bank_transaction_id") + if not header_txn_id or header_txn_id == "NA": + header_txn_id = payload.get("payout_id") + + je = frappe.new_doc("Journal Entry") + je.update( + { + "is_system_generated": 1, + "title": _("Easebuzz Settlement {0}").format(payload.get("payout_id")), + "voucher_type": "Bank Entry", + "naming_series": "ACC-JV-.YYYY.-", + "company": bucket["company"], + "posting_date": getdate(payload.get("payout_date")), + "cheque_no": header_txn_id, + "cheque_date": getdate(payload.get("payout_date")), + "user_remark": build_remark(payload, log_name, bucket, company_doc.get("default_currency")), + "mode_of_payment": "Online", + "is_opening": "No", + } + ) + for account in accounts: + je.append("accounts", account) + + return je, total_debit + + +# --------------------------------------------------------------------------- # +# Entry point +# --------------------------------------------------------------------------- # + +def process_settlement_log(name, force=False): + """Parse, segregate, validate and post one Easebuzz Settlement Log. + + Safe to re-run: an already-posted (payout_id, company) pair is recorded as + ``Already Posted`` instead of creating a second Journal Entry. + """ + force = frappe.parse_json(force) if isinstance(force, str) else force + doc = frappe.get_doc("Easebuzz Settlement Log", name) + + if doc.status == "Processed" and not force: + return doc.status + + try: + settings = get_reconciliation_settings() + payload = parse_settlement_payload(doc.data) + _stamp_header(doc, payload) + + if not payload.get("payout_id"): + raise SettlementError(_("Settlement payload carries no payout_id.")) + if not doc.payout_date: + # Without it the posting date would silently fall back to today. + raise SettlementError(_("Settlement payload carries no payout_date.")) + + start_date = settings and settings.get("reconciliation_start_date") + if start_date and getdate(doc.payout_date) < getdate(start_date): + return _finish( + doc, + "Skipped", + _("Payout date {0} is before the reconciliation start date {1}.").format( + doc.payout_date, getdate(start_date) + ), + ) + + rules, unknown_behaviour = load_mode_rules(settings) + tolerance = flt((settings and settings.get("charge_tolerance")) or DEFAULT_CHARGE_TOLERANCE) + + result = segregate(payload, rules, unknown_behaviour) + validate_segregation(payload, result, tolerance) + + except Exception as exc: + logger().error(f"Easebuzz Settlement Log {name} failed preflight: {exc}") + return _finish(doc, "Failed", str(exc)) + + # Persisted before any posting so a worker that dies mid-settlement leaves a + # visible "Processing" log rather than a silent "Pending" one. + frappe.db.set_value( + "Easebuzz Settlement Log", doc.name, + {"status": "Processing", "error_message": None}, update_modified=False, + ) + frappe.db.commit() + doc.status = "Processing" + doc.error_message = None + doc.set("reconciliation", []) + + auto_submit = bool(settings and settings.get("auto_submit_journal_entry")) + precision = frappe.get_precision("Journal Entry Account", "debit") + if result["unknown_modes"]: + logger().warning( + f"Easebuzz Settlement Log {name}: transaction types with no rule: " + f"{sorted(result['unknown_modes'])}" + ) + + # Easebuzz redelivers settlements as a *new* log, so the enqueue job_id + # differs and `deduplicate` does not apply. Serialise on the payout instead, + # so the already_posted check and the insert cannot interleave. + with filelock(f"easebuzz-settlement-{payload['payout_id']}", timeout=600): + failures, held_for_review = _post_companies( + doc, payload, result, auto_submit, precision, name + ) + + if failures: + return _finish(doc, "Needs Review", "\n".join(failures)) + if held_for_review: + return _finish( + doc, + "Needs Review", + _("Journal Entries are held as Draft for confirmation before auto-submit is enabled."), + ) + return _finish(doc, "Processed", None) + + +def _post_companies(doc, payload, result, auto_submit, precision, name): + """Create one Journal Entry per company, isolating each company's failure.""" + failures = [] + held_for_review = False + + for index, company in enumerate(sorted(result["companies"])): + bucket = result["companies"][company] + row = { + "company": company, + "payout_id": payload.get("payout_id"), + "bank_total": flt(bucket["bank_total"], precision), + "charges_debited": flt(bucket["included"], precision), + "charges_skipped": flt(bucket["skipped"], precision), + "skipped_modes": ", ".join(sorted(bucket["skipped_modes"])) or None, + } + + existing = already_posted(payload["payout_id"], company) + if existing: + row.update({"journal_entry": existing, "status": "Already Posted"}) + doc.append("reconciliation", row) + continue + + # One company failing must not roll back the companies that already + # posted, so each is wrapped in its own savepoint. + save_point = f"easebuzz_settlement_{index}" + frappe.db.savepoint(save_point) + try: + je, _total = build_journal_entry(payload, doc.name, bucket, precision) + je.insert(ignore_permissions=True) + + submit = auto_submit and not is_first_settlement_for_company(company) + if submit: + je.submit() + else: + held_for_review = True + + row.update({"journal_entry": je.name, "status": "Submitted" if submit else "Draft"}) + doc.append("reconciliation", row) + except Exception as exc: + frappe.db.rollback(save_point=save_point) + logger().error(f"Easebuzz Settlement Log {name} / {company}: {exc}") + row.update({"status": "Failed", "error": str(exc)}) + doc.append("reconciliation", row) + failures.append(f"{company}: {exc}") + + return failures, held_for_review + + +def _stamp_header(doc, payload): + doc.payout_id = payload.get("payout_id") + if payload.get("payout_date"): + doc.payout_date = getdate(payload.get("payout_date")) + doc.total_amount = flt(payload.get("total_amount")) + doc.payout_amount = flt(payload.get("payout_amount")) + doc.charges_amount = flt(payload.get("service_charge_amount")) + flt( + payload.get("service_tax_amount") + ) + + +def _finish(doc, status, message): + """Persist the outcome. + + Reconciliation rows are never cleared here. They are the only record of + which (payout_id, company) pairs already have a Journal Entry, so dropping + them on a failed re-run would let the next run post a duplicate. + """ + doc.status = status + doc.error_message = message + doc.save(ignore_permissions=True) + frappe.db.commit() + return status diff --git a/easebuzz/hooks.py b/easebuzz/hooks.py index 40bcd17..f7a0088 100644 --- a/easebuzz/hooks.py +++ b/easebuzz/hooks.py @@ -102,11 +102,10 @@ # --------------- # Hook on document methods and events -doc_events = { - "Easebuzz Settlement Log": { - "before_save": "easebuzz.easebuzz.doctype.easebuzz_settlement_log.easebuzz_settlement_log.process_log" - } -} +# Settlement reconciliation runs from EasebuzzSettlementLog.after_insert, not +# from a before_save hook -- before_save re-posted the whole settlement on every +# subsequent save of the log. +doc_events = {} # Scheduled Tasks # --------------- From ed688526a544dade872fa4cbd2cf3723bb5b838b Mon Sep 17 00:00:00 2001 From: rohitkumarthongar Date: Thu, 20 Aug 2026 11:03:26 +0530 Subject: [PATCH 08/13] chore: add pyproject.toml The reconciliation branch never picked up the packaging metadata that prod carries, so the app had only the legacy setup.py and no ruff configuration. Restore prod's file verbatim so the two branches converge cleanly on merge rather than diverging on build backend or lint rules. Declares flit as the build backend with the version read from easebuzz.__version__, requires Python >=3.10, pins frappe >=15.40.4,<16.0.0 for bench, and configures ruff (line length 110, target py310). setup.py, requirements.txt and MANIFEST.in are left in place, matching prod. Linting the app under this config reports 86 pre-existing findings across 10 legacy files -- mostly true-false comparisons, trailing whitespace and unsorted imports. None are in the reconciliation code and none are fixed here; that backlog is left for a separate pass so this commit stays packaging-only. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..68877fc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[project] +name = "easebuzz" +authors = [ + { name = "Hybrowlabs Technologies", email = "support@hybrowlabs.com"} +] +description = "Easebuzz Payment Gateway Integration for Frappe" +requires-python = ">=3.10" +readme = "README.md" +dynamic = ["version"] +dependencies = [ + # "frappe~=15.0.0" # Installed and managed by bench. +] + +[build-system] +requires = ["flit_core >=3.4,<4"] +build-backend = "flit_core.buildapi" + +# These dependencies are only installed when developer mode is enabled +[tool.bench.dev-dependencies] +# package_name = "~=1.1.0" + +[tool.bench.frappe-dependencies] +frappe = ">=15.40.4,<16.0.0" + +[tool.ruff] +line-length = 110 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "F", + "E", + "W", + "I", + "UP", + "B", +] +ignore = [ + "B017", # assertRaises(Exception) - should be more specific + "B018", # useless expression, not assigned to anything + "B023", # function doesn't bind loop variable - will have last iteration's value + "B904", # raise inside except without from + "E101", # indentation contains mixed spaces and tabs + "E402", # module level import not at top of file + "E501", # line too long + "E741", # ambiguous variable name + "F401", # "unused" imports + "F403", # can't detect undefined names from * import + "F405", # can't detect undefined names from * import + "F722", # syntax error in forward type annotation + "W191", # indentation contains tabs +] +typing-modules = ["frappe.types.DF"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "tab" +docstring-code-format = true From 732723f06c9d10fbc3364a37b908f072b1e492c6 Mon Sep 17 00:00:00 2001 From: rohitkumarthongar Date: Thu, 20 Aug 2026 14:31:50 +0530 Subject: [PATCH 09/13] feat(reconciliation): Add options to add charges account or default account on reconcilation --- .../__init__.py | 0 .../easebuzz_company_charge_account.json | 53 ++++++ .../easebuzz_company_charge_account.py | 8 + .../easebuzz_settings/easebuzz_settings.js | 141 +++++++++++++- .../easebuzz_settings/easebuzz_settings.json | 54 ++++-- .../easebuzz_settings/easebuzz_settings.py | 178 ++++++++++++++++++ .../test_easebuzz_settlement_log.py | 48 +++++ easebuzz/easebuzz/utils/settlement.py | 43 ++++- 8 files changed, 494 insertions(+), 31 deletions(-) create mode 100644 easebuzz/easebuzz/doctype/easebuzz_company_charge_account/__init__.py create mode 100644 easebuzz/easebuzz/doctype/easebuzz_company_charge_account/easebuzz_company_charge_account.json create mode 100644 easebuzz/easebuzz/doctype/easebuzz_company_charge_account/easebuzz_company_charge_account.py diff --git a/easebuzz/easebuzz/doctype/easebuzz_company_charge_account/__init__.py b/easebuzz/easebuzz/doctype/easebuzz_company_charge_account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easebuzz/easebuzz/doctype/easebuzz_company_charge_account/easebuzz_company_charge_account.json b/easebuzz/easebuzz/doctype/easebuzz_company_charge_account/easebuzz_company_charge_account.json new file mode 100644 index 0000000..6c3f613 --- /dev/null +++ b/easebuzz/easebuzz/doctype/easebuzz_company_charge_account/easebuzz_company_charge_account.json @@ -0,0 +1,53 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-08-20 10:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "charges_account", + "notes" + ], + "fields": [ + { + "columns": 3, + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "columns": 5, + "description": "Expense account this company's Easebuzz service charge and tax are debited to.", + "fieldname": "charges_account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Easebuzz Charges Account", + "options": "Account", + "reqd": 1 + }, + { + "columns": 2, + "fieldname": "notes", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Notes" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-08-20 10:00:00.000000", + "modified_by": "Administrator", + "module": "Easebuzz", + "name": "Easebuzz Company Charge Account", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/easebuzz/easebuzz/doctype/easebuzz_company_charge_account/easebuzz_company_charge_account.py b/easebuzz/easebuzz/doctype/easebuzz_company_charge_account/easebuzz_company_charge_account.py new file mode 100644 index 0000000..2af3ff0 --- /dev/null +++ b/easebuzz/easebuzz/doctype/easebuzz_company_charge_account/easebuzz_company_charge_account.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Hybrowlabs and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class EasebuzzCompanyChargeAccount(Document): + pass diff --git a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js index d0b5823..f5e1557 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js @@ -1,12 +1,33 @@ // Copyright (c) 2026, Hybrowlabs and contributors // For license information, please see license.txt -const KNOWN_MODES_METHOD = - "easebuzz.easebuzz.doctype.easebuzz_settings.easebuzz_settings.get_known_transaction_types"; +const METHOD_PREFIX = "easebuzz.easebuzz.doctype.easebuzz_settings.easebuzz_settings."; +const KNOWN_MODES_METHOD = METHOD_PREFIX + "get_known_transaction_types"; +const CHARGE_STATUS_METHOD = METHOD_PREFIX + "get_charge_account_status"; +const CREATE_CHARGE_ACCOUNT_METHOD = METHOD_PREFIX + "create_charge_account"; frappe.ui.form.on("Easebuzz Settings", { + setup(frm) { + // An Account belongs to exactly one company, so the row's own company is + // the only sensible scope -- and charges are always an expense ledger. + frm.set_query("charges_account", "company_charge_accounts", (doc, cdt, cdn) => { + const row = locals[cdt][cdn]; + return { + filters: { + company: row.company, + is_group: 0, + root_type: "Expense", + }, + }; + }); + }, + refresh(frm) { - if (frm.is_new() || !frm.doc.enable_payment_mode_rules) return; + if (frm.is_new()) return; + + frm.add_custom_button(__("Check Charges Accounts"), () => show_charge_accounts(frm)); + + if (!frm.doc.enable_payment_mode_rules) return; // Rows are appended client-side and left for the user to save, so the // button never persists unrelated unsaved edits on the form. @@ -48,3 +69,117 @@ frappe.ui.form.on("Easebuzz Payment Mode Rule", { } }, }); + +function show_charge_accounts(frm) { + frappe.call(CHARGE_STATUS_METHOD).then((r) => { + const rows = r.message || []; + if (!rows.length) { + frappe.msgprint({ + title: __("No Settling Companies"), + message: __( + "No company has a Default Easebuzz Account (PG suspense) set, so no settlement can post yet." + ), + indicator: "orange", + }); + return; + } + + const dialog = new frappe.ui.Dialog({ + title: __("Easebuzz Charges Accounts"), + size: "large", + fields: [{ fieldname: "summary", fieldtype: "HTML" }], + primary_action_label: __("Close"), + primary_action: () => dialog.hide(), + }); + + dialog.fields_dict.summary.$wrapper.html(render_charge_accounts(rows)); + dialog.$wrapper.on("click", "[data-create-company]", (event) => { + const company = $(event.currentTarget).attr("data-create-company"); + create_charge_account(frm, dialog, company); + }); + dialog.$wrapper.on("click", "[data-link-company]", (event) => { + const $button = $(event.currentTarget); + const company = $button.attr("data-link-company"); + const account = $button.closest("tr").find("select").val(); + if (account) apply_charge_account(frm, dialog, company, account, false); + }); + dialog.show(); + }); +} + +function render_charge_accounts(rows) { + const body = rows + .map((row) => { + if (row.account) { + return ` + ${frappe.utils.escape_html(row.company)} + ${frappe.utils.escape_html(row.account)} + ${__(row.source)} + `; + } + + const options = (row.candidates || []) + .map((name) => ``) + .join(""); + const link = options + ? ` + ` + : ""; + + return ` + ${frappe.utils.escape_html(row.company)} + ${__("Not set")} + + ${link} + + + `; + }) + .join(""); + + return `

${__( + "Charges post to the account on the Company master first; the Company Charge Accounts table is the fallback. A company with neither is held for review when its settlement carries charges." + )}

+ + + ${body} +
${__("Company")}${__("Charges Account")}${__("Source")}
`; +} + +function create_charge_account(frm, dialog, company) { + frappe + .call({ + method: CREATE_CHARGE_ACCOUNT_METHOD, + args: { company: company }, + freeze: true, + freeze_message: __("Creating charges account..."), + }) + .then((r) => { + if (!r.message) return; + apply_charge_account(frm, dialog, company, r.message.account, r.message.created); + }); +} + +function apply_charge_account(frm, dialog, company, account, created) { + // The row is appended and left for the user to save, so the button never + // persists unrelated unsaved edits on the form. + const existing = (frm.doc.company_charge_accounts || []).find((row) => row.company === company); + if (existing) { + frappe.model.set_value(existing.doctype, existing.name, "charges_account", account); + } else { + const row = frm.add_child("company_charge_accounts"); + row.company = company; + row.charges_account = account; + } + frm.refresh_field("company_charge_accounts"); + + frappe.show_alert({ + message: created + ? __("Created {0} and added it for {1}. Save to apply.", [account, company]) + : __("Added {0} for {1}. Save to apply.", [account, company]), + indicator: "green", + }); + dialog.hide(); +} diff --git a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json index 191f93b..dcd21fd 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json @@ -21,6 +21,8 @@ "charge_tolerance", "enable_payment_mode_rules", "unknown_mode_behaviour", + "charge_accounts_section", + "company_charge_accounts", "payment_mode_rules_section", "allowed_mode_of_payment" ], @@ -60,25 +62,25 @@ }, { "default": "0", + "description": "Create the settlement Journal Entry automatically when an Easebuzz Settlement Log is inserted. Off = logs are stored with status Pending and can be processed manually from the log.", "fieldname": "auto_create_journal_entry", "fieldtype": "Check", - "label": "Auto Create Journal Entry", - "description": "Create the settlement Journal Entry automatically when an Easebuzz Settlement Log is inserted. Off = logs are stored with status Pending and can be processed manually from the log." + "label": "Auto Create Journal Entry" }, { "default": "0", + "depends_on": "auto_create_journal_entry", + "description": "Off = every Journal Entry is left as Draft and the log is marked Needs Review. The first settlement for a company is ALWAYS left as Draft regardless of this setting.", "fieldname": "auto_submit_journal_entry", "fieldtype": "Check", - "label": "Auto Submit Journal Entry", - "depends_on": "auto_create_journal_entry", - "description": "Off = every Journal Entry is left as Draft and the log is marked Needs Review. The first settlement for a company is ALWAYS left as Draft regardless of this setting." + "label": "Auto Submit Journal Entry" }, { + "depends_on": "auto_create_journal_entry", + "description": "Settlements with an earlier payout date are marked Skipped and never posted. Leave blank to process every incoming settlement.", "fieldname": "reconciliation_start_date", "fieldtype": "Date", - "label": "Reconcile Settlements On or After", - "depends_on": "auto_create_journal_entry", - "description": "Settlements with an earlier payout date are marked Skipped and never posted. Leave blank to process every incoming settlement." + "label": "Reconcile Settlements On or After" }, { "fieldname": "column_break_recon", @@ -86,39 +88,51 @@ }, { "default": "0.50", + "description": "Maximum allowed difference between the charges derived from split_transactions[] and the payload header total. Easebuzz rounding drift of up to ~0.15 has been observed.", "fieldname": "charge_tolerance", "fieldtype": "Currency", - "label": "Charge Reconciliation Tolerance", - "description": "Maximum allowed difference between the charges derived from split_transactions[] and the payload header total. Easebuzz rounding drift of up to ~0.15 has been observed." + "label": "Charge Reconciliation Tolerance" }, { "default": "0", + "description": "Off = every payment mode's charges are debited to the charges expense account.", "fieldname": "enable_payment_mode_rules", "fieldtype": "Check", - "label": "Enable Payment Mode Rules", - "description": "Off = every payment mode's charges are debited to the charges expense account." + "label": "Enable Payment Mode Rules" }, { + "default": "Debit", + "depends_on": "enable_payment_mode_rules", + "description": "Applied when Easebuzz reports a transaction_type that has no row in the table below.", "fieldname": "unknown_mode_behaviour", "fieldtype": "Select", "label": "Unknown Mode Behaviour", - "options": "Debit\nSkip\nFail", - "default": "Debit", - "depends_on": "enable_payment_mode_rules", - "description": "Applied when Easebuzz reports a transaction_type that has no row in the table below." + "options": "Debit\nSkip\nFail" }, { + "fieldname": "charge_accounts_section", + "fieldtype": "Section Break", + "label": "Easebuzz Charges Accounts" + }, + { + "description": "Fallback used when a Company has no Easebuzz Charges account of its own. The Company field on the Company master wins; this table is consulted next. A settlement carrying charges for a company with neither is held for review.", + "fieldname": "company_charge_accounts", + "fieldtype": "Table", + "label": "Company Charge Accounts", + "options": "Easebuzz Company Charge Account" + }, + { + "depends_on": "enable_payment_mode_rules", "fieldname": "payment_mode_rules_section", "fieldtype": "Section Break", - "label": "Payment Mode Rules", - "depends_on": "enable_payment_mode_rules" + "label": "Payment Mode Rules" }, { + "description": "Only the charges of modes with Debit Charges to Expense ticked are added to the charges debit line. Bank payout lines are never filtered \u2014 that money physically reached the bank.", "fieldname": "allowed_mode_of_payment", "fieldtype": "Table", "label": "Allowed Mode of Payment", - "options": "Easebuzz Payment Mode Rule", - "description": "Only the charges of modes with Debit Charges to Expense ticked are added to the charges debit line. Bank payout lines are never filtered \u2014 that money physically reached the bank." + "options": "Easebuzz Payment Mode Rule" } ], "index_web_pages_for_search": 1, diff --git a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py index 97f61a0..1cc9844 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py @@ -13,6 +13,10 @@ # The transaction_type strings Easebuzz sends, paired with the ERPNext Mode of # Payment they correspond to. Matching is done on the string, not the link, so # a missing or renamed Mode of Payment record cannot break a rule. +# Name used when the settings form creates a missing charges ledger. ERPNext +# appends the company abbreviation, so this becomes "Easebuzz Charges - UESF". +DEFAULT_CHARGE_ACCOUNT_NAME = "Easebuzz Charges" + EASEBUZZ_TRANSACTION_TYPES = ( ("UPI", "UPI"), ("Credit Card", "Credit Card"), @@ -35,6 +39,7 @@ def after_insert(self): def validate(self): self.validate_payment_mode_rules() + self.validate_company_charge_accounts() def validate_payment_mode_rules(self): """Reject duplicate or blank transaction types in the charge rule table. @@ -60,6 +65,55 @@ def validate_payment_mode_rules(self): ) seen[key] = row.idx + def validate_company_charge_accounts(self): + """Keep the fallback table unambiguous and postable. + + A second row for the same company would make the account that gets + debited depend on row order, and an account belonging to another company + would only surface as an ERPNext error deep inside a settlement job. + """ + seen = {} + for row in self.get("company_charge_accounts") or []: + if row.company in seen: + frappe.throw( + frappe._( + "Rows #{0} and #{1} both configure a charges account for {2}. " + "Each company may appear only once." + ).format(seen[row.company], row.idx, row.company) + ) + seen[row.company] = row.idx + + account = frappe.db.get_value( + "Account", + row.charges_account, + ["company", "is_group", "root_type"], + as_dict=True, + ) + if not account: + frappe.throw( + frappe._("Row #{0}: Account {1} does not exist.").format( + row.idx, row.charges_account + ) + ) + if account.company != row.company: + frappe.throw( + frappe._( + "Row #{0}: Account {1} belongs to {2}, not to {3}." + ).format(row.idx, row.charges_account, account.company, row.company) + ) + if account.is_group: + frappe.throw( + frappe._( + "Row #{0}: {1} is a group account. Charges must be debited to a ledger." + ).format(row.idx, row.charges_account) + ) + if account.root_type != "Expense": + frappe.throw( + frappe._( + "Row #{0}: {1} is {2}, not an Expense account." + ).format(row.idx, row.charges_account, account.root_type) + ) + def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: frappe.throw( @@ -279,6 +333,130 @@ def get_known_transaction_types(): ] +@frappe.whitelist() +def get_charge_account_status(): + """Report where each settling company's Easebuzz charges would be debited. + + A company settles through Easebuzz when it has a PG suspense account, so + that is what defines "participating" -- the reconciliation never touches a + company without one. For each, report the account that + ``resolve_charges_account`` would pick and, when there is none, the existing + account that looks like the right one so the user links it instead of + creating a second charges GL. + """ + settings_rows = {} + for name in frappe.get_all("Easebuzz Settings", pluck="name"): + for row in frappe.get_all( + "Easebuzz Company Charge Account", + filters={"parent": name, "parenttype": "Easebuzz Settings"}, + fields=["company", "charges_account"], + ): + settings_rows.setdefault(row.company, row.charges_account) + + companies = frappe.get_all( + "Company", + filters={"default_easebuzz_account": ("is", "set")}, + fields=["name", "abbr", "custom_easebuzz_charges"], + order_by="name", + ) + + status = [] + for company in companies: + account = company.custom_easebuzz_charges + source = "Company" if account else None + if not account: + account = settings_rows.get(company.name) + source = "Easebuzz Settings" if account else None + + status.append( + { + "company": company.name, + "account": account, + "source": source, + "suggested_account_name": DEFAULT_CHARGE_ACCOUNT_NAME, + "suggested_parent": account or find_expense_parent(company.name), + "candidates": [] if account else find_charge_account_candidates(company.name), + } + ) + return status + + +def find_charge_account_candidates(company): + """Ledger expense accounts of this company that already look like the one.""" + return frappe.get_all( + "Account", + filters={ + "company": company, + "is_group": 0, + "root_type": "Expense", + "account_name": ("like", "%Easebuzz%"), + }, + pluck="name", + order_by="name", + ) + + +def find_expense_parent(company): + """The group account a new Easebuzz charges ledger should sit under.""" + for account_name in ("Indirect Expenses", "Expenses"): + parent = frappe.db.get_value( + "Account", + {"company": company, "account_name": account_name, "is_group": 1}, + "name", + ) + if parent: + return parent + + return frappe.db.get_value( + "Account", + {"company": company, "root_type": "Expense", "is_group": 1, "parent_account": ("is", "not set")}, + "name", + ) + + +@frappe.whitelist() +def create_charge_account(company, account_name=None, parent_account=None): + """Create the Easebuzz charges ledger for a company, or return the existing one. + + Called from the settings form when a company has no charges account, so the + settlement does not have to be re-run against a half-configured chart of + accounts. Never creates a second ledger with the same name. + """ + frappe.has_permission("Account", "create", throw=True) + + account_name = (account_name or DEFAULT_CHARGE_ACCOUNT_NAME).strip() + existing = frappe.db.get_value( + "Account", + {"company": company, "account_name": account_name, "is_group": 0}, + "name", + ) + if existing: + return {"account": existing, "created": False} + + parent_account = parent_account or find_expense_parent(company) + if not parent_account: + frappe.throw( + frappe._( + "{0} has no expense group account to create {1} under. " + "Create the account manually and select it here." + ).format(company, account_name) + ) + + account = frappe.get_doc( + { + "doctype": "Account", + "account_name": account_name, + "company": company, + "parent_account": parent_account, + "root_type": "Expense", + "report_type": "Profit and Loss", + "is_group": 0, + } + ).insert() + + return {"account": account.name, "created": True} + + @frappe.whitelist(allow_guest=True) def get_merchant_key(): controller = frappe.get_doc("Easebuzz Settings") diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py index 9c3a9e8..3a871bc 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py @@ -7,11 +7,18 @@ from easebuzz.easebuzz.utils.settlement import ( SettlementError, parse_settlement_payload, + resolve_charges_account, resolve_rule, segregate, validate_segregation, ) + +class _Doc(dict): + """Stands in for a Frappe document: attribute and ``.get()`` access.""" + + __getattr__ = dict.get + SETTLEMENT = { "payout_id": "PTTEST0001", "payout_date": "2026-08-18 12:55:06.618976", @@ -153,3 +160,44 @@ def test_orphan_charge_fails_validation(self): def test_payload_without_split_payouts_is_rejected(self): with self.assertRaises(SettlementError): segregate({"payout_id": "X", "split_payouts": []}, None, "Debit") + + +class TestChargesAccountResolution(unittest.TestCase): + """Company master first, Easebuzz Settings fallback second.""" + + def company(self, account=None, name="UESF"): + return _Doc(name=name, custom_easebuzz_charges=account) + + def settings(self, *rows): + return _Doc(company_charge_accounts=[_Doc(**row) for row in rows]) + + def test_company_master_wins(self): + settings = self.settings( + {"company": "UESF", "charges_account": "Fallback - UESF"}, + ) + account = resolve_charges_account(self.company("Easebuzz Charges - UESF"), settings) + self.assertEqual(account, "Easebuzz Charges - UESF") + + def test_falls_back_to_settings_table(self): + settings = self.settings( + {"company": "RESPL", "charges_account": "Easebuzz Charges - RESPL"}, + {"company": "UESF", "charges_account": "Easebuzz Charges - UESF"}, + ) + self.assertEqual( + resolve_charges_account(self.company(), settings), "Easebuzz Charges - UESF" + ) + + def test_other_companies_rows_are_not_used(self): + settings = self.settings({"company": "RESPL", "charges_account": "Easebuzz Charges - RESPL"}) + self.assertIsNone(resolve_charges_account(self.company(), settings)) + + def test_blank_row_account_is_not_used(self): + settings = self.settings({"company": "UESF", "charges_account": None}) + self.assertIsNone(resolve_charges_account(self.company(), settings)) + + def test_no_settings_record_at_all(self): + self.assertIsNone(resolve_charges_account(self.company(), None)) + self.assertEqual( + resolve_charges_account(self.company("Easebuzz Charges - UESF"), None), + "Easebuzz Charges - UESF", + ) diff --git a/easebuzz/easebuzz/utils/settlement.py b/easebuzz/easebuzz/utils/settlement.py index 5bb58fb..43dc990 100644 --- a/easebuzz/easebuzz/utils/settlement.py +++ b/easebuzz/easebuzz/utils/settlement.py @@ -224,6 +224,30 @@ def get_company_accounts(company): return doc +def resolve_charges_account(company_doc, settings=None): + """Return the expense account this company's Easebuzz charges are debited to. + + The Company master wins, so a company Finance has already configured keeps + posting where they put it. The ``Company Charge Accounts`` table on Easebuzz + Settings is the fallback: a company that starts settling before anyone fills + in its Company field should not fail the whole payload. + + Returns ``None`` when neither is set -- the caller decides whether that + matters, because a settlement whose charges are all skipped by payment-mode + rules needs no charges account at all. + """ + account = company_doc.get("custom_easebuzz_charges") + if account: + return account + + rows = settings.get("company_charge_accounts") if settings is not None else None + for row in rows or []: + if row.get("company") == company_doc.name and row.get("charges_account"): + return row.get("charges_account") + + return None + + # --------------------------------------------------------------------------- # # Segregation # --------------------------------------------------------------------------- # @@ -407,7 +431,7 @@ def build_remark(payload, log_name, bucket, currency=None): return "\n".join(lines) -def build_journal_entry(payload, log_name, bucket, precision): +def build_journal_entry(payload, log_name, bucket, precision, settings=None): """Build one balanced Journal Entry for a single company. Every line is rounded to currency precision first and the suspense credit is @@ -416,12 +440,15 @@ def build_journal_entry(payload, log_name, bucket, precision): """ company_doc = get_company_accounts(bucket["company"]) charges = flt(bucket["included"], precision) + charges_account = resolve_charges_account(company_doc, settings) if charges else None - if charges and not company_doc.get("custom_easebuzz_charges"): + if charges and not charges_account: raise SettlementError( _( - "Company {0} has no Easebuzz Charges account (custom_easebuzz_charges) set, " - "but this settlement carries {1} of charges to debit." + "Company {0} has no Easebuzz Charges account, but this settlement carries " + "{1} of charges to debit. Set Easebuzz Charges on the Company, or add a row " + "for {0} under Easebuzz Charges Accounts in Easebuzz Settings -- the settings " + "form can create the account for you." ).format(bucket["company"], charges) ) @@ -467,7 +494,7 @@ def build_journal_entry(payload, log_name, bucket, precision): total_debit = flt(total_debit + charges, precision) accounts.append( { - "account": company_doc.custom_easebuzz_charges, + "account": charges_account, "cost_center": cost_center, "debit_in_account_currency": charges, "debit": charges, @@ -594,7 +621,7 @@ def process_settlement_log(name, force=False): # so the already_posted check and the insert cannot interleave. with filelock(f"easebuzz-settlement-{payload['payout_id']}", timeout=600): failures, held_for_review = _post_companies( - doc, payload, result, auto_submit, precision, name + doc, payload, result, auto_submit, precision, name, settings ) if failures: @@ -608,7 +635,7 @@ def process_settlement_log(name, force=False): return _finish(doc, "Processed", None) -def _post_companies(doc, payload, result, auto_submit, precision, name): +def _post_companies(doc, payload, result, auto_submit, precision, name, settings=None): """Create one Journal Entry per company, isolating each company's failure.""" failures = [] held_for_review = False @@ -635,7 +662,7 @@ def _post_companies(doc, payload, result, auto_submit, precision, name): save_point = f"easebuzz_settlement_{index}" frappe.db.savepoint(save_point) try: - je, _total = build_journal_entry(payload, doc.name, bucket, precision) + je, _total = build_journal_entry(payload, doc.name, bucket, precision, settings) je.insert(ignore_permissions=True) submit = auto_submit and not is_first_settlement_for_company(company) From 006f4d5bf9f6a20a9bbeffedce0b434c07ecc8ce Mon Sep 17 00:00:00 2001 From: rohitkumarthongar Date: Thu, 20 Aug 2026 18:08:35 +0530 Subject: [PATCH 10/13] fix(reconciliation): trigger on save, and never let queueing abort the insert Saving an Easebuzz Settlement Log did nothing, and with reconciliation switched on it failed outright. Two causes, both fixed here. Queueing could abort ingestion. `after_insert` called `frappe.enqueue` unguarded, and enqueue raises ConnectionError when Redis is unreachable (it only falls back to running synchronously during a migration). The exception escaped `after_insert` and rolled back the insert, so on a bench with no queue the settlement log was never saved at all -- the payload was lost, and the gateway posts it exactly once. Reproduced on unity.local: with Auto Create Journal Entry on and redis down, insert failed with ConnectionError; with no settings record it saved but silently did nothing. Queueing is now wrapped so nothing in it can raise. When the queue is unreachable the work is registered on `frappe.db.after_commit` instead, which runs it outside the save cycle, so a bench without workers still reconciles. Trigger moved from `after_insert` to `on_update`, which fires on insert and on every later save. Saving a log now starts reconciliation, and re-saving a Failed one retries it -- fix the bank account mapping, hit save, it posts. This is safe now in a way the original `before_save` hook was not: the pipeline is idempotent, so a re-run records Already Posted rather than posting twice. Only Pending and Failed logs auto-fire; Processed, Processing, Needs Review and Skipped are left alone so re-saving cannot refire them. `_finish` marks its own save with a flag so writing the outcome does not start another round. Silent no-ops are now explained on the record. Previously a missing Easebuzz Settings record or an unticked Auto Create Journal Entry returned without a trace, which is indistinguishable from a broken hook. The reason is written to the log's Error Message instead, and cleared once reconciliation starts. Verified on unity.local across all four paths: no settings record -> saves, Pending, reason recorded; Auto Create off -> saves, reason names the settings record; Auto Create on with redis down -> saves (previously aborted) and the after-commit fallback runs and records the real failure reason; re-saving a Failed log retries it and terminates. 19 unit tests pass, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../easebuzz_settlement_log.py | 97 ++++++++++++++----- easebuzz/easebuzz/utils/settlement.py | 3 + easebuzz/hooks.py | 7 +- 3 files changed, 80 insertions(+), 27 deletions(-) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index 13af4e7..b1e1d4b 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -7,10 +7,16 @@ from easebuzz.easebuzz.utils.settlement import ( get_reconciliation_settings, + logger, parse_settlement_payload, process_settlement_log, ) +# Saving a log in one of these states starts (or retries) reconciliation. +# "Processed", "Processing", "Needs Review" and "Skipped" are left alone -- those +# either already posted or are waiting on a person, so re-saving must not refire. +AUTO_PROCESS_STATUSES = ("", "Pending", "Failed") + class EasebuzzSettlementLog(Document): def before_insert(self): @@ -25,45 +31,88 @@ def before_insert(self): if payload.get("payout_date"): self.payout_date = frappe.utils.getdate(payload.get("payout_date")) - @frappe.whitelist() - def process_log(self, force=False): - """Reconcile this log now, from the form or from the console. + def on_update(self): + """Reconcile whenever the log is saved, insert included. - Kept as a document method because the reconciliation branch exposes it - that way; the real work lives in ``utils.settlement``. + ``on_update`` fires on both insert and subsequent saves, so this is the + single trigger. Re-running is safe: an already-posted + (payout_id, company) pair is recorded as Already Posted rather than + posted twice, which is what the original ``before_save`` hook lacked. """ + if self.flags.easebuzz_reconciling: + # We are inside the save that process_settlement_log itself performs. + return + if (self.status or "") not in AUTO_PROCESS_STATUSES: + return + self.queue_reconciliation() + + @frappe.whitelist() + def process_log(self, force=False): + """Reconcile this log now, from the form or from the console.""" self.check_permission("write") return process_settlement_log(self.name, force=force) - def after_insert(self): - """Queue reconciliation. + def queue_reconciliation(self): + """Hand the log to a worker, or run it after commit if there is none. - Enqueued rather than run inline: a settlement carries up to 17 bank - splits and ~90 transactions, which is too much for the webhook request. - Runs on insert only -- on ``before_save`` it would re-post on every - subsequent save of the log. + Nothing in here may raise. The gateway posts each settlement once, so + an unreachable queue or a broken settings record must leave a Pending + log to retry from -- never abort the insert and lose the payload. """ try: settings = get_reconciliation_settings() except Exception as exc: - # The gateway posts this payload once. An ambiguous or broken - # settings record must leave a Pending log to retry from, never - # abort the insert and lose the settlement. - frappe.logger("easebuzz", allow_site=True).error( - f"Easebuzz Settlement Log {self.name}: cannot resolve settings: {exc}" + self._note(_("Cannot resolve Easebuzz Settings: {0}").format(exc)) + return + + if not settings: + self._note(_("No Easebuzz Settings record exists, so reconciliation is off.")) + return + if not settings.get("auto_create_journal_entry"): + self._note( + _("Auto Create Journal Entry is off in Easebuzz Settings ({0}).").format( + settings.name + ) ) return - if not settings or not settings.get("auto_create_journal_entry"): + name = self.name + try: + frappe.enqueue( + process_settlement_log, + queue="long", + enqueue_after_commit=True, + job_id=f"easebuzz-settlement-{name}", + deduplicate=True, + name=name, + ) + self._note(None) + except Exception as exc: + # No Redis or no worker. Fall back to running after the current + # transaction commits, which keeps it out of this save cycle. + logger().warning( + f"Easebuzz Settlement Log {name}: queue unavailable ({exc}); " + "reconciling inline after commit" + ) + self._note(None) + frappe.db.after_commit.add(lambda: _reconcile_inline(name)) + + def _note(self, message): + """Record why reconciliation did or did not start, without rerunning hooks.""" + if (self.error_message or None) == (message or None): return + self.error_message = message + self.db_set("error_message", message, update_modified=False) + - frappe.enqueue( - process_settlement_log, - queue="long", - enqueue_after_commit=True, - job_id=f"easebuzz-settlement-{self.name}", - deduplicate=True, - name=self.name, +def _reconcile_inline(name): + """Run reconciliation outside the save cycle; never let it escape.""" + try: + process_settlement_log(name) + except Exception: + logger().error( + f"Easebuzz Settlement Log {name}: inline reconciliation failed\n" + f"{frappe.get_traceback()}" ) diff --git a/easebuzz/easebuzz/utils/settlement.py b/easebuzz/easebuzz/utils/settlement.py index 43dc990..38f6248 100644 --- a/easebuzz/easebuzz/utils/settlement.py +++ b/easebuzz/easebuzz/utils/settlement.py @@ -703,6 +703,9 @@ def _finish(doc, status, message): """ doc.status = status doc.error_message = message + # Tells EasebuzzSettlementLog.on_update that this save is ours, so saving the + # outcome does not start another round of reconciliation. + doc.flags.easebuzz_reconciling = True doc.save(ignore_permissions=True) frappe.db.commit() return status diff --git a/easebuzz/hooks.py b/easebuzz/hooks.py index f7a0088..ea6f402 100644 --- a/easebuzz/hooks.py +++ b/easebuzz/hooks.py @@ -102,9 +102,10 @@ # --------------- # Hook on document methods and events -# Settlement reconciliation runs from EasebuzzSettlementLog.after_insert, not -# from a before_save hook -- before_save re-posted the whole settlement on every -# subsequent save of the log. +# Settlement reconciliation runs from EasebuzzSettlementLog.on_update, which +# fires on insert and on every later save. It is not a before_save hook: that +# ran before the row existed and had no idempotency, so it re-posted the whole +# settlement on every save. doc_events = {} # Scheduled Tasks From 6133e55873b37946438a394a510c5f2f63d6e3cd Mon Sep 17 00:00:00 2001 From: rohitkumarthongar Date: Fri, 21 Aug 2026 10:47:38 +0530 Subject: [PATCH 11/13] Update Enque to db function to automate on same time --- .../easebuzz_settlement_log.py | 100 ++++++++++++------ 1 file changed, 67 insertions(+), 33 deletions(-) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index b1e1d4b..255aae5 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -50,52 +50,42 @@ def on_update(self): def process_log(self, force=False): """Reconcile this log now, from the form or from the console.""" self.check_permission("write") + force = frappe.utils.sbool(force) return process_settlement_log(self.name, force=force) def queue_reconciliation(self): - """Hand the log to a worker, or run it after commit if there is none. + """Automatically reconcile this settlement after the save commits.""" - Nothing in here may raise. The gateway posts each settlement once, so - an unreachable queue or a broken settings record must leave a Pending - log to retry from -- never abort the insert and lose the payload. - """ try: settings = get_reconciliation_settings() except Exception as exc: - self._note(_("Cannot resolve Easebuzz Settings: {0}").format(exc)) + self._note( + _("Cannot resolve Easebuzz Settings: {0}").format(exc) + ) return if not settings: - self._note(_("No Easebuzz Settings record exists, so reconciliation is off.")) + self._note( + _("No Easebuzz Settings record exists, so reconciliation is off.") + ) return + if not settings.get("auto_create_journal_entry"): self._note( _("Auto Create Journal Entry is off in Easebuzz Settings ({0}).").format( - settings.name + settings.get("name") or _("Unknown") ) ) return name = self.name - try: - frappe.enqueue( - process_settlement_log, - queue="long", - enqueue_after_commit=True, - job_id=f"easebuzz-settlement-{name}", - deduplicate=True, - name=name, - ) - self._note(None) - except Exception as exc: - # No Redis or no worker. Fall back to running after the current - # transaction commits, which keeps it out of this save cycle. - logger().warning( - f"Easebuzz Settlement Log {name}: queue unavailable ({exc}); " - "reconciling inline after commit" - ) - self._note(None) - frappe.db.after_commit.add(lambda: _reconcile_inline(name)) + self._note(None) + + # Execute only after the Settlement Log save is committed. + # This does not depend on Redis or background workers. + frappe.db.after_commit.add( + lambda log_name=name: _reconcile_inline(log_name) + ) def _note(self, message): """Record why reconciliation did or did not start, without rerunning hooks.""" @@ -106,24 +96,68 @@ def _note(self, message): def _reconcile_inline(name): - """Run reconciliation outside the save cycle; never let it escape.""" + """Process and commit reconciliation after the Settlement Log save.""" + try: - process_settlement_log(name) + result = process_settlement_log(name) + + # The original document save was already committed before this callback. + # Commit the Journal Entry and Settlement Log status changes separately. + frappe.db.commit() + + return result + except Exception: + traceback = frappe.get_traceback() + + # Discard any partially created Journal Entry or accounting rows. + frappe.db.rollback() + logger().error( - f"Easebuzz Settlement Log {name}: inline reconciliation failed\n" - f"{frappe.get_traceback()}" + f"Easebuzz Settlement Log {name}: " + f"automatic reconciliation failed\n{traceback}" ) + try: + frappe.db.set_value( + "Easebuzz Settlement Log", + name, + { + "status": "Failed", + "error_message": traceback, + }, + update_modified=False, + ) + frappe.db.commit() + + except Exception: + failure_traceback = frappe.get_traceback() + frappe.db.rollback() + + logger().error( + f"Easebuzz Settlement Log {name}: " + f"unable to record failure\n{failure_traceback}" + ) + @frappe.whitelist() def process_log(docname=None, doc=None, method=None, force=False): - """Process a settlement log on demand, from the form or from the console.""" + """Process a settlement log on demand, from the form or console.""" name = docname + if not name and doc is not None: name = doc if isinstance(doc, str) else doc.name + if not name: frappe.throw(_("No Easebuzz Settlement Log specified.")) - frappe.has_permission("Easebuzz Settlement Log", "write", doc=name, throw=True) + frappe.has_permission( + "Easebuzz Settlement Log", + "write", + doc=name, + throw=True, + ) + + force = frappe.utils.sbool(force) + return process_settlement_log(name, force=force) From ef96f64fa78a9ac22f2c6b4f1e362ea80793ef1c Mon Sep 17 00:00:00 2001 From: rohitkumarthongar Date: Fri, 21 Aug 2026 11:49:33 +0530 Subject: [PATCH 12/13] feat update to create auto journal entry --- .../easebuzz_settlement_log.py | 110 +++++++----------- 1 file changed, 45 insertions(+), 65 deletions(-) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index 255aae5..e920176 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -50,42 +50,42 @@ def on_update(self): def process_log(self, force=False): """Reconcile this log now, from the form or from the console.""" self.check_permission("write") - force = frappe.utils.sbool(force) return process_settlement_log(self.name, force=force) def queue_reconciliation(self): - """Automatically reconcile this settlement after the save commits.""" + """Reconcile this log once the save commits. + Nothing in here may raise. The gateway posts each settlement once, so + a broken settings record must leave a Pending log to retry from -- + never abort the insert and lose the payload. + """ try: settings = get_reconciliation_settings() except Exception as exc: - self._note( - _("Cannot resolve Easebuzz Settings: {0}").format(exc) - ) + self._note(_("Cannot resolve Easebuzz Settings: {0}").format(exc)) return - if not settings: - self._note( - _("No Easebuzz Settings record exists, so reconciliation is off.") - ) - return - - if not settings.get("auto_create_journal_entry"): + # A site with no Easebuzz Settings record at all has not configured + # reconciliation, and process_settlement_log falls back to defaults -- + # which is why the Process button posts fine without one. Saving has + # to behave the same way, or the button and the automatic trigger + # disagree about the same payload. Only an explicit "off" stops us. + if settings and not settings.get("auto_create_journal_entry"): self._note( _("Auto Create Journal Entry is off in Easebuzz Settings ({0}).").format( - settings.get("name") or _("Unknown") + settings.name ) ) return - name = self.name self._note(None) - # Execute only after the Settlement Log save is committed. - # This does not depend on Redis or background workers. - frappe.db.after_commit.add( - lambda log_name=name: _reconcile_inline(log_name) - ) + # Run on commit rather than through a worker. The settlement has to + # post on save even where no queue is running, and reconciling here + # keeps it out of the current save cycle -- process_settlement_log + # saves the log itself and commits its own work. + name = self.name + frappe.db.after_commit.add(lambda: _reconcile_inline(name)) def _note(self, message): """Record why reconciliation did or did not start, without rerunning hooks.""" @@ -96,68 +96,48 @@ def _note(self, message): def _reconcile_inline(name): - """Process and commit reconciliation after the Settlement Log save.""" + """Run reconciliation outside the save cycle; never let it escape. + The log's own save has already committed by the time this runs, so a + failure here must not be left as a silent Pending -- record it as Failed + so the list view shows it and a re-save retries it. + """ try: - result = process_settlement_log(name) - - # The original document save was already committed before this callback. - # Commit the Journal Entry and Settlement Log status changes separately. - frappe.db.commit() - - return result - + process_settlement_log(name) + return except Exception: traceback = frappe.get_traceback() - # Discard any partially created Journal Entry or accounting rows. - frappe.db.rollback() + # Discard whatever the failed run left half-built. + frappe.db.rollback() + logger().error( + f"Easebuzz Settlement Log {name}: automatic reconciliation failed\n{traceback}" + ) + try: + frappe.db.set_value( + "Easebuzz Settlement Log", + name, + {"status": "Failed", "error_message": traceback}, + update_modified=False, + ) + frappe.db.commit() + except Exception: + failure = frappe.get_traceback() + frappe.db.rollback() logger().error( - f"Easebuzz Settlement Log {name}: " - f"automatic reconciliation failed\n{traceback}" + f"Easebuzz Settlement Log {name}: unable to record failure\n{failure}" ) - try: - frappe.db.set_value( - "Easebuzz Settlement Log", - name, - { - "status": "Failed", - "error_message": traceback, - }, - update_modified=False, - ) - frappe.db.commit() - - except Exception: - failure_traceback = frappe.get_traceback() - frappe.db.rollback() - - logger().error( - f"Easebuzz Settlement Log {name}: " - f"unable to record failure\n{failure_traceback}" - ) - @frappe.whitelist() def process_log(docname=None, doc=None, method=None, force=False): - """Process a settlement log on demand, from the form or console.""" + """Process a settlement log on demand, from the form or from the console.""" name = docname - if not name and doc is not None: name = doc if isinstance(doc, str) else doc.name - if not name: frappe.throw(_("No Easebuzz Settlement Log specified.")) - frappe.has_permission( - "Easebuzz Settlement Log", - "write", - doc=name, - throw=True, - ) - - force = frappe.utils.sbool(force) - + frappe.has_permission("Easebuzz Settlement Log", "write", doc=name, throw=True) return process_settlement_log(name, force=force) From d08ce8b170a199982bdb41e2efc56d12e48164a5 Mon Sep 17 00:00:00 2001 From: rohitkumarthongar Date: Mon, 24 Aug 2026 11:34:33 +0530 Subject: [PATCH 13/13] Update changes on Reco flow --- .../easebuzz_settings/easebuzz_settings.json | 4 +- .../easebuzz_settlement_log.js | 18 +++++ .../easebuzz_settlement_log.py | 14 ++-- .../test_easebuzz_settlement_log.py | 39 +++++++++++ easebuzz/easebuzz/utils/settlement.py | 66 +++++++------------ 5 files changed, 89 insertions(+), 52 deletions(-) diff --git a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json index dcd21fd..9195464 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json @@ -62,7 +62,7 @@ }, { "default": "0", - "description": "Create the settlement Journal Entry automatically when an Easebuzz Settlement Log is inserted. Off = logs are stored with status Pending and can be processed manually from the log.", + "description": "Create settlement Journal Entries automatically when an Easebuzz Settlement Log is inserted. Enabling this on any Easebuzz Settings record enables automation for the site. If it is off on every record, logs remain Pending and can be processed manually.", "fieldname": "auto_create_journal_entry", "fieldtype": "Check", "label": "Auto Create Journal Entry" @@ -70,7 +70,7 @@ { "default": "0", "depends_on": "auto_create_journal_entry", - "description": "Off = every Journal Entry is left as Draft and the log is marked Needs Review. The first settlement for a company is ALWAYS left as Draft regardless of this setting.", + "description": "Submit automatically created Journal Entries. If this is enabled on any settings record where Auto Create is enabled, all settlement Journal Entries are submitted; otherwise they remain Draft for review.", "fieldname": "auto_submit_journal_entry", "fieldtype": "Check", "label": "Auto Submit Journal Entry" diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js index beb2528..d2cd05d 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js @@ -12,8 +12,26 @@ const STATUS_COLOUR = { frappe.ui.form.on("Easebuzz Settlement Log", { refresh(frm) { + clearTimeout(frm.easebuzz_refresh_timer); if (frm.is_new()) return; + // Automatic reconciliation runs just after the log's original save has + // committed. The save response can therefore still contain Pending or + // Processing even though the server is already working on it. Poll while + // it is active so status, errors and reconciliation rows appear without a + // manual browser refresh. + const is_active = + frm.doc.status === "Processing" || + (frm.doc.status === "Pending" && !frm.doc.error_message); + if (is_active) { + frm.easebuzz_refresh_attempts = (frm.easebuzz_refresh_attempts || 0) + 1; + if (frm.easebuzz_refresh_attempts <= 60) { + frm.easebuzz_refresh_timer = setTimeout(() => frm.reload_doc(), 2000); + } + } else { + frm.easebuzz_refresh_attempts = 0; + } + if (frm.doc.status) { frm.page.set_indicator(__(frm.doc.status), STATUS_COLOUR[frm.doc.status] || "gray"); } diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py index e920176..074322c 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -65,16 +65,12 @@ def queue_reconciliation(self): self._note(_("Cannot resolve Easebuzz Settings: {0}").format(exc)) return - # A site with no Easebuzz Settings record at all has not configured - # reconciliation, and process_settlement_log falls back to defaults -- - # which is why the Process button posts fine without one. Saving has - # to behave the same way, or the button and the automatic trigger - # disagree about the same payload. Only an explicit "off" stops us. - if settings and not settings.get("auto_create_journal_entry"): + if not settings: + self._note(_("No Easebuzz Settings record exists, so reconciliation is off.")) + return + if not settings.get("auto_create_journal_entry"): self._note( - _("Auto Create Journal Entry is off in Easebuzz Settings ({0}).").format( - settings.name - ) + _("Auto Create Journal Entry is off in all Easebuzz Settings records.") ) return diff --git a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py index 3a871bc..a76e570 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/test_easebuzz_settlement_log.py @@ -3,9 +3,11 @@ import json import unittest +from unittest.mock import patch from easebuzz.easebuzz.utils.settlement import ( SettlementError, + get_reconciliation_settings, parse_settlement_payload, resolve_charges_account, resolve_rule, @@ -19,6 +21,10 @@ class _Doc(dict): __getattr__ = dict.get + def __setattr__(self, key, value): + self[key] = value + + SETTLEMENT = { "payout_id": "PTTEST0001", "payout_date": "2026-08-18 12:55:06.618976", @@ -89,6 +95,39 @@ def test_rejects_unparseable_payload(self): parse_settlement_payload(bad) +class TestReconciliationSettings(unittest.TestCase): + def test_any_enabled_record_enables_site_and_auto_submit(self): + records = [ + _Doc(name="recent", auto_create_journal_entry=1, auto_submit_journal_entry=0), + _Doc(name="older", auto_create_journal_entry=1, auto_submit_journal_entry=1), + ] + selected = _Doc(name="recent") + + with patch("easebuzz.easebuzz.utils.settlement.frappe.get_all", return_value=records), patch( + "easebuzz.easebuzz.utils.settlement.frappe.get_doc", return_value=selected + ) as get_doc: + settings = get_reconciliation_settings() + + get_doc.assert_called_once_with("Easebuzz Settings", "recent") + self.assertTrue(settings.auto_create_journal_entry) + self.assertTrue(settings.auto_submit_journal_entry) + + def test_all_disabled_records_keep_automation_off(self): + records = [ + _Doc(name="recent", auto_create_journal_entry=0, auto_submit_journal_entry=1), + _Doc(name="older", auto_create_journal_entry=0, auto_submit_journal_entry=0), + ] + selected = _Doc(name="recent") + + with patch("easebuzz.easebuzz.utils.settlement.frappe.get_all", return_value=records), patch( + "easebuzz.easebuzz.utils.settlement.frappe.get_doc", return_value=selected + ): + settings = get_reconciliation_settings() + + self.assertFalse(settings.auto_create_journal_entry) + self.assertFalse(settings.auto_submit_journal_entry) + + class TestModeRules(unittest.TestCase): def test_rules_off_debits_every_mode(self): self.assertTrue(resolve_rule("Credit Card", None, "Debit", set())) diff --git a/easebuzz/easebuzz/utils/settlement.py b/easebuzz/easebuzz/utils/settlement.py index 38f6248..cb7492b 100644 --- a/easebuzz/easebuzz/utils/settlement.py +++ b/easebuzz/easebuzz/utils/settlement.py @@ -94,37 +94,38 @@ def _loads(text): # --------------------------------------------------------------------------- # def get_reconciliation_settings(): - """Resolve the Easebuzz Settings record that owns the reconciliation config. + """Resolve the site-wide settlement reconciliation configuration. ``Easebuzz Settings`` is not a Single -- it is named by ``easebuzz_account`` - and a site may hold several. ``frappe.get_last_doc`` would pick whichever - was created last, which makes the applied rules invisible. Resolve - deterministically instead: prefer the single record that has reconciliation - switched on, and refuse to guess when that is ambiguous. + and a site may hold several (for example, separate surcharge gateways). A + settlement callback does not carry the originating settings name, so the + automation switches must apply across those records: enabling Auto Create + on any record enables it for incoming logs, and Auto Submit is enabled when + it is checked on any Auto-Create-enabled record. + + The most recently modified enabled record supplies the detailed rules. This + makes the form the user just configured authoritative without rejecting a + perfectly valid site merely because another gateway is also enabled. """ - names = frappe.get_all("Easebuzz Settings", pluck="name", order_by="name") - if not names: - return None - if len(names) == 1: - return frappe.get_cached_doc("Easebuzz Settings", names[0]) - - enabled = frappe.get_all( + records = frappe.get_all( "Easebuzz Settings", - filters={"auto_create_journal_entry": 1}, - pluck="name", - order_by="name", + fields=["name", "auto_create_journal_entry", "auto_submit_journal_entry"], + order_by="modified desc, name asc", ) - if len(enabled) == 1: - return frappe.get_cached_doc("Easebuzz Settings", enabled[0]) - if not enabled: + if not records: return None - raise SettlementError( - _( - "{0} Easebuzz Settings records have Auto Create Journal Entry enabled ({1}). " - "Enable it on exactly one record so the applied charge rules are traceable." - ).format(len(enabled), ", ".join(enabled)) + enabled = [row for row in records if row.auto_create_journal_entry] + selected = enabled[0] if enabled else records[0] + settings = frappe.get_doc("Easebuzz Settings", selected.name) + + # get_doc returns an uncached document, so these effective site-wide values + # do not pollute Frappe's document cache. + settings.auto_create_journal_entry = bool(enabled) + settings.auto_submit_journal_entry = any( + row.auto_submit_journal_entry for row in enabled ) + return settings def load_mode_rules(settings): @@ -396,23 +397,6 @@ def already_posted(payout_id, company): return None -def is_first_settlement_for_company(company): - """True until a settlement JE for this company has been submitted. - - The first JE per company is held as Draft so the mapping can be confirmed - before anything posts to the ledger. - """ - rows = frappe.get_all( - "Easebuzz Settlement Reconciliation", - filters={"company": company, "journal_entry": ("is", "set")}, - pluck="journal_entry", - ) - for name in rows: - if frappe.db.get_value("Journal Entry", name, "docstatus") == 1: - return False - return True - - def build_remark(payload, log_name, bucket, currency=None): lines = [ _("Easebuzz Settlement {0}").format(payload.get("payout_id")), @@ -665,7 +649,7 @@ def _post_companies(doc, payload, result, auto_submit, precision, name, settings je, _total = build_journal_entry(payload, doc.name, bucket, precision, settings) je.insert(ignore_permissions=True) - submit = auto_submit and not is_first_settlement_for_company(company) + submit = auto_submit if submit: je.submit() else: