ci / lint-test (push) Successful in 35s
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.
963 lines
38 KiB
TypeScript
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);
|
|
}
|