diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6840136 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash(npx next:*)", + "Read(//mnt/m2cache/appdata/smarthome/**)", + "Bash(docker compose:*)", + "Bash(curl -s http://localhost:4100/ -I)", + "Bash(grep:*)", + "Bash(docker port:*)", + "Bash(docker inspect:*)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(k, v['IPAddress']\\) for k,v in d.items\\(\\)]\")", + "Bash(npm install:*)", + "Bash(node_modules/.bin/vitest:*)", + "Bash(find:*)", + "Bash(xargs ls:*)", + "Bash(npm run:*)", + "Bash(docker exec postgres-personal:*)" + ] + } +} diff --git a/europe-2026-expense-report.md b/europe-2026-expense-report.md new file mode 100644 index 0000000..54dbba7 --- /dev/null +++ b/europe-2026-expense-report.md @@ -0,0 +1,342 @@ +# Europe 2026 — Expense Report + +**Trip:** 22 days · Mar 21 – Apr 12, 2026 +**Route:** Rome → Venice → Ortisei (Dolomites) → Varenna (Lake Como) → St Moritz → Lucerne → Paris +**Group:** 5 travellers — Siddharth & Meghalee (Melbourne), Molina, Sibnath & Rumna (Delhi) +**Data:** 212 tagged transactions across all cards + +--- + +## Group Breakdown — AU vs India + +The two groups fly different routes and airlines. Everything else (accommodation, trains, on-trip spending) is shared across all 5 travellers. + +| | AU Group (Siddharth + Meghalee) | India Group (Molina + Sibnath + Rumna) | +|---|---:|---:| +| Flights | $2,164 | $6,151 | +| Share of shared costs | $12,577 | $18,866 | +| **Group total** | **$14,741** | **$25,017** | +| Per person | $7,371 | $8,339 | + +**Shared costs (accommodation + trains + all on-trip):** $31,443 +Apportioned 2:3 by headcount — AU group (2 pax) = 40% · India group (3 pax) = 60% + +| Scenario | Amount | +|---|---:| +| Total trip | $39,757 | +| **Excl. all flights** | **$31,443** | +| Excl. AU flights only (what India group is responsible for) | $37,593 | +| Excl. India flights only (what AU group is responsible for) | $33,607 | + +> Australia group flights: China Eastern MEL→PVG→FCO × 2 ($1,885) + Qatar cancellation fee ($279) = **$2,164** +> India group flights: ITA Airways DEL→FCO × 3 (Azwebin, $2,696) + Air India CDG→DEL→GAU × 3 ($3,454) = **$6,151** +> India group flights are ~3× the AU group total despite flying a similar number of legs — driven by 3 pax vs 2, plus the ITA outbound on top of Air India return. + +--- + +## Overall Summary + +| | AUD | +|---|---:| +| Gross charged | $42,696.73 | +| Refunds received | −$2,939.22 | +| **Net total spend** | **$39,757.51** | + +| Phase | Net Spend | % of Total | +|---|---:|---:| +| Pre-trip bookings | $23,730.02 | 60% | +| On-trip (22 days) | $16,027.49 | 40% | + +**On-trip daily average:** $729/day for the group · $146/person/day + +--- + +## Refunds & Cancellations + +| Merchant | Charged | Refunded | Net | Detail | +|---|---:|---:|---:|---| +| Qatar Airways | $2,396.90 | −$2,118.10 | **$278.80** | Cancelled. Replaced with China Eastern MEL→PVG→FCO. | +| Luxury Escapes | $3,283.80 | −$820.95 | **$2,462.85** | Partial refund on Paris Adagio Montmartre. | +| FreeNow | $43.32 | −$0.17 | $43.15 | Hold reversal. | + +--- + +## Pre-Trip Bookings — $23,730 net + +### Flights + +| Carrier | Passengers | Gross | Refund | Net | +|---|---|---:|---:|---:| +| ITA Airways (via Azwebin) | Delhi group (3) DEL → FCO | $2,696.34 | — | $2,696.34 | +| Air India | Delhi group (3) CDG → DEL → GAU (return) | $3,454.32 | — | $3,454.32 | +| China Eastern | Siddharth MEL → PVG → FCO | $849.05 | — | $849.05 | +| China Eastern | Meghalee MEL → PVG → FCO | $849.05 | — | $849.05 | +| China Eastern (ancillary) | Seat/baggage fees | $187.14 | — | $187.14 | +| Qatar Airways | Siddharth + Meghalee (cancelled) | $2,396.90 | −$2,118.10 | $278.80 | +| **Flights total** | | **$10,432.80** | **−$2,118.10** | **$8,314.70** | + +> ITA Airways (billed as Azwebin Fiumicino) — 3 outbound tickets Delhi → Rome plus 3 sets of ancillary fees, all charged Jan 17. +> Qatar was booked first (Jan 17) and then cancelled; refund of $2,118.10 processed Mar 24 (during Rome stay). Net cancellation penalty: **$278.80**. China Eastern replaced it at $1,885.24 total — slightly cheaper. + +### Accommodation + +| Property | Destination | Nights | Booked via | Net Cost | +|---|---|---|---|---:| +| Fontana di Trevi area (Vrbo HA-XTP24R) | Rome | 4 | Vrbo | $2,221.66 | +| Apartment Moro 4 — Rialto | Venice | 3 | Direct | $1,410.11 | +| 174A Apartments Murata | Ortisei | 4 | Muse Holiday | $1,815.06 | +| In the heart of Varenna (Vrbo HA7B9C9J) | Varenna | 2 | Vrbo | $2,274.46 | +| Cozy Central Duplex | St Moritz | 2 | Agoda | $2,560.45 | +| Hotel Luzernerhof | Lucerne | 3 | Agoda | $1,930.07 | +| Adagio Paris Montmartre | Paris | 4 | Luxury Escapes | $2,745.24 | +| **Accommodation total** | | **22 nights** | | **$14,957.05** | + +> Paris: Luxury Escapes $3,283.80 − $820.95 refund + $282.39 extra charge = $2,745.24 net. +> **Average cost per night (5 pax):** $680 · **per person per night:** $136 + +### Pre-booked Transport + +| Date | Merchant | Route | Amount | +|---|---|---|---:| +| Jan 26 | Trenitalia (Frecciarossa Business) | Rome → Venice (5 pax) | $468.55 | +| Mar 4 | Rail Europe | Various European routes | $372.40 | +| | **Total** | | **$840.95** | + +--- + +## On-Trip Spending — $16,027 net + +### By Destination + +| Destination | Dates | Net Spend | Txns | Daily Avg | +|---|---|---:|---:|---:| +| Rome *(inc. transit days Mar 19–20)* | Mar 19–24 | $2,955 | 46 | $493 | +| Venice | Mar 25–27 | $960 | 21 | $320 | +| Ortisei / Dolomites | Mar 28–31 | $3,595 | 19 | $899 | +| Varenna / Lake Como | Apr 1–2 | $863 | 16 | $432 | +| St Moritz | Apr 3–4 | $355 | 10 | $178 | +| Lucerne | Apr 5–7 | $3,422 | 22 | $1,141 | +| Paris | Apr 8–12 | $3,878 | 46 | $970 | +| **Total on-trip** | **22 days** | **$16,027** | **180** | **$729** | + +--- + +### Rome & Transit (Mar 19–24) — $2,955 + +**Mar 19–20 (transit via Shanghai → Milan → Rome):** +- Glovo Milan × 4 deliveries: $93 — feeding the group across airport transit days +- Shanghai Sunny restaurant: $16 (CNY 78) +- Todis Roma groceries: $55 (manual, likely night of arrival) + +**On ground in Rome:** + +| Category | Key Items | Total | +|---|---|---:| +| Dining | Lione 21 EUR 90 ($148) · Suresh EUR 52 ($85) · Glovo × 4 ($179) · Faro Café ($40) · Bap Vaschette ($29) · various | ~$780 | +| Transport | FreeNow × 2 ($43) · Uber × 6 ($151) · ATAC bus ($2) · Radiotaxi ($11) | ~$210 | +| Travel | Trenitalia local ($23) · Roma Termini station tickets EUR 300 ($493) · Trenitalia EUR 275 ($452) · Trenitalia EUR 165 ($274) | ~$1,242 | +| Entertainment | St Peter's Basilica tickets: $58 | $58 | +| Groceries | Todis × 2 ($12) · F.Lli Casali ($17) · SumUp ($12) | $41 | +| Shopping | Scudieri International Florence EUR 51: $84 | $84 | +| Cash | Mar 21 cash transactions lump sum | $337 | + +> **Florence day trip (Mar 22):** Trenitalia tickets $452 + $274 bought at Roma Termini ($493) and online, plus Scudieri shopping EUR 51. +> The two large Trenitalia charges on Mar 22–23 suggest additional train legs beyond Rome–Venice (possibly Florence or Naples day trip and ticket changes). + +--- + +### Venice (Mar 25–27) — $960 + +| Item | Amount | +|---|---:| +| Water taxi Ferrovia → accommodation | $79 | +| Caffe Rialto Venezia (dinner) | $109 | +| Fresh pasta restaurant (Sum Dal) | $96 | +| Baci Pasta | $70 | +| Dolcemente Salato (pastries/café) | $67 | +| Farini Pizza × 3 visits | $92 | +| Tvm Rialto Diretti 2 — shopping EUR 125 | $209 | +| Muse Holiday extra charge EUR 60 | $100 | +| Uber × 2 | $48 | +| Despar groceries | $19 | +| Gelatoteca Suso + Bar Americano | $17 | +| Pasticceria Ballarin + Bar Filovia × 2 | $28 | + +> Grand Canal water taxi arrival ($79) was the planned "wow moment". +> Tvm Rialto Diretti 2 (EUR 125) — likely Murano glass or quality Venetian craft souvenirs, the largest single on-trip discretionary purchase outside of gear/activities. + +--- + +### Ortisei / Dolomites (Mar 28–31) — $3,595 + +The most expensive on-trip leg. Five major items account for $2,857 of it: + +| Item | Amount | Detail | +|---|---:|---| +| Erich Perathon (gear shop, Val Gardena) | **$1,477** | EUR 880 — outdoor/ski clothing or equipment in Santa Cristina | +| Funivie Seceda cable car | **$441** | EUR 236+27 — the centrepiece cable car to 2,519m above Ortisei | +| Trenitalia Venice → Bolzano (5 pax) | **$528** | EUR 259.50 + EUR 55 split across two bookings | +| Taxi Demez Gregor (into valley) | **$238** | EUR 142 — private taxi from Bolzano to Ortisei (no public option for 5 with bags) | +| Funivia Lagazuoi, Cortina | **$222** | EUR 132.50 — Cortina d'Ampezzo day trip cable car | +| Despar Dolomiti (groceries) | **$201** | EUR 97.59 + EUR 22.30 across two shops | +| Ristorante Meuble Pont, Cortina | **$180** | EUR 107.50 — group dinner on Cortina day trip | +| Turonda Pizza Ortisei | $93 | | +| Istanbul Kebab + Buffet Bolzano × 2 | $111 | Transit + local meals | +| Lago di Braies chocolate stop | $21 | | +| Café Adler + Caffe Corso | $34 | | + +--- + +### Varenna / Lake Como (Apr 1–2) — $863 + +| Item | Amount | +|---|---:| +| Taxi Demez Gregor (out of valley) | $239 | +| Trenitalia (onward journey) | $101 | +| Pizza e Sfizi × 2 dinners | $113 | +| Mr Panino Bellagio (lunch) | $67 | +| Bernasconi Claudia Varenna | $55 | +| Varenna 1 shop EUR 55 | $92 | +| Valli Mara Bellagio souvenirs | $71 | +| Bistro di Corti × 2 | $50 | +| Lake Bakery × 2 | $30 | +| Ristorante Liberty, Tirano | $44 | +| Groceries (Despar, Macelleria × 2) | $49 | +| Gelato + bakery | $28 | + +> Bellagio day trip from Varenna was a highlight — lunch at Mr Panino, ice cream at Gilardoni Stefania, souvenirs. +> Taxi Demez again for the exit from Val Gardena (charged Apr 1, same driver/company as arrival). + +--- + +### St Moritz (Apr 3–4) — $355 + +Almost entirely self-catered: + +| Item | Amount | +|---|---:| +| Coop supermarket (big shop) | $114 | +| Pizzaway × 2 orders | $89 | +| Bäckerei Bad × 3 visits | $58 | +| Coop (additional) | $14 | +| Bus und Service | $22 | +| Taxi St Moritz CHF 30 | $55 | +| SBB WC | $3 | + +> Despite being the highest accommodation cost per night ($1,280/night), almost nothing was spent on-trip. The group self-catered using Coop and relied on the bakery. + +--- + +### Lucerne (Apr 5–7) — $3,422 + +Three large purchases dominate: + +| Item | Amount | Detail | +|---|---:|---| +| Rail Europe (last-minute) | **$1,526** | EUR 911.95 — Swiss Travel Pass or multi-city rail tickets; bought while already in Lucerne | +| Daytrip.com private tour | **$1,078** | EUR 642 — private guided day tour (description: "Prague" but likely Rhine Falls or day excursion from Lucerne) | +| Rigi Bahnen (cog railway) | **$844** | CHF 461.60 — historic Rigi mountain railway, 5 pax round trip (charged Apr 10 but relates to Lucerne stay) | +| Hotel Rigi Kaltbad dining | $47 | Lunch at mountain hotel summit | +| Rigi Dorfladen shop | $37 | CHF 20 at Rigi summit village | +| Uber Eats | $94 | CHF 51.23 | +| WAL*TENZ GMBH Zurich | $82 | CHF 45.10 — dining/café in Zurich | +| QUZGYXOO-2 | $150 | CHF 82.50 — unidentified Lucerne charge | +| GO4T Luzern | $114 | CHF 62.40 — likely a guided activity or boat tour | +| Amorino Gelato + Alpineum Café | $51 | | +| Juice Paradise bubble tea | $29 | | +| SBB toilets × 4 | $11 | CHF 1.50 each — Swiss public toilets | + +> The Rail Europe purchase last-minute cost $1,526 vs booking pre-trip where Swiss Travel Passes are generally cheaper. Worth pre-purchasing on future Swiss trips. + +--- + +### Paris (Apr 8–12) — $3,878 + +The return leg via Basel with 4 nights in Paris: + +**Transit Basel (Apr 8):** +Bahn Mi Pho CHF 69 ($126) + McDonald's CHF 51 ($93) + Weber1s bagels ($66) + Kaffeemacher coffee ($17) + Confiseur Bachmann ($22) + Acento Argentino ($26) = **$350 in Basel** for the group during travel day + +**Paris spending:** + +| Item | Amount | Detail | +|---|---:|---| +| Viator/TripAdvisor guided tour | $296 | USD 203.70 — skip-the-line or guided Versailles/Paris tour | +| Palace of Versailles tickets | $292 | Full group | +| Louvre Museum tickets | $277 | Full group | +| Adagio Paris hotel extra charge | $282 | EUR 169 — extra night or room upgrade | +| FC Soleil restaurant | $207 | EUR 125 — group dinner | +| Uber airport transfer (Apr 12) | $126 | EUR 75.98 — departure to CDG | +| Deliveroo × 2 | $123 | EUR 52.49 + EUR 21.90 | +| Yak and Yeti restaurant | $83 | EUR 49.90 | +| Uber Eats | $65 | | +| Wild Deer restaurant | $64 | EUR 38.20 | +| Uber trips × 5 | $192 | Multiple rides across Paris | +| Fric Frac Montmartre | $55 | EUR 33 | +| Mitao | $56 | | +| Navigo transit pass | $61 | Group metro/bus | +| Little Versailles coffeeshop | $36 | | +| Pain Pain bakery × 2 | $29 | | +| Sarl by Orsel (groceries) × 2 | $35 | | +| Various small | $49 | | + +> Museums + guided tour = **$865 on Paris attractions** (Versailles, Louvre, Viator). +> Uber was heavily used in Paris — 5+ trips totalling $254 including the CDG airport transfer. + +--- + +## Spend by Category (Net) + +| Category | Net Spend | Txns | Notes | +|---|---:|---:|---| +| Travel | $31,290 | 51 | Flights, accommodation, trains, cable cars | +| Dining | $4,241 | 95 | 95 restaurant/café/delivery transactions | +| Transport | $2,636 | 35 | Uber, taxis, buses, metro passes | +| Shopping | $2,053 | 6 | Erich Perathon $1,477 + Venice/Varenna/Florence | +| Entertainment | $698 | 4 | Versailles, Louvre, Basilica, Bellagio souvenirs | +| Groceries | $589 | 15 | Despar, Coop, Todis, Macelleria | +| Other (net) | −$1,751 | 4 | Qatar refund exceeds "other" debits | +| Uncategorised | $42 | 2 | Residual | +| **Total** | **$39,757** | **212** | | + +> The negative "Other" figure reflects the Qatar Airways refund ($2,118) offsetting the ITA/Azwebin ancillary fees charged in the same category. + +--- + +## Top 15 On-Trip Transactions + +| Date | Merchant | AUD | Category | +|---|---|---:|---| +| Mar 28 | Erich Perathon, Val Gardena | $1,477 | Shopping — EUR 880 gear | +| Apr 6 | Rail Europe | $1,526 | Travel — EUR 912 rail passes | +| Apr 7 | Daytrip.com | $1,078 | Transport — EUR 642 private tour | +| Apr 10 | Rigi Bahnen | $844 | Travel — CHF 462 mountain railway | +| Mar 22 | Roma Termini (train tickets) | $493 | Travel — EUR 300 | +| Mar 22 | Trenitalia | $452 | Travel — EUR 275 | +| Mar 28 | Trenitalia | $435 | Travel — EUR 260 | +| Mar 30 | Funivie Seceda | $396 | Travel — EUR 236 cable car | +| Apr 9 | Viator/TripAdvisor | $296 | Travel — USD 204 Paris tour | +| Apr 8 | Palace of Versailles | $292 | Entertainment | +| Apr 10 | Adagio Paris extra | $282 | Travel | +| Apr 8 | Louvre Museum | $277 | Entertainment | +| Mar 23 | Trenitalia | $274 | Travel — EUR 165 | +| Mar 21 | Cash transactions (Rome) | $337 | Various | +| Apr 1 | Taxi Demez Gregor | $239 | Transport — EUR 142 | + +--- + +## Key Observations + +**Flight changes cost $278.80.** Qatar was booked first (Jan 17, $2,396.90) and later cancelled; the refund of $2,118.10 came through during the Rome stay on Mar 24. China Eastern (MEL→PVG→FCO) replaced it at $1,885.24 — marginally cheaper but via Shanghai with a longer travel time. + +**60% of spend was locked in before leaving Australia.** Pre-trip bookings (flights for all 5, accommodation across 7 cities, and pre-purchased trains) totalled $23,730 vs $16,027 spent on the ground. + +**Dolomites was the most expensive on-trip leg** despite only 4 days — Erich Perathon EUR 880 gear purchase in Val Gardena accounts for $1,477 alone. Without it, Dolomites would be the cheapest destination per day. + +**Rail spend ballooned in Lucerne.** Rail Europe EUR 912 ($1,526) bought last-minute in Lucerne was the single largest on-trip travel cost. Swiss Travel Passes are considerably cheaper when purchased outside Switzerland before travel. + +**Dining: 95 transactions, $4,241 net.** The group averaged $193/day on food across 22 days ($39/person/day). Glovo delivery was the primary dining mode during transit days and in Rome (10+ orders). Paris had the most sit-down restaurant spending — FC Soleil EUR 125, Yak and Yeti EUR 50, plus multiple delivery orders. + +**St Moritz: highest accommodation cost, lowest on-trip spend.** Agoda charged $2,560 for 2 nights but the group spent only $355 on-trip — almost entirely groceries and the local bakery. + +**Paris Uber spend: $254 across 6+ trips** including a $126 CDG airport transfer (EUR 76). Combined with 2 Deliveroo orders and Uber Eats, rideshare/delivery accounted for ~$380 of Paris on-trip costs. diff --git a/image copy.png b/image copy.png new file mode 100644 index 0000000..685cf22 Binary files /dev/null and b/image copy.png differ diff --git a/image.png b/image.png new file mode 100644 index 0000000..a2e9c40 Binary files /dev/null and b/image.png differ diff --git a/prisma/migrations/0015_category_normalization/migration.sql b/prisma/migrations/0015_category_normalization/migration.sql new file mode 100644 index 0000000..4c053ef --- /dev/null +++ b/prisma/migrations/0015_category_normalization/migration.sql @@ -0,0 +1,137 @@ +-- Canonical category vocabulary, enforced at the database. +-- +-- Categories arrive from three places: the Gemini extraction (which writes +-- straight to Postgres from N8N, bypassing the app entirely), CSV import, and +-- manual edits. Only the DB sits under all three, so that is where the rule has +-- to live -- the same reasoning as normalize_statement_type() in 0013. +-- +-- What had leaked in without it: +-- payment 19 rows $42,569.42 transaction_type written into the category +-- refund 15 rows $8,558.99 ditto +-- Shopping 13 rows $2,917.44 title-case duplicates of real categories, +-- Dining 10 rows $127.30 which every GROUP BY counted separately +-- (NULL) 3 rows $1,135.05 + +CREATE OR REPLACE FUNCTION normalize_category(raw TEXT) +RETURNS TEXT AS $$ +DECLARE + v TEXT; +BEGIN + IF raw IS NULL OR btrim(raw) = '' THEN RETURN 'other'; END IF; + + -- Title-case and spaced variants collapse onto the canonical spelling: + -- 'Home Goods' -> 'home_goods', 'Shopping' -> 'shopping'. + v := replace(replace(lower(btrim(raw)), ' ', '_'), '-', '_'); + + IF v IN ('groceries','dining','transport','fuel','shopping','utilities', + 'entertainment','travel','health','insurance','subscriptions', + 'cash_advance','government','education','rent','home_goods', + 'home_maintenance','transfers','income','investment','loan_interest', + 'personal_care','pets','gifts','charity','fees','other') + THEN RETURN v; END IF; + + -- transaction_type leaking into the category field. A payment is money moving + -- to a card or account, which is exactly what 'transfers' means here. + IF v IN ('payment','payments','transfer') THEN RETURN 'transfers'; END IF; + + -- Bare 'interest' is credit-card interest; loan interest is categorised + -- loan_interest by the extraction prompt and matches the list above. + IF v IN ('fee','bank_fees','interest') THEN RETURN 'fees'; END IF; + + -- Singular/plural and common synonyms. + IF v IN ('grocery','supermarket') THEN RETURN 'groceries'; END IF; + IF v IN ('subscription') THEN RETURN 'subscriptions'; END IF; + IF v IN ('utility','bills') THEN RETURN 'utilities'; END IF; + IF v IN ('gift') THEN RETURN 'gifts'; END IF; + IF v IN ('pet') THEN RETURN 'pets'; END IF; + IF v IN ('restaurant','restaurants','food','takeaway') THEN RETURN 'dining'; END IF; + IF v IN ('petrol','gas','gasoline') THEN RETURN 'fuel'; END IF; + IF v IN ('medical','pharmacy') THEN RETURN 'health'; END IF; + IF v IN ('donation','donations') THEN RETURN 'charity'; END IF; + IF v IN ('salary','wages') THEN RETURN 'income'; END IF; + IF v IN ('investments','savings') THEN RETURN 'investment'; END IF; + IF v IN ('housing','mortgage') THEN RETURN 'rent'; END IF; + + -- A 'refund' is not a category -- it says nothing about what was bought. The + -- backfill below recovers the real category from the merchant where it can; + -- anything still unknown lands in 'other' rather than inventing a category. + RETURN 'other'; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + + +-- Recover categories for rows typed as refunds before collapsing them to +-- 'other': use the most common category that merchant has on ordinary spend. +UPDATE transactions t +SET category = m.mode_category +FROM ( + SELECT merchant_normalized, + MODE() WITHIN GROUP (ORDER BY category) AS mode_category + FROM transactions + WHERE merchant_normalized IS NOT NULL + AND category IS NOT NULL + AND category NOT IN ('payment','refund','fee','interest') + AND transaction_type IN ('debit','fee','interest') + GROUP BY merchant_normalized +) m +WHERE t.merchant_normalized = m.merchant_normalized + AND t.category = 'refund' + AND m.mode_category IS NOT NULL; + + +-- Backfill everything else through the function. +UPDATE transactions +SET category = normalize_category(category) +WHERE category IS NULL OR category <> normalize_category(category); + +UPDATE transaction_overrides +SET category_override = normalize_category(category_override) +WHERE category_override IS NOT NULL + AND category_override <> normalize_category(category_override); + + +CREATE OR REPLACE FUNCTION transactions_normalize_category() +RETURNS TRIGGER AS $$ +BEGIN + NEW.category := normalize_category(NEW.category); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_transactions_normalize_category ON transactions; +CREATE TRIGGER trg_transactions_normalize_category + BEFORE INSERT OR UPDATE OF category ON transactions + FOR EACH ROW EXECUTE FUNCTION transactions_normalize_category(); + +-- Overrides keep NULL meaning "no override"; only a set value is normalised. +CREATE OR REPLACE FUNCTION overrides_normalize_category() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.category_override IS NOT NULL THEN + NEW.category_override := normalize_category(NEW.category_override); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_overrides_normalize_category ON transaction_overrides; +CREATE TRIGGER trg_overrides_normalize_category + BEFORE INSERT OR UPDATE OF category_override ON transaction_overrides + FOR EACH ROW EXECUTE FUNCTION overrides_normalize_category(); + + +ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_category_check; +ALTER TABLE transactions ADD CONSTRAINT transactions_category_check + CHECK (category IS NULL OR category IN ( + 'groceries','dining','transport','fuel','shopping','utilities','entertainment', + 'travel','health','insurance','subscriptions','cash_advance','government', + 'education','rent','home_goods','home_maintenance','transfers','income', + 'investment','loan_interest','personal_care','pets','gifts','charity','fees','other')); + +ALTER TABLE transaction_overrides DROP CONSTRAINT IF EXISTS overrides_category_check; +ALTER TABLE transaction_overrides ADD CONSTRAINT overrides_category_check + CHECK (category_override IS NULL OR category_override IN ( + 'groceries','dining','transport','fuel','shopping','utilities','entertainment', + 'travel','health','insurance','subscriptions','cash_advance','government', + 'education','rent','home_goods','home_maintenance','transfers','income', + 'investment','loan_interest','personal_care','pets','gifts','charity','fees','other')); diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index 48a1f3b..5652617 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -6,8 +6,8 @@ import { STATEMENTS_JOIN, EFFECTIVE_CATEGORY, EXCLUDE_NON_SPEND, - SPEND_ROWS, - SPEND_BASE, + NET_SPEND_ROWS, + SPEND_SIGNED, mySplitOf, } from "@/lib/analytics-sql"; @@ -35,14 +35,14 @@ export async function GET(req: NextRequest) { `SELECT TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, ${EFFECTIVE_CATEGORY} as category, - SUM(${mySplitOf(SPEND_BASE)})::numeric(12,2) as total_spent, + SUM(${mySplitOf(SPEND_SIGNED)})::numeric(12,2) as total_spent, COUNT(*)::int as transaction_count FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 ${STATEMENTS_JOIN} WHERE ${OWNER_SCOPE} = $1 - AND ${SPEND_ROWS} + AND ${NET_SPEND_ROWS} AND ${EXCLUDE_NON_SPEND} AND t.transaction_date >= $2 AND t.transaction_date < $3 diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 53bf885..cfde3ee 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -100,6 +100,7 @@ function DrillDownRow({ Date Merchant + Total My share Category % mine @@ -107,8 +108,14 @@ function DrillDownRow({ {txns.map((t) => { - const sharePct = t.my_share_percent ?? 100; - const effectiveAmt = t.amount * sharePct / 100; + // Share is resolved server-side (see getTransactions) so this + // matches the category total being drilled into. The old + // `my_share_percent ?? 100` missed transaction_splits rows + // entirely and assumed 100% when a transaction was allocated + // wholly to someone else. + const sharePct = Number(t.my_share_pct ?? 100); + const grossAmt = Number(t.amount_aud ?? t.amount); + const effectiveAmt = Number(t.my_amount ?? grossAmt); const isDebit = SPEND_TYPES.has(t.transaction_type); return ( @@ -116,10 +123,13 @@ function DrillDownRow({ {new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })} {t.merchant_name || t.description} + + {fmtExact(grossAmt)} + {fmtTx(effectiveAmt, t.transaction_type)} {sharePct < 100 && ( - {fmtExact(t.amount)} + ({sharePct}%) )} diff --git a/src/app/statements/page.tsx b/src/app/statements/page.tsx index 414654d..73fc0bb 100644 --- a/src/app/statements/page.tsx +++ b/src/app/statements/page.tsx @@ -222,6 +222,19 @@ export default function StatementsPage() { {s.transaction_count} + {/* Balance assertion: flags statements whose transactions + don't add up to the closing balance. */} + {s.balance_diff !== null && s.balance_diff !== undefined && + Math.abs(Number(s.balance_diff)) >= 0.02 && ( +
+ ⚠ {formatAmount(Math.abs(Number(s.balance_diff)))} off +
+ )} {participants?.length ? ( diff --git a/src/lib/analytics-sql.ts b/src/lib/analytics-sql.ts index fbe86e3..04348b3 100644 --- a/src/lib/analytics-sql.ts +++ b/src/lib/analytics-sql.ts @@ -72,8 +72,28 @@ export const mySplitOf = (base: string, participant = "$1") => `((${base}) * ${myShare(participant)} / 100)`; /** - * Predicate excluding money-movement categories. + * Predicate excluding categories that are not spend. * The COALESCE matters: a bare `category NOT IN (...)` evaluates to NULL for * uncategorised rows, which silently drops them from spend totals. */ -export const EXCLUDE_NON_SPEND = `${EFFECTIVE_CATEGORY} NOT IN ('transfers', 'investment')`; +export const EXCLUDE_NON_SPEND = `${EFFECTIVE_CATEGORY} NOT IN ('transfers', 'investment', 'income')`; + +/** + * Rows that count towards NET spend — outgoings plus the refunds that cancel + * them. Pair with SPEND_SIGNED, which carries the direction. + */ +export const NET_SPEND_ROWS = `(${SPEND_ROWS} OR t.transaction_type IN ('refund', 'credit'))`; + +/** + * SPEND_BASE, signed: refunds and credits come back as negatives so they cancel + * the original purchase. + * + * Without this a refund is counted nowhere. It is excluded from spend by type + * and only counted as income if categorised 'income', which a refund is not — + * so it falls through both and never reduces anything. A $2,888.92 Expedia + * purchase refunded in full eight days later still read as $2,888.92 of spend. + */ +export const SPEND_SIGNED = `CASE + WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE}) + ELSE (${SPEND_BASE}) +END`; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index d477094..a560147 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -32,6 +32,9 @@ export interface TransactionRow { my_share_percent: number | null; effective_category: string; effective_merchant: string; + // My share of this transaction, resolved server-side so the UI matches analytics. + my_share_pct: number; + my_amount: number; // statement context (null for manual transactions) bank_name: string; // Native currency of the statement this row came from ('AUD' for manual rows). @@ -81,6 +84,10 @@ export interface StatementRow { owner_name: string; created_at: string; transaction_count: number; + // Balance assertion (see BALANCE_DELTA). Null when the statement has no + // opening/closing balance to check against. + expected_closing: number | null; + balance_diff: number | null; } interface TransactionFilters { @@ -202,6 +209,24 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, COALESCE(s.bank_name, 'Manual') as bank_name, COALESCE(s.currency, 'AUD') as currency, + -- My share, resolved the same way analytics does it (see myShare in + -- analytics-sql.ts): explicit split row, then override, then whatever is + -- left after everyone else. Computed here so the UI cannot drift from + -- the totals it is drilling into. + COALESCE( + (SELECT x.share_percent FROM transaction_splits x + WHERE x.transaction_id = t.id AND x.participant_id = $1), + o.my_share_percent, + 100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x + WHERE x.transaction_id = t.id AND x.participant_id <> $1), 0) + )::numeric(5,2) as my_share_pct, + (COALESCE(t.amount_aud, t.amount) * COALESCE( + (SELECT x.share_percent FROM transaction_splits x + WHERE x.transaction_id = t.id AND x.participant_id = $1), + o.my_share_percent, + 100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x + WHERE x.transaction_id = t.id AND x.participant_id <> $1), 0) + ) / 100)::numeric(12,2) as my_amount, COALESCE(t.owner_id, s.owner_id) as owner_id, p.name as owner_name, COALESCE(src.created_at, t.created_at) as created_at, @@ -279,13 +304,43 @@ export async function getTransactionById(id: number) { return rows[0] || null; } +/** + * Does opening_balance + the period's transactions equal closing_balance? + * + * The single cheapest check on extraction quality: it catches missed rows, + * duplicates, sign errors and transactions filed against the wrong statement, + * none of which are visible by eye. Borrowed from double-entry accounting, + * where it is called a balance assertion. + * + * Sign depends on what the balance means. On a liability (credit card, loan) + * the balance is what you OWE, so spending increases it and payments reduce it. + * On an asset (transaction, savings, offset) the balance is what you HOLD, so + * the signs invert. + */ +export const BALANCE_DELTA = `SUM(CASE + WHEN s.statement_type IN ('credit_card', 'loan') + THEN CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END + ELSE CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN -t.amount ELSE t.amount END +END)`; + export async function getStatements(ownerId: number) { const sql = ` SELECT s.*, (SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count, - p.name as owner_name + p.name as owner_name, + recon.expected_closing, + recon.balance_diff FROM statements s LEFT JOIN participants p ON p.id = s.owner_id + LEFT JOIN LATERAL ( + SELECT + (s.opening_balance + ${BALANCE_DELTA})::numeric(12,2) as expected_closing, + (s.opening_balance + ${BALANCE_DELTA} - s.closing_balance)::numeric(12,2) as balance_diff + FROM transactions t + WHERE t.statement_id = s.id + AND s.opening_balance IS NOT NULL + AND s.closing_balance IS NOT NULL + ) recon ON true WHERE s.owner_id = $1 ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC `;