Files
EpicNext-Cms/src/lib/services/paypal.ts
T
Simo 22d53d0e9c Add security middleware, audit log, alerts, PayPal, cron, radio + apps
Security (launch blockers):
- src/middleware.ts (edge): forwards x-pathname + real client IP.
- access-guard.ts (Node, from root layout): routes non-staff to /maintenance
  when maintenance mode is on, banned users to /banned. New /banned + /maintenance
  pages (the consumers the admin toggle was missing). Admin layout enforces
  force_staff_2fa before /admin.
- staff-activity.ts audit log wired into ban/lift/give-currency/set-rank actions.

Infra (parallel agents): alert service (alert_logs + Discord embed + email),
PayPal top-up (create/capture API routes + /shop/topup), cron worker
(scripts/jobs-worker.ts via croner: emulator-ping->alert, maintenance-check,
bans-cleanup), social connections page, admin radio settings/banners/ranks.
Public radio subsystem: /radio (+schedule, shouts+post, contests, giveaways,
apply, leaderboard) and /apply/staff + /apply/team submission forms. Radio nav
link added. .env.example documents the new optional vars.

(radio song-requests dropped: its table is a stub in AtomCMS — columns added by
un-modeled alter-migrations.)

Verified: tsc exit 0, vitest 48/48, next build exit 0 (82 page routes).
2026-06-28 15:10:19 +02:00

172 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PayPal v2 REST helper (Orders API) for the top-up flow. SDK-free: uses the
// global fetch only. Credentials and base URL come from process.env because they
// are not declared in src/env.ts:
// PAYPAL_CLIENT_ID – REST app client id
// PAYPAL_SECRET – REST app secret
// PAYPAL_API – API base, defaults to the sandbox host
// PAYPAL_CURRENCY – ISO currency for orders, defaults to USD
// PAYPAL_CREDITS_PER_USD – credits granted per 1.00 unit, defaults to 100
//
// The website has no dedicated balance column (see prisma/schema.prisma — User
// carries `credits`, the in-game wallet), so a top-up credits the buyer's
// `credits` wallet via sendCurrency(), exactly like the voucher flow.
export const PAYPAL_API =
process.env.PAYPAL_API?.replace(/\/+$/, "") ?? "https://api-m.sandbox.paypal.com";
export const PAYPAL_CURRENCY = (process.env.PAYPAL_CURRENCY ?? "USD").toUpperCase();
/** Credits granted per 1.00 of the order currency (configurable, sane default). */
export function creditsPerUnit(): number {
const n = Number(process.env.PAYPAL_CREDITS_PER_USD ?? "100");
return Number.isFinite(n) && n > 0 ? n : 100;
}
export class PayPalConfigError extends Error {}
function credentials(): { clientId: string; secret: string } {
const clientId = process.env.PAYPAL_CLIENT_ID;
const secret = process.env.PAYPAL_SECRET;
if (!clientId || !secret) {
throw new PayPalConfigError(
"PayPal is not configured. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET.",
);
}
return { clientId, secret };
}
/** True when both PayPal credentials are present. */
export function isPayPalConfigured(): boolean {
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET);
}
/** OAuth2 client-credentials token (short-lived; we fetch one per request). */
async function getAccessToken(): Promise<string> {
const { clientId, secret } = credentials();
const basic = Buffer.from(`${clientId}:${secret}`).toString("base64");
const res = await fetch(`${PAYPAL_API}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
cache: "no-store",
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`PayPal auth failed (${res.status}): ${body.slice(0, 300)}`);
}
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) throw new Error("PayPal auth returned no access_token.");
return json.access_token;
}
export interface CreatedOrder {
id: string;
approveUrl: string | null;
}
/**
* Create a CAPTURE order for `amount` of the configured currency. Returns the
* order id and the payer approval URL (rel === "approve") to redirect to.
*/
export async function createOrder(
amount: number,
opts: { description?: string; returnUrl?: string; cancelUrl?: string } = {},
): Promise<CreatedOrder> {
const token = await getAccessToken();
const value = amount.toFixed(2);
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
cache: "no-store",
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: PAYPAL_CURRENCY, value },
description: opts.description?.slice(0, 127),
},
],
application_context: {
shipping_preference: "NO_SHIPPING",
user_action: "PAY_NOW",
...(opts.returnUrl ? { return_url: opts.returnUrl } : {}),
...(opts.cancelUrl ? { cancel_url: opts.cancelUrl } : {}),
},
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`PayPal create order failed (${res.status}): ${body.slice(0, 300)}`);
}
const json = (await res.json()) as {
id: string;
links?: { rel: string; href: string }[];
};
const approveUrl =
json.links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href ?? null;
return { id: json.id, approveUrl };
}
export interface CaptureResult {
id: string;
status: string;
amount: number;
currency: string;
captureId: string | null;
payerEmail: string | null;
}
/** Capture a previously-approved order id. */
export async function captureOrder(orderId: string): Promise<CaptureResult> {
const token = await getAccessToken();
const res = await fetch(`${PAYPAL_API}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
cache: "no-store",
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`PayPal capture failed (${res.status}): ${body.slice(0, 300)}`);
}
const json = (await res.json()) as {
id: string;
status: string;
payer?: { email_address?: string };
purchase_units?: {
payments?: {
captures?: {
id: string;
amount?: { value?: string; currency_code?: string };
}[];
};
}[];
};
const capture = json.purchase_units?.[0]?.payments?.captures?.[0];
const amount = capture?.amount?.value ? Number(capture.amount.value) : 0;
const currency = capture?.amount?.currency_code ?? PAYPAL_CURRENCY;
return {
id: json.id,
status: json.status,
amount,
currency,
captureId: capture?.id ?? null,
payerEmail: json.payer?.email_address ?? null,
};
}