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>
This commit is contained in:
2026-07-08 19:04:47 -04:00
parent 63bf0b68f5
commit 75a9c9d154
2 changed files with 268 additions and 1 deletions

View File

@@ -6,9 +6,12 @@ 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.utils import getdate, nowdate
from frappe.contacts.doctype.address.address import get_address_display, get_default_address
from frappe.utils import flt, getdate, nowdate
# Roles allowed to run collections/statement actions.
ALLOWED_ROLES = [
@@ -105,3 +108,167 @@ def _get_outstanding_invoices(customer):
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):
"""Assemble everything the statement template needs for one customer."""
cust = frappe.get_doc("Customer", customer)
invoices = _get_outstanding_invoices(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_outstanding = 0.0
for inv in invoices:
aging[inv["aging_bucket"]] += flt(inv["outstanding_amount"])
total_outstanding += flt(inv["outstanding_amount"])
# Late fee is booked and populated by generate_statements (later commit);
# get_statement_data on its own reports a zero fee.
late_fee = 0.0
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_outstanding": total_outstanding,
"late_fee": late_fee,
"total_due": total_outstanding + late_fee,
"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; }}
.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.9in; left: 1.125in;
width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em;
overflow: hidden;
}}
.statement-body {{ padding: 3.35in 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; }}
.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>"""
@frappe.whitelist()
def generate_statements(customers):
"""Render printable statements (one page per customer) for the selection.
`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"))
pages, rendered, skipped = [], [], []
for customer in customers:
data = get_statement_data(customer)
if not data["invoices"]:
skipped.append(customer)
continue
pages.append(_render_page(data))
rendered.append(customer)
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

@@ -0,0 +1,100 @@
{# One customer account statement = one printed page.
Envelope geometry (recipient window at top:1.9in / left:1.125in) mirrors the
existing double-window print formats so the same #10 double-window envelopes
work. 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 }}</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><span>Total Outstanding:</span><span>{{ fmt(s.total_outstanding, currency=s.currency) }}</span></p>
{% if s.late_fee and s.late_fee > 0 %}
<p><span>Late Payment Fee:</span><span>{{ fmt(s.late_fee, currency=s.currency) }}</span></p>
{% endif %}
<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 -->