import { queryRaw } from "./db"; export interface TagRow { id: number; name: string; color: string; } export interface TransactionRow { id: number; statement_id: number | null; transaction_date: string; description: string; amount: number; amount_aud: number | null; transaction_type: string; merchant_name: string | null; merchant_normalized: string | null; location: string | null; foreign_currency_amount: number | null; foreign_currency_code: string | null; category: string; row_index: number; created_at: string; // loan repayment split — set only when the lender itemises it (migration 0014) principal_amount: number | null; interest_amount: number | null; // How it was paid (migration 0016). NULL = unknown, treated as reconcilable. // 'cash' is excluded from reconciliation — see notCash(). payment_method: string | null; // override fields category_override: string | null; merchant_override: string | null; notes: string | null; 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). // `amount` is in this currency; `amount_aud` is the converted figure. currency: string; owner_id: number; owner_name: string; // tags tags: TagRow[]; // splits splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[]; // trip trip_id: number | null; trip_name: string | null; trip_color: string | null; } export interface StatementRow { id: number; bank_name: string; card_name: string | null; account_number: string; account_type: string | null; account_holder_name: string | null; billing_start_date: string | null; billing_end_date: string | null; total_amount_due: number; minimum_amount_due: number | null; payment_due_date: string; opening_balance: number | null; closing_balance: number | null; total_credits: number | null; total_debits: number | null; interest_charged: number | null; fees_charged: number | null; credit_limit: number | null; currency: string; statement_type: string | null; // Loan statements only (see migration 0014) interest_rate: number | null; scheduled_repayment: number | null; repayment_frequency: string | null; redraw_available: number | null; loan_term_months: number | null; tier_used: string | null; owner_id: number; 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 { from?: string; to?: string; categories?: string[]; bank_names?: string[]; tag_ids?: string[]; transaction_types?: string[]; search?: string; statement_id?: string; sort_by?: string; sort_dir?: string; limit?: number; offset?: number; amount_min?: number; amount_max?: number; has_split?: string; trip_id?: string; } export async function getTransactions(ownerId: number, filters: TransactionFilters) { const conditions: string[] = [ `(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`, `NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`, ]; const params: unknown[] = [ownerId]; let paramIdx = 2; if (filters.from) { conditions.push(`t.transaction_date >= $${paramIdx++}`); params.push(filters.from); } if (filters.to) { conditions.push(`t.transaction_date <= $${paramIdx++}`); params.push(filters.to); } if (filters.categories?.length) { conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`); params.push(filters.categories); } if (filters.bank_names?.length) { const hasManual = filters.bank_names.includes("Manual"); const bankList = filters.bank_names.filter((b) => b !== "Manual"); if (hasManual && bankList.length > 0) { conditions.push(`(t.statement_id IS NULL OR s.bank_name = ANY($${paramIdx++}::text[]))`); params.push(bankList); } else if (hasManual) { conditions.push(`t.statement_id IS NULL`); } else { conditions.push(`s.bank_name = ANY($${paramIdx++}::text[])`); params.push(bankList); } } if (filters.tag_ids?.length) { const noTags = filters.tag_ids.includes("untagged"); const realTagIds = filters.tag_ids.filter((id) => id !== "untagged").map(Number); if (noTags) { conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id)`); } else if (realTagIds.length > 0) { conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`); params.push(realTagIds); } } if (filters.transaction_types?.length) { conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`); params.push(filters.transaction_types); } if (filters.search) { conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx} OR COALESCE(o.merchant_normalized, t.merchant_normalized) ILIKE $${paramIdx})`); params.push(`%${filters.search}%`); paramIdx++; } if (filters.statement_id) { conditions.push(`t.statement_id = $${paramIdx++}`); params.push(Number(filters.statement_id)); } if (filters.amount_min !== undefined) { conditions.push(`t.amount >= $${paramIdx++}`); params.push(filters.amount_min); } if (filters.amount_max !== undefined) { conditions.push(`t.amount <= $${paramIdx++}`); params.push(filters.amount_max); } if (filters.has_split === "yes") { conditions.push(`EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`); } else if (filters.has_split === "no") { conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`); } if (filters.trip_id === "unassigned") { conditions.push(`o.trip_id IS NULL`); } else if (filters.trip_id) { conditions.push(`o.trip_id = $${paramIdx++}`); params.push(Number(filters.trip_id)); } const where = `WHERE ${conditions.join(" AND ")}`; const sortCol = filters.sort_by === "amount" ? "t.amount" : filters.sort_by === "created_at" ? "t.created_at" : "t.transaction_date"; const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC"; const limit = filters.limit || 50; const offset = filters.offset || 0; const countSql = ` SELECT COUNT(*)::int as total FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN statements s ON s.id = t.statement_id ${where} `; const countResult = await queryRaw<{ total: number }>(countSql, params); const total = countResult[0]?.total || 0; const dataSql = ` SELECT t.*, o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent, COALESCE(o.category_override, t.category) as effective_category, 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, o.trip_id, tr.name as trip_name, tr.color as trip_color, txn_tags.tags, txn_splits.splits FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id) LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL LEFT JOIN trips tr ON tr.id = o.trip_id LEFT JOIN LATERAL ( SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id WHERE tt.transaction_id = t.id ) txn_tags ON true LEFT JOIN LATERAL ( SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits FROM transaction_splits ts JOIN participants sp ON sp.id = ts.participant_id WHERE ts.transaction_id = t.id ) txn_splits ON true ${where} ORDER BY ${sortCol} ${sortDir}, t.row_index ASC LIMIT $${paramIdx++} OFFSET $${paramIdx++} `; params.push(limit, offset); const raw = await queryRaw(dataSql, params); const data = raw.map((r) => ({ ...r, tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []), splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []), })) as TransactionRow[]; return { data, total, limit, offset }; } // A user may act on a transaction they own (directly or via the parent // statement) or one they participate in via a split. export async function canAccessTransactions(ownerId: number, transactionIds: number[]): Promise { if (!transactionIds.length) return false; const rows = await queryRaw<{ n: number }>( `SELECT COUNT(*)::int AS n FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id WHERE t.id = ANY($2::int[]) AND (COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts WHERE ts.transaction_id = t.id AND ts.participant_id = $1))`, [ownerId, transactionIds] ); return rows[0]?.n === transactionIds.length; } export async function getTransactionById(id: number) { const sql = ` SELECT t.*, o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent, COALESCE(o.category_override, t.category) as effective_category, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, COALESCE(s.bank_name, 'Manual') as bank_name, COALESCE(t.owner_id, s.owner_id) as owner_id, p.name as owner_name FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id) WHERE t.id = $1 `; const rows = await queryRaw(sql, [id]); 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, 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 `; return queryRaw(sql, [ownerId]); } export async function getStatementById(id: 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 FROM statements s LEFT JOIN participants p ON p.id = s.owner_id WHERE s.id = $1 `; const rows = await queryRaw(sql, [id]); return rows[0] || null; } export async function getMerchantSuggestions(search: string) { const sql = ` SELECT DISTINCT COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as merchant FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id WHERE COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) ILIKE $1 ORDER BY merchant LIMIT 20 `; return queryRaw<{ merchant: string }>(sql, [`%${search}%`]); } export async function getBankNames() { const [bankRows, manualCount] = await Promise.all([ queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`), queryRaw<{ count: number }>(`SELECT COUNT(*)::int as count FROM transactions WHERE statement_id IS NULL`), ]); const banks = bankRows.map((r) => r.bank_name); if (manualCount[0]?.count > 0) banks.push("Manual"); return banks; } export interface ParticipantBalance { id: number; name: string; total_owed: number; unsettled_count: number; } export async function getParticipantBalances(ownerId: number, tagIds?: number[]) { const params: unknown[] = [ownerId]; let tagFilter = ""; if (tagIds?.length) { params.push(tagIds); tagFilter = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`; } // Payments settle the total relationship between two people, not a specific tag. // Only subtract payments when viewing the unfiltered total; with a tag filter // active, show the raw split amount for that tag context only. const paymentsJoin = tagIds?.length ? "" : ` LEFT JOIN ( SELECT CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid, SUM(CASE WHEN sp.to_participant_id = $1 THEN sp.amount ELSE -sp.amount END) AS net_paid FROM split_payments sp WHERE sp.from_participant_id = $1 OR sp.to_participant_id = $1 GROUP BY pid ) payments ON payments.pid = p.id`; const paymentsSelect = tagIds?.length ? "" : "- COALESCE(payments.net_paid, 0)::numeric(12,2)"; const paymentsGroup = tagIds?.length ? "" : ", payments.net_paid"; return queryRaw(` SELECT p.id, p.name, COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2) ${paymentsSelect} AS total_owed, COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count FROM participants p LEFT JOIN ( -- They owe me: their splits on transactions I own -- Settle in AUD: on a foreign-currency row the amount column is in its own -- currency, so splitting on it nets a USD figure against AUD ones. SELECT ts.participant_id AS pid, (CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100 AS signed_amount, 1 AS split_count FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id LEFT JOIN statements s ON s.id = t.statement_id WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1 AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL) ${tagFilter} UNION ALL -- I owe them: my splits on transactions they own SELECT COALESCE(t.owner_id, s.owner_id) AS pid, -((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100) AS signed_amount, 0 AS split_count FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id LEFT JOIN statements s ON s.id = t.statement_id WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1 AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL) ${tagFilter} ) splits ON splits.pid = p.id ${paymentsJoin} WHERE p.id != $1 GROUP BY p.id, p.name ${paymentsGroup} ORDER BY p.name `, params); } export interface SharedTransactionRow extends TransactionRow { splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[]; } export async function ensureTag(name: string, color: string): Promise { const rows = await queryRaw<{ id: number }>( `INSERT INTO tags (name, color) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name, color] ); return rows[0].id; } export async function batchInsertCSVTransactions( ownerId: number, rows: { date: string; description: string; amount: number; transaction_type: string; merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string; }[], tagId: number ): Promise { if (rows.length === 0) return 0; const baseRows = await queryRaw<{ base: number }>( `SELECT COALESCE(MAX(row_index), -1) as base FROM transactions WHERE owner_id = $1 AND statement_id IS NULL`, [ownerId] ); const base = Number(baseRows[0].base); const valueClauses: string[] = []; const params: unknown[] = [ownerId]; let p = 2; rows.forEach((r, i) => { valueClauses.push(`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`); params.push(r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null, r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null); }); const txIds = await queryRaw<{ id: number }>( `INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, row_index) VALUES ${valueClauses.join(", ")} RETURNING id`, params ); if (txIds.length > 0) { const tagValueClauses = txIds.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`); const tagParams: unknown[] = txIds.flatMap((r) => [r.id, tagId]); await queryRaw( `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${tagValueClauses.join(", ")} ON CONFLICT DO NOTHING`, tagParams ); } return txIds.length; } /** * Excludes cash from reconciliation. * * Cash never appears on a statement, so a cash transaction would sit in the * queue forever being offered matches within 3 days and 1% on amount. Accepting * one is silently destructive: reconciled manual rows are filtered out of every * query, so the cash spend vanishes while the card transaction it matched * claims to be that same spend. * * Only cash is excluded. Bank transfers do appear on a statement now that * transaction accounts are imported, and NULL means unknown — both stay * candidates, which preserves the behaviour of every pre-existing row. */ export const notCash = (alias = "t") => `(${alias}.payment_method IS NULL OR ${alias}.payment_method <> 'cash')`; export interface PotentialMatch { id: number; transaction_date: string; description: string; amount: number; transaction_type: string; effective_merchant: string; effective_category: string; bank_name: string; billing_end_date: string | null; } export interface ManualTxWithMatches extends TransactionRow { matches: PotentialMatch[]; } export async function getPendingReconciliations(ownerId: number): Promise { // Fetch all unreconciled manual transactions const raw = await queryRaw( `SELECT t.*, o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent, COALESCE(o.category_override, t.category) as effective_category, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, 'Manual' as bank_name, t.owner_id, p.name as owner_name, txn_tags.tags, txn_splits.splits FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN participants p ON p.id = t.owner_id LEFT JOIN LATERAL ( SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id WHERE tt.transaction_id = t.id ) txn_tags ON true LEFT JOIN LATERAL ( SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits FROM transaction_splits ts JOIN participants sp ON sp.id = ts.participant_id WHERE ts.transaction_id = t.id ) txn_splits ON true WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL AND ${notCash("t")} ORDER BY t.transaction_date DESC, t.row_index ASC`, [ownerId] ); const manualTxs = raw.map((r) => ({ ...r, tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []), splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []), })) as TransactionRow[]; if (manualTxs.length === 0) return []; // Fetch all potential matches in one query using window function const matchRows = await queryRaw( `SELECT manual_id, id, transaction_date, description, amount, transaction_type, effective_merchant, effective_category, bank_name, billing_end_date FROM ( SELECT m.id AS manual_id, t.id, t.transaction_date, t.description, t.amount, t.transaction_type, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, '') AS effective_merchant, COALESCE(o.category_override, t.category, '') AS effective_category, s.bank_name, s.billing_end_date, ROW_NUMBER() OVER ( PARTITION BY m.id ORDER BY ABS(t.amount - m.amount), ABS(t.transaction_date - m.transaction_date) ) AS rn FROM transactions m JOIN transactions t ON t.statement_id IS NOT NULL AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3 AND t.amount BETWEEN m.amount * 0.99 AND m.amount * 1.01 JOIN statements s ON s.id = t.statement_id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id WHERE m.statement_id IS NULL AND m.owner_id = $1 AND m.reconciled_with_id IS NULL AND ${notCash("m")} AND COALESCE(t.owner_id, s.owner_id) = $1 AND NOT EXISTS ( SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id ) ) sq WHERE rn <= 5 ORDER BY manual_id, rn`, [ownerId] ); // Group matches by manual_id const matchesByManualId = new Map(); for (const row of matchRows) { const list = matchesByManualId.get(row.manual_id) ?? []; list.push({ id: row.id, transaction_date: row.transaction_date, description: row.description, amount: row.amount, transaction_type: row.transaction_type, effective_merchant: row.effective_merchant, effective_category: row.effective_category, bank_name: row.bank_name, billing_end_date: row.billing_end_date, }); matchesByManualId.set(row.manual_id, list); } return manualTxs.map((tx) => ({ ...tx, matches: matchesByManualId.get(tx.id) ?? [], })); } export async function getTags() { return queryRaw(` SELECT tg.id, tg.name, tg.color, COUNT(tt.transaction_id)::int as transaction_count FROM tags tg LEFT JOIN transaction_tags tt ON tt.tag_id = tg.id GROUP BY tg.id ORDER BY tg.name `); } export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean, participantId?: number) { const params: unknown[] = [ownerId]; let tagClause = ""; if (noTags) { tagClause = `AND NOT EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id)`; } else if (tagIds?.length) { params.push(tagIds); tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`; } let participantClause = ""; if (participantId) { params.push(participantId); participantClause = `AND EXISTS (SELECT 1 FROM transaction_splits ts_p WHERE ts_p.transaction_id = t.id AND ts_p.participant_id = $${params.length})`; } const rows = await queryRaw(` SELECT t.*, o.category_override, o.merchant_normalized as merchant_override, o.notes, COALESCE(o.category_override, t.category) as effective_category, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, COALESCE(s.bank_name, 'Manual') as bank_name, COALESCE(t.owner_id, s.owner_id) as owner_id, p_owner.name as owner_name, COALESCE(src.created_at, t.created_at) as created_at, json_agg(json_build_object( 'split_id', ts.id, 'participant_id', ts.participant_id, 'name', p.name, 'share_percent', ts.share_percent, 'settled', ts.settled ) ORDER BY p.name) as split_data FROM transactions t JOIN transaction_splits ts ON ts.transaction_id = t.id JOIN participants p ON p.id = ts.participant_id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id) LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL WHERE ( ( COALESCE(t.owner_id, s.owner_id) = $1 AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1) ) OR ( COALESCE(t.owner_id, s.owner_id) != $1 AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1) ) ) AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL) ${tagClause} ${participantClause} GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at ORDER BY t.transaction_date DESC `, params); return rows.map((r) => ({ ...r, splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data, })); } // ─── Trips ─────────────────────────────────────────────────────────────────── export interface TripRow { id: number; owner_id: number; name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean; created_at: string; total_spend: number; transaction_count: number; } export interface TripAnalytics { trip: TripRow; total_spend: number; transaction_count: number; num_days: number; daily_average: number; category_breakdown: { category: string; amount: number; count: number }[]; daily_spend: { date: string; amount: number }[]; top_merchants: { merchant: string; amount: number; count: number }[]; tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[]; participant_splits: { participant_id: number; name: string; owed: number }[]; } export async function getTrips(ownerId: number): Promise { return queryRaw(` SELECT t.*, COALESCE(SUM( 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 LEFT JOIN transaction_overrides o ON o.trip_id = t.id LEFT JOIN transactions tx ON tx.id = o.transaction_id WHERE t.owner_id = $1 GROUP BY t.id ORDER BY t.created_at DESC `, [ownerId]); } export async function getTripById(id: number, ownerId: number): Promise { const rows = await queryRaw(` SELECT t.*, COALESCE(SUM( 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 LEFT JOIN transaction_overrides o ON o.trip_id = t.id LEFT JOIN transactions tx ON tx.id = o.transaction_id WHERE t.id = $1 AND t.owner_id = $2 GROUP BY t.id `, [id, ownerId]); return rows[0] ?? null; } export async function getTripAnalytics(tripId: number, ownerId: number): Promise { const trip = await getTripById(tripId, ownerId); if (!trip) throw new Error("Trip not found"); const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([ queryRaw<{ category: string; amount: number; count: number }>(` SELECT COALESCE(o.category_override, tx.category, 'other') AS category, SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount, COUNT(*)::int AS count FROM transaction_overrides o 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]), queryRaw<{ date: string; amount: number }>(` SELECT tx.transaction_date::text AS date, SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount FROM transaction_overrides o 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]), queryRaw<{ merchant: string; amount: number; count: number }>(` SELECT COALESCE(o.merchant_normalized, tx.merchant_normalized, tx.merchant_name, tx.description) AS merchant, SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount, COUNT(*)::int AS count FROM transaction_overrides o 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 `, [tripId]), queryRaw<{ tag_id: number; name: string; color: string; amount: number; count: number }>(` SELECT tg.id AS tag_id, tg.name, tg.color, SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount, COUNT(DISTINCT tx.id)::int AS count FROM transaction_overrides o JOIN transactions tx ON tx.id = o.transaction_id JOIN transaction_tags tt ON tt.transaction_id = tx.id 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]), // No settled/unsettled breakdown here. It was computed from // transaction_splits.settled, which only /api/splits/settle writes and // nothing in the UI calls — so it is false on all 673 splits and every trip // reported 100% unsettled, including trips paid in full. A real per-trip // figure is not computable either: split_payments carries no trip // attribution, so a payment cannot be assigned to a trip. Settlement is a // property of the whole relationship until settlement contexts exist // (see docs/shared-expenses-design.md). queryRaw<{ participant_id: number; name: string; owed: number }>(` SELECT p.id AS participant_id, p.name, SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed FROM transaction_overrides o JOIN transactions tx ON tx.id = o.transaction_id JOIN transaction_splits ts ON ts.transaction_id = tx.id 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]), ]); const num_days = (trip.start_date && trip.end_date) ? Math.max(1, Math.round((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1) : Math.max(dailyRows.length, 1); return { trip, total_spend: trip.total_spend, transaction_count: trip.transaction_count, num_days, daily_average: trip.total_spend / num_days, category_breakdown: categoryRows, daily_spend: dailyRows, top_merchants: merchantRows, tag_breakdown: tagRows, participant_splits: splitRows, }; } export async function createTrip( ownerId: number, data: { name: string; description?: string | null; start_date?: string | null; end_date?: string | null; color?: string } ): Promise { const rows = await queryRaw(` INSERT INTO trips (owner_id, name, description, start_date, end_date, color) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *, 0::float AS total_spend, 0::int AS transaction_count `, [ownerId, data.name, data.description ?? null, data.start_date ?? null, data.end_date ?? null, data.color ?? '#6366f1']); return rows[0]; } export async function updateTrip( id: number, ownerId: number, data: Partial<{ name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean }> ): Promise { const setClauses: string[] = []; const params: unknown[] = []; let idx = 1; if (data.name !== undefined) { setClauses.push(`name = $${idx++}`); params.push(data.name); } if ('description' in data) { setClauses.push(`description = $${idx++}`); params.push(data.description ?? null); } if ('start_date' in data) { setClauses.push(`start_date = $${idx++}`); params.push(data.start_date ?? null); } if ('end_date' in data) { setClauses.push(`end_date = $${idx++}`); params.push(data.end_date ?? null); } if (data.color !== undefined) { setClauses.push(`color = $${idx++}`); params.push(data.color); } if (data.archived !== undefined) { setClauses.push(`archived = $${idx++}`); params.push(data.archived); } if (!setClauses.length) return getTripById(id, ownerId); params.push(id, ownerId); const rows = await queryRaw(` UPDATE trips SET ${setClauses.join(', ')} WHERE id = $${idx++} AND owner_id = $${idx} RETURNING *, 0::float AS total_spend, 0::int AS transaction_count `, params); return rows[0] ?? null; } export async function deleteTrip(id: number, ownerId: number): Promise { await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]); } export async function assignTransactionsToTrip( tripId: number | null, transactionIds: number[] ): Promise { if (!transactionIds.length) return; await queryRaw(` INSERT INTO transaction_overrides (transaction_id, trip_id) SELECT unnest($1::int[]), $2 ON CONFLICT (transaction_id) DO UPDATE SET trip_id = EXCLUDED.trip_id `, [transactionIds, tripId]); } export async function getTagTransactionIds(tagId: number): Promise { const rows = await queryRaw<{ transaction_id: number }>( `SELECT transaction_id FROM transaction_tags WHERE tag_id = $1`, [tagId] ); return rows.map((r) => r.transaction_id); }