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
- source — one file the agent read: name, a content hash the agent computed, kind (bank | card | other), the account label as printed, period start and end, opening and closing balance, row count, currency. Immutable once accepted. The same hash is never accepted twice.
- transaction — one row of a source: date, signed amount in minor units, description as printed, counterparty if the row names one, source and row index, the raw line as read. Status: unreviewed → purchase | recurring | transfer | income | fee | ignored, set by review with a reason; recurring is spending that repeats, and naming its vendor declares the subscription. Never edited in amount or date: a wrong row is a wrong source, re-imported after the agent re-reads it.
- purchase — a transaction reviewed as spending: vendor, category, note, optional receipt. Category empty until someone sets it; empty beats guessed.
- vendor — a name and its aliases as statements spell it, so the same shop is one vendor.
- subscription — a declared recurring charge: vendor, cadence (weekly | monthly | yearly), expected amount and currency, tolerance, start. Status: active | cancelled | lapsed. Confirmed by matches: each period either has a matching purchase, or shows as missed. Lapsed is derived (two periods missed), never asserted; cancelled is an act with a reason.
- rule — something the person said ahead of time ("the next five days are the Texas conference, book it to travel"): a label, the category and status it suggests, and what it matches (a date window, words the statement line contains, a vendor, a currency; at least one of window, words or vendor). Status: active | ended, ending is an act with a reason. A rule never books a row: it suggests, and the suggestion is computed when rows are read, so ending a rule removes its suggestions and leaves every reviewed row as the person confirmed it.
- bill — what a vendor says the person owes: the vendor, their invoice number, the bill and due dates, the amount and currency as printed, a category in the person's words, the file it came from (name and hash; the file stays with the person). open → approved (a person only, optionally with a pay-by date) → paid (recorded after the fact with the day it went and how, linked to the statement row that shows it) | rejected (with a reason). A bill is a record, never a payment.
- receipt — an attachment the person gave the agent: file name, hash, vendor, date, total, currency. Matched to at most one transaction, by an act with a reason; unmatched is a visible state, not an error.
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
- P-1 Money is integer minor units, signed; every source and every transaction carries a currency; sums are per currency and never cross.
- P-2 Every transaction traces to a source: source id, row index, and the raw line as the agent read it. A transaction without provenance cannot exist.
- P-3 A statement is accepted whole or not at all. The agent hands over the statement's lines as it was given them and states the control totals the statement prints: the opening and closing balance always, the row count when one is printed. The batch is refused when the rows do not reconcile to them, naming the gap. That is where a transcription error is caught. (0.3: the lines as text are the normal path and the hash is computed from them; typed rows remain for a statement with no text, and then carry the hash. Five runs out of five, a 26B model retyped forty-seven rows into fields when the schema listed fields first.)
- P-4 The same source (by hash) is refused a second time. Rows already present from an overlapping source (same date, amount, description) are skipped and listed in the result, never silently merged and never silently duplicated.
- P-5 Nothing is invented: category, vendor and status are empty or unreviewed until an act with a reason sets them. Review is an act, and the reason is part of the record.
- P-10 One row may break into several categories, and the legs must add to the row exactly or the split is refused with the gap named. The row's own amount, date and provenance never move: the statement said what it said, and a breakdown is an annotation on top of it.
- P-11 A standing rule suggests and never books. A row a rule matches stays unreviewed and carries the suggestion, the rule's name and id, until a review act confirms it; accepting is a review, so it carries a reason and follows the person's confirmation. A rule suggesting spending never suggests it for money in, nor income for money out. The most specific rule wins (words, then vendor, then the narrowest window), and other matching rules that say something different are listed with the suggestion. Ending a rule removes its suggestions and changes no reviewed row. A rule must narrow something: one that would match every row is refused, as is a window that ends before it starts.
- P-12 A bill is a record, never a payment: nothing here pays anything. Approving payment is a person's act, refused to an agent whatever its role. A payment is recorded only after it happened, with the day it went (never in the future), and an agent records one only for an approved bill; a person recording it approves it in the same act. A linked statement row must be money out, in the bill's currency, for the bill's amount, and pays one bill only; an unreviewed row is reviewed as the bill says. The same vendor and invoice number is refused a second time, as is a bill with no number that matches another's vendor, amount and date. A bill due before its own date is refused. Bills count on a cash basis: the statement row that paid one reaches the books, and bills unpaid at a date are named beside the statements.
- P-6 A subscription is declared, then confirmed by the record: a period with no matching purchase is shown as missed — but only once a statement covering that period has been read; before that it is "no statement yet". Two missed periods make it lapsed. A vendor may carry several subscriptions (different cadence or amount). The module never assumes a charge happened.
- P-7 A receipt matches at most one transaction and a transaction at most one receipt; matching is an act with a reason, refused when amounts differ beyond the receipt's stated total or dates differ by more than seven days, unless the reason says why.
- P-8 Every write is a logged act with an actor; refusals are logged too.
- P-9 Files never live here. A source is its hash, its metadata and its rows; the file stays with the person. The record can prove what was read, not re-show the page.
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
| Item | Why deferred, not rejected |
|---|---|
| Bank connections / feeds | Files 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 conversion | Same whole-area concern as everywhere; sums stay per currency. |
| Budgets, forecasts, advice | Saybooks records; it does not counsel. A budget is policy the person owns. |
| Business expense treatment | Tax 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
| Act | Kind | Required |
|---|---|---|
import_statement | write | name kind currency period_start period_end opening_balance closing_balance |
review_transaction | write | transaction status |
set_vendor | write | transaction vendor |
add_receipt | write | name hash total currency date |
match_receipt | write | receipt transaction |
unmatch_receipt | write | receipt |
declare_subscription | write | vendor cadence amount currency |
cancel_subscription | write | subscription reason |
sources | read | none |
source | read | source |
transactions | read | none |
purchases | read | none |
subscriptions | read | none |
receipts | read | none |
spend | read | none |
review_batch | write | rows |
vocabulary | read | none |
discard_source | write | source reason |
rename_category | write | from to |
split_transaction | write | transaction |
add_rule | write | label category |
end_rule | write | rule reason |
accept_suggestions | write | reason |
rules | read | none |
add_bill | write | vendor bill_date amount |
approve_bill | write | bill |
record_bill_payment | write | bill paid_at |
reject_bill | write | bill reason |
bills | read | none |
payables | read | none |
Invariants
| Id | Invariant |
|---|---|
P-1 | Money is integer minor units, signed; every source and transaction carries a currency; sums are per currency and never cross. |
P-2 | Every transaction traces to a source: source id, row index, and the raw line as read. |
P-3 | A 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-4 | The same source hash is refused a second time; rows already present are skipped and listed, never silently merged or duplicated. |
P-5 | Nothing is invented: category, vendor and status are empty or unreviewed until an act with a reason sets them. |
P-6 | A 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-7 | A 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-8 | Every write is a logged act with an actor; refusals are logged too. |
P-9 | Files never live here: a source or a receipt is its hash, its metadata and what was read. |
P-11 | A 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-12 | A 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
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | import_statement | ok rows_in=4 rows_skipped=0 reconciled=true | |
| 2 | import_statement | refused already imported | P-4: same hash |
| 3 | import_statement | refused do not reconcile | P-3: a transposed amount — the rows sum to a different closing balance, the gap is named |
| 4 | transactions | ok count=4 | |
| 5 | review_transaction | ok status="transfer" | |
| 6 | review_transaction | ok status="purchase" category="groceries" | |
| 7 | purchases | ok count=1 | |
| 8 | review_batch | ok reviewed=2 vendors_named=2 still_unreviewed=0 | P-5: many rows, one reasoned act, validated whole |
| 9 | review_batch | refused money in | a batch with one bad row is refused whole |
overlapping statements: present rows skipped and listed, new rows in pass
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | import_statement | ok | |
| 2 | import_statement | ok rows_in=1 rows_skipped=2 | |
| 3 | transactions | ok count=5 | |
| 4 | discard_source | ok rows_removed=1 | P-2: a wrong read is discarded whole and imported again; nothing is edited in place |
| 5 | transactions | ok count=4 |
subscriptions: declared, confirmed, missed, lapsed pass
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | import_statement | ok | |
| 2 | vocabulary | ok unreviewed=4 | the agent reads the words in use before proposing |
| 3 | review_transaction | ok status="recurring" vendor="Netflix" subscription="SUB-0001" subscription_declared=true | P-6: recurring with a vendor declares the subscription |
| 4 | subscriptions | ok count=1 active_total=1 missed_total=0 | |
| 5 | subscriptions | ok missed_total=0 | P-6 coverage: no statement covers September yet, so the September period is 'no statement yet', not missed |
| 6 | import_statement | ok | September's statement arrives without a Netflix charge |
| 7 | subscriptions | ok missed_total=1 | September has not charged yet as of the 10th: one missed period, still active |
| 8 | import_statement | ok | October's statement, again without Netflix |
| 9 | subscriptions | ok lapsed_total=1 | two periods without a charge: lapsed, derived |
| 10 | cancel_subscription | ok status="cancelled" |
receipts: added, matched with a reason, mismatch refused, unmatched shown pass
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | import_statement | ok | |
| 2 | add_receipt | ok matched=false | |
| 3 | add_receipt | ok | |
| 4 | match_receipt | refused total | P-7: 12.50 is not 89.00 |
| 5 | match_receipt | ok matched=true | |
| 6 | receipts | ok count=1 | |
| 7 | unmatch_receipt | ok matched=false |
splitting: one line, several things pass
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | import_statement | ok rows_in=2 | |
| 2 | split_transaction | refused not reviewed yet | only spending or income breaks down; the row has not been reviewed |
| 3 | review_transaction | ok | |
| 4 | split_transaction | refused gap of | P-10: $7,300 of a $7,420.16 run — the processor's fee is missing |
| 5 | split_transaction | ok split_into=3 amount=-742016 | the legs add up; the row's own amount is untouched |
| 6 | split_transaction | refused at least two parts | a split of one is just the row itself |
| 7 | split_transaction | ok split_into=0 |
the statement as the bank printed it pass
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | import_statement | refused says 7 rows; 6 were read | A printed count that disagrees with the lines is refused when one is given; nothing landed, so the text stays free to import. |
| 2 | import_statement | refused do not reconcile | P-3: a misread closing balance is caught by the rows, and the refusal names the gap. Still nothing landed. |
| 3 | import_statement | ok rows_in=6 rows_skipped=0 reconciled=true | Five rows read from lines that print no sign and no year: the running balance says which way each went, the period says which year. |
| 4 | transactions | ok count=6 | 12/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. |
| 5 | import_statement | refused already imported as SRC-0001 | P-4: same text, same hash, and nobody had to make an id up. |
the balance on each day's last row only pass
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | import_statement | ok rows_in=7 rows_skipped=0 reconciled=true | 01/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. |
| 2 | transactions | ok count=7 | |
| 3 | import_statement | refused Day 2027-01-05 | A 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
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | add_rule | ok suggesting=0 | recorded before any statement: nothing to suggest yet |
| 2 | add_rule | refused matches every row | P-11 a rule must narrow something |
| 3 | add_rule | refused ends before it starts | |
| 4 | import_statement | ok rows_in=4 suggested_by_rules=2 | P-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 |
| 5 | rules | ok active=1 suggesting=2 | |
| 6 | accept_suggestions | ok reviewed=2 still_unreviewed=2 | P-11 accepting is a reasoned review act |
| 7 | accept_suggestions | refused suggesting nothing | nothing left to accept: the rows it matched are reviewed |
| 8 | end_rule | ok ended=true | P-11 ending changes no reviewed row |
bills: prepared here, approved by a person pass
| # | Act | Expect | Why |
|---|---|---|---|
| 1 | import_statement | ok | a bank statement, so what is due can be set against cash |
| 2 | add_bill | ok status="open" | |
| 3 | add_bill | refused already on record | P-12 a bill forwarded twice is one bill |
| 4 | add_bill | refused before its own date | |
| 5 | add_bill | refused Say when it is due | never a guessed due date |
| 6 | add_bill | ok due_date="2026-11-04" | |
| 7 | approve_bill | refused person's act | P-12 approving payment is a person's act, refused to an agent whatever its role |
| 8 | record_bill_payment | refused not approved | P-12 an agent records payment only for an approved bill |
| 9 | payables | ok open_count=2 | Acme'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 |
| 10 | reject_bill | refused required | |
| 11 | reject_bill | ok status="rejected" | |
| 12 | bills | ok 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.