import { and, eq } from "drizzle-orm"; import type { ResultSetHeader } from "mysql2"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { sessionUserId } from "@/lib/auth/session-user"; import { db, WebsitePaypalTransactions } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { logger } from "@/lib/logger"; import { logServerError } from "@/lib/server-log"; import { type CaptureResult, captureOrder, creditsPerUnit, isPayPalConfigured, } from "@/lib/services/paypal"; import { type AuthorizedTopup, authorizeTopupCapture, claimTopupDelivery, TopupCaptureError, } from "@/lib/services/paypal-topup"; import { rcon } from "@/lib/services/rcon"; import { currencyDb, sendCurrency } from "@/lib/services/send-currency"; 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 = sessionUserId(session.user.id); if (!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 }); } // The create endpoint records ownership before returning the approval URL. // Do not let a signed-in user submit somebody else's approved order id. let authorized: AuthorizedTopup; try { authorized = await authorizeTopupCapture( userId, orderId, async (transactionId) => { const [row] = await db .select({ userId: WebsitePaypalTransactions.userId, status: WebsitePaypalTransactions.status, amount: WebsitePaypalTransactions.amount, }) .from(WebsitePaypalTransactions) .where(eq(WebsitePaypalTransactions.transactionId, transactionId)) .limit(1); return row ?? null; }, ); } catch (error) { if (error instanceof TopupCaptureError) { const status = error.code === "ORDER_NOT_FOUND" ? 404 : 409; return NextResponse.json( { error: "Order not found or already processed." }, { status }, ); } return NextResponse.json( { error: "Could not verify the payment order." }, { status: 500 }, ); } if (authorized.action === "DELIVER_CREDITS") { const amount = authorized.amount ?? 0; const credits = Math.floor(amount * creditsPerUnit()); try { await claimTopupDelivery(async () => { const [result] = (await db .update(WebsitePaypalTransactions) .set({ status: "CREDIT_DELIVERING", updatedAt: new Date() }) .where( and( eq(WebsitePaypalTransactions.userId, userId), eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"), ), )) as unknown as [ResultSetHeader]; return { count: result.affectedRows }; }); await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits); await db .update(WebsitePaypalTransactions) .set({ status: "COMPLETED", updatedAt: new Date() }) .where( and( eq(WebsitePaypalTransactions.userId, userId), eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CREDIT_DELIVERING"), ), ); return NextResponse.json({ ok: true, recovered: true, status: "COMPLETED", amount, credits, }); } catch (error) { logServerError("paypal.credit_recovery_failed", error, { userId, orderId, }); return NextResponse.json( { error: "Payment is recorded but credits could not be delivered. Contact staff.", }, { status: 500 }, ); } } let result: CaptureResult; 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 }, ); } const hotelName = await resolveHotelName(); if (result.status !== "COMPLETED") { // Record the non-completed attempt so support can trace it. try { await db .update(WebsitePaypalTransactions) .set({ status: result.status, description: `${hotelName} top-up (not completed)`, amount: result.amount, currency: result.currency, createdAt: new Date(), updatedAt: new Date(), }) .where( and( eq(WebsitePaypalTransactions.userId, userId), eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CREATED"), ), ); } 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 { const [claimed] = (await db .update(WebsitePaypalTransactions) .set({ status: "CAPTURED_PENDING_CREDIT", description: `${hotelName} top-up: ${credits} credits`, amount: result.amount, currency: result.currency, createdAt: new Date(), updatedAt: new Date(), }) .where( and( eq(WebsitePaypalTransactions.userId, userId), eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CREATED"), ), )) as unknown as [ResultSetHeader]; if (claimed.affectedRows !== 1) throw new Error("Top-up order was already claimed"); } 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 }, ); } // Atomically claim delivery so concurrent retries cannot grant twice. If the // external delivery outcome is ambiguous, CREDIT_DELIVERING remains visible // for staff reconciliation instead of automatically risking a second grant. try { await claimTopupDelivery(async () => { const [claimResult] = (await db .update(WebsitePaypalTransactions) .set({ status: "CREDIT_DELIVERING", updatedAt: new Date() }) .where( and( eq(WebsitePaypalTransactions.userId, userId), eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"), ), )) as unknown as [ResultSetHeader]; return { count: claimResult.affectedRows }; }); await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits); await db .update(WebsitePaypalTransactions) .set({ status: "COMPLETED", updatedAt: new Date() }) .where( and( eq(WebsitePaypalTransactions.userId, userId), eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CREDIT_DELIVERING"), ), ); } 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, }); }