import { NextRequest, NextResponse } from "next/server"; import { getTransactionById, canAccessTransactions } from "@/lib/queries"; import { getCurrentUser } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { queryRaw } from "@/lib/db"; const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"]; export async function GET( 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; if (!(await canAccessTransactions(user.id, [Number(id)]))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const txn = await getTransactionById(Number(id)); if (!txn) return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json(txn); } export async function PATCH( 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 transactionId = Number(id); if (!(await canAccessTransactions(user.id, [transactionId]))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const body = await req.json(); const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method, owner_id, release } = body as { category?: string; merchant_normalized?: string; notes?: string; transaction_type?: string; payment_method?: string | null; my_share_percent?: number | null; description?: string; amount?: number; transaction_date?: string; trip_id?: number | null; owner_id?: number; /** Acknowledge that reassigning will remove the row from my own view. */ release?: boolean; }; if (my_share_percent !== undefined && my_share_percent !== null) { if (typeof my_share_percent !== "number" || my_share_percent <= 0 || my_share_percent > 100) { return NextResponse.json({ error: "my_share_percent must be between 1 and 100" }, { status: 400 }); } } // Direct field edits — only allowed for manual transactions (statement_id IS NULL) const directFields = [description, amount, transaction_date].filter((v) => v !== undefined); if (directFields.length > 0) { const txRows = await queryRaw<{ statement_id: number | null }>( `SELECT statement_id FROM transactions WHERE id = $1`, [transactionId] ); if (!txRows[0]?.statement_id) { const setClauses: string[] = []; const params: unknown[] = []; let idx = 1; if (description !== undefined) { setClauses.push(`description = $${idx++}`); params.push(description); } if (amount !== undefined) { setClauses.push(`amount = $${idx++}`); params.push(amount); } if (transaction_date !== undefined) { setClauses.push(`transaction_date = $${idx++}`); params.push(transaction_date); } if (setClauses.length) { params.push(transactionId); await queryRaw(`UPDATE transactions SET ${setClauses.join(", ")} WHERE id = $${idx}`, params); } } } // transaction_type is a direct correction on the transactions table if (transaction_type !== undefined) { if (!VALID_TYPES.includes(transaction_type)) { return NextResponse.json({ error: "Invalid transaction_type" }, { status: 400 }); } await queryRaw( `UPDATE transactions SET transaction_type = $1 WHERE id = $2`, [transaction_type, transactionId] ); } // payment_method is a property of the transaction itself, not a user override // of extracted data, so it lives on the transactions table. if (payment_method !== undefined) { const VALID_METHODS = ["card", "cash", "bank_transfer", "other"]; if (payment_method !== null && !VALID_METHODS.includes(payment_method)) { return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 }); } await queryRaw( `UPDATE transactions SET payment_method = $1 WHERE id = $2`, [payment_method, transactionId] ); } // Owner — who actually paid. A direct column, not an override: it decides // whose account the money left, which every balance and every spend analytic // scopes on (MY_SPEND_SCOPE / OWNER_SCOPE). // // Handled here rather than in the direct-fields block above because it has // its own rules, and because getting it wrong is not a cosmetic error: it // moves money between two people's ledgers. if (owner_id !== undefined) { if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) { return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 }); } const txRows = await queryRaw<{ statement_id: number | null; owner_id: number | null }>( `SELECT statement_id, owner_id FROM transactions WHERE id = $1`, [transactionId] ); const tx = txRows[0]; if (!tx) return NextResponse.json({ error: "Not found" }, { status: 404 }); // A statement row's owner is the statement's owner — the effective owner is // COALESCE(t.owner_id, s.owner_id), so setting it here would either be a // no-op or would silently detach one row from the account it was extracted // from. Reassigning the statement is the correct move and moves its rows // with it. if (tx.statement_id) { return NextResponse.json( { error: "This row came from a statement, so its owner is the statement's owner. Change the statement's owner instead — that moves every row on it.", code: "statement_owned", }, { status: 400 } ); } const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]); if (!known.length) { return NextResponse.json({ error: "Unknown participant" }, { status: 400 }); } // The one-way door. Access is "owner OR holds a split" // (canAccessTransactions), so handing a row to someone else while holding // no split on it removes it from the caller's list and 404s every route // that could put it back — only the new owner can undo it. Splitting first // keeps the row reachable AND is what makes the balance correct, so the // refusal points at the step that was skipped rather than just blocking. if (owner_id !== user.id && !release) { const mine = await queryRaw( `SELECT 1 FROM transaction_splits WHERE transaction_id = $1 AND participant_id = $2`, [transactionId, user.id] ); if (!mine.length) { return NextResponse.json( { error: "You hold no split on this transaction, so reassigning it would remove it from your view for good — only the new owner could change it back. Add your split first, or confirm you are giving it away entirely.", code: "would_lose_access", }, { status: 409 } ); } } // Existing splits are deliberately left alone. They record shares, not // direction: getParticipantBalances derives who owes whom from ownership, // so a 50/50 row flips from "they owe me" to "I owe them" with no rewrite. await queryRaw(`UPDATE transactions SET owner_id = $1 WHERE id = $2`, [owner_id, transactionId]); } // category/merchant/notes/my_share_percent/trip_id go through the overrides table const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined; if (!hasOverride) { return NextResponse.json({ ok: true }); } const data: Record = { updated_at: new Date() }; if (category !== undefined) data.category_override = category; if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized; if (notes !== undefined) data.notes = notes; if (my_share_percent !== undefined) data.my_share_percent = my_share_percent; if (trip_id !== undefined) data.trip_id = trip_id; const override = await prisma.transaction_overrides.upsert({ where: { transaction_id: transactionId }, update: data, create: { transaction_id: transactionId, category_override: category || null, merchant_normalized: merchant_normalized || null, notes: notes || null, my_share_percent: my_share_percent != null ? String(my_share_percent) : null, trip_id: trip_id ?? null, }, }); return NextResponse.json(override); }