Files
finance-app/src/lib/queries.ts
T
siddharthd dbfbd5196d
ci / lint-test (push) Successful in 48s
fix(transactions): supersede rows imported twice instead of deleting them
Statements 107, 142 and 143 bill overlapping periods on one ANZ account, so 31
transactions -- $42,040.68 -- are in the ledger twice.

They are marked superseded, not deleted. Every child of transactions is ON
DELETE CASCADE (splits, tags, overrides, expense_metadata, order_reviews), so
deleting "the duplicate" destroys whatever curation sits on it, and which
member of a pair holds that curation is an accident of import order: here 1
pair carries splits and 6 carry overrides, all on the surviving side, but
nothing guarantees that. Superseding keeps the row, keeps its children, and
makes a mistake one UPDATE to undo rather than a restore from backup.

reconciled_with_id could not be reused. Its predicate is scoped to
statement_id IS NULL on purpose -- a statement line pointing at something else
is the survivor, not the duplicate -- and here both rows are statement lines.

The exclusion goes into EXCLUDE_RECONCILED_SOURCE rather than into a new
fragment, so every query already asking "count each purchase once" gets it
without being edited. The trip cost queries did not use that fragment at all
and now do; verified a no-op on current data (0 trip-tagged rows are either
reconciled sources or duplicates), but they were one import away from
double-counting.

Most of the $42k is transfers and investments, which spend already excludes.
The damage was elsewhere: duplicated rows in the list, and rules re-splitting a
duplicate -- txn 3807 is one of these 31 and was a candidate for splitting
earlier today.

Balances are unchanged: no duplicate carried a split.
2026-07-28 11:54:25 +10:00

1172 lines
48 KiB
TypeScript

