Files
ns_erpnext_app/ns_app/api/statements.py
Norman King 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

500 lines
17 KiB
Python

"""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 match the app's proven #9 (9x4) double-window envelope
geometry — recipient window at top:1.5in/left:1.125in, mirroring
sales_invoice_ns.html ("9x4 envelope position"). */
.return-window {{
position: absolute; top: 0.55in; left: 0.6in;
width: 3.5in; font-size: 11px; line-height: 1.3;
}}
.doc-header {{
position: absolute; top: 0.55in; right: 0.6in;
width: 3in; text-align: right; font-size: 13px; line-height: 1.5;
}}
.doc-header .doc-title {{ font-size: 20px; font-weight: bold; letter-spacing: 1px; }}
.recipient-window {{
position: absolute; top: 1.5in; left: 1.125in;
width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em;
overflow: hidden;
}}
.statement-body {{ padding: 2.95in 0.6in 0.6in 0.6in; }}
.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}