| 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 }, |
| ); |
| } |
|
|