// 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')`; /** * Rows that count towards spend. * * The `interest_amount IS NOT NULL` arm is for loan repayments: when a lender * itemises principal and interest on a single repayment row, that row is usually * typed 'payment' (money reducing the loan balance) and would otherwise be * skipped — but its interest portion is real spend. */ export const SPEND_ROWS = `(t.transaction_type IN ('debit', 'fee', 'interest') OR t.interest_amount IS NOT NULL)`; /** * The amount of a row that counts as spend, before split adjustment. * * For an itemised loan repayment only the interest portion is an expense; the * principal builds equity and is a balance-sheet move, not spending. */ export const SPEND_BASE = `CASE WHEN t.interest_amount IS NOT NULL THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END`; /** Effective category, honouring overrides. Never NULL. */ export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`; /** * My percentage share of a transaction, 0-100. * * Resolution order: * 1. An explicit `transaction_splits` row for me. * 2. The `my_share_percent` override. * 3. Whatever is left after everyone else's shares. * * Step 3 is the one that matters. Assuming 100% when no split row exists for me * is wrong whenever a transaction is allocated entirely to someone else — I paid, * they owe all of it, and there is no row for me to find. Those rows would * otherwise land in my spend at full value. * * Requires `transaction_splits ts` joined on `ts.participant_id = ` * and `transaction_overrides o` joined on the transaction. */ export const myShare = (participant = "$1") => `COALESCE( ts.share_percent, 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 <> ${participant} ), 0) )`; /** `base` scaled to my share. Use for every per-user spend total. */ export const mySplitOf = (base: string, participant = "$1") => `((${base}) * ${myShare(participant)} / 100)`; /** * 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', '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`;