Commit Graph
85 Commits
Author SHA1 Message Date
siddharthd 5db42f086f fix(orders): match the card leg by masking, not by card brand
ci / lint-test (push) Failing after 45s
Backfill dry-run over 130 real messages surfaced one 422: 'payments sum to 1.17
but receipt states 16.50'. The receipt is a mixed Uber payment —
Uber Cash $1.17 + Westpac ••••8032 $15.33 — and the card regex only matched
Visa|MasterCard|American Express|Amex, so an issuer-named leg was dropped
entirely. validateOrderTotals correctly refused it rather than recording $1.17
as the cost of a $16.50 order.

Anchors on the ••••NNNN masking instead, which also covers the form already
seen in the corpus ('Mastercard ••••3893 (CBA Ultimate) CHF 51.23'). Fixture
and regression test added.

Also repoints .env.test at the current postgres-personal container IP and
documents why: the container publishes no host port, so the address changes on
every recreate and the whole integration suite fails with connection errors
until it is refreshed.
2026-07-27 01:39:42 +10:00
siddharthd 1103397397 fix(orders): three defects found reviewing my own branch
ci / lint-test (push) Failing after 1m27s
None of these were caught by 105 green tests, because the code they live in was
barely tested and the HTTP path was not tested at all.

1. reconcilePendingOrders hardcoded category 'dining', so any order resolved
   through the deferred path booked as dining regardless of merchant — a
   Woolworths grocery order that parks and later reconciles was misfiled.
   That reintroduced, through the back door, exactly the misfiling
   resolveCategory() exists to prevent. Now calls it.

2. reconcileCardLeg never marked a statement line as consumed, so two orders on
   the same card inside the +/-4 day window both bound to the same charge and
   each booked its own credits remainder — double-counting spend. At 10-15
   orders a month on one card that is not a corner case. Migration 0020 adds
   matched_transaction_id with a unique index; the matcher now excludes lines
   already claimed.

3. The ingest API returned HTTP 200 for every parse failure, and the Slack
   alert fires only on non-200. So the single most likely production failure —
   a provider template change breaking every order at once — was completely
   silent. Split into NotAReceiptError (promotions, delivery updates, refund
   and adjustment notices: 200, silent, expected traffic) and OrderParseError
   (it IS a receipt and would not parse: 422, alerts).

Also: order_reference now anchors on Uber's own tripReference cell rather than
'first UUID in the document'. I had claimed to verify that the first UUID was
always the order UUID; that check compared against zero samples and was
vacuous. tripReference is present in all 29 captured receipts and, for ue-00,
equals the UUID the PDF redirect resolves to. The positional fallback remains
but only flags when there is genuine ambiguity.

Adds the API route's first tests — auth gate and error taxonomy — plus
anchoring regressions. 63 unit + 53 integration green on five consecutive runs;
corpus holds at 63/65.
2026-07-27 01:15:39 +10:00
siddharthd a9e251d969 feat(orders): amendments, family imports, and the ingest API
Closes the three gaps left after the parser rebuild.

Refund amendments. ue-05 is a real refund: 'Previous total $49.94 / Refund
-$4.21 / New Total $45.73'. Uber reuses the order UUID across the receipt and
the amendment, so the two can be matched. The transaction is reduced in place
rather than offset with a second row — the order is one event whose cost
changed, and a compensating row would misreport both the meal count and the
merchant's spend. When the original was never ingested, nothing is invented.

[Family] orders now import instead of parking. Their payment line names the
payer, not an instrument ('Payments Siddharth LKR 3,783.20'), so no split is
recoverable and there is no card leg to reconcile against — they would have sat
pending forever, which fails the actual requirement to import and tag them.
Treated as credits, flagged as an assumption. Safe because the family tag
removes them from every budget regardless of instrument, and the LKR amount is
preserved with amount_aud left NULL rather than asserting an FX rate.

Ingest API. n8n now POSTs each message to /api/orders/ingest instead of parsing
in a Code node — the n8n sandbox has no require or fs, so a parser there cannot
be tested against the fixture corpus, which is the one thing that makes this
parser trustworthy. Auth is a shared secret, since machine callers have no
Traefik session header. Rejections return 422 and record nothing.

60 unit + 45 integration green on three consecutive runs; 63/65 corpus holds.
2026-07-27 00:48:20 +10:00
siddharthd c82a22767f feat(orders): wire the real parser in, defer card reconciliation
Ingestion now runs on the rebuilt parser. Three substantive changes.

