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>
275 lines
8.6 KiB
Python
275 lines
8.6 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, getdate, nowdate
|
|
|
|
# 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):
|
|
"""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}
|