feat(statements+analytics): normalise statement_type; fix analytics scoping
ci / lint-test (push) Successful in 38s
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:
@@ -0,0 +1,32 @@
|
||||
// Shared SQL fragments for analytics queries, so spend/income semantics stay
|
||||
// identical across routes.
|
||||
//
|
||||
// Two rules every analytics query must follow:
|
||||
//
|
||||
// 1. Join `statements` with LEFT JOIN and scope on COALESCE(t.owner_id, s.owner_id).
|
||||
// An INNER JOIN silently drops every manual/CSV transaction (statement_id IS
|
||||
// NULL) — which is most of the reconciliation and cash-spend data.
|
||||
// 2. Exclude `transfers` and `investment` from spend. Once bank statements are
|
||||
// imported, a credit-card payment appears twice: once as a debit leaving the
|
||||
// bank account and again as the underlying purchases on the card statement.
|
||||
// Categorising the money movement as `transfers` and excluding it here is what
|
||||
// stops the double count. Investments are a balance-sheet move, not spend.
|
||||
|
||||
/** Owner scoping that works for both statement-linked and manual transactions. */
|
||||
export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
|
||||
|
||||
/** Join clause to pair with OWNER_SCOPE. */
|
||||
export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`;
|
||||
|
||||
/** Transaction types that represent money going out. */
|
||||
export const SPEND_TYPES = `('debit', 'fee', 'interest')`;
|
||||
|
||||
/** Effective category, honouring overrides. Never NULL. */
|
||||
export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`;
|
||||
|
||||
/**
|
||||
* Predicate excluding money-movement categories.
|
||||
* 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')`;
|
||||
+11
-2
@@ -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]),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Canonical statement types. Mirrors the CHECK constraint and the
|
||||
// normalize_statement_type() SQL function in migration 0013 — keep them in sync.
|
||||
export const STATEMENT_TYPES = [
|
||||
"credit_card",
|
||||
"transaction",
|
||||
"savings",
|
||||
"loan",
|
||||
"offset",
|
||||
"investment",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type StatementType = (typeof STATEMENT_TYPES)[number];
|
||||
|
||||
export const STATEMENT_TYPE_LABELS: Record<StatementType, string> = {
|
||||
credit_card: "Credit Card",
|
||||
transaction: "Transaction",
|
||||
savings: "Savings",
|
||||
loan: "Loan",
|
||||
offset: "Offset",
|
||||
investment: "Investment",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
export function isStatementType(v: string | null | undefined): v is StatementType {
|
||||
return !!v && (STATEMENT_TYPES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
/** Falls back to "other" for anything unrecognised (e.g. rows predating 0013). */
|
||||
export function asStatementType(v: string | null | undefined): StatementType {
|
||||
return isStatementType(v) ? v : "other";
|
||||
}
|
||||
|
||||
export function formatStatementType(v: string | null | undefined): string {
|
||||
return STATEMENT_TYPE_LABELS[asStatementType(v)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Types where the headline figure is "amount owed" rather than "balance held",
|
||||
* and where a payment due date is meaningful.
|
||||
*/
|
||||
export function isLiability(v: string | null | undefined): boolean {
|
||||
const t = asStatementType(v);
|
||||
return t === "credit_card" || t === "loan";
|
||||
}
|
||||
Reference in New Issue
Block a user