diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index 1e121b5..252194c 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -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""" + + + +Customer Statements + + + +
+ +
+ {body} + +""" + + +@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} diff --git a/ns_app/templates/statements/customer_statement.html b/ns_app/templates/statements/customer_statement.html new file mode 100644 index 0000000..54fa7b1 --- /dev/null +++ b/ns_app/templates/statements/customer_statement.html @@ -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 %} +
+ + +
+ {{ s.company_name }}
+ {{ s.return_address | safe }} +
+ + +
+
STATEMENT
+
Date: {{ frappe.utils.formatdate(s.statement_date, "MM-dd-yyyy") }}
+
Account: {{ s.customer }}
+
+ + +
+ {{ s.customer_name }}
+ {{ s.customer_address | safe }} +
+ + +
+ +
+ 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. +
+ + + + + + + + + + + + + + {% for inv in s.invoices %} + + + + + + + + + {% endfor %} + +
InvoiceDateDue DateDays OverdueAgingOutstanding
{{ inv.name }}{{ frappe.utils.formatdate(inv.posting_date, "MM-dd-yyyy") }}{{ frappe.utils.formatdate(inv.due_date, "MM-dd-yyyy") }}{{ inv.days_overdue if inv.days_overdue else "—" }}{{ inv.aging_bucket }}{{ fmt(inv.outstanding_amount, currency=s.currency) }}
+ + +
+

Total Outstanding:{{ fmt(s.total_outstanding, currency=s.currency) }}

+ {% if s.late_fee and s.late_fee > 0 %} +

Late Payment Fee:{{ fmt(s.late_fee, currency=s.currency) }}

+ {% endif %} +

Total Due:{{ fmt(s.total_due, currency=s.currency) }}

+
+ + + + + + + + + + + + + + + + + + + + + +
Current1–3031–6061–9090+
{{ fmt(s.aging["Current"], currency=s.currency) }}{{ fmt(s.aging["1-30"], currency=s.currency) }}{{ fmt(s.aging["31-60"], currency=s.currency) }}{{ fmt(s.aging["61-90"], currency=s.currency) }}{{ fmt(s.aging["90+"], currency=s.currency) }}
+ + + +
+