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:
@@ -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}
|
||||
|
||||
100
ns_app/templates/statements/customer_statement.html
Normal file
100
ns_app/templates/statements/customer_statement.html
Normal 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">1–30</th>
|
||||
<th class="c">31–60</th>
|
||||
<th class="c">61–90</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 -->
|
||||
Reference in New Issue
Block a user