import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { prisma, queryRaw } from "@/lib/db"; 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() as { matches: { manual_id: number; statement_tx_id: number }[]; }; if (!Array.isArray(body.matches) || body.matches.length === 0) { return NextResponse.json({ error: "No matches provided" }, { status: 400 }); } // Verify all manual_ids belong to this user const manualIds = body.matches.map((m) => m.manual_id); const owned = await queryRaw<{ id: number }>( `SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND owner_id = $2`, [manualIds, user.id] ); if (owned.length !== manualIds.length) { return NextResponse.json({ error: "One or more transactions not found" }, { status: 404 }); } let reconciled = 0; for (const { manual_id, statement_tx_id } of body.matches) { await prisma.$transaction(async (tx) => { // Copy overrides: manual → statement tx const override = await tx.transaction_overrides.findUnique({ where: { transaction_id: manual_id }, }); if (override) { await tx.transaction_overrides.upsert({ where: { transaction_id: statement_tx_id }, update: { category_override: override.category_override, merchant_normalized: override.merchant_normalized, notes: override.notes, my_share_percent: override.my_share_percent, updated_at: new Date(), }, create: { transaction_id: statement_tx_id, category_override: override.category_override, merchant_normalized: override.merchant_normalized, notes: override.notes, my_share_percent: override.my_share_percent, }, }); await tx.transaction_overrides.deleteMany({ where: { transaction_id: manual_id } }); } // Move tags: manual → statement tx const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } }); if (tags.length) { await tx.transaction_tags.createMany({ data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })), skipDuplicates: true, }); await tx.transaction_tags.deleteMany({ where: { transaction_id: manual_id } }); } // Move splits: manual → statement tx const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } }); if (splits.length) { await tx.transaction_splits.createMany({ data: splits.map((s) => ({ transaction_id: statement_tx_id, participant_id: s.participant_id, share_percent: s.share_percent, })), skipDuplicates: true, }); await tx.transaction_splits.deleteMany({ where: { transaction_id: manual_id } }); } // Move provenance: manual → statement tx. // // Overrides, tags and splits above were always carried across; expense_metadata was // the one child left behind, which did not matter while every metadata row came from // an email that had created its own transaction. It matters now: a scanned grocery // receipt puts its line items here, and reconciliation hides the manual row from // every figure — so without this the shop's contents disappear at exactly the moment // the statement line appears, and `COLES 0556 MANOR LAKES` stays as unreadable as it // was before the receipt was ever scanned. // // transaction_id is UNIQUE, so a statement row that already has metadata (an emailed // or Paperless copy got there first) keeps it. The pantry row stays attached to the // reconciled manual transaction and is flagged, rather than raising a constraint // violation or silently overwriting the other source. const moved = await tx.$executeRawUnsafe( `UPDATE expense_metadata SET transaction_id = $1 WHERE transaction_id = $2 AND NOT EXISTS (SELECT 1 FROM expense_metadata other WHERE other.transaction_id = $1)`, statement_tx_id, manual_id ); if (moved === 0) { await tx.$executeRawUnsafe( `UPDATE expense_metadata SET flags = coalesce(flags, '[]'::jsonb) || '["metadata_collision_on_reconcile"]'::jsonb WHERE transaction_id = $1`, manual_id ); } // Mark manual tx as reconciled (link to statement tx) await tx.$executeRawUnsafe( `UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, statement_tx_id, manual_id ); }); reconciled++; } return NextResponse.json({ reconciled }); }