631e75e58f34eda0dc82119cee826a335be6fe96
148
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
631e75e58f |
test(intake): cover the review findings, and CLAUDE.md for the rules they set
ci / lint-test (push) Successful in 59s
Unit: word-boundary merchant matching (Bigals Pizza is not an IGA), mrkdwn escaping vs the plain_text button label, a foreign total on a card, and the finish_reason gate. Integration: the NULL-platform wildcard on both the food and grocery paths, candidates crossing platforms, EXCLUDE_RECONCILED_SOURCE, the pinned metadata row, the panel reading the row the write landed on, the once-per-batch undo baseline, the priceless-upload hold, Create re-matching, two pending cards booking once, a pantry-resolved sibling, and a USD receipt. Eleven of the thirteen fail against the pre-fix tree; the exceptions are the no-batch-id baseline guard (a no-change assertion) and the concurrent-tap one, which is a race and passed either way. |
||
|
|
c25db5211b |
intake: a NULL platform is "no platform", and Create re-checks before it inserts
Six fixes in the router, all of them cases where a stale or over-wide answer was written rather than re-asked. - ($3 IS NULL OR em.platform = $3) made an in-store receipt a wildcard: a till receipt from the Coles tap matched the Uber Eats Coles order for the same day and money, and overwrote its items. In-store now matches only rows with no platform of their own. - findCandidates loses its platform filter entirely. That card is a human choice, shown precisely because the exact key missed — the platform is one of the things that may be wrong, so the list must widen there, not narrow. - createFromBatch re-runs the match. The card is minutes old by the time it is tapped and the email/order bridge books orders on its own schedule; the match it was drawn from is not current. - ...and checks for a resolved sibling batch. findDuplicateBatch ignores unresolved batches by design, so two pending cards for one order each booked it. - INSERT ... ON CONFLICT (source, source_ref) DO NOTHING with a re-select, rather than SELECT-then-INSERT, which loses a double tap. - A non-AUD receipt is booked as order-ingestion.ts books one: amount native, amount_aud NULL, foreign_currency_* set. No FX rate exists at intake. Also: EXCLUDE_RECONCILED_SOURCE in both queries instead of a bare superseded_by_id check — a reconciled manual twin at the exact key read as an ambiguity, which is the same spend counted twice. And routeFood refuses to replace an itemised receipt with an extraction carrying no prices at all: the reconciliation check passes those (there is nothing to check), so without this a priceless upload silently destroys a good record. It holds instead. RouteContext.subtotal was written and never read; it is replaced by currency, which the cards need. |
||
|
|
9a442b7355 |
intake: escape mrkdwn, and stop printing every total as dollars
Merchant names, transaction descriptions and model output are none of ours: Slack reads &, < and > as markup, so "Ben & Jerry's" and a description carrying <http://…|text> rendered as something the card never meant to show. esc() on every interpolated value in mrkdwn — never on our own literal *bold*, and never on a button's plain_text label, where entities would print verbatim. money() also hard-coded "$" while the order nudge already prints "USD 15.00". The cards now carry the batch's currency. |
||
|
|
f9638cc5e5 |
line-items: one metadata row, agreed on by the write and the read
Three defects on one surface, all invisible while a transaction has exactly one expense_metadata row — which is almost always. 1. ORDER BY (transaction_id = $1) DESC did the opposite of its comment. Postgres orders DESC as NULLS FIRST, and the row that merely POINTS at the transaction has a NULL transaction_id, so the clause written to prefer the owned row preferred the other one — in exactly the case it exists for. matchTransaction already spelled out NULLS LAST; now so do this and GET /api/transactions/[id]/order, which had no ORDER BY at all and could read a different row from the one an enrichment had just written. 2. replaceLineItems takes an optional metadataId. The match resolves a row through a platform-filtered join; re-picking independently here can land the write on a row the match ruled out. 3. previous_line_items on intake_batches is the undo baseline, recorded once per batch under COALESCE and read back. Returning whatever was just read hands a pantry retry its own write as the "previous" state, so undo restores the thing it undid. Migration 0034 is unapplied anywhere, so the column is added there rather than in a 0035. |
||
|
|
7424d13858 |
intake: only a reply that finished is a reply the schema constrained
liteLLMExtractor rejected finish_reason "length" and accepted everything else, so a content_filter or tool_calls stop was parsed as if the strict json_schema had held. Reject any reason present and not "stop", naming it. |
||
|
|
be3d10d4f1 |
intake: match merchants on whole words, not substrings
merchantKey("Bigals Pizza") contains "iga", so GROCERY_PATTERN filed a pizza
shop as a grocer and merchantMatches matched it against every IGA on the same
date and total. Short names in a substring list collide with ordinary words,
and the collision reads as a correct classification rather than a bug.
GROCERY_PATTERN is now a word-boundary alternation; merchantMatches asks
whether one name's words appear as a contiguous run inside the other's, which
keeps Coles <-> Coles (Wyndham Vale) and Order - Coles (Wyndham Vale).
Also collapses the third PLATFORM_LABEL copy: order-ingestion's was exported
and read nowhere, order-details.tsx had its own, and the intake one — the only
live copy — was missing `uber`. One map, in intake-rules.
|
||
|
|
32a47bf8ee |
test(intake): rules, PDF text, C2 idempotency, enrich-vs-candidates, pantry hand-off, Slack taps
Unit: merchant pattern, header validation, reconciliation tolerance, verbatim line items, one-call message shape, card action ids; unpdf over the two real Uber Eats exports (Coles $85.74 / 15 lines, Burgertory $22.40 / Michael + Joel). Integration: fetch stubbed at the LiteLLM and pantry boundaries so the PDF path, persistence, match and writes run for real against personal_test. intake-live.test.ts is opt-in (INTAKE_LIVE_TEST=1) and was run green against cloud-budget on both PDFs. |
||
|
|
c7ea516c34 |
slack: dispatch the four intake taps on action_id (plan §11 C5)
intake_use_candidate / intake_create / intake_grocery / intake_meal carry JSON
{batch_id, ...} rather than <id>:<verb>, so they branch before the order-nudge
parse. They act on the persisted batch: enrich the chosen transaction, create
ING-10-style (statement_id NULL, owner_id from shared_by, source/source_ref
make a second press a no-op), or remember the merchant's kind and route. A tap
on an already-resolved batch returns its current card and writes nothing. Card
updates go to response_url, as the existing handlers do.
|
||
|
|
db5c9a02a7 |
intake: POST /api/intake router + PUT /api/transactions/[id]/line-items (plan §11 C1, C2)
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.
|
||
|
|
b958d05287 |
test(db): resolve the integration DSN at run time instead of storing an IP (#161)
ci / lint-test (push) Successful in 1m5s
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. |
||
|
|
eaf5d5ac66 |
test(analytics): guard the three unguarded Priority 0 behaviours (#155)
docs/ui-information-architecture-review.md claimed Priority 0 was guarded by analytics-sql.test.ts. That file only ever covered the three SQL *fragments* (EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, INVESTMENT_SIGNED). Items 2, 3 and 6 of the Priority 0 list had nothing, and all three fail silently — no error, no empty screen, just a number that quietly disagrees with the one beside it. New: src/__tests__/integration/analytics-priority0.test.ts, 18 tests. - **Item 2, pace/headline parity.** /api/analytics/daily summed over a month must equal /api/analytics/monthly's headline to the cent, per category, and across every month in the window. Cents rather than exact equality is the assertion because the two routes group at different grains (month+category vs month+day+category) and both carry 4dp — the displayed grain is what has to agree, which is precisely the bug (the chart ending the month a few cents off the headline above it). Fixtures are only shapes where a naive gross debit sum disagrees: a 50% split, a refund, fee and interest rows, an itemised loan repayment, a reconciled source row, family-tagged spend, transfers/investment/income, and a row someone else paid that I hold a split on. A fourth test asserts the naive sum does NOT match — without it, parity between two queries identical by construction would pass on fixtures too plain to discriminate. - **Item 3, fees date range.** Default 12 months with the window reported (from=2025-09-01, not 08-31 — toDateStr, not toISOString); the older tail excluded; months=0 all-time with a null range rather than a period claim the number does not make; a narrower months=3; the transaction-level fee lines bounded by the same window as the statement totals, since a bounded total beside an unbounded list is the same defect wearing the fix; and a nonsense months param clamped rather than producing an Invalid Date window. - **Item 6, partial-month comparisons.** The in-progress month never enters a baseline; a partial month is compared through the same day of each prior month; a complete month keeps full totals; top movers truncate both sides; year boundary and leap February. Item 6 was untestable by construction, which is why it had no test: the rules were closures in budget/page.tsx reading `new Date()` inline. Extracted to src/lib/partial-month.ts with an injectable clock, and budget/page.tsx now imports them. The extraction also de-duplicates the baseline filter, which was written out identically in two places. The file freezes the clock (vi.useFakeTimers, toFake: ["Date"] only — pg needs real timers for connection handling). Without it the fixtures drift out of the routes' rolling windows over time and the assertions go vacuous, which is the same class of silent failure the file exists to catch. Mutation-checked, all three: breaking daily's spend semantics fails 3 of the item-2 tests (the placebo correctly still passes); forcing the fees window to all-time fails 5; letting the current month into the baseline fails 1; and dropping the elapsed-day truncation fails 3. |
||
|
|
fc9ffaba25 |
chore(ci): clear the lint debt and make lint blocking (#79)
CI ran lint with continue-on-error because 23 errors predated it. All are fixed, so the flag is gone and a typecheck step is added beside it — nothing else in CI compiles the test files, so a type error in a fixture could only ever be found on a developer's machine. Real fixes, no blanket disables and no suppressions: - react-hooks/set-state-in-effect (6): each was "reset or seed state when an input changes", so each becomes a state adjustment during render behind a previous-value guard — the documented React pattern for it. Two were also latent bugs. transactions/page.tsx depended on `others`, rebuilt by .filter() every render, so the effect already ran every render, one commit late. shared/page.tsx depended on the `tagIds` array identity, so the row window was reset more often than "changing any filter" claims — including after "Show more", the one interaction it must not undo; the key now joins by value. csv-import-modal reads localStorage once at mount, which is a lazy useState initializer, not an effect (SSR-guarded, though the modal is mounted from client state and never prerenders). - react-hooks/static-components (3): SortIcon was declared inside SharedPage, making it a new component type on every render. Hoisted to module scope with the sort state passed as props. - @typescript-eslint/no-explicit-any (9): the dynamically imported route handlers in two integration tests. Typed as their real signatures with one `as unknown as NextRequest` cast at the fixture boundary, so the call sites and response bodies get checked instead of opted out. - react/no-unescaped-entities (5): exact-equivalent entities (', "), so no rendered text changes. Also fixes the one pre-existing tsc error (unit/order-ingestion.test.ts:511): `options` is optional, and `?? 0` keeps the assertion failing on an absent list rather than silencing it with a non-null bang. 9 warnings remain (no-unused-vars, exhaustive-deps). eslint exits 0 on warnings, so lint blocks on errors only — deliberately, since none of the remaining warnings should stop a push. |
||
|
|
9f1be74330 |
transactions: let a gift card be a payment method you can pick
ci / lint-test (push) Successful in 43s
`credits` has been a valid payment_method since migration 0018, which added it so the order pipeline could file gift-card-funded orders. It reached the database, `needsCardMatch()` (which holds those rows out of reconciliation) and `bankLabel()` (which renders them "Gift Card"). It never reached the two places a human touches it. So entering a gift-card purchase by hand was impossible: the dropdown stopped at "Other", the row took the NULL default, and NULL is *reconcilable* — it joined the pending queue to be offered card matches within 3 days and 1% that cannot exist, because the balance already paid the bill. Accepting one is the destructive case the cash exclusion documents: reconciled manual rows are filtered out of every query, so the spend vanishes while an unrelated card row claims to be it. - `src/lib/payment-methods.ts` — one canonical list, mirroring the `statement-types.ts` pattern, plus the TS mirror of `needsCardMatch()`. - `PATCH /api/transactions/[id]` — the `VALID_METHODS` literal omitted `credits`, so the API answered 400 for a value the CHECK constraint admits and 372 live rows already carry. It validates against the list now. - `POST /api/transactions` — validated at all, so a bad value is a 400 naming the field rather than a 500 carrying a Postgres error. - Add modal — offers every method, and explains the consequence when you pick one that suppresses reconciliation. Relabelled "Paid by" → "Payment method", which the edit modal uses for the *owner*; they are different questions. - Edit modal — had no payment-method field at all, so an existing row wrongly in the queue could not be corrected. Manual rows only: the queue is `statement_id IS NULL`, so it decides nothing on a statement row. The test asserts the list against `pg_get_constraintdef` and evaluates `needsCardMatch()` as real SQL rather than re-implementing it, so the next migration that widens the constraint fails the suite instead of drifting for two months. Mutation-tested by dropping `credits`. Unit 130 pass, integration 206 pass (the integration DB needed `npm run test:setup` first — `.env.test` still named a stale container IP). |
||
|
|
2cfaf8e8c8 |
statements: derive account identity, fix the new-bank alert (0033)
ci / lint-test (push) Successful in 42s
account_number fragments exactly like bank_name did, from the same cause: ANZ's Access Advantage arrived as both '4085-56264' (4 statements) and '408556264' (1). uq_statement_identity keyed on it, so re-importing one period under the other spelling evaded the duplicate check. account_number_key is GENERATED ALWAYS — the number with separators stripped — and the unique index moves onto it. Derived rather than rewritten because the raw number is the readable one and some of it is structure: Up stores '633-123 / 176540052', a BSB and an account number, and flattening it would lose a distinction a human reads at a glance to fix a machine problem. The Amex mask and case are preserved; 'X' records which digits were redacted. Not the cause of the documented 31-row / $42,040.68 ANZ duplication — 107/142/143 overlap on different end dates, which that index cannot catch at any spelling. Adds statement_identity_drift: one account under two bank names, or one bank under two spellings of one number. Non-empty means fragmentation the normaliser could not know about. Currently one row, the ANZ pair above, now unified by key. Also documents the N8N regression this series caused. Check Known Bank compared the raw Gemini name against the canonical column before the insert, so nothing ever matched and every Zip statement was tagged pending and held for Slack approval — a stall, not just a noisy alert. Fixed in workflow FysADdFwEtwONQl4 by comparing like with like through normalize_bank_name(), passing currency so a genuinely new national entity still alerts. Verified live: 12s after vs 30-53s before. |
||
|
|
95d8544752 |
transactions: change who paid, on a row and on a statement
ci / lint-test (push) Successful in 46s
Owner was write-once for every ingestion path — a pantry receipt hardcodes DEFAULT_OWNER_ID and there was not one `UPDATE ... SET owner_id` in src/ — so a shop the other person paid for was permanently filed as yours. PATCH /api/transactions/[id] now takes owner_id, for manual rows only. A statement row returns 400 statement_owned and points at the statements page: its effective owner is COALESCE(t.owner_id, s.owner_id), so writing it there would either no-op or detach one row from the account it came from. PATCH /api/statements/[id] is new. The statements page has had an owner dropdown since it was built, wired to a route with no PATCH handler — every change 405'd, and because useUpdateStatement never checked res.ok it failed silently and the select snapped back on refetch. It writes both tables: 2,194 statement rows carry their own owner_id against 1,803 that inherit, so updating `statements` alone moves less than half and splits one account's history between two people. The guard is the point. Access is "owner OR holds a split", so handing a row over while holding no split removes it from your list and 404s every route that could put it back — only the new owner can undo it. That is 409 would_lose_access, and the modal offers both ways forward: add my split first, or give it away anyway. Taking a row onto your own ledger is never blocked, and claiming a row you cannot see is a 404 before any owner logic runs. Splits are deliberately not rewritten. They record shares, not direction, so a 50/50 flips from "they owe me" to "I owe them" untouched, settled included. Also adds the missing res.ok check to useUpdateTransaction, without which every rejection resolved as success: the modal closed, the list refetched, and the edit silently vanished. 14 new integration tests; 203 integration + 130 unit green. |
||
|
|
6c14b493ef |
analytics: your share of what someone else paid is your spend
ci / lint-test (push) Successful in 1m25s
Every spend analytic gated on `OWNER_SCOPE = $1` and scaled by `mySplitOf` *within* that gate, so ownership was a precondition for an expense being yours. Half a grocery shop Sonu paid for counted as zero — in monthly, daily, merchants, subscriptions, fees and the budget page. 167 rows / $3,210.91 across Jan-Jul 2026, worst in April (+$1,354.83, the Europe trips), while getParticipantBalances booked the matching debt correctly. The app could say you owed her for a shop while insisting you had not spent anything on it. New MY_SPEND_SCOPE(): owner = me OR I hold a split. OWNER_SCOPE stays on the things that measure an *account* rather than a person — the income and investment lines, and the statement-level fee rollup. myShare had to change with it, and widening the gate alone would have been worse than the bug: its `100 - everyone else` fallback is the payer's remainder, so on someone else's unsplit row it returns 100 and moves their whole bill onto you. It now branches on ownership — my row resolves as before; their row takes an explicit split row only, absent meaning 0. That 0 is what makes the wider gate safe. my_share_percent is deliberately not read on someone else's row: one unscoped column, writable by anyone who can see the row, so "my" can only mean the owner's. All 402 rows carrying one today are owner-side. MY_SHARE_PCT mirrors myShare for the transactions list, which has no viewer-scoped ts join; a test asserts the two agree across seven fixture shapes. No historical restatement — every non-owner split is 2026-dated, and the 1,266 pre-2026 SplitMyExpenses splits are all on rows you own. Also fixes a latent failure in the NATIVE_CURRENCY test, which inserted a statement relying on participant id 1 existing (owner_id is NOT NULL DEFAULT 1 with an FK) and only passed when a sibling file had left one behind. It now owns its fixture. 15 new integration tests; 189 integration + 130 unit green. |
||
|
|
3edcc27781 |
csv import: map a currency column, or foreign rows land as AUD
ci / lint-test (push) Successful in 41s
Deleting the Frollo importer dropped its currency handling and nothing replaced it: ColumnMapping had no currency column, so applyMapping could never produce foreign_currency_code and batchInsertCSVTransactions' support for it was unreachable from the UI. The USD 10,782 salary imported as A$10,782 — about a third under, sitting in a column of AUD figures looking entirely normal, which is the same defect the owner spotted in the first place. An optional Currency column now sets foreign_currency_code and foreign_currency_amount when the cell is a three-letter code other than AUD, and leaves amount_aud NULL rather than inventing a rate. That is the shape order ingestion already uses and what AMOUNT_UNCONVERTED looks for, so the row renders as its native figure with "no AUD rate" and the statement supplies the real number when it arrives. Caught by reading the imported row rather than the import summary: the summary said 171 inserted and was right about everything it reported. |
||
|
|
461c021e7a |
csv import: exclude what the statements already cover, and delete the Frollo importer
ci / lint-test (push) Successful in 43s
"This is becoming too complex... Frollo should be done through that [the manual
CSV import]" (owner). It was right: a bespoke importer, an API route, a CLI, two
scheduled n8n workflows and a shared secret existed to do what the CSV import
modal already did, minus one rule.
That rule is statement coverage, and it turns out to be the whole thing. Applying
each account's newest billing_end_date as a watermark takes the real 2,607-row
Frollo export down to 171 rows — with no account allowlist, no credit-card
exclusion and no Frollo-specific scoping at all. Cards drop out on their own
because their statements are current; the 46 card rows that survive are genuinely
post-statement. Every bit of the bespoke apparatus was doing by hand what one
query does generically.
Deleted: src/lib/frollo-csv.ts, src/lib/frollo-ingest.ts, scripts/import-frollo.mts,
src/app/api/frollo/, both test files, the FROLLO_INGEST_TOKEN wiring, and the n8n
Frollo Import + Frollo Freshness Check workflows.
Added to the shared CSV path, so every import benefits:
- getStatementCoverage() + /api/import/statement-coverage. The review step
leaves out rows an account's statements already cover and says how many, with
the rows one click away. Only applies when an account column is mapped and
that account has statements — a row is never dropped on a guess.
- Optional Account and Row ID columns in the mapper. Account drives the
watermark and is stored as source_account; Row ID becomes source_ref.
- An in-file duplicate warning. A CDR re-consent re-exports history under
fresh ids, so source_ref cannot see it — 385 twins in one 2,563-row export
doubled every salary payment, and that is not visible by eye in a review
table.
Two pre-existing bugs in that path, both of which this plan depends on:
- The category chosen in the review step was accepted by
batchInsertCSVTransactions and then left out of the INSERT column list, so it
was silently discarded and the trigger wrote 'other'. Not cosmetic: an
uncategorised credit is admitted by NET_SPEND_ROWS and negated by
SPEND_SIGNED, so 62 imported transfers cancelled $74,338 of spend while
counting as no income.
- The path had no idempotency whatsoever. row_index is assigned MAX+1 on every
run, which makes uq_transaction_identity structurally unable to fire, so a
second import of the same file duplicated all of it. Now writes source +
source_ref with ON CONFLICT DO NOTHING.
awaitsStatementLine()'s removal note is kept but rewritten: it no longer points
at a deleted file, and the lesson stands — the queue jump from 8 to 558 was the
measurement, not the noise.
|
||
|
|
b82c4570bd |
frollo: don't import what the statements already cover
ci / lint-test (push) Successful in 45s
"We should not be importing from Frollo what we already have from statements" (owner). The amount+direction guard could not deliver that, because the two sources decompose the same event differently: Frollo bundles the Wise fee into the transfer (10001.13) where the statement itemises it (10000.00 + 1.13). So 38 Wise USD rows passed the amount guard as new while being the same money, and were the reason the transactions view showed a USD figure where every neighbouring row showed AUD. A statement's billing_end_date is a hard watermark: everything on that account up to that date is already in the ledger, itemised and converted. Guard 1 now drops any feed row at or before its account's newest statement. Guard 2 (amount + direction) stays as the net for accounts that have no statement at all. Note this is the coverage test the first import needed and got wrong. That one asked whether a row's date fell inside a statement's min-max window, which for periods spanning 182 to 460 days swallows a year and answers nothing. The watermark asks a question that has an answer: up to what date is this account complete? Matching is on last4, verified against the live statement set — the eight in-scope accounts with statements each map to one bank, no cross-bank collision. The watermarks are printed by the CLI so a wrong boundary is visible rather than inferred from a row count. Re-imported from empty: 425 covered by statement, 15 amount twins, 110 inserted. Exactly one row now carries a foreign currency with no AUD figure — the 2026-08-12 HDR salary, which is the genuinely-new pre-statement row this feed exists for. Was 39. |
||
|
|
afd75d3f09 |
transactions: never print a foreign amount as AUD
ci / lint-test (push) Successful in 48s
The amount cell rendered formatAmount(amount_aud ?? amount) with no currency argument, so a row with no AUD figure fell back to its native amount and was stamped with a dollar sign. A USD 10,782.00 Wise credit showed as "+$10,782.00" in a column of AUD figures — the same payment from the statement, one row above, correctly showed $15,518.53 over USD 10,782.00. So the feed row read as $10,782 AUD when the real value is about $15,500, and the sub-line just repeated the same number correctly labelled. amount_unconverted is already selected by the transactions query (queries.ts:298) and was simply unused here. When set, the native figure becomes the headline and the absence of a rate is stated rather than papered over. Pre-existing rather than Frollo's: any row with amount_aud NULL and a foreign currency hits it, including foreign order-receipt rows. Frollo made it visible at scale — 39 rows. |
||
|
|
bb8a009e02 |
frollo: don't re-import what the ledger already has, and stop hiding the queue
ci / lint-test (push) Successful in 44s
The first import wrote 550 rows on 2026-08-13. 422 of them (77%) were second
copies of transactions the ledger already held from statements — $1,023,824.63
of movement counted twice. The owner found it by opening the transactions view
and seeing one HDR Global salary listed twice, once as A$15,518.53 from the
statement and once as US$10,782.00 from the feed.
The currency was never the defect. toLedgerRow already left amount_aud NULL and
named the currency in foreign_currency_code, which is the documented contract for
a row whose AUD value is unknown, and the transactions page labels it. What made
it look wrong was the duplicate sitting beside it.
Two changes.
Upstream, ingestFrolloCsv now drops rows the ledger already holds, matching on
amount + direction within LEDGER_MATCH_DAYS (3). Three things had to be right and
the first two were not, each caught only by rehearsing against real data rather
than fixtures:
- pg returns a DATE as a JS Date while Prisma and the CSV give strings.
String(date).slice(0,10) is "Wed Mar 10", which parses to NaN, so the first
dry run reported 550 to insert and zero duplicates. dayMs() takes both.
- Direction has to be in the key. This ledger is full of internal transfers
between the owner's own accounts and the feed carries both legs: 2026-05-18
has +3076.04 into ANZ and -3076.04 out of AMP. Matching on amount alone let
the credit leg consume the ledger's debit row, so the real duplicate was
written — 20 rows got in that way.
- 'refund' is money in. The feed calls a reversed account fee a credit and the
statement importer types it 'refund'; classifying it as an outflow left every
ANZ servicing-fee reversal behind.
Downstream, awaitsStatementLine() is removed. Its premise — feed rows never await
a statement line — was asserted, never tested, and false for almost every
account. Worse is how it got there: the reconcile queue jumped 8 -> 558 when the
feed landed, that jump was read as noise and filtered away, and filtering it
removed the only mechanism that would ever have collapsed the duplicates. The
queue was right. A feed row IS a row awaiting its statement line.
Re-imported: 375 dropped as already-on-ledger, 175 inserted. Residual duplicates
4 rows / $15.01, all sub-$5 account fees where several identical amounts fall in
overlapping windows and greedy consumption picks the wrong one; not chased
further at this scale.
The CLI prints the already-on-ledger count even when zero — a number you have to
go looking for is a number nobody looks at.
|
||
|
|
21e9e765a3 |
Add /api/frollo/ingest so the import can run unattended
ci / lint-test (push) Successful in 40s
Shares one module with the CLI rather than reimplementing the insert: frollo-ingest.ts holds parsing, scoping, de-duplication and the write, and both callers pass in their own SQL executor (Prisma in the route, a pg client in the script). The alternative is two implementations of the same insert, which is how the pantry healthcheck came to be fixed in one repo and left broken in the other. The route refuses rather than guesses. findAnomalies() returns every reason an unattended run should stop - a configured account contributing no rows, an unrecognised account, a near-consecutive-id collapse that might be a real repeat, a batch over ~200 rows, or an export taken with pending included - and the route answers 409 having written nothing. Two defects the wiring surfaced. Deliberately excluded credit cards were reported as unknown accounts, which would have raised the new-account anomaly on every single run and left the automatic path permanently refusing; EXCLUDED_ACCOUNTS now distinguishes excluded from unknown. And pending was tested after account scope, so pending rows on cards - which is all of them so far - classified as out-of-scope and the wrong-export-option signal could never fire; pending is now tested first. |
||
|
|
493ff6f631 |
Import Frollo account feeds for the accounts statements don't cover
ci / lint-test (push) Successful in 47s
Credit cards keep arriving as monthly statements and stay the source of truth for them. This covers the other fourteen accounts, whose statements arrive every 182 to 460 days — AMP including the loan, ANZ Access, Wise including the income account, Up, ING, and the small transaction accounts. About 50 rows a month, where the alternative is downloading each statement by hand. The file needs three defences, all found by diffing three real exports (smarthome DECISIONS.md ING-11): A CDR re-consent makes Frollo re-ingest an account's whole history under fresh transaction ids while the originals survive, and consents expire annually. On this export 112 rows were such twins, and every HDR salary payment appeared twice — importing blind doubles reported income. dedupe() collapses each natural-key group to its lowest id, lowest because old ids were a strict subset of new across two exports, so source_ref stays stable and a re-import inserts nothing. An earlier version of that rule kept close-id rows on a 10,000 threshold, reasoning that genuine same-day repeats have consecutive ids. Verifying it against the income rows killed it: in-scope duplicate pairs have id gaps from 58 to 260 million, so no threshold separates them from the gaps of 1-4 that real repeats showed. It now collapses unconditionally and flags anything within 10 for review — the errors are asymmetric, and nothing in scope has ever tripped the flag. A lapsed consent removes an account from the export silently, with no error and no marker; the row count just drops. So the import asserts the account roster and refuses to run when a configured account contributes nothing. Also holds these rows out of the pending-reconciliation queue. A feed row is the account's own ledger entry, not a receipt awaiting a statement line — these accounts' statements are deliberately not imported — so without the exclusion 550 rows a year would bury the receipts that need a decision. The queue stays at 8 instead of 558. Foreign rows follow order-ingestion's existing shape: amount is the native figure, foreign_currency_code names it, amount_aud stays NULL rather than asserting a rate, and AMOUNT_UNCONVERTED already reports the balance as incomplete. Dry run by default. Verified against the real export before applying: 550 rows inserted, 14 accounts, re-run inserts 0. |
||
|
|
2c666236b2 |
orders: share the merchant tidy, and fix two bugs the corpus run found (board 212)
ci / lint-test (push) Successful in 41s
The tidy existed but lived inside the list page, so the DETAIL page never had it: following a row through showed "Apple" becoming "Apple Pty Ltd." Moved to src/lib/merchant-label.ts and applied on both surfaces. Running it over all 1,451 distinct merchant names — rather than over examples — turned up two real defects: Coburger & Co -> "Coburger &" eBay Commerce Australia Pty Ltd. -> "EBay Commerce Australia" The first strips a suffix that is part of the brand and leaves a dangling connector. The second is the capitalisation rule firing on "starts with a lower-case letter" when what it means is "is a bare domain" — mangling a brand that is deliberately lower-cased. Both are now guarded, and re-running over the full corpus reports 170 names tidied and 0 defects. PRESENTATION ONLY. It merges nothing and must never decide two rows are the same merchant. Amazon.in stays Amazon.in — a different marketplace with different currency and geography, not a name variant — and there is a test asserting it can never equal amazon.com.au. Kogan.com, GOG.com, AliExpress.com and Catch.com.au are real brand names containing a TLD and are left alone. Real unification is the merchant-alias bridge, ticket 176, which has already merged what can be merged safely. The five names that read alike after tidying (ebay/eBay/eBay Inc., menulog/Menulog Pty Ltd, deliveroo/Deliveroo, grab/Grab, paypal/PayPal) are genuinely one merchant each, so reading alike is correct. Lower-case platform SLUGS are deliberately not capitalised: "ebay" -> "Ebay" is wrong, and getting it right is the registry's job, not the stylesheet's. |
||
|
|
215303161b |
orders: bring the lifecycle-only toggle's numbers up to date (board 210)
ci / lint-test (push) Successful in 42s
The count moved three times on 2026-08-12 and the history is the point:
755 when the predicate was written
904 after board 219 turned unstated zeros into NULLs — those 149 rows had
been failing the "no amount" test on a technicality
699 after 210a retired 205 Amazon "share your experience" mails, which were
never lifecycle events at all
620 after 210b joined 79 phantoms to their real parent, placebo-verified at
a 1.9% error rate
What is left is the corpus coverage ceiling rather than a defect — a mail whose
parent is not in the corpus has nothing to join to — and the tooltip now says
that instead of describing the whole class as an open ingestion defect.
|
||
|
|
3b5e495ca4 |
orders phase 2: order_transaction_links, and move every reader to it (board 205)
ci / lint-test (push) Successful in 41s
Phase 1 read linkage out of expense_metadata, which is shaped as ONE ROW PER
TRANSACTION — transaction_id UNIQUE, matched_transaction_id partial-unique — so
every multiplicity it expresses is smuggled through a string key (0029 keys
split shipments <entity_key>#f<fact_id>). A BNPL plan needs four rows against
one order and has no such trick available.
Migration 0030 adds order_transaction_links (many-to-many, keyed on entity_key
rather than entities.id: finance-app does not model the spine and must not hold
an FK across a boundary a re-extraction can decompile) and backfills all 63
existing bridge rows.
THE UNIQUENESS RULE WAS WRONG FIRST TIME, in the most on-brand way available.
It read (entity_key, leg_kind, COALESCE(leg_index, 0)), so two shipment legs of
the same order — both leg_index NULL — collapsed to one key and ON CONFLICT DO
NOTHING dropped one SILENTLY. Caught only because the backfill reported 62
against 63 candidates. The row it ate was order_amazon_249-4859367-0690246's
$130.00 second shipment, the same order named in migration 0029's comment as
the reason split shipments need distinct keys at all. What identifies a leg
depends on its kind: an instalment by its INDEX, a shipment by its FACT, a
whole-order charge by neither.
ALL FOUR READERS MOVE TOGETHER, links first with expense_metadata as fallback:
- LINK_LATERAL (list txn_count/first_txn_id, and the has_transaction facets
that read it)
- the detail page's transactions query
- /api/transactions/[id]/order
- the order_ctx lateral in queries.ts
Moving fewer is not a smaller change, it is an inconsistent one: the matcher's
four links for order_ebay_14-11714-95953 come with no expense_metadata row, so
a half-move would show four instalments on the detail page while the list said
txn_count = 0 and put the order on the wrong side of BOTH has_transaction
filters. Verified after: detail 4 legs, list txn_count 4, has_transaction=yes
includes it, =no excludes it.
UNION not UNION ALL on transaction_id — after the backfill the same charge is
legitimately in both stores and counting it twice would show "2 charges" on a
single-payment order.
order_platform is deliberately NOT coalesced with the link's platform. It gates
the receipt disclosure arrow on /transactions, and a BNPL leg has no receipt
behind it — filling it in put an arrow on four Afterpay rows that expand to
nothing, which is the exact promise the arrow exists to avoid making. Separate
leg fields carry the sub-line instead ("1 of 4 - DJI Air 3 Fly More Combo").
order-details.tsx now also requires a real receipt (platform present) before
rendering, because the endpoint can answer with a link alone.
Two invariant views, both empty and expected to stay so: order_link_orphans
(spine re-keying silently orphans a TEXT key — bridge links are rebuildable,
`manual` ones are lost curation) and order_link_drift (the two stores
disagreeing). The plan's suggested fix for drift — widening the bridge's NOT
EXISTS guard to "neither store has it" — was NOT taken: the expense_metadata
INSERT has no ON CONFLICT, so re-attempting an order that already has a receipt
row would duplicate it. Detecting is cheap; a non-idempotent re-write is not.
Unchanged: order_feed 6,265, order_spend AUD 4,895 / $442,651.80. Links 75.
|
||
|
|
6b9b5fe518 |
orders: badge the annual summary receipts (board 211)
ci / lint-test (push) Successful in 40s
Migration 033 adds entity_orders.summarises_period; order_spend drops those rows and order_feed keeps them. Unbadged, a $2,376 annual tax receipt restating twelve monthly donations renders as the largest single purchase of the year, so both surfaces say what it is: - /orders — a "year summary" chip beside the auth badges - /orders/<key> — a banner in the same shape as the settled-rail one, saying the monthly debits are in the ledger under their own dates The amount is deliberately still shown. It is the year's giving total and the reason the row is worth keeping; it just belongs to no total on the page. The detail page already falls back to o.order_total when order_spend has no row, which is exactly the case now — verified: gross_total null, order_total 2125.5. |
||
|
|
231ef3411a |
orders: record why line items may not exempt a row from the phantom filter
ci / lint-test (push) Successful in 40s
Board 219 nulled 263 unstated zero totals in the spine, which moved 149 rows into the lifecycle-only hide predicate — they had been failing its "no amount" test on a technicality, an unstated 0 they should never have carried, while matching the phantom signature on every other count. Tried exempting rows that carry line items, on the theory that naming what was bought proves a purchase. It surfaced 442 rows and the first window checked showed why it is wrong: the Amazon "Ordered:" mail that mints a msg- phantom carries the item list too, so Xelsluthe and YIWENTEC each rendered TWICE again — the exact complaint this page was fixed for today. Reverted, and the reason recorded in place so it is not re-attempted. Items do not separate an orphan purchase from an item-carrying echo. Only deduplication does, and that is board 210. No behaviour change from the previous commit; comments and counts only. |
||
|
|
699e4a2ddd |
orders: cast cadence order_count to int — BigInt broke every recurring merchant
ci / lint-test (push) Successful in 43s
order_merchant_cadence.order_count is a count(*), so BIGINT, and JSON.stringify throws on it: 'Do not know how to serialize a BigInt', a 500 carrying no SQL. It fires only where a cadence EXISTS, so the failure hid behind the merchant_entity_id fault and reappeared the moment that was fixed — St. Ali (40 orders, 14-day cadence) still 500'd while Amazon opened fine. |
||
|
|
79940202f1 |
orders: fix the 42703 that broke EVERY order detail page
getOrderDetail joined order_merchant_cadence on merchant_entity_id. Migration 021 rekeyed that view to merchant_key (merchant_entity_id is NULL on ~9% of the feed, and duplicate merchant entities split one shop's history); 027 and 029 moved order_feed and order_spend across, and this query was missed. Every /orders/<key> request has since failed with column rec.merchant_entity_id does not exist The reason nobody saw a 500 is the second half of this commit: the page collapsed every failure into 'That order could not be found.' A server fault wearing the costume of a data condition reads as an empty spine and gets investigated in the wrong repo. The hook now carries the status and only a genuine 404 says the order is missing. |
||
|
|
9790b64e1d |
orders: badge sender authentication, not source_trust
source_trust is 'untrusted_external' on 100% of rows — every order came from email — so the badge marked every row and discriminated nothing. auth_verdict (migration 029) does: 93% pass, and the 7% that do not are the ones worth seeing. Adds the injection-scanner flag beside it. |
||
|
|
1b34cd65d8 |
orders: restore the design the prototype had, and stop repeating the row
ci / lint-test (push) Successful in 49s
Rebuilding the prototype in React quietly dropped most of what made it read well. Side by side, the live page had lost the year strip entirely — which was both the hero and the context for the date range — along with the masthead totals, the A$ prefix on amounts, and the Fraunces wordmark. The trust marker rendered as a tofu box. Restored, with the strip clickable so a bar scopes to that year. Also three content fixes, all visible the moment anyone looked at a real page. The title and the manifest were printing the same text twice on 1,964 of 4,649 itemised rows — 42% — because on a single-item order the order title IS the line item. The title now earns its line only when it says something the items do not. Food line items carry the full customisation: one Subway order runs to 450 characters naming every topping, and it swamped everything around it. Rows show a short form, cut at the first bracket where there is one, since "Footlong (Italian Herb…" is the product and the bracket is the customisation. Rows now expand, following the disclosure pattern the transactions page already uses, and the query fetches twelve items rather than three so opening one costs no round trip. Merchant names get a presentational tidy — corporate suffixes dropped, a lower-cased domain capitalised. This never merges two merchants: the estate holds amazon.com.au, Amazon.in and Amazon Services Australia as three distinct entities, and Amazon.in must stay separate because it is a different marketplace rather than a name variant. Actually unifying them is the merchant-alias bridge. |
||
|
|
c05c4b5a35 |
orders: show the context, not just the row
ci / lint-test (push) Successful in 44s
The first cut rendered entity_orders and nothing else, and it read like a table dump: a courier tracking notice and a subscription payment both presented as retail purchases, merchants shown as sender-domain slugs, and no indication that the same coffee roaster had been billing fortnightly for two years. Rows now carry what the ingestion layer already knew. A kind badge from content_class distinguishes a delivery notice, an invoice, a booking and a subscription from an actual purchase — NULL stays unbadged rather than being labelled a purchase, because 48% of rows predate the interpretation index and "unknown" is not "order". display_name shows the resolved merchant (98% of rows) instead of the slug. A recurrence badge marks merchants billing on a cadence, which is derived from the gaps between orders rather than stated anywhere in the mail. And the order's own title now appears when it differs from the merchant, so the manifest is not the only detail on the row. Adds the services lane for bookings, invoices and subscriptions — things with no goods and no delivery, where the retail columns are dead space. |
||
|
|
b26d526e83 |
orders: a browse surface for the purchase history the ledger cannot show
ci / lint-test (push) Successful in 59s
The spine holds ~6,300 purchase orders back to 2006, ~4,600 of them itemised, and 61 reach a transaction. Everything else has been visible only through SQL. This adds /orders and /orders/[entityKey] over it. The point of the page is the manifest. /transactions can only ever say "AMAZON AU MARKETPLACE SYDNEY"; a row here says what was in the box, which is the one thing the ledger structurally cannot carry. Five lanes, because the shapes genuinely differ — retail ends refunded or returned 14.1% of the time against food's 5.1%, food has no meaningful ETA where grocery has one on 54.8% of orders, digital never ships at all. The lane comes from order_lane() in migration 018 rather than a column, because slug 'uber' carries 394 taxi rides and 485 Eats orders. Defaults to this year: 449 orders rather than 6,283. Twenty-one years is the archive, not the working set. Three things the data forced. Unknown amounts render "not stated", never $0.00, because 1,648 orders have no amount and a zero would be false. A full reversal strikes the figure through; a partial refund does not, since striking $191.40 when $13.33 came back is a lie — the charge stays primary and the credit sits under it with the net. And rows with no amount, no reference and one lifecycle event are hidden by default, which lifts amount coverage from 74% to 84%; the toggle says on its face that it is a workaround for board 210 rather than a fix. Reads are raw SQL in lib/order-feed.ts rather than queries.ts: the spine is written by the ingestion-engine, is not in prisma/schema.prisma and never will be, and mixing it into a file where everything is Prisma-modelled would destroy that invariant. /orders is gated by an explicit viewer allowlist. Not because the three people listed need protecting from each other — everything here is on one person's cards — but because a participant is an accounting entity and any participant row with an email is a login. Adding someone to split a holiday must not silently hand them the purchase history. Verified live: gate returns 403 for non-participants and for a missing identity header; search "drone" finds the DJI order through its line items; the detail page renders its three lifecycle events and its Afterpay settlement sibling; and a bridged Amazon order shows both split-shipment charges. |
||
|
|
872da3b12c |
shared page: window the rendered rows — DOM no longer grows with history
ci / lint-test (push) Successful in 1m4s
The table rendered every split row (1,279 today, ~600/yr growth), so page weight was unbounded even after the re-render fix. Render the first 100 of the filtered/sorted set with Show more (+100) / Show all; any filter or sort change resets the window. Data stays fully client-side on purpose — search still counts matches across all history — only the DOM is capped. Headless-measured expand stall: 165ms -> 35ms. |
||
|
|
b4f28010df |
shared page: fix expand freeze — cache date formatter, memoize rows
ci / lint-test (push) Successful in 1m0s
Expanding a receipt on /shared froze the browser: this table renders every split row at once (1,279 today), and one state change re-rendered all of them. 520ms of each pass was formatDate constructing a fresh Intl.DateTimeFormat per call (2,558 calls per render); the rest was rebuilding 1,279 rows to change one. Headless-measured click stall: 802ms -> 165ms on the server; slower machines multiply the former. - module-level DATE_FMT, reused - rows extracted into memoized SharedTxRow with stable callbacks - filter+sort wrapped in useMemo so unrelated renders skip it |
||
|
|
0e5422919e |
shared page: expandable receipt details on split transactions
ci / lint-test (push) Successful in 57s
The item list existed only on the owner's transactions page — the person a meal or order was split WITH could see the amount but never what was in it. getSharedTransactions now carries the same order_platform LATERAL the transactions page uses, and rows with a receipt get the same disclosure arrow + OrderDetails expansion. The shared viewer is a split participant, so canAccessTransactions already authorises the order API for them; meal rows keep the verdict UI (the partner records their own), bridge rows stay review-free. |
||
|
|
7f8e363b5b |
order details: hide the review surface for spine-bridged retail rows
ci / lint-test (push) Successful in 58s
Verdicts are the meal lane's feedback loop (loved/never steers the next restaurant order and the nudge card warns on re-order). Bridge rows (source='order-bridge': Amazon, eBay, Officeworks...) have no such loop, so ItemVerdictToggle and OrderVerdict stay hidden there. The order API now returns 'source'; OrderReceipt.platform widened to string — the bridge writes real platform slugs beyond the meal trio. |
||
|
|
7819a88af2 |
order-details: null item amounts render nothing, not $0.00; qty defaults to 1
ci / lint-test (push) Successful in 1m4s
Spine-bridged receipts (Amazon shipment notices, eBay) name the goods without per-item prices — null means 'the mail didn't say', and printing $0.00 would assert it did. |
||
|
|
9e4b518f57 |
Split a trip into its two economies, so travel stops being a 60% slab
ci / lint-test (push) Successful in 48s
travel dominated every trip page and said nothing. The tempting fix is a finer travel taxonomy, which needs a hand-maintained merchant list — the trap #19 already describes — and it is also the wrong diagnosis. travel is the only category that spans both phases of a trip. Every other one is 100% on-the-ground: on Europe 2026, dining, transport, entertainment, groceries and shopping are all exactly $0.00 before departure. The chart was not bad, it was two economies stacked into one, and travel was the only thing visible in the union. So split on start_date and use the axis that carries information in each phase. Booked ahead ($22,050.51, 57%) is all flights and stays, so merchant is the axis — Agoda $4,490, Air India $3,454, Luxury Escapes $3,284. On the ground ($16,946.94) travel falls to $8,241 among dining $4,452 and transport $2,938, and category is finally worth charting. The hero is the ratio, not a lone total, with the on-ground daily rate beside it — the only figure comparable between trips, since totals are not: Europe $677.88/day against Auckland $83.39. A trip with near-zero committed spend says so, because Sonu + Sunny's $184.84 is a filing artefact (both legs' bookings sit on the first trip), not a cheap trip. Two dataviz rules this page was breaking. Category bars now use one copper hue with the name as a direct label: the per-bar rainbow double-encoded identity the label already carries, and the trip subset fails CVD validation on this surface (other vs shopping at delta-E 5.0 protan, below the floor of 6). And the hero figure drops the serif and tabular-nums, which read as decoration at that size. The phase bar is two ordinal steps of one hue, validated with --ordinal against the card surface, with a 2px gap so the boundary is an edge. 278 passing, build clean. Data verified against the database directly; I could not render the page in a browser to eyeball the layout. |
||
|
|
2d341e24a0 |
A negative trip balance is over-coverage, not a bill
ci / lint-test (push) Successful in 49s
You were right and my label was wrong. A payment is allocated to a trip as a lump sum, and the grouped-payment allocation gave each trip enough to clear the payer's gross share. So netting the other side off leaves a fully-paid trip negative by exactly what the payment over-covered: Europe reads -$802.75 because Sonu paid $8,004.04 against a net share of $7,201.30. That surplus is already carried in the overall balance, where she still owes $5,313.38 — nothing is owed to her, and "you owe them" said the opposite. The arithmetic never changed; only what the page claims it means. paid_to_me is the discriminator: negative with a payment into the scope is over-coverage, negative with no payment is genuinely owed because the viewer's share of the other person's spending exceeds theirs. Both cases now tested. All three of today's negatives are the first kind. The trip table gains an Overall balance column from the unscoped participant balances, because a single trip's figure cannot tell you whether to pay anyone, and the footnote now says settlement is always against the overall figure. 278 passing, build clean. |
||
|
|
6db2345c49 |
Net the trip debt to one figure, and show payer, category and search on Shared
ci / lint-test (push) Successful in 48s
Shared view: the query already returned owner_name and effective_category, the table just never rendered them. Paid by sits next to Splits because together they are whose money went out and whose share it was. Search is client-side — this endpoint returns all 1,267 split rows in one request with no pagination, so there is nothing for a round-trip to narrow, and the sort was already client-side. It matches description, merchant, notes, category and payer, but not participant names: the dropdown does that, and "sonu" matching every row she is split on would read as broken. Trip owed collapses to one settle-up figure per person, with the breakdown beside it so the net is auditable rather than asserted. I argued against netting a few hours ago and was wrong. The claim was that the grouped-payment allocation cleared each trip against the one-directional gross, so netting would redefine that debt after the fact. The rows say otherwise: Europe's $802.75 is 56 transactions Sonu actually paid across Rome, Venice, the Dolomites, Bellagio, Lucerne and Paris on which I hold 25%, and paid_by_me is $0.00 on every row of every trip because nothing has ever been recorded going from me to her. Her side looked settled only because the allocation derived her payment split from her gross, so it lands on zero by construction. The one-directional view was hiding a live obligation, not protecting an allocation. Nets now: Auckland Sonu +$1,077.25, Europe Sonu -$802.75, Sonu + Sunny -$936.34, Europe Molina -$816.16. Also correcting an error in my own reporting: I said Auckland's mirror was $0.00. It is $428.39 — 17 Auckland rows Sonu paid that I hold a split on. Two ad-hoc verification queries mis-joined on a nullable scope column and under-reported the mirror side. The app code was never affected and the owed column is still byte-identical. The footnote now states the trap the netting exposes: a debt settled by a payment left on the household tab still reads as outstanding on the trip. Payment 5 (Molina to Sonu, $1,605.49) is exactly that case and is left alone as a data decision. 277 passing, build clean. |
||
|
|
cb7665ded1 |
Let everyone on a trip see it, and give payments their scope back
ci / lint-test (push) Successful in 52s
Trips were scoped to trips.owner_id, so Sonu saw no trips at all — despite having paid for 104 of the tagged rows herself. Her own spending was invisible on the only page organised around it. A participant is now anyone with a split on, who paid for, or whose payment is scoped to, a transaction tagged to the trip. Derived, not stored. A trip_participants table was designed and rejected: the expenses already carry the fact, and two records of one fact drift apart. Deriving it also excludes Singapore + Bangkok 2026 from Sonu for free, which a table would have to be kept in sync to do. Siddharth 4 trips, Sonu 3, Molina 1. Everything about a trip is shared except delete. Both trip foreign keys are ON DELETE SET NULL, so deleting Europe 2026 untags 210 transactions and NULLs the trip scope on 6 payments — where the hand-derived Europe-first allocation lives, which nothing recomputes. That stays with the owner. Trip owed now returns both directions and nets neither. An obligation lives on a row someone else paid for, so a viewer-as-payer figure can never hold it, and Sonu's Europe read "you are owed $2,408.24" while omitting the $8,004.04 she owed. Collapsing the two into a signed net is the tempting next step and would have corrupted the scope allocation: the grouped-payment allocation cleared each trip against the one-directional gross, so redefining the debt afterwards turns $8,004.04 already allocated into an $802.75 over-allocation with household understated by the same amount. Verified byte-identical — Auckland $1,505.64, Europe Molina -$816.16, Europe Sonu $0.00, Sonu + Sunny $0.00. getTransactions gained trip_all_rows so a participant sees the whole trip. It is opt-in and not implied by trip_id, because the same endpoint backs the main transactions list and its trip filter must keep owner scoping. Participation is re-checked in SQL, so passing the flag for someone else's trip returns nothing. Payments can finally say what they settle. trip_id has existed since migration 0022 but POST never read it and GET never returned it, so every payment made in the app landed on household and the 9 trip-scoped rows were hand-written SQL. "Both" needs no new shape — one row per scope sharing a linked_transaction_id. Three write paths had no authorisation at all and were reachable by any participant: assignTransactionsToTrip checked nothing, DELETE on a payment deleted by bare id, and POST accepted any from/to pair. All three now check. Also fixes the test suite, which was pointing at postgres-pantry: container IPs move on recreation and 172.22.0.47 stopped being postgres-personal. It only failed safe because the credentials did not match — resetDB now refuses to truncate anything not named personal_test. 22 new tests, 276 passing, build clean. |
||
|
|
c70d2b1fac |
Sign the investments line so withdrawals net against contributions
ci / lint-test (push) Successful in 50s
A withdrawal from a fund is a disinvestment, not income: units convert back to cash and net worth is unchanged. Summed unsigned it read as more money invested. March 2026 showed $38,615.34 of investing in a month that was net -$11,384.66, because a $25,000 Raiz withdrawal was added to an $8,563.80 IBKR deposit instead of cancelling it. Each credit costs twice — once for being added, once for not being subtracted — so the error is double the credit: $50,000 in March, $3,000 in May 2025, $53,000 across the window. Since net = income - spent - investments, March's net of -$55,918.73 should read -$5,918.73. Filing withdrawals as income is the other tempting answer and is worse: it books an asset disposal as earnings and feeds the same figure into net with a flattering sign. Same reason the Up item sales in Known Gaps do not belong on the income line. What this cannot resolve: part of a withdrawal genuinely is income — the capital gain. The bank descriptor is one gross figure with no cost base, so it cannot be decomposed from statement data. Netting tracks cash committed against cash returned and leaves the gain for holdings data to surface; it does not assert the gain is zero. The budget page gates the Invested card on `!== 0` rather than `> 0` — a net-disinvesting month is real data, not an empty one — and renders negative months in amber so the sign is not hidden by matching digits. |
||
|
|
f6c500b27a |
Hide transfers in the transactions view by default
ci / lint-test (push) Successful in 51s
Transfers move money between your own accounts; at 433 of 3,996 rows (~11%) they crowd out the rows that represent actual spending. getTransactions gains `exclude_categories`, opt-in per caller and deliberately not defaulted in queries.ts: the rules preview and the bulk rule-apply path both read candidate rows through getTransactions, and a default exclusion there would silently shrink what a rule can see and reach — invisibly, since a rule that matches nothing looks the same as a rule with nothing to do. Two behaviours the filter needs, both tested: - An explicit category pick beats the exclusion. Selecting "Transfers" while the default is on subtracts it from the hidden list instead of returning zero rows and reading as "you have no transfers". - COALESCE the effective category to '' before `<> ALL`. NULL <> ALL(...) is NULL, not true, so an uncategorised row would disappear from a filter that never named its category — the trap EXCLUDE_NON_SPEND already documents. The default is off when the view is scoped to a statement: that is a reconciliation view, the row count has to match the statement, and a credit-card payment is the row you went there to check. |
||
|
|
a465b147a3 |
Read receipt lines under the keys the receipt panel renders
ci / lint-test (push) Successful in 48s
The order-details panel reads qty/description/amount from expense_metadata.line_items. A grocery shop is a receipt like any other, so it stores the same keys rather than name/quantity/line_total — otherwise the rows arrive complete and display blank, which is exactly the failure ING-8 names. unit and category are the two fields a grocery line has and a delivery line does not; nothing renders them yet and the category composition will. |
||
|
|
17028c79ff |
Accept grocery receipts scanned in pantry as candidate spend
ci / lint-test (push) Successful in 48s
Adds /api/receipts/ingest as a sibling to the order lane, sharing its shape but making one decision differently: nothing is parked. An order can wait for its statement because it is already visible as an email; a gift-card grocery shop is visible nowhere at all, so a scan that produces no transaction produces nothing anyone can see. Every payment becomes a manual row immediately and the existing pending-reconciliation queue resolves the ones with a card leg coming. One transaction per tender leg. A $114.57 shop settled $40.75 gift card + $73.82 Mastercard has a statement line for $73.82 only. A single row marked credits is excluded from the queue while that line double-counts; marked card it is searched for at 1% of $114.57 and never matches. Either way the shop books $188.39. Per-leg rows make each amount the settled amount, so the matcher works untouched. Reconciliation now carries expense_metadata across. It already moved overrides, tags and splits from the manual row to the statement row and left metadata behind, which did not matter while metadata only came from an email that made its own transaction. It matters now that it carries a shop's line items: unmoved, the contents vanish at exactly the moment the statement line appears, and COLES 0556 MANOR LAKES stays as unreadable as before anything was scanned. transaction_id is UNIQUE, so a statement row that already has metadata keeps it and the pantry row is flagged rather than raising a constraint violation. Also regenerates the Prisma model. card_last4, currency, flags, reconciled_at, matched_transaction_id, platform and route have been in the database since migrations 0019/0020 and were absent from schema.prisma — regenerating the client from it would have dropped columns the order lane writes on every ingest. 23 integration tests against the real schema, built from the three receipts that drove the design. Existing suites unchanged: 104 unit, 144 integration. |
||
|
|
22e4a1ead0 |
fix(splits): make every split account for 100%
ci / lint-test (push) Successful in 1m39s
A 50/50 arrangement was stored as a single row saying "Sonu 50%". The
arithmetic was never wrong — `myShare` resolves the payer's share as
`100 - SUM(everyone else)`, so balances and per-user spend were correct
throughout. It was still a bug, because a ledger is read as well as
computed: on screen that row is a 50% share against a blank, which looks
like half the money is unallocated and is indistinguishable from a split
somebody abandoned half-finished.
It also leaked. `getSharedTransactions` filters by participant with an
EXISTS on an explicit split row, so filtering the Shared view by the
payer silently dropped every transaction where their share was only ever
implied.
Four write paths could produce it, three of them unguarded:
- the Slack nudge's share button, which inserted one row
- `POST /api/transactions`, where the add form shows an amber total
under 100 but saves anyway — this is how Lawn Mowing and Hedge
Pruning were stored
- `applyRuleActions`, where ten of the fourteen live split rules name
only the other person
`completeSplit` is now the single place that writes the remainder, and
every one of those paths ends in it. The remainder goes to the
transaction's *owner*, never to "me": the owner's row on their own
transaction is excluded from both halves of the balance query, so it
cannot create, enlarge or discharge a debt, whereas a row for me on
someone else's transaction is a real obligation. That distinction is
what makes this safe to apply to existing data.
Also fixes the order panel's "Shared 50/50" toggle, which was inert in
both directions: it posted a lone 50% row to share (rejected — must
total 100%) and an empty array to un-share (rejected — array required),
because no way to clear a split existed. DELETE on the splits route is
that way.
Backfill: 7 rows, verified against a row-level dump diff — 2549 -> 2556
rows, none removed, none modified — and participant balances byte
identical before and after (Molina 19556.07, Sonu 20913.35). Every split
in the database now totals 100%.
Not done: a database-level constraint. Enforcing the sum needs a
deferred constraint trigger, and the rule path commits its DELETE and
INSERT as separate statements, so the trigger would reject the
intermediate state. Making it work means wrapping every write path in a
transaction, which is a larger change than the defect warrants.
|
||
|
|
dd0462a5f9 |
fix(orders): decode • so item options separate again
ci / lint-test (push) Successful in 53s
`•` was missing from the entity table, and that was not cosmetic. DoorDash separates an item's name from its options with a bullet and parseDoorDashLineItems splits on the literal "•" — so left encoded, the split never happened and the line collapsed into the description: "Bucket and Side Pack (Meal Deals) • Hot Bucket • Chips" with options []. The entity showed on screen and the structure behind it was gone. Numeric entities are now decoded generically rather than one at a time, which is how $ came to be listed individually while its neighbours were not, and & resolves last so a literal "&bull;" stays as written instead of turning into a bullet. Repaired the 47 stored rows by re-parsing the captured email behind each one rather than string-replacing the entity, since a replacement would have fixed the display and left options [] underneath. Rehearsed first: all 47 re-parsed, all 47 gained options, 0 line items lost, 0 amounts changed. Old values kept in dump/rollback-line-items-20260728-223737.json. Pre-existing — 22 rows predate today — but the pre-cutover backfill more than doubled the affected rows, which is what surfaced it. Verified in the Order details panel, not in SQL. |
||
|
|
3144cf3176 |
feat(orders): record credits orders from before the cutover
ci / lint-test (push) Successful in 58s
I1 refused any credits-funded order dated before 2026-01-09, storing
nothing at all — no transaction and no metadata, so the receipt was
discarded rather than kept as history.
Its reason was splits, not spend: before the cutover shared expenses
lived in SplitMyExpenses, and re-importing them would double-charge
against carryover transaction 2348. That reason expired with
|