Deferred card reconciliation. A 'MasterCard 8032 and/or credits' receipt never
states the split, but the card leg lands on the statement — Subway's $29.08
order shows $13.06 on 8032, so $16.02 was credits. For a live order that
statement is weeks away, so the split cannot be settled at ingest time. Such
orders are now parked with provenance and no transaction, and
reconcilePendingOrders() resolves them once the statement arrives. Backfill
takes the same path and resolves immediately. Migration 0019 adds the columns
that make an order resumable; applied to personal_test only, prod untouched.

Payment detection bug, found by the new tests: the old regex delimited the
'Paid with' line on a double space, which whitespace collapsing removes. Every
card and mixed receipt fell through to the credits branch — the Woolworths
receipt booked $60.93 of credits spend that never happened.

Category resolution reversed deliberately. Correction 1 said never default to
dining; the implementation of that sent everything unrecognised to 'other', and
knowing six merchants meant Carl's Jr, Taco Bell, Chilli India, Oporto, Schnitz
and Souvlaki GR all landed there. Grocers are an enumerable set and restaurants
are not, so match groceries explicitly and let the residual be dining.

Tests rebuilt on real captured receipts; the synthetic fixtures are deleted.
60 unit + 41 integration green on three consecutive runs.
2026-07-26 22:43:10 +10:00
siddharthd 33db7d05ef feat(orders): rebuild the receipt parser against real captured email
The previous parser was written against synthetic fixtures shaped to match the
code. It invented a table layout DoorDash does not send, generated
order_reference from Math.random(), read the order date from a 'Date:' string
present in no real message, and detected [Family] by searching the body for the
substring 'family'. Its tests passed because the fixtures were built to satisfy
it. Against 36 real DoorDash and 29 real Uber Eats receipts it does not work.

Rebuilt from the real corpus. 63 of 65 now parse and validate; the 2 rejected
are correctly rejected — one is an order-adjustment notice and one a refund,
neither of which is a receipt.

Corrects an inherited diagnosis: the Mad Mex 'Discounts -$24.09' was recorded
as an HTML-flattening artefact masking a 'true discount of $9.45'. Parsing the
table cells structurally returns the same figures and no $9.45 exists anywhere
in the message — DoorDash genuinely prints a Discounts line that equals subtotal
plus service fee, and the components fail to reconcile on 32 of 36 receipts. So
the breakdown is stored as provenance and never gated on; validation instead
cross-checks the two independently stated totals and the payment line, which is
the number that becomes money.

Real-world cases the corpus forced, none of which were in the spec: [Family]
orders are LKR purchases for family in Sri Lanka (reading them as dollars
inflates ~200x), Swiss orders arrive in CHF, grocery 'Final receipt' mails carry
no Total Charged row, and a declined payment is printed alongside the successful
retry and must be skipped or it records money that never moved.

order_reference now comes from the Uber order UUID embedded in the body, or the
provider message id where DoorDash supplies no order id at all — never random,
so re-ingestion is genuinely idempotent.
2026-07-26 22:25:26 +10:00
siddharthd 6d3b6e1a9d feat(orders): withdraw the ShopBack transfer guard
Tests 12 & 13 asserted that every 'ShopBack Gift Cards' row becomes a transfer.
Resolved against the ShopBack purchase emails, 3 of the 14 matching rows are
Airbnb, 1 Shell, 1 Amazon — the bank descriptor's trailing token is a sequence
counter, not a brand code, so the description cannot identify what was bought.
Only $313.66 of $3,411.16 was ever reclassifiable.

Withdrawn rather than narrowed: making it safe needs ShopBack purchase-email
ingestion, brand resolution and an approval gate, to correctly handle 2
transactions in 20 months. Those two rows get handled by hand.
2026-07-26 22:09:31 +10:00
siddharthd 9f168cf4c8 test(orders): stop integration files racing on the shared test database
The integration suite shares one personal_test database and helpers.resetDB()
TRUNCATEs it with CASCADE, which reaches expense_metadata via the transactions
FK. With file parallelism on, queries.test.ts and participants.test.ts were
truncating rows out from under order-ingestion.test.ts mid-test, so a different
set of assertions failed on every run — including I6 (credits), I7
(idempotency) and I11 ([Family]), the three invariants the suite exists to
prove. Serialise the files.

