// 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 { 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 { 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 { 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, }; }