import { queryRaw } from "./db"; export interface TagRow { id: number; name: string; color: string; } export interface TransactionRow { id: number; statement_id: number; transaction_date: string; description: string; amount: number; 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; // override fields category_override: string | null; merchant_override: string | null; notes: string | null; effective_category: string; effective_merchant: string; // statement context bank_name: string; owner_id: number; owner_name: string; // tags tags: TagRow[]; } 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; tier_used: string | null; owner_id: number; owner_name: string; created_at: string; transaction_count: number; } interface TransactionFilters { from?: string; to?: string; category?: string; bank_name?: string; search?: string; statement_id?: string; tag_id?: string; sort_by?: string; sort_dir?: string; limit?: number; offset?: number; } export async function getTransactions(ownerId: number, filters: TransactionFilters) { const conditions: string[] = [`s.owner_id = $1`]; 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.category) { conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`); params.push(filters.category); } if (filters.bank_name) { conditions.push(`s.bank_name = $${paramIdx++}`); params.push(filters.bank_name); } if (filters.search) { conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name 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.tag_id) { conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`); params.push(Number(filters.tag_id)); } const where = `WHERE ${conditions.join(" AND ")}`; const sortCol = filters.sort_by === "amount" ? "t.amount" : "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 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, COALESCE(o.category_override, t.category) as effective_category, COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant, s.bank_name, s.owner_id, p.name as owner_name, txn_tags.tags FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id JOIN statements s ON s.id = t.statement_id LEFT JOIN participants p ON p.id = s.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 ${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 ?? []), })) as TransactionRow[]; return { data, total, limit, offset }; } export async function getTransactionById(id: number) { const sql = ` 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, s.bank_name, s.owner_id, p.name as owner_name FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id JOIN statements s ON s.id = t.statement_id LEFT JOIN participants p ON p.id = s.owner_id WHERE t.id = $1 `; const rows = await queryRaw(sql, [id]); return rows[0] || null; } 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 FROM statements s LEFT JOIN participants p ON p.id = s.owner_id 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 sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`; return queryRaw<{ bank_name: string }>(sql); } export interface ParticipantBalance { id: number; name: string; total_owed: number; unsettled_count: number; } export async function getParticipantBalances(ownerId: number) { return queryRaw(` SELECT p.id, p.name, COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed, COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count FROM participants p LEFT JOIN transaction_splits ts ON ts.participant_id = p.id LEFT JOIN transactions t ON t.id = ts.transaction_id LEFT JOIN statements s ON s.id = t.statement_id WHERE (s.owner_id = $1 OR s.id IS NULL) GROUP BY p.id, p.name ORDER BY p.name `, [ownerId]); } export interface SharedTransactionRow extends TransactionRow { splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[]; } 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) { 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, s.bank_name, s.owner_id, p_owner.name as owner_name, 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 JOIN statements s ON s.id = t.statement_id LEFT JOIN participants p_owner ON p_owner.id = s.owner_id WHERE s.owner_id = $1 AND EXISTS ( SELECT 1 FROM transaction_splits ts2 JOIN participants p2 ON p2.id = ts2.participant_id WHERE ts2.transaction_id = t.id AND p2.name != 'Me' ) GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name ORDER BY t.transaction_date DESC `, [ownerId]); return rows.map((r) => ({ ...r, splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data, })); }