Suite was reported 31/31 green; observed 29/31 then 28/31 on consecutive runs.
Now 31/31 on three consecutive runs.
2026-07-26 19:50:21 +10:00
siddharthd 775e5cc08f feat(orders): add Uber Eats and Uber Rides support, fare parsing, and category resolution 2026-07-26 19:00:59 +10:00
siddharthd bbb90238e4 feat(orders): implement order ingestion pipeline, review ratings schema, and [Family] exclusion 2026-07-26 18:52:34 +10:00
siddharthd d06088fe34 docs: monthly expense baseline and emergency reserve analysis
ci / lint-test (push) Successful in 37s
One-off analysis, nothing built. Realistic baseline $4,140/mo -> $24,800 for
six months, against $89,770 already accessible ($81,017 loan redraw + $8,753
offset).

Records four corrections the raw data needs before any restatement:
misfiled Raiz/Vanguard/moomoo debits counted as spend, `other` credits read as
negative spend, `government` conflating ATO with rates/rego, and `fees` being
mostly annual.

CLAUDE.md gains two traps found while doing it: partial split coverage inside a
category is usually correct rather than a gap (only shared utilities and
subscriptions are split), and the loan repayment is voluntarily above contracted
($2,500 vs $1,190.54 per fortnight) with the difference recoverable via redraw.
2026-07-26 16:57:23 +10:00
siddharthd c465742635 docs: capture this session's learnings for a future pickup
ci / lint-test (push) Successful in 40s
CLAUDE.md gains the traps a new session would otherwise re-discover:

- Rules: a zero-condition rule matches everything (rule 43 would split all ~3,700
  transactions); preview-then-apply-by-id is the safe pattern and why it beats
  auto-applying on ingestion; how run provenance works.
- Shared expenses: transaction_splits.settled is dead data; getParticipantBalances
  is correct and must not be 'fixed'; settlement cannot be attributed per trip.
- The shared loan: separate ledger, fixed 50% with a tracked receivable, why the
  share must not be derived from actual payments, and why interest stays as spend.
- Extraction: balance assertions are the check that works, do not derive
  opening_balance or add a totals assertion (both would be tautological), Gemini
  invents summary fields it was not given, empty statements must not throw, FX is
  per-date, and CSV comparisons need millisecond ordering.

The design doc records Phase 0 as done - including that the original Phase 0 plan
was wrong, since reading the code first is what prevented breaking a working
balance page.

Known Gaps lists what is open: the unbuilt phases, 11 failing assertions, the
uncategorised Up rows, and the CSVs sitting in 030490e's history.
2026-07-26 16:19:02 +10:00
siddharthd 3f04cbd5e7 fix(trips): stop reporting a settlement breakdown that cannot be computed
ci / lint-test (push) Successful in 35s
The trip view showed Total Owed / Settled / Unsettled per participant, with the
last two derived from transaction_splits.settled. Nothing sets that flag - its
only writer was /api/splits/settle, which no UI calls - so it is false on all 673
splits and every trip reported 100% unsettled, including trips already paid in
full. Molina has paid $20,782.79 against $19,556.07 of splits and the Europe trip
still showed her entire share outstanding.

A correct per-trip figure is not computable either: split_payments records only
from, to, amount and date, so a payment cannot be attributed to a trip. The trip
view now shows each participant's share and points at Shared for what is actually
owed, which is where settlement genuinely lives.

Also removes /api/splits/settle. It was unreachable from the UI but live on its
URL, and a single call with participant_id would mark every one of that person's
splits settled - writing a flag nothing reads. Settlement will be reintroduced
against settlement contexts (docs/shared-expenses-design.md).

getParticipantBalances is deliberately untouched: it computes splits minus
payments, which is coherent. Excluding settled splits there while still
subtracting the payments that settled them would double-count.
2026-07-26 16:13:20 +10:00
siddharthd ba87ff86e7 docs: record loan decisions — separate ledger, 50/50 fixed, $4,000 receivable
ci / lint-test (push) Successful in 35s
The loan is a separate ledger, not a settlement context: a contribution must
never be able to settle a dinner.

The share is fixed at 50%, not derived from actual payments. During Sonu's leave
the obligation did not change, only the payment did - a percentage-of-actual
model would silently redefine her share as 30% and make the shortfall vanish. So
the model needs an expected schedule alongside actual contributions, with the
difference as a tracked receivable. Currently $4,000.00 over Jul 2025 - Jun 2026.

On interest: recorded the mechanics (it is debited to the loan and repaid as part
of the balance - the reconciliation is exact) alongside the counter-argument that
$16,523.64 left and bought nothing, which is what an expense is. Recommends
keeping it as spend with a fixed/discretionary grouping to address the real
concern, but flags it as a judgement call rather than settling it.
2026-07-26 16:03:53 +10:00
siddharthd 4b7a7e5d9c docs: design proposal for shared expenses, settlement and the shared loan
ci / lint-test (push) Successful in 35s
Three problems that look separate are one: the app records money moving, and
separately records who owes whom, and the two never meet.

