Commit Graph
90 Commits
Author SHA1 Message Date
siddharthd c656f5d26b feat(orders): read Uber trips, and reject the charge summary that duplicates them
ci / lint-test (push) Failing after 45s
Local rides are paid with credits (only overseas ones go on a card), so trips
belong to this slice and were simply never fetched — the Graph query searched
"order with Uber", the Eats subject. Captured 15 real messages from the mailbox
via a dry-run before touching anything, which found two defects that no amount
of reasoning about the template would have:

**Uber sends two mails per trip.** A "charge summary" when the ride ends, then
the real receipt when payment settles — same subject, same total. The summary
carries no tripReference, so order_reference fell back to `msg:<message-id>`
and I7 could not dedupe it against the receipt that follows. Every trip would
have been recorded twice. It says so itself ("This is not a payment receipt ...
You will receive a trip receipt when the payment is processed"), so it is now a
NotAReceiptError — 200 and silent, like every other expected non-receipt.

**Trip receipts label neither end of the journey.** Delivery receipts write
"1:20 pm - Pick-up"; trips print the time alone. The split regex put the time
into `label` and left `time` null. Time is now read properly, and a two-stop
trip is labelled Pick-up/Drop-off positionally — only where the receipt was
silent, so a template that does label its stops keeps its own wording.

Verified against all 15 captured messages: 7 trips recorded, 5 charge summaries
and 3 promotions skipped, 0 failures, no duplicate references. Two of the seven
are AUD credits-funded ($84.78 + $47.97) and would become transactions; the
five NZD ones are card-settled and correctly create provenance only (I5).

Fixtures ut-00 (local credits trip), ut-01 (overseas card trip) and ut-summary
(the charge summary) are captured mail, not written by hand.
2026-07-27 11:21:13 +10:00
siddharthd 4febf38292 test(orders): clean statement fixtures before ingest, not after
ci / lint-test (push) Failing after 40s
These tests insert a Westpac statement and a `DD *DOORDASH ...` charge, and
only removed them at the end of the test — so they survived into the next run,
where `reconcileCardLeg` could match one at ingest time and resolve an order
that was meant to park `awaiting_card_statement`.

That is a real ordering bug in the fixtures regardless. It is my best
explanation for the intermittent failure in "parks an unresolvable split",
but I could not reproduce it: seeding the exact leftover row and running the
old code passed anyway. So this is hygiene with a plausible mechanism, not a
confirmed fix — if that test fails again, this was not the cause.
2026-07-27 11:01:23 +10:00
siddharthd b6cd62f7b5 feat(orders): show the receipt in the transaction detail panel
ci / lint-test (push) Failing after 47s
`expense_metadata` has held the itemised receipt since ingestion started and
nothing in the UI ever read it. A transaction that came from a DoorDash or Uber
Eats receipt showed a merchant and an amount, with the item list and the
delivery addresses sitting unread in the row behind it (user, 2026-07-27).

Adds GET /api/transactions/[id]/order and an "Order details" section in the
edit modal: line items with their options, pick-up/delivery stops with times
and addresses, the card tail when one was involved, and the provider's own
order reference.

Two details that matter:

- The lookup resolves from **both** sides — `transaction_id` OR
  `matched_transaction_id`. A card-settled order creates no transaction of its
  own (I5); the receipt points at the statement line instead. Matching only on
  transaction_id would have left the panel blank on exactly the card-paid
  orders, which are the ones whose detail is hardest to find elsewhere.
- An empty item list says so in words rather than rendering nothing. Uber
  itemises groceries but not restaurant orders, and orders ingested before the
  Uber item parser existed have none either — a blank section reads as a bug
  when it is usually the receipt.

Read-only. This is what a provider sent; editing it would make provenance mean
nothing.
2026-07-27 10:55:57 +10:00
siddharthd df4b875b82 feat(orders): make an ingested order legible in the transactions view
ci / lint-test (push) Failing after 43s
Four things the view could not tell you, all from reading the rows (user,
2026-07-27).

**Which platform.** The parser has always known — it has to, to read the
template — and then discarded it. "Order - Burger Corner" gives no way to know
whether to open DoorDash or Uber Eats for the detail, and restaurants exist on
both. Now stored on expense_metadata and named in the description:
"Order - Burger Corner (Uber Eats)". Migration 0021 recovers it for the 101
backfilled rows from the order_reference shape — DoorDash receipts carry no id
of their own so ingestion synthesises `msg:<message-id>`, Uber carries a real
trip UUID, which makes the discriminator exact.

**Bank said "Manual".** That label is derived, not stored, and "Manual" reads
as "hand-entered, still awaiting a card line to match". A gift-card order has
no card line coming, ever. It now reads "Gift Card", and — the part that
actually mattered — credits joins cash in needsCardMatch(), so these stop
sitting in the pending-reconciliation queue. All 81 were queued against a match
that could not exist.

**Uber line items were never parsed.** 67 of 101 orders had none. Uber itemises
groceries but not restaurant orders, so some of that is genuine; the rest was
simply unread. Its markup is better than DoorDash's — every cell carries a
data-testid with the item's uuid, so qty/title/amount bind by id rather than by
column position. Sold-out items (0.00) are kept: they are why a total is lower
than what was ordered.

**Uber prints pick-up and delivery addresses on every receipt** and they were
thrown away. Captured as `route` [{label, time, address}], de-duplicated
because the template renders the whole block twice for narrow screens. Wording
is kept as printed ("Pick-up" on some receipts, "Pickup" on others) rather than
normalised, so a template change stays visible. This is the same block a *trip*
receipt uses for start and destination — rides are not ingested today, but the
reader will not need changing when they are.

Also stores source_email_subject/from, which order ingestion had left null on
columns that already existed.

Verified against the captured corpus: route on all 6 Uber fixtures, 5/5 items
on the GLOMARK grocery receipt including the sold-out one. Production data
updated by smarthome:docker/scripts/order-presentation-2026-07-27.sql
(81 descriptions, `backfill` tag, re-run clean). `route` and Uber line items
are parsed from here on only — recovering them for already-ingested orders
means re-reading the mail, which I7 idempotency refuses by design.
2026-07-27 10:51:30 +10:00
siddharthd ae0c34fce7 fix(orders): two defects the backfill exposed that tests could not
ci / lint-test (push) Failing after 1m26s
Both were found by looking at the data after the live backfill, not by the
suite — 105 tests were green while 85 rows were invisible and 4 were double
counted.

owner_id was NULL on every ingested order. Analytics scope on
COALESCE(t.owner_id, s.owner_id), and an ingested order carries no statement,
so the coalesce resolved to NULL and matched no owner. The rows existed in
`transactions` and appeared in no view in the app. Ingestion now sets
DEFAULT_OWNER_ID, and a regression test asserts the row survives the same
COALESCE scoping the UI uses.

[Family] orders are card-settled, not credits-funded. Their receipts name the
payer ("Payments Siddharth LKR 3,783.20") and no instrument, which an earlier
version read as credits. The card statement carries all four of them (CBA
...3893, exact foreign_currency_amount matches), so creating a transaction
duplicated spend already recorded — the double-count I5 exists to prevent.
They now record provenance only; the statement line is the transaction and is
what carries the `family` tag that keeps them out of budgets.

Production data corrected separately by
smarthome:docker/scripts/fix-order-backfill-2026-07-27.sql.

Also: reconciliation tests no longer assert global row counts.
reconcilePendingOrders() scans every pending row, so leftovers from other
files moved the totals — the source of an intermittent failure that only
appeared on the first run after a source edit.
2026-07-27 10:41:10 +10:00
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
siddharthd 85e7801407 feat(shared): replace settle buttons with payment ledger
- New split_payments table records actual payments between participants
- Balance = total split obligations - total payments (splits never marked settled)
- Record Payment modal per participant: direction toggle, amount pre-filled with balance, date, notes
- Payment history inline on each balance card with +/- display and delete
- Per-transaction Settle button removed; Action column removed from shared table
- Splits always show the true cost breakdown regardless of payment state
2026-03-14 21:09:00 +11:00
siddharthd 5206388958 feat(filters): smart query bar with amount operators and multi-select dropdowns
- Query bar parses >500, >=500, <500, <=500, 500-1500 into amount_min/max filters
- Parsed tokens shown as dismissable chips below the query bar
- Category, Bank, Tag, Type filters upgraded from single-select to multi-select
- MultiSelect dropdown component with checkbox list and active-state border
- Backend: TransactionFilters uses string[] for categories/bank_names/tag_ids/transaction_types
- SQL: ANY($n::text[]) / ANY($n::int[]) for array filters
2026-03-14 20:39:28 +11:00
siddharthd 8076d1a949 docs: update README and add CLAUDE.md for finance app 2026-03-14 20:06:37 +11:00
siddharthd aeaca84cc7 feat(edit-transaction): edit modal with notes, inline tags, and split management
- New EditTransactionModal with scrollable body (sticky header/footer)
- Statement transactions: read-only core fields; manual transactions: editable date/amount/description
- Override fields for all: merchant, category, type, notes (textarea)
- InlineTags sub-component: add/remove tags without dropdown clipping issues
- Live split display via useTransactionSplits, opens SplitModal for editing
- PATCH /api/transactions/:id extended for description/amount/transaction_date (manual only)
- Transactions page: edit button per row, notes shown below description in italic
2026-03-14 20:06:32 +11:00
siddharthd 278e57354c feat(insights): analytics drill-down, fee tracking, and category improvements
- Monthly spend chart with category breakdown drill-down
- Merchant frequency and spend analytics with per-merchant history
- Subscription detection and recurring charge tracking
- Fee and interest analytics endpoint
- Expanded category list with formatCategory display helper
2026-03-14 20:06:24 +11:00
siddharthd 9f90d8726f feat(rules): apply_split rules with run history and revert
- POST /api/rules/apply — run all enabled rules against unmatched transactions
- POST /api/rules/apply/:id — apply a single rule by id
- DELETE /api/rules/apply/:id — revert a rule run (remove applied splits)
- Rules page: show run history with revert button, apply individual rules
2026-03-14 20:06:19 +11:00
siddharthd 859043f5a5 feat(shared): bidirectional split balance, credit direction, and multi-user view
- Rewrite participant balance to UNION both directions (they owe me + I owe them)
- Credits/refunds subtract from owed amount for correct net balance
- Allow secondary users to see transactions split with them
- Add participant balance cards with colour-coded owe direction
- Add inline AddParticipantForm with name + optional email
2026-03-14 20:06:13 +11:00
siddharthd fc22a61a43 feat(transactions): manual transaction support and multi-owner query infrastructure
- Add POST /api/transactions to create manual transactions (statement_id=NULL, owner_id set directly)
- Queries switch from JOIN to LEFT JOIN statements so manual transactions are visible
- COALESCE(t.owner_id, s.owner_id) throughout for owner resolution
- Add "Manual" bank filter option in getTransactions
- Search extended to include merchant_normalized override
- Split data fetched via lateral subquery on every transaction row
- getParticipantBalances rewritten as UNION for bidirectional net balances
  (credits/refunds negate, split from either side of the relationship)
- getSharedTransactions: remove my_share_percent from SELECT (fixes GROUP BY error),
  WHERE rewritten as two distinct cases (owner with others split vs participant on others' txn)
- getTransactions: OR EXISTS condition so split participants see shared transactions
- add-transaction-modal component for creating manual transactions with splits
- 0008_my_share_percent migration adds my_share_percent to transaction_overrides
2026-03-14 20:04:00 +11:00
siddharthd 0985c38be8 fix(rules): save-as-rule uses full merchant name with equals, not first description word 2026-03-11 12:46:16 +11:00
siddharthd af4c64bba7 feat(splits): save split as rule from split modal
- Checkbox in split modal: 'Also save as rule for <merchant>'
- Creates a rule with apply_split action storing the participant shares
- Rules engine now handles apply_split: deletes existing splits and re-applies
- Bulk split mode hides the checkbox (rule wouldn't make sense for ad-hoc bulk)
2026-03-11 12:40:03 +11:00
siddharthd a8743ba7df feat(transactions): save-as-rule prompt after merchant/category edit
After changing a merchant name or category inline, a toast-style prompt
appears offering to save it as a rule. Shows a preview of the condition
and action before saving. Dismissable without creating a rule.
2026-03-11 12:05:35 +11:00
siddharthd 7b3fd4b65f fix(merchants): net spend accounting for refunds/credits
- Merchant totals now show net spend (gross debits minus refunds)
- Refund count and amount shown in profile drawer and table
- Scatter plot Y-axis uses net_spend, X-axis uses debit_count
- Per-merchant transaction history includes refunds (shown as negative)
- Monthly trend chart reflects net spend per month
2026-03-10 00:43:58 +11:00
siddharthd dd11019fdf fix(transactions): editable transaction type, fee/interest counted as spend, fees category
- TypeBadge is now clickable — opens inline select to change debit/credit/fee/interest/etc.
- PATCH /api/transactions/[id] now accepts transaction_type, updates transactions table directly
- Analytics monthly query includes fee and interest types as spend (not just debit)
- fee and interest amounts show red in transaction list (same as debit)
- Add fees category to taxonomy
2026-03-10 00:24:42 +11:00
siddharthd 714c5a9b25 feat(merchants): scatter plot, merchant profiles, and per-merchant transaction history
- /merchants page with spend-vs-frequency scatter chart (click to open profile)
- Merchant profile drawer: stats, monthly trend line, full transaction history
- /api/analytics/merchants: split-adjusted merchant aggregates + monthly trends
- /api/analytics/merchants/[merchant]: per-merchant transaction list
- Add Merchants nav item to sidebar
2026-03-10 00:05:48 +11:00
siddharthd 2a10450c3e feat(analytics): replace charts with category trend lines, Pareto chart, and cumulative spend
- Category spend trend lines (top 8 categories, 6-month view) replacing stacked bar chart
- Pareto chart showing 80/20 spend concentration with cumulative % line
- Cumulative spend chart tracking actual vs typical monthly pace
- Fix: add amount_aud to TransactionRow interface
2026-03-09 23:59:07 +11:00
siddharthd e72d3ad9e5 feat(categories): add home_goods and home_maintenance categories 2026-03-09 23:37:28 +11:00
siddharthd a7461ff83b docs: replace boilerplate README with full data model and architecture reference 2026-03-09 23:12:20 +11:00
siddharthd c1d031511a feat(insights): committed/discretionary chart, recurring charge detection, fees & interest audit 2026-03-09 23:04:52 +11:00
siddharthd 7379437cc3 feat(statements): add bank/type/owner/year filters and row numbering 2026-03-09 22:27:21 +11:00
siddharthd 8bd7d77a8a fix(statements): owner assignment dropdown, fix Wise CC false positive, remove amount label
- Add owner <select> dropdown per row using useUpdateStatement + useParticipants
- Detect CC by statement_type.includes('card') instead of credit_limit/payment_due_date
  (Wise multi-currency account had payment_due_date set but is not a CC)
- Amount: remove 'due'/'balance' label; color green for positive bank balances, red for CC/overdraft
- Add statement_type to StatementRow type
2026-03-09 21:05:34 +11:00
siddharthd f90ba332bd feat(statements): table layout + statement-scoped transaction view
- Statements page: replace card grid with compact table showing bank,
  account, period, due date, currency, amount (due for CC / balance for
  bank), transaction count, and View button
- Transactions page: wrap in Suspense, read statement_id from URL search
  params on load; show a dismissible indigo banner with bank name and
  billing period when filtering by statement; × Clear filter removes it
2026-03-09 12:03:39 +11:00
siddharthd e3aa17acdd fix(analytics): cast tx.amount to Number before formatting (PG returns string) 2026-03-08 21:06:14 +11:00
siddharthd 1eff0f9337 fix(analytics): use React.Fragment with key for expandable category rows 2026-03-08 21:03:53 +11:00
siddharthd 3cf67f6e2a feat(analytics): stacked category chart, savings rate line, expandable rows
- Replace grouped cashflow BarChart with ComposedChart: expense categories
  as colour-coded stacked bars + amber savings-rate % line on right Y-axis
- Add category colour legend below chart (matches stacked bars)
- Horizontal category bar chart now uses per-category colours
- Breakdown table: click any category row to expand/collapse individual
  transactions; each transaction has an inline category dropdown that
  calls PATCH /api/transactions/:id → transaction_overrides, then
  invalidates analytics query so totals update immediately
2026-03-08 20:53:55 +11:00
siddharthd 90d8db4abe chore: add migration 0007 for amount_aud + exchange_rate_to_aud columns 2026-03-08 19:16:51 +11:00
siddharthd d1a0eedf03 feat(analytics): cashflow view with income/investment/net — split-adjusted + multi-currency
- Add 'investment' category (shares, ETFs, super)
- Analytics API: separate income, investment, expense queries; use amount_aud for FX-aware sums
- Analytics page: cashflow summary (income/expenses/invested/net cash), grouped bar chart,
  income + invested rows in 6-month trend table
- MonthlyAnalytics interface: add income, investments, net fields to totals
- DB: amount_aud + exchange_rate_to_aud columns added and backfilled (in prior migration)
2026-03-08 19:15:20 +11:00
siddharthd 5dbeb0cb87 chore: commit previously untracked runtime files (splits, auth, participants, shared) 2026-03-08 18:00:46 +11:00
siddharthd 30a7857d13 feat(analytics): replace budget page with spending analytics + split-adjusted amounts
- Rename 'Budget' → 'Analytics' in sidebar
- Rewrite /budget page: summary cards, recharts bar charts (monthly trend + category breakdown), 6-month trend table
- Fix analytics API to count only user's share for split transactions (CASE WHEN ts.share_percent IS NOT NULL THEN amount * share_percent / 100 ELSE amount END)
- Install recharts
2026-03-08 17:58:33 +11:00
siddharthd 1e79ada6d8 feat(finance): implement Shared Expenses page
Show split transactions with per-participant balance cards and settle buttons.
2026-03-08 17:24:04 +11:00
siddharthd be85822cc7 merge: Phase 5 (Rules Engine) + Phase 6 (Budget & Analytics)
Resolve additive conflicts in schema.prisma and hooks.ts — both models and all hooks retained.
2026-03-08 17:09:57 +11:00
siddharthd d455738732 feat(finance): Phase 6 — Budget & Analytics
Add monthly budgets per category with spend-vs-budget dashboard and 6-month trend table.
Includes upsert budget API, monthly analytics endpoint, inline budget editing, and route auth fixes.
2026-03-08 16:57:33 +11:00
siddharthd 31cffbe1bb feat(finance): Phase 5 — Rules Engine
Add rules engine with CRUD API, condition/action evaluation, and apply-all endpoint.
UI: rule builder form with field/operator/value conditions, tag multi-select, apply button with result stats.
2026-03-08 16:56:13 +11:00
siddharthd 93450f7caa feat(finance): Phase 4 — Tags
- tags table: name, color; transaction_tags junction table
- GET/POST /api/tags; DELETE /api/tags/:id
- POST/DELETE /api/transactions/:id/tags for per-transaction tagging
- Bulk tag/untag via /api/transactions/bulk (action: tag/untag)
- Tags returned inline with transaction list via LATERAL join
- Tag filter on Transactions page
- Bulk "Tag as..." in bulk action bar
- Tag pills + "+" picker on each transaction row
- /tags page: create with color picker, list with counts, delete
2026-03-08 16:28:03 +11:00
siddharthd 35a5be97b0 feat: initial finance SPA — phases 1 & 2
Next.js 16 personal finance dashboard connected to postgres-personal.

Phase 1 (Foundation):
- API routes: GET /api/transactions (paginated, filterable, sortable),
  GET /api/statements, GET /api/merchants
- Transactions data table with date/category/bank/search filters, pagination, sort
- Statements card grid with period, due date, amount, transaction count
- Sidebar layout with nav for all planned sections

Phase 2 (Normalisation):
- PATCH /api/transactions/[id] — upsert transaction_overrides
- POST /api/transactions/bulk — bulk categorize/normalize
- Inline click-to-edit category (22 options) and merchant name
- Blue dot override indicator, bulk action bar
- Effective values via COALESCE(override, llm_value) pattern

Stack: Next.js 16 (App Router, standalone), Prisma 7.x + @prisma/adapter-pg,
TanStack Query, Tailwind CSS. Auth via Traefik chain-oauth@file.
2026-03-07 23:31:40 +11:00
siddharthd 28207b42b5 Initial commit from Create Next App 2026-03-07 19:32:57 +11:00