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

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)