Documents what is broken with evidence - two half-built settlement models,
settlements existing twice unlinked, and Sonu's $37,980 of loan contributions
sitting unrecognised as generic transfers - then proposes settlement contexts,
payments as transactions rather than a side table, and loan co-ownership.

Nothing built. Five open questions, two of which are decisions about the
arrangement rather than the software.
2026-07-26 15:57:03 +10:00
siddharthd 3778bfe836 feat(rules): show what an apply run changed, and which rule ran
ci / lint-test (push) Successful in 38s
Apply History listed only counts - '13 matches · 13 transactions' - which reads
identically whether the run renamed a merchant or split every transaction with
another participant. Revert is destructive, so that is not enough to decide on.

Two additions. Migration 0017 records rule_id, rule_name and source on each run:
rule_name is denormalised so history stays readable after a rule is edited or
deleted, and there is no FK so deleting a rule cannot cascade away the audit
trail. Both write paths now populate it - the condition-matched run and the
selection-based quick action.

And rows expand to show the run's snapshot set against current values: which
transactions were touched and what changed on each. Rows changed by something
else since the run are called out, because reverting restores the pre-run value
and would discard that later edit.

Runs recorded before this show 'Unknown rule' - the rule they came from is not
recoverable.
2026-07-26 15:20:56 +10:00
siddharthd cc852e7c6f feat(rules): preview what a rule would change before applying it
ci / lint-test (push) Successful in 34s
Selecting a rule now shows the transactions it would alter, so a subset can be
ticked and applied rather than trusting a bulk run. The apply step takes explicit
transaction ids (the existing bulk apply_rule path), so what you tick is exactly
what changes - a rule whose conditions are too broad cannot reach further than
the preview showed.

Matches are split into 'would change' and 'already correct'. A merchant
normalisation rule matching 400 rows where 380 already hold the right value is 20
changes and 380 rows of noise; only the 20 are listed.

Preview is offered for every rule including manual_only quick actions, which
previously had no way to see their reach at all. A rule with no conditions
matches every transaction - that is how apply already behaves, so the preview
reports it prominently rather than hiding it.

Applies still snapshot to rule_apply_runs, so they remain revertable.
2026-07-26 15:08:29 +10:00
siddharthd 31a8177958 chore: untrack dump/ — raw statement exports must never be committed
A git add -A in 030490e swept the Wise CSV exports into the repo and they were
pushed. They contain account numbers, an IBAN, payer and payee names and full
transaction detail.

This removes them from HEAD and ignores the directory. It does NOT purge them
from history — 030490e still contains them.
2026-07-26 14:49:47 +10:00
siddharthd 030490efa3 feat(cash): mark how a transaction was paid, exclude cash from reconciliation
ci / lint-test (push) Successful in 41s
getPendingReconciliations treated every unreconciled manual transaction as
awaiting a matching statement row. Cash never appears on a statement, so a cash
entry sat in the queue indefinitely being offered matches within 3 days and 1%
on amount - and accepting one is silently destructive: reconciled manual rows
are filtered out of every query, so the cash spend disappears while the card
transaction it matched claims to be that same spend.

Migration 0016 adds transactions.payment_method (card | cash | bank_transfer |
other, NULL = unknown) with a CHECK constraint and a partial index. The notCash()
fragment excludes cash from both halves of the reconciliation query - the pending
list and the candidate match subquery, which aliases the manual row as m.

Only cash is excluded. Bank transfers do appear on a statement now that
transaction accounts are imported, and NULL means unknown, so both stay
candidates and every pre-existing row behaves exactly as before.

ATM withdrawals deliberately stay categorised as spend rather than transfers.
Treating them as transfers is only correct if every cash purchase is logged;
with partial logging it silently deletes the unlogged remainder from spend.
2026-07-26 14:38:47 +10:00
siddharthd d6b4ec84f6 feat(integrity): balance assertions, category constraint, refund netting
ci / lint-test (push) Successful in 39s
Four related fixes to spend correctness.

Balance assertions. Nothing checked that a statement's transactions add up to
its closing balance. getStatements now computes opening + movement - closing
and the statements page flags any statement that does not reconcile. Sign
depends on what the balance means: on a credit card or loan it is what you owe,
so spending increases it; on a transaction or offset account it is what you
hold. 11 statements currently fail, $4,177 unexplained - including two adjacent
ANZ statements off by exactly +/-$230.38, a transaction filed against the wrong
one.

