/** * PayPal direct integration — capture, tracking, refund. * * POST /api/v1/paypal/capture — Capture approved order (called from success page) * POST /api/v1/paypal/tracking — Add tracking number to captured order * POST /api/v1/paypal/refund — Refund a captured payment * GET /api/v1/paypal/order/:id/status — Check PayPal order status */ import { Router, type Request, type Response } from "express"; import { pool } from "../db.js"; import { handlePaymentComplete } from "./checkout.js"; const router = Router(); // ─── PayPal API helpers ─────────────────────────────────────────────────────── const PAYPAL_API_URL = process.env.PAYPAL_API_URL || "https://api-m.paypal.com"; const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || ""; const PAYPAL_CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET || ""; async function getAccessToken(): Promise { const res = await fetch(`${PAYPAL_API_URL}/v1/oauth2/token`, { method: "POST", headers: { Authorization: `Basic ${Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded", }, body: "grant_type=client_credentials", }); const data = await res.json() as { access_token?: string; error?: string }; if (!data.access_token) throw new Error(`PayPal auth failed: ${data.error || "no access_token"}`); return data.access_token; } async function paypalFetch(method: string, path: string, body?: object): Promise<{ status: number; data: any }> { const token = await getAccessToken(); const res = await fetch(`${PAYPAL_API_URL}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, ...(body ? { body: JSON.stringify(body) } : {}), }); const data = await res.json().catch(() => ({})); return { status: res.status, data }; } // ─── Capture an approved PayPal order ───────────────────────────────────────── // Called when the buyer is redirected back after approving payment on PayPal. router.post("/api/v1/paypal/capture", async (req: Request, res: Response) => { try { const { paypal_order_id, order_id, order_type } = req.body as { paypal_order_id?: string; order_id?: string; order_type?: string; }; if (!paypal_order_id) { res.status(400).json({ error: "paypal_order_id required" }); return; } const { status, data } = await paypalFetch("POST", `/v2/checkout/orders/${paypal_order_id}/capture`); if (status >= 400) { console.error("[paypal] Capture failed:", data); // If already captured, treat as success if (data?.details?.[0]?.issue === "ORDER_ALREADY_CAPTURED") { const check = await paypalFetch("GET", `/v2/checkout/orders/${paypal_order_id}`); if (check.data?.status === "COMPLETED") { res.json({ success: true, status: "COMPLETED", already_captured: true }); return; } } res.status(502).json({ error: "PayPal capture failed", details: data }); return; } if (data.status === "COMPLETED") { console.log(`[paypal] Captured ${paypal_order_id} successfully`); // Find the internal order and mark as paid const resolvedOrderId = order_id || data.purchase_units?.[0]?.custom_id || data.purchase_units?.[0]?.reference_id; // Infer order type from ID prefix if not provided let resolvedOrderType = order_type || ""; if (!resolvedOrderType && resolvedOrderId) { if (resolvedOrderId.startsWith("CB-")) resolvedOrderType = "compliance_batch"; else if (resolvedOrderId.startsWith("CO-")) resolvedOrderType = "compliance"; else if (resolvedOrderId.startsWith("FO-")) resolvedOrderType = "formation"; else resolvedOrderType = "canada_crtc"; } if (resolvedOrderId) { try { // Store PayPal capture details in the order before marking paid const captureId = data.purchase_units?.[0]?.payments?.captures?.[0]?.id || ""; const payerEmail = data.payer?.email_address || ""; // Update the correct table with PayPal order ID const tableMap: Record = { formation: { table: "formation_orders", col: "order_number" }, canada_crtc: { table: "canada_crtc_orders", col: "order_number" }, compliance: { table: "compliance_orders", col: "order_number" }, compliance_batch: { table: "compliance_orders", col: "batch_id" }, }; const target = tableMap[resolvedOrderType] || tableMap.canada_crtc; await pool.query( `UPDATE ${target.table} SET paypal_order_id = $1, payment_method = 'paypal' WHERE ${target.col} = $2`, [paypal_order_id, resolvedOrderId], ).catch((err: any) => console.error("[paypal] DB update failed:", err.message)); await handlePaymentComplete(resolvedOrderId, resolvedOrderType, `paypal-${captureId}`); } catch (err) { console.error("[paypal] handlePaymentComplete failed (non-blocking):", err); } } res.json({ success: true, status: "COMPLETED", capture_id: data.purchase_units?.[0]?.payments?.captures?.[0]?.id, payer_email: data.payer?.email_address, }); } else { res.json({ success: false, status: data.status, details: data }); } } catch (err: any) { console.error("[paypal] Capture error:", err); res.status(500).json({ error: err.message || "PayPal capture failed" }); } }); // ─── Check PayPal order status ──────────────────────────────────────────────── router.get("/api/v1/paypal/order/:id/status", async (req: Request, res: Response) => { try { const { id } = req.params; const { status, data } = await paypalFetch("GET", `/v2/checkout/orders/${id}`); if (status >= 400) { res.status(status).json({ error: "PayPal order lookup failed", details: data }); return; } res.json({ paypal_order_id: data.id, status: data.status, payer_email: data.payer?.email_address, amount: data.purchase_units?.[0]?.amount, custom_id: data.purchase_units?.[0]?.custom_id, }); } catch (err: any) { console.error("[paypal] Status check error:", err); res.status(500).json({ error: err.message }); } }); // ─── Add tracking number to a captured order ────────────────────────────────── router.post("/api/v1/paypal/tracking", async (req: Request, res: Response) => { try { const { capture_id, tracking_number, carrier, order_id } = req.body as { capture_id?: string; tracking_number?: string; carrier?: string; order_id?: string; }; if (!capture_id || !tracking_number) { res.status(400).json({ error: "capture_id and tracking_number required" }); return; } // PayPal tracking API — POST /v1/shipping/trackers-batch const { status, data } = await paypalFetch("POST", "/v1/shipping/trackers-batch", { trackers: [{ transaction_id: capture_id, tracking_number, status: "SHIPPED", carrier: carrier || "OTHER", }], }); if (status >= 400) { console.error("[paypal] Tracking update failed:", data); res.status(502).json({ error: "PayPal tracking update failed", details: data }); return; } console.log(`[paypal] Tracking added for capture ${capture_id}: ${tracking_number}`); res.json({ success: true, tracking_number, capture_id }); } catch (err: any) { console.error("[paypal] Tracking error:", err); res.status(500).json({ error: err.message }); } }); // ─── Refund a captured payment ──────────────────────────────────────────────── router.post("/api/v1/paypal/refund", async (req: Request, res: Response) => { try { const { capture_id, amount, currency, reason, order_id } = req.body as { capture_id?: string; amount?: string; currency?: string; reason?: string; order_id?: string; }; if (!capture_id) { res.status(400).json({ error: "capture_id required" }); return; } const refundBody: Record = {}; if (amount) { refundBody.amount = { value: amount, currency_code: currency || "USD" }; } if (reason) { refundBody.note_to_payer = reason; } const { status, data } = await paypalFetch( "POST", `/v2/payments/captures/${capture_id}/refund`, Object.keys(refundBody).length > 0 ? refundBody : undefined, ); if (status >= 400) { console.error("[paypal] Refund failed:", data); res.status(502).json({ error: "PayPal refund failed", details: data }); return; } console.log(`[paypal] Refund ${data.id} for capture ${capture_id}: ${data.status}`); // Update order status if order_id provided if (order_id) { await pool.query( `UPDATE canada_crtc_orders SET payment_status = 'refunded' WHERE order_number = $1`, [order_id], ).catch(() => {}); await pool.query( `UPDATE formation_orders SET payment_status = 'refunded' WHERE order_number = $1`, [order_id], ).catch(() => {}); } res.json({ success: true, refund_id: data.id, status: data.status, amount: data.amount, }); } catch (err: any) { console.error("[paypal] Refund error:", err); res.status(500).json({ error: err.message }); } }); export default router;