From 1784d3819ba1e5cfa84d3336b786d63f32d3fbbe Mon Sep 17 00:00:00 2001 From: Norman King Date: Tue, 25 Aug 2026 04:53:43 -0400 Subject: [PATCH] refactor: move customer statements into its own app Customer statements + late-fee billing now live in the standalone `customer_statements` app, so they can be versioned and deployed independently of the NMI payment integration. Removed here: api/statements.py, setup.py (its custom fields existed only for late fees), customer_statements.js, customer_list.js, the statement template, and the feature doc. hooks.py drops the statements JS, doctype_list_js, and after_migrate accordingly. Co-Authored-By: Claude Opus 5 --- docs/CUSTOMER_STATEMENTS.md | 190 ----- ns_app/api/statements.py | 746 ------------------ ns_app/hooks.py | 11 +- ns_app/public/js/customer_list.js | 20 - ns_app/public/js/customer_statements.js | 200 ----- ns_app/setup.py | 64 -- .../statements/customer_statement.html | 95 --- 7 files changed, 1 insertion(+), 1325 deletions(-) delete mode 100644 docs/CUSTOMER_STATEMENTS.md delete mode 100644 ns_app/api/statements.py delete mode 100644 ns_app/public/js/customer_list.js delete mode 100644 ns_app/public/js/customer_statements.js delete mode 100644 ns_app/setup.py delete mode 100644 ns_app/templates/statements/customer_statement.html diff --git a/docs/CUSTOMER_STATEMENTS.md b/docs/CUSTOMER_STATEMENTS.md deleted file mode 100644 index 091c36e..0000000 --- a/docs/CUSTOMER_STATEMENTS.md +++ /dev/null @@ -1,190 +0,0 @@ -# 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, accrued **from the last fee run** -rather than from each invoice's due date, so no period is ever billed twice: - -``` -accrue_from = max(invoice.due_date, last_billed_upto) -interest = Σ(invoice.outstanding × rate_of_interest/100/365 × days_since(accrue_from)) -charge = interest + dunning_fee # flat fee only when raising a new fee invoice - = interest # when topping up an existing one -``` - -The Σ runs over **every** overdue invoice, unpaid late-fee invoices included — -they are receivables like any other and are charged on the same terms. - -`last_billed_upto` is stored on the fee invoice itself -(`custom_late_fee_billed_upto`). Fee invoices raised before that field existed -fall back to their posting date, which is when they were billed. - -The flat `dunning_fee` is a one-off charge for falling into collections, applied -when a fee invoice is first raised — not again on every top-up. - -### 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, charged once when a fee invoice is raised | -| `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 charge per customer / company / calendar month. - An unpaid fee invoice accrues interest on the same terms as any other overdue - receivable (see below). - -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. - -### One fee invoice per collections episode -While an earlier fee invoice still carries a balance, the next run **amends it** -and appends the new period's interest as a further line item, instead of raising -a second invoice. The customer sees one growing charge, itemised by period. - -Amending means cancelling and re-raising, so the invoice number gains a suffix -(`LPF-2026-00001` → `LPF-2026-00001-1`). The original posting and due dates are -carried over deliberately: re-dating to today would reset the invoice to -*Current* in the statement's aging buckets and hide how long the balance has -been owed. - -**Partially paid fee invoices.** Cancelling unlinks any Payment Entries, leaving -the cash unallocated on them. After the amended invoice is submitted, each -payment is re-applied to it via `reconcile_against_document` — the same -primitive the Payment Reconciliation tool uses — so the outstanding amount and -the Payment Ledger reflect what the customer actually owes. (As with any -reconciliation, ERPNext clears `against_voucher` on the payment's **GL** rows and -tracks the allocation on the **Payment Ledger**; the AR reports read the latter.) - -**When it can't amend.** Cancelling is only reversible for links this app knows -how to restore. If the open fee invoice has a Journal Entry or credit note -applied, a negative payment allocation, or a posting date inside a frozen -accounting period, it is left alone, the charge goes onto a new invoice, and the -reason is recorded on the customer's timeline: - -> Late fee LPF-2026-00001 could not be amended (a Journal Entry is applied -> against it); the charge was billed on a new invoice. - -### Unpaid fee invoices accrue too -A late-fee invoice is an overdue receivable like any other, and once past its -due date its outstanding balance is part of the interest base. Because the -charge lands back on the invoice carrying that balance, **interest compounds**: -each run adds interest on the fee balance the previous runs built up. - -A customer whose only remaining overdue item is an unpaid fee invoice therefore -keeps accruing — the balance grows by `outstanding × rate/365 × days_since_last_run` -every run until it is paid. No new flat fee is raised while a fee invoice is -open, so the growth is interest alone. - ---- - -## 3. Configuration / prerequisites - -1. **Migrate** the app (`bench --site migrate`) — creates the - `Late Fee Item` custom field on Dunning Type, the `Late Fee Billed Upto` - custom field on Sales Invoice, 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 charged on LPF-2026-00001-1). - -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 / Late Fee Billed Upto custom fields, 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 deleted file mode 100644 index 4db2b1c..0000000 --- a/ns_app/api/statements.py +++ /dev/null @@ -1,746 +0,0 @@ -"""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.-" - -# Custom field on Sales Invoice recording the date interest was last charged, so -# the next run accrues from there instead of re-charging from the due date. -BILLED_UPTO_FIELD = "custom_late_fee_billed_upto" - -# 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. -# -# A customer gets **one** fee invoice per collections episode, not one per -# month: while an earlier fee invoice still carries a balance, the next run -# amends it and appends the new period's interest as another line, so the -# customer sees a single growing charge instead of a stack of small ones. The -# flat dunning_fee is a one-off for falling into collections and is charged only -# when a fee invoice is first raised. Interest accrues from the last run -# (BILLED_UPTO_FIELD), not from each invoice's due date, so no period is billed -# twice. An unpaid fee invoice is itself an overdue receivable and accrues on -# the same terms as any other, so interest compounds onto the fee balance. - -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): - """Submitted late-fee Sales Invoices for a customer/company, newest first. - - `distinct` matters: an amended fee invoice carries one item row per period - billed, so the join would otherwise return it several times. - """ - if not fee_item: - return [] - return frappe.db.sql( - """ - select distinct si.name, si.posting_date, si.due_date, si.debit_to, - si.outstanding_amount, si.creation, si.{billed_upto} as billed_upto - 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 - order by si.posting_date desc, si.creation desc - """.format(billed_upto=BILLED_UPTO_FIELD), - (customer, company, fee_item), - as_dict=True, - ) - - -def _last_billed_upto(fee_invoices): - """Date late-fee interest was last charged, or None if it never has been. - - Fee invoices raised before the marker field existed fall back to their - posting date, which is exactly when they were billed. - """ - dates = [getdate(fi.billed_upto or fi.posting_date) for fi in fee_invoices] - return max(dates) if dates else None - - -def _open_fee_invoice(fee_invoices): - """The most recent fee invoice still carrying a balance, or None.""" - for fi in fee_invoices: - if flt(fi.outstanding_amount) > 0: - return fi - return None - - -def _accrued_interest(overdue_invoices, rate_of_interest, last_billed_upto): - """Interest accrued since the last fee run (or since each invoice fell due). - - Charging on `days_overdue` every run would re-bill every period already - paid for, so each invoice accrues only from whichever is later: its due date - or the last time a fee was charged. - - An unpaid late-fee invoice is an overdue receivable like any other and is - charged on the same terms — its balance accrues interest too, which then - lands back on the invoice carrying it. - """ - today = getdate(nowdate()) - daily_interest = flt(rate_of_interest) / 100.0 / 365.0 - interest = 0.0 - for inv in overdue_invoices: - accrue_from = getdate(inv["due_date"]) - if last_billed_upto and last_billed_upto > accrue_from: - accrue_from = last_billed_upto - days = (today - accrue_from).days - if days > 0: - interest += flt(inv["outstanding_amount"]) * daily_interest * days - return interest - - -def _amend_blockers(fee_invoice): - """Reasons this fee invoice cannot safely be cancelled and re-raised. - - Amending means cancelling, and cancelling is only reversible for the links - we know how to restore (plain Payment Entry allocations). Anything else is - left alone and billed on a fresh invoice instead. - """ - reasons = [] - - frozen = frappe.db.get_single_value("Accounts Settings", "acc_frozen_upto") - if frozen and getdate(fee_invoice.posting_date) <= getdate(frozen): - reasons.append(_("its posting date falls in a frozen accounting period")) - - if frappe.db.exists( - "Journal Entry Account", - {"reference_type": "Sales Invoice", "reference_name": fee_invoice.name, "docstatus": 1}, - ): - reasons.append(_("a Journal Entry is applied against it")) - - if frappe.db.exists( - "Sales Invoice", {"return_against": fee_invoice.name, "docstatus": 1} - ): - reasons.append(_("a credit note is applied against it")) - - if frappe.db.exists( - "Payment Entry Reference", - { - "reference_doctype": "Sales Invoice", - "reference_name": fee_invoice.name, - "docstatus": 1, - "allocated_amount": ("<", 0), - }, - ): - reasons.append(_("a payment allocates a negative amount to it")) - - return reasons - - -def _payment_allocations(invoice_name): - """Submitted Payment Entry allocations against an invoice, one row per entry. - - Grouped per payment because re-linking consumes a payment's unallocated - balance in one go; two rows for the same entry would double-count it. - """ - return frappe.db.sql( - """ - select pe.name as payment_entry, pe.party_type, pe.party, - sum(per.allocated_amount) as allocated_amount, - max(per.account) as account - from `tabPayment Entry Reference` per - inner join `tabPayment Entry` pe on pe.name = per.parent - where per.reference_doctype = 'Sales Invoice' - and per.reference_name = %s - and per.docstatus = 1 and pe.docstatus = 1 - and per.allocated_amount > 0 - group by pe.name, pe.party_type, pe.party - """, - invoice_name, - as_dict=True, - ) - - -def _relink_payments(amended, allocations): - """Re-apply payments freed by the cancellation onto the amended invoice. - - Cancelling unlinks the payments, leaving them sitting as unallocated cash on - their Payment Entries; this puts them back so the amended invoice shows the - balance the customer actually owes. `reconcile_against_document` is the same - primitive the Payment Reconciliation tool uses, so the ledger and the - invoice's outstanding amount are reposted the standard way. - """ - from erpnext.accounts.utils import reconcile_against_document - - company_currency = frappe.get_cached_value("Company", amended.company, "default_currency") - in_company_currency = amended.party_account_currency == company_currency - remaining = flt(amended.outstanding_amount) - - args = [] - for alloc in allocations: - if remaining <= 0: - break - # The freed cash sits in unallocated_amount; that total is also what - # ERPNext validates the allocation against. - unallocated = flt( - frappe.db.get_value("Payment Entry", alloc.payment_entry, "unallocated_amount") - ) - amount = min(flt(alloc.allocated_amount), unallocated, remaining) - if amount <= 0: - continue - args.append( - frappe._dict( - { - "voucher_type": "Payment Entry", - "voucher_no": alloc.payment_entry, - "voucher_detail_no": None, - "against_voucher_type": "Sales Invoice", - "against_voucher": amended.name, - "account": alloc.account or amended.debit_to, - "party_type": alloc.party_type, - "party": alloc.party, - "is_advance": "No", - "dr_or_cr": "credit_in_account_currency", - "unadjusted_amount": unallocated, - "allocated_amount": amount, - "exchange_rate": 1 if in_company_currency else amended.conversion_rate, - "grand_total": ( - amended.base_grand_total if in_company_currency else amended.grand_total - ), - "outstanding_amount": remaining, - "difference_account": frappe.get_cached_value( - "Company", amended.company, "exchange_gain_loss_account" - ), - } - ) - ) - remaining -= amount - - if args: - reconcile_against_document(args) - - -def _amend_fee_invoice(fee_invoice, charge, billed_upto): - """Add a charge to an open fee invoice by amending it; return the new name. - - The whole sequence runs inside the caller's transaction, so a failure - anywhere rolls back the unlink and the cancellation with it. - """ - from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries - - allocations = _payment_allocations(fee_invoice.name) - doc = frappe.get_doc("Sales Invoice", fee_invoice.name) - - # Unlinking and reconciling narrate themselves with msgprint dialogs. A run - # covering fifty customers would bury the user in them, and the timeline - # comment already records what happened. - muted = frappe.flags.mute_messages - frappe.flags.mute_messages = True - try: - if allocations: - unlink_ref_doc_from_payment_entries(doc) - doc.cancel() - - amended = frappe.copy_doc(doc, ignore_no_copy=False) - amended.amended_from = doc.name - amended.naming_series = LATE_FEE_NAMING_SERIES - # Same debt, so the original dates stand. Re-dating to today would - # reset the invoice to "Current" on the statement's aging buckets, which - # read from due_date, and hide how long the balance has been owed. - amended.set_posting_time = 1 - amended.posting_date = doc.posting_date - amended.posting_time = doc.posting_time - amended.due_date = doc.due_date - amended.set(BILLED_UPTO_FIELD, billed_upto) - amended.append("items", charge) - amended.insert(ignore_permissions=True) - amended.submit() - - if allocations: - amended.reload() - _relink_payments(amended, allocations) - finally: - frappe.flags.mute_messages = muted - - return amended.name - - -def _new_fee_invoice(customer, company, settings, charge, billed_upto): - """Raise a fresh late-fee Sales Invoice; return its name.""" - 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.set(BILLED_UPTO_FIELD, billed_upto) - si.append("items", charge) - # 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() - return si.name - - -def _post_late_fee_invoice(customer, company, overdue_invoices, period): - """Bill late-payment interest for one customer/company (once per month). - - Tops up the customer's open fee invoice where there is one, otherwise raises - a new one. 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) - last_billed = _last_billed_upto(fee_invoices) - open_invoice = _open_fee_invoice(fee_invoices) - - # Idempotency: at most one charge per (customer, company, month). - month_start = getdate(period + "-01") - if last_billed and last_billed >= month_start: - return open_invoice.name if open_invoice else None - - today = getdate(nowdate()) - interest = _accrued_interest(overdue_invoices, settings.rate_of_interest, last_billed) - # The flat dunning fee is a one-off for falling into collections, charged - # when the fee invoice is raised — not again every time it is topped up. - amount = round(interest if open_invoice else interest + flt(settings.dunning_fee), 2) - if amount <= 0: - return None - - charge = { - "item_code": fee_item, - "qty": 1, - "rate": amount, - "income_account": settings.income_account, - "cost_center": settings.cost_center - or frappe.get_cached_value("Company", company, "cost_center"), - "description": _("Late payment fee for statement period {0}").format(period), - } - - if open_invoice: - blockers = _amend_blockers(open_invoice) - if not blockers: - name = _amend_fee_invoice(open_invoice, charge, today) - frappe.db.commit() - return name - _note_amend_skipped(customer, open_invoice.name, blockers) - - name = _new_fee_invoice(customer, company, settings, charge, today) - frappe.db.commit() - return name - - -def _note_amend_skipped(customer, fee_invoice, reasons): - """Record why an open fee invoice was left alone and a new one raised.""" - frappe.get_doc("Customer", customer).add_comment( - "Info", - _("Late fee {0} could not be amended ({1}); the charge was billed on a new invoice.").format( - fee_invoice, ", ".join(reasons) - ), - ) - - -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 charged on {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 (once per - customer per month) for each customer with overdue invoices — added to their - open fee invoice if they have one, otherwise raised as a new Sales Invoice. - 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 57f47f9..c4fe7c6 100644 --- a/ns_app/hooks.py +++ b/ns_app/hooks.py @@ -7,8 +7,7 @@ app_license = "MIT" # Load on every page app_include_js = [ - "/assets/ns_app/js/customer_quick_entry.js", - "/assets/ns_app/js/customer_statements.js" + "/assets/ns_app/js/customer_quick_entry.js" ] # Load on Sales Invoice form @@ -16,14 +15,6 @@ 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 deleted file mode 100644 index ac239ed..0000000 --- a/ns_app/public/js/customer_list.js +++ /dev/null @@ -1,20 +0,0 @@ -// 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 deleted file mode 100644 index 59a0bb5..0000000 --- a/ns_app/public/js/customer_statements.js +++ /dev/null @@ -1,200 +0,0 @@ -// 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 deleted file mode 100644 index 4535729..0000000 --- a/ns_app/setup.py +++ /dev/null @@ -1,64 +0,0 @@ -"""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 BILLED_UPTO_FIELD, 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." - ), - } - ], - "Sales Invoice": [ - { - "fieldname": BILLED_UPTO_FIELD, - "label": "Late Fee Billed Upto", - "fieldtype": "Date", - "insert_after": "due_date", - "read_only": 1, - "print_hide": 1, - # Must survive frappe.copy_doc() when the invoice is amended to add - # another period's interest — it is what stops that period being - # billed twice. - "no_copy": 0, - "description": ( - "On a late-fee invoice, the date interest was last charged. The " - "next statement run accrues interest from here." - ), - } - ], -} - - -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 deleted file mode 100644 index 6661727..0000000 --- a/ns_app/templates/statements/customer_statement.html +++ /dev/null @@ -1,95 +0,0 @@ -{# 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) }}
- - - -
-