Category normalisation (migration 0015). Categories arrive from Gemini (which
writes straight to Postgres from N8N), CSV import and manual edits, so the rule
belongs in the database - same reasoning as normalize_statement_type in 0013.
Adds normalize_category(), triggers on transactions and transaction_overrides,
and CHECK constraints. Backfilled 122 rows: 19 'payment' ($42,569) to transfers,
14 'refund' recovered to the merchant's usual category, and title-case duplicates
folded into their canonical spelling - 'Shopping' and 'shopping' had been
counted as separate categories by every GROUP BY.

Refund netting. Monthly analytics counted only debits, so a refund was counted
nowhere: excluded from spend by type, and not income by category. A $2,888.92
Expedia purchase refunded in full eight days later still read as $2,888.92 of
spend. SPEND_SIGNED and NET_SPEND_ROWS bring refunds in as negatives; 'income'
joins the excluded categories so incoming money cannot leak in as negative
spend. $36,773 across 93 rows now nets correctly.

Drill-down share. The insights drill-down used my_share_percent ?? 100, which
ignored transaction_splits entirely and repeated the bug fixed in ab00f8c one
layer up. getTransactions now returns my_share_pct and my_amount resolved
server-side, and the table shows gross alongside your share.
2026-07-26 10:35:59 +10:00
siddharthd ab00f8c592 fix(analytics): my share is what is left over, not 100%
ci / lint-test (push) Successful in 41s
The split-adjusted spend expression assumed a transaction with no split row for
me was entirely mine. That is wrong when a transaction is allocated fully to
someone else: I paid, they owe all of it, and there is no row for me to match.
Both branches of the CASE missed and the ELSE charged me the full amount.

24 transactions were affected, all travel bookings between 2026-01-09 and
2026-06-26, overstating my spend by $8,579.07 across every analytics view.

Adds myShare/mySplitOf to analytics-sql.ts, which fall back to
100 - (sum of everyone else's shares) instead of 100, and applies them to all
five analytics routes. Centralised for the same reason as EXCLUDE_NON_SPEND:
the expression was duplicated five times and had already drifted.
2026-07-26 10:05:09 +10:00
siddharthd 02495e4173 fix(currency): show and settle foreign transactions in AUD
ci / lint-test (push) Successful in 1m24s
The transactions page rendered the statement's native amount through a
formatter hardcoded to AUD, so a USD row displayed its USD figure labelled as
dollars while every analytics query counted the converted amount_aud. Same
transaction, two different numbers depending on the page.

getTransactions now returns the statement currency, the amount column shows
amount_aud with the native figure beneath it when the two differ, and the split
and duplicate modals seed from the converted amount (a duplicate becomes a
manual AUD row, so the native figure would be wrong there).

Settlement balances had the same split: getParticipantBalances and the
per-participant balance route summed raw amount while trip totals summed
amount_aud, so a shared foreign expense would net a USD figure against AUD
ones. All three now agree on amount_aud. No change to current balances - every
statement in the database is AUD today - but correct once Wise data lands.
2026-07-26 10:00:15 +10:00
siddharthd 315bc06d0d fix(merchants): page crashed on an uncategorised transaction
ci / lint-test (push) Successful in 38s
Regression from the analytics scoping work. Chain:

1. Three transactions have a NULL category. The old WHERE clause
   `COALESCE(o.category_override, t.category) NOT IN ('transfers','investment')`
   evaluated to NULL for those rows, so they were silently dropped — which is
   the bug EXCLUDE_NON_SPEND fixed by defaulting to 'other'.
2. Including them exposed that the SELECT still used the non-null-safe
   COALESCE, so MODE() returned NULL for the affected merchant
   ("Shared expenses carryover (SplitMyExpenses)", a manual transaction that
   the LEFT JOIN fix also newly included).
3. formatCategory(null) threw "Cannot read properties of null (reading
   'split')" and took down the whole page render.

Fixed at both layers:
- Both merchants routes now use the EFFECTIVE_CATEGORY fragment, so the API
  cannot emit a null category.
- formatCategory tolerates null/undefined and returns "Uncategorised". An
  uncategorised transaction should never be able to crash a page.

