- Add 11 missing server action files: badges, bulk-users, catalog, catalog-bc, catalog-items, import-badges, import-furni, multi-account-detect, permissions, rooms, soundtracks - Add missing admin navigation links: tickets, sounds, translations, import, radio sub-pages - Add translation keys for all new navigation items
156 lines
5.1 KiB
TypeScript
156 lines
5.1 KiB
TypeScript
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<Response> {
|
|
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,
|
|
});
|
|
}
|