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:
+25
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user