Payment Terms & Installment Scheduling
A design reference for turning a reusable payment-terms plan into dated, trackable installments on a financial document
1. Requirements
1.1 Functional requirements
- Define a reusable Term Definition: the fraction of a total that’s due, when it becomes due, and an optional early-payment discount.
- Group several Term Definitions into an Installment Plan, assignable once and reused across sales invoices, purchase invoices, sales orders, purchase orders, and quotations.
- On assignment, expand the plan into dated Installment Rows carrying a share of that document’s total — due date and discount date computed from the document’s own posting/bill date, never the plan’s authoring date.
- Support three due-date arithmetic modes (and the same three for the discount’s expiry), so a plan can mix “N days after the invoice,” “N days after month-end,” and “N months after month-end” rows.
- Convert each row’s percentage share into a currency amount, in both the document’s transaction currency and the owning legal entity’s base currency.
- Guarantee, before a document is finalized, that the rows’ amounts foot back to the document’s total (with a small rounding tolerance rather than bit-for-bit equality).
- Let a partial payment reduce the outstanding balance of one specific Installment Row instead of the document as a whole, when the assigned plan opts into that.
- Feed rows that are both past due and still outstanding into a downstream overdue-escalation record, without mutating the original row.
- Fall back to a single, whole-document installment (100% due immediately) whenever no plan is assigned.
1.2 Non-functional requirements
- Reusability: a plan is authored once and referenced by name from many documents; editing it does not retroactively rewrite installments already generated elsewhere.
- Determinism: the same plan, posting date, and totals must always produce the same due dates and amounts.
- Overridability: a generated row can still be hand-edited on the document without detaching it from the plan that produced it.
- Auditability of partial payment: it must always be possible to tell, per row, how much has been paid and how much remains.
- Fast-fail validation: authoring mistakes (percentages not footing to 100%, duplicate due dates, negative credit periods) are caught at save time, not during collections.
1.3 Constraints
- The due-date and discount-date arithmetic understands exactly three modes; there is no custom date rule or a fixed calendar date independent of the posting date.
- An Installment Row lives on the document itself as a child row, not a separate top-level ledger; its amount fields are document-local running counters, not derived by summing postings.
- The percentage-to-amount conversion is computed independently per row — there is no automatic “remainder to the last row” adjustment; the plan author must author percentages that foot to exactly 100%.
- Per-installment allocation is only available when the assigned plan explicitly opts into it; otherwise a payment is recorded against the document as a whole.
2. High-Level Design
2.1 Component diagram
The Party Balance Entry and Settlement Entry are the same entities from the general-ledger design; this module adds a finer-grained, document-local layer of dated rows underneath the document’s own totals. An Installment Row’s outstanding field is a maintained snapshot, analogous to the document’s own cached outstanding figure — refreshed by postings, neither one the ledger of record.
2.2 Schedule generation and allocation flow
3. Deep Dive
3.1 Data model
Term Definition — a standalone, named rule reusable across any number of plans: an invoice-portion percentage, a default payment method, a due-date basis with its credit period, and an optional early-payment discount (type, amount, its own validity basis/period).
Installment Plan — a named, ordered collection of Plan Lines, plus a flag that turns on per-installment payment allocation for every document the plan is assigned to.
Plan Line — binds a plan to a Term Definition. Its fields are copied down from the Term Definition when left empty, but can be overridden per plan. Two authoring-time rules apply: portions across all lines must sum to exactly 100.00% (rounded to two decimals) or the plan cannot be saved; and if the per-installment allocation flag is on, every line must reference a named Term Definition, with duplicate lines (identical Term Definition, credit days, credit months, due-date basis) rejected as likely copy-paste mistakes.
Installment Row — the materialized, per-document line generated from a Plan Line (or a single 100% fallback row). Carries due date, discount date, invoice portion, payment amount and its base-currency equivalent, and running outstanding / paid_amount / discounted_amount counters (mirrored in base currency). A negative credit-days or credit-months value is rejected by the row’s own field constraints.
Payment Method — a generic label for how money moves (cash, cheque, wire, a named external collector); a Term Definition can carry a default one that flows down to every row generated from it.
Overdue Installment Snapshot — a copy of one Installment Row’s key figures taken onto a separate escalation document once that row is past due and still carrying a balance. It links back to the source document and row, but its own figures are independent counters — updating a snapshot never rewrites the row it was copied from. Building that escalation workflow is a separate concern, out of scope here.
3.2 Due-date and discount-date arithmetic
Both a row’s due date and its discount-expiry date are computed by the same three-mode arithmetic, seeded from the document’s bill date (falling back to its posting date):
mode = "Day(s) after invoice date"
→ date + credit_days
mode = "Day(s) after the end of the invoice month"
→ last_day_of(date's month) + credit_days
mode = "Month(s) after the end of the invoice month"
→ last_day_of( month(date) + credit_months )
The discount-expiry date uses the identical three modes against the discount’s own validity period, and is left blank entirely whenever the term carries no discount. One guard rail sits on top of the due-date result: if the computed due date would fall before the document’s posting date (e.g., an author mistakenly leaves credit days at zero on a month-end-based term whose arithmetic lands earlier than posting), the due date is forced forward to the posting date rather than allowed to predate the document.
3.3 Percentage-to-amount conversion and the rounding remainder
Every row with a portion percentage gets its payment amount computed independently:
payment_amount = round(grand_total x portion / 100, amount_precision)
base_payment_amount = round(base_grand_total x portion / 100, amount_precision)
outstanding = payment_amount (at generation time)
There is no cross-row remainder redistribution — each row rounds on its own, and whatever a plan’s percentages foot to at the two-decimal level is what the rows sum to. The plan-level rule already forces the portions to sum to exactly 100.00%, so most totals reconcile exactly, but a total with more effective precision than the portions can still leave a few cents of drift after independent per-row rounding. That drift isn’t blocked — it’s tolerated up to an absolute 0.1-unit band at validation time (3.4), rather than being swept into a distinct “rounding” row.
A worked example makes both effects concrete: a document with grand total 100.01, a plan with three Plan Lines split 33.33% / 33.33% / 33.34% (foots to 100.00%, so the plan saves cleanly), due-date basis “Day(s) after invoice date” at 30/60/90 credit days, posting date 2026-07-30:
Row 1: portion 33.33% -> payment_amount = round(100.01 x 33.33 / 100, 2) = 33.33 due 2026-08-29
Row 2: portion 33.33% -> payment_amount = round(100.01 x 33.33 / 100, 2) = 33.33 due 2026-09-28
Row 3: portion 33.34% -> payment_amount = round(100.01 x 33.34 / 100, 2) = 33.34 due 2026-10-28
Row total = 33.33 + 33.33 + 33.34 = 100.00
Document grand total = 100.01
Difference = 0.01 (within the 0.1 tolerance -> schedule is accepted)
The one-cent gap here is never assigned to any row; it simply passes validation because it falls inside the tolerance band.
3.4 Validation rules
- Plan authoring time: a plan’s Plan Line portions must sum to 100.00% (two decimals) or saving is blocked; duplicate lines (same Term Definition + credit period + due-date basis) are rejected the same way.
- Document save time: the sum of Installment Row payment amounts (and base-currency amounts) must be within 0.1 of the document’s grand total, net of write-off and advance — otherwise saving is blocked.
- Date integrity: no two rows on a document may share a due date; a discount date can never fall after its own row’s due date; for quotations and sales orders a row’s due date cannot precede the document’s transaction date.
- Field-level guards: credit days and credit months are constrained to non-negative values.
3.5 Attributing partial payments to specific installments
Whether a payment can target one Installment Row instead of the whole document is controlled by the plan’s per-installment allocation flag:
- Flag off (default): a Settlement Entry’s allocation is recorded against the document as a whole; rows are untouched unless an allocation line explicitly names a Term Definition.
- Flag on: raising a Settlement Entry pre-populates one allocation line per row still carrying a balance, each defaulting to that row’s remaining outstanding (converted using the document’s own conversion rate). The same splitting applies when picking outstanding documents to pay down — one selectable line per unpaid row instead of one per document.
For every allocation line naming a Term Definition, submission looks up that row’s current outstanding, paid_amount, and any qualifying discount: it rejects the allocation outright if the amount exceeds that row’s own remaining outstanding, even if the document overall has capacity elsewhere; otherwise it increases paid_amount (net of any discount) and its base-currency equivalent, decreases outstanding by the allocated amount, and increases discounted_amount if a discount applied. Cancelling a settlement reverses the same arithmetic, so resubmission after cancellation cannot double-count.
3.6 Early-payment discount
A Term Definition’s discount is either a percentage of the grand total or a fixed amount, valid only until the row’s discount date (3.2). A settlement raised on or before that date, against a row without a discount already applied, deducts the amount from what’s actually collected — percentage off the grand total (base or transaction currency, per which side is multi-currency), flat amount as-is — and posts the deducted amount separately as an income/tax loss rather than letting it vanish from the settlement.
4. Scale and Reliability
- Read/write pattern: schedule generation is cheap and runs once per document save; the hot path is per-document validation and the settlement-time row lookup and update, both scoped to a single document’s own rows.
- No cross-document contention: Installment Rows are child rows of their own document, so two unrelated documents’ schedules never contend for the same lock — concurrency only matters within one document’s own rows, updated by direct, targeted row updates keyed on the document and the specific Term Definition.
- Idempotency of settlement reversal: cancellation applies the exact inverse of the original increment/decrement, so cancel-then-resubmit cycles do not drift a row’s running balances, provided each settlement is cancelled at most once.
- Consistency with the document-level balance: a row’s outstanding figure is a subordinate, finer-grained snapshot; the authoritative receivable/payable balance for the document still comes from the Party Balance Entry mechanism in the general-ledger design. The two are expected to stay reconcilable but are maintained through separate update paths.
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Installment Rows live as child rows on the document, not a separate ledger | Simple to query and print; but outstanding/paid_amount are direct counters, not derived from postings, so they can drift if updated outside the normal settlement path. |
| Per-row rounding, no remainder redistribution | Stateless, identical formula per row; a plan whose percentages don’t foot to the total’s actual precision leaves a small, silently-tolerated gap rather than a reconciled schedule. |
| A fixed 0.1 absolute tolerance on the schedule-total check, not exact equality | Absorbs routine rounding drift without blocking ordinary documents; would also silently accept a larger, genuine data-entry mistake entered manually. |
| Per-installment allocation is opt-in, not the default | Keeps the common case simple; two documents on similar-looking plans can behave completely differently at settlement time depending on one checkbox. |
| Discount eligibility checked only against the discount date, not how much has been paid | Easy to reason about, but a partial early payment can claim a discount off the row’s full amount rather than the remaining balance. |
| Overdue tracking is a copied snapshot, not a live join | Decoupled and safe to re-run; but the snapshot must be kept in step with the source row by a separate update path, not by construction. |
6. What to Revisit as the System Grows
- Remainder handling: if plans with many rows against odd totals become common, consider assigning the rounding drift explicitly to a designated row (typically the last one) instead of leaning on the blanket 0.1 tolerance, so schedules foot exactly rather than approximately.
- Discount fairness on partial payments: reconsider whether an early-payment discount should scale down when only part of a row’s balance is paid before the discount date, rather than being available in full regardless of how much is actually being settled.
- Drift detection between row-level and document-level balances: since the two are maintained through separate update paths, a periodic reconciliation check between a row’s outstanding total and the document’s own balance would catch divergence introduced by manual edits.
- Plan-level allocation flag visibility: per-installment allocation is a single, easy-to-miss checkbox on the plan rather than a property visible on the document itself; surfacing it more prominently would reduce audit surprises.
- Out of scope here: the escalation workflow that consumes the Overdue Installment Snapshot (interest computation, escalation levels, communication) belongs to a separate, dedicated design.