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.
182 lines
7.1 KiB
TypeScript
182 lines
7.1 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { queryRaw } from "@/lib/db";
|
|
import { getTransactions } from "@/lib/queries";
|
|
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
|
|
|
interface SnapshotEntry {
|
|
transaction_id: number;
|
|
had_override: boolean;
|
|
prev_category_override: string | null;
|
|
prev_merchant_normalized: string | null;
|
|
prev_tag_ids: number[];
|
|
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
|
}
|
|
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
|
|
const runs = await queryRaw<{
|
|
id: number;
|
|
applied_at: string;
|
|
split_from: string | null;
|
|
matched: number;
|
|
transactions_affected: number;
|
|
reverted_at: string | null;
|
|
}>(
|
|
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at
|
|
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
|
[user.id]
|
|
);
|
|
|
|
return NextResponse.json(runs);
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const user = await getCurrentUser(req);
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
|
|
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null; ruleId?: number | null };
|
|
const splitFrom = body.splitFrom || null;
|
|
const ruleId = body.ruleId || null;
|
|
|
|
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
|
ruleId
|
|
? `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND id = $2`
|
|
: `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
|
ruleId ? [user.id, ruleId] : [user.id]
|
|
);
|
|
|
|
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
|
|
|
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
|
|
|
// --- Pre-pass: find all transactions that will match any rule ---
|
|
const parsedRules = rules.map((r) => ({
|
|
conditions: (typeof r.conditions === "string" ? JSON.parse(r.conditions) : r.conditions) as Condition[],
|
|
actions: (typeof r.actions === "string" ? JSON.parse(r.actions) : r.actions) as Actions,
|
|
}));
|
|
|
|
const matchedIds = new Set<number>();
|
|
for (const tx of transactions) {
|
|
for (const { conditions } of parsedRules) {
|
|
if (conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx))) {
|
|
matchedIds.add(tx.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Capture before-state for all matched transactions (batched) ---
|
|
const snapshot: SnapshotEntry[] = [];
|
|
if (matchedIds.size > 0) {
|
|
const ids = Array.from(matchedIds);
|
|
const idList = ids.join(",");
|
|
|
|
const overrides = await queryRaw<{ transaction_id: number; category_override: string | null; merchant_normalized: string | null }>(
|
|
`SELECT transaction_id, category_override, merchant_normalized FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`,
|
|
[ids]
|
|
);
|
|
const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o]));
|
|
|
|
const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>(
|
|
`SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`,
|
|
[ids]
|
|
);
|
|
const tagMap = new Map<number, number[]>();
|
|
for (const row of tagRows) {
|
|
if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []);
|
|
tagMap.get(row.transaction_id)!.push(row.tag_id);
|
|
}
|
|
|
|
const splitRows = await queryRaw<{ transaction_id: number; participant_id: number; share_percent: number; settled: boolean }>(
|
|
`SELECT transaction_id, participant_id, share_percent, settled FROM transaction_splits WHERE transaction_id = ANY($1::int[])`,
|
|
[ids]
|
|
);
|
|
const splitMap = new Map<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
|
|
for (const row of splitRows) {
|
|
if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []);
|
|
splitMap.get(row.transaction_id)!.push({ participant_id: row.participant_id, share_percent: row.share_percent, settled: row.settled });
|
|
}
|
|
|
|
for (const id of ids) {
|
|
const ov = overrideMap.get(id);
|
|
snapshot.push({
|
|
transaction_id: id,
|
|
had_override: !!ov,
|
|
prev_category_override: ov?.category_override ?? null,
|
|
prev_merchant_normalized: ov?.merchant_normalized ?? null,
|
|
prev_tag_ids: tagMap.get(id) ?? [],
|
|
prev_splits: splitMap.get(id) ?? [],
|
|
});
|
|
}
|
|
|
|
void idList; // suppress unused warning
|
|
}
|
|
|
|
// --- Apply rules ---
|
|
let matched = 0;
|
|
const affectedIds = new Set<number>();
|
|
|
|
for (const { conditions, actions } of parsedRules) {
|
|
for (const tx of transactions) {
|
|
const allMatch = conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
|
if (!allMatch) continue;
|
|
|
|
matched++;
|
|
affectedIds.add(tx.id);
|
|
|
|
if (actions.set_category || actions.set_merchant) {
|
|
await queryRaw(
|
|
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (transaction_id) DO UPDATE SET
|
|
category_override = COALESCE($2, transaction_overrides.category_override),
|
|
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
|
|
updated_at = NOW()`,
|
|
[tx.id, actions.set_category || null, actions.set_merchant || null]
|
|
);
|
|
}
|
|
|
|
if (actions.add_tag_ids?.length) {
|
|
for (const tagId of actions.add_tag_ids) {
|
|
await queryRaw(
|
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
[tx.id, tagId]
|
|
);
|
|
}
|
|
}
|
|
|
|
if (actions.apply_split?.length) {
|
|
if (splitFrom && tx.transaction_date < splitFrom) continue;
|
|
// Remove only participants no longer in the rule's split, and upsert the
|
|
// rest — a plain delete+reinsert would reset settled flags on every run.
|
|
await queryRaw(
|
|
`DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`,
|
|
[tx.id, actions.apply_split.map((s) => s.participant_id)]
|
|
);
|
|
for (const s of actions.apply_split) {
|
|
await queryRaw(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (transaction_id, participant_id)
|
|
DO UPDATE SET share_percent = EXCLUDED.share_percent`,
|
|
[tx.id, s.participant_id, s.share_percent]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Save run record ---
|
|
const run = await queryRaw<{ id: number }>(
|
|
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
|
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)]
|
|
);
|
|
|
|
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
|
}
|