One upload point, every applicable step. Persist the batch, classify each
file, ONE LiteLLM call (cloud-budget, temperature 0, strict json_schema; PDFs
as unpdf text, never pixels) with an order-item prompt that keeps names
verbatim, header (merchant, date, total), merchant pattern
(coles|woolworths|woolies|aldi|iga|foodworks|costco) -> grocery | food | ask,
exact (platform, merchant, date, total) match against transactions +
expense_metadata, then act:
- food + unique match -> replace line_items via the C2 path, 'enriched'
- food + miss/ambiguity -> 'candidates' (same merchant ±3d) with
intake_use_candidate / intake_create buttons; never auto-creates
- grocery -> original files to pantry POST /api/capture with
finance_target_transaction_id + finance_action enrich|create (C3);
409 -> 'duplicate'
- ask / needs_header / held / duplicate / unsupported as replies, not writes
Reconciliation is against the printed item subtotal (a delivery total is
items + fees - promotions: 18.94 of Coles lines settled at 5.74), or
2%, else 'held'. Originals are kept under INTAKE_FILE_DIR by sha256 so a later
'Groceries' tap can still forward them.
C2 is an idempotent replace on transaction_id = :id OR
matched_transaction_id = :id, accepts INTAKE_TOKEN or RECEIPT_INGEST_TOKEN,
creates the expense_metadata row when absent, 404 when the transaction does
not exist, returns {previous_line_items, line_items} so pantry's undo is the
same call with the previous list.
setup-test-db.sh wrote .env.test once with the Postgres container's bridge IP
baked in, and Docker reassigns those on recreation. The file rotted silently:
its stored 172.22.0.18 belongs to **gitea** as of today, and an earlier drift
had the suite pointing its TRUNCATE at **postgres-pantry** — which it would
have wiped had the credentials happened to match.
Root-cause fix: stop persisting a host at all.
- scripts/resolve-test-db.sh re-derives the DSN from the live container and
rewrites .env.test. Resolution order is TEST_DATABASE_URL, then the
container's published host port, then the bridge IP. `docker port
postgres-personal` reports 127.0.0.1:5432 since the 2026-08-17 PG17
migration, so loopback is what gets used here — and a loopback address
cannot be reassigned to another container, which is the whole point.
(Note this supersedes the smarthome CLAUDE.md's "postgres-personal publishes
no host port"; that is now stale.) The bridge-IP fallback is kept for a host
where the port is not published, and the script never prints the password.
- Wired as `pretest:integration`, so npm runs it before every integration run.
.env.test becomes a generated artifact rather than stored configuration.
- setup-test-db.sh now only creates the database and copies the schema, then
delegates to the resolver.
- helpers.ts::assertTestDatabase stays and its message now points at the
resolver rather than at `docker inspect`. It still earns its place: running
vitest directly bypasses the pretest hook, and a stale .env.test would then
still be read.
Docs: CLAUDE.md gains a Testing section carrying this, the prisma-generate
trap for a fresh checkout or worktree, and the frozen-clock rule for tests
whose subject is defined relative to "now". AGENTS.md updated for the same, and
for lint now being blocking.
- Extract evaluateCondition + rule types into src/lib/rules.ts for testability
- 48 unit tests for evaluateCondition (all fields/operators) and formatCategory
- 21 integration tests for getTransactions filters and getParticipantBalances
- Vitest configs for unit (vitest.config.ts) and integration (vitest.integration.config.ts)
- setup-test-db.sh creates personal_test DB from production schema via pg_dump
- Use vi.doMock + dynamic import pattern to isolate test DB from Prisma singleton