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 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 04:53:43 -04:00
parent 48eb1b86a9
commit 1784d3819b
7 changed files with 1 additions and 1325 deletions

View File

@@ -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 / 130 / 3160 / 6190 / 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 <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.

View File

@@ -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"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Customer Statements</title>
<style>
@page {{ size: Letter; margin: 0; }}
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: Helvetica, Arial, sans-serif; color: #333; }}
.toolbar {{ text-align: center; padding: 12px; background: #f5f5f5; }}
.toolbar button {{ font-size: 14px; padding: 8px 20px; cursor: pointer; }}
.statement-page {{
position: relative;
width: 8.5in;
min-height: 11in;
margin: 0 auto;
padding: 0;
page-break-after: always;
overflow: hidden;
}}
.statement-page:last-of-type {{ page-break-after: auto; }}
/* Window positions are field-tuned to the #9 (9x4) double-window envelope
(verified against a printed proof). */
.return-window {{
position: absolute; top: 0.8in; left: 0.6in;
width: 3.5in; font-size: 11px; line-height: 1.3;
}}
.doc-header {{
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: 2.5in; left: 1.125in;
width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em;
overflow: hidden;
}}
.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,
table.aging th, table.aging td {{
border: 1px solid #ccc; padding: 6px; font-size: 13px;
}}
table.items th, table.aging th {{ background: #f5f5f5; text-align: left; }}
.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 {{
border-top: 2px solid #333; padding-top: 6px; font-weight: bold; font-size: 16px;
}}
table.aging {{ margin-top: 8px; }}
.footer {{
margin-top: 24px; font-size: 10px; color: #777; text-align: center;
white-space: pre-line;
}}
@media print {{ .toolbar {{ display: none; }} }}
</style>
</head>
<body>
<div class="toolbar">
<button onclick="window.print()">Print Statements</button>
</div>
{body}
</body>
</html>"""
# ── 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}

View File

@@ -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 = [
{

View File

@@ -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();
});
};
})();

View File

@@ -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 => `
<tr>
<td class="text-center">
<input type="checkbox" class="cust-check-${uid}"
data-name="${frappe.utils.escape_html(r.customer)}" checked>
</td>
<td>${frappe.utils.escape_html(r.customer_name || r.customer)}</td>
<td class="text-center">${r.overdue_count}</td>
<td class="text-center">${r.max_days_overdue}</td>
<td class="text-right">${format_currency(r.total_outstanding)}</td>
</tr>`).join("");
const dialog = new frappe.ui.Dialog({
title: __("Generate Customer Statements"),
size: "large",
fields: [
{
fieldtype: "HTML",
fieldname: "selector",
options: `
<div style="max-height:45vh; overflow:auto;">
<table class="table table-bordered table-sm" style="font-size:13px; margin:0;">
<thead style="position:sticky; top:0; background:#f5f5f5;">
<tr>
<th style="width:36px;">
<input type="checkbox" id="sel_all_${uid}" title="${__("Select all")}" checked>
</th>
<th>${__("Customer")}</th>
<th class="text-center">${__("Overdue Invoices")}</th>
<th class="text-center">${__("Max Days Overdue")}</th>
<th class="text-right">${__("Total Outstanding")}</th>
</tr>
</thead>
<tbody id="sel_body_${uid}">${body}</tbody>
</table>
</div>
<div id="sel_count_${uid}" style="margin-top:8px; font-weight:bold;"></div>`
},
{
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: `<p>${__("Generate an account statement for <b>{0}</b>.", [frappe.utils.escape_html(customer)])}</p>`
},
{
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();
};

View File

@@ -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()

View File

@@ -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 %}
<div class="statement-page">
<!-- Return address (top-left envelope window) -->
<div class="return-window">
<strong>{{ s.company_name }}</strong><br>
{{ s.return_address | safe }}
</div>
<!-- Document header (top-right) -->
<div class="doc-header">
<div class="doc-title">STATEMENT</div>
<div><strong>Date:</strong> {{ frappe.utils.formatdate(s.statement_date, "MM-dd-yyyy") }}</div>
<div><strong>Account:</strong> {{ s.customer }}</div>
</div>
<!-- Recipient address (lower envelope window) -->
<div class="recipient-window">
{{ s.customer_name }}<br>
{{ s.customer_address | safe }}
</div>
<!-- Statement body (starts below the address windows) -->
<div class="statement-body">
<div class="intro">
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.
</div>
<table class="items">
<thead>
<tr>
<th>Invoice</th>
<th class="c">Date</th>
<th class="c">Due Date</th>
<th class="c">Days Overdue</th>
<th class="c">Aging</th>
<th class="r">Outstanding</th>
</tr>
</thead>
<tbody>
{% for inv in s.invoices %}
<tr class="{{ 'overdue' if inv.is_overdue else '' }}">
<td>{{ inv.name }}{% if inv.is_late_fee %} <span class="tag">late fee</span>{% endif %}</td>
<td class="c">{{ frappe.utils.formatdate(inv.posting_date, "MM-dd-yyyy") }}</td>
<td class="c">{{ frappe.utils.formatdate(inv.due_date, "MM-dd-yyyy") }}</td>
<td class="c">{{ inv.days_overdue if inv.days_overdue else "—" }}</td>
<td class="c">{{ inv.aging_bucket }}</td>
<td class="r">{{ fmt(inv.outstanding_amount, currency=s.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div class="totals">
<p class="grand"><span>Total Due:</span><span>{{ fmt(s.total_due, currency=s.currency) }}</span></p>
</div>
<!-- Aging summary -->
<table class="aging">
<thead>
<tr>
<th class="c">Current</th>
<th class="c">130</th>
<th class="c">3160</th>
<th class="c">6190</th>
<th class="c">90+</th>
</tr>
</thead>
<tbody>
<tr>
<td class="c">{{ fmt(s.aging["Current"], currency=s.currency) }}</td>
<td class="c">{{ fmt(s.aging["1-30"], currency=s.currency) }}</td>
<td class="c">{{ fmt(s.aging["31-60"], currency=s.currency) }}</td>
<td class="c">{{ fmt(s.aging["61-90"], currency=s.currency) }}</td>
<td class="c">{{ fmt(s.aging["90+"], currency=s.currency) }}</td>
</tr>
</tbody>
</table>
<div class="footer">
Prompt payment is always appreciated. We accept payments by check or over
the phone using a debit or credit card. Automatic payment setup is also
available upon request. Please contact us if payment has already been sent.
</div>
</div><!-- /statement-body -->
</div><!-- /statement-page -->