import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { captureOrder, creditsPerUnit, isPayPalConfigured } from "@/lib/services/paypal"; import { rcon } from "@/lib/services/rcon"; import { sendCurrency } from "@/lib/services/send-currency"; import { env } from "@/env"; import { logger } from "@/lib/logger"; export const dynamic = "force-dynamic"; /** * POST /api/paypal/capture — capture an approved order and credit the buyer. * Body: { orderId: string } (PayPal order id from /api/paypal/create). * * On a COMPLETED capture we: * 1. record the payment in website_paypal_transactions (idempotent on * transaction_id so a double-submit can't double-credit), and * 2. credit the buyer's `credits` wallet via sendCurrency (RCON-first, DB * fallback). NOTE: the schema has no dedicated website-balance column — * User.credits is the website/in-game wallet, so the top-up lands there, * consistent with the voucher redeem flow. * * Auth-gated via auth(); the crediting user id is the session user, not a body * field, so a captured order always credits the person who is signed in. */ export async function POST(req: Request): Promise { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "You must be signed in." }, { status: 401 }); } const userId = Number(session.user.id); if (!Number.isFinite(userId)) { return NextResponse.json({ error: "Invalid session." }, { status: 401 }); } if (!isPayPalConfigured()) { return NextResponse.json( { error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." }, { status: 503 }, ); } let body: unknown; try { body = await req.json(); } catch { return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); } const orderId = String((body as { orderId?: unknown })?.orderId ?? "").trim(); if (!orderId) { return NextResponse.json({ error: "Missing orderId." }, { status: 422 }); } // Idempotency: if this order was already recorded, don't capture/credit again. try { const existing = await prisma.websitePaypalTransactions.findFirst({ where: { transactionId: orderId }, select: { id: true, status: true }, }); if (existing) { return NextResponse.json({ ok: existing.status === "COMPLETED", alreadyProcessed: true, status: existing.status, }); } } catch { // If the lookup fails we fall through; the capture call itself is the source // of truth and PayPal rejects a second capture of the same order. } let result; try { result = await captureOrder(orderId); } catch (e) { logger.error("PayPal capture failed", { module: "paypal/capture", error: (e as Error).message }); return NextResponse.json( { error: "Could not capture the PayPal payment. If you were charged, contact staff." }, { status: 502 }, ); } if (result.status !== "COMPLETED") { // Record the non-completed attempt so support can trace it. try { await prisma.websitePaypalTransactions.create({ data: { userId, transactionId: result.id || orderId, status: result.status, description: `${env.HOTEL_NAME} top-up (not completed)`, amount: result.amount, currency: result.currency, createdAt: new Date(), updatedAt: new Date(), }, }); } catch { /* best-effort logging */ } return NextResponse.json( { ok: false, status: result.status, error: "Payment was not completed." }, { status: 402 }, ); } const credits = Math.floor(result.amount * creditsPerUnit()); // Record the transaction BEFORE crediting so a crash mid-grant can't be // reprocessed into a double credit (the idempotency check above keys on this). try { await prisma.websitePaypalTransactions.create({ data: { userId, transactionId: result.captureId ?? result.id, status: "COMPLETED", description: `${env.HOTEL_NAME} top-up: ${credits} credits`, amount: result.amount, currency: result.currency, createdAt: new Date(), updatedAt: new Date(), }, }); } catch (e) { logger.error("PayPal capture record failed", { module: "paypal/capture", error: (e as Error).message }); return NextResponse.json( { error: "Payment captured but could not be recorded. Contact staff with your order id." }, { status: 500 }, ); } // Credit the buyer's website credits wallet (RCON-first, DB fallback). try { await sendCurrency({ rcon, db: prisma }, userId, "credits", credits); } catch (e) { logger.error("PayPal capture credit failed", { module: "paypal/capture", error: (e as Error).message }); return NextResponse.json( { ok: false, error: "Payment recorded but credits could not be delivered. Contact staff.", }, { status: 500 }, ); } return NextResponse.json({ ok: true, status: "COMPLETED", amount: result.amount, currency: result.currency, credits, }); }