/** * PaymentService — PayPal Orders API v2 integration * * Handles OAuth 2.0 token management, order creation, payment capture, * and webhook signature verification. * * Security: PAYPAL_CLIENT_SECRET and access tokens are NEVER logged or stored. */ // --------------------------------------------------------------------------- // Token cache // --------------------------------------------------------------------------- interface TokenCache { accessToken: string; expiresAt: number; // Unix timestamp in ms } let tokenCache: TokenCache | null = null; // --------------------------------------------------------------------------- // Base URL // --------------------------------------------------------------------------- /** * Returns the PayPal API base URL based on PAYPAL_MODE environment variable. * Defaults to live if PAYPAL_MODE is not "sandbox". */ export function getPayPalBaseUrl(): string { return process.env.PAYPAL_MODE === "sandbox" ? "https://api-m.sandbox.paypal.com" : "https://api-m.paypal.com"; } // --------------------------------------------------------------------------- // OAuth 2.0 — Client Credentials // --------------------------------------------------------------------------- /** * Retrieves a valid PayPal access token, using the cached token when still valid. * Automatically fetches a new token when the cached one has expired. * * SECURITY: The access token is held only in memory and is never logged. */ export async function getAccessToken(): Promise { // Return cached token if it is still valid if (tokenCache !== null && Date.now() < tokenCache.expiresAt) { return tokenCache.accessToken; } const clientId = process.env.PAYPAL_CLIENT_ID; const clientSecret = process.env.PAYPAL_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error( "PayPal credentials are not configured. Set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET." ); } const baseUrl = getPayPalBaseUrl(); // Basic auth — secret is used only here and never logged const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); const response = await fetch(`${baseUrl}/v1/oauth2/token`, { method: "POST", headers: { Authorization: `Basic ${credentials}`, "Content-Type": "application/x-www-form-urlencoded", }, body: "grant_type=client_credentials", }); if (!response.ok) { const errorText = await response.text(); throw new Error( `PayPal OAuth token request failed (HTTP ${response.status}): ${errorText}` ); } const data = (await response.json()) as { access_token: string; expires_in: number }; // Cache with a 60-second buffer before actual expiry tokenCache = { accessToken: data.access_token, expiresAt: Date.now() + (data.expires_in - 60) * 1000, }; return tokenCache.accessToken; } // --------------------------------------------------------------------------- // Create PayPal Order // --------------------------------------------------------------------------- /** * Creates a PayPal Order for the given registration with a fixed amount of $120.00 USD. * * @param registrationId - The internal registration ID used as the purchase unit reference. * @returns The PayPal Order ID and the approval URL to redirect the user to. * @throws If the PayPal API returns a non-2xx response. */ export async function createPayPalOrder( registrationId: string ): Promise<{ orderId: string; approvalUrl: string }> { const token = await getAccessToken(); const baseUrl = getPayPalBaseUrl(); const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; // PayPal requires full absolute URLs for return/cancel const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/$/, "") + basePath; const body = { intent: "CAPTURE", purchase_units: [ { reference_id: registrationId, amount: { currency_code: "USD", value: "120.00", }, }, ], application_context: { return_url: `${appUrl}/registration/success`, cancel_url: `${appUrl}/registration/cancel`, }, }; const response = await fetch(`${baseUrl}/v2/checkout/orders`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", // Idempotency key — unique per call to prevent duplicate orders on retry "PayPal-Request-Id": crypto.randomUUID(), }, body: JSON.stringify(body), }); if (!response.ok) { const errorText = await response.text(); throw new Error( `PayPal create order failed (HTTP ${response.status}): ${errorText}` ); } const data = (await response.json()) as { id: string; links: Array<{ rel: string; href: string }>; }; const orderId = data.id; const approveLink = data.links.find((l) => l.rel === "approve"); if (!approveLink) { throw new Error( "PayPal create order response did not include an approval URL." ); } return { orderId, approvalUrl: approveLink.href }; } // --------------------------------------------------------------------------- // Capture PayPal Order // --------------------------------------------------------------------------- /** * Captures a previously approved PayPal Order. * * @param orderId - The PayPal Order ID to capture. * @returns Capture details: captureId, status, and amount. * @throws If the PayPal API returns a non-2xx response or capture data is missing. */ export async function capturePayPalOrder(orderId: string): Promise<{ captureId: string; status: string; amount: { value: string; currency_code: string }; }> { const token = await getAccessToken(); const baseUrl = getPayPalBaseUrl(); const response = await fetch( `${baseUrl}/v2/checkout/orders/${orderId}/capture`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, } ); if (!response.ok) { const errorText = await response.text(); throw new Error( `PayPal capture order failed (HTTP ${response.status}): ${errorText}` ); } const data = (await response.json()) as { purchase_units: Array<{ payments: { captures: Array<{ id: string; status: string; amount: { value: string; currency_code: string }; }>; }; }>; }; const capture = data.purchase_units?.[0]?.payments?.captures?.[0]; if (!capture) { throw new Error( "PayPal capture response did not include capture details." ); } return { captureId: capture.id, status: capture.status, amount: capture.amount, }; } // --------------------------------------------------------------------------- // Verify Webhook Signature // --------------------------------------------------------------------------- /** * Verifies a PayPal webhook signature using the PayPal Webhooks Verify Signature API. * * @returns `true` if the signature is valid, `false` otherwise (including on errors). */ export async function verifyWebhookSignature(params: { authAlgo: string; certUrl: string; transmissionId: string; transmissionSig: string; transmissionTime: string; webhookId: string; webhookEvent: unknown; }): Promise { try { const token = await getAccessToken(); const baseUrl = getPayPalBaseUrl(); const body = { auth_algo: params.authAlgo, cert_url: params.certUrl, transmission_id: params.transmissionId, transmission_sig: params.transmissionSig, transmission_time: params.transmissionTime, webhook_id: params.webhookId, webhook_event: params.webhookEvent, }; const response = await fetch( `${baseUrl}/v1/notifications/verify-webhook-signature`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(body), } ); if (!response.ok) { return false; } const data = (await response.json()) as { verification_status: string }; return data.verification_status === "SUCCESS"; } catch { // Never throw — return false on any error so the webhook handler can respond with 401 return false; } }