Specification · purchases @ 0.5

purchases — What You Buy · Area Specification

last conformance run: 9/9 scenarios pass · 30 acts mapped 2026-10-07 20:55 UTC

Spec

Status: 0.5. A spec on the record before a line of module code, so the shape can be argued with. 0.3 makes the statement's own lines the normal path of import_statement. 0.4 adds standing rules: what the person says ahead of time, kept as suggestions, never as bookings. 0.5 adds bills: what vendors say is owed, prepared here, approved by a person, paid in their bank.

1. Calibration: who this is for

One person, a household, or a very small business that wants to know what it buys, what it pays for every month without noticing, and whether it has the receipt. The facts arrive as files: bank and card statements, receipts. Nobody parses them here. The agent reads the file and states what it read; the module keeps the record, checks that the statement adds up, refuses what it cannot reconcile, and remembers where every number came from. Agent first: the import is a batch of rows the agent hands over, never a parser we maintain per bank.

2. Scope

In: statements as sources with control totals, transactions traced to their source row, review and classification, purchases, subscriptions declared and then confirmed by what actually charges, receipts matched to transactions, spend by category and month, per currency.

Out (§9): bank connections and feeds (files only, by design), currency conversion, budgets, forecasts, advice, tax treatment of purchases, paying anything.

3. Entities and lifecycles

4. The acts

