Files
EpicNext-Cms/src/lib/services/paypal.ts
T
openhands df38dccbf1
Local Build and Deploy / deploy (push) Failing after 46s
style: format code biome
2026-07-13 21:57:41 +02:00

186 lines
5.3 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,
};
}