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

298 lines
8.6 KiB
TypeScript

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<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: 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,
});
}