Compare commits
18 Commits
main
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| a986348de9 | |||
| 192af42614 | |||
| 1b41fb0845 | |||
| 41d3fec08c | |||
| ccc3fac69d | |||
| 0aefdeb5ff | |||
| 326adf865a | |||
| 5181f4a177 | |||
| 46967208e9 | |||
| c20dd18287 | |||
| acd7df1129 | |||
| 9e2e86cead | |||
| 75a9c9d154 | |||
| 63bf0b68f5 | |||
| 790d4d0e9d | |||
| 87f40e6b83 | |||
| 61ac7f563c | |||
| f89a672334 |
135
docs/CUSTOMER_STATEMENTS.md
Normal file
135
docs/CUSTOMER_STATEMENTS.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Customer Statements & Late Payment Fees
|
||||
|
||||
> Branch: `feature/customer-statements`
|
||||
|
||||
Generates printable, **one-page-per-customer** account statements — formatted to
|
||||
fit a standard #9 (9x4) **double-window envelope** — for customers with overdue
|
||||
invoices, and (optionally) bills a **late-payment fee** that posts to the ledger
|
||||
and is collectible through the app's existing payment flow.
|
||||
|
||||
---
|
||||
|
||||
## 1. What it does
|
||||
|
||||
From either the **Customer list** or a **Customer form**, a user can generate
|
||||
account statements:
|
||||
|
||||
1. **Pick customers.** On the list, *Generate Statements* opens a dialog listing
|
||||
every customer with overdue invoices (overdue count, max days overdue, total
|
||||
outstanding) with select-all. On a Customer form, *Generate Statement* targets
|
||||
that one customer.
|
||||
2. **Choose whether to bill a late fee** via a checkbox in the popup
|
||||
(*Generate late payment fee*, on by default).
|
||||
3. **Get a printable report.** A new browser tab opens with one statement per
|
||||
page — each showing the customer's open invoices, aging buckets
|
||||
(Current / 1–30 / 31–60 / 61–90 / 90+), and a **Total Due**. The customer and
|
||||
company (return) addresses sit in the two envelope-window positions.
|
||||
|
||||
Each generation is recorded on the customer's timeline as an audit-trail entry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Late payment fees
|
||||
|
||||
### How the fee is calculated
|
||||
Interest uses ERPNext's own Dunning formula:
|
||||
|
||||
```
|
||||
fee = Σ(invoice.outstanding × rate_of_interest/100/365 × days_overdue) + dunning_fee
|
||||
```
|
||||
|
||||
### Where the settings live — ERPNext **Dunning Type**
|
||||
All fee configuration comes from the existing **Dunning Type** doctype
|
||||
(Accounting ▸ Dunning Type). Nothing is auto-created; generation stops with a
|
||||
clear error until it is configured. The default (`is_default`) Dunning Type for
|
||||
the company is used. Fields consumed:
|
||||
|
||||
| Dunning Type field | Purpose |
|
||||
|--------------------|---------|
|
||||
| `rate_of_interest` | Annual interest rate (%) |
|
||||
| `dunning_fee` | Flat fee added per statement |
|
||||
| `income_account` | Credited when the fee is billed |
|
||||
| `cost_center` | Cost center for the fee line (falls back to company default) |
|
||||
| `custom_late_fee_item` | **Late Fee Item** — the Item used to bill the fee (custom field added by this app) |
|
||||
|
||||
### How the fee posts, and how it gets paid
|
||||
The fee is billed as a **submitted Sales Invoice** (item → the Dunning Type
|
||||
income account). This is deliberate: because it is a real Sales Invoice it
|
||||
|
||||
- increases the customer's receivable balance immediately, and
|
||||
- appears in `get_unpaid_invoices` and is charged/settled automatically by the
|
||||
app's existing payment paths (**Run Payment / AutoPay / multi-invoice** →
|
||||
`create_payment_entry`), which allocate against Sales Invoices.
|
||||
|
||||
A bare Journal Entry (or an ERPNext Dunning document) would raise the balance but
|
||||
sit **uncollectible** by those flows — hence the Sales Invoice.
|
||||
|
||||
### Fee invoice specifics
|
||||
- **Dedicated naming series `LPF-.YYYY.-`** (e.g. `LPF-2026-00001`) so late-fee
|
||||
invoices are easy to spot and filter. Registered on Sales Invoice's
|
||||
`naming_series` via `after_migrate`.
|
||||
- **Never taxed.** A single zero-amount "Actual" tax line keeps ERPNext from
|
||||
auto-applying company/item tax templates, so the invoice total equals the
|
||||
computed fee exactly and nothing extra hits the ledger.
|
||||
- **Idempotent** — at most one fee invoice per customer / company / calendar
|
||||
month. Prior fee invoices are excluded from the interest base (no fee-on-fee).
|
||||
|
||||
On the statement the fee shows as a normal invoice line tagged **“late fee”**,
|
||||
folded into a single **Total Due** that equals the customer's balance.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration / prerequisites
|
||||
|
||||
1. **Migrate** the app (`bench --site <site> migrate`) — creates the
|
||||
`Late Fee Item` custom field on Dunning Type and registers the `LPF-` series.
|
||||
2. Create an **Item** to represent the fee (a non-stock sales item, e.g.
|
||||
"Late Payment Fee").
|
||||
3. Create/complete a **Dunning Type** for the company with: rate of interest,
|
||||
dunning fee, **income account**, and the **Late Fee Item**. Mark it default.
|
||||
|
||||
If any of these is missing, statement generation throws a clear, actionable
|
||||
error and posts nothing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Audit trail
|
||||
|
||||
Each generated statement adds an *Info* comment to the customer's timeline, e.g.
|
||||
|
||||
> Statement generated — Total Due $557.17 (late fee invoice LPF-2026-00001).
|
||||
|
||||
The note reflects the outcome: the fee invoice raised, *no late fee*, or
|
||||
*late fee skipped* (when the fee checkbox was cleared). It is attributed to the
|
||||
generating user.
|
||||
|
||||
---
|
||||
|
||||
## 5. Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `ns_app/api/statements.py` | Overdue-customer query, statement builder, printable HTML, late-fee billing (Sales Invoice), audit-trail entry |
|
||||
| `ns_app/templates/statements/customer_statement.html` | Jinja template for one customer page (envelope windows + aging table) |
|
||||
| `ns_app/public/js/customer_statements.js` | List action + Customer-form button + selection/fee-toggle popups (loaded globally) |
|
||||
| `ns_app/setup.py` | `after_migrate`: creates the Late Fee Item custom field, registers the `LPF-` naming series |
|
||||
| `ns_app/hooks.py` | Wires the JS (`app_include_js`) and `after_migrate` |
|
||||
|
||||
### Server API (`ns_app.api.statements`)
|
||||
- `get_customers_with_overdue_invoices()` — customers with overdue invoices.
|
||||
- `generate_statements(customers, skip_late_fee=0)` — bills fees (unless skipped),
|
||||
renders the printable HTML, records the audit entry. Returns
|
||||
`{html, rendered, skipped}`.
|
||||
- `get_statement_data(customer)` — the per-customer statement data (internal).
|
||||
|
||||
Access is restricted to System Manager, Sales User/Manager, Accounts
|
||||
User/Manager.
|
||||
|
||||
---
|
||||
|
||||
## 6. Notes / non-goals
|
||||
|
||||
- No persisted "Statement" doctype — statements are generated on demand.
|
||||
- No email/fax delivery — print only.
|
||||
- Whether the fee should be taxed and the interest rate/fee amounts are business
|
||||
settings, controlled entirely through Dunning Type.
|
||||
498
ns_app/api/statements.py
Normal file
498
ns_app/api/statements.py
Normal file
@@ -0,0 +1,498 @@
|
||||
"""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, fmt_money, getdate, nowdate
|
||||
|
||||
# Dedicated naming series so late-fee invoices are easy to spot and filter.
|
||||
LATE_FEE_NAMING_SERIES = "LPF-.YYYY.-"
|
||||
|
||||
# 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, invoices=None):
|
||||
"""Assemble everything the statement template needs for one customer.
|
||||
|
||||
Late-fee charges are billed as Sales Invoices, so they appear in the invoice
|
||||
list like any other open item (flagged `is_late_fee`); there is no separate
|
||||
fee total to add.
|
||||
"""
|
||||
cust = frappe.get_doc("Customer", customer)
|
||||
if invoices is None:
|
||||
invoices = _get_outstanding_invoices(customer)
|
||||
|
||||
fee_names = _late_fee_invoice_names(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_due = 0.0
|
||||
for inv in invoices:
|
||||
inv["is_late_fee"] = inv["name"] in fee_names
|
||||
aging[inv["aging_bucket"]] += flt(inv["outstanding_amount"])
|
||||
total_due += flt(inv["outstanding_amount"])
|
||||
|
||||
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_due": total_due,
|
||||
"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; }}
|
||||
/* Window positions are field-tuned to the #9 (9x4) double-window envelope
|
||||
(verified against a printed proof). */
|
||||
.return-window {{
|
||||
position: absolute; top: 0.8in; left: 0.6in;
|
||||
width: 3.5in; font-size: 11px; line-height: 1.3;
|
||||
}}
|
||||
.doc-header {{
|
||||
position: absolute; top: 0.8in; 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: 2.5in; left: 1.125in;
|
||||
width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em;
|
||||
overflow: hidden;
|
||||
}}
|
||||
.statement-body {{ padding: 3.9in 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; }}
|
||||
.tag {{
|
||||
display: inline-block; font-size: 10px; font-weight: bold; color: #fff;
|
||||
background: #c62828; border-radius: 3px; padding: 1px 5px; vertical-align: middle;
|
||||
}}
|
||||
.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>"""
|
||||
|
||||
|
||||
# ── Late-payment fee (billed as a Sales Invoice on generation) ───────────────
|
||||
#
|
||||
# Fee schedule/amounts come from ERPNext's existing **Dunning Type** settings
|
||||
# (rate_of_interest is a yearly %, plus a flat dunning_fee), editable in the
|
||||
# desk. Interest is computed with ERPNext's own Dunning formula so the numbers
|
||||
# match a Dunning document. The fee is billed as a submitted **Sales Invoice**
|
||||
# (item -> Dunning Type income account) so it both hits the ledger and is
|
||||
# collectible by the app's existing payment flow (Run Payment / AutoPay /
|
||||
# multi-invoice), which settles Sales Invoices.
|
||||
|
||||
DUNNING_TYPE_FIELDS = [
|
||||
"name",
|
||||
"rate_of_interest",
|
||||
"dunning_fee",
|
||||
"income_account",
|
||||
"cost_center",
|
||||
"company",
|
||||
"custom_late_fee_item",
|
||||
]
|
||||
|
||||
|
||||
def _get_fee_settings(company):
|
||||
"""Resolve the Dunning Type used for late fees for a company.
|
||||
|
||||
Nothing is auto-created: the user must configure a Dunning Type (rate of
|
||||
interest, fee, income account) for the company in ERPNext. If none exists we
|
||||
stop with a clear, actionable error rather than inventing default values.
|
||||
"""
|
||||
|
||||
def first(filters):
|
||||
rows = frappe.get_all(
|
||||
"Dunning Type", filters=filters, fields=DUNNING_TYPE_FIELDS, limit=1
|
||||
)
|
||||
return rows[0] if rows else None
|
||||
|
||||
dt = first({"company": company, "is_default": 1}) or first({"company": company})
|
||||
if not dt:
|
||||
frappe.throw(
|
||||
_(
|
||||
"No Dunning Type is configured for {0}. Create one under "
|
||||
"Accounting > Dunning Type — set the rate of interest, dunning "
|
||||
"fee, and income account — before generating statements."
|
||||
).format(company)
|
||||
)
|
||||
return dt
|
||||
|
||||
|
||||
def _late_fee_period():
|
||||
"""Statement period key used for idempotency (one fee per calendar month)."""
|
||||
return getdate(nowdate()).strftime("%Y-%m")
|
||||
|
||||
|
||||
def _get_fee_invoices(customer, company, fee_item):
|
||||
"""Return submitted late-fee Sales Invoices for a customer (by fee item)."""
|
||||
if not fee_item:
|
||||
return []
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select si.name, si.posting_date
|
||||
from `tabSales Invoice` si
|
||||
inner join `tabSales Invoice Item` sii on sii.parent = si.name
|
||||
where si.customer = %s and si.company = %s and si.docstatus = 1
|
||||
and sii.item_code = %s
|
||||
""",
|
||||
(customer, company, fee_item),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
|
||||
def _post_late_fee_invoice(customer, company, overdue_invoices, period):
|
||||
"""Bill a late fee as a submitted Sales Invoice (idempotent per month).
|
||||
|
||||
Returns the fee invoice name, or None if nothing was billed.
|
||||
"""
|
||||
if not overdue_invoices:
|
||||
return None
|
||||
|
||||
settings = _get_fee_settings(company)
|
||||
fee_item = settings.get("custom_late_fee_item")
|
||||
if not fee_item:
|
||||
frappe.throw(
|
||||
_("Set a Late Fee Item on Dunning Type {0} before generating statements.").format(
|
||||
settings.name
|
||||
)
|
||||
)
|
||||
if not settings.income_account:
|
||||
frappe.throw(
|
||||
_("Set an Income Account on Dunning Type {0} before generating statements.").format(
|
||||
settings.name
|
||||
)
|
||||
)
|
||||
|
||||
fee_invoices = _get_fee_invoices(customer, company, fee_item)
|
||||
|
||||
# Idempotency: at most one fee invoice per (customer, company, month).
|
||||
month_start = getdate(period + "-01")
|
||||
for fi in fee_invoices:
|
||||
if getdate(fi.posting_date) >= month_start:
|
||||
return fi.name
|
||||
|
||||
# Interest on overdue balances, excluding prior fee invoices (no fee-on-fee).
|
||||
prior_fee_names = {fi.name for fi in fee_invoices}
|
||||
daily_interest = flt(settings.rate_of_interest) / 100.0 / 365.0
|
||||
interest = sum(
|
||||
flt(inv["outstanding_amount"]) * daily_interest * inv["days_overdue"]
|
||||
for inv in overdue_invoices
|
||||
if inv["name"] not in prior_fee_names
|
||||
)
|
||||
fee = round(interest + flt(settings.dunning_fee), 2)
|
||||
if fee <= 0:
|
||||
return None
|
||||
|
||||
cost_center = settings.cost_center or frappe.get_cached_value(
|
||||
"Company", company, "cost_center"
|
||||
)
|
||||
|
||||
si = frappe.new_doc("Sales Invoice")
|
||||
si.naming_series = LATE_FEE_NAMING_SERIES
|
||||
si.customer = customer
|
||||
si.company = company
|
||||
si.posting_date = nowdate()
|
||||
si.due_date = nowdate()
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": fee_item,
|
||||
"qty": 1,
|
||||
"rate": fee,
|
||||
"income_account": settings.income_account,
|
||||
"cost_center": cost_center,
|
||||
"description": _("Late payment fee for statement period {0}").format(period),
|
||||
},
|
||||
)
|
||||
# Late fees are not taxed. A single zero "Actual" tax line keeps the taxes
|
||||
# table non-empty, which stops ERPNext from auto-applying the company or
|
||||
# item tax templates; being zero it posts nothing to the ledger.
|
||||
si.taxes_and_charges = ""
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": settings.income_account,
|
||||
"description": _("Late fees are not taxed"),
|
||||
"tax_amount": 0,
|
||||
"rate": 0,
|
||||
},
|
||||
)
|
||||
si.insert(ignore_permissions=True)
|
||||
si.submit()
|
||||
frappe.db.commit()
|
||||
return si.name
|
||||
|
||||
|
||||
def _late_fee_invoice_names(customer):
|
||||
"""Names of the customer's submitted late-fee Sales Invoices (any company)."""
|
||||
fee_items = [
|
||||
d.custom_late_fee_item
|
||||
for d in frappe.get_all("Dunning Type", fields=["custom_late_fee_item"])
|
||||
if d.custom_late_fee_item
|
||||
]
|
||||
if not fee_items:
|
||||
return set()
|
||||
rows = frappe.db.sql(
|
||||
"""
|
||||
select distinct sii.parent
|
||||
from `tabSales Invoice Item` sii
|
||||
inner join `tabSales Invoice` si on si.name = sii.parent
|
||||
where si.customer = %s and si.docstatus = 1 and sii.item_code in %s
|
||||
""",
|
||||
(customer, tuple(fee_items)),
|
||||
as_dict=True,
|
||||
)
|
||||
return {r.parent for r in rows}
|
||||
|
||||
|
||||
def _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee):
|
||||
"""Log statement generation on the customer's timeline (audit trail)."""
|
||||
total = fmt_money(data["total_due"], currency=data["currency"])
|
||||
if skip_late_fee:
|
||||
fee_note = _("late fee skipped")
|
||||
elif fee_invoice_names:
|
||||
fee_note = _("late fee invoice {0}").format(", ".join(fee_invoice_names))
|
||||
else:
|
||||
fee_note = _("no late fee")
|
||||
frappe.get_doc("Customer", customer).add_comment(
|
||||
"Info", _("Statement generated — Total Due {0} ({1}).").format(total, fee_note)
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def generate_statements(customers, skip_late_fee=0):
|
||||
"""Render printable statements (one page per customer) for the selection.
|
||||
|
||||
Side effect (unless `skip_late_fee`): a late-payment fee is billed as a
|
||||
Sales Invoice (once per customer per month) for each customer with overdue
|
||||
invoices. Each generation is recorded on the customer's timeline.
|
||||
|
||||
`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"))
|
||||
skip_late_fee = int(skip_late_fee or 0)
|
||||
|
||||
period = _late_fee_period()
|
||||
pages, rendered, skipped = [], [], []
|
||||
|
||||
for customer in customers:
|
||||
invoices = _get_outstanding_invoices(customer)
|
||||
if not invoices:
|
||||
skipped.append(customer)
|
||||
continue
|
||||
|
||||
# Bill the late fee per company (on overdue invoices only).
|
||||
fee_invoice_names = []
|
||||
if not skip_late_fee:
|
||||
overdue_by_company = {}
|
||||
for inv in invoices:
|
||||
if inv["is_overdue"]:
|
||||
overdue_by_company.setdefault(inv["company"], []).append(inv)
|
||||
for comp, invs in overdue_by_company.items():
|
||||
name = _post_late_fee_invoice(customer, comp, invs, period)
|
||||
if name:
|
||||
fee_invoice_names.append(name)
|
||||
|
||||
# Re-fetch so the statement includes the freshly billed fee invoice(s).
|
||||
data = get_statement_data(customer)
|
||||
pages.append(_render_page(data))
|
||||
rendered.append(customer)
|
||||
_record_statement_activity(customer, data, fee_invoice_names, skip_late_fee)
|
||||
|
||||
if not pages:
|
||||
frappe.throw(_("None of the selected customers have an outstanding balance."))
|
||||
|
||||
return {"html": _wrap_document(pages), "rendered": rendered, "skipped": skipped}
|
||||
@@ -7,7 +7,8 @@ app_license = "MIT"
|
||||
|
||||
# Load on every page
|
||||
app_include_js = [
|
||||
"/assets/ns_app/js/customer_quick_entry.js"
|
||||
"/assets/ns_app/js/customer_quick_entry.js",
|
||||
"/assets/ns_app/js/customer_statements.js"
|
||||
]
|
||||
|
||||
# Load on Sales Invoice form
|
||||
@@ -15,6 +16,14 @@ doctype_js = {
|
||||
"Sales Invoice": "public/js/sales_invoice.js"
|
||||
}
|
||||
|
||||
# Load on Customer list view (merges the "Generate Statements" action)
|
||||
doctype_list_js = {
|
||||
"Customer": "public/js/customer_list.js"
|
||||
}
|
||||
|
||||
# Ensure custom fields exist after every migrate
|
||||
after_migrate = "ns_app.setup.after_migrate"
|
||||
|
||||
# Fixtures tracked in Git
|
||||
fixtures = [
|
||||
{
|
||||
|
||||
1
ns_app/modules.txt
Normal file
1
ns_app/modules.txt
Normal file
@@ -0,0 +1 @@
|
||||
NS App
|
||||
0
ns_app/ns_app/__init__.py
Normal file
0
ns_app/ns_app/__init__.py
Normal file
0
ns_app/patches.txt
Normal file
0
ns_app/patches.txt
Normal file
20
ns_app/public/js/customer_list.js
Normal file
20
ns_app/public/js/customer_list.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// Customer list action: "Generate Statements". Registered as a doctype_list_js
|
||||
// so it loads alongside ERPNext's own Customer list settings (in app order,
|
||||
// after them). We MERGE into listview_settings — preserving any existing
|
||||
// onload / add_fields — instead of reassigning the object, which would clobber
|
||||
// ERPNext's settings (and be clobbered by them). The shared generate/print
|
||||
// helpers live on `ns_statements` (public/js/customer_statements.js).
|
||||
|
||||
frappe.listview_settings["Customer"] = frappe.listview_settings["Customer"] || {};
|
||||
|
||||
(function () {
|
||||
const settings = frappe.listview_settings["Customer"];
|
||||
const original_onload = settings.onload;
|
||||
|
||||
settings.onload = function (listview) {
|
||||
if (original_onload) original_onload(listview);
|
||||
listview.page.add_inner_button(__("Generate Statements"), () => {
|
||||
ns_statements.pick_and_generate();
|
||||
});
|
||||
};
|
||||
})();
|
||||
200
ns_app/public/js/customer_statements.js
Normal file
200
ns_app/public/js/customer_statements.js
Normal file
@@ -0,0 +1,200 @@
|
||||
// Customer Statements: generate printable, one-page-per-customer account
|
||||
// statements formatted for a window envelope. Two entry points share the same
|
||||
// generate/print helpers — a multi-select action on the Customer list and a
|
||||
// single-customer button on the Customer form. Loaded globally so both the
|
||||
// list view and the form can reach the shared `ns_statements` helpers.
|
||||
|
||||
frappe.provide("ns_statements");
|
||||
|
||||
// The Customer list button is registered separately in customer_list.js
|
||||
// (a doctype_list_js) so it merges with — rather than overwrites — ERPNext's
|
||||
// own listview_settings["Customer"]. Shared helpers live here on ns_statements.
|
||||
|
||||
// ── Entry point: Customer form ───────────────────────────────────────────────
|
||||
frappe.ui.form.on("Customer", {
|
||||
refresh(frm) {
|
||||
if (frm.is_new()) return;
|
||||
frm.add_custom_button(__("Generate Statement"), () => {
|
||||
ns_statements.generate_for_customer(frm.doc.name);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Shared: call the backend and open the printable document ─────────────────
|
||||
ns_statements.run = function (customers, skip_late_fee) {
|
||||
frappe.call({
|
||||
method: "ns_app.api.statements.generate_statements",
|
||||
args: { customers, skip_late_fee: skip_late_fee ? 1 : 0 },
|
||||
freeze: true,
|
||||
freeze_message: __("Generating statements..."),
|
||||
callback(r) {
|
||||
if (!r.message || !r.message.html) return;
|
||||
ns_statements.open_print_window(r.message.html);
|
||||
const skipped = (r.message.skipped || []).length;
|
||||
if (skipped) {
|
||||
frappe.show_alert({
|
||||
message: __("Skipped {0} customer(s) with no balance.", [skipped]),
|
||||
indicator: "orange"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ns_statements.open_print_window = function (html) {
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) {
|
||||
frappe.msgprint(__("Please allow pop-ups to view the statements."));
|
||||
return;
|
||||
}
|
||||
w.document.open();
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
};
|
||||
|
||||
// ── List flow: pick customers with overdue invoices, then generate ───────────
|
||||
ns_statements.pick_and_generate = function () {
|
||||
frappe.call({
|
||||
method: "ns_app.api.statements.get_customers_with_overdue_invoices",
|
||||
freeze: true,
|
||||
freeze_message: __("Finding customers with overdue invoices..."),
|
||||
callback(r) {
|
||||
const rows = r.message || [];
|
||||
if (!rows.length) {
|
||||
frappe.msgprint({
|
||||
title: __("No Overdue Customers"),
|
||||
message: __("No customers currently have overdue invoices."),
|
||||
indicator: "green"
|
||||
});
|
||||
return;
|
||||
}
|
||||
ns_statements._selection_dialog(rows);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ns_statements._selection_dialog = function (rows) {
|
||||
const uid = Date.now();
|
||||
const selected = new Set(rows.map(r => r.customer)); // default: all selected
|
||||
|
||||
const body = rows.map(r => `
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="cust-check-${uid}"
|
||||
data-name="${frappe.utils.escape_html(r.customer)}" checked>
|
||||
</td>
|
||||
<td>${frappe.utils.escape_html(r.customer_name || r.customer)}</td>
|
||||
<td class="text-center">${r.overdue_count}</td>
|
||||
<td class="text-center">${r.max_days_overdue}</td>
|
||||
<td class="text-right">${format_currency(r.total_outstanding)}</td>
|
||||
</tr>`).join("");
|
||||
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Generate Customer Statements"),
|
||||
size: "large",
|
||||
fields: [
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
fieldname: "selector",
|
||||
options: `
|
||||
<div style="max-height:45vh; overflow:auto;">
|
||||
<table class="table table-bordered table-sm" style="font-size:13px; margin:0;">
|
||||
<thead style="position:sticky; top:0; background:#f5f5f5;">
|
||||
<tr>
|
||||
<th style="width:36px;">
|
||||
<input type="checkbox" id="sel_all_${uid}" title="${__("Select all")}" checked>
|
||||
</th>
|
||||
<th>${__("Customer")}</th>
|
||||
<th class="text-center">${__("Overdue Invoices")}</th>
|
||||
<th class="text-center">${__("Max Days Overdue")}</th>
|
||||
<th class="text-right">${__("Total Outstanding")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sel_body_${uid}">${body}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="sel_count_${uid}" style="margin-top:8px; font-weight:bold;"></div>`
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
fieldname: "generate_late_fee",
|
||||
label: __("Generate late payment fee"),
|
||||
default: 1,
|
||||
description: __("Bills a late-fee invoice (once per customer this month) for overdue balances.")
|
||||
}
|
||||
],
|
||||
primary_action_label: __("Generate Statements"),
|
||||
primary_action() {
|
||||
const customers = [...selected];
|
||||
if (!customers.length) {
|
||||
frappe.msgprint(__("Select at least one customer."));
|
||||
return;
|
||||
}
|
||||
const gen_fee = dialog.get_value("generate_late_fee");
|
||||
const proceed = () => {
|
||||
dialog.hide();
|
||||
ns_statements.run(customers, !gen_fee);
|
||||
};
|
||||
if (gen_fee) {
|
||||
frappe.confirm(
|
||||
__("Generate statements for {0} customer(s)? A late-fee invoice will be raised (once per customer this month) for any overdue balances.", [customers.length]),
|
||||
proceed
|
||||
);
|
||||
} else {
|
||||
proceed();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
|
||||
const update_count = () => {
|
||||
const el = document.getElementById(`sel_count_${uid}`);
|
||||
if (el) el.innerText = __("{0} of {1} selected", [selected.size, rows.length]);
|
||||
};
|
||||
update_count();
|
||||
|
||||
dialog.$wrapper.on("change", `.cust-check-${uid}`, function () {
|
||||
if (this.checked) selected.add(this.dataset.name);
|
||||
else selected.delete(this.dataset.name);
|
||||
const all = dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`);
|
||||
const selAll = document.getElementById(`sel_all_${uid}`);
|
||||
if (selAll) selAll.checked = [...all].every(c => c.checked);
|
||||
update_count();
|
||||
});
|
||||
|
||||
dialog.$wrapper.on("change", `#sel_all_${uid}`, function () {
|
||||
dialog.$wrapper[0].querySelectorAll(`.cust-check-${uid}`).forEach(cb => {
|
||||
cb.checked = this.checked;
|
||||
if (this.checked) selected.add(cb.dataset.name);
|
||||
else selected.delete(cb.dataset.name);
|
||||
});
|
||||
update_count();
|
||||
});
|
||||
};
|
||||
|
||||
// ── Form flow: single customer, with the same fee toggle ─────────────────────
|
||||
ns_statements.generate_for_customer = function (customer) {
|
||||
const d = new frappe.ui.Dialog({
|
||||
title: __("Generate Statement"),
|
||||
fields: [
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
options: `<p>${__("Generate an account statement for <b>{0}</b>.", [frappe.utils.escape_html(customer)])}</p>`
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
fieldname: "generate_late_fee",
|
||||
label: __("Generate late payment fee"),
|
||||
default: 1,
|
||||
description: __("Bills a late-fee invoice (once this month) for overdue balances.")
|
||||
}
|
||||
],
|
||||
primary_action_label: __("Generate"),
|
||||
primary_action(values) {
|
||||
d.hide();
|
||||
ns_statements.run([customer], !values.generate_late_fee);
|
||||
}
|
||||
});
|
||||
d.show();
|
||||
};
|
||||
46
ns_app/setup.py
Normal file
46
ns_app/setup.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""App setup: custom fields created/synced on migrate."""
|
||||
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||
|
||||
from ns_app.api.statements import LATE_FEE_NAMING_SERIES
|
||||
|
||||
# Fee schedule/amounts live on ERPNext's Dunning Type; this adds the one thing
|
||||
# it lacks — the Item used to bill a late fee as a Sales Invoice.
|
||||
CUSTOM_FIELDS = {
|
||||
"Dunning Type": [
|
||||
{
|
||||
"fieldname": "custom_late_fee_item",
|
||||
"label": "Late Fee Item",
|
||||
"fieldtype": "Link",
|
||||
"options": "Item",
|
||||
"insert_after": "income_account",
|
||||
"description": (
|
||||
"Item used to bill a late-payment fee as a Sales Invoice when "
|
||||
"customer statements are generated."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _register_late_fee_naming_series():
|
||||
"""Add the late-fee series to Sales Invoice's naming_series options."""
|
||||
field = frappe.get_meta("Sales Invoice").get_field("naming_series")
|
||||
options = [o for o in (field.options or "").split("\n")] if field else []
|
||||
if LATE_FEE_NAMING_SERIES not in options:
|
||||
options.append(LATE_FEE_NAMING_SERIES)
|
||||
make_property_setter(
|
||||
"Sales Invoice",
|
||||
"naming_series",
|
||||
"options",
|
||||
"\n".join(options),
|
||||
"Text",
|
||||
validate_fields_for_doctype=False,
|
||||
)
|
||||
|
||||
|
||||
def after_migrate():
|
||||
create_custom_fields(CUSTOM_FIELDS)
|
||||
_register_late_fee_naming_series()
|
||||
95
ns_app/templates/statements/customer_statement.html
Normal file
95
ns_app/templates/statements/customer_statement.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{# One customer account statement = one printed page.
|
||||
Envelope geometry (window positions in _wrap_document) is field-tuned to the
|
||||
#9 (9x4) double-window envelope. 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 }}{% if inv.is_late_fee %} <span class="tag">late fee</span>{% endif %}</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 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