new-site/api/src/routes/paypal.ts
justin e82aa0b8c2 Fix PayPal capture for compliance orders + MCS-150 form generator
PayPal capture was defaulting to canada_crtc_orders table for all
non-formation orders. Now properly routes compliance_batch orders
to compliance_orders table with batch_id lookup. Also infers
order type from ID prefix (CB-=batch, CO-=compliance, FO-=formation).

MCS-150 form generator: produces DOCX with fax cover sheet + filled
MCS-150 form for faxing to FMCSA at 202-366-3477.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 11:34:16 -05:00

262 lines
9.8 KiB
TypeScript

/**
* 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<string> {
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<string, { table: string; col: string }> = {
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<string, any> = {};
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;