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.
This commit is contained in:
2026-07-26 00:00:55 +10:00
parent e0b0fc91e0
commit 25ef504574
12 changed files with 269 additions and 39 deletions
+11 -2
View File
@@ -670,7 +670,9 @@ export async function getTrips(ownerId: number): Promise<TripRow[]> {
SELECT
t.*,
COALESCE(SUM(
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN COALESCE(tx.amount_aud, tx.amount) ELSE 0 END
CASE WHEN tx.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
THEN COALESCE(tx.amount_aud, tx.amount) ELSE 0 END
), 0)::float AS total_spend,
COUNT(o.transaction_id)::int AS transaction_count
FROM trips t
@@ -687,7 +689,9 @@ export async function getTripById(id: number, ownerId: number): Promise<TripRow
SELECT
t.*,
COALESCE(SUM(
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN COALESCE(tx.amount_aud, tx.amount) ELSE 0 END
CASE WHEN tx.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
THEN COALESCE(tx.amount_aud, tx.amount) ELSE 0 END
), 0)::float AS total_spend,
COUNT(o.transaction_id)::int AS transaction_count
FROM trips t
@@ -713,6 +717,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
`, [tripId]),
@@ -725,6 +730,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 1
`, [tripId]),
@@ -738,6 +744,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
LIMIT 10
@@ -754,6 +761,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN tags tg ON tg.id = tt.tag_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY tg.id
ORDER BY 4 DESC
`, [tripId]),
@@ -771,6 +779,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN participants p ON p.id = ts.participant_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY p.id
ORDER BY 3 DESC
`, [tripId]),