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_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..f5e1557 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.js @@ -1,8 +1,185 @@ -// 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 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()) 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. + 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); + } + }, +}); + +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 fc41a7c..9195464 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.json @@ -12,7 +12,19 @@ "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", + "charge_accounts_section", + "company_charge_accounts", + "payment_mode_rules_section", + "allowed_mode_of_payment" ], "fields": [ { @@ -42,11 +54,90 @@ "fieldtype": "Data", "label": "Easebuzz Account", "unique": 1 + }, + { + "fieldname": "reconciliation_section", + "fieldtype": "Section Break", + "label": "Settlement Reconciliation" + }, + { + "default": "0", + "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" + }, + { + "default": "0", + "depends_on": "auto_create_journal_entry", + "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" + }, + { + "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" + }, + { + "fieldname": "column_break_recon", + "fieldtype": "Column Break" + }, + { + "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" + }, + { + "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" + }, + { + "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" + }, + { + "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" + }, + { + "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" } ], "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 +158,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..1cc9844 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settings/easebuzz_settings.py @@ -10,6 +10,21 @@ 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. +# 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"), + ("Debit Card", "Debit Card"), + ("Netbanking", "Netbanking"), +) + + class EasebuzzSettings(Document): supported_currencies = ["INR"] @@ -21,7 +36,84 @@ 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() + self.validate_company_charge_accounts() + + 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_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( @@ -223,6 +315,148 @@ 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() +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/easebuzz_settlement_log.js b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.js index b15792f..d2cd05d 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,61 @@ -// 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) { + 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"); + } + + 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 ea2b407..074322c 100644 --- a/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py +++ b/easebuzz/easebuzz/doctype/easebuzz_settlement_log/easebuzz_settlement_log.py @@ -1,129 +1,139 @@ -# Copyright (c) 2024, Hybrowlabs and contributors +# 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 + +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): - 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", - }) - 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 + 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")) + + def on_update(self): + """Reconcile whenever the log is saved, insert included. + + ``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 queue_reconciliation(self): + """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)) + 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 all Easebuzz Settings records.") + ) + return + + self._note(None) + + # 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.""" + if (self.error_message or None) == (message or None): + return + self.error_message = message + self.db_set("error_message", message, update_modified=False) + + +def _reconcile_inline(name): + """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: + process_settlement_log(name) + return + except Exception: + traceback = frappe.get_traceback() + + # 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}: unable to record failure\n{failure}" + ) + + +@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.")) + + 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..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 @@ -1,9 +1,242 @@ -# 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 unittest.mock import patch +from easebuzz.easebuzz.utils.settlement import ( + SettlementError, + get_reconciliation_settings, + parse_settlement_payload, + resolve_charges_account, + resolve_rule, + segregate, + validate_segregation, +) -class TestEasebuzzSettlementLog(FrappeTestCase): - pass + +class _Doc(dict): + """Stands in for a Frappe document: attribute and ``.get()`` access.""" + + __getattr__ = dict.get + + def __setattr__(self, key, value): + self[key] = value + + +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 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())) + + 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") + + +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/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..cb7492b --- /dev/null +++ b/easebuzz/easebuzz/utils/settlement.py @@ -0,0 +1,695 @@ +# 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 site-wide settlement reconciliation configuration. + + ``Easebuzz Settings`` is not a Single -- it is named by ``easebuzz_account`` + 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. + """ + records = frappe.get_all( + "Easebuzz Settings", + fields=["name", "auto_create_journal_entry", "auto_submit_journal_entry"], + order_by="modified desc, name asc", + ) + if not records: + return None + + 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): + """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 + + +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 +# --------------------------------------------------------------------------- # + +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 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, settings=None): + """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) + charges_account = resolve_charges_account(company_doc, settings) if charges else None + + if charges and not charges_account: + raise SettlementError( + _( + "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) + ) + + 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": charges_account, + "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, settings + ) + + 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, settings=None): + """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, settings) + je.insert(ignore_permissions=True) + + submit = auto_submit + 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 + # 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 40bcd17..ea6f402 100644 --- a/easebuzz/hooks.py +++ b/easebuzz/hooks.py @@ -102,11 +102,11 @@ # --------------- # 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.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 # --------------- 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