feat(statements): bill late fee as a collectible Sales Invoice

Charge a late-payment fee when statements are generated so the customer's
receivable reflects it (the gap ERPNext Dunning leaves — it never
increases AR). The fee is billed as a submitted Sales Invoice (item ->
Dunning Type income account, rate = computed fee) rather than a Journal
Entry, so the app's existing payment flow (Run Payment / AutoPay /
multi-invoice) charges and settles it automatically via its Sales Invoice
references — a bare JE would sit uncollected.

Fee schedule/amounts come from the existing Dunning Type settings (yearly
rate_of_interest + flat dunning_fee), interest computed with ERPNext's own
Dunning formula. The fee Item is configured via a new Late Fee Item custom
field on Dunning Type (created in an after_migrate hook; ns_app/setup.py).
Nothing is auto-seeded: generation stops with a clear error if no Dunning
Type is configured or its income account / fee item is unset.

Billing is idempotent per customer/company/month, and prior fee invoices
are excluded from the interest base (no fee-on-fee). The fee invoice shows
on the statement flagged 'late fee', folded into Total Due (which equals
the customer's balance and is fully collectible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 19:12:08 -04:00
parent 9e2e86cead
commit acd7df1129
5 changed files with 229 additions and 20 deletions

View File

@@ -127,23 +127,28 @@ def _resolve_company(invoices):
)
def get_statement_data(customer):
"""Assemble everything the statement template needs for one customer."""
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)
invoices = _get_outstanding_invoices(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_outstanding = 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_outstanding += flt(inv["outstanding_amount"])
# Late fee is booked and populated by generate_statements (later commit);
# get_statement_data on its own reports a zero fee.
late_fee = 0.0
total_due += flt(inv["outstanding_amount"])
return {
"customer": cust.name,
@@ -156,9 +161,7 @@ def get_statement_data(customer):
or frappe.db.get_single_value("Global Defaults", "default_currency"),
"invoices": invoices,
"aging": aging,
"total_outstanding": total_outstanding,
"late_fee": late_fee,
"total_due": total_outstanding + late_fee,
"total_due": total_due,
"statement_date": nowdate(),
}
@@ -221,6 +224,10 @@ def _wrap_document(pages):
.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 {{
@@ -243,10 +250,173 @@ def _wrap_document(pages):
</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.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),
},
)
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}
@frappe.whitelist()
def generate_statements(customers):
"""Render printable statements (one page per customer) for the selection.
Booking side effect: a late-payment fee is posted to the ledger (once per
customer per month) for each customer with overdue invoices.
`customers` may arrive as a JSON-encoded list from the client.
"""
frappe.only_for(ALLOWED_ROLES)
@@ -259,12 +429,26 @@ def generate_statements(customers):
if not customers:
frappe.throw(_("No customers selected"))
period = _late_fee_period()
pages, rendered, skipped = [], [], []
for customer in customers:
data = get_statement_data(customer)
if not data["invoices"]:
invoices = _get_outstanding_invoices(customer)
if not invoices:
skipped.append(customer)
continue
# Bill the late fee per company (on overdue invoices only).
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():
_post_late_fee_invoice(customer, comp, invs, period)
# 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)

View File

@@ -20,6 +20,9 @@ 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 = [
{

View File

@@ -113,7 +113,7 @@ function show_selection_dialog(rows) {
return;
}
frappe.confirm(
__("Generate statements for {0} customer(s)?", [customers.length]),
__("Generate statements for {0} customer(s)? A late-fee invoice will be raised (once per customer this month) for any overdue balances.", [customers.length]),
() => {
frappe.call({
method: "ns_app.api.statements.generate_statements",

26
ns_app/setup.py Normal file
View File

@@ -0,0 +1,26 @@
"""App setup: custom fields created/synced on migrate."""
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
# 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 after_migrate():
create_custom_fields(CUSTOM_FIELDS)

View File

@@ -48,7 +48,7 @@
<tbody>
{% for inv in s.invoices %}
<tr class="{{ 'overdue' if inv.is_overdue else '' }}">
<td>{{ inv.name }}</td>
<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>
@@ -61,10 +61,6 @@
<!-- Totals -->
<div class="totals">
<p><span>Total Outstanding:</span><span>{{ fmt(s.total_outstanding, currency=s.currency) }}</span></p>
{% if s.late_fee and s.late_fee > 0 %}
<p><span>Late Payment Fee:</span><span>{{ fmt(s.late_fee, currency=s.currency) }}</span></p>
{% endif %}
<p class="grand"><span>Total Due:</span><span>{{ fmt(s.total_due, currency=s.currency) }}</span></p>
</div>