From acd7df1129332b64a19f73afc0187675bc64426b Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:12:08 -0400 Subject: [PATCH] feat(statements): bill late fee as a collectible Sales Invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Charge a late-payment fee when statements are generated so the customer's receivable reflects it (the gap ERPNext Dunning leaves — it never increases AR). The fee is billed as a submitted Sales Invoice (item -> Dunning Type income account, rate = computed fee) rather than a Journal Entry, so the app's existing payment flow (Run Payment / AutoPay / multi-invoice) charges and settles it automatically via its Sales Invoice references — a bare JE would sit uncollected. Fee schedule/amounts come from the existing Dunning Type settings (yearly rate_of_interest + flat dunning_fee), interest computed with ERPNext's own Dunning formula. The fee Item is configured via a new Late Fee Item custom field on Dunning Type (created in an after_migrate hook; ns_app/setup.py). Nothing is auto-seeded: generation stops with a clear error if no Dunning Type is configured or its income account / fee item is unset. Billing is idempotent per customer/company/month, and prior fee invoices are excluded from the interest base (no fee-on-fee). The fee invoice shows on the statement flagged 'late fee', folded into Total Due (which equals the customer's balance and is fully collectible). Co-Authored-By: Claude Opus 4.8 --- ns_app/api/statements.py | 212 ++++++++++++++++-- ns_app/hooks.py | 3 + ns_app/public/js/customer_list.js | 2 +- ns_app/setup.py | 26 +++ .../statements/customer_statement.html | 6 +- 5 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 ns_app/setup.py diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index 252194c..eb44593 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -127,23 +127,28 @@ def _resolve_company(invoices): ) -def get_statement_data(customer): - """Assemble everything the statement template needs for one customer.""" +def get_statement_data(customer, invoices=None): + """Assemble everything the statement template needs for one customer. + + Late-fee charges are billed as Sales Invoices, so they appear in the invoice + list like any other open item (flagged `is_late_fee`); there is no separate + fee total to add. + """ cust = frappe.get_doc("Customer", customer) - invoices = _get_outstanding_invoices(customer) + if invoices is None: + invoices = _get_outstanding_invoices(customer) + + fee_names = _late_fee_invoice_names(customer) company = _resolve_company(invoices) company_doc = frappe.get_doc("Company", company) if company else None aging = {"Current": 0.0, "1-30": 0.0, "31-60": 0.0, "61-90": 0.0, "90+": 0.0} - total_outstanding = 0.0 + total_due = 0.0 for inv in invoices: + inv["is_late_fee"] = inv["name"] in fee_names aging[inv["aging_bucket"]] += flt(inv["outstanding_amount"]) - total_outstanding += flt(inv["outstanding_amount"]) - - # Late fee is booked and populated by generate_statements (later commit); - # get_statement_data on its own reports a zero fee. - late_fee = 0.0 + total_due += flt(inv["outstanding_amount"]) return { "customer": cust.name, @@ -156,9 +161,7 @@ def get_statement_data(customer): or frappe.db.get_single_value("Global Defaults", "default_currency"), "invoices": invoices, "aging": aging, - "total_outstanding": total_outstanding, - "late_fee": late_fee, - "total_due": total_outstanding + late_fee, + "total_due": total_due, "statement_date": nowdate(), } @@ -221,6 +224,10 @@ def _wrap_document(pages): .c {{ text-align: center; }} .r {{ text-align: right; }} tr.overdue td {{ color: #c62828; }} + .tag {{ + display: inline-block; font-size: 10px; font-weight: bold; color: #fff; + background: #c62828; border-radius: 3px; padding: 1px 5px; vertical-align: middle; + }} .totals {{ width: 45%; margin: 12px 0 12px auto; font-size: 14px; }} .totals p {{ display: flex; justify-content: space-between; margin: 4px 0; }} .totals p.grand {{ @@ -243,10 +250,173 @@ def _wrap_document(pages): """ +# ── Late-payment fee (billed as a Sales Invoice on generation) ─────────────── +# +# Fee schedule/amounts come from ERPNext's existing **Dunning Type** settings +# (rate_of_interest is a yearly %, plus a flat dunning_fee), editable in the +# desk. Interest is computed with ERPNext's own Dunning formula so the numbers +# match a Dunning document. The fee is billed as a submitted **Sales Invoice** +# (item -> Dunning Type income account) so it both hits the ledger and is +# collectible by the app's existing payment flow (Run Payment / AutoPay / +# multi-invoice), which settles Sales Invoices. + +DUNNING_TYPE_FIELDS = [ + "name", + "rate_of_interest", + "dunning_fee", + "income_account", + "cost_center", + "company", + "custom_late_fee_item", +] + + +def _get_fee_settings(company): + """Resolve the Dunning Type used for late fees for a company. + + Nothing is auto-created: the user must configure a Dunning Type (rate of + interest, fee, income account) for the company in ERPNext. If none exists we + stop with a clear, actionable error rather than inventing default values. + """ + + def first(filters): + rows = frappe.get_all( + "Dunning Type", filters=filters, fields=DUNNING_TYPE_FIELDS, limit=1 + ) + return rows[0] if rows else None + + dt = first({"company": company, "is_default": 1}) or first({"company": company}) + if not dt: + frappe.throw( + _( + "No Dunning Type is configured for {0}. Create one under " + "Accounting > Dunning Type — set the rate of interest, dunning " + "fee, and income account — before generating statements." + ).format(company) + ) + return dt + + +def _late_fee_period(): + """Statement period key used for idempotency (one fee per calendar month).""" + return getdate(nowdate()).strftime("%Y-%m") + + +def _get_fee_invoices(customer, company, fee_item): + """Return submitted late-fee Sales Invoices for a customer (by fee item).""" + if not fee_item: + return [] + return frappe.db.sql( + """ + select si.name, si.posting_date + from `tabSales Invoice` si + inner join `tabSales Invoice Item` sii on sii.parent = si.name + where si.customer = %s and si.company = %s and si.docstatus = 1 + and sii.item_code = %s + """, + (customer, company, fee_item), + as_dict=True, + ) + + +def _post_late_fee_invoice(customer, company, overdue_invoices, period): + """Bill a late fee as a submitted Sales Invoice (idempotent per month). + + Returns the fee invoice name, or None if nothing was billed. + """ + if not overdue_invoices: + return None + + settings = _get_fee_settings(company) + fee_item = settings.get("custom_late_fee_item") + if not fee_item: + frappe.throw( + _("Set a Late Fee Item on Dunning Type {0} before generating statements.").format( + settings.name + ) + ) + if not settings.income_account: + frappe.throw( + _("Set an Income Account on Dunning Type {0} before generating statements.").format( + settings.name + ) + ) + + fee_invoices = _get_fee_invoices(customer, company, fee_item) + + # Idempotency: at most one fee invoice per (customer, company, month). + month_start = getdate(period + "-01") + for fi in fee_invoices: + if getdate(fi.posting_date) >= month_start: + return fi.name + + # Interest on overdue balances, excluding prior fee invoices (no fee-on-fee). + prior_fee_names = {fi.name for fi in fee_invoices} + daily_interest = flt(settings.rate_of_interest) / 100.0 / 365.0 + interest = sum( + flt(inv["outstanding_amount"]) * daily_interest * inv["days_overdue"] + for inv in overdue_invoices + if inv["name"] not in prior_fee_names + ) + fee = round(interest + flt(settings.dunning_fee), 2) + if fee <= 0: + return None + + cost_center = settings.cost_center or frappe.get_cached_value( + "Company", company, "cost_center" + ) + + si = frappe.new_doc("Sales Invoice") + si.customer = customer + si.company = company + si.posting_date = nowdate() + si.due_date = nowdate() + si.append( + "items", + { + "item_code": fee_item, + "qty": 1, + "rate": fee, + "income_account": settings.income_account, + "cost_center": cost_center, + "description": _("Late payment fee for statement period {0}").format(period), + }, + ) + si.insert(ignore_permissions=True) + si.submit() + frappe.db.commit() + return si.name + + +def _late_fee_invoice_names(customer): + """Names of the customer's submitted late-fee Sales Invoices (any company).""" + fee_items = [ + d.custom_late_fee_item + for d in frappe.get_all("Dunning Type", fields=["custom_late_fee_item"]) + if d.custom_late_fee_item + ] + if not fee_items: + return set() + rows = frappe.db.sql( + """ + select distinct sii.parent + from `tabSales Invoice Item` sii + inner join `tabSales Invoice` si on si.name = sii.parent + where si.customer = %s and si.docstatus = 1 and sii.item_code in %s + """, + (customer, tuple(fee_items)), + as_dict=True, + ) + return {r.parent for r in rows} + + @frappe.whitelist() def generate_statements(customers): """Render printable statements (one page per customer) for the selection. + Booking side effect: a late-payment fee is posted to the ledger (once per + customer per month) for each customer with overdue invoices. + `customers` may arrive as a JSON-encoded list from the client. """ frappe.only_for(ALLOWED_ROLES) @@ -259,12 +429,26 @@ def generate_statements(customers): if not customers: frappe.throw(_("No customers selected")) + period = _late_fee_period() pages, rendered, skipped = [], [], [] + for customer in customers: - data = get_statement_data(customer) - if not data["invoices"]: + invoices = _get_outstanding_invoices(customer) + if not invoices: skipped.append(customer) continue + + # Bill the late fee per company (on overdue invoices only). + overdue_by_company = {} + for inv in invoices: + if inv["is_overdue"]: + overdue_by_company.setdefault(inv["company"], []).append(inv) + + for comp, invs in overdue_by_company.items(): + _post_late_fee_invoice(customer, comp, invs, period) + + # Re-fetch so the statement includes the freshly billed fee invoice(s). + data = get_statement_data(customer) pages.append(_render_page(data)) rendered.append(customer) diff --git a/ns_app/hooks.py b/ns_app/hooks.py index 089f50a..bcaad81 100644 --- a/ns_app/hooks.py +++ b/ns_app/hooks.py @@ -20,6 +20,9 @@ doctype_list_js = { "Customer": "public/js/customer_list.js" } +# Ensure custom fields exist after every migrate +after_migrate = "ns_app.setup.after_migrate" + # Fixtures tracked in Git fixtures = [ { diff --git a/ns_app/public/js/customer_list.js b/ns_app/public/js/customer_list.js index 16292aa..e0faf0a 100644 --- a/ns_app/public/js/customer_list.js +++ b/ns_app/public/js/customer_list.js @@ -113,7 +113,7 @@ function show_selection_dialog(rows) { return; } frappe.confirm( - __("Generate statements for {0} customer(s)?", [customers.length]), + __("Generate statements for {0} customer(s)? A late-fee invoice will be raised (once per customer this month) for any overdue balances.", [customers.length]), () => { frappe.call({ method: "ns_app.api.statements.generate_statements", diff --git a/ns_app/setup.py b/ns_app/setup.py new file mode 100644 index 0000000..0a37058 --- /dev/null +++ b/ns_app/setup.py @@ -0,0 +1,26 @@ +"""App setup: custom fields created/synced on migrate.""" + +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +# Fee schedule/amounts live on ERPNext's Dunning Type; this adds the one thing +# it lacks — the Item used to bill a late fee as a Sales Invoice. +CUSTOM_FIELDS = { + "Dunning Type": [ + { + "fieldname": "custom_late_fee_item", + "label": "Late Fee Item", + "fieldtype": "Link", + "options": "Item", + "insert_after": "income_account", + "description": ( + "Item used to bill a late-payment fee as a Sales Invoice when " + "customer statements are generated." + ), + } + ] +} + + +def after_migrate(): + create_custom_fields(CUSTOM_FIELDS) diff --git a/ns_app/templates/statements/customer_statement.html b/ns_app/templates/statements/customer_statement.html index 54fa7b1..2b48f65 100644 --- a/ns_app/templates/statements/customer_statement.html +++ b/ns_app/templates/statements/customer_statement.html @@ -48,7 +48,7 @@ {% for inv in s.invoices %} - {{ inv.name }} + {{ inv.name }}{% if inv.is_late_fee %} late fee{% endif %} {{ frappe.utils.formatdate(inv.posting_date, "MM-dd-yyyy") }} {{ frappe.utils.formatdate(inv.due_date, "MM-dd-yyyy") }} {{ inv.days_overdue if inv.days_overdue else "—" }} @@ -61,10 +61,6 @@
-

Total Outstanding:{{ fmt(s.total_outstanding, currency=s.currency) }}

- {% if s.late_fee and s.late_fee > 0 %} -

Late Payment Fee:{{ fmt(s.late_fee, currency=s.currency) }}

- {% endif %}

Total Due:{{ fmt(s.total_due, currency=s.currency) }}