import { queryRaw } from "./db";
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, ACTIVE_OBLIGATION, STATEMENTS_JOIN, OWNER_SCOPE, NET_SPEND_ROWS, SPEND_SIGNED } from "./analytics-sql";
export interface RoutePointRow {
label: string;
time: string | null;
address: string;
}
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' and 'credits' are excluded from reconciliation — see needsCardMatch().
payment_method: string | null;
/** Uber pick-up/drop-off, when this row came from an order receipt. */
order_route: RoutePointRow[] | null;
order_platform: "doordash" | "ubereats" | "uber" | 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;
// The currency `amount` is denominated in; `amount_aud` is the converted
// figure where one exists. Usually the statement's currency, but an
// order-receipt row has no statement and carries its own — see
// NATIVE_CURRENCY. Not simply 'AUD' for every statement-less row.
currency: string;
/** True when `amount` is non-AUD and no converted figure exists. */
amount_unconverted: boolean;
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;
/**
* Other statements for this account billing the same days — see
* STATEMENT_OVERLAPS. Non-empty means some of these transactions are almost
* certainly imported twice. Empty array, never null.
*/
overlaps: { id: number; days: number }[];
}
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))`,
EXCLUDE_RECONCILED_SOURCE,
];
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) {
// "Manual" and "Gift Card" are not banks — they are the two shapes a
// statement-less row can take, and bankLabel() decides which. The filter
// has to split on the same condition or the chip selects nothing.
const hasManual = filters.bank_names.includes("Manual");
const hasGiftCard = filters.bank_names.includes("Gift Card");
const bankList = filters.bank_names.filter((b) => b !== "Manual" && b !== "Gift Card");
const alternatives: string[] = [];
if (hasManual) {
alternatives.push(`(t.statement_id IS NULL AND t.payment_method IS DISTINCT FROM 'credits')`);
}
if (hasGiftCard) {
alternatives.push(`(t.statement_id IS NULL AND t.payment_method = 'credits')`);
}
if (bankList.length > 0) {
alternatives.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
params.push(bankList);
}
conditions.push(`(${alternatives.join(" OR ")})`);
}
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,
${bankLabel()} as bank_name,
${NATIVE_CURRENCY} as currency,
${AMOUNT_UNCONVERTED} as amount_unconverted,
-- 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,
order_ctx.route as order_route,
order_ctx.platform as order_platform
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN statements s ON s.id = t.statement_id
-- Order provenance, for the sub-line under the description. Five rows all
-- reading "Order - Uber Trip" are indistinguishable; where they went is the
-- only thing that tells them apart, and it was already stored.
-- Both directions, because a card-settled order has no transaction of its
-- own and points at the statement line instead (I5).
LEFT JOIN LATERAL (
SELECT em.route, em.platform
FROM expense_metadata em
WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
LIMIT 1
) order_ctx ON true
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,
${bankLabel()} 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)`;
/**
* Other statements for the same account whose billing period overlaps this one.
*
* An account cannot be billed twice for the same day, so an overlap means the
* same transactions were imported twice. This is not hypothetical: ANZ
* statements 107 and 143 overlap by 118 days and put ~$42,000 of duplicate rows
* in the ledger, which silently inflated spend and dragged the CSV split match
* rate down to 46.5%.
*
* Two details are what make it actually catch that case:
*
* - The account number is compared with non-digits stripped. The duplicate got
* in precisely because the existing duplicate key compared raw text, and ANZ
* wrote the same account as `408556264` on one statement and `4085-56264` on
* the other.
* - The range is half-open `[)`. These statements are issued back-to-back with
* one period's end date equal to the next one's start, so inclusive bounds
* flag every consecutive pair — 5 hits of which 3 were false. Half-open
* leaves exactly the 2 real ones.
*
* NULL bounds are excluded rather than passed to `daterange`, where NULL means
* unbounded and would make an undated statement overlap the entire history.
*/
const STATEMENT_OVERLAPS = `
SELECT COALESCE(json_agg(json_build_object(
'id', o.id,
'days', (LEAST(s.billing_end_date, o.billing_end_date)
- GREATEST(s.billing_start_date, o.billing_start_date))
) ORDER BY o.id), '[]'::json) AS overlaps
FROM statements o
WHERE o.id <> s.id
AND o.owner_id = s.owner_id
AND regexp_replace(o.account_number, '\\D', '', 'g')
= regexp_replace(s.account_number, '\\D', '', 'g')
AND o.billing_start_date IS NOT NULL AND o.billing_end_date IS NOT NULL
AND s.billing_start_date IS NOT NULL AND s.billing_end_date IS NOT NULL
AND daterange(s.billing_start_date, s.billing_end_date, '[)')
&& daterange(o.billing_start_date, o.billing_end_date, '[)')`;
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,
ov.overlaps
FROM statements s
LEFT JOIN participants p ON p.id = s.owner_id
LEFT JOIN LATERAL (${STATEMENT_OVERLAPS}) ov ON true
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, statementless] = await Promise.all([
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
queryRaw<{ label: string }>(
`SELECT DISTINCT ${bankLabel("t", "s")} as label
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.statement_id IS NULL`
),
]);
const banks = bankRows.map((r) => r.bank_name);
// Order matters for the filter chips: real banks first, then the
// statement-less kinds, in a stable order rather than whatever the DB returns.
for (const label of ["Manual", "Gift Card"]) {
if (statementless.some((r) => r.label === label)) banks.push(label);
}
return banks;
}
export interface ParticipantBalance {
id: number;
name: string;
total_owed: number;
unsettled_count: number;
/** Splits counted at a non-AUD figure because no converted amount exists. */
unconverted_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.
//
// That asymmetry is a symptom, not a design: a tag is a view and has no
// payments, so a tag-filtered balance had nothing honest to subtract. A trip
// does have payments (`split_payments.trip_id`, migration 0022), which is why
// the per-trip figure in getTripAnalytics can be netted and this one cannot.
// The fix for the tag case is to stop showing a balance there, not to invent
// one — see docs/shared-expenses-design.md.
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,
-- Splits whose AUD value is unknown. They are still summed above (as
-- their native figure), so a non-zero count means this balance is
-- approximate and the UI has to say so.
COALESCE(SUM(splits.unconverted_count), 0)::int AS unconverted_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,
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_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 ${EXCLUDE_RECONCILED_SOURCE}
AND ${ACTIVE_OBLIGATION}
${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,
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_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 ${EXCLUDE_RECONCILED_SOURCE}
AND ${ACTIVE_OBLIGATION}
${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.
*/
/**
* Payment methods that can still be matched against a card statement line.
*
* Cash never appears on one. Neither does a credits-funded delivery order: the
* gift card already paid it, so there is no card leg coming, ever. Leaving
* those in the queue meant 81 orders sat in "pending reconciliation" waiting
* for a match that could not exist (user, 2026-07-27).
*/
export const needsCardMatch = (alias = "t") =>
`(${alias}.payment_method IS NULL OR ${alias}.payment_method NOT IN ('cash', 'credits'))`;
/**
* Bank label for a transaction. A row with no statement was not imported from
* one, and the label has to say *why*: "Manual" reads as "hand-entered, still
* awaiting a card line", which is wrong for a gift-card order — nothing is
* awaited. `s` must be the statements alias in scope.
*/
export const bankLabel = (t = "t", s = "s") =>
`COALESCE(${s}.bank_name, CASE WHEN ${t}.payment_method = 'credits' THEN 'Gift Card' ELSE 'Manual' END)`;
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 ${needsCardMatch("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 ${needsCardMatch("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,
${bankLabel()} as bank_name,
-- The table renders t.amount, which is not always AUD. Without these the
-- rows visibly disagreed with the participant balances, which do convert.
${NATIVE_CURRENCY} as currency,
${AMOUNT_UNCONVERTED} as amount_unconverted,
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 ${EXCLUDE_RECONCILED_SOURCE}
${tagClause}
${participantClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.currency, 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;
/** Their share of this trip, net of payments scoped to it. */
owed: number;
/** Splits counted at a non-AUD figure because no converted amount exists. */
unconverted_count: number;
}[];
}
// `total_spend` is the headline figure on the trips list and the trip header,
// and it nets refunds for the same reason getTripAnalytics does — see the note
// there. Trips are aliased `tr` so that `t` can be `transactions`, which is the
// alias the shared fragments assume.
const TRIP_TOTAL_SPEND = `COALESCE(SUM(
CASE WHEN ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
THEN ${SPEND_SIGNED} ELSE 0 END
), 0)::float AS total_spend`;
export async function getTrips(ownerId: number): Promise<TripRow[]> {
return queryRaw<TripRow>(`
SELECT
tr.*,
${TRIP_TOTAL_SPEND},
COUNT(o.transaction_id)::int AS transaction_count
FROM trips tr
LEFT JOIN transaction_overrides o ON o.trip_id = tr.id
LEFT JOIN transactions t ON t.id = o.transaction_id
WHERE tr.owner_id = $1
GROUP BY tr.id
ORDER BY tr.created_at DESC
`, [ownerId]);
}
export async function getTripById(id: number, ownerId: number): Promise<TripRow | null> {
const rows = await queryRaw<TripRow>(`
SELECT
tr.*,
${TRIP_TOTAL_SPEND},
COUNT(o.transaction_id)::int AS transaction_count
FROM trips tr
LEFT JOIN transaction_overrides o ON o.trip_id = tr.id
LEFT JOIN transactions t ON t.id = o.transaction_id
WHERE tr.id = $1 AND tr.owner_id = $2
GROUP BY tr.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");
// What the trip cost, with refunds subtracted.
//
// These four queries filtered on `transaction_type IN ('debit','fee','interest')`,
// which drops every refund and credit — so money that came back was still
// counted as trip spend. A partly-refunded booking read at its full price and
// a fully-refunded one read as pure cost.
//
// NET_SPEND_ROWS admits the refunds and SPEND_SIGNED carries their direction,
// the same pair the general analytics adopted after a refunded Expedia
// purchase read as $2,888.92 of spend. `transactions` is aliased `t` because
// the fragments assume that alias.
//
// A cancelled booking is a different case and is NOT handled here: both its
// legs are untagged from the trip by hand, because a trip the booking was
// cancelled from never incurred that cost at all. This nets the partial
// refunds — a price adjustment on a booking that did happen.
//
// COUNT(*) deliberately still counts refund rows: a refund is a transaction
// that occurred on the trip, even though it subtracts from the total.
const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([
queryRaw<{ category: string; amount: number; count: number }>(`
SELECT
COALESCE(o.category_override, t.category, 'other') AS category,
SUM(${SPEND_SIGNED})::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
`, [tripId]),
queryRaw<{ date: string; amount: number }>(`
SELECT
t.transaction_date::text AS date,
SUM(${SPEND_SIGNED})::float AS amount
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.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, t.merchant_normalized, t.merchant_name, t.description) AS merchant,
SUM(${SPEND_SIGNED})::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.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(${SPEND_SIGNED})::float AS amount,
COUNT(DISTINCT t.id)::int AS count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
JOIN transaction_tags tt ON tt.transaction_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY tg.id
ORDER BY 4 DESC
`, [tripId]),
// Owed per participant for THIS trip, net of payments made against it.
//
// This was gross splits, with a comment explaining why it could not be
// anything better: `split_payments` carried no trip attribution, so a
// payment could not be assigned to a trip and every trip reported 100%
// unsettled including trips paid in full. `split_payments.trip_id`
// (migration 0022) closes that, so the figure is now real.
//
// Three exclusions, all load-bearing:
// - ACTIVE_OBLIGATION drops settled splits, so a closed trip reads zero
// rather than its original gross.
// - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has
// superseded. The trip queries never applied it, so a reconciled trip
// expense was counted twice here.
// - AMOUNT_UNCONVERTED counts rows whose AUD value is unknown, the same
// way getParticipantBalances does. They are still summed (at their
// native figure), so a non-zero count means this total is approximate
// and the UI has to say so. A trip is where foreign rows actually live,
// so netting a EUR figure against AUD ones silently is most likely to
// bite exactly here.
//
// A fourth, and it is what "owed" actually means: only rows THIS owner paid
// for. Without ${OWNER_SCOPE} the figure sums every split on every trip
// transaction regardless of who paid, so it silently mixes debts owed to
// different people. On Europe 2026 that put $1,605.49 of Molina's share of
// Sonu-paid rows into a number labelled as owed to the owner — a debt that
// is real, but between the other two participants, and which they settled
// directly (split_payments id 5, Molina -> Sonu, exactly $1,605.49).
// A participant's own share of a row they paid for was in there too, which
// is nobody's debt at all.
//
// `transactions` is aliased `t` so the shared fragments apply directly —
// they assume that alias, and hand-inlining a copy is what let the
// reconciled-row exclusion drift out of the analytics routes to begin with.
queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(`
WITH owed AS (
SELECT ts.participant_id AS pid,
SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross,
SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
${STATEMENTS_JOIN}
JOIN transaction_splits ts ON ts.transaction_id = t.id
WHERE o.trip_id = $1
AND ${OWNER_SCOPE} = $2
AND ts.participant_id <> $2
AND t.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
AND ${ACTIVE_OBLIGATION}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY ts.participant_id
),
-- Only payments made TO this owner. Payment 5 on Europe is Molina -> Sonu:
-- a real settlement, but of a debt between those two, so it must not
-- reduce what Molina owes here. Symmetrical with the owner scoping above.
paid AS (
SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt
FROM split_payments sp
WHERE sp.trip_id = $1
AND sp.to_participant_id = $2
GROUP BY sp.from_participant_id
)
SELECT p.id AS participant_id, p.name,
(COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed,
COALESCE(owed.unconverted, 0)::int AS unconverted_count
FROM participants p
LEFT JOIN owed ON owed.pid = p.id
LEFT JOIN paid ON paid.pid = p.id
WHERE owed.pid IS NOT NULL OR paid.pid IS NOT NULL
ORDER BY 3 DESC
`, [tripId, ownerId]),
]);
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);
}