Verified against the live DB: 200 merchants, zero null categories, and
/merchants /transactions /insights /budget /statements all render 200.
2026-07-26 01:35:50 +10:00
siddharthd 76db9dddb4 feat(loans): principal/interest split so repayments stop distorting spend
ci / lint-test (push) Successful in 41s
A loan repayment is not an expense. A $3,000 mortgage repayment is roughly
$1,200 of principal (equity — a balance-sheet move) and $1,800 of interest (the
only part that is genuinely spend).

Migration 0014:
- transactions.principal_amount / interest_amount, populated only when the lender
  itemises the split on the repayment row
- statements.interest_rate, scheduled_repayment, repayment_frequency,
  redraw_available, loan_term_months
- normalize_repayment_frequency() + trigger, so "Fortnightly", "Bi-Weekly" and
  "Every 2 weeks" all land on 'fortnightly'

Two statement shapes are handled. Where the loan statement lists repayments and
"Interest Charged" as separate rows (the common Australian case), transaction_type
already does the work. Where a lender itemises the split on the repayment row,
that row is typed 'payment' and would be skipped entirely — losing the interest.
New SPEND_ROWS / SPEND_BASE fragments in analytics-sql.ts count such rows at
interest_amount instead of amount.

Adds the loan_interest category (+ colour, and the missing fees colour).

Verified against the live DB with a synthetic ANZ home loan statement: a $3,000
itemised repayment plus a $10 service fee moved April spend by exactly $1,810,
with the $1,200 principal excluded and still retained on the row. Test data
removed and the figure confirmed back at its original value.
2026-07-26 00:21:01 +10:00
siddharthd 25ef504574 feat(statements+analytics): normalise statement_type; fix analytics scoping
ci / lint-test (push) Successful in 38s
Groundwork for importing bank and loan statements alongside credit cards.

statement_type was whatever free text Gemini put in account_type ('Credit Card',
'credit card', 'credit_card', 'Business Card', 'ACCESS ADVANTAGE',
'multi-currency account'). The UI coped only by doing .includes("card"), which
breaks as soon as bank and loan statements arrive.

- Migration 0013 adds normalize_statement_type() + a BEFORE INSERT/UPDATE
  trigger and a CHECK constraint over credit_card|transaction|savings|loan|
  offset|investment|other. The trigger means the N8N workflow keeps working
  unchanged while it still sends free text. Raw value stays in account_type.
  Backfilled 99 existing rows.
- src/lib/statement-types.ts mirrors the vocabulary for the UI; statements page
  now filters by the real types and headlines balance vs amount due per type.

Analytics were scoped with INNER JOIN statements + s.owner_id, which silently
dropped all 180 manual/CSV transactions (statement_id IS NULL) from every
report. Switched all six routes to LEFT JOIN + COALESCE(t.owner_id, s.owner_id)
via shared fragments in src/lib/analytics-sql.ts, so the transfers/investment
exclusion that stops card-payment double counting stays consistent. Also
extended that exclusion to trip analytics, which had none.

Drive-by: /api/analytics/subscriptions was returning 500 on an unserialisable
BigInt from COUNT(*) + 1.

Verified against the live DB: monthly spend picks up the previously invisible
manual transactions (Apr 9,849.91 -> 14,286.70) and all four analytics
endpoints return 200.
2026-07-26 00:00:55 +10:00
siddharthd e0b0fc91e0 feat(rules): manual-only rules as one-click quick actions on selected transactions
ci / lint-test (push) Successful in 52s
A rule flagged manual_only never runs in the apply-all pass; instead it shows
as a button in the transactions bulk bar and applies its actions to the current
selection (conditions ignored — the selection is the condition). Recorded as a
rule_apply_run, so it reverts from Rules -> Apply History like any other run.

Motivation: tagging Home + splitting 50/50 with Sonu was two bulk actions every
time. Now it is one click, and any other combo can be defined the same way.

Extracts the action-application and snapshot logic from the apply route into
src/lib/rule-actions.ts so both callers share one implementation — splits upsert
rather than delete+reinsert, so settled flags survive.
2026-07-25 23:02:26 +10:00
siddharthd 856e1a51ab docs: agent/MCP access guide — connect + query finance data from any MCP client
ci / lint-test (push) Successful in 38s
2026-07-25 22:27:19 +10:00
siddharthd 831d30669b feat(ui): ink & copper ledger retheme + analytics redesign
ci / lint-test (push) Successful in 43s
App-wide retheme done at the token layer: Tailwind's zinc scale is remapped
to warm ink/paper neutrals and indigo to copper in globals.css, so every
page inherits the palette. Fraunces (serif display) added for page titles
and hero figures; all figures now render in mono with tabular numerals.

