Specification · loans @ 0.1

loans — Loans · Area Specification

last conformance run: 4/4 scenarios pass · 7 acts mapped 2026-10-25 22:49 UTC

Spec

Status: 0.1. A mortgage, a car loan, a student or personal loan, kept from the lender's figures. The statement rows that paid it are split into principal, interest and escrow; what is owed, the year ahead, the payoff date and the interest paid each year follow. Part D of the personal finances plan (docs/plan-personal-finances.md).

1. Calibration: who this is for

A household with a mortgage and a car loan, whose payments go out of checking each month. They want to know what is still owed, when it ends, how much went to interest this year, and whether their record agrees with the lender's. Their accountant wants mortgage interest as its own figure.

2. Scope

In: a loan from the lender's figures (owed on a day, a fixed rate, the monthly payment, escrow, the due day); linking each statement row that paid it, split as the lender printed it or by the schedule; what is owed, the twelve months ahead and the payoff date; the lender's figure against ours; interest, principal and escrow per year; the loan's balance in net worth.

Out (§9): variable rates, recasts and refinances (start a new loan from the new figures), escrow accounting beyond the monthly amount, lines of credit, and paying anything.

3. Entities and lifecycles

4. The acts

Writes: add, link_payment, unlink_payment. Reads: loans, loan (payments with splits, the year ahead, the payoff, the lender's figure against ours), candidates (rows that look like a payment, with why), interest (per loan and year).

5. Invariants

6. Required read models

loans and loan (owed and as of when, rate, next payment, payoff date, months left, interest left, interest this year, the lender tie), candidates, interest.

7. Contract vs freedom

Contract: the lender's figures, exact splits, one posting per payment, a named difference with the lender. Freedom: when to link, which statement a payment shows on.

8. Conformance (scenarios)

01 a mortgage payment suggested, linked and split by the schedule; what is owed goes down by its principal · 02 a lender split that does not add up refused, one that does recorded as the lender's · 03 a payment that cannot cover the interest refused at the start, and a row dated inside the starting balance refused · 04 a year's interest from two payments. The postings, net worth and the lender tie are contract gate 24.

9. Out of scope, on purpose

Variable rates and recasts (a new loan from the new figures), escrow account balances, lines of credit, and payments: these books record what the bank statement shows was paid.

Acts

ActKindRequired
addwritename kind currency balance balance_date rate_bp payment due_day source
link_paymentwriteloan transaction reason
unlink_paymentwriteloan payment_id reason
loansreadnone
loanreadloan
candidatesreadnone
interestreadnone

Invariants

IdInvariant
L-1Money is integer cents; a payment's principal, interest and escrow add up to its row exactly.
L-2A loan starts from the lender's figures with their source; a payment that cannot cover a month's interest is refused.
L-3A linked row is in the books once: principal off what is owed, interest and escrow as spending, paid from its account.
L-4The lender's printed split is the record; a split by the schedule says so.
L-5What is owed is the lender's figure less the principal paid since; a difference with the lender's later figure is named.
L-6Suggestions never link.
L-7Every write is a logged command with an actor; refusals are logged too.

Scenarios

Each scenario is a file of acts with expected outcomes. ok means the act must succeed with the listed fields; refused means the act must be refused with a sentence containing the listed text. Refusals are contract.

a mortgage payment suggested, linked and split by the schedule pass

L-3, L-4, L-6: the schedule's split says so; what is owed goes down by the principal.
Environment: import_statement
#ActExpectWhy
1addokL-2
2candidatesok count=2L-6
3link_paymentok split_from="schedule" interest=162500 principal=42500 escrow=45000 owed=29957500L-4
4loanok owed=29957500 status="open"

the lender's split is the record, and it adds up pass

L-1, L-4: a split that does not add up is refused; the lender's split is kept as printed.
Environment: import_statement
#ActExpectWhy
1addokL-2
2link_paymentrefused adds up exactlyL-1
3link_paymentok split_from="lender" principal=42600L-4

a payment that cannot cover the interest, and a row inside the starting balance, are refused pass

L-2: the figures must make sense together; a row before the lender's figure is already inside it.
Environment: import_statement
#ActExpectWhy
1addrefused does not cover a month's interestL-2
2addok
3link_paymentrefused already inside the balance

a year's interest from two payments pass

L-3: interest per loan and year, from the splits.
Environment: import_statement
#ActExpectWhy
1addokL-2
2link_paymentok
3link_paymentok
4interestok count=1

These pages are generated from the files in specs/ in the repository; scenarios are executed by src/conformance.js on every build. Written and maintained by Peter Varga, who also runs Portland AI Works. github.com/alviso/saybooks, AGPL-3.0.