/** * PayPal Payment Service * ───────────────────────────────────────────────────────────────────────────── * Uses PayPal Orders API v2 (REST). * Supports: Create Order → Capture Order → Webhook verification * * Configuration (set via environment variables — DO NOT hardcode): * PAYPAL_CLIENT_ID — PayPal REST API client ID * PAYPAL_CLIENT_SECRET — PayPal REST API client secret * PAYPAL_WEBHOOK_ID — Webhook ID from PayPal Developer Dashboard * PAYPAL_SANDBOX — "true" to use sandbox environment * * Integration point: * After capturing the order (or receiving PAYMENT.CAPTURE.COMPLETED webhook), * call tokenDistributionService.creditXic() to distribute XIC tokens. * * Docs: https://developer.paypal.com/docs/api/orders/v2/ */ import { getDb } from "../db"; import { fiatOrders } from "../../drizzle/schema"; import { eq } from "drizzle-orm"; import { creditXic, calcXicAmount } from "../tokenDistributionService"; // ─── Configuration ──────────────────────────────────────────────────────────── // TODO: Replace placeholder values with real credentials from PayPal Developer Dashboard // https://developer.paypal.com/dashboard/applications const PAYPAL_CONFIG = { clientId: process.env.PAYPAL_CLIENT_ID || "PLACEHOLDER_PAYPAL_CLIENT_ID", clientSecret: process.env.PAYPAL_CLIENT_SECRET || "PLACEHOLDER_PAYPAL_CLIENT_SECRET", webhookId: process.env.PAYPAL_WEBHOOK_ID || "PLACEHOLDER_PAYPAL_WEBHOOK_ID", // Sandbox: https://api-m.sandbox.paypal.com // Production: https://api-m.paypal.com baseUrl: process.env.PAYPAL_SANDBOX === "true" ? "https://api-m.sandbox.paypal.com" : "https://api-m.paypal.com", sandbox: process.env.PAYPAL_SANDBOX === "true", returnUrl: process.env.PAYPAL_RETURN_URL || "https://pre-sale.newassetchain.io/payment/success", cancelUrl: process.env.PAYPAL_CANCEL_URL || "https://pre-sale.newassetchain.io/payment/cancel", }; // ─── Types ──────────────────────────────────────────────────────────────────── export interface PaypalOrderParams { orderId: string; usdAmount: string; // USD amount, e.g. "100.00" xicReceiveAddress: string; userId?: string; description?: string; } export interface PaypalOrderResult { success: boolean; paypalOrderId?: string; // PayPal's order ID approveUrl?: string; // URL to redirect user for approval orderId?: string; // our internal order ID error?: string; } // ─── OAuth Token Cache ──────────────────────────────────────────────────────── let _accessToken: string | null = null; let _tokenExpiry = 0; /** * Get PayPal OAuth 2.0 access token. * Tokens are cached until expiry. */ async function getAccessToken(): Promise { if (_accessToken && Date.now() < _tokenExpiry - 60_000) { return _accessToken; } if (PAYPAL_CONFIG.clientId === "PLACEHOLDER_PAYPAL_CLIENT_ID") { console.warn("[PayPal] Using placeholder credentials — API calls will fail"); return "PLACEHOLDER_ACCESS_TOKEN"; } const credentials = Buffer.from(`${PAYPAL_CONFIG.clientId}:${PAYPAL_CONFIG.clientSecret}`).toString("base64"); const response = await fetch(`${PAYPAL_CONFIG.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) { throw new Error(`[PayPal] Failed to get access token: ${response.status}`); } const data = await response.json() as any; _accessToken = data.access_token; _tokenExpiry = Date.now() + (data.expires_in * 1000); return _accessToken!; } // ─── Helpers ────────────────────────────────────────────────────────────────── /** Generate unique order ID with PAYPAL prefix */ export function generatePaypalOrderId(): string { const ts = Date.now().toString(); const rand = Math.random().toString(36).substring(2, 8).toUpperCase(); return `PAYPAL-${ts}-${rand}`; } // ─── Core Functions ─────────────────────────────────────────────────────────── /** * Create a PayPal order. * Returns an approval URL to redirect the user to PayPal for payment. * * Flow: * 1. Create order via PayPal Orders API v2 * 2. Save order to fiat_orders table * 3. Return approveUrl for frontend redirect * 4. After user approves, frontend calls capturePaypalOrder() */ export async function createPaypalOrder(params: PaypalOrderParams): Promise { const { orderId, usdAmount, xicReceiveAddress, userId, description } = params; const usdValue = parseFloat(usdAmount); const xicAmount = calcXicAmount(usdValue); try { const accessToken = await getAccessToken(); const db = await getDb(); if (!db) return { success: false, error: "Database not available" }; // Build PayPal order request const orderPayload = { intent: "CAPTURE", purchase_units: [ { reference_id: orderId, description: description || `NAC XIC Token Purchase — ${xicAmount} XIC`, custom_id: xicReceiveAddress, // store XIC receive address in custom_id amount: { currency_code: "USD", value: usdAmount, breakdown: { item_total: { currency_code: "USD", value: usdAmount }, }, }, items: [ { name: "XIC Token", description: `${xicAmount} XIC tokens at $0.02/XIC`, quantity: "1", unit_amount: { currency_code: "USD", value: usdAmount }, category: "DIGITAL_GOODS", }, ], }, ], payment_source: { paypal: { experience_context: { payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED", brand_name: "NAC XIC Token Presale", locale: "en-US", landing_page: "LOGIN", shipping_preference: "NO_SHIPPING", user_action: "PAY_NOW", return_url: `${PAYPAL_CONFIG.returnUrl}?orderId=${orderId}`, cancel_url: `${PAYPAL_CONFIG.cancelUrl}?orderId=${orderId}`, }, }, }, }; const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders`, { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Request-Id": orderId, // idempotency key }, body: JSON.stringify(orderPayload), }); const data = await response.json() as any; if (!response.ok) { console.error("[PayPal] Create order error:", data); return { success: false, error: data.message || "PayPal API error" }; } // Find approve URL const approveLink = data.links?.find((l: any) => l.rel === "payer-action" || l.rel === "approve"); const approveUrl = approveLink?.href; // Save to database await db.insert(fiatOrders).values({ orderId, gatewayOrderId: data.id, channel: "paypal", userId: userId || null, xicReceiveAddress, usdtEquivalent: usdAmount, currency: "USD", originalAmount: usdAmount, xicAmount: xicAmount.toString(), status: "pending", paymentUrl: approveUrl || null, expiredAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour }); console.log(`[PayPal] Order created: ${orderId} (PayPal: ${data.id}) | USD ${usdAmount} → ${xicAmount} XIC → ${xicReceiveAddress}`); return { success: true, paypalOrderId: data.id, approveUrl, orderId }; } catch (err) { console.error("[PayPal] Failed to create order:", err); return { success: false, error: String(err) }; } } /** * Capture a PayPal order after user approval. * Called by frontend after user returns from PayPal approval page. * This is the final step that actually charges the user. */ export async function capturePaypalOrder(paypalOrderId: string, internalOrderId: string): Promise<{ success: boolean; captureId?: string; error?: string }> { try { const accessToken = await getAccessToken(); const db = await getDb(); if (!db) return { success: false, error: "Database not available" }; const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders/${paypalOrderId}/capture`, { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Request-Id": `capture-${internalOrderId}`, }, }); const data = await response.json() as any; if (!response.ok) { console.error("[PayPal] Capture error:", data); return { success: false, error: data.message || "Capture failed" }; } if (data.status !== "COMPLETED") { return { success: false, error: `Unexpected status: ${data.status}` }; } const capture = data.purchase_units?.[0]?.payments?.captures?.[0]; const captureId = capture?.id; const payerEmail = data.payer?.email_address; const xicReceiveAddress = data.purchase_units?.[0]?.custom_id; // Find order in database const orders = await db.select().from(fiatOrders) .where(eq(fiatOrders.orderId, internalOrderId)) .limit(1); const order = orders[0]; if (!order) return { success: false, error: "Order not found" }; if (order.status === "distributed" || order.status === "paid") { return { success: true, captureId }; } // Update to paid await db.update(fiatOrders) .set({ status: "paid", payerEmail: payerEmail || null, callbackPayload: JSON.stringify(data), updatedAt: new Date(), }) .where(eq(fiatOrders.orderId, internalOrderId)); // Distribute XIC const creditResult = await creditXic({ txHash: `PAYPAL-${captureId}`, chainType: "PAYPAL", fromAddress: payerEmail || "paypal_payer", toAddress: "paypal_merchant", usdtAmount: parseFloat(order.usdtEquivalent), xicAmount: parseFloat(order.xicAmount), xicReceiveAddress: order.xicReceiveAddress || xicReceiveAddress || undefined, remark: `PayPal order ${internalOrderId}, USD ${order.originalAmount}`, }); if (creditResult.success) { await db.update(fiatOrders) .set({ status: "distributed", distributedAt: new Date() }) .where(eq(fiatOrders.orderId, internalOrderId)); console.log(`[PayPal] ✅ XIC distributed for order ${internalOrderId}`); } return { success: true, captureId }; } catch (err) { console.error("[PayPal] Capture error:", err); return { success: false, error: String(err) }; } } /** * Handle PayPal webhook events. * PayPal sends PAYMENT.CAPTURE.COMPLETED when payment is confirmed. * This is a backup to capturePaypalOrder() for cases where the user * closes the browser before returning to our site. * * Docs: https://developer.paypal.com/api/rest/webhooks/ */ export async function handlePaypalWebhook( headers: Record, body: any ): Promise<{ ok: boolean; message: string }> { // TODO: Implement PayPal webhook signature verification // Reference: https://developer.paypal.com/api/rest/webhooks/rest/#link-eventtypelistforallapps // For now, process the event directly (add signature verification before production) const { event_type, resource } = body; if (event_type !== "PAYMENT.CAPTURE.COMPLETED") { return { ok: true, message: "ignored" }; } const captureId = resource?.id; const customId = resource?.custom_id; // our XIC receive address const invoiceId = resource?.invoice_id; // our internal order ID (if set) const payerEmail = resource?.payer?.email_address; const amount = resource?.amount?.value; if (!captureId) return { ok: false, message: "missing_capture_id" }; try { const db = await getDb(); if (!db) return { ok: false, message: "db_unavailable" }; // Find order by gatewayOrderId (PayPal order ID) // Note: resource.supplementary_data?.related_ids?.order_id contains the PayPal order ID const paypalOrderId = resource?.supplementary_data?.related_ids?.order_id; if (!paypalOrderId) return { ok: true, message: "no_order_id" }; const orders = await db.select().from(fiatOrders) .where(eq(fiatOrders.gatewayOrderId, paypalOrderId)) .limit(1); const order = orders[0]; if (!order) return { ok: true, message: "order_not_found" }; if (order.status === "distributed" || order.status === "paid") { return { ok: true, message: "already_processed" }; } await db.update(fiatOrders) .set({ status: "paid", payerEmail: payerEmail || null, callbackPayload: JSON.stringify(body), updatedAt: new Date(), }) .where(eq(fiatOrders.id, order.id)); const creditResult = await creditXic({ txHash: `PAYPAL-${captureId}`, chainType: "PAYPAL", fromAddress: payerEmail || "paypal_payer", toAddress: "paypal_merchant", usdtAmount: parseFloat(order.usdtEquivalent), xicAmount: parseFloat(order.xicAmount), xicReceiveAddress: order.xicReceiveAddress || customId || undefined, remark: `PayPal webhook capture ${captureId}, USD ${amount}`, }); if (creditResult.success) { await db.update(fiatOrders) .set({ status: "distributed", distributedAt: new Date() }) .where(eq(fiatOrders.id, order.id)); console.log(`[PayPal] ✅ XIC distributed via webhook for order ${order.orderId}`); } return { ok: true, message: "success" }; } catch (err) { console.error("[PayPal] Webhook processing error:", err); return { ok: false, message: String(err) }; } } /** * Query PayPal order status. */ export async function queryPaypalOrder(paypalOrderId: string): Promise<{ success: boolean; status?: string; error?: string }> { try { const accessToken = await getAccessToken(); const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders/${paypalOrderId}`, { headers: { "Authorization": `Bearer ${accessToken}` }, }); const data = await response.json() as any; if (response.ok) return { success: true, status: data.status }; return { success: false, error: data.message }; } catch (err) { return { success: false, error: String(err) }; } }