forked from UKSOURCE/ipv6
282 lines
8.2 KiB
TypeScript
282 lines
8.2 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
// 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<boolean> {
|
|
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;
|
|
}
|
|
}
|