Analytics page redesigned around a month spine — twelve clickable columns
scaled to each month's spend that act as hero, context, and period
navigation. Adds a 'What changed' top-movers panel vs the previous month,
replaces the 8-line category trend chart with per-category sparkline small
multiples, heat-tints the six-month ledger table, and restyles the Pareto,
pace chart, and cashflow strip. Kept: Pareto, cumulative-vs-typical pace,
drill-downs, regular/occasional split.

Insights and Merchants restyled to the same kit; chart tokens centralised
in category-colors.ts (CHART). Cleared the pre-existing lint errors in
insights (typed tooltip, removed any-casts).
2026-07-19 20:37:49 +10:00
siddharthd 99af10f9ea chore: trips catch-up migration (0011) + docs for Komodo push-to-deploy
ci / lint-test (push) Successful in 43s
- prisma/migrations/0011_trips: trips table, trip_id on overrides, partial
  index — idempotent; matches DDL already applied to prod. Applied to
  personal_test (integration suite was failing on missing trips relation).
- README/CLAUDE.md: deployment is now push-to-deploy via Komodo
  (deploy-finance Procedure, Gitea webhook); compose command is fallback.
- README: migrations table completed through 0011; note that the container
  must only be reachable via Traefik (header-trust auth).
2026-07-19 20:07:09 +10:00
siddharthd 0b40924af0 fix(security+trips): auth/ownership on all API routes; trip analytics in AUD
Ten routes accepted requests with no getCurrentUser check (transactions/[id],
bulk, splits, tags-on-tx, splits/settle, statements/[id], tags, tags/[id],
merchants, participants/[id]/balance), and by-id routes did no ownership
check at all — any participant could read or modify another's data.

Adds canAccessTransactions() (owner via statement/direct, or split
participant), applies it to every transaction-scoped route, owner-scopes
statements/[id], and rescopes splits/settle in raw SQL so settlement only
touches splits the caller is party to.

Also: all trip analytics now sum COALESCE(amount_aud, amount) instead of raw
amount, matching every other analytics query — trip totals previously added
foreign-currency amounts to AUD ones unit-less.

And rules apply_split no longer delete+reinserts splits (which reset settled
flags on every run) — it upserts share_percent and removes only participants
no longer in the rule.
2026-07-19 20:07:09 +10:00
siddharthd 48ec151c15 feat(trips): trip tracking with analytics, tag conversion, and transaction assignment
Adds trips table usage across API and UI: trip CRUD, per-trip analytics
(category/daily/merchant/tag/participant breakdowns), tag-to-trip
conversion, trip assignment via transaction overrides, and trip filter
in the transactions view. Recovered from working tree after local git
corruption; feature was already live via host-context Docker builds.
2026-07-19 20:00:51 +10:00
siddharthd b706973fb5 chore: verify Komodo push-to-deploy wiring
ci / lint-test (push) Successful in 1m12s
2026-07-19 18:34:44 +10:00
siddharthd 710e089c4f ci: lint advisory until pre-existing debt cleared
ci / lint-test (push) Successful in 40s
2026-07-19 01:05:53 +10:00
siddharthd b2a8a56c7b ci: trigger first Actions run
ci / lint-test (push) Failing after 48s
2026-07-19 01:00:11 +10:00
siddharthd d384cc96f6 ci: add Gitea Actions lint/test workflow
ci / lint-test (push) Failing after 1m33s
2026-07-19 00:59:22 +10:00
siddharthd 5df164e9a5 feat(secrets): add SOPS+age encryption for .env
.env encrypted to .env.sops using shared Unraid age key. .gitignore
updated to allow .env.sops while still blocking plain .env.
2026-05-15 19:28:13 +10:00
siddharthd b8cd1b0f89 fix(reconcile): prevent split/tag double-counting on reconciled transactions
Move splits, tags and overrides from manual to statement side on reconcile
(delete from manual after copying) instead of just copying. Add read-time
filter to exclude reconciled manual transactions from balance and shared
transaction queries. Also adds participant filter to shared expenses page.
2026-05-11 19:15:41 +10:00
siddharthd ce67e38d77 docs: update CLAUDE.md and README to reflect recent changes
- Add reconciled_with_id and created_at columns to transactions table docs
- Document split_payments, expense_metadata, rule_apply_runs tables
- Update /api/transactions route docs with has_split filter and all sort options
- Add /api/transactions/reconcile and /api/split-payments to API table
- Document import date (created_at) behaviour and reconciliation caveat
- Add Prisma regeneration note to CLAUDE.md
- Note schema drift for tables added without migration files
2026-05-10 16:52:55 +10:00
siddharthd b8296b6e29 fix(prisma): sync schema with DB
Add missing models: statements, transactions, expense_metadata, rule_apply_runs.
Fixes pre-existing type errors on split_payments and my_share_percent which
were caused by a stale generated client (regenerate with npx prisma generate).
2026-05-10 16:13:40 +10:00
siddharthd 0c1f88ed9c feat(transactions): add imported date column, split filter, and sortable columns
- Show created_at as "Imported" column in transactions and shared views
- For reconciled transactions, show original CSV import date (not statement processing date) via LEFT JOIN on reconciled_with_id
- Add has_split filter (all/split only/unsplit only) to transactions page
- Transactions table: sortable by imported date; split filter dropdown
- Shared table: client-side sort by date, imported, and amount
2026-05-10 16:04:54 +10:00
siddharthd 4a49add277 feat: CSV import and batch reconciliation UI
- Add reconciled_with_id column to transactions (links manual → statement tx)
- CSV import wizard: 4-step modal (upload → map columns → review → done)
  - Handles any bank format via column mapping with localStorage presets
  - Single signed or separate debit/credit column modes
  - Editable preview table before committing
  - Auto-tags all imported rows with 'csv-import'
