fb / artifacts /api-server /src /lib /paypal.ts
sare26's picture
Upload 21 files
ccc21f3 verified
Raw
History Blame Contribute Delete
6.77 kB
import { logger } from "./logger";
import { db, appConfigTable } from "@workspace/db";
import { eq } from "drizzle-orm";
const PAYPAL_MODE = process.env["PAYPAL_MODE"] ?? "sandbox";
const PAYPAL_BASE =
PAYPAL_MODE === "live"
? "https://api-m.paypal.com"
: "https://api-m.sandbox.paypal.com";
const CLIENT_ID = process.env["PAYPAL_CLIENT_ID"];
const CLIENT_SECRET = process.env["PAYPAL_CLIENT_SECRET"];
if (!CLIENT_ID || !CLIENT_SECRET) {
logger.warn("PayPal credentials missing — subscription endpoints will fail");
}
interface CachedToken {
token: string;
expiresAt: number;
}
let cachedToken: CachedToken | null = null;
export async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) {
return cachedToken.token;
}
const credentials = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString(
"base64",
);
const res = await fetch(`${PAYPAL_BASE}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!res.ok) {
const text = await res.text();
throw new Error(`PayPal auth failed: ${res.status} ${text}`);
}
const data = (await res.json()) as { access_token: string; expires_in: number };
cachedToken = {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return cachedToken.token;
}
async function paypalRequest(
method: string,
path: string,
body?: unknown,
extraHeaders?: Record<string, string>,
): Promise<unknown> {
const token = await getAccessToken();
const res = await fetch(`${PAYPAL_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
...(extraHeaders ?? {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed: unknown = null;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
}
if (!res.ok) {
logger.error({ status: res.status, path, body, response: parsed }, "PayPal request failed");
throw new Error(`PayPal ${method} ${path} failed: ${res.status}`);
}
return parsed;
}
async function deleteCachedConfig(key: string): Promise<void> {
await db.delete(appConfigTable).where(eq(appConfigTable.key, key));
}
async function checkPlanExists(planId: string): Promise<boolean> {
try {
const res = (await paypalRequest("GET", `/v1/billing/plans/${planId}`)) as { status?: string };
return !!res?.status;
} catch {
return false;
}
}
async function checkProductExists(productId: string): Promise<boolean> {
try {
const res = (await paypalRequest("GET", `/v1/catalogs/products/${productId}`)) as { id?: string };
return !!res?.id;
} catch {
return false;
}
}
async function getOrCreateProductId(): Promise<string> {
const [row] = await db
.select()
.from(appConfigTable)
.where(eq(appConfigTable.key, "paypal_product_id"));
if (row) {
const valid = await checkProductExists(row.value);
if (valid) return row.value;
logger.warn("Cached PayPal product not found in this environment — recreating");
await deleteCachedConfig("paypal_product_id");
}
const product = (await paypalRequest("POST", "/v1/catalogs/products", {
name: "FB Group Publisher PRO",
description: "Subscription access to FB Group Publisher PRO",
type: "SERVICE",
category: "SOFTWARE",
})) as { id: string };
await db
.insert(appConfigTable)
.values({ key: "paypal_product_id", value: product.id });
logger.info({ productId: product.id }, "Created new PayPal product");
return product.id;
}
export async function getOrCreatePlanId(): Promise<string> {
const [row] = await db
.select()
.from(appConfigTable)
.where(eq(appConfigTable.key, "paypal_plan_id_v2"));
if (row) {
const valid = await checkPlanExists(row.value);
if (valid) return row.value;
logger.warn({ planId: row.value }, "Cached PayPal plan not found in this environment — recreating");
await deleteCachedConfig("paypal_plan_id_v2");
}
const productId = await getOrCreateProductId();
const plan = (await paypalRequest("POST", "/v1/billing/plans", {
product_id: productId,
name: "FB Group Publisher PRO – $2.50/2months",
description: "$2.50 every 2 months (trial handled in-app)",
status: "ACTIVE",
billing_cycles: [
{
frequency: { interval_unit: "MONTH", interval_count: 2 },
tenure_type: "REGULAR",
sequence: 1,
total_cycles: 0,
pricing_scheme: {
fixed_price: { value: "2.50", currency_code: "USD" },
},
},
],
payment_preferences: {
auto_bill_outstanding: true,
setup_fee: { value: "0", currency_code: "USD" },
setup_fee_failure_action: "CONTINUE",
payment_failure_threshold: 2,
},
})) as { id: string };
await db
.insert(appConfigTable)
.values({ key: "paypal_plan_id_v2", value: plan.id });
logger.info({ planId: plan.id }, "Created new PayPal plan");
return plan.id;
}
export interface CreateSubResult {
id: string;
approvalUrl: string;
}
export async function createSubscription(
returnUrl: string,
cancelUrl: string,
customId: string,
): Promise<CreateSubResult> {
const planId = await getOrCreatePlanId();
const sub = (await paypalRequest("POST", "/v1/billing/subscriptions", {
plan_id: planId,
custom_id: customId,
application_context: {
brand_name: "FB Group Publisher PRO",
locale: "ar-SA",
shipping_preference: "NO_SHIPPING",
user_action: "SUBSCRIBE_NOW",
return_url: returnUrl,
cancel_url: cancelUrl,
},
})) as { id: string; links: Array<{ rel: string; href: string }> };
const approval = sub.links.find((l) => l.rel === "approve");
if (!approval) throw new Error("PayPal did not return approval link");
return { id: sub.id, approvalUrl: approval.href };
}
export interface PaypalSubscription {
id: string;
status: string;
custom_id?: string;
billing_info?: {
next_billing_time?: string;
};
start_time?: string;
}
export async function getSubscription(
subscriptionId: string,
): Promise<PaypalSubscription> {
return (await paypalRequest(
"GET",
`/v1/billing/subscriptions/${subscriptionId}`,
)) as PaypalSubscription;
}
export async function cancelSubscriptionApi(
subscriptionId: string,
reason: string,
): Promise<void> {
await paypalRequest(
"POST",
`/v1/billing/subscriptions/${subscriptionId}/cancel`,
{ reason },
);
}