diff --git a/docs/CUSTOMER_STATEMENTS.md b/docs/CUSTOMER_STATEMENTS.md new file mode 100644 index 0000000..1dbb260 --- /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 #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. + +--- + +## 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. diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py new file mode 100644 index 0000000..bfbed18 --- /dev/null +++ b/ns_app/api/statements.py @@ -0,0 +1,498 @@ +"""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 json + +import frappe +from frappe import _ +from frappe.contacts.doctype.address.address import get_address_display, get_default_address +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 = [ + "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 + + +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, 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) + 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_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_due += flt(inv["outstanding_amount"]) + + 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_due": total_due, + "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} + +""" + + +# ── 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.naming_series = LATE_FEE_NAMING_SERIES + 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), + }, + ) + # 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() + 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} + + +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, skip_late_fee=0): + """Render printable statements (one page per customer) for the selection. + + 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. + """ + 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")) + skip_late_fee = int(skip_late_fee or 0) + + period = _late_fee_period() + pages, rendered, skipped = [], [], [] + + for customer in customers: + invoices = _get_outstanding_invoices(customer) + if not invoices: + skipped.append(customer) + continue + + # Bill the late fee per company (on overdue invoices only). + 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.")) + + return {"html": _wrap_document(pages), "rendered": rendered, "skipped": skipped} diff --git a/ns_app/hooks.py b/ns_app/hooks.py index c4fe7c6..57f47f9 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,6 +16,14 @@ 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" + # 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..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 new file mode 100644 index 0000000..59a0bb5 --- /dev/null +++ b/ns_app/public/js/customer_statements.js @@ -0,0 +1,200 @@ +// 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"); + +// 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", { + 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(); +}; diff --git a/ns_app/setup.py b/ns_app/setup.py new file mode 100644 index 0000000..cd90c57 --- /dev/null +++ b/ns_app/setup.py @@ -0,0 +1,46 @@ +"""App setup: custom fields created/synced on migrate.""" + +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. +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 _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() diff --git a/ns_app/templates/statements/customer_statement.html b/ns_app/templates/statements/customer_statement.html new file mode 100644 index 0000000..6661727 --- /dev/null +++ b/ns_app/templates/statements/customer_statement.html @@ -0,0 +1,95 @@ +{# One customer account statement = one printed page. + 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 %} +
+ + +
+ {{ 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 }}{% 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 "—" }}{{ inv.aging_bucket }}{{ fmt(inv.outstanding_amount, currency=s.currency) }}
+ + +
+

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