fix(security+trips): auth/ownership on all API routes; trip analytics in AUD

Ten routes accepted requests with no getCurrentUser check (transactions/[id],
bulk, splits, tags-on-tx, splits/settle, statements/[id], tags, tags/[id],
merchants, participants/[id]/balance), and by-id routes did no ownership
check at all — any participant could read or modify another's data.

Adds canAccessTransactions() (owner via statement/direct, or split
participant), applies it to every transaction-scoped route, owner-scopes
statements/[id], and rescopes splits/settle in raw SQL so settlement only
touches splits the caller is party to.

Also: all trip analytics now sum COALESCE(amount_aud, amount) instead of raw
amount, matching every other analytics query — trip totals previously added
foreign-currency amounts to AUD ones unit-less.

And rules apply_split no longer delete+reinserts splits (which reset settled
flags on every run) — it upserts share_percent and removes only participants
no longer in the rule.
This commit is contained in:
2026-07-19 20:07:09 +10:00
parent 48ec151c15
commit 0b40924af0
12 changed files with 133 additions and 33 deletions
+25 -9
View File
@@ -231,6 +231,22 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
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.*,
@@ -654,7 +670,7 @@ export async function getTrips(ownerId: number): Promise<TripRow[]> {
SELECT
t.*,
COALESCE(SUM(
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END
CASE WHEN tx.transaction_type IN ('debit','fee','interest') 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
@@ -671,7 +687,7 @@ export async function getTripById(id: number, ownerId: number): Promise<TripRow
SELECT
t.*,
COALESCE(SUM(
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END
CASE WHEN tx.transaction_type IN ('debit','fee','interest') 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
@@ -691,7 +707,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
queryRaw<{ category: string; amount: number; count: number }>(`
SELECT
COALESCE(o.category_override, tx.category, 'other') AS category,
SUM(tx.amount)::float AS amount,
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
@@ -704,7 +720,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
queryRaw<{ date: string; amount: number }>(`
SELECT
tx.transaction_date::text AS date,
SUM(tx.amount)::float AS amount
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
@@ -716,7 +732,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
queryRaw<{ merchant: string; amount: number; count: number }>(`
SELECT
COALESCE(o.merchant_normalized, tx.merchant_normalized, tx.merchant_name, tx.description) AS merchant,
SUM(tx.amount)::float AS amount,
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
@@ -730,7 +746,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
queryRaw<{ tag_id: number; name: string; color: string; amount: number; count: number }>(`
SELECT
tg.id AS tag_id, tg.name, tg.color,
SUM(tx.amount)::float AS amount,
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
@@ -746,9 +762,9 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
SELECT
p.id AS participant_id,
p.name,
SUM(ts.share_percent / 100.0 * tx.amount)::float AS owed,
SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS settled,
SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS unsettled
SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed,
SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS settled,
SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS unsettled
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
JOIN transaction_splits ts ON ts.transaction_id = tx.id