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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
26
ns_app/setup.py
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user