16 Commits

Author SHA1 Message Date
192af42614 fix(app): add modules.txt/patches.txt so bench keeps ns_app in apps.txt
ns_app was hand-created without the standard scaffolding files, so it was
missing modules.txt and patches.txt. bench's is_frappe_app() check requires
all of hooks.py, modules.txt, and patches.txt to exist; BenchApps.sync()
rebuilds sites/apps.txt from the folders that pass that check. sync() runs on
bench get-app, install-app (of any app), update, etc., so every such command
silently dropped ns_app from apps.txt.

Once ns_app was missing from apps.txt, `bench build --app ns_app` crashed
(esbuild get_public_path returned undefined), the sites/assets/ns_app symlink
was never created, and /assets/ns_app/js/customer_statements.js 404'd. That
left ns_statements undefined, so the Customer "Generate Statements" button's
handler did nothing and the statement screen never opened on production.

Add modules.txt ("NS App", matching app_title), an empty patches.txt, and the
"NS App" module package folder so bench recognizes ns_app as a Frappe app and
stops removing it. Run `bench --site <site> migrate` to register the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:02:53 -04:00
41d3fec08c fix(statements): raise customer address window 0.5in
Move the customer address window up to 2.5in per printed proof, and
pull the body padding-top back up to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:20:44 -04:00
ccc3fac69d fix(statements): nudge envelope windows to printed-proof positions
Company (return) address down 0.25in (to 0.8in) and customer address
down 1.5in (to 3.0in) to line up with the #9 double-window envelope,
per a printed proof. Push the body padding-top to clear the lower
customer window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:06:37 -04:00
0aefdeb5ff fix(statements): set envelope geometry for #9 double-window
Reset the statement window positions to the app's proven #9 (9x4)
double-window geometry, mirroring sales_invoice_ns.html — recipient
window at top:1.5in/left:1.125in. It previously copied the dunning
format's 1.9in, which sits too low for a #9. Tighten the body
padding-top to keep clearance below the higher window, and correct the
stale #10 references in the template comment and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:52:07 -04:00
326adf865a fix(statements): restore Customer list button (merge listview_settings)
The list button was registered by reassigning
frappe.listview_settings['Customer'] in a globally-loaded script, but
ERPNext's own Customer list_js (loaded when the list opens) overwrote it,
so the button never appeared. Register it via doctype_list_js instead —
which Frappe appends after the doctype's own list_js — and merge into the
existing settings (wrapping onload, preserving ERPNext's add_fields)
rather than reassigning. The form button and shared ns_statements helpers
stay in customer_statements.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:36:21 -04:00
5181f4a177 docs: describe the customer statements feature
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:58:09 -04:00
46967208e9 feat(statements): Customer-form button, consolidated UI, disable-fee toggle
Replace customer_list.js with customer_statements.js (loaded globally),
which adds the statement UI to both entry points:
- Customer list: 'Generate Statements' multi-select action.
- Customer form: 'Generate Statement' button for a single customer.

Both open a popup with a 'Generate late payment fee' checkbox (default on)
that maps to generate_statements(skip_late_fee), so fee billing can be
turned off per run. Shared generate/print helpers live on ns_statements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:52:15 -04:00
c20dd18287 feat(statements): untaxed late-fee series, skip-fee option, audit trail
- Bill late-fee invoices under a dedicated naming series (LPF-.YYYY.-),
  registered on Sales Invoice via after_migrate, so they are easy to spot
  and filter.
- Late fees are never taxed: a zero 'Actual' tax line keeps the taxes
  table non-empty so ERPNext skips auto-applying company/item tax
  templates (posts nothing to the ledger). Fee total == computed fee.
- generate_statements(skip_late_fee=...) generates a statement without
  billing a fee.
- Record every generation on the customer's timeline (add_comment) as an
  audit trail, noting Total Due and the fee invoice raised / skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:52:03 -04:00
acd7df1129 feat(statements): bill late fee as a collectible Sales Invoice
Charge a late-payment fee when statements are generated so the customer's
receivable reflects it (the gap ERPNext Dunning leaves — it never
increases AR). The fee is billed as a submitted Sales Invoice (item ->
Dunning Type income account, rate = computed fee) rather than a Journal
Entry, so the app's existing payment flow (Run Payment / AutoPay /
multi-invoice) charges and settles it automatically via its Sales Invoice
references — a bare JE would sit uncollected.

Fee schedule/amounts come from the existing Dunning Type settings (yearly
rate_of_interest + flat dunning_fee), interest computed with ERPNext's own
Dunning formula. The fee Item is configured via a new Late Fee Item custom
field on Dunning Type (created in an after_migrate hook; ns_app/setup.py).
Nothing is auto-seeded: generation stops with a clear error if no Dunning
Type is configured or its income account / fee item is unset.

Billing is idempotent per customer/company/month, and prior fee invoices
are excluded from the interest base (no fee-on-fee). The fee invoice shows
on the statement flagged 'late fee', folded into Total Due (which equals
the customer's balance and is fully collectible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:35:04 -04:00
9e2e86cead feat(statements): add Customer list "Generate Statements" button
Register doctype_list_js for Customer and add customer_list.js, which
lists customers with overdue invoices in a selection dialog (checkbox
table + select-all), then calls generate_statements and opens the
printable, one-page-per-customer document in a new window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:08:14 -04:00
75a9c9d154 feat(statements): statement builder + printable envelope template
Add get_statement_data (open invoices, aging buckets, totals, formatted
customer + company addresses) and generate_statements, which renders one
page per customer via a Jinja template and returns a printable HTML
document. Recipient window geometry (top:1.9in/left:1.125in) mirrors the
existing double-window print formats for #10 envelope compatibility; each
page uses page-break-after:always. Late fee is a zero placeholder here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:04:47 -04:00
63bf0b68f5 feat(statements): add overdue-customers query API
Add ns_app/api/statements.py with get_customers_with_overdue_invoices
(one aggregated row per customer with overdue Sales Invoices) plus the
_get_outstanding_invoices / _aging_bucket helpers used to build the
per-customer statement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:01:03 -04:00
790d4d0e9d Merge pull request 'main' (#17) from main into production
Reviewed-on: #17
2026-06-15 14:14:35 +00:00
87f40e6b83 Merge pull request 'add autopay and fix customer entry form.' (#13) from main into production
Reviewed-on: #13
2026-05-18 15:27:57 +00:00
61ac7f563c Merge pull request 'update customer quick entry form and field descriptions' (#12) from main into production
Reviewed-on: #12
2026-05-07 23:47:50 +00:00
f89a672334 Merge pull request 'Added route options to monitor and auto populate fields based off where the quick entry form was spawned from.' (#9) from main into production
Reviewed-on: #9
2026-05-06 09:29:44 +00:00
10 changed files with 1005 additions and 1 deletions

135
docs/CUSTOMER_STATEMENTS.md Normal file
View File

@@ -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 / 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:
```
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 <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.

498
ns_app/api/statements.py Normal file
View File

@@ -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"""<!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.
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}

View File

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

1
ns_app/modules.txt Normal file
View File

@@ -0,0 +1 @@
NS App

View File

0
ns_app/patches.txt Normal file
View File

View File

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

View File

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

46
ns_app/setup.py Normal file
View File

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

View File

@@ -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 %}
<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 -->