Files
Epicnabbo-Catalogus-Updated…/src/app/api/paypal/capture/route.ts
T

201 lines
7.3 KiB
TypeScript

import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user";
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";
import { authorizeTopupCapture, claimTopupDelivery, TopupCaptureError } from "@/lib/services/paypal-topup";
import { logServerError } from "@/lib/server-log";
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<Response> {
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;
try {
authorized = await authorizeTopupCapture(userId, orderId, async (transactionId) =>
prisma.websitePaypalTransactions.findFirst({
where: { transactionId },
select: { userId: true, status: true, amount: true },
}),
);
} 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(() =>
prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}),
);
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
data: { status: "COMPLETED", updatedAt: new Date() },
});
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;
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.updateMany({
where: { userId, transactionId: orderId, status: "CREATED" },
data: {
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 {
const claimed = await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREATED" },
data: {
status: "CAPTURED_PENDING_CREDIT",
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
amount: result.amount,
currency: result.currency,
createdAt: new Date(),
updatedAt: new Date(),
},
});
if (claimed.count !== 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(() =>
prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
}),
);
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
await prisma.websitePaypalTransactions.updateMany({
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
data: { status: "COMPLETED", updatedAt: new Date() },
});
} 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,
});
}