Dunning & Overdue Payment Escalation
A design reference for detecting overdue receivables and generating templated escalation notices
1. Requirements
1.1 Functional requirements
- Identify overdue invoice installments for a customer in one legal entity, at installment granularity: an installment qualifies once its due date has passed and it still carries an outstanding balance.
- Let staff pull overdue installments, across one or more invoices for the same customer, onto a single escalation notice scoped to one customer, legal entity, and currency.
- Compute per installment the days overdue and a daily interest charge on its outstanding amount, plus one flat fee per notice.
- Determine an escalation level per installment: how many prior notices already targeted it, so repeat notices are distinguishable from the first.
- Resolve a language-appropriate letter body and closing paragraph from a reusable, per-legal-entity template set, merged with the notice’s own fields.
- Track whether a notice is open or satisfied, flipping that automatically as the underlying receivable is paid down, credited, or reopened.
1.2 Non-functional requirements
- Consistency with the books: outstanding amounts come from the same derived-balance mechanism the rest of receivables relies on (the Party Balance Entry ledger), never a locally cached figure.
- Currency integrity: a notice never mixes installments in different currencies, since one calculation and one converted total apply to the whole notice.
- Non-destructive calculation: interest, fee, and letter text can be recomputed any time before submission without altering the underlying invoices.
- Separation from ledger-writing: submitting the notice must not itself create any accounting posting — that is a separate settlement step.
1.3 Constraints
- Only overdue, still-open installments on submitted invoices for the chosen customer/legal entity are selectable via a standing filter, not a background job — pulling installments onto a notice is manual and staff-triggered.
- Exactly one escalation profile may be default per legal entity; a new notice adopts it automatically but any profile can be substituted.
- Each profile carries at most one letter-text variant per language, and at most one flagged default language, enforced at save time.
- A notice cannot be submitted once its accounting period is closed, the same safeguard applied to other financial documents.
2. High-Level Design
2.1 Component diagram
Fetching maps qualifying payment-schedule rows into new Overdue Payment Lines and copies the legal entity’s default escalation profile onto the notice; overdue days and totals are then computed and the notice is saved as a draft — nothing has posted to the ledger yet.
3. Deep Dive
3.1 Data model
Dunning Notice — the submittable document: customer, legal entity, currency/conversion rate, assigned escalation profile (rate and fee overridable per notice), print language and generated letter text, and rolled-up total_outstanding / total_interest / dunning_amount / base_dunning_amount / grand_total. Status is Unresolved, Resolved, or Cancelled (§ 3.4). Its line table only accepts rows through the fetch action.
Overdue Payment Line — one row per overdue installment: source invoice, payment-schedule identifier, due date, outstanding amount (drawn from the Party Balance Entry ledger), computed overdue days, interest, and escalation level.
Escalation Profile — a named, per-legal-entity template of default flat fee, yearly interest rate, an income-type Ledger Account and cost center, and a set of Letter Text Variants. One profile per legal entity may be default; its income account and cost center must belong to the same legal entity, be non-group, and (for the account) be of Income type.
Letter Text Variant — a per-language body/closing text pair on one Escalation Profile, written as merge-field templates, with one variant flaggable as the profile’s default language.
3.2 Overdue-days, interest, and escalation-level algorithm
Recomputed on every save, both for live client preview and authoritatively on the server:
daily_interest = rate_of_interest / 100 / 365
for each Overdue Payment Line:
overdue_days = (notice.posting_date - line.due_date).days # the notice's own date, not "today"
interest = line.outstanding * daily_interest * overdue_days
total_outstanding = sum(line.outstanding)
total_interest = sum(line.interest)
dunning_amount = total_interest + dunning_fee # one flat fee per notice, not per line
base_dunning_amount = dunning_amount * conversion_rate
grand_total = total_outstanding + dunning_amount
Escalation level is computed per line, not a running counter on the invoice: it counts how many other, already-submitted notices carry a line against that same payment-schedule identifier, plus one. A cancelled notice drops out of future counts, but a level already saved does not retroactively renumber if an earlier notice is later cancelled.
3.3 Letter text resolution
lookup Letter Text Variant where parent = notice.escalation_profile
and (language = notice.print_language) or (is_default_language = true)
if found: render body_text/closing_text against the notice's own fields; adopt variant.language
else: show a dismissible "letter text not found" alert; leave body/closing text blank
Templates use sandboxed merge-field rendering and are syntax-checked when the escalation profile is saved. Generated text lands in ordinary, editable fields, so staff can hand-adjust wording before printing — nothing here sends or emails a letter; that is a separate, out-of-scope action.
3.4 Notice status lifecycle
The Resolved/Unresolved flip fires as a side effect whenever the Party Balance Entry ledger recomputes an outstanding amount for one of the notice’s linked invoices — not a scheduled check. A notice can also be force-closed with a manual “Resolve” action, independent of whether anything actually settled it.
3.5 Interaction contract (illustrative)
CALL get_dunning_letter_text(notice) → re-resolves body/closing text + print_language
CALL create_settlement_for(reference_type="Dunning Notice", reference_name=notice.id)
→ one reference line per invoice (allocated_amount = line.outstanding), plus
one posting line for -1 * notice.dunning_amount against the profile's
income account/cost center — the posting funnel described in the
general-ledger design then debits cash/bank in full, credits receivables,
and credits income for the rest
3.6 Error handling
- Mixed currency: an installment whose invoice currency differs from the notice’s currency is rejected at validation, naming the offending invoice.
- Missing letter text: a profile/language with no matching variant leaves the letter fields blank with a non-blocking alert, rather than failing the save.
- Misconfigured profile accounts: an income account/cost center from a different legal entity, a group node, disabled, or not of Income type is rejected when the profile is saved; duplicate letter-text languages or more than one flagged default are rejected the same way.
- Cancellation: cancelling a notice does not unwind downstream ledger or reconciliation records a settlement built on top of it may have created; those follow their own cancellation paths.
4. Scale and Reliability
- Volume is bounded by receivables aging, not transaction throughput — notices are created in small, staff-driven batches per collections cycle.
- Escalation-level lookup costs one count query per line, per save; it scales with how many times an installment has previously been dunned, which stays small in practice.
- Auto-resolution rides on the outstanding-amount recompute every other receivables event already triggers, so status stays correct with no dedicated scheduled job. There is no external-facing surface in this flow (no outbound call, webhook, or queue) — actually emailing a generated letter sits outside what this module does.
- The interest/overdue-days formula exists independently on the client and server and must be kept in sync by hand; an unsubmitted draft also keeps whatever figures were last calculated until someone re-saves it.
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Escalation level counts prior submitted notices per installment, not a stored counter | Self-corrects if an earlier notice is cancelled — but a level already saved does not retroactively renumber. |
| One flat fee per notice rather than per installment | Simple “one letter, one fee” model — but a bundled notice can’t charge a different fee per installment. |
| Interest computed from the notice’s own posting date, not wall-clock “today” | Consistent dating for a given accounting day — but a draft left open for days keeps stale figures until re-saved. |
| Ledger posting kept out of the notice’s submission, delegated to a follow-up settlement | Pure calculation/paperwork layer — but a submitted, unresolved notice can sit with zero accounting impact until someone settles it. |
| Letter text resolved by per-language lookup with sandboxed templating | Flexible per-locale wording without code changes — but a missing template degrades silently to blank print text. |
| Status auto-flip wired off the shared outstanding-amount recompute, not a dunning-specific event | Tracks accounting truth with no scheduled job — but couples dunning to a code path whose main purpose is unrelated. |
6. What to Revisit as the System Grows
- Escalation-level renumbering: if the level is ever used to auto-select a harsher profile, it needs recomputing on read rather than trusted as a stored value.
- Fee tiering: a single flat fee per profile cannot express “first notice free, later notices costlier” without manually switching profiles.
- No proactive detection job: nothing in the read source auto-generates a notice or nudges staff at a new overdue-days threshold — fetch, compute, and resolve are all manually triggered.
- Duplicated interest formula: the same day-count calculation lives independently on the client and server, a maintenance seam worth consolidating.