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>
262 lines
9.8 KiB
TypeScript
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;
|