import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; import { completeSplit } from "@/lib/splits"; 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 POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getCurrentUser(req); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); const { id } = await params; const runId = Number(id); const rows = await queryRaw<{ id: number; owner_id: number; reverted_at: string | null; snapshot: unknown; }>( `SELECT id, owner_id, reverted_at, snapshot FROM rule_apply_runs WHERE id = $1`, [runId] ); if (!rows.length) return NextResponse.json({ error: "Run not found" }, { status: 404 }); const run = rows[0]; if (run.owner_id !== user.id) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); if (run.reverted_at) return NextResponse.json({ error: "Already reverted" }, { status: 409 }); const snapshot = (typeof run.snapshot === "string" ? JSON.parse(run.snapshot) : run.snapshot) as SnapshotEntry[]; for (const entry of snapshot) { const txId = entry.transaction_id; // Restore overrides if (entry.had_override) { 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 = $2, merchant_normalized = $3, updated_at = NOW()`, [txId, entry.prev_category_override, entry.prev_merchant_normalized] ); } else { // No override existed before — remove any that were created await queryRaw( `DELETE FROM transaction_overrides WHERE transaction_id = $1 AND category_override IS NULL AND merchant_normalized IS NULL`, [txId] ); // If override row exists but was only partially set by this run, clear those fields await queryRaw( `UPDATE transaction_overrides SET category_override = NULL, merchant_normalized = NULL, updated_at = NOW() WHERE transaction_id = $1`, [txId] ); } // Restore tags: remove any that weren't there before, don't touch pre-existing ones const prevTagIds = entry.prev_tag_ids; if (prevTagIds.length > 0) { await queryRaw( `DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id != ALL($2::int[])`, [txId, prevTagIds] ); } else { // No tags existed before — remove all tags (they were all added by this run) // Note: this only removes tags on transactions that matched this run await queryRaw(`DELETE FROM transaction_tags WHERE transaction_id = $1`, [txId]); } // Restore splits await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [txId]); for (const s of entry.prev_splits) { await queryRaw( `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, [txId, s.participant_id, s.share_percent, s.settled] ); } // A snapshot taken before splits were required to total 100% holds the old // partial shape, and restoring it verbatim would reintroduce exactly what // this run is being undone from. The owner's share is balance-neutral, so // completing it cannot change what the revert owes anyone. await completeSplit(txId); } await queryRaw( `UPDATE rule_apply_runs SET reverted_at = NOW() WHERE id = $1`, [runId] ); return NextResponse.json({ reverted: snapshot.length }); }