From 63bf0b68f53415f048a954a1f6e6fa52722b5497 Mon Sep 17 00:00:00 2001 From: Norman King Date: Wed, 8 Jul 2026 19:01:03 -0400 Subject: [PATCH] feat(statements): add overdue-customers query API Add ns_app/api/statements.py with get_customers_with_overdue_invoices (one aggregated row per customer with overdue Sales Invoices) plus the _get_outstanding_invoices / _aging_bucket helpers used to build the per-customer statement. Co-Authored-By: Claude Opus 4.8 --- ns_app/api/statements.py | 107 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 ns_app/api/statements.py diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py new file mode 100644 index 0000000..1e121b5 --- /dev/null +++ b/ns_app/api/statements.py @@ -0,0 +1,107 @@ +"""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 frappe +from frappe import _ +from frappe.utils import 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