feat(statements): top up open late-fee invoices instead of stacking new ones
Statement generation raised a fresh LPF invoice every month, so a customer who never paid accumulated a pile of small invoices, each carrying the flat dunning_fee again. Interest was also recomputed from each invoice's due date every run, re-billing periods already charged for. Now a customer gets 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, rather than creating a second invoice. - Payment Entries allocated to a partly paid fee invoice are unlinked by the cancellation and re-applied to the amended invoice via reconcile_against_document (the primitive Payment Reconciliation uses), so the outstanding amount and Payment Ledger stay correct. - Original posting and due dates are carried over. 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. - Interest accrues from the last run, tracked by a new custom_late_fee_billed_upto field on Sales Invoice, so no period is billed twice. Fee invoices predating the field fall back to their posting date, which is when they were billed, so no migration patch is needed. - The flat dunning_fee is charged once, when a fee invoice is first raised, not again on every top-up. - Unpaid fee invoices are in the interest base on the same terms as any other overdue receivable, so interest compounds onto the fee balance. Amending means cancelling, which is only reversible for links we can restore. If the open fee invoice has a Journal Entry or credit note applied, a negative payment allocation, or a posting date in a frozen period, it is left alone, the charge goes on a new invoice, and the reason is recorded on the customer's timeline. Verified against nsi.local with two rolled-back integration probes covering the amend + re-link path (including two consecutive amendments) and the blocked-amend fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,12 +32,26 @@ 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:
|
||||
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:
|
||||
|
||||
```
|
||||
fee = Σ(invoice.outstanding × rate_of_interest/100/365 × days_overdue) + dunning_fee
|
||||
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
|
||||
@@ -47,7 +61,7 @@ the company is used. Fields consumed:
|
||||
| Dunning Type field | Purpose |
|
||||
|--------------------|---------|
|
||||
| `rate_of_interest` | Annual interest rate (%) |
|
||||
| `dunning_fee` | Flat fee added per statement |
|
||||
| `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) |
|
||||
@@ -71,18 +85,59 @@ sit **uncollectible** by those flows — hence the Sales Invoice.
|
||||
- **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).
|
||||
- **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 and registers the `LPF-` series.
|
||||
`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,
|
||||
@@ -97,7 +152,7 @@ error and posts nothing.
|
||||
|
||||
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).
|
||||
> 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
|
||||
@@ -112,7 +167,7 @@ generating user.
|
||||
| `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/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`)
|
||||
|
||||
Reference in New Issue
Block a user