- Batch reconcile page: shows all unreconciled manual transactions with
  potential statement matches (date ±3 days, amount ±1%) pre-fetched
  - Select matches across multiple rows, apply all at once
  - Copies overrides/tags/splits from manual → statement tx atomically
  - Manual tx marked reconciled (linked), hidden from main transactions view
  - Transactions with no matches shown separately
- Import CSV button on transactions page
- Reconcile nav item in sidebar
2026-04-13 06:23:08 +10:00
siddharthd 07b8c1ef16 fix(shared): exclude payments from balance when tag filter is active 2026-04-13 05:41:00 +10:00
siddharthd 1b561af9e9 feat(filters): add No tags filter to transactions and shared pages 2026-04-13 05:20:49 +10:00
siddharthd 1296555f17 test: add unit and integration test suites
- Extract evaluateCondition + rule types into src/lib/rules.ts for testability
- 48 unit tests for evaluateCondition (all fields/operators) and formatCategory
- 21 integration tests for getTransactions filters and getParticipantBalances
- Vitest configs for unit (vitest.config.ts) and integration (vitest.integration.config.ts)
- setup-test-db.sh creates personal_test DB from production schema via pg_dump
- Use vi.doMock + dynamic import pattern to isolate test DB from Prisma singleton
2026-04-01 19:59:29 +11:00
siddharthd 7491e70a15 fix(participants): show Me contextually per logged-in user
Participant id=1 was named "Me" in the DB, causing Sonu and other users
to see "Me" referring to Siddharth when viewing splits and shared expenses.

- Rename participant id=1 from "Me" to "Siddharth" in the DB
- /api/participants now substitutes "Me" for whichever participant matches
  the current user, so the label is always relative to the viewer
- split-modal: default split uses currentUser.id instead of name === "Me"
- transactions/page: filter and display logic uses participant ID not name
- shared/page: split chips show "Me" when participant_id === current user

Also includes add-transaction-modal tags support (pre-existing staged change).
2026-04-01 18:36:29 +11:00
siddharthd 0a1f6b48a2 feat(ui): mobile-responsive sidebar + rules improvements
- Sidebar: hidden on mobile, opens as slide-out drawer with hamburger
  toggle; auto-closes on navigation; desktop layout unchanged
- Layout: responsive padding accounting for mobile header bar
- Rules: add tag as a condition field (has/not-has tag)
- Rules: apply a single rule via per-rule Apply button
- Rules: splits-from defaults to 2026-01-09
2026-03-21 08:33:08 +11:00
siddharthd ef73a9cea0 fix(payment): participantId stuck as empty string when participants load async 2026-03-14 21:33:25 +11:00
siddharthd d53d3106f2 fix(shared): tag filter SQL precedence, balance cards filter by tag 2026-03-14 21:30:33 +11:00
siddharthd 02ac136e19 fix(payment): crash on open due to amount.toFixed on numeric string
feat(shared): tag filter on shared transactions list
2026-03-14 21:27:08 +11:00
siddharthd 084b8764e3 feat(transactions): Payment button to record existing transaction as debt payment 2026-03-14 21:20:25 +11:00
siddharthd 281f0d3782 fix(shared): show full description and notes in split transactions table 2026-03-14 21:11:34 +11:00