9b59edcf4888423399d49376eb53024190cb79d6
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9b59edcf48 |
reconcile: machine auth for the scheduled sweep, across all owners
ci / lint-test (push) Successful in 46s
I built the auto-reconcile endpoint and then left nothing calling it — exactly the gap I had criticised reconcilePendingOrders for. Found by tracing the pipeline end to end rather than by anything failing. PATCH now takes two callers. A person in the browser reconciles their own queue via the session; the scheduled sweep has no session, carries the machine token, and sweeps EVERY owner with a queue. That distinction is load-bearing: 169 of the 177 rows outstanding belong to the second participant, so a sweep scoped to the session user would leave almost all of the queue untouched. A wrong machine token does not fail the request outright — it falls through to session auth, so the all-owner sweep stays gated while a browser caller is unaffected. Verified live: 403 with a bad token and no session, dry-run OK with the real one. 3 new tests. Two test-harness bugs fixed while adding them: the request stub had no `headers` at all, and the statement helper reused one account number, so two owners collided on uq_statement_identity rather than modelling two real cards. |
||
|
|
3d0d75519a |
wallet: the deterministic parse must beat the model's location
ci / lint-test (push) Successful in 46s
Found by reading the backfilled rows rather than by a test. The precedence was subtitle -> MODEL -> descriptor, so the model's answer won wherever it had one, which is always. That defeats the entire point of parsing the descriptor positionally: the ledger got "SAN FRANCISOUS" for the only foreign merchant in the capture window, plus the padded "MELBOURNE AU", and the bare "Sydney"/"Perth" with their country codes dropped. Correct order is subtitle -> deterministic descriptor -> model. After the fix those rows read SAN FRANCISO US, MELBOURNE AU, Sydney AU, Perth AU, and a subtitle-derived place still wins where one exists. The test that should have caught this passed because its fixture omitted `location`, so the fallback chain was never exercised — every real interpretation carries one. Fixture now includes the model's actual mangled answer for that row, so the test fails if the order regresses. Re-ran the backfill on the corrected code: 21 transactions, $6,284.37 unchanged, 13 with a location. The 21 previously-written rows were deleted first rather than superseded, which is normally forbidden on transactions — checked before doing it that they carried zero splits, tags and overrides, being machine-written minutes earlier by this same lane with nothing yet attached. |
||
|
|
8d1875225f |
wallet: a transaction for every tap (board #252, ING-11)
ci / lint-test (push) Successful in 52s
Step 3, and the last app-side piece. POST /api/wallet/ingest takes an
interpreted notification and writes a statementless card transaction plus
one expense_metadata row. Sibling to /api/orders/ingest and
/api/receipts/ingest, same shared-secret shape, its own token so the
wallet credential rotates without touching the order flow.
Nothing here knows about statements, and that is the point: statementless
+ payment_method 'card' is exactly the reconcile queue's predicate, so a
wallet row enters it unaided and the auto-matcher confirms it later.
THE PAIR IS MERGED, NEVER DEDUPED. One tap emits two notifications ~2-8s
apart: the issuer app has the card tail, Apple Wallet has the merchant and
the suburb, and neither alone is complete. Matched on amount and date
because the two share no identifier. My first version COALESCEd every
field, which made the result depend on arrival order — the issuer half
usually lands first, so the truncated all-caps "WYNDHAM VAL AU" beat the
subtitle's "Wyndham Vale, VIC". A subtitle-derived place now overwrites; a
descriptor-derived one only fills a gap. The test that caught this asserts
both arrival orders.
CURRENCY IS NEVER ASSUMED. Only Amex states one ("A$5,103.49"); Westpac,
CommBank and Apple Wallet write a bare "$", which is 14 of 21 captured
rows. Recorded as unknown and flagged, not assumed AUD — harmless at home,
a 23x error on a THB tap abroad. When a currency IS stated and is not AUD,
the NATIVE figure goes to foreign_currency_amount, because the AUD amount
is the bank's own conversion and a notification cannot predict it, while
the native pair is exact on both sides and is what the statement will
match on. transactions.amount is NOT NULL so it takes a provisional
conversion from the ledger-derived median rate, flagged aud_provisional,
overwritten at reconcile. No rate history means no_rate_reference rather
than an invented number.
wallet-descriptor.ts moves the positional parsing out of the model, which
handled ONE fixed-width format three ways on the same day's data: kept the
country code on "WYNDHAM VAL AU", stripped it on "Sydney" and "Perth", and
swallowed "SAN FRANCISOUS" whole — that being "SAN FRANCISO" truncated to
the 12-char city field plus "US", on the only foreign merchant in the
entire capture window. Every unit-test input is a real captured string.
14 unit + 12 integration tests. npm run test:all green (196 + 304), lint
clean.
|
||
|
|
8e66e527ae |
receipts: adopt the wallet row instead of creating a second one (board #252)
ci / lint-test (push) Successful in 51s
Step 2 of wallet-first ingestion. Signals for one purchase arrive in sequence, not in competition: the notification within seconds of the tap, the receipt hours or days later. Until now the receipt path always INSERTed its own transaction, so the same shop became two rows. The case this is built from is measured, not hypothetical. Woolworths $103.30 on 2026-08-18: the wallet event landed 09:00:14 on the 18th and nothing created a transaction until 20:25:06 on the 19th -- 35h25m later, by this path, from scratch -- discarding what only the notification knew (the instrument, and the suburb). findAdoptableTransaction() is deliberately as narrow as findAutoMatches(): exact amount, a few days either side, unclaimed, and UNIQUE. Two candidates means two plausible purchases, and guessing between them moves one shop's line items onto another shop's row. It also refuses a row that already carries line items, because a fully-described row is a different purchase that happens to share an amount. Returning null is always safe -- the caller falls back to creating its own row, which is the old behaviour. On adoption, both the transaction UPDATE and the expense_metadata UPDATE have a column list that IS the ownership boundary, following order_transaction_bridge's UPGRADE pass: the receipt read the register tape so it wins on what was bought, and `location` is structurally ABSENT from the update rather than conditionally skipped, so the notification's suburb cannot be erased by a bug here. expense_metadata.transaction_id is UNIQUE, so an adopted row that already has metadata is enriched in place rather than raising. 5 new integration tests including both refusals. Existing receipt tests unchanged and passing. npm run test:all green. |
||
|
|
f31f12799a |
reconcile: automatic pass for unambiguous statement matches (board #252)
ci / lint-test (push) Successful in 56s
Prerequisite for the wallet lane writing a transaction per tap. The queue is worked by hand and already ~177 deep; wallet adds ~63 rows a month, ~767 a year. Filtering those rows back out is explicitly ruled out by the awaitsStatementLine() removal note: "an imported row that has no statement belongs in the queue; volume is solved upstream, never downstream by hiding what is there." The rule is deliberately stricter than the queue's own candidate ranking (top 5 within +/-1% and +/-3 days, for a human to choose between). A machine gets one shot and no judgement, so: exact amount, never a tolerance band; and unique BOTH ways, so two identical $13.50 bakery taps in one window are refused rather than paired arbitrarily. Foreign rows match on the native pair, because `amount` is the bank's own conversion and a wallet row cannot predict it. VALIDATED AGAINST REAL DECISIONS, and that overturned my reasoning. I first required both sides to be non-foreign, arguing a currency mismatch is ambiguous. Checked against the 48 reconciliations a human has actually made: that version matched ZERO of them. All 48 are exactly the shape it refused — a manual row carrying no currency, matched on exact AUD against a statement row that does carry one (EUR, CHF) — because overseas receipts and orders record what was charged in AUD, and only the wallet lane records natively. Reverted; the rule now reproduces all 48 and proposes nothing they did not. The coincidence it was meant to guard against is already covered by unique-both-ways. Extracted the merge into src/lib/reconcile.ts so the manual route and the automatic pass cannot drift — there must be exactly one implementation of something that moves curation off a row reconciliation is about to hide. Fixed a latent bug in that merge while extracting it: splits were recreated WITHOUT `settled`/`settled_at`, and the column defaults to false, so reconciling a settled split silently marked a paid debt owed again. No queue row carries one today, but this path is about to run unattended, where that surfaces as money rather than as an exception. PATCH ?dry=1 reports without writing. Each pair is its own transaction, so one bad pair leaves the rest untouched and in the queue. 11 new integration tests, most of them about refusal. Live dry-run proposes 0 pairs for either owner, which is correct: nothing in the queue has an exact statement twin yet. npm run test:all green (182 + 287). |
||
|
|
5e355c7e85 |
currency: normalise foreign_currency_code to ISO-4217 (board #254)
ci / lint-test (push) Successful in 49s
Exact-code equality is the whole basis of FX reconciliation — order_transaction_bridge matches on foreign_currency_code = %(ccy)s — so a row labelled 'BAHT' could never match a THB order and a row labelled 'null' could never match anything. 226 of 1,632 coded rows (14%) were unmatchable for this reason alone, silently degrading a shipped job. 187 rows carried the literal 4-character STRING 'null' rather than SQL NULL, 42 of those also carrying foreign_currency_amount = -1.00 exactly. Root cause is a truthy guard in the CC processors' Parse node — 't.foreign_currency_code || null' passes the string "null" straight through, because a non-empty string is truthy. Not historical: 155 rows in 2026-03 but also 8 in May, 3 in July and 21 in August. Fixed at source in the same change (smarthome cc-statement-processor-paperless.json). Mappings were verified against IMPLIED RATE, not trusted from the label, because a row whose rate contradicts its currency is a broken ledger row rather than a mislabelled one — the trap order_transaction_bridge's rate guard exists for. All six agreed: BAHT 0.0433 vs THB 0.0470, INDIAN RUPEE 0.0183 vs INR 0.0180, U.A.E. DIRHAM 0.4287 vs AED 0.4184, US DOLLAR 1.4054 vs USD 1.5513, NZ DOLLAR 0.8243 vs NZD 0.9311, EURO 1.6342 vs EUR 1.6964. foreign_currency_amount is untouched by the relabels — only the code was ever wrong, so this is a relabel, never a recompute. Applied row counts matched the pre-flight exactly: 42, 187, 13, 10, 6, 4, 4, 2. Postcondition asserted inside the transaction: 0 non-ISO codes remain. Verified after: 0 'null' strings, 0 -1.00 sentinels, 1445 coded rows (1632 - 187). |
||
|
|
669f9119b9 |
enrichment readers: resolve one metadata row, deterministically (board #253)
ci / lint-test (push) Successful in 53s
Four readers of expense_metadata that only the 1:1 UNIQUE constraints keep
correct, found while designing wallet-first ingestion. Each fails silently
rather than erroring, so none of them would announce itself.
merchantVerdict (order-reviews.ts) was a genuine fan-out: a plain JOIN on
transaction_id OR matched_transaction_id, no per-transaction cap, feeding
`for (const r of rows) counts[r.rating] += 1`. One review joined to N
metadata rows is counted N times, inflating the loved/never tallies that
drive the pre-order warning — and its LIMIT 50 applies to the fanned-out
set, so duplicates would also push older reviews out of the window. Now a
LATERAL, so the merchant matched is the merchant the UI shows.
nudgeState (slack/interactive) had no LIMIT and no ORDER BY, feeding
queryRow, which returns rows[0]. Postgres guarantees no ordering, so the
merchant and currency on every Slack card refresh could flip at random.
It was the only "resolve both links" site missing the convention its
siblings all carry.
getSharedTransactions had LATERAL LIMIT 1 with no ORDER BY — bounded, but
arbitrary, and free to disagree with the main transaction list about the
same transaction.
transaction_search (migration 0035): NOT the fan-out I first claimed. It
joined only on em.transaction_id, which is UNIQUE, so it could not
multiply rows and sums over it were never inflated. The real defect is
narrower and worse: it OMITTED matched_transaction_id, so every
card-settled order was invisible to it — 148 transactions (Glovo Milan,
Uber Eats, all source='order-bridge') reporting NULL payment_method /
order_reference / line_items while the data sat in the table. This view is
the agent read surface (Hermes' MCP selects those columns from it) and
home-dashboard reads it via dash_ro, so the omission was estate-wide.
Broadening the join is what introduces fan-out risk — transaction_id and
matched_transaction_id are two SEPARATE unique indexes, so two different
rows can legally point at one transaction — hence LATERAL rather than a
second OR. Verified before applying: 4760 rows unchanged, debit sum
unchanged at 1514412.89, only the 148 rows differ. Grants re-granted and
re-verified, since CREATE OR REPLACE VIEW drops them.
Also fixes an integration assertion
|
||
|
|
258fe3b617 |
intake: the needs-header card names what is missing and gives the right advice for it
ci / lint-test (push) Successful in 49s
A Woolworths eReceipt SCREENSHOT has no date anywhere on it — store, total, items, payment, savings, not one date (2026-08-22, batch 60ed68be…) — while the app's own Share button sends the raw receipt with the date line. The card said 'share the top of the screen too', which is delivery-app advice and sends the person back for a shot that cannot exist. Now: the missing field by name, and for a dateless in-store receipt, the Share button or pantry's own upload. |
||
|
|
0823244e4a |
intake: the word "Image" is not a receipt, and the prompt must not be data
ci / lint-test (push) Successful in 52s
Batch 3d2ffbe8 arrived as one text part whose entire content was the five characters `Image` — an iOS Shortcut coerced the shared photo to text. The model was asked anyway and answered out of its prompt: Burgertory (Braybrook), two "The Pride" lines at $22.80, a $41.15 total. Nothing on it existed. Burgertory was in ORDER_ITEM_PROMPT as the example of how to print a branch in parentheses. Only the missing date kept it out of the matcher; the invented header and items were stored on the batch regardless. Three layers, none of them a model: 1. textCouldBeReceipt() — deterministic, before any model call. A TEXT part with fewer than two words, no digit, or under 40 characters and no money figure cannot be a receipt. Length is NOT the test on its own: `TOTAL $17.00` is twelve characters and is exactly what this door accepts. A rejected part is dropped like an unreadable file type, so one junk part beside a good screenshot still goes through; if nothing usable is left the card quotes what the phone actually sent. PDFs and images untouched. 2. The prompt names no real shop — merchant examples are PROMPT_PLACEHOLDER_MERCHANTS, exported from intake-rules so the prompt and the guard cannot drift — and it is told what to return when the input is not a receipt at all. 3. confabulationReason() — the belt. Reachable where the pre-check cannot look: a PDF that renders to a scrap of junk still reaches the model. Also rejects any answer whose merchant IS a placeholder. All three file `unsupported`, never `needs_header`. A confabulated header is not a partial one, and the needs_header path stores what it is given. Same shape as the ingestion engine's 2026-08-05 empty-body defect: a stage that can be handed nothing has to say so, because the model downstream will always answer. Tests: 16 unit cases over the two pure functions and the prompt; three integration cases — `Image` is declined with zero model calls and nothing but `unsupported` on the batch row, a junk part beside a real receipt still enriches, and a junk PDF is unsupported rather than needs_header. The first and third fail on the tree before this commit. |
||
|
|
d88ca2a24c |
intake: a duplicate card that refuses a purchase must say what to do instead
ci / lint-test (push) Successful in 1m5s
The header (merchant, date, total) is a purchase's identity here, so a genuine second identical purchase — two of the same coffee, one shop, one day — reads as a re-share and is refused. That default is right; a re-share is far commoner than a repeat. But the card said only "Nothing was changed", which is a dead end: the money is real and the ledger silently never gets it. Say to add it in the app. |
||
|
|
91586ce4a4 |
test(intake): the second review pass, and CLAUDE.md for the rules it set
ci / lint-test (push) Successful in 45s
Six of the nine fixes have a test that fails against the previous tree: the metadata row's currency, the Create-path hold, two concurrent batches booking once, mrkdwn escaping including plain_text, the escaped currency, and candidate-row currency. Three do not, and the reason is the same each time — the window needs a crash or a lost race that a test cannot force. Asserted instead: a stale metadataId falls back to the live row rather than reporting a phantom write; two writes racing to create a metadata row both resolve to one; the create path records a baseline at all. All three passed before as well. |
||
|
|
4b09d4136f |
intake: lock the purchase, not the batch, and hold on both write paths
Two different unresolved batches for one purchase could each create a transaction. ON CONFLICT (source, source_ref) cannot stop it — their source_refs differ — and both taps clear their match and duplicate checks before either inserts. The create now takes a transaction-scoped advisory lock keyed on (platform, merchant, date, total), re-checks inside it, and inserts; the loser reports `duplicate` against the winner's transaction, the same answer the sequential path gives. The lock has to be inside a real transaction, which this app did not have. queryRaw is $queryRawUnsafe on a POOLED client, so every call is its own implicit transaction and pg_advisory_xact_lock taken in a statement of its own is released before the next statement runs — the lock would have looked right and held nothing. withTransaction() in db.ts is the scope; the integration helper mirrors it with BEGIN/COMMIT on one pooled connection. Also: the "hold rather than replace an itemised receipt with an unpriced upload" guard lived only in routeFood, and createFromBatch's re-match walked straight past it — the same upload destroyed the same receipt depending on which button reached it. One function, both callers. |
||
|
|
4f0f0dd285 |
intake: escape the currency and the button label too
money() interpolated the extracted currency raw, and parseExtraction accepts any string for it — so "<@U123>" would have been a live mention on every card the batch produced. And plain_text disables *formatting*, not entity decoding: &, < and > are control characters everywhere in a Slack payload, so the Create button's label is escaped like any other value we did not write. My earlier note claiming the opposite was wrong. Candidate rows also carry their transaction's currency (NATIVE_CURRENCY) rather than always printing "$" under a header reading "USD 22.40". |
||
|
|
b788b4e8fa |
line-items: snapshot before writing, and stop reporting writes that did not happen
Three faults on the same function, all of them "the write is assumed to have worked". - The create path snapshotted the undo baseline AFTER its INSERT. A crash in between left the retry recording the intake's own write as "previous", so undo restored the thing it was meant to reverse — the exact failure previous_line_items exists to prevent. Both paths now snapshot first. - An UPDATE against a row deleted between the select and the write affects nothing and still returned a metadata_id, so the batch was marked enriched against a row nobody had written to. The write is now checked and falls through to the ordered lookup, then to a create. - Two calls for one transaction could both find no row and both INSERT; expense_metadata.transaction_id is UNIQUE, so one lost with a constraint error that reached the user as an error card. ON CONFLICT (transaction_id) DO UPDATE, with (xmax = 0) so created_metadata stays truthful. And the new row's currency comes from the batch instead of a hard-coded 'AUD'. OrderDetails prefers expense_metadata.currency, so a USD order — booked correctly on the transaction with foreign_currency_code — printed "$". |
||
|
|
b5569b8ccc |
schema: previous_line_items on the model; say what the finish_reason rule is
Migration 0034 has the column and the Prisma model did not, so a regenerated client and the database disagreed. The extraction comment claimed only a finished reply is accepted while the code accepts an absent finish_reason. The code is right — failing every call on a provider that omits an optional field would be a certain outage in exchange for a case that also requires syntactically complete JSON, and the harm a truncated reply does is closed on the write side. The prose now states the rule as it is: a reason that is PRESENT must be `stop`. |
||
|
|
d4ff68363a |
expense_metadata: every reader resolves the row a write lands on
getTransactions' provenance LATERAL, merchantForTransaction and the Slack details modal all select on transaction_id = $1 OR matched_transaction_id = $1 with a bare LIMIT 1, so they could read a different row than replaceLineItems writes. Same ORDER BY as the match, the write and the order-detail GET. Defensive, not a live bug: measured 2026-08-21, expense_metadata holds 667 rows, 147 of them pointing-only, and ZERO transactions resolve to more than one row by either link. This changes nothing today. It is here so the readers still agree on the day multiplicity appears, which is the only condition under which any of them was ever wrong. |
||
|
|
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.
|
||
|
|
a4e5f540cc |
docs: intake router routes, env vars, migration 0034
ci / lint-test (push) Successful in 48s
|
||
|
|
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.
|
||
|
|
a538376638 |
db(0034): intake_batches + intake_merchant_kinds for the intake router
One row per upload through POST /api/intake, written BEFORE extraction so a Slack tap minutes later has something to act on and a re-share (same sha256 set, or the same (platform, merchant, date, total) header already resolved) is detected. Columns are the §11 C1 contract in smarthome plans/intake-router-slice-plan.md. intake_merchant_kinds holds the remembered 'Groceries or a meal?' answer per normalised merchant (plan §2, Codex #6). Not applied to production — finance has no migrate service; apply by hand at deploy, then npm run test:setup. |
||
|
|
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). |
||
|
|
4f307e9517 |
docs: what importing a BNPL account changes, and the Zip data decisions
ci / lint-test (push) Successful in 41s
24 Zip statements landed (Dec 2020 - Jun 2026), all balance-asserting at $0.00. Zip is a credit account, so three existing row types had to be re-read. Data changes applied via transaction_overrides; this records why. A ZIPPAY debit leaving the bank duplicates the Zip statement's purchases only where a Zip statement covers that period. 2 of 8 do, and are now transfers ($125.97). The other six stay spend: for those periods the bank debit is the only record the purchase happened, so moving them would have deleted $703.35 of real spend. 'Account Credit' is cashback, confirmed by the owner and consistent with Zip labelling its other money-in types explicitly (Payment = repayment, 'Refund <merchant>' = merchant refund). 34 rows, $740, round amounts, no matching bank debit. Gemini split the identical description 23 'other' (subtracts from spend) / 11 'transfers' (excluded), so one thing did two opposite things. Now all 34 shopping+credit, netting against spend the way refunds already do. Income was rejected for the reason the Up item sales note gives. Prezzee gift cards ($1,580) stay spend — stored value only nets out if every downstream purchase is logged, and for 2020 it is not. Same reasoning as the ATM-withdrawal rule. 31 uncategorised debits mapped by merchant with merchant_normalized set too, since the raw descriptions carry card numbers and order UUIDs. All 137 Zip rows are now categorised; $11,135.11 net spend, $10,085.11 transfers. Flags statement 200 (Sept 2021): extracted without billing_end_date or balances, so it is the one statement uq_statement_identity cannot cover (the index is partial on billing_end_date IS NOT NULL) and a re-import would duplicate it. |
||
|
|
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. |
||
|
|
c3b623052d |
statements: keep one bank's national entities apart (migration 0032)
ci / lint-test (push) Successful in 44s
Fixes 0031. Every merge it made was provably one account — each split pair shared an account_number — but the PREFIX generalises past that evidence. Citibank Australia and Citibank India are different banks with different accounts and currency, and `citi%` flattens them into one. Older Citi India statements are queued to load, so this would have merged them on arrival. Not Citi-specific: `hsbc%` and `%american express%` carry the identical defect. Currency alone cannot be the discriminator: Wise holds AUD, EUR and USD accounts under one provider, so a blanket "non-AUD is a different bank" rule would shatter it into three. Wise stays unmapped for exactly that reason, now a documented property rather than an accident. The country comes from the name when the name states it and from the currency otherwise, with AUD as home taking no suffix — so nothing renames: the backfill touched 0 rows. 'Citibank' + AUD -> 'Citibank' (unchanged) 'Citibank India' + INR -> 'Citibank India' 'Citibank N.A.' + INR -> 'Citibank India' (currency carries it) 'CITIBANK INDIA' + AUD -> 'Citibank India' (name beats a wrong currency) 'N.A.' is deliberately not read as a country marker: it means "National Association", a US legal form printed on Citibank letterhead worldwide, including India. Reading it as US is precisely the mistake this fixes. The trigger now fires on UPDATE OF bank_name, currency, since the country is partly derived from the currency. |
||
|
|
0bb4329249 |
statements: one institution, one bank name (migration 0031)
ci / lint-test (push) Successful in 46s
bank_name is a component of uq_statement_identity (bank_name, account_number, billing_end_date), so two spellings of one institution mean the same statement can be ingested twice without the index ever firing. Gemini reads the name off whatever the PDF prints, so the spelling varies per document, not per account. Zip exposed it: account 2705256 arrived under four names in sixteen minutes — ZipMoney Payments Pty Ltd, ZipMoney Payments Pty Limited, Zip Pay, ZipPay — with ten more in the ingestion queue. Not unique: 17 bank_name values represented 13 institutions, every split pair sharing an account number (Amex 14/1, Citibank/Citi 8/4, NAB 3/1). Fragmentation also splits both bank filters and by_bank in /api/analytics/fees. normalize_bank_name() + a BEFORE INSERT OR UPDATE trigger, mirroring the 0013 statement_type pattern with one deliberate difference: the vocabulary is OPEN. An unrecognised name passes through tidied rather than collapsing to a fallback, and there is no CHECK constraint — a bank never used before must be able to arrive without a migration, and destroying its name on first contact is worse than leaving it unmapped. Matching is by prefix, not by enumerated spelling, so unseen variants normalise with no code change. Branches for Westpac, ANZ, HSBC, ING, AMP, Up and CommBank are no-ops that map to the spelling already in use, to catch the legal-entity variant a future PDF might print. Wise is left alone: one spelling is a rename, not a merge. Backfill verified collision-free against uq_statement_identity first. 17 names -> 12. account_owner_mappings keys on (bank_name, account_number) with its own UNIQUE constraint and is updated too; it is empty today. |
||
|
|
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. |
||
|
|
22c2349a47 |
docs: one CSV import path, and the statement-coverage rule that replaced the Frollo importer
ci / lint-test (push) Successful in 43s
|
||
|
|
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. |
||
|
|
2738213a23 |
docs: record the ledger-duplicate guard and why awaitsStatementLine is gone
ci / lint-test (push) Successful in 40s
|
||
|
|
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.
|
||
|
|
3e826a317b |
CLAUDE.md: document the Frollo account feed and the new reconcile exclusion
ci / lint-test (push) Successful in 39s
Two things a future reader would otherwise have to reverse-engineer. The reconcile queue now excludes account-feed rows for a reason unrelated to payment method: a feed row is the account's own ledger entry, not a receipt awaiting a statement line, because the importer only covers accounts whose statements are deliberately not imported. Measured, the queue went from 8 to 558 without it. And the de-duplication rule needs its history attached, because the obvious version is wrong in a way that passes tests: keeping close-id rows as genuine repeats survived 29 tests and a clean dry run while still doubling three salary payments. In-scope duplicate pairs have id gaps from 58 to 260 million; real repeats sat at 1-4. |
||
|
|
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. |
||
|
|
502e3f563c |
0029: rescope uq_expense_source_order for per-shipment bridge rows
ci / lint-test (push) Successful in 1m1s
Split-shipment orders are charged per shipment; the spine bridge now writes one row per shipment sharing (source, order_reference). Meal-lane idempotency semantics unchanged (index scope excludes only source='order-bridge'); bridge rows get their own unique index on (source, source_message_id). Applied 2026-08-10. |
||
|
|
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. |
||
|
|
a1e776e9be |
docs: quote the net trip phase figures, not the raw sums
ci / lint-test (push) Successful in 47s
The phase queries apply NET_SPEND_ROWS and EXCLUDE_RECONCILED_SOURCE like every other analytic, so committed is $21,229.56 rather than the $22,050.51 a raw sum gives. The $820.95 gap is a partial refund on the Luxury Escapes booking, and it is netted out on purpose. |
||
|
|
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. |
||
|
|
6e179d3a0a |
docs: put the UI review's priorities 1-4 on the board, and correct two claims
ci / lint-test (push) Successful in 53s
The review was written 2026-07-26 and last touched 2026-07-29, one day before the board replaced ACTIONS.md, so it was never migrated. Priorities 1-4 existed only in this file. They are now epic #154 with #155-#159 under it. Two claims in the doc were wrong. It said Priority 0 was guarded by analytics-sql.test.ts; that file covers the three SQL fragments and nothing else, so items 2, 3 and 6 -- pace/headline parity, the fees date range, and partial-month comparison -- have no test at all. Those are the three where a regression is silent, which is why the gap is worth a ticket rather than a note. And it predated two changes in the same metric-integrity family: the signed investments line ( |
||
|
|
dbc6fd1352 |
Add source identity to transactions so a feed can be re-imported safely
ci / lint-test (push) Successful in 51s
The CSV importer has no idempotency and structurally cannot have any: batchInsertCSVTransactions assigns row_index = MAX(row_index) + 1, and row_index is the fifth column of uq_transaction_identity, so the constraint is guaranteed a fresh value on every run. The same file imported twice produces two full sets of rows and nothing objects. Tolerable for the hand-driven bank CSV this was built for. Not tolerable for any recurring feed, whose windows overlap by design — and not cleanable afterwards, since every child of transactions is ON DELETE CASCADE, so a duplicate must be superseded rather than deleted (0023). ~$42k of re-imported statement rows already show how that goes. source_ref carries the provider's own key verbatim, enforced by a partial unique index rather than an application-side ON CONFLICT that a refactor can quietly drop. Found while evaluating Frollo (DECISIONS.md ING-11 in the smarthome repo). That feed is not being ingested — 88% of it duplicates existing statement coverage — but this gap is real independently of it. |
||
|
|
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. |
||
|
|
549abd8cca |
docs: point work tracking at the board, not ACTIONS.md
ci / lint-test (push) Successful in 41s
Status for this app now lives on the Vikunja board (saved filter finance-app), which replaced the smarthome repo's ACTIONS.md on 2026-07-30. Notes the thing a single-label filter hides: a ticket can carry several system labels — the receipt→pantry work is finance-app, pantry-app and email-ingestion at once — so the finance filter is a view, not the boundary of what will touch this codebase. Also records the one dated item here: postgres-personal runs PostgreSQL 14, EOL 2026-11-12, and it holds statements, transactions, orders and expense_metadata. |
||
|
|
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. |
||
|
|
69b3ed8ea9 |
docs: catch CLAUDE.md and the UI review up with the shared-expenses rebuild
ci / lint-test (push) Successful in 45s
The redesign is live (2026-07-28); only the loan model remains a proposal. The UI review's 'settled is dead data' guidance described the pre-rebuild state and is superseded — settled now gates ACTIVE_OBLIGATION. |
||
|
|
a56e5e2de5 |
docs: splits total 100%, and why the remainder is the owner's
ci / lint-test (push) Successful in 45s
|
||
|
|
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
|
||
|
|
fe104a9618 |
fix(orders): read both legs of an Uber payment
ci / lint-test (push) Successful in 53s
Uber Cash IS credits, and the payment line had become unreadable in the
newer layout: "Payments Uber Cash 10/17/25 8:50 PM A$54.87" carries a
timestamp between the label and the amount, and writes the currency as a
prefix. Both defeated the pattern, so credits_amount stayed null and the
order was filed as card-settled — sent looking for a card leg that does
not exist, found nothing, and left as an orphan with no transaction and
no card tail to match on. 118 captured messages sit in that state, and
every one of them is pre-cutover, so reading them correctly means I1
skips them rather than storing enrichment that points at nothing.
The card leg had the same blind spot, hidden behind the first: the gap
between the mask and the amount was [^\d]{0,40}, which a timestamp
breaks. While BOTH legs were unreadable a mixed payment still looked
consistent — the order read as card-settled for the full total. Fixing
only credits reads half an order, which validateOrderTotals correctly
refuses. Found exactly that way: four messages that validated before
began failing "payments sum to 34.92 but receipt states 43.60".
Legs are summed rather than taken first, because one order can be charged
in instalments and an instrument can carry no mask at all (PayPal). But a
leg that already equals the stated total IS the payment, not an
instalment: a Dubai trip prints an AED 17.67 authorisation and then the
AED 577.83 settled charge, and adding the hold overstates the trip. A
mixed credits+card order is unaffected — neither leg equals the total
there, which is why it needs summing.
A/B over all 776 captures: 468 parsed by both, zero change to any amount,
currency or existing card tail, 0 lost, 118 credits figures newly read.
Mutation-tested: dropping the exact-leg rule fails 2 tests, dropping the
Uber Cash read fails 7.
|
||
|
|
63aaf1eb21 |
fix(orders): read A$ totals and credits-funded receipts
ci / lint-test (push) Successful in 51s
Two parse bugs that between them made 218 of 776 captured messages
unreadable. Neither was the "old template" they were filed as.
Uber writes the currency three ways and only two were handled. "Total
A$54.87" is what it sends for ordinary Australian orders — A$ misses
[A-Z]{3} by one character — so 98 of 275 Uber Eats mails and 78 of 275
trip mails failed with "no Total found" while the amount sat in plain
sight. Most were 2024-2025, i.e. current mail. NZ$, US$, S$, HK$, C$ and
a bare rupee/euro/pound symbol are handled the same way. A bare "$" is
still left unresolved on purpose: a dozen currencies use it, and the
body-wide scan that reads the receipt's own stated code should win.
A DoorDash order paid from credits states "Total Charged $0.00"
truthfully, above a real subtotal. Read literally that is a $0 order, and
validateOrderTotals rejected 88 of them as non-positive — discarding
exactly the credit-funded spend this pipeline exists to surface. The
order's value is its subtotal; recording zero would show the order and
hide what it cost. Guarded on the receipt actually saying credits, so an
empty mail still fails rather than inheriting a stray subtotal, and the
header cross-check stands down for these or it rejects the figure the
parser deliberately overrode.
Measured A/B over all 776 captures: 254 parsed by both parsers with zero
change to any amount or currency, 0 lost, 214 newly readable. Both fixes
mutation-tested — reverting the regex fails 4 tests, removing the credits
branch fails 3.
Fixtures are real captured receipts, per the 2026-07-26 rewrite: the
earlier synthetic suite passed while the parser could not read a real
email.
No history replay — I7 idempotency refuses re-reads and that needs an
explicit update mode. This fixes ingest from here on.
|
||
|
|
7cf247951a |
docs: the verdict scale, and the two Slack rules that cost real data
ci / lint-test (push) Successful in 43s
|
||
|
|
d458228625 |
feat(orders): a fifth verdict, 'bad', between ok and never again
ci / lint-test (push) Successful in 47s
The jump from "OK" to "Never again" is too big and most disappointments live in the gap (user, 2026-07-28) — so a merely poor meal either flattered itself as OK or got blacklisted. Only 'never' raises the warning on a future order. A blacklist that fires for every mediocre delivery is one nobody reads, so 'bad' records the disappointment without triggering the alarm. Both set order_again = false — you would not choose either again — and that split between "would I order it" and "warn me about it" is the point of the extra level. Migration widens the CHECK; nothing is removed, so no existing row needs mapping. |
||
|
|
82f751cbf4 |
fix(slack): update the card via response_url, not the HTTP response
ci / lint-test (push) Successful in 43s
Every press wrote correctly and then left the card showing stale state, so a working button looked dead — and a button that looks dead gets pressed again, which toggled the split back. That is how two orders got unshared while looking like nothing had happened. The cause was a wrong assumption in the original design: Block Kit interactivity ignores the HTTP response body. Replacing a message from the response is legacy attachment-style behaviour. The update has to go to payload.response_url, which needs no bot token — so it stays in the app rather than becoming another n8n node. Two things that were also invisible now speak up. The share guard used to return silently when it refused a three-way or uneven split, which is indistinguishable from a broken button; it now says which it was, as an ephemeral only the presser sees. Same for an unmapped Slack user. The response still echoes the rendered blocks. Slack ignores them, but it lets a card be rendered server-side without pressing anything — which is what stops the replay tooling from hand-writing a card with a guessed share state, the mistake that cost a real split earlier today. |
||
|
|
9f0f38449b |
feat(slack): ask the other person too, and put rating before sharing
ci / lint-test (push) Successful in 43s
Two halves of the same requirement, one of which was quietly missing. Sharing split the money but never reached her: she is not in #smarthome, so the card whose caption said "both verdicts welcome" was one she could not see. Now a share DMs her a card of her own. A DM rather than adding her to the channel, so her surface stays "orders that concern me" instead of the whole house's ops feed. She was already in SLACK_USER_MAP, so her press files under participant 4. Only on the press that turns sharing ON, and only when someone else did the sharing. Re-notifying on every later rating press would turn one shared meal into a stream of DMs, which is how a nudge gets muted. Her card carries no share button: she is being told it was shared, not asked to decide, and two people toggling one split from separate copies of a card is a race with no upside. The app still holds no Slack bot token — it returns a notify instruction and n8n sends it, the same shape as the modal open. If SLACK_USER_MAP has no id for her the DM is skipped silently: the split is correct and complete either way, and failing the press over an unaddressable nudge would be the worse trade. Card reordered to rate -> details -> share. You judge the food, then decide who pays for it; asking "was this shared?" first inverts the order a person thinks in. The status caption moved under the share button it describes rather than sitting orphaned mid-card. |
||
|
|
0595d49d5c |
fix(orders): the restaurant is the merchant, not the courier
ci / lint-test (push) Successful in 43s
Reverses a change made on request. The platform in the headline
fragmented the merchant: the same restaurant read as two, depending on
who carried the bag, and that is not a distinction anyone rating the food
cares about. It also already has a home — the expandable Order details
panel renders expense_metadata.platform next to its heading, which is
where the user asked for it.
The fragmentation was worse than cosmetic. merchantVerdict joined on an
exact merchant_normalized, and the platforms capitalise differently
("TEG Kebabs & Biryani" on Uber Eats, "TEG KEBABS & BIRYANI" on
DoorDash), so one restaurant kept two separate histories and a "never
again" recorded through one app never warned in the other — silently
defeating the point of the memory. Now case-folded; verified on real
data, where the DoorDash order sees 1 prior verdict against 0 before.
81 existing descriptions backfilled in one transaction, dry-run first and
dumped beforehand. The regex is anchored to the end so suburb parens
survive: "Order - Coles (Wyndham Vale) (Uber Eats)" becomes
"Order - Coles (Wyndham Vale)", not "Order - Coles".
|
||
|
|
50c5b7c430 |
fix(slack): don't let one tap flatten a hand-made split
ci / lint-test (push) Successful in 43s
Splits on orders are made by hand, so a third participant or an uneven share is a deliberate decision — and "Make it just me" deleted every split row regardless. A one-tap button silently destroying an arrangement made with more care than the tap that undid it is the same failure shape as the rewrite that dropped `settled`. Now it refuses when a participant other than the two consumers is present, or when the share is not 50. Verified against the running stack: a three-way split and a 70/30 both survive a press; a plain 50/50 still toggles off and back on. Also: the nudge reads share state instead of assuming false. Today a freshly ingested order has no splits — the 140 that do were split by hand after the backfill, not by a rule — but the label drives a destructive button, so a wrong assumption there costs data rather than a cosmetic error. One query is cheaper. |
||
|
|
14d6b40578 |
feat(slack): per-item verdicts and a note, in a Slack modal
ci / lint-test (push) Successful in 42s
The card can rate an order but cannot ask which dish or why: a message cannot collect free text, and an actions block caps at 25 elements while item counts vary per receipt. A modal is the only Slack-native answer, and it stays inside Slack — no browser, no app, which is the whole reason it exists rather than a link. The overall rating deliberately stays on the card. That is the thing done every time and it should cost one tap; this is for when something was notably good or bad. finance-app holds no Slack bot token by design, so it returns the view and n8n — which already has the credential — calls views.open. One copy of the token, no new secret, no compose change. Item text travels in private_metadata because a submission returns block ids and values, never labels, so there is otherwise no way back to which dish a radio button referred to. Capped at 20 rows: a grocery order runs long and nobody scrolls a modal to rate a tin of tomatoes. The modal does NOT write the rating. A form that silently reset a decision the user did not revisit is the same class of bug as the split rewrite that dropped `settled`. |
||
|
|
8fbcbc5f83 |
fix(slack): one select instead of four rating buttons
ci / lint-test (push) Successful in 47s
Slack's mobile client gives every button in an actions block its own full-width row, so the four ratings rendered as four stacked bars and the nudge filled the screen (user, with a screenshot). A select is one row and still one decision. The interactive route now resolves both shapes — a button carries `value`, a select carries it on `selected_option`. Reading only the former would have left sharing working while rating silently did nothing. |
||
|
|
aaa36dd75e |
feat(slack): answer the order nudge in Slack, without opening the app
ci / lint-test (push) Successful in 40s
Being sent to a web app to answer "was this shared?" is enough friction that the question stops getting answered — which is the exact failure the nudge exists to prevent. So the buttons now act in place: pressing "Shared 50/50" writes the transaction_splits row and edits the message, and the app is never opened. Slack does NOT reach this route directly. It posts to an n8n webhook that forwards the raw body and signature headers here (user's suggestion). That is the better shape: n8n already terminates public webhooks, so the app keeps its blanket OAuth chain and gains no internet-facing unauthenticated route, and no Traefik change is needed. n8n cannot verify the signature itself — its Code sandbox has no `require`, so no `crypto`. Two independent gates, both failing closed: the shared x-ingest-token (came from n8n) and Slack's v0 signature over the raw body (came from Slack, not replayed within 5 minutes). An unset signing secret rejects everything rather than waving it through, because the alternative turns a misconfigured deploy into an open write endpoint. An unmapped Slack user is refused rather than defaulted to the owner. In a two-person household a wrong attribution is not a rounding error, it is the other person's opinion recorded under your name. Block Kit is built in the app (ingest returns slack_blocks) rather than in n8n expressions: a template string is untestable, and this shape has to stay in step with what the interactive endpoint re-renders after each press. Null when there is no transaction yet — a card-settled order is parked until its statement arrives, so there is nothing to split or rate. Also: /transactions now honours ?q=, so the link lands on the row instead of the top of an unfiltered ledger. |
||
|
|
66a6a51fb8 |
docs: order verdicts are built; record the four load-bearing shape decisions
ci / lint-test (push) Successful in 41s
|
||
|
|
b8919a4775 |
feat(transactions): honour ?q= so a link can land on one row
ci / lint-test (push) Successful in 42s
The Slack order nudge links here. Without it the link drops you at the top of an unfiltered ledger and the merchant has to be found by hand, which is how a nudge stops being opened. |
||
|
|
0cb46a087b |
feat(orders): record what we thought of an order, per person
The ledger already knew we had ordered from a place; it did not know the food was bad. Orders got repeated from places we disliked because nobody remembered by the time the next one went in. That is what the receipt ingestion was for (ING-9) and the last piece was missing: order_reviews existed as a table with no API, no UI and no writes. Four levels, not three. "Loved" and "liked" are both "would order again" but only one is worth a detour, and "ok" is not a recommendation. A verdict belongs to a person, not to an order. A shared meal produces two opinions and they routinely disagree — that disagreement is the useful part, and the old UNIQUE on transaction_id alone could not hold it. Now UNIQUE (transaction_id, participant_id), and the default is the signed-in user rather than the owner: Sonu authenticates through the same Traefik OAuth as participant 4, so an owner default would have filed her verdict under his name. Per-item opinions key on the item DESCRIPTION, not its index. An index is meaningless across orders; "the Pad Thai here is good" is the signal that has to survive into the next order from the same merchant. Only the two poles are offered — a per-item "ok" answers neither of the questions you ask at order time. Sharing is recorded as a real 50/50 split, not a decorative flag. The split already IS the record that an order was shared, and two records of one fact drift apart. An ABSENT item_verdicts means "leave them alone"; an empty array clears them. Without that distinction a note-only save silently wipes every per-item opinion — the same shape as the bug that reset `settled` on split rewrites, and just as invisible on screen. Mutation-tested: making keepItems a no-op fails exactly one test. mockDbWithPool gained queryRow. Omitting an export from the mock makes it undefined at the call site, which fails as "not a function" and reads like a code bug rather than a test-harness gap. |
||
|
|
d081d80a3f |
docs: rewrite the shared-expenses section, which had gone false
ci / lint-test (push) Successful in 39s
Every warning in it was inverted by this week's work:
- "settled is dead data, false on every row" -- there are now 1,266 settled
splits across 657 pre-2026 transactions.
- "do not fix getParticipantBalances to exclude settled splits" -- it now
excludes them, via ACTIVE_OBLIGATION, and must.
- "settlement cannot be attributed per trip" -- migration 0022 added
split_payments.trip_id and it is attributed.
- "splits exist from 2026-01-09 only" -- pre-2026 transactions are now split
deliberately, to stop them inflating spend.
Replaced with what is actually true, including the rule that matters most: the
cutover DATE is the primary balance gate and the settled flag only refines it,
so pre-2026 expenses can be split freely.
|
||
|
|
db6b7f8375 |
docs(shared): the cutover date is the gate, the flag refines it
ci / lint-test (push) Successful in 44s
|
||
|
|
788219b9fd |
fix(splits): the cutover date, not a boolean, is what gates a balance
ci / lint-test (push) Successful in 46s
Nothing dated before 2026-01-09 can be owed, because carryover transaction 2348
already carries the entire pre-cutover balance as a single figure. ACTIVE_OBLIGATION
now says so directly.
This inverts which mechanism is load-bearing, and that is the point. Until now
the only thing keeping $37,233.28 of paid debt out of the balances was
transaction_splits.settled -- a boolean that any delete-and-recreate write path
resets to false, as the split modal did until commit
|
||
|
|
6add958132 |
fix(splits): editing a split must not resurrect a settled debt
ci / lint-test (push) Successful in 47s
The route replaces every split for a transaction rather than editing in place, so the recreated rows took the column default settled=false. Opening the split modal on a historical transaction and saving it therefore converted a discharged obligation into a live one, with nothing on screen saying so. That is not theoretical. 657 pre-2026 transactions now carry settled splits imported from SplitMyExpenses -- $37,233.28 of balance that the carryover (transaction 2348) already accounts for. Editing one would double-count its share against a debt that was paid years ago. Now carries settled and settled_at across the rewrite, per participant, the same way the rules revert route already does. Changing someone's percentage does not re-open the obligation: it was settled outside this app and stays settled. A participant who was not on the transaction before is a genuinely new obligation and correctly starts unsettled. rule-actions.ts was already safe here -- it upserts ON CONFLICT DO UPDATE SET share_percent, so it never touches the flag. |
||
|
|
3339a0b9b7 |
chore: ignore the scripts venv
ci / lint-test (push) Successful in 45s
scripts/split_csv_match.py needs psycopg2, so scripts/ now has a venv beside it. This repo deploys from its working tree, so an untracked .venv would be swept into the Docker build context. |