This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+217
-163
@@ -1,14 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { captureOrder, creditsPerUnit, isPayPalConfigured } from "@/lib/services/paypal";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import {
|
||||
captureOrder,
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
} from "@/lib/services/paypal";
|
||||
import {
|
||||
authorizeTopupCapture,
|
||||
claimTopupDelivery,
|
||||
TopupCaptureError,
|
||||
} from "@/lib/services/paypal-topup";
|
||||
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";
|
||||
|
||||
@@ -28,173 +36,219 @@ export const dynamic = "force-dynamic";
|
||||
* 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 });
|
||||
}
|
||||
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 },
|
||||
);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
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 },
|
||||
);
|
||||
}
|
||||
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 },
|
||||
);
|
||||
}
|
||||
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());
|
||||
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 },
|
||||
);
|
||||
}
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: "COMPLETED",
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
credits,
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createOrder, creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
createOrder,
|
||||
creditsPerUnit,
|
||||
isPayPalConfigured,
|
||||
PAYPAL_CURRENCY,
|
||||
} from "@/lib/services/paypal";
|
||||
import { recordCreatedTopup } from "@/lib/services/paypal-topup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -20,73 +25,84 @@ const MAX_AMOUNT = 500;
|
||||
* Auth-gated via auth(): the order is tied to the session, never to a body field.
|
||||
*/
|
||||
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 to top up." }, { status: 401 });
|
||||
}
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You must be signed in to top up." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fail fast (and clearly) when the sandbox/live keys aren't set.
|
||||
if (!isPayPalConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
// Fail fast (and clearly) when the sandbox/live keys aren't set.
|
||||
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 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = (body as { amount?: unknown })?.amount;
|
||||
const amount = Math.round(Number(raw) * 100) / 100;
|
||||
if (!Number.isFinite(amount) || amount < MIN_AMOUNT || amount > MAX_AMOUNT) {
|
||||
return NextResponse.json(
|
||||
{ error: `Enter an amount between ${MIN_AMOUNT} and ${MAX_AMOUNT} ${PAYPAL_CURRENCY}.` },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const raw = (body as { amount?: unknown })?.amount;
|
||||
const amount = Math.round(Number(raw) * 100) / 100;
|
||||
if (!Number.isFinite(amount) || amount < MIN_AMOUNT || amount > MAX_AMOUNT) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Enter an amount between ${MIN_AMOUNT} and ${MAX_AMOUNT} ${PAYPAL_CURRENCY}.`,
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
|
||||
try {
|
||||
const order = await createOrder(amount, {
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
returnUrl: `${base}/shop/topup?status=success`,
|
||||
cancelUrl: `${base}/shop/topup?status=cancel`,
|
||||
});
|
||||
try {
|
||||
const order = await createOrder(amount, {
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
returnUrl: `${base}/shop/topup?status=success`,
|
||||
cancelUrl: `${base}/shop/topup?status=cancel`,
|
||||
});
|
||||
|
||||
if (!order.approveUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal did not return an approval link. Try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
if (!order.approveUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "PayPal did not return an approval link. Try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
await recordCreatedTopup(
|
||||
{ userId, orderId: order.id, amount, currency: PAYPAL_CURRENCY, credits },
|
||||
(data) => prisma.websitePaypalTransactions.create({ data }),
|
||||
);
|
||||
await recordCreatedTopup(
|
||||
{ userId, orderId: order.id, amount, currency: PAYPAL_CURRENCY, credits },
|
||||
(data) => prisma.websitePaypalTransactions.create({ data }),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
approveUrl: order.approveUrl,
|
||||
amount,
|
||||
currency: PAYPAL_CURRENCY,
|
||||
credits,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("PayPal create order failed", { module: "paypal/create", error: (e as Error).message });
|
||||
return NextResponse.json(
|
||||
{ error: "Could not start the PayPal checkout. Please try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
approveUrl: order.approveUrl,
|
||||
amount,
|
||||
currency: PAYPAL_CURRENCY,
|
||||
credits,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("PayPal create order failed", {
|
||||
module: "paypal/create",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Could not start the PayPal checkout. Please try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user