Compare commits
20 Commits
main
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e1ec126a | |||
| a08a02ad4b | |||
| a986348de9 | |||
| 192af42614 | |||
| 1b41fb0845 | |||
| 41d3fec08c | |||
| ccc3fac69d | |||
| 0aefdeb5ff | |||
| 326adf865a | |||
| 5181f4a177 | |||
| 46967208e9 | |||
| c20dd18287 | |||
| acd7df1129 | |||
| 9e2e86cead | |||
| 75a9c9d154 | |||
| 63bf0b68f5 | |||
| 790d4d0e9d | |||
| 87f40e6b83 | |||
| 61ac7f563c | |||
| f89a672334 |
190
docs/CUSTOMER_STATEMENTS.md
Normal file
190
docs/CUSTOMER_STATEMENTS.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# 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, accrued **from the last fee run**
|
||||||
|
rather than from each invoice's due date, so no period is ever billed twice:
|
||||||
|
|
||||||
|
```
|
||||||
|
accrue_from = max(invoice.due_date, last_billed_upto)
|
||||||
|
interest = Σ(invoice.outstanding × rate_of_interest/100/365 × days_since(accrue_from))
|
||||||
|
charge = interest + dunning_fee # flat fee only when raising a new fee invoice
|
||||||
|
= interest # when topping up an existing one
|
||||||
|
```
|
||||||
|
|
||||||
|
The Σ runs over **every** overdue invoice, unpaid late-fee invoices included —
|
||||||
|
they are receivables like any other and are charged on the same terms.
|
||||||
|
|
||||||
|
`last_billed_upto` is stored on the fee invoice itself
|
||||||
|
(`custom_late_fee_billed_upto`). Fee invoices raised before that field existed
|
||||||
|
fall back to their posting date, which is when they were billed.
|
||||||
|
|
||||||
|
The flat `dunning_fee` is a one-off charge for falling into collections, applied
|
||||||
|
when a fee invoice is first raised — not again on every top-up.
|
||||||
|
|
||||||
|
### 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, charged once when a fee invoice is raised |
|
||||||
|
| `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 charge per customer / company / calendar month.
|
||||||
|
An unpaid fee invoice accrues interest on the same terms as any other overdue
|
||||||
|
receivable (see below).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### One fee invoice per collections episode
|
||||||
|
While an earlier fee invoice still carries a balance, the next run **amends it**
|
||||||
|
and appends the new period's interest as a further line item, instead of raising
|
||||||
|
a second invoice. The customer sees one growing charge, itemised by period.
|
||||||
|
|
||||||
|
Amending means cancelling and re-raising, so the invoice number gains a suffix
|
||||||
|
(`LPF-2026-00001` → `LPF-2026-00001-1`). The original posting and due dates are
|
||||||
|
carried over deliberately: re-dating to today would reset the invoice to
|
||||||
|
*Current* in the statement's aging buckets and hide how long the balance has
|
||||||
|
been owed.
|
||||||
|
|
||||||
|
**Partially paid fee invoices.** Cancelling unlinks any Payment Entries, leaving
|
||||||
|
the cash unallocated on them. After the amended invoice is submitted, each
|
||||||
|
payment is re-applied to it via `reconcile_against_document` — the same
|
||||||
|
primitive the Payment Reconciliation tool uses — so the outstanding amount and
|
||||||
|
the Payment Ledger reflect what the customer actually owes. (As with any
|
||||||
|
reconciliation, ERPNext clears `against_voucher` on the payment's **GL** rows and
|
||||||
|
tracks the allocation on the **Payment Ledger**; the AR reports read the latter.)
|
||||||
|
|
||||||
|
**When it can't amend.** Cancelling is only reversible for links this app knows
|
||||||
|
how to restore. If the open fee invoice has a Journal Entry or credit note
|
||||||
|
applied, a negative payment allocation, or a posting date inside a frozen
|
||||||
|
accounting period, it is left alone, the charge goes onto a new invoice, and the
|
||||||
|
reason is recorded on the customer's timeline:
|
||||||
|
|
||||||
|
> Late fee LPF-2026-00001 could not be amended (a Journal Entry is applied
|
||||||
|
> against it); the charge was billed on a new invoice.
|
||||||
|
|
||||||
|
### Unpaid fee invoices accrue too
|
||||||
|
A late-fee invoice is an overdue receivable like any other, and once past its
|
||||||
|
due date its outstanding balance is part of the interest base. Because the
|
||||||
|
charge lands back on the invoice carrying that balance, **interest compounds**:
|
||||||
|
each run adds interest on the fee balance the previous runs built up.
|
||||||
|
|
||||||
|
A customer whose only remaining overdue item is an unpaid fee invoice therefore
|
||||||
|
keeps accruing — the balance grows by `outstanding × rate/365 × days_since_last_run`
|
||||||
|
every run until it is paid. No new flat fee is raised while a fee invoice is
|
||||||
|
open, so the growth is interest alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration / prerequisites
|
||||||
|
|
||||||
|
1. **Migrate** the app (`bench --site <site> migrate`) — creates the
|
||||||
|
`Late Fee Item` custom field on Dunning Type, the `Late Fee Billed Upto`
|
||||||
|
custom field on Sales Invoice, 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 charged on LPF-2026-00001-1).
|
||||||
|
|
||||||
|
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 / Late Fee Billed Upto custom fields, 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.
|
||||||
746
ns_app/api/statements.py
Normal file
746
ns_app/api/statements.py
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
"""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.-"
|
||||||
|
|
||||||
|
# Custom field on Sales Invoice recording the date interest was last charged, so
|
||||||
|
# the next run accrues from there instead of re-charging from the due date.
|
||||||
|
BILLED_UPTO_FIELD = "custom_late_fee_billed_upto"
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# A customer gets **one** fee invoice per collections episode, not one per
|
||||||
|
# month: while an earlier fee invoice still carries a balance, the next run
|
||||||
|
# amends it and appends the new period's interest as another line, so the
|
||||||
|
# customer sees a single growing charge instead of a stack of small ones. The
|
||||||
|
# flat dunning_fee is a one-off for falling into collections and is charged only
|
||||||
|
# when a fee invoice is first raised. Interest accrues from the last run
|
||||||
|
# (BILLED_UPTO_FIELD), not from each invoice's due date, so no period is billed
|
||||||
|
# twice. An unpaid fee invoice is itself an overdue receivable and accrues on
|
||||||
|
# the same terms as any other, so interest compounds onto the fee balance.
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Submitted late-fee Sales Invoices for a customer/company, newest first.
|
||||||
|
|
||||||
|
`distinct` matters: an amended fee invoice carries one item row per period
|
||||||
|
billed, so the join would otherwise return it several times.
|
||||||
|
"""
|
||||||
|
if not fee_item:
|
||||||
|
return []
|
||||||
|
return frappe.db.sql(
|
||||||
|
"""
|
||||||
|
select distinct si.name, si.posting_date, si.due_date, si.debit_to,
|
||||||
|
si.outstanding_amount, si.creation, si.{billed_upto} as billed_upto
|
||||||
|
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
|
||||||
|
order by si.posting_date desc, si.creation desc
|
||||||
|
""".format(billed_upto=BILLED_UPTO_FIELD),
|
||||||
|
(customer, company, fee_item),
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _last_billed_upto(fee_invoices):
|
||||||
|
"""Date late-fee interest was last charged, or None if it never has been.
|
||||||
|
|
||||||
|
Fee invoices raised before the marker field existed fall back to their
|
||||||
|
posting date, which is exactly when they were billed.
|
||||||
|
"""
|
||||||
|
dates = [getdate(fi.billed_upto or fi.posting_date) for fi in fee_invoices]
|
||||||
|
return max(dates) if dates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _open_fee_invoice(fee_invoices):
|
||||||
|
"""The most recent fee invoice still carrying a balance, or None."""
|
||||||
|
for fi in fee_invoices:
|
||||||
|
if flt(fi.outstanding_amount) > 0:
|
||||||
|
return fi
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _accrued_interest(overdue_invoices, rate_of_interest, last_billed_upto):
|
||||||
|
"""Interest accrued since the last fee run (or since each invoice fell due).
|
||||||
|
|
||||||
|
Charging on `days_overdue` every run would re-bill every period already
|
||||||
|
paid for, so each invoice accrues only from whichever is later: its due date
|
||||||
|
or the last time a fee was charged.
|
||||||
|
|
||||||
|
An unpaid late-fee invoice is an overdue receivable like any other and is
|
||||||
|
charged on the same terms — its balance accrues interest too, which then
|
||||||
|
lands back on the invoice carrying it.
|
||||||
|
"""
|
||||||
|
today = getdate(nowdate())
|
||||||
|
daily_interest = flt(rate_of_interest) / 100.0 / 365.0
|
||||||
|
interest = 0.0
|
||||||
|
for inv in overdue_invoices:
|
||||||
|
accrue_from = getdate(inv["due_date"])
|
||||||
|
if last_billed_upto and last_billed_upto > accrue_from:
|
||||||
|
accrue_from = last_billed_upto
|
||||||
|
days = (today - accrue_from).days
|
||||||
|
if days > 0:
|
||||||
|
interest += flt(inv["outstanding_amount"]) * daily_interest * days
|
||||||
|
return interest
|
||||||
|
|
||||||
|
|
||||||
|
def _amend_blockers(fee_invoice):
|
||||||
|
"""Reasons this fee invoice cannot safely be cancelled and re-raised.
|
||||||
|
|
||||||
|
Amending means cancelling, and cancelling is only reversible for the links
|
||||||
|
we know how to restore (plain Payment Entry allocations). Anything else is
|
||||||
|
left alone and billed on a fresh invoice instead.
|
||||||
|
"""
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
frozen = frappe.db.get_single_value("Accounts Settings", "acc_frozen_upto")
|
||||||
|
if frozen and getdate(fee_invoice.posting_date) <= getdate(frozen):
|
||||||
|
reasons.append(_("its posting date falls in a frozen accounting period"))
|
||||||
|
|
||||||
|
if frappe.db.exists(
|
||||||
|
"Journal Entry Account",
|
||||||
|
{"reference_type": "Sales Invoice", "reference_name": fee_invoice.name, "docstatus": 1},
|
||||||
|
):
|
||||||
|
reasons.append(_("a Journal Entry is applied against it"))
|
||||||
|
|
||||||
|
if frappe.db.exists(
|
||||||
|
"Sales Invoice", {"return_against": fee_invoice.name, "docstatus": 1}
|
||||||
|
):
|
||||||
|
reasons.append(_("a credit note is applied against it"))
|
||||||
|
|
||||||
|
if frappe.db.exists(
|
||||||
|
"Payment Entry Reference",
|
||||||
|
{
|
||||||
|
"reference_doctype": "Sales Invoice",
|
||||||
|
"reference_name": fee_invoice.name,
|
||||||
|
"docstatus": 1,
|
||||||
|
"allocated_amount": ("<", 0),
|
||||||
|
},
|
||||||
|
):
|
||||||
|
reasons.append(_("a payment allocates a negative amount to it"))
|
||||||
|
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def _payment_allocations(invoice_name):
|
||||||
|
"""Submitted Payment Entry allocations against an invoice, one row per entry.
|
||||||
|
|
||||||
|
Grouped per payment because re-linking consumes a payment's unallocated
|
||||||
|
balance in one go; two rows for the same entry would double-count it.
|
||||||
|
"""
|
||||||
|
return frappe.db.sql(
|
||||||
|
"""
|
||||||
|
select pe.name as payment_entry, pe.party_type, pe.party,
|
||||||
|
sum(per.allocated_amount) as allocated_amount,
|
||||||
|
max(per.account) as account
|
||||||
|
from `tabPayment Entry Reference` per
|
||||||
|
inner join `tabPayment Entry` pe on pe.name = per.parent
|
||||||
|
where per.reference_doctype = 'Sales Invoice'
|
||||||
|
and per.reference_name = %s
|
||||||
|
and per.docstatus = 1 and pe.docstatus = 1
|
||||||
|
and per.allocated_amount > 0
|
||||||
|
group by pe.name, pe.party_type, pe.party
|
||||||
|
""",
|
||||||
|
invoice_name,
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relink_payments(amended, allocations):
|
||||||
|
"""Re-apply payments freed by the cancellation onto the amended invoice.
|
||||||
|
|
||||||
|
Cancelling unlinks the payments, leaving them sitting as unallocated cash on
|
||||||
|
their Payment Entries; this puts them back so the amended invoice shows the
|
||||||
|
balance the customer actually owes. `reconcile_against_document` is the same
|
||||||
|
primitive the Payment Reconciliation tool uses, so the ledger and the
|
||||||
|
invoice's outstanding amount are reposted the standard way.
|
||||||
|
"""
|
||||||
|
from erpnext.accounts.utils import reconcile_against_document
|
||||||
|
|
||||||
|
company_currency = frappe.get_cached_value("Company", amended.company, "default_currency")
|
||||||
|
in_company_currency = amended.party_account_currency == company_currency
|
||||||
|
remaining = flt(amended.outstanding_amount)
|
||||||
|
|
||||||
|
args = []
|
||||||
|
for alloc in allocations:
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
# The freed cash sits in unallocated_amount; that total is also what
|
||||||
|
# ERPNext validates the allocation against.
|
||||||
|
unallocated = flt(
|
||||||
|
frappe.db.get_value("Payment Entry", alloc.payment_entry, "unallocated_amount")
|
||||||
|
)
|
||||||
|
amount = min(flt(alloc.allocated_amount), unallocated, remaining)
|
||||||
|
if amount <= 0:
|
||||||
|
continue
|
||||||
|
args.append(
|
||||||
|
frappe._dict(
|
||||||
|
{
|
||||||
|
"voucher_type": "Payment Entry",
|
||||||
|
"voucher_no": alloc.payment_entry,
|
||||||
|
"voucher_detail_no": None,
|
||||||
|
"against_voucher_type": "Sales Invoice",
|
||||||
|
"against_voucher": amended.name,
|
||||||
|
"account": alloc.account or amended.debit_to,
|
||||||
|
"party_type": alloc.party_type,
|
||||||
|
"party": alloc.party,
|
||||||
|
"is_advance": "No",
|
||||||
|
"dr_or_cr": "credit_in_account_currency",
|
||||||
|
"unadjusted_amount": unallocated,
|
||||||
|
"allocated_amount": amount,
|
||||||
|
"exchange_rate": 1 if in_company_currency else amended.conversion_rate,
|
||||||
|
"grand_total": (
|
||||||
|
amended.base_grand_total if in_company_currency else amended.grand_total
|
||||||
|
),
|
||||||
|
"outstanding_amount": remaining,
|
||||||
|
"difference_account": frappe.get_cached_value(
|
||||||
|
"Company", amended.company, "exchange_gain_loss_account"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
remaining -= amount
|
||||||
|
|
||||||
|
if args:
|
||||||
|
reconcile_against_document(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _amend_fee_invoice(fee_invoice, charge, billed_upto):
|
||||||
|
"""Add a charge to an open fee invoice by amending it; return the new name.
|
||||||
|
|
||||||
|
The whole sequence runs inside the caller's transaction, so a failure
|
||||||
|
anywhere rolls back the unlink and the cancellation with it.
|
||||||
|
"""
|
||||||
|
from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries
|
||||||
|
|
||||||
|
allocations = _payment_allocations(fee_invoice.name)
|
||||||
|
doc = frappe.get_doc("Sales Invoice", fee_invoice.name)
|
||||||
|
|
||||||
|
# Unlinking and reconciling narrate themselves with msgprint dialogs. A run
|
||||||
|
# covering fifty customers would bury the user in them, and the timeline
|
||||||
|
# comment already records what happened.
|
||||||
|
muted = frappe.flags.mute_messages
|
||||||
|
frappe.flags.mute_messages = True
|
||||||
|
try:
|
||||||
|
if allocations:
|
||||||
|
unlink_ref_doc_from_payment_entries(doc)
|
||||||
|
doc.cancel()
|
||||||
|
|
||||||
|
amended = frappe.copy_doc(doc, ignore_no_copy=False)
|
||||||
|
amended.amended_from = doc.name
|
||||||
|
amended.naming_series = LATE_FEE_NAMING_SERIES
|
||||||
|
# Same debt, so the original dates stand. Re-dating to today would
|
||||||
|
# reset the invoice to "Current" on the statement's aging buckets, which
|
||||||
|
# read from due_date, and hide how long the balance has been owed.
|
||||||
|
amended.set_posting_time = 1
|
||||||
|
amended.posting_date = doc.posting_date
|
||||||
|
amended.posting_time = doc.posting_time
|
||||||
|
amended.due_date = doc.due_date
|
||||||
|
amended.set(BILLED_UPTO_FIELD, billed_upto)
|
||||||
|
amended.append("items", charge)
|
||||||
|
amended.insert(ignore_permissions=True)
|
||||||
|
amended.submit()
|
||||||
|
|
||||||
|
if allocations:
|
||||||
|
amended.reload()
|
||||||
|
_relink_payments(amended, allocations)
|
||||||
|
finally:
|
||||||
|
frappe.flags.mute_messages = muted
|
||||||
|
|
||||||
|
return amended.name
|
||||||
|
|
||||||
|
|
||||||
|
def _new_fee_invoice(customer, company, settings, charge, billed_upto):
|
||||||
|
"""Raise a fresh late-fee Sales Invoice; return its name."""
|
||||||
|
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.set(BILLED_UPTO_FIELD, billed_upto)
|
||||||
|
si.append("items", charge)
|
||||||
|
# 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()
|
||||||
|
return si.name
|
||||||
|
|
||||||
|
|
||||||
|
def _post_late_fee_invoice(customer, company, overdue_invoices, period):
|
||||||
|
"""Bill late-payment interest for one customer/company (once per month).
|
||||||
|
|
||||||
|
Tops up the customer's open fee invoice where there is one, otherwise raises
|
||||||
|
a new one. 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)
|
||||||
|
last_billed = _last_billed_upto(fee_invoices)
|
||||||
|
open_invoice = _open_fee_invoice(fee_invoices)
|
||||||
|
|
||||||
|
# Idempotency: at most one charge per (customer, company, month).
|
||||||
|
month_start = getdate(period + "-01")
|
||||||
|
if last_billed and last_billed >= month_start:
|
||||||
|
return open_invoice.name if open_invoice else None
|
||||||
|
|
||||||
|
today = getdate(nowdate())
|
||||||
|
interest = _accrued_interest(overdue_invoices, settings.rate_of_interest, last_billed)
|
||||||
|
# The flat dunning fee is a one-off for falling into collections, charged
|
||||||
|
# when the fee invoice is raised — not again every time it is topped up.
|
||||||
|
amount = round(interest if open_invoice else interest + flt(settings.dunning_fee), 2)
|
||||||
|
if amount <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
charge = {
|
||||||
|
"item_code": fee_item,
|
||||||
|
"qty": 1,
|
||||||
|
"rate": amount,
|
||||||
|
"income_account": settings.income_account,
|
||||||
|
"cost_center": settings.cost_center
|
||||||
|
or frappe.get_cached_value("Company", company, "cost_center"),
|
||||||
|
"description": _("Late payment fee for statement period {0}").format(period),
|
||||||
|
}
|
||||||
|
|
||||||
|
if open_invoice:
|
||||||
|
blockers = _amend_blockers(open_invoice)
|
||||||
|
if not blockers:
|
||||||
|
name = _amend_fee_invoice(open_invoice, charge, today)
|
||||||
|
frappe.db.commit()
|
||||||
|
return name
|
||||||
|
_note_amend_skipped(customer, open_invoice.name, blockers)
|
||||||
|
|
||||||
|
name = _new_fee_invoice(customer, company, settings, charge, today)
|
||||||
|
frappe.db.commit()
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _note_amend_skipped(customer, fee_invoice, reasons):
|
||||||
|
"""Record why an open fee invoice was left alone and a new one raised."""
|
||||||
|
frappe.get_doc("Customer", customer).add_comment(
|
||||||
|
"Info",
|
||||||
|
_("Late fee {0} could not be amended ({1}); the charge was billed on a new invoice.").format(
|
||||||
|
fee_invoice, ", ".join(reasons)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 charged on {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 (once per
|
||||||
|
customer per month) for each customer with overdue invoices — added to their
|
||||||
|
open fee invoice if they have one, otherwise raised as a new Sales Invoice.
|
||||||
|
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
|
# Load on every page
|
||||||
app_include_js = [
|
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
|
# Load on Sales Invoice form
|
||||||
@@ -15,6 +16,14 @@ doctype_js = {
|
|||||||
"Sales Invoice": "public/js/sales_invoice.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 tracked in Git
|
||||||
fixtures = [
|
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();
|
||||||
|
};
|
||||||
64
ns_app/setup.py
Normal file
64
ns_app/setup.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""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 BILLED_UPTO_FIELD, 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."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Sales Invoice": [
|
||||||
|
{
|
||||||
|
"fieldname": BILLED_UPTO_FIELD,
|
||||||
|
"label": "Late Fee Billed Upto",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"insert_after": "due_date",
|
||||||
|
"read_only": 1,
|
||||||
|
"print_hide": 1,
|
||||||
|
# Must survive frappe.copy_doc() when the invoice is amended to add
|
||||||
|
# another period's interest — it is what stops that period being
|
||||||
|
# billed twice.
|
||||||
|
"no_copy": 0,
|
||||||
|
"description": (
|
||||||
|
"On a late-fee invoice, the date interest was last charged. The "
|
||||||
|
"next statement run accrues interest from here."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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