From 63bf0b68f53415f048a954a1f6e6fa52722b5497 Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:01:03 -0400 Subject: [PATCH 01/11] feat(statements): add overdue-customers query API Add ns_app/api/statements.py with get_customers_with_overdue_invoices (one aggregated row per customer with overdue Sales Invoices) plus the _get_outstanding_invoices / _aging_bucket helpers used to build the per-customer statement. Co-Authored-By: Claude Opus 4.8 --- ns_app/api/statements.py | 107 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 ns_app/api/statements.py diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py new file mode 100644 index 0000000..1e121b5 --- /dev/null +++ b/ns_app/api/statements.py @@ -0,0 +1,107 @@ +"""Customer account statements. + +Generates printable, one-customer-per-page account statements for customers with +overdue invoices, formatted for a standard double-window envelope. Statement +generation also books a late-payment fee to the ledger (see the late-fee helpers +added alongside the generator). +""" + +import frappe +from frappe import _ +from frappe.utils import getdate, nowdate + +# Roles allowed to run collections/statement actions. +ALLOWED_ROLES = [ + "System Manager", + "Sales User", + "Sales Manager", + "Accounts User", + "Accounts Manager", +] + + +@frappe.whitelist() +def get_customers_with_overdue_invoices(): + """Return one row per customer that has at least one overdue Sales Invoice. + + A Sales Invoice is overdue when it is submitted, still has an outstanding + balance, and its due date is in the past. + """ + frappe.only_for(ALLOWED_ROLES) + + today = nowdate() + rows = frappe.get_all( + "Sales Invoice", + filters={ + "docstatus": 1, + "outstanding_amount": [">", 0], + "due_date": ["<", today], + }, + fields=[ + "customer", + "customer_name", + "count(name) as overdue_count", + "sum(outstanding_amount) as total_outstanding", + "min(due_date) as oldest_due_date", + ], + group_by="customer, customer_name", + order_by="total_outstanding desc", + ) + + for row in rows: + row["max_days_overdue"] = ( + (getdate(today) - getdate(row.oldest_due_date)).days + if row.oldest_due_date + else 0 + ) + + return rows + + +def _aging_bucket(days_overdue): + """Map days-overdue to a standard aging bucket label.""" + if days_overdue <= 0: + return "Current" + if days_overdue <= 30: + return "1-30" + if days_overdue <= 60: + return "31-60" + if days_overdue <= 90: + return "61-90" + return "90+" + + +def _get_outstanding_invoices(customer): + """Return all open (submitted, unpaid) Sales Invoices for a customer. + + The statement lists the full open balance, so this includes not-yet-due + invoices; each row is annotated with days overdue, an overdue flag, and its + aging bucket. + """ + today = getdate(nowdate()) + invoices = frappe.get_all( + "Sales Invoice", + filters={ + "customer": customer, + "docstatus": 1, + "outstanding_amount": [">", 0], + }, + fields=[ + "name", + "posting_date", + "due_date", + "outstanding_amount", + "grand_total", + "company", + ], + order_by="due_date asc", + ) + + for inv in invoices: + due = getdate(inv.due_date) if inv.due_date else None + days = (today - due).days if due else 0 + inv["days_overdue"] = days if days > 0 else 0 + inv["is_overdue"] = days > 0 + inv["aging_bucket"] = _aging_bucket(inv["days_overdue"]) + + return invoices -- 2.39.5 From 75a9c9d15472416171f10dc00d19986c2f59e804 Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:04:47 -0400 Subject: [PATCH 02/11] feat(statements): statement builder + printable envelope template Add get_statement_data (open invoices, aging buckets, totals, formatted customer + company addresses) and generate_statements, which renders one page per customer via a Jinja template and returns a printable HTML document. Recipient window geometry (top:1.9in/left:1.125in) mirrors the existing double-window print formats for #10 envelope compatibility; each page uses page-break-after:always. Late fee is a zero placeholder here. Co-Authored-By: Claude Opus 4.8 --- ns_app/api/statements.py | 169 +++++++++++++++++- .../statements/customer_statement.html | 100 +++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 ns_app/templates/statements/customer_statement.html diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index 1e121b5..252194c 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -6,9 +6,12 @@ generation also books a late-payment fee to the ledger (see the late-fee helpers added alongside the generator). """ +import json + import frappe from frappe import _ -from frappe.utils import getdate, nowdate +from frappe.contacts.doctype.address.address import get_address_display, get_default_address +from frappe.utils import flt, getdate, nowdate # Roles allowed to run collections/statement actions. ALLOWED_ROLES = [ @@ -105,3 +108,167 @@ def _get_outstanding_invoices(customer): inv["aging_bucket"] = _aging_bucket(inv["days_overdue"]) return invoices + + +def _address_display(doctype, name): + """Return the formatted (HTML) default address for a party, or ''.""" + address_name = get_default_address(doctype, name) + if not address_name: + return "" + return get_address_display(frappe.get_doc("Address", address_name).as_dict()) or "" + + +def _resolve_company(invoices): + """Pick the company for the statement header/return address.""" + if invoices: + return invoices[0].company + return frappe.defaults.get_user_default("Company") or frappe.db.get_single_value( + "Global Defaults", "default_company" + ) + + +def get_statement_data(customer): + """Assemble everything the statement template needs for one customer.""" + cust = frappe.get_doc("Customer", customer) + invoices = _get_outstanding_invoices(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 + for inv in invoices: + 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 + + return { + "customer": cust.name, + "customer_name": cust.customer_name, + "customer_address": _address_display("Customer", cust.name), + "company": company, + "company_name": company_doc.company_name if company_doc else "", + "return_address": _address_display("Company", company) if company else "", + "currency": (company_doc.default_currency if company_doc else None) + 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, + "statement_date": nowdate(), + } + + +def _render_page(data): + path = frappe.get_app_path( + "ns_app", "templates", "statements", "customer_statement.html" + ) + with open(path) as f: + template = f.read() + return frappe.render_template(template, {"s": data}) + + +def _wrap_document(pages): + """Wrap rendered per-customer pages in a printable HTML document.""" + body = "\n".join(pages) + return f""" + + + +Customer Statements + + + +
+ +
+ {body} + +""" + + +@frappe.whitelist() +def generate_statements(customers): + """Render printable statements (one page per customer) for the selection. + + `customers` may arrive as a JSON-encoded list from the client. + """ + frappe.only_for(ALLOWED_ROLES) + + if isinstance(customers, str): + try: + customers = json.loads(customers) + except (ValueError, TypeError): + customers = [customers] + if not customers: + frappe.throw(_("No customers selected")) + + pages, rendered, skipped = [], [], [] + for customer in customers: + data = get_statement_data(customer) + if not data["invoices"]: + skipped.append(customer) + continue + pages.append(_render_page(data)) + rendered.append(customer) + + if not pages: + frappe.throw(_("None of the selected customers have an outstanding balance.")) + + return {"html": _wrap_document(pages), "rendered": rendered, "skipped": skipped} diff --git a/ns_app/templates/statements/customer_statement.html b/ns_app/templates/statements/customer_statement.html new file mode 100644 index 0000000..54fa7b1 --- /dev/null +++ b/ns_app/templates/statements/customer_statement.html @@ -0,0 +1,100 @@ +{# One customer account statement = one printed page. + Envelope geometry (recipient window at top:1.9in / left:1.125in) mirrors the + existing double-window print formats so the same #10 double-window envelopes + work. Rendered via frappe.render_template with context key `s` + (see ns_app.api.statements.get_statement_data). #} +{% set fmt = frappe.utils.fmt_money %} +
+ + +
+ {{ s.company_name }}
+ {{ s.return_address | safe }} +
+ + +
+
STATEMENT
+
Date: {{ frappe.utils.formatdate(s.statement_date, "MM-dd-yyyy") }}
+
Account: {{ s.customer }}
+
+ + +
+ {{ s.customer_name }}
+ {{ s.customer_address | safe }} +
+ + +
+ +
+ The following is a summary of your account as of + {{ frappe.utils.formatdate(s.statement_date, "MM-dd-yyyy") }}. + Please remit payment for any past-due balance at your earliest convenience. +
+ + + + + + + + + + + + + + {% for inv in s.invoices %} + + + + + + + + + {% endfor %} + +
InvoiceDateDue DateDays OverdueAgingOutstanding
{{ inv.name }}{{ 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 "—" }}{{ inv.aging_bucket }}{{ fmt(inv.outstanding_amount, currency=s.currency) }}
+ + +
+

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) }}

+
+ + + + + + + + + + + + + + + + + + + + + +
Current1–3031–6061–9090+
{{ fmt(s.aging["Current"], currency=s.currency) }}{{ fmt(s.aging["1-30"], currency=s.currency) }}{{ fmt(s.aging["31-60"], currency=s.currency) }}{{ fmt(s.aging["61-90"], currency=s.currency) }}{{ fmt(s.aging["90+"], currency=s.currency) }}
+ + + +
+
-- 2.39.5 From 9e2e86ceadeb3b32d0edaa6c444f3fd560f048f3 Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:08:14 -0400 Subject: [PATCH 03/11] feat(statements): add Customer list "Generate Statements" button Register doctype_list_js for Customer and add customer_list.js, which lists customers with overdue invoices in a selection dialog (checkbox table + select-all), then calls generate_statements and opens the printable, one-page-per-customer document in a new window. Co-Authored-By: Claude Opus 4.8 --- ns_app/hooks.py | 5 + ns_app/public/js/customer_list.js | 151 ++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 ns_app/public/js/customer_list.js diff --git a/ns_app/hooks.py b/ns_app/hooks.py index c4fe7c6..089f50a 100644 --- a/ns_app/hooks.py +++ b/ns_app/hooks.py @@ -15,6 +15,11 @@ doctype_js = { "Sales Invoice": "public/js/sales_invoice.js" } +# Load on Customer list view (adds "Generate Statements" action) +doctype_list_js = { + "Customer": "public/js/customer_list.js" +} + # Fixtures tracked in Git fixtures = [ { diff --git a/ns_app/public/js/customer_list.js b/ns_app/public/js/customer_list.js new file mode 100644 index 0000000..16292aa --- /dev/null +++ b/ns_app/public/js/customer_list.js @@ -0,0 +1,151 @@ +// Adds a "Generate Statements" action to the Customer list. It lists customers +// with overdue invoices, lets the user pick which ones, and opens a printable +// (one-page-per-customer) statement document in a new window. + +frappe.listview_settings["Customer"] = { + onload(listview) { + listview.page.add_inner_button(__("Generate Statements"), () => { + open_statement_selector(); + }); + } +}; + + +function open_statement_selector() { + frappe.call({ + method: "ns_app.api.statements.get_customers_with_overdue_invoices", + freeze: true, + freeze_message: __("Finding customers with overdue invoices..."), + callback(r) { + const rows = r.message || []; + if (!rows.length) { + frappe.msgprint({ + title: __("No Overdue Customers"), + message: __("No customers currently have overdue invoices."), + indicator: "green" + }); + return; + } + show_selection_dialog(rows); + } + }); +} + + +function show_selection_dialog(rows) { + const uid = Date.now(); + const selected = new Set(rows.map(r => r.customer)); // default: all selected + + const body = rows.map(r => ` + + + + + ${frappe.utils.escape_html(r.customer_name || r.customer)} + ${r.overdue_count} + ${r.max_days_overdue} + ${format_currency(r.total_outstanding)} + `).join(""); + + const dialog = new frappe.ui.Dialog({ + title: __("Generate Customer Statements"), + size: "large", + fields: [{ + fieldtype: "HTML", + fieldname: "selector", + options: ` +
+ + + + + + + + + + + ${body} +
+ + ${__("Customer")}${__("Overdue Invoices")}${__("Max Days Overdue")}${__("Total Outstanding")}
+
+
` + }], + primary_action_label: __("Generate Statements"), + primary_action() { generate(); } + }); + + dialog.show(); + + const update_count = () => { + const el = document.getElementById(`sel_count_${uid}`); + if (el) el.innerText = __("{0} of {1} selected", [selected.size, rows.length]); + }; + update_count(); + + // Row checkboxes (delegated) + dialog.$wrapper.on("change", `.cust-check-${uid}`, function () { + if (this.checked) selected.add(this.dataset.name); + else selected.delete(this.dataset.name); + + const all = dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`); + const selAll = document.getElementById(`sel_all_${uid}`); + if (selAll) selAll.checked = [...all].every(c => c.checked); + update_count(); + }); + + // Select-all + dialog.$wrapper.on("change", `#sel_all_${uid}`, function () { + dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`).forEach(cb => { + cb.checked = this.checked; + if (this.checked) selected.add(cb.dataset.name); + else selected.delete(cb.dataset.name); + }); + update_count(); + }); + + function generate() { + const customers = [...selected]; + if (!customers.length) { + frappe.msgprint(__("Select at least one customer.")); + return; + } + frappe.confirm( + __("Generate statements for {0} customer(s)?", [customers.length]), + () => { + frappe.call({ + method: "ns_app.api.statements.generate_statements", + args: { customers }, + freeze: true, + freeze_message: __("Generating statements..."), + callback(r) { + if (!r.message || !r.message.html) return; + dialog.hide(); + open_print_window(r.message.html); + const skipped = (r.message.skipped || []).length; + if (skipped) { + frappe.show_alert({ + message: __("Skipped {0} customer(s) with no balance.", [skipped]), + indicator: "orange" + }); + } + } + }); + } + ); + } +} + + +function open_print_window(html) { + const w = window.open("", "_blank"); + if (!w) { + frappe.msgprint(__("Please allow pop-ups to view the statements.")); + return; + } + w.document.open(); + w.document.write(html); + w.document.close(); +} -- 2.39.5 From acd7df1129332b64a19f73afc0187675bc64426b Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:12:08 -0400 Subject: [PATCH 04/11] 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) }}

-- 2.39.5 From c20dd182875442e0f9c1df660fd4518a7a3be517 Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:52:03 -0400 Subject: [PATCH 05/11] feat(statements): untaxed late-fee series, skip-fee option, audit trail - Bill late-fee invoices under a dedicated naming series (LPF-.YYYY.-), registered on Sales Invoice via after_migrate, so they are easy to spot and filter. - Late fees are never taxed: a zero 'Actual' tax line keeps the taxes table non-empty so ERPNext skips auto-applying company/item tax templates (posts nothing to the ledger). Fee total == computed fee. - generate_statements(skip_late_fee=...) generates a statement without billing a fee. - Record every generation on the customer's timeline (add_comment) as an audit trail, noting Total Due and the fee invoice raised / skipped. Co-Authored-By: Claude Opus 4.8 --- ns_app/api/statements.py | 60 ++++++++++++++++++++++++++++++++-------- ns_app/setup.py | 20 ++++++++++++++ 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index eb44593..e50f11a 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -11,7 +11,10 @@ import json import frappe from frappe import _ from frappe.contacts.doctype.address.address import get_address_display, get_default_address -from frappe.utils import flt, getdate, nowdate +from frappe.utils import flt, fmt_money, getdate, nowdate + +# Dedicated naming series so late-fee invoices are easy to spot and filter. +LATE_FEE_NAMING_SERIES = "LPF-.YYYY.-" # Roles allowed to run collections/statement actions. ALLOWED_ROLES = [ @@ -367,6 +370,7 @@ def _post_late_fee_invoice(customer, company, overdue_invoices, period): ) si = frappe.new_doc("Sales Invoice") + si.naming_series = LATE_FEE_NAMING_SERIES si.customer = customer si.company = company si.posting_date = nowdate() @@ -382,6 +386,20 @@ def _post_late_fee_invoice(customer, company, overdue_invoices, period): "description": _("Late payment fee for statement period {0}").format(period), }, ) + # Late fees are not taxed. A single zero "Actual" tax line keeps the taxes + # table non-empty, which stops ERPNext from auto-applying the company or + # item tax templates; being zero it posts nothing to the ledger. + si.taxes_and_charges = "" + si.append( + "taxes", + { + "charge_type": "Actual", + "account_head": settings.income_account, + "description": _("Late fees are not taxed"), + "tax_amount": 0, + "rate": 0, + }, + ) si.insert(ignore_permissions=True) si.submit() frappe.db.commit() @@ -410,12 +428,27 @@ def _late_fee_invoice_names(customer): return {r.parent for r in rows} +def _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee): + """Log statement generation on the customer's timeline (audit trail).""" + total = fmt_money(data["total_due"], currency=data["currency"]) + if skip_late_fee: + fee_note = _("late fee skipped") + elif fee_invoice_names: + fee_note = _("late fee invoice {0}").format(", ".join(fee_invoice_names)) + else: + fee_note = _("no late fee") + frappe.get_doc("Customer", customer).add_comment( + "Info", _("Statement generated — Total Due {0} ({1}).").format(total, fee_note) + ) + + @frappe.whitelist() -def generate_statements(customers): +def generate_statements(customers, skip_late_fee=0): """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. + Side effect (unless `skip_late_fee`): a late-payment fee is billed as a + Sales Invoice (once per customer per month) for each customer with overdue + invoices. Each generation is recorded on the customer's timeline. `customers` may arrive as a JSON-encoded list from the client. """ @@ -428,6 +461,7 @@ def generate_statements(customers): customers = [customers] if not customers: frappe.throw(_("No customers selected")) + skip_late_fee = int(skip_late_fee or 0) period = _late_fee_period() pages, rendered, skipped = [], [], [] @@ -439,18 +473,22 @@ def generate_statements(customers): 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) + fee_invoice_names = [] + if not skip_late_fee: + 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(): + name = _post_late_fee_invoice(customer, comp, invs, period) + if name: + fee_invoice_names.append(name) # 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) + _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee) if not pages: frappe.throw(_("None of the selected customers have an outstanding balance.")) diff --git a/ns_app/setup.py b/ns_app/setup.py index 0a37058..cd90c57 100644 --- a/ns_app/setup.py +++ b/ns_app/setup.py @@ -2,6 +2,9 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe.custom.doctype.property_setter.property_setter import make_property_setter + +from ns_app.api.statements import LATE_FEE_NAMING_SERIES # 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. @@ -22,5 +25,22 @@ CUSTOM_FIELDS = { } +def _register_late_fee_naming_series(): + """Add the late-fee series to Sales Invoice's naming_series options.""" + field = frappe.get_meta("Sales Invoice").get_field("naming_series") + options = [o for o in (field.options or "").split("\n")] if field else [] + if LATE_FEE_NAMING_SERIES not in options: + options.append(LATE_FEE_NAMING_SERIES) + make_property_setter( + "Sales Invoice", + "naming_series", + "options", + "\n".join(options), + "Text", + validate_fields_for_doctype=False, + ) + + def after_migrate(): create_custom_fields(CUSTOM_FIELDS) + _register_late_fee_naming_series() -- 2.39.5 From 46967208e9d4c54a2e6943bfed2d00e0f6044138 Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:52:15 -0400 Subject: [PATCH 06/11] feat(statements): Customer-form button, consolidated UI, disable-fee toggle Replace customer_list.js with customer_statements.js (loaded globally), which adds the statement UI to both entry points: - Customer list: 'Generate Statements' multi-select action. - Customer form: 'Generate Statement' button for a single customer. Both open a popup with a 'Generate late payment fee' checkbox (default on) that maps to generate_statements(skip_late_fee), so fee billing can be turned off per run. Shared generate/print helpers live on ns_statements. Co-Authored-By: Claude Opus 4.8 --- ns_app/hooks.py | 8 +- ns_app/public/js/customer_list.js | 151 ----------------- ns_app/public/js/customer_statements.js | 205 ++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 157 deletions(-) delete mode 100644 ns_app/public/js/customer_list.js create mode 100644 ns_app/public/js/customer_statements.js diff --git a/ns_app/hooks.py b/ns_app/hooks.py index bcaad81..1802a5b 100644 --- a/ns_app/hooks.py +++ b/ns_app/hooks.py @@ -7,7 +7,8 @@ app_license = "MIT" # Load on every page app_include_js = [ - "/assets/ns_app/js/customer_quick_entry.js" + "/assets/ns_app/js/customer_quick_entry.js", + "/assets/ns_app/js/customer_statements.js" ] # Load on Sales Invoice form @@ -15,11 +16,6 @@ doctype_js = { "Sales Invoice": "public/js/sales_invoice.js" } -# Load on Customer list view (adds "Generate Statements" action) -doctype_list_js = { - "Customer": "public/js/customer_list.js" -} - # Ensure custom fields exist after every migrate after_migrate = "ns_app.setup.after_migrate" diff --git a/ns_app/public/js/customer_list.js b/ns_app/public/js/customer_list.js deleted file mode 100644 index e0faf0a..0000000 --- a/ns_app/public/js/customer_list.js +++ /dev/null @@ -1,151 +0,0 @@ -// Adds a "Generate Statements" action to the Customer list. It lists customers -// with overdue invoices, lets the user pick which ones, and opens a printable -// (one-page-per-customer) statement document in a new window. - -frappe.listview_settings["Customer"] = { - onload(listview) { - listview.page.add_inner_button(__("Generate Statements"), () => { - open_statement_selector(); - }); - } -}; - - -function open_statement_selector() { - frappe.call({ - method: "ns_app.api.statements.get_customers_with_overdue_invoices", - freeze: true, - freeze_message: __("Finding customers with overdue invoices..."), - callback(r) { - const rows = r.message || []; - if (!rows.length) { - frappe.msgprint({ - title: __("No Overdue Customers"), - message: __("No customers currently have overdue invoices."), - indicator: "green" - }); - return; - } - show_selection_dialog(rows); - } - }); -} - - -function show_selection_dialog(rows) { - const uid = Date.now(); - const selected = new Set(rows.map(r => r.customer)); // default: all selected - - const body = rows.map(r => ` - - - - - ${frappe.utils.escape_html(r.customer_name || r.customer)} - ${r.overdue_count} - ${r.max_days_overdue} - ${format_currency(r.total_outstanding)} - `).join(""); - - const dialog = new frappe.ui.Dialog({ - title: __("Generate Customer Statements"), - size: "large", - fields: [{ - fieldtype: "HTML", - fieldname: "selector", - options: ` -
- - - - - - - - - - - ${body} -
- - ${__("Customer")}${__("Overdue Invoices")}${__("Max Days Overdue")}${__("Total Outstanding")}
-
-
` - }], - primary_action_label: __("Generate Statements"), - primary_action() { generate(); } - }); - - dialog.show(); - - const update_count = () => { - const el = document.getElementById(`sel_count_${uid}`); - if (el) el.innerText = __("{0} of {1} selected", [selected.size, rows.length]); - }; - update_count(); - - // Row checkboxes (delegated) - dialog.$wrapper.on("change", `.cust-check-${uid}`, function () { - if (this.checked) selected.add(this.dataset.name); - else selected.delete(this.dataset.name); - - const all = dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`); - const selAll = document.getElementById(`sel_all_${uid}`); - if (selAll) selAll.checked = [...all].every(c => c.checked); - update_count(); - }); - - // Select-all - dialog.$wrapper.on("change", `#sel_all_${uid}`, function () { - dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`).forEach(cb => { - cb.checked = this.checked; - if (this.checked) selected.add(cb.dataset.name); - else selected.delete(cb.dataset.name); - }); - update_count(); - }); - - function generate() { - const customers = [...selected]; - if (!customers.length) { - frappe.msgprint(__("Select at least one customer.")); - return; - } - frappe.confirm( - __("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", - args: { customers }, - freeze: true, - freeze_message: __("Generating statements..."), - callback(r) { - if (!r.message || !r.message.html) return; - dialog.hide(); - open_print_window(r.message.html); - const skipped = (r.message.skipped || []).length; - if (skipped) { - frappe.show_alert({ - message: __("Skipped {0} customer(s) with no balance.", [skipped]), - indicator: "orange" - }); - } - } - }); - } - ); - } -} - - -function open_print_window(html) { - const w = window.open("", "_blank"); - if (!w) { - frappe.msgprint(__("Please allow pop-ups to view the statements.")); - return; - } - w.document.open(); - w.document.write(html); - w.document.close(); -} diff --git a/ns_app/public/js/customer_statements.js b/ns_app/public/js/customer_statements.js new file mode 100644 index 0000000..f417586 --- /dev/null +++ b/ns_app/public/js/customer_statements.js @@ -0,0 +1,205 @@ +// Customer Statements: generate printable, one-page-per-customer account +// statements formatted for a window envelope. Two entry points share the same +// generate/print helpers — a multi-select action on the Customer list and a +// single-customer button on the Customer form. Loaded globally so both the +// list view and the form can reach the shared `ns_statements` helpers. + +frappe.provide("ns_statements"); + +// ── Entry point: Customer list ─────────────────────────────────────────────── +frappe.listview_settings["Customer"] = { + onload(listview) { + listview.page.add_inner_button(__("Generate Statements"), () => { + ns_statements.pick_and_generate(); + }); + } +}; + +// ── Entry point: Customer form ─────────────────────────────────────────────── +frappe.ui.form.on("Customer", { + refresh(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__("Generate Statement"), () => { + ns_statements.generate_for_customer(frm.doc.name); + }); + } +}); + +// ── Shared: call the backend and open the printable document ───────────────── +ns_statements.run = function (customers, skip_late_fee) { + frappe.call({ + method: "ns_app.api.statements.generate_statements", + args: { customers, skip_late_fee: skip_late_fee ? 1 : 0 }, + freeze: true, + freeze_message: __("Generating statements..."), + callback(r) { + if (!r.message || !r.message.html) return; + ns_statements.open_print_window(r.message.html); + const skipped = (r.message.skipped || []).length; + if (skipped) { + frappe.show_alert({ + message: __("Skipped {0} customer(s) with no balance.", [skipped]), + indicator: "orange" + }); + } + } + }); +}; + +ns_statements.open_print_window = function (html) { + const w = window.open("", "_blank"); + if (!w) { + frappe.msgprint(__("Please allow pop-ups to view the statements.")); + return; + } + w.document.open(); + w.document.write(html); + w.document.close(); +}; + +// ── List flow: pick customers with overdue invoices, then generate ─────────── +ns_statements.pick_and_generate = function () { + frappe.call({ + method: "ns_app.api.statements.get_customers_with_overdue_invoices", + freeze: true, + freeze_message: __("Finding customers with overdue invoices..."), + callback(r) { + const rows = r.message || []; + if (!rows.length) { + frappe.msgprint({ + title: __("No Overdue Customers"), + message: __("No customers currently have overdue invoices."), + indicator: "green" + }); + return; + } + ns_statements._selection_dialog(rows); + } + }); +}; + +ns_statements._selection_dialog = function (rows) { + const uid = Date.now(); + const selected = new Set(rows.map(r => r.customer)); // default: all selected + + const body = rows.map(r => ` + + + + + ${frappe.utils.escape_html(r.customer_name || r.customer)} + ${r.overdue_count} + ${r.max_days_overdue} + ${format_currency(r.total_outstanding)} + `).join(""); + + const dialog = new frappe.ui.Dialog({ + title: __("Generate Customer Statements"), + size: "large", + fields: [ + { + fieldtype: "HTML", + fieldname: "selector", + options: ` +
+ + + + + + + + + + + ${body} +
+ + ${__("Customer")}${__("Overdue Invoices")}${__("Max Days Overdue")}${__("Total Outstanding")}
+
+
` + }, + { + fieldtype: "Check", + fieldname: "generate_late_fee", + label: __("Generate late payment fee"), + default: 1, + description: __("Bills a late-fee invoice (once per customer this month) for overdue balances.") + } + ], + primary_action_label: __("Generate Statements"), + primary_action() { + const customers = [...selected]; + if (!customers.length) { + frappe.msgprint(__("Select at least one customer.")); + return; + } + const gen_fee = dialog.get_value("generate_late_fee"); + const proceed = () => { + dialog.hide(); + ns_statements.run(customers, !gen_fee); + }; + if (gen_fee) { + frappe.confirm( + __("Generate statements for {0} customer(s)? A late-fee invoice will be raised (once per customer this month) for any overdue balances.", [customers.length]), + proceed + ); + } else { + proceed(); + } + } + }); + + dialog.show(); + + const update_count = () => { + const el = document.getElementById(`sel_count_${uid}`); + if (el) el.innerText = __("{0} of {1} selected", [selected.size, rows.length]); + }; + update_count(); + + dialog.$wrapper.on("change", `.cust-check-${uid}`, function () { + if (this.checked) selected.add(this.dataset.name); + else selected.delete(this.dataset.name); + const all = dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`); + const selAll = document.getElementById(`sel_all_${uid}`); + if (selAll) selAll.checked = [...all].every(c => c.checked); + update_count(); + }); + + dialog.$wrapper.on("change", `#sel_all_${uid}`, function () { + dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`).forEach(cb => { + cb.checked = this.checked; + if (this.checked) selected.add(cb.dataset.name); + else selected.delete(cb.dataset.name); + }); + update_count(); + }); +}; + +// ── Form flow: single customer, with the same fee toggle ───────────────────── +ns_statements.generate_for_customer = function (customer) { + const d = new frappe.ui.Dialog({ + title: __("Generate Statement"), + fields: [ + { + fieldtype: "HTML", + options: `

${__("Generate an account statement for {0}.", [frappe.utils.escape_html(customer)])}

` + }, + { + fieldtype: "Check", + fieldname: "generate_late_fee", + label: __("Generate late payment fee"), + default: 1, + description: __("Bills a late-fee invoice (once this month) for overdue balances.") + } + ], + primary_action_label: __("Generate"), + primary_action(values) { + d.hide(); + ns_statements.run([customer], !values.generate_late_fee); + } + }); + d.show(); +}; -- 2.39.5 From 5181f4a177fce45b9c5f8a76ac3d0fc881444e7f Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:58:09 -0400 Subject: [PATCH 07/11] docs: describe the customer statements feature Co-Authored-By: Claude Opus 4.8 --- docs/CUSTOMER_STATEMENTS.md | 135 ++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/CUSTOMER_STATEMENTS.md diff --git a/docs/CUSTOMER_STATEMENTS.md b/docs/CUSTOMER_STATEMENTS.md new file mode 100644 index 0000000..5f746d2 --- /dev/null +++ b/docs/CUSTOMER_STATEMENTS.md @@ -0,0 +1,135 @@ +# Customer Statements & Late Payment Fees + +> Branch: `feature/customer-statements` + +Generates printable, **one-page-per-customer** account statements — formatted to +fit a standard #10 **double-window envelope** — for customers with overdue +invoices, and (optionally) bills a **late-payment fee** that posts to the ledger +and is collectible through the app's existing payment flow. + +--- + +## 1. What it does + +From either the **Customer list** or a **Customer form**, a user can generate +account statements: + +1. **Pick customers.** On the list, *Generate Statements* opens a dialog listing + every customer with overdue invoices (overdue count, max days overdue, total + outstanding) with select-all. On a Customer form, *Generate Statement* targets + that one customer. +2. **Choose whether to bill a late fee** via a checkbox in the popup + (*Generate late payment fee*, on by default). +3. **Get a printable report.** A new browser tab opens with one statement per + page — each showing the customer's open invoices, aging buckets + (Current / 1–30 / 31–60 / 61–90 / 90+), and a **Total Due**. The customer and + company (return) addresses sit in the two envelope-window positions. + +Each generation is recorded on the customer's timeline as an audit-trail entry. + +--- + +## 2. Late payment fees + +### How the fee is calculated +Interest uses ERPNext's own Dunning formula: + +``` +fee = Σ(invoice.outstanding × rate_of_interest/100/365 × days_overdue) + dunning_fee +``` + +### Where the settings live — ERPNext **Dunning Type** +All fee configuration comes from the existing **Dunning Type** doctype +(Accounting ▸ Dunning Type). Nothing is auto-created; generation stops with a +clear error until it is configured. The default (`is_default`) Dunning Type for +the company is used. Fields consumed: + +| Dunning Type field | Purpose | +|--------------------|---------| +| `rate_of_interest` | Annual interest rate (%) | +| `dunning_fee` | Flat fee added per statement | +| `income_account` | Credited when the fee is billed | +| `cost_center` | Cost center for the fee line (falls back to company default) | +| `custom_late_fee_item` | **Late Fee Item** — the Item used to bill the fee (custom field added by this app) | + +### How the fee posts, and how it gets paid +The fee is billed as a **submitted Sales Invoice** (item → the Dunning Type +income account). This is deliberate: because it is a real Sales Invoice it + +- increases the customer's receivable balance immediately, and +- appears in `get_unpaid_invoices` and is charged/settled automatically by the + app's existing payment paths (**Run Payment / AutoPay / multi-invoice** → + `create_payment_entry`), which allocate against Sales Invoices. + +A bare Journal Entry (or an ERPNext Dunning document) would raise the balance but +sit **uncollectible** by those flows — hence the Sales Invoice. + +### Fee invoice specifics +- **Dedicated naming series `LPF-.YYYY.-`** (e.g. `LPF-2026-00001`) so late-fee + invoices are easy to spot and filter. Registered on Sales Invoice's + `naming_series` via `after_migrate`. +- **Never taxed.** A single zero-amount "Actual" tax line keeps ERPNext from + auto-applying company/item tax templates, so the invoice total equals the + computed fee exactly and nothing extra hits the ledger. +- **Idempotent** — at most one fee invoice per customer / company / calendar + month. Prior fee invoices are excluded from the interest base (no fee-on-fee). + +On the statement the fee shows as a normal invoice line tagged **“late fee”**, +folded into a single **Total Due** that equals the customer's balance. + +--- + +## 3. Configuration / prerequisites + +1. **Migrate** the app (`bench --site migrate`) — creates the + `Late Fee Item` custom field on Dunning Type and registers the `LPF-` series. +2. Create an **Item** to represent the fee (a non-stock sales item, e.g. + "Late Payment Fee"). +3. Create/complete a **Dunning Type** for the company with: rate of interest, + dunning fee, **income account**, and the **Late Fee Item**. Mark it default. + +If any of these is missing, statement generation throws a clear, actionable +error and posts nothing. + +--- + +## 4. Audit trail + +Each generated statement adds an *Info* comment to the customer's timeline, e.g. + +> Statement generated — Total Due $557.17 (late fee invoice LPF-2026-00001). + +The note reflects the outcome: the fee invoice raised, *no late fee*, or +*late fee skipped* (when the fee checkbox was cleared). It is attributed to the +generating user. + +--- + +## 5. Files + +| File | Role | +|------|------| +| `ns_app/api/statements.py` | Overdue-customer query, statement builder, printable HTML, late-fee billing (Sales Invoice), audit-trail entry | +| `ns_app/templates/statements/customer_statement.html` | Jinja template for one customer page (envelope windows + aging table) | +| `ns_app/public/js/customer_statements.js` | List action + Customer-form button + selection/fee-toggle popups (loaded globally) | +| `ns_app/setup.py` | `after_migrate`: creates the Late Fee Item custom field, registers the `LPF-` naming series | +| `ns_app/hooks.py` | Wires the JS (`app_include_js`) and `after_migrate` | + +### Server API (`ns_app.api.statements`) +- `get_customers_with_overdue_invoices()` — customers with overdue invoices. +- `generate_statements(customers, skip_late_fee=0)` — bills fees (unless skipped), + renders the printable HTML, records the audit entry. Returns + `{html, rendered, skipped}`. +- `get_statement_data(customer)` — the per-customer statement data (internal). + +Access is restricted to System Manager, Sales User/Manager, Accounts +User/Manager. + +--- + +## 6. Notes / non-goals + +- No persisted "Statement" doctype — statements are generated on demand. +- No email/fax delivery — print only. +- Whether the fee should be taxed and the interest rate/fee amounts are business + settings, controlled entirely through Dunning Type. -- 2.39.5 From 326adf865a44411a4fe12bbbd2ef72ad0d3dee8b Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 20:36:21 -0400 Subject: [PATCH 08/11] fix(statements): restore Customer list button (merge listview_settings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list button was registered by reassigning frappe.listview_settings['Customer'] in a globally-loaded script, but ERPNext's own Customer list_js (loaded when the list opens) overwrote it, so the button never appeared. Register it via doctype_list_js instead — which Frappe appends after the doctype's own list_js — and merge into the existing settings (wrapping onload, preserving ERPNext's add_fields) rather than reassigning. The form button and shared ns_statements helpers stay in customer_statements.js. Co-Authored-By: Claude Opus 4.8 --- ns_app/hooks.py | 5 +++++ ns_app/public/js/customer_list.js | 20 ++++++++++++++++++++ ns_app/public/js/customer_statements.js | 11 +++-------- 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 ns_app/public/js/customer_list.js diff --git a/ns_app/hooks.py b/ns_app/hooks.py index 1802a5b..57f47f9 100644 --- a/ns_app/hooks.py +++ b/ns_app/hooks.py @@ -16,6 +16,11 @@ doctype_js = { "Sales Invoice": "public/js/sales_invoice.js" } +# Load on Customer list view (merges the "Generate Statements" action) +doctype_list_js = { + "Customer": "public/js/customer_list.js" +} + # Ensure custom fields exist after every migrate after_migrate = "ns_app.setup.after_migrate" diff --git a/ns_app/public/js/customer_list.js b/ns_app/public/js/customer_list.js new file mode 100644 index 0000000..ac239ed --- /dev/null +++ b/ns_app/public/js/customer_list.js @@ -0,0 +1,20 @@ +// Customer list action: "Generate Statements". Registered as a doctype_list_js +// so it loads alongside ERPNext's own Customer list settings (in app order, +// after them). We MERGE into listview_settings — preserving any existing +// onload / add_fields — instead of reassigning the object, which would clobber +// ERPNext's settings (and be clobbered by them). The shared generate/print +// helpers live on `ns_statements` (public/js/customer_statements.js). + +frappe.listview_settings["Customer"] = frappe.listview_settings["Customer"] || {}; + +(function () { + const settings = frappe.listview_settings["Customer"]; + const original_onload = settings.onload; + + settings.onload = function (listview) { + if (original_onload) original_onload(listview); + listview.page.add_inner_button(__("Generate Statements"), () => { + ns_statements.pick_and_generate(); + }); + }; +})(); diff --git a/ns_app/public/js/customer_statements.js b/ns_app/public/js/customer_statements.js index f417586..59a0bb5 100644 --- a/ns_app/public/js/customer_statements.js +++ b/ns_app/public/js/customer_statements.js @@ -6,14 +6,9 @@ frappe.provide("ns_statements"); -// ── Entry point: Customer list ─────────────────────────────────────────────── -frappe.listview_settings["Customer"] = { - onload(listview) { - listview.page.add_inner_button(__("Generate Statements"), () => { - ns_statements.pick_and_generate(); - }); - } -}; +// The Customer list button is registered separately in customer_list.js +// (a doctype_list_js) so it merges with — rather than overwrites — ERPNext's +// own listview_settings["Customer"]. Shared helpers live here on ns_statements. // ── Entry point: Customer form ─────────────────────────────────────────────── frappe.ui.form.on("Customer", { -- 2.39.5 From 0aefdeb5ffe958ed5e217b0d2802c2204ce22c02 Mon Sep 17 00:00:00 2001 From: Norman King Date: Thu, 9 Jul 2026 07:52:07 -0400 Subject: [PATCH 09/11] fix(statements): set envelope geometry for #9 double-window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset the statement window positions to the app's proven #9 (9x4) double-window geometry, mirroring sales_invoice_ns.html — recipient window at top:1.5in/left:1.125in. It previously copied the dunning format's 1.9in, which sits too low for a #9. Tighten the body padding-top to keep clearance below the higher window, and correct the stale #10 references in the template comment and docs. Co-Authored-By: Claude Opus 4.8 --- docs/CUSTOMER_STATEMENTS.md | 2 +- ns_app/api/statements.py | 7 +++++-- ns_app/templates/statements/customer_statement.html | 7 ++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/CUSTOMER_STATEMENTS.md b/docs/CUSTOMER_STATEMENTS.md index 5f746d2..1dbb260 100644 --- a/docs/CUSTOMER_STATEMENTS.md +++ b/docs/CUSTOMER_STATEMENTS.md @@ -3,7 +3,7 @@ > Branch: `feature/customer-statements` Generates printable, **one-page-per-customer** account statements — formatted to -fit a standard #10 **double-window envelope** — for customers with overdue +fit a standard #9 (9x4) **double-window envelope** — for customers with overdue invoices, and (optionally) bills a **late-payment fee** that posts to the ledger and is collectible through the app's existing payment flow. diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index e50f11a..84f81e8 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -202,6 +202,9 @@ def _wrap_document(pages): overflow: hidden; }} .statement-page:last-of-type {{ page-break-after: auto; }} + /* Window positions match the app's proven #9 (9x4) double-window envelope + geometry — recipient window at top:1.5in/left:1.125in, mirroring + sales_invoice_ns.html ("9x4 envelope position"). */ .return-window {{ position: absolute; top: 0.55in; left: 0.6in; width: 3.5in; font-size: 11px; line-height: 1.3; @@ -212,11 +215,11 @@ def _wrap_document(pages): }} .doc-header .doc-title {{ font-size: 20px; font-weight: bold; letter-spacing: 1px; }} .recipient-window {{ - position: absolute; top: 1.9in; left: 1.125in; + position: absolute; top: 1.5in; left: 1.125in; width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em; overflow: hidden; }} - .statement-body {{ padding: 3.35in 0.6in 0.6in 0.6in; }} + .statement-body {{ padding: 2.95in 0.6in 0.6in 0.6in; }} .intro {{ font-size: 12px; margin-bottom: 12px; }} table.items, table.aging {{ width: 100%; border-collapse: collapse; }} table.items th, table.items td, diff --git a/ns_app/templates/statements/customer_statement.html b/ns_app/templates/statements/customer_statement.html index 2b48f65..63ce8b1 100644 --- a/ns_app/templates/statements/customer_statement.html +++ b/ns_app/templates/statements/customer_statement.html @@ -1,7 +1,8 @@ {# One customer account statement = one printed page. - Envelope geometry (recipient window at top:1.9in / left:1.125in) mirrors the - existing double-window print formats so the same #10 double-window envelopes - work. Rendered via frappe.render_template with context key `s` + Envelope geometry (recipient window at top:1.5in / left:1.125in) mirrors the + app's proven #9 (9x4) double-window print format (sales_invoice_ns.html) so + the same #9 double-window envelopes work. Rendered via + frappe.render_template with context key `s` (see ns_app.api.statements.get_statement_data). #} {% set fmt = frappe.utils.fmt_money %}
-- 2.39.5 From ccc3fac69d9f0c93c9ccb3b03a6d973eb0ef3527 Mon Sep 17 00:00:00 2001 From: Norman King Date: Thu, 9 Jul 2026 08:06:37 -0400 Subject: [PATCH 10/11] fix(statements): nudge envelope windows to printed-proof positions Company (return) address down 0.25in (to 0.8in) and customer address down 1.5in (to 3.0in) to line up with the #9 double-window envelope, per a printed proof. Push the body padding-top to clear the lower customer window. Co-Authored-By: Claude Opus 4.8 --- ns_app/api/statements.py | 13 ++++++------- ns_app/templates/statements/customer_statement.html | 8 +++----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index 84f81e8..49f5cb5 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -202,24 +202,23 @@ def _wrap_document(pages): overflow: hidden; }} .statement-page:last-of-type {{ page-break-after: auto; }} - /* Window positions match the app's proven #9 (9x4) double-window envelope - geometry — recipient window at top:1.5in/left:1.125in, mirroring - sales_invoice_ns.html ("9x4 envelope position"). */ + /* Window positions are field-tuned to the #9 (9x4) double-window envelope + (verified against a printed proof). */ .return-window {{ - position: absolute; top: 0.55in; left: 0.6in; + position: absolute; top: 0.8in; left: 0.6in; width: 3.5in; font-size: 11px; line-height: 1.3; }} .doc-header {{ - position: absolute; top: 0.55in; right: 0.6in; + position: absolute; top: 0.8in; right: 0.6in; width: 3in; text-align: right; font-size: 13px; line-height: 1.5; }} .doc-header .doc-title {{ font-size: 20px; font-weight: bold; letter-spacing: 1px; }} .recipient-window {{ - position: absolute; top: 1.5in; left: 1.125in; + position: absolute; top: 3.0in; left: 1.125in; width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em; overflow: hidden; }} - .statement-body {{ padding: 2.95in 0.6in 0.6in 0.6in; }} + .statement-body {{ padding: 4.4in 0.6in 0.6in 0.6in; }} .intro {{ font-size: 12px; margin-bottom: 12px; }} table.items, table.aging {{ width: 100%; border-collapse: collapse; }} table.items th, table.items td, diff --git a/ns_app/templates/statements/customer_statement.html b/ns_app/templates/statements/customer_statement.html index 63ce8b1..6661727 100644 --- a/ns_app/templates/statements/customer_statement.html +++ b/ns_app/templates/statements/customer_statement.html @@ -1,9 +1,7 @@ {# One customer account statement = one printed page. - Envelope geometry (recipient window at top:1.5in / left:1.125in) mirrors the - app's proven #9 (9x4) double-window print format (sales_invoice_ns.html) so - the same #9 double-window envelopes work. Rendered via - frappe.render_template with context key `s` - (see ns_app.api.statements.get_statement_data). #} + Envelope geometry (window positions in _wrap_document) is field-tuned to the + #9 (9x4) double-window envelope. Rendered via frappe.render_template with + context key `s` (see ns_app.api.statements.get_statement_data). #} {% set fmt = frappe.utils.fmt_money %}
-- 2.39.5 From 41d3fec08c9959b6efc89c9db51e1c890ff92ee0 Mon Sep 17 00:00:00 2001 From: Norman King Date: Thu, 9 Jul 2026 08:20:44 -0400 Subject: [PATCH 11/11] fix(statements): raise customer address window 0.5in Move the customer address window up to 2.5in per printed proof, and pull the body padding-top back up to match. Co-Authored-By: Claude Opus 4.8 --- ns_app/api/statements.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index 49f5cb5..bfbed18 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -214,11 +214,11 @@ def _wrap_document(pages): }} .doc-header .doc-title {{ font-size: 20px; font-weight: bold; letter-spacing: 1px; }} .recipient-window {{ - position: absolute; top: 3.0in; left: 1.125in; + position: absolute; top: 2.5in; left: 1.125in; width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em; overflow: hidden; }} - .statement-body {{ padding: 4.4in 0.6in 0.6in 0.6in; }} + .statement-body {{ padding: 3.9in 0.6in 0.6in 0.6in; }} .intro {{ font-size: 12px; margin-bottom: 12px; }} table.items, table.aging {{ width: 100%; border-collapse: collapse; }} table.items th, table.items td, -- 2.39.5