Writes (19): import_statement, split_transaction (one row broken into legs that add to it exactly), rename_category (one word changed on every row that carries it), discard_source (a wrong read thrown out whole, with a reason, its hash freed), review_transaction, review_batch (many rows, one reasoned act, validated whole), set_vendor, add_receipt, match_receipt, declare_subscription, cancel_subscription, unmatch_receipt, add_rule (a standing rule, with its window, words or vendor), end_rule (with a reason), accept_suggestions (confirm what rules suggest, one reasoned act, rows the person rejects left out), add_bill, approve_bill (a person only), record_bill_payment, reject_bill. Reads (11): vocabulary (the statuses with their meaning, the person's categories, vendors and active rules — what an agent proposes from), sources, transactions (each unreviewed row with the suggestion a rule makes for it), purchases, subscriptions, receipts, spend, source, rules, bills, payables (open bills by when they fall due, set against the cash on the latest bank statements).

5. Invariants

6. Required read models

sources (what was imported, when, by whom, reconciled), transactions (by period, status, source), purchases (by vendor, category, month), subscriptions (with next expected charge, missed periods, derived status), receipts (matched and unmatched), spend (by category and by month, per currency), source (one source with its rows and the reconciliation).

7. Contract vs freedom

Contract: batch-or-nothing with control totals, provenance on every row, per-currency sums, declared-then-confirmed subscriptions, matching as a reasoned act, hash-refused re-imports. Freedom: category vocabulary (the person's words), vendor alias rules, tolerances, how the agent reads a given bank's layout, which files count as receipts.

8. Conformance (scenarios)

01 a card statement imported whole: control totals reconcile; a second import of the same hash refused; a transposed amount refused with the gap named · 02 overlapping statements: rows already present skipped and listed, new rows in · 03 subscriptions: declared from a purchase, confirmed by the next month's charge, missed when it does not come, lapsed after two · 04 receipts: added, matched with a reason, a mismatched total refused, unmatched shown · 05 splitting: a payroll line broken into wages, employer taxes and the processor's fee; legs that do not add up refused with the gap named; the row itself unchanged · 08 standing rules: a conference rule recorded before the statement, the statement's rows in the window suggested as travel (a refund in the same window not), the suggestions accepted in one act, a second accept refused, a rule that narrows nothing refused, the rule ended · 09 bills: a vendor's bill recorded, the same one forwarded twice refused, one due before its date refused, one with no due date and no terms refused; approval refused to the agent; a payment refused before approval; what is coming due set against the latest statement; a bill rejected with a reason.

9. Deferred — with reasons

ItemWhy deferred, not rejected
Bank connections / feedsFiles are the honest boundary: the person chooses what the agent sees, and every import is a deliberate act. A feed is a standing permission, which is a different product.
Currency conversionSame whole-area concern as everywhere; sums stay per currency.
Budgets, forecasts, adviceSaybooks records; it does not counsel. A budget is policy the person owns.
Business expense treatmentTax deductibility varies by country and status; recording what was bought is our part. The postings themselves reach the accountant's ledger through the bridge area (0.2): a reviewed purchase debits its category and credits the account it was paid from.
Reading receipts (OCR)The agent reads; the module records what it says. No image pipeline here.

---

Change log: 0.5 (2026-10-07) — bills prepared for approval, from Aryeh Munk's phase 1 list ("Enable AI to make payments, process invoices"), reshaped so nothing pays: the agent prepares, a person approves, the bank pays, the record follows. 0.4 (2026-10-07) — standing rules, from Aryeh Munk's phase 1 list ("I am traveling these next 5 days for conference, book to Texas conference"), kept as suggestions so P-5 still holds. 0.2 (2026-09-10) — splitting one row into legs that add to it exactly, for payroll runs and mixed receipts, prompted by a demo session pointing out that one lump to one account would annoy a bookkeeper. 0.1-draft (2026-09-06) — drafted from Peter's and Pavan's "my purchases" idea, with the agent-first import discipline agreed the same day: no parsers, batch-or-nothing, control totals, provenance, refusals.

Acts

ActKindRequired
import_statementwritename kind currency period_start period_end opening_balance closing_balance
review_transactionwritetransaction status
set_vendorwritetransaction vendor
add_receiptwritename hash total currency date
match_receiptwritereceipt transaction
unmatch_receiptwritereceipt
declare_subscriptionwritevendor cadence amount currency
cancel_subscriptionwritesubscription reason
sourcesreadnone
sourcereadsource
transactionsreadnone
purchasesreadnone
subscriptionsreadnone
receiptsreadnone
spendreadnone
review_batchwriterows
vocabularyreadnone
discard_sourcewritesource reason
rename_categorywritefrom to
split_transactionwritetransaction
add_rulewritelabel category
end_rulewriterule reason
accept_suggestionswritereason
rulesreadnone
add_billwritevendor bill_date amount
approve_billwritebill
record_bill_paymentwritebill paid_at
reject_billwritebill reason
billsreadnone
payablesreadnone

Invariants

IdInvariant
P-1Money is integer minor units, signed; every source and transaction carries a currency; sums are per currency and never cross.
P-2Every transaction traces to a source: source id, row index, and the raw line as read.
P-3A statement is accepted whole or not at all: the rows must reconcile to the printed opening and closing balance, and to the printed row count when the statement prints one, or the batch is refused with the gap named.
P-4The same source hash is refused a second time; rows already present are skipped and listed, never silently merged or duplicated.
P-5Nothing is invented: category, vendor and status are empty or unreviewed until an act with a reason sets them.
P-6A subscription is declared, then confirmed by the record: a period with no matching charge is missed, two make it lapsed; a charge is never assumed.
P-7A receipt matches at most one transaction and vice versa; matching is a reasoned act, refused when the total or date does not fit unless overridden with a reason.
P-8Every write is a logged act with an actor; refusals are logged too.
P-9Files never live here: a source or a receipt is its hash, its metadata and what was read.
P-11A standing rule suggests and never books: matching rows stay unreviewed with the suggestion until a reasoned review confirms them; ending a rule removes its suggestions and changes no reviewed row.
P-12A bill is a record, never a payment: only a person approves paying it, a payment is recorded after it happened and by an agent only for an approved bill, a linked statement row must match it, and the same bill is refused twice.

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 card statement imported whole, reconciled, never twice pass

P-2 provenance on every row, P-3 batch-or-nothing against control totals, P-4 the same hash refused.
#ActExpectWhy
1import_statementok rows_in=4 rows_skipped=0 reconciled=true
2import_statementrefused already importedP-4: same hash
3import_statementrefused do not reconcileP-3: a transposed amount — the rows sum to a different closing balance, the gap is named
4transactionsok count=4
5review_transactionok status="transfer"
6review_transactionok status="purchase" category="groceries"
7purchasesok count=1
8review_batchok reviewed=2 vendors_named=2 still_unreviewed=0P-5: many rows, one reasoned act, validated whole
9review_batchrefused money ina batch with one bad row is refused whole

overlapping statements: present rows skipped and listed, new rows in pass

P-4: overlap is reported, never merged silently, never duplicated.
#ActExpectWhy
1import_statementok
2import_statementok rows_in=1 rows_skipped=2
3transactionsok count=5
4discard_sourceok rows_removed=1P-2: a wrong read is discarded whole and imported again; nothing is edited in place
5transactionsok count=4

subscriptions: declared, confirmed, missed, lapsed pass

P-6: the record confirms a subscription; the module never assumes a charge.
#ActExpectWhy
1import_statementok
2vocabularyok unreviewed=4the agent reads the words in use before proposing
3review_transactionok status="recurring" vendor="Netflix" subscription="SUB-0001" subscription_declared=trueP-6: recurring with a vendor declares the subscription
4subscriptionsok count=1 active_total=1 missed_total=0
5subscriptionsok missed_total=0P-6 coverage: no statement covers September yet, so the September period is 'no statement yet', not missed
6import_statementokSeptember's statement arrives without a Netflix charge
7subscriptionsok missed_total=1September has not charged yet as of the 10th: one missed period, still active
8import_statementokOctober's statement, again without Netflix
9subscriptionsok lapsed_total=1two periods without a charge: lapsed, derived
10cancel_subscriptionok status="cancelled"

receipts: added, matched with a reason, mismatch refused, unmatched shown pass

P-7: one receipt, one transaction, a reasoned act; P-9 the file stays with the person.
#ActExpectWhy
1import_statementok
2add_receiptok matched=false
3add_receiptok
4match_receiptrefused totalP-7: 12.50 is not 89.00
5match_receiptok matched=true
6receiptsok count=1
7unmatch_receiptok matched=false

splitting: one line, several things pass

P-10 the legs add to the row exactly or the split is refused; the row itself never moves.
#ActExpectWhy
1import_statementok rows_in=2
2split_transactionrefused not reviewed yetonly spending or income breaks down; the row has not been reviewed
3review_transactionok
4split_transactionrefused gap ofP-10: $7,300 of a $7,420.16 run — the processor's fee is missing
5split_transactionok split_into=3 amount=-742016the legs add up; the row's own amount is untouched
6split_transactionrefused at least two partsa split of one is just the row itself
7split_transactionok split_into=0

the statement as the bank printed it pass

P-3 with text as the normal path: dates without a year taken from the period, unsigned columns signed by the running balance, a printed count checked only when given; P-4 the hash comes from the text, so the same text is refused without anyone inventing an id.
#ActExpectWhy
1import_statementrefused says 7 rows; 6 were readA printed count that disagrees with the lines is refused when one is given; nothing landed, so the text stays free to import.
2import_statementrefused do not reconcileP-3: a misread closing balance is caught by the rows, and the refusal names the gap. Still nothing landed.
3import_statementok rows_in=6 rows_skipped=0 reconciled=trueFive rows read from lines that print no sign and no year: the running balance says which way each went, the period says which year.
4transactionsok count=612/28 is 2026, 01/03 is 2027; the deposit is positive, the rest negative; the single-spaced line with a comma in the payee's name is one row, not two.
5import_statementrefused already imported as SRC-0001P-4: same text, same hash, and nobody had to make an id up.

the balance on each day's last row only pass

P-3 with the shape big banks print: unsigned deposit and withdrawal columns, a running balance on a day's last row only. The signs are solved against each day's movement; when two assignments fit, the description words decide; when none fits, the day is named.
#ActExpectWhy
1import_statementok rows_in=7 rows_skipped=0 reconciled=true01/05: three rows, one balance; only -120 +1,500 -200 reaches +1,180. 01/14: two rows of 100.00 and no movement; 'deposit' and 'refund' say which is which.
2transactionsok count=7
3import_statementrefused Day 2027-01-05A misread digit on a middle row of a day: no sign assignment reaches the printed balance, and the refusal names the day rather than the whole statement.

standing rules: said ahead, suggested, confirmed pass

P-11 a rule suggests and never books; the rows stay unreviewed until a reasoned act confirms them, money in is never suggested as spending, and ending a rule changes no reviewed row.
#ActExpectWhy
1add_ruleok suggesting=0recorded before any statement: nothing to suggest yet
2add_rulerefused matches every rowP-11 a rule must narrow something
3add_rulerefused ends before it starts
4import_statementok rows_in=4 suggested_by_rules=2P-11 the two charges in the window are suggested as travel; the refund in the same window is money in and is not; the grocery run is outside it
5rulesok active=1 suggesting=2
6accept_suggestionsok reviewed=2 still_unreviewed=2P-11 accepting is a reasoned review act
7accept_suggestionsrefused suggesting nothingnothing left to accept: the rows it matched are reviewed
8end_ruleok ended=trueP-11 ending changes no reviewed row

bills: prepared here, approved by a person pass

P-12 a bill is a record, never a payment: the agent records it, a person approves it, the bank pays it, and the record follows. The person's half lives in contract gate 24, because scenarios run as an agent.
#ActExpectWhy
1import_statementoka bank statement, so what is due can be set against cash
2add_billok status="open"
3add_billrefused already on recordP-12 a bill forwarded twice is one bill
4add_billrefused before its own date
5add_billrefused Say when it is duenever a guessed due date
6add_billok due_date="2026-11-04"
7approve_billrefused person's actP-12 approving payment is a person's act, refused to an agent whatever its role
8record_bill_paymentrefused not approvedP-12 an agent records payment only for an approved bill
9payablesok open_count=2Acme's 1,250.00 is due within the week, Northwest's 1,800.00 within the month, against 3,800.00 on the September statement
10reject_billrefused required
11reject_billok status="rejected"
12billsok count=2 open=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.