Files
finance-app/src/lib/queries.ts
T
siddharthd 3f04cbd5e7
ci / lint-test (push) Successful in 35s
fix(trips): stop reporting a settlement breakdown that cannot be computed
The trip view showed Total Owed / Settled / Unsettled per participant, with the
last two derived from transaction_splits.settled. Nothing sets that flag - its
only writer was /api/splits/settle, which no UI calls - so it is false on all 673
splits and every trip reported 100% unsettled, including trips already paid in
full. Molina has paid $20,782.79 against $19,556.07 of splits and the Europe trip
still showed her entire share outstanding.

A correct per-trip figure is not computable either: split_payments records only
from, to, amount and date, so a payment cannot be attributed to a trip. The trip
view now shows each participant's share and points at Shared for what is actually
owed, which is where settlement genuinely lives.

Also removes /api/splits/settle. It was unreachable from the UI but live on its
URL, and a single call with participant_id would mark every one of that person's
splits settled - writing a flag nothing reads. Settlement will be reintroduced
against settlement contexts (docs/shared-expenses-design.md).

getParticipantBalances is deliberately untouched: it computes splits minus
payments, which is coherent. Excluding settled splits there while still
subtracting the payments that settled them would double-count.
2026-07-26 16:13:20 +10:00

963 lines
38 KiB
TypeScript

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<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(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<boolean> {
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<TransactionRow>(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<StatementRow>(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<StatementRow>(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<ParticipantBalance>(`
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<number> {
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<number> {
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<ManualTxWithMatches[]> {
// Fetch all unreconciled manual transactions
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(
`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<PotentialMatch & { manual_id: number; rn: number }>(
`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<number, PotentialMatch[]>();
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<TagRow & { transaction_count: number }>(`
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<TransactionRow & { split_data: string }>(`
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<TripRow[]> {
return queryRaw<TripRow>(`
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<TripRow | null> {
const rows = await queryRaw<TripRow>(`
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<TripAnalytics> {
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<TripRow> {
const rows = await queryRaw<TripRow>(`
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<TripRow | null> {
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<TripRow>(`
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<void> {
await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]);
}
export async function assignTransactionsToTrip(
tripId: number | null,
transactionIds: number[]
): Promise<void> {
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<number[]> {
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);
}