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 { 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, ): Promise { 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 { await db.delete(appConfigTable).where(eq(appConfigTable.key, key)); } async function checkPlanExists(planId: string): Promise { 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 { try { const res = (await paypalRequest("GET", `/v1/catalogs/products/${productId}`)) as { id?: string }; return !!res?.id; } catch { return false; } } async function getOrCreateProductId(): Promise { 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 { 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 { 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 { return (await paypalRequest( "GET", `/v1/billing/subscriptions/${subscriptionId}`, )) as PaypalSubscription; } export async function cancelSubscriptionApi( subscriptionId: string, reason: string, ): Promise { await paypalRequest( "POST", `/v1/billing/subscriptions/${subscriptionId}/cancel`, { reason }, ); }