new-site/api/src/routes/checkout.ts
justin 7909f130c6 Fix $0 checkout bypass: remove nonexistent status column
compliance_orders has payment_status, not status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 00:36:15 -05:00

2055 lines
85 KiB
TypeScript

/**
* Checkout Routes — Stripe Checkout Sessions
*
* POST /api/v1/checkout/create-session
* 1. Validate input
* 2. CRTC orders: verify identity status
* 3. Fetch order from PG, build line items
* 4. Calculate surcharge server-side (never trust client)
* 5. Find or create ERPNext Customer
* 6. Duplicate-check (idempotency)
* 7. Create Stripe Checkout Session
* 8. Record session_id on PG order row
* 9. Return { checkout_url, session_id, total_cents, surcharge_cents }
*
* GET /api/v1/checkout/session/:session_id
* Polls Stripe for session status.
* On "paid": updates PG order → advances ERPNext Sales Order workflow.
*
* Payment confirmation also arrives via Stripe webhook:
* POST /api/v1/webhooks/stripe (in webhooks.ts)
*/
import { Router } from "express";
import { z } from "zod";
import Stripe from "stripe";
import { pool } from "../db.js";
import {
getResource,
createResource,
callMethod,
ERPNextError,
ensureWebsiteUser,
linkUserToCustomer,
createInvoiceFromSalesOrder,
updateResource,
} from "../erpnext-client.js";
import { sendOrderConfirmationEmail } from "../email";
const router = Router();
// ─── Stripe client ────────────────────────────────────────────────────────────
const STRIPE_SECRET_KEY =
(process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_SECRET_KEY?.trim()) ||
process.env.STRIPE_SECRET_KEY ||
"";
const STRIPE_API_VERSION: Stripe.LatestApiVersion = "2026-03-25.dahlia";
const stripe = STRIPE_SECRET_KEY
? new Stripe(STRIPE_SECRET_KEY, { apiVersion: STRIPE_API_VERSION })
: null;
const DOMAIN = process.env.DOMAIN
? `https://${process.env.DOMAIN}`
: "http://localhost:4321";
// ─── Surcharge rates (applied to service fees only, never gov/filing fees) ────
const GATEWAY_SURCHARGES: Record<string, number> = {
card: 3.0, // Stripe 2.9% + 30c; we charge flat 3%
ach: 0.0, // ACH (Stripe) — 0.8% capped $5; too small to itemize
paypal: 3.0, // PayPal direct — 2.99% + 49c; we charge 3%
klarna: 6.0, // Stripe Klarna 5.99% + 30c; we charge 6%
crypto: 0.0, // SHKeeper — 0%
};
// Human-readable gateway labels for invoice line items
const GATEWAY_LABELS: Record<string, string> = {
card: "Card",
ach: "ACH",
paypal: "PayPal",
klarna: "Klarna",
crypto: "Crypto",
};
// ─── Validation schema ────────────────────────────────────────────────────────
const CreateSessionSchema = z.object({
order_id: z.string().min(1),
order_type: z.enum(["canada_crtc", "formation", "bundle", "compliance", "compliance_batch", "fcc_carrier_registration"]),
payment_method: z.enum(["card", "ach", "paypal", "klarna", "crypto"]),
});
// ─── Schema migration (run once at startup) ───────────────────────────────────
let schemaMigrated = false;
async function ensureColumns(): Promise<void> {
if (schemaMigrated) return;
await pool.query(`ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`);
await pool.query(`ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS erpnext_sales_order TEXT`);
await pool.query(`ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment'`);
await pool.query(`ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`);
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment'`);
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`);
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_method TEXT`);
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_pct NUMERIC`);
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_cents INTEGER`);
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment'`);
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ`);
// compliance_orders columns are created by migration 044 — just ensure extras
try {
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS paypal_order_id TEXT`);
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB`);
} catch { /* table may not exist yet */ }
schemaMigrated = true;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function toDollars(cents: number): number {
return Math.round(cents) / 100;
}
/**
* Find or create ERPNext Customer.
*
* Returns the customer's Frappe link key plus a `portal_user_created` flag
* — true when this request was the one to create the ERPNext Website User
* (i.e. the customer is new and needs a set-password step on the success
* page). The flag is persisted back to the PG order row by the caller so
* the success page and the delivery worker can surface the onboarding
* password/magic-link UI.
*/
async function findOrCreateCustomer(
email: string,
fullName: string,
): Promise<{ customerName: string; portalUserCreated: boolean }> {
// ERPNext Customer standard field is email_id
const existing = (await getResource(
"Customer",
undefined,
{ email_id: email },
["name"],
1,
)) as Array<{ name: string }>;
let customerName: string;
if (existing.length > 0) {
customerName = existing[0].name;
} else {
const created = (await createResource("Customer", {
customer_name: fullName,
customer_type: "Individual",
customer_group: "Individual",
territory: "All Territories",
email_id: email,
})) as { name: string };
customerName = created.name;
}
// Ensure matching ERPNext Website User exists (non-fatal if it fails).
// `created` distinguishes first-touch customers from returning ones.
let portalUserCreated = false;
try {
const { created } = await ensureWebsiteUser(email, fullName);
portalUserCreated = created;
await linkUserToCustomer(customerName, email);
} catch (err) {
console.warn("[checkout] ensureWebsiteUser warning (non-fatal):", err);
}
return { customerName, portalUserCreated };
}
/**
* Fetch order from PG and build Stripe line items + surcharge basis.
* Returns null if order not found or not in pending_payment state.
*/
// Service slugs whose payment unlocks the classified CDR traffic study
// for the reporting year (paywall — see plan file § I.).
const CDR_STUDY_GRANTING_SLUGS = new Set([
"fcc-499a",
"fcc-499a-499q",
"fcc-full-compliance",
"cdr-analysis",
]);
async function grantCDRStudyAccess(
order: Record<string, unknown>,
order_id: string,
): Promise<void> {
const serviceSlug = (order.service_slug as string) || "";
if (!serviceSlug || !CDR_STUDY_GRANTING_SLUGS.has(serviceSlug)) return;
const telecomEntityId = order.telecom_entity_id as number | null;
if (!telecomEntityId) return;
// Find the cdr_ingestion_profile for this entity (may not exist if the
// customer hasn't enabled CDR ingestion yet — grants can predate profiles).
const profileRows = await pool.query(
`SELECT id FROM cdr_ingestion_profiles WHERE telecom_entity_id = $1`,
[telecomEntityId],
);
if (profileRows.rows.length === 0) {
console.log(
`[checkout] CDR paywall: no profile yet for entity ${telecomEntityId}` +
`grant will be issued when the profile is created (paywall lookup checks intents).`,
);
// Stash the intent on compliance_orders so profile creation can back-fill.
await pool.query(
`UPDATE compliance_orders
SET intake_data = jsonb_set(
COALESCE(intake_data, '{}'::jsonb),
'{cdr_grant_pending}', 'true'::jsonb
)
WHERE order_number = $1`,
[order_id],
);
return;
}
const profileId = profileRows.rows[0].id as number;
// Reporting year — prefer intake_data.reporting_year (customer may
// explicitly file for a prior year), fall back to the order's year.
const intake = (order.intake_data as Record<string, unknown>) || {};
const reportingYear =
Number(intake.reporting_year) ||
new Date(order.created_at as string).getUTCFullYear();
await pool.query(
`INSERT INTO cdr_study_access_grants (profile_id, reporting_year, granted_by_order)
VALUES ($1, $2, $3)
ON CONFLICT (profile_id, reporting_year, granted_by_order) DO NOTHING`,
[profileId, reportingYear, order_id],
);
console.log(
`[checkout] CDR grant issued: profile=${profileId} year=${reportingYear} order=${order_id}`,
);
}
async function fetchOrderData(
order_id: string,
order_type: string,
): Promise<{
order: Record<string, unknown>;
stripeLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
service_cents: number;
base_cents: number;
discount_cents?: number;
customer_email: string;
customer_name: string;
} | null> {
// ── Canada CRTC ─────────────────────────────────────────────────────────
if (order_type === "fcc_carrier_registration") {
const { rows } = await pool.query(
`SELECT * FROM fcc_carrier_registrations WHERE order_number = $1`,
[order_id],
);
if (!rows.length) return null;
const order = rows[0] as Record<string, unknown>;
if (order.payment_status !== "pending_payment") return null;
const baseCents = (order.service_fee_cents as number) || 129900;
const formCents = ((order.formation_fee_cents as number) || 0) + ((order.state_fee_cents as number) || 0);
const addonCents = (order.addon_fee_cents as number) || 0;
const pucCents = (order.puc_fee_cents as number) || 0;
const totalCents = baseCents + formCents + addonCents + pucCents;
const lineItems: Array<{price_data: {currency: "usd"; product_data: {name: string}; unit_amount: number}; quantity: number}> = [
{ price_data: { currency: "usd", product_data: { name: "FCC Carrier / ISP Registration" }, unit_amount: baseCents }, quantity: 1 },
];
if (formCents > 0) {
lineItems.push({ price_data: { currency: "usd", product_data: { name: `Business Formation (${order.formation_state || "?"} ${((order.entity_type as string) || "LLC").toUpperCase()})` }, unit_amount: formCents }, quantity: 1 });
}
if ((order.include_stir_shaken as boolean)) {
lineItems.push({ price_data: { currency: "usd", product_data: { name: "STIR/SHAKEN Implementation" }, unit_amount: 49900 }, quantity: 1 });
}
if ((order.include_ocn as boolean)) {
lineItems.push({ price_data: { currency: "usd", product_data: { name: "NECA OCN Registration" }, unit_amount: 265000 }, quantity: 1 });
}
if (pucCents > 0) {
const stateCount = ((order.state_puc_states as string[]) || []).length;
lineItems.push({ price_data: { currency: "usd", product_data: { name: `State PUC Registration (${stateCount} state${stateCount !== 1 ? "s" : ""})` }, unit_amount: pucCents }, quantity: 1 });
}
return {
order,
stripeLineItems: lineItems as Stripe.Checkout.SessionCreateParams.LineItem[],
service_cents: totalCents,
base_cents: totalCents,
customer_email: (order.customer_email as string) || "",
customer_name: (order.customer_name as string) || "",
};
}
if (order_type === "canada_crtc") {
const { rows } = await pool.query(
`SELECT * FROM canada_crtc_orders WHERE order_number = $1`,
[order_id],
);
if (!rows.length) return null;
const order = rows[0] as Record<string, unknown>;
if (order.payment_status !== "pending_payment") return null;
const service_cents = (order.service_fee_cents as number) || 389900;
const gov_fee_cents = (order.government_fee_cents as number) || 35000;
const mailbox_cents = 12000;
const company_type = (order.company_type as string) || "numbered";
return {
order,
stripeLineItems: [
{
price_data: {
currency: "usd",
product_data: { name: "Canada CRTC Telecom Carrier Package", description: `${order.incorporation_province || "BC"} incorporation, CRTC registration, CCTS, domain, email, DID, binder (${company_type})` },
unit_amount: service_cents,
},
quantity: 1,
},
{
price_data: {
currency: "usd",
product_data: { name: "Provincial Filing Fees", description: `Non-refundable government filing fees — ${order.incorporation_province === "ON" ? "Ontario Business Registry" : "BC Corporate Online"}` },
unit_amount: gov_fee_cents,
},
quantity: 1,
},
{
price_data: {
currency: "usd",
product_data: { name: "Vancouver Virtual Office", description: `Registered office — ${(order.mailbox_address as string) || "329 Howe St, Vancouver"} — 1 year` },
unit_amount: mailbox_cents,
},
quantity: 1,
},
],
service_cents,
base_cents: service_cents + gov_fee_cents + mailbox_cents,
customer_email: (order.customer_email as string) || "",
customer_name: (order.customer_name as string) || "Unknown",
};
}
// ── US Formation ─────────────────────────────────────────────────────────
if (order_type === "formation") {
const { rows } = await pool.query(
`SELECT * FROM formation_orders WHERE order_number = $1`,
[order_id],
);
if (!rows.length) return null;
const order = rows[0] as Record<string, unknown>;
if (order.payment_status !== "pending_payment") return null;
const svc = (order.service_fee_cents as number) || 17900;
const fees = (order.state_fee_cents as number) || 0;
const stateCode = (order.state_code as string) || "";
const entityType = (order.entity_type as string) || "LLC";
const items: Stripe.Checkout.SessionCreateParams.LineItem[] = [
{
price_data: {
currency: "usd",
product_data: { name: `${entityType} Formation`, description: `${entityType} Formation — ${stateCode}` },
unit_amount: svc,
},
quantity: 1,
},
];
if (fees > 0) {
items.push({
price_data: {
currency: "usd",
product_data: { name: "State Filing Fee", description: `${stateCode} state filing fee (non-refundable government fee)` },
unit_amount: fees,
},
quantity: 1,
});
}
return {
order,
stripeLineItems: items,
service_cents: svc,
base_cents: svc + fees,
customer_email: (order.customer_email as string) || "",
customer_name: (order.customer_name as string) || "Unknown",
};
}
// ── Bundle ────────────────────────────────────────────────────────────────
if (order_type === "bundle") {
const { rows } = await pool.query(
`SELECT bo.*, sb.name as bundle_name
FROM bundle_orders bo
JOIN service_bundles sb ON sb.slug = bo.bundle_slug
WHERE bo.order_number = $1`,
[order_id],
);
if (!rows.length) return null;
const order = rows[0] as Record<string, unknown>;
// Allow 'received' or 'pending_payment' — bundles start as 'received'
if (order.payment_status && order.payment_status !== "pending_payment" && order.payment_status !== "received") return null;
const total_cents = (order.final_total_cents as number) || 0;
const discount_cents = (order.discount_cents as number) || 0;
const bundle_name = (order.bundle_name as string) || (order.bundle_slug as string) || "Service Bundle";
const items: Stripe.Checkout.SessionCreateParams.LineItem[] = [
{
price_data: {
currency: "usd",
product_data: { name: bundle_name, description: "20% bundle discount applied" },
unit_amount: total_cents + discount_cents,
},
quantity: 1,
},
];
if (discount_cents > 0) {
// Stripe Checkout doesn't support negative line items directly;
// we encode the discount as a coupon applied to the session instead.
}
return {
order,
stripeLineItems: items,
service_cents: total_cents,
base_cents: total_cents,
customer_email: (order.customer_email as string) || "",
customer_name: (order.customer_name as string) || "Unknown",
};
}
// ── Compliance service (CCPA audit, FLSA audit, etc.) ────────────────────
if (order_type === "compliance") {
let rows: Record<string, unknown>[] = [];
try {
const result = await pool.query(
`SELECT * FROM compliance_orders WHERE order_number = $1`,
[order_id],
);
rows = result.rows as Record<string, unknown>[];
} catch (err: any) {
if (err?.code === "42P01") {
console.warn("[checkout] compliance_orders table not found; compliance checkout is unavailable");
return null;
}
throw err;
}
if (!rows.length) return null;
const order = rows[0] as Record<string, unknown>;
if (order.payment_status !== "pending_payment") return null;
// Gate on pre-payment validation — intake must have been verified by
// POST /api/v1/compliance-orders/:order_number/validate before Stripe
// Checkout can run. Prevents submissions that would fail at the
// handler for missing required fields.
if (order.intake_data_validated !== true) {
throw Object.assign(
new Error(
"Order intake has not been validated. POST /api/v1/compliance-orders/" +
order.order_number + "/validate first.",
),
{ status: 422, code: "INTAKE_NOT_VALIDATED" },
);
}
const svc = (order.service_fee_cents as number) || 0;
const govFee = (order.gov_fee_cents as number) || 0;
const service_name = (order.service_name as string) || "Compliance Service";
const lineItems: Array<{price_data: {currency: "usd"; product_data: {name: string}; unit_amount: number}; quantity: number}> = [
{
price_data: {
currency: "usd",
product_data: { name: service_name },
unit_amount: svc,
},
quantity: 1,
},
];
if (govFee > 0) {
lineItems.push({
price_data: {
currency: "usd",
product_data: { name: (order.gov_fee_label as string) || "Government filing fee" },
unit_amount: govFee,
},
quantity: 1,
});
}
return {
order,
stripeLineItems: lineItems,
service_cents: svc + govFee,
base_cents: svc + govFee,
customer_email: (order.customer_email as string) || "",
customer_name: (order.customer_name as string) || "Unknown",
};
}
if (order_type === "compliance_batch") {
// Batch checkout: order_id is the batch_id (CB-XXXXXXXX)
let rows: Record<string, unknown>[] = [];
try {
const result = await pool.query(
`SELECT * FROM compliance_orders WHERE batch_id = $1 AND payment_status = 'pending_payment' ORDER BY created_at`,
[order_id],
);
rows = result.rows as Record<string, unknown>[];
} catch (err: any) {
if (err?.code === "42P01") return null;
throw err;
}
if (!rows.length) return null;
// Build Stripe line items at FULL price — discount applied via Stripe coupon
const stripeLineItems: Array<{price_data: {currency: "usd"; product_data: {name: string}; unit_amount: number}; quantity: number}> = [];
for (const order of rows) {
const price = (order.service_fee_cents as number) || 0;
stripeLineItems.push({
price_data: {
currency: "usd",
product_data: { name: (order.service_name as string) || "Compliance Service" },
unit_amount: price,
},
quantity: 1,
});
// Gov filing fees shown as separate passthrough line items
const govFee = (order.gov_fee_cents as number) || 0;
if (govFee > 0) {
stripeLineItems.push({
price_data: {
currency: "usd",
product_data: { name: (order.gov_fee_label as string) || "Government filing fee" },
unit_amount: govFee,
},
quantity: 1,
});
}
}
const totalService = rows.reduce((sum, o) => sum + ((o.service_fee_cents as number) || 0), 0);
const totalGovFees = rows.reduce((sum, o) => sum + ((o.gov_fee_cents as number) || 0), 0);
const totalDiscount = rows.reduce((sum, o) => sum + ((o.discount_cents as number) || 0), 0);
// baseCents is pre-discount (line items are at full price, coupon handles discount)
const baseCents = totalService + totalGovFees;
return {
order: rows[0],
stripeLineItems,
service_cents: baseCents,
base_cents: baseCents,
discount_cents: totalDiscount,
customer_email: (rows[0].customer_email as string) || "",
customer_name: (rows[0].customer_name as string) || "Unknown",
};
}
return null;
}
// ─── POST /api/v1/checkout/create-session ─────────────────────────────────────
router.post("/api/v1/checkout/create-session", async (req, res) => {
if (!stripe) {
res.status(503).json({ error: "Payment gateway not configured (STRIPE_SECRET_KEY missing)" });
return;
}
const parsed = CreateSessionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid request", details: parsed.error.flatten() });
return;
}
const { order_id, order_type, payment_method } = parsed.data;
try {
await ensureColumns();
// ── Identity gate (CRTC only) — skip in test mode ───────────────────────
const isTestMode = STRIPE_SECRET_KEY.startsWith("sk_test_");
if (order_type === "canada_crtc" && !isTestMode) {
try {
const soList = (await getResource(
"Sales Order",
undefined,
{ custom_external_order_id: order_id },
["name", "custom_identity_status"],
1,
)) as Array<{ name: string; custom_identity_status: string }>;
if (soList.length > 0) {
const id_status = soList[0].custom_identity_status;
if (id_status === "Failed") {
res.status(422).json({ error: "Identity verification failed.", code: "IDENTITY_FAILED" });
return;
}
if (id_status === "Pending") {
res.status(422).json({ error: "Identity verification still pending.", code: "IDENTITY_PENDING" });
return;
}
} else {
const ivCheck = await pool.query(
`SELECT iv.overall_result
FROM canada_crtc_orders o
JOIN identity_verifications iv ON iv.stripe_session_id = o.identity_session_id
WHERE o.order_number = $1`,
[order_id],
);
if (!ivCheck.rows.length) {
res.status(422).json({ error: "Identity verification required.", code: "IDENTITY_REQUIRED" });
return;
}
const r = ivCheck.rows[0] as Record<string, unknown>;
if (r.overall_result === "failed") {
res.status(422).json({ error: "Identity verification failed.", code: "IDENTITY_FAILED" });
return;
}
if (r.overall_result === "pending") {
res.status(422).json({ error: "Identity verification still pending.", code: "IDENTITY_PENDING" });
return;
}
}
} catch (idErr) {
if (idErr instanceof ERPNextError) {
console.error("[checkout] ERPNext identity check failed:", idErr);
res.status(503).json({ error: "Identity service temporarily unavailable.", code: "ERPNEXT_UNAVAILABLE" });
return;
}
throw idErr;
}
}
// ── Fetch order + build line items ─────────────────────────────────────
let orderData;
try {
orderData = await fetchOrderData(order_id, order_type);
} catch (err: any) {
// Intake-not-validated is a normal 422 path; everything else is a
// genuine error.
if (err?.code === "INTAKE_NOT_VALIDATED") {
res.status(422).json({ error: err.message, code: err.code });
return;
}
throw err;
}
if (!orderData) {
res.status(404).json({ error: "Order not found or not in pending_payment state" });
return;
}
const { order, stripeLineItems, service_cents, base_cents, customer_email, customer_name } = orderData;
// For batch orders, total discount comes from the orderData; for single orders, from the order row
const discount_cents = (orderData as any).discount_cents ?? (order.discount_cents as number) ?? 0;
// ── Server-side surcharge (applied to post-discount amount) ───────────
const surcharge_pct = GATEWAY_SURCHARGES[payment_method] ?? 0;
const surcharge_cents = Math.round(((base_cents - discount_cents) * surcharge_pct) / 100);
const total_cents = base_cents + surcharge_cents - discount_cents;
// ── Idempotency — reuse existing open Stripe session only if same method ─
const existingSessionId = (order.stripe_session_id as string) || null;
if (existingSessionId && payment_method !== "paypal" && payment_method !== "crypto") {
try {
const existing = await stripe.checkout.sessions.retrieve(existingSessionId);
if (existing.status === "open") {
// Check if the payment method matches — if user switched, expire the old session
const existingMethod = existing.metadata?.payment_method;
if (existingMethod === payment_method) {
res.json({
checkout_url: existing.url,
session_id: existing.id,
total_cents,
surcharge_cents,
surcharge_pct,
});
return;
}
// Different method requested — expire old session
try {
await stripe.checkout.sessions.expire(existingSessionId);
console.log(`[checkout] Expired old session ${existingSessionId} (switching ${existingMethod}${payment_method})`);
} catch { /* already expired or completed */ }
}
// expired, complete, or method changed — fall through to create new
} catch {
// Session not found or expired — fall through
}
}
// Clear stale session ID when switching to non-Stripe gateway
if (existingSessionId && (payment_method === "paypal" || payment_method === "crypto")) {
try {
const existing = await stripe.checkout.sessions.retrieve(existingSessionId);
if (existing.status === "open") {
await stripe.checkout.sessions.expire(existingSessionId);
console.log(`[checkout] Expired Stripe session ${existingSessionId} (switching to ${payment_method})`);
}
} catch { /* ignore */ }
}
// ── Add surcharge line item if applicable ──────────────────────────────
const allLineItems = [...stripeLineItems];
if (surcharge_cents > 0) {
allLineItems.push({
price_data: {
currency: "usd",
product_data: {
name: `${GATEWAY_LABELS[payment_method]} Processing Fee`,
description: `${surcharge_pct}% processing surcharge`,
},
unit_amount: surcharge_cents,
},
quantity: 1,
});
}
// ── Apply discount coupon if applicable ────────────────────────────────
let discounts: Stripe.Checkout.SessionCreateParams.Discount[] | undefined;
if (discount_cents > 0) {
// Create a one-time Stripe coupon for this exact discount amount
const coupon = await stripe.coupons.create({
amount_off: discount_cents,
currency: "usd",
duration: "once",
name: `Order discount (${order_id})`,
metadata: { order_id, order_type },
});
discounts = [{ coupon: coupon.id }];
}
// ── Find or create ERPNext Customer (non-blocking for checkout) ────────
let erpnextCustomer: string | undefined;
let portalUserCreated = false;
try {
const { customerName, portalUserCreated: created } =
await findOrCreateCustomer(customer_email, customer_name);
erpnextCustomer = customerName;
portalUserCreated = created;
} catch (err) {
console.warn("[checkout] ERPNext customer sync failed (non-blocking):", err);
}
// Persist the portal-user-created flag so the success page + delivery
// worker can surface the set-password onboarding (migration 047). The
// table depends on order_type — do the correct UPDATE per type.
if (portalUserCreated) {
try {
const table =
order_type === "canada_crtc"
? "canada_crtc_orders"
: order_type === "formation"
? "formation_orders"
: order_type === "compliance" || order_type === "compliance_batch"
? "compliance_orders"
: null;
if (table) {
await pool.query(
`UPDATE ${table} SET portal_user_created = TRUE WHERE order_number = $1`,
[order_id],
);
}
} catch (pgErr) {
console.warn("[checkout] Could not persist portal_user_created flag:", pgErr);
}
}
// ── Create ERPNext Sales Order (non-blocking, all payment methods) ────
if (order_type === "canada_crtc" && erpnextCustomer) {
try {
// Fetch binder mailing details from the PG order row (already loaded above)
const pgOrder = orderData.order as Record<string, any>;
const hasOwnAddr = pgOrder.has_own_ca_address === true;
const ownCaCompany = pgOrder.own_ca_company as string | null;
const ownCaAttn = pgOrder.own_ca_attn as string | null;
// For AMB orders, fetch operator_name from amb_locations
let ambOperatorName: string | null = null;
if (!hasOwnAddr && pgOrder.amb_location_slug) {
try {
const { rows: ambOp } = await pool.query(
"SELECT operator_name FROM amb_locations WHERE slug = $1",
[pgOrder.amb_location_slug],
);
if (ambOp.length > 0) ambOperatorName = ambOp[0].operator_name;
} catch { /* non-fatal */ }
}
const so = (await createResource("Sales Order", {
customer: erpnextCustomer,
delivery_date: new Date(Date.now() + 90 * 86400000).toISOString().split("T")[0],
custom_external_order_id: order_id,
custom_order_type: order_type,
custom_payment_gateway: GATEWAY_LABELS[payment_method] || payment_method,
custom_surcharge_pct: surcharge_pct,
custom_identity_status: "Verified",
// Binder mailing address details
custom_mailbox_address: pgOrder.mailbox_address || null,
custom_has_own_ca_address: hasOwnAddr ? 1 : 0,
custom_own_ca_company: hasOwnAddr ? (ownCaCompany || null) : (ambOperatorName || null),
custom_own_ca_attn: hasOwnAddr ? (ownCaAttn || null) : null,
workflow_state: "Received",
items: [{
item_code: "CRTC-PACKAGE",
qty: 1,
rate: toDollars(base_cents),
}, ...(surcharge_cents > 0 ? [{
item_code: "PAYMENT-PROCESSING-FEE",
description: `${GATEWAY_LABELS[payment_method] || payment_method} ${surcharge_pct}%`,
qty: 1,
rate: toDollars(surcharge_cents),
}] : [])],
})) as { name: string };
try {
await callMethod("frappe.client.submit", { doc: { doctype: "Sales Order", name: so.name } });
} catch { /* submit may fail if workflow doesn't require it */ }
await pool.query(
`UPDATE canada_crtc_orders SET erpnext_sales_order = $1 WHERE order_number = $2`,
[so.name, order_id],
);
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for ${order_id}`);
} catch (soErr) {
console.warn("[checkout] ERPNext Sales Order creation failed (non-blocking):", soErr);
}
}
// ── Create ERPNext Sales Order (compliance services) ────────────────────
if (order_type === "compliance" && erpnextCustomer) {
try {
const { COMPLIANCE_SERVICES } = await import("./compliance-orders.js");
const pgOrder = orderData.order as Record<string, any>;
const serviceSlug = (pgOrder.service_slug as string) || "";
const serviceInfo = COMPLIANCE_SERVICES[serviceSlug];
const itemCode = serviceInfo?.erpnext_item || "COMPLIANCE-SERVICE";
const so = (await createResource("Sales Order", {
customer: erpnextCustomer,
delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
custom_external_order_id: order_id,
custom_order_type: "compliance",
custom_payment_gateway: GATEWAY_LABELS[payment_method] || payment_method,
custom_surcharge_pct: surcharge_pct,
workflow_state: "Received",
items: [{
item_code: itemCode,
description: pgOrder.service_name || serviceInfo?.name || "Compliance Service",
qty: 1,
rate: toDollars(base_cents),
}, ...(surcharge_cents > 0 ? [{
item_code: "PAYMENT-PROCESSING-FEE",
description: `${GATEWAY_LABELS[payment_method] || payment_method} ${surcharge_pct}%`,
qty: 1,
rate: toDollars(surcharge_cents),
}] : [])],
})) as { name: string };
try {
await callMethod("frappe.client.submit", { doc: { doctype: "Sales Order", name: so.name } });
} catch { /* submit may fail if workflow doesn't require it */ }
await pool.query(
`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE order_number = $2`,
[so.name, order_id],
);
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for compliance ${order_id}`);
} catch (soErr) {
console.warn("[checkout] Compliance Sales Order creation failed (non-blocking):", soErr);
}
}
// ── Create Stripe Checkout Session ─────────────────────────────────────
const STRIPE_PAYMENT_METHOD_MAP: Record<string, Stripe.Checkout.SessionCreateParams.PaymentMethodType[]> = {
card: ["card"],
ach: ["us_bank_account"],
klarna: ["klarna"],
paypal: [], // PayPal direct — bypasses Stripe
crypto: [], // SHKeeper — bypasses Stripe
};
const paymentMethodTypes = STRIPE_PAYMENT_METHOD_MAP[payment_method] ?? ["card"];
// ── $0 orders: skip all payment gateways, mark paid immediately ──────
const effectiveTotal = base_cents - discount_cents;
if (effectiveTotal <= 0 && base_cents > 0) {
const zeroTable: Record<string, string> = {
canada_crtc: "canada_crtc_orders",
formation: "formation_orders",
bundle: "bundle_orders",
compliance: "compliance_orders",
compliance_batch: "compliance_orders",
};
const zt = zeroTable[order_type];
if (zt) {
const whereCol = order_type === "compliance_batch" ? "batch_id" : "order_number";
await pool.query(
`UPDATE ${zt} SET payment_status = 'paid', payment_method = 'free', surcharge_cents = 0, surcharge_pct = 0 WHERE ${whereCol} = $1`,
[order_id],
);
}
console.log(`[checkout] $0 order — skipping payment for ${order_type} ${order_id} (discount ${discount_cents} >= base ${base_cents})`);
res.json({
checkout_url: `${DOMAIN}/order/success?order_id=${order_id}&order_type=${order_type}&free=1`,
session_id: null,
total_cents: 0,
surcharge_cents: 0,
surcharge_pct: 0,
});
return;
}
if (payment_method === "crypto") {
// Crypto orders bypass Stripe — create SHKeeper invoice via API
const shkeeperUrl = process.env.SHKEEPER_URL || "http://127.0.0.1:5000";
const shkeeperPublic = process.env.SHKEEPER_PUBLIC_URL || "https://crypto.performancewest.net";
const shkeeperKey = process.env.SHKEEPER_API_KEY || "";
const callbackUrl = `${DOMAIN}/api/v1/webhooks/shkeeper`;
if (!shkeeperKey) {
res.status(503).json({ error: "Cryptocurrency payments are not configured. Please choose another payment method." });
return;
}
// Mark order as crypto and redirect to our crypto-pay page
// The actual SHKeeper invoice is created when the user picks a coin
const table = order_type === "canada_crtc" ? "canada_crtc_orders"
: order_type === "formation" ? "formation_orders"
: "bundle_orders";
await pool.query(
`UPDATE ${table} SET payment_method = 'crypto' WHERE order_number = $1`,
[order_id],
);
const paymentPageUrl = `${DOMAIN}/order/crypto-pay?order_id=${order_id}&order_type=${order_type}&amount=${toDollars(total_cents)}${order.expedited ? "&expedited=1" : ""}`;
res.json({
checkout_url: paymentPageUrl,
session_id: `crypto-${order_id}`,
total_cents,
surcharge_cents: 0,
surcharge_pct: 0,
});
return;
}
if (payment_method === "paypal") {
// PayPal direct — redirect to PayPal checkout
const paypalClientId = process.env.PAYPAL_CLIENT_ID || "";
const paypalBaseUrl = process.env.PAYPAL_API_URL || "https://api-m.paypal.com";
const paypalSecret = process.env.PAYPAL_CLIENT_SECRET || "";
if (!paypalClientId || !paypalSecret) {
res.status(503).json({ error: "PayPal is not configured. Please choose another payment method." });
return;
}
try {
// Get PayPal access token
const authRes = await fetch(`${paypalBaseUrl}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${paypalClientId}:${paypalSecret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
const authData = await authRes.json() as { access_token?: string };
if (!authData.access_token) throw new Error("PayPal auth failed");
// Create PayPal order
const ppOrder = await fetch(`${paypalBaseUrl}/v2/checkout/orders`, {
method: "POST",
headers: {
Authorization: `Bearer ${authData.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [{
reference_id: order_id,
description: `Performance West — ${order_type === "canada_crtc" ? "Canada CRTC Package" : "Order"} ${order_id}`,
amount: {
currency_code: "USD",
value: toDollars(total_cents),
},
custom_id: order_id,
}],
payment_source: {
paypal: {
experience_context: {
brand_name: "Performance West Inc.",
return_url: `${DOMAIN}/order/success?paypal=1&order_id=${order_id}&order_type=${order_type}`,
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`,
user_action: "PAY_NOW",
landing_page: "LOGIN",
},
},
},
}),
});
const ppData = await ppOrder.json() as { id?: string; links?: Array<{ rel: string; href: string }> };
const approveLink = ppData.links?.find(l => l.rel === "payer-action")?.href
|| ppData.links?.find(l => l.rel === "approve")?.href;
if (!approveLink) throw new Error("PayPal order creation failed — no approval URL");
// Store PayPal order ID for capture on return
await pool.query(
`UPDATE ${order_type === "canada_crtc" ? "canada_crtc_orders" : "formation_orders"}
SET paypal_order_id = $1, payment_method = 'paypal'
WHERE order_number = $2`,
[ppData.id, order_id],
);
res.json({
checkout_url: approveLink,
session_id: `paypal-${ppData.id}`,
total_cents,
surcharge_cents,
surcharge_pct,
});
} catch (ppErr) {
console.error("[checkout] PayPal order creation failed:", ppErr);
res.status(502).json({ error: "PayPal checkout failed. Please try another payment method." });
}
return;
}
const paymentIntentData: Stripe.Checkout.SessionCreateParams.PaymentIntentData = {
metadata: {
order_id,
order_type,
payment_method,
},
...(payment_method === "ach" ? { setup_future_usage: "off_session" } : {}),
};
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: paymentMethodTypes,
line_items: allLineItems,
...(discounts ? { discounts } : {}),
customer_email: customer_email || undefined,
success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`,
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`,
metadata: {
order_id,
order_type,
payment_method,
customer_email: customer_email || "",
customer_name: customer_name || "",
...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}),
},
payment_intent_data: paymentIntentData,
// ACH via Plaid: verify account balance before accepting payment
...(payment_method === "ach" ? {
payment_method_options: {
us_bank_account: {
financial_connections: {
permissions: ["payment_method", "balances"],
prefetch: ["balances"],
},
verification_method: "instant",
},
},
} : {}),
});
// (Sales Order already created above, before gateway split)
// ── Record Stripe session on PG order ──────────────────────────────────
const tableMap: Record<string, string> = {
canada_crtc: "canada_crtc_orders",
formation: "formation_orders",
bundle: "bundle_orders",
compliance: "compliance_orders",
compliance_batch: "compliance_orders",
};
const table = tableMap[order_type];
if (table) {
// For batch orders, update by batch_id; otherwise by order_number
const whereCol = order_type === "compliance_batch" ? "batch_id" : "order_number";
await pool.query(
`UPDATE ${table}
SET stripe_session_id = $1,
payment_method = $2,
surcharge_pct = $3,
surcharge_cents = $4
WHERE ${whereCol} = $5`,
[session.id, payment_method, surcharge_pct, surcharge_cents, order_id],
);
}
console.log(`[checkout] Stripe session ${session.id} created for ${order_type} ${order_id}`);
res.json({
checkout_url: session.url,
session_id: session.id,
total_cents,
surcharge_cents,
surcharge_pct,
});
} catch (err) {
console.error("[checkout] create-session error:", err);
res.status(500).json({ error: "Failed to create checkout session" });
}
});
// ─── GET /api/v1/checkout/session/:session_id ─────────────────────────────────
// Poll this after customer returns from Stripe checkout.
router.get("/api/v1/checkout/session/:session_id", async (req, res) => {
if (!stripe) {
res.status(503).json({ error: "Payment gateway not configured" });
return;
}
const { session_id } = req.params;
if (!session_id) {
res.status(400).json({ error: "session_id required" });
return;
}
// Crypto pseudo-sessions don't go through Stripe
if (session_id.startsWith("crypto-")) {
const order_id = session_id.replace("crypto-", "");
res.json({ status: "pending", session_id, order_id, order_type: "unknown", total_cents: null });
return;
}
try {
const session = await stripe.checkout.sessions.retrieve(session_id, {
expand: ["payment_intent"],
});
const statusMap: Record<string, string> = {
open: "pending",
complete: "paid",
expired: "expired",
};
const status = statusMap[session.status ?? "open"] ?? "pending";
// Resolve order from metadata
const order_id = session.metadata?.order_id ?? null;
const order_type = session.metadata?.order_type ?? null;
// ── On payment completion: update PG + advance ERPNext + send confirmation ──
if (status === "paid" && order_id && order_type) {
await handlePaymentComplete(order_id, order_type, session_id);
}
res.json({
status,
session_id,
order_id,
order_type,
total_cents: session.amount_total,
customer_email: session.customer_details?.email || session.metadata?.customer_email || null,
customer_name: session.customer_details?.name || session.metadata?.customer_name || null,
});
} catch (err) {
console.error("[checkout] session poll error:", err);
res.status(500).json({ error: "Failed to retrieve session status" });
}
});
// ─── Shared payment-complete handler ─────────────────────────────────────────
// Called from both poll endpoint and Stripe webhook.
export async function handlePaymentComplete(
order_id: string,
order_type: string,
session_id: string,
): Promise<void> {
const tableMap: Record<string, string> = {
canada_crtc: "canada_crtc_orders",
formation: "formation_orders",
bundle: "bundle_orders",
compliance: "compliance_orders",
compliance_batch: "compliance_orders",
fcc_carrier_registration: "fcc_carrier_registrations",
};
const table = tableMap[order_type];
if (!table) return;
// For batch orders, update by batch_id instead of order_number
const updated = order_type === "compliance_batch"
? await pool.query(
`UPDATE ${table}
SET payment_status = 'paid',
paid_at = NOW()
WHERE batch_id = $1
AND payment_status IN ('pending_payment', 'received')
RETURNING *`,
[order_id],
)
: await pool.query(
`UPDATE ${table}
SET payment_status = 'paid',
paid_at = NOW()
WHERE order_number = $1
AND payment_status IN ('pending_payment', 'received')
RETURNING *`,
[order_id],
);
if (!updated.rows.length) {
// Already processed
return;
}
const order = updated.rows[0] as Record<string, unknown>;
const paymentMethod = (order.payment_method as string) || "card";
console.log(`[checkout] Payment confirmed: ${order_type} ${order_id} via ${paymentMethod}`);
// ── Telegram order notification ──────────────────────────────────────
try {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (botToken && chatId) {
const customerName = (order.customer_name as string) || "Unknown";
const customerEmail = (order.customer_email as string) || "";
const serviceName = (order.service_slug as string)
|| (order.service_name as string)
|| order_type;
// Calculate total in dollars
const feeCents = Number(order.service_fee_cents || order.total_cents || 0);
const formationCents = Number(order.formation_fee_cents || 0);
const stateCents = Number(order.state_fee_cents || 0);
const addonCents = Number(order.addon_fee_cents || 0);
const pucCents = Number(order.puc_fee_cents || 0);
const totalCents = feeCents + formationCents + stateCents + addonCents + pucCents;
const totalDollars = (totalCents / 100).toFixed(2);
const msg = `💰 NEW ORDER\n\n`
+ `Customer: ${customerName}\n`
+ `Email: ${customerEmail}\n`
+ `Service: ${serviceName}\n`
+ `Total: $${totalDollars}\n`
+ `Payment: ${paymentMethod}\n`
+ `Order: ${order_id}\n`
+ `Type: ${order_type}`;
fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text: msg }),
}).catch(() => {}); // fire and forget
}
} catch {}
// ── CDR traffic study paywall: issue grants on qualifying orders ──────
//
// When a customer pays for a 499-A filing service OR the standalone
// cdr-analysis service, unlock the classified traffic study for that
// reporting year. Non-fatal — the customer still sees ingestion
// counts even without a grant.
if (order_type === "compliance" || order_type === "compliance_batch") {
try {
await grantCDRStudyAccess(order, order_id);
} catch (grantErr) {
console.warn("[checkout] CDR grant issuance warning (non-fatal):", grantErr);
}
// Send intake/next-steps email for compliance orders
try {
await sendComplianceIntakeEmail(order_id, order_type, updated.rows);
} catch (intakeErr) {
console.error("[checkout] Compliance intake email failed (non-fatal):", intakeErr);
}
}
// ── Umami analytics — server-side payment event ─────────────────────────
try {
const umamiUrl = process.env.UMAMI_URL || "http://umami:3000";
const websiteId = "55250014-ee15-44ac-a1f6-81dabad3fe0f";
await fetch(`${umamiUrl}/api/send`, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": "PW-API/1.0" },
body: JSON.stringify({
payload: {
website: websiteId,
hostname: "performancewest.net",
url: `/checkout/complete/${order_type}`,
name: "payment-complete",
data: {
order_type,
order_id,
payment_method: paymentMethod,
total_cents: Number(order.service_fee_cents || order.total_cents || 0),
service: (order.service_slug as string) || order_type,
},
},
type: "event",
}),
});
} catch { /* non-fatal */ }
// ── Advance ERPNext Sales Order workflow (CRTC) ────────────────────────
if (order_type === "canada_crtc") {
const soName = (order.erpnext_sales_order as string) || null;
// PayPal: funds are instant — advance directly to "Client Selection"
// Stripe card/ACH/Klarna: advance to "Awaiting Funds" — balance.available webhook handles next step
// Crypto: advance to "Awaiting Funds" — manual admin step later
const isInstantFunds = paymentMethod === "paypal";
const targetState = isInstantFunds ? "Client Selection" : "Awaiting Funds";
if (soName) {
try {
await callMethod("frappe.client.set_value", {
doctype: "Sales Order",
name: soName,
fieldname: "workflow_state",
value: targetState,
});
console.log(`[checkout] Advanced Sales Order ${soName} to ${targetState}`);
} catch (wfErr) {
console.error(`[checkout] Workflow advance failed for ${soName}:`, wfErr);
}
}
// If instant funds, also mark funds_available and send portal setup email
if (isInstantFunds) {
await pool.query(
`UPDATE canada_crtc_orders SET funds_available = TRUE, funds_available_at = NOW() WHERE order_number = $1`,
[order_id],
);
try {
await sendClientSelectionEmail(order_id, order);
} catch (emailErr) {
console.error("[checkout] Client selection email failed (non-blocking):", emailErr);
}
}
}
// ── Advance compliance batch orders (fan out worker dispatch per service) ──
// ── FCC Carrier Registration — dispatch pipeline worker ──────────────────
if (order_type === "fcc_carrier_registration") {
const workerUrl = process.env.WORKER_URL || "http://workers:8090";
setImmediate(async () => {
try {
const dispatchRes = await fetch(`${workerUrl}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "process_fcc_carrier_registration",
order_number: order_id,
}),
});
if (dispatchRes.ok) {
console.log(`[checkout] FCC carrier registration dispatched: ${order_id}`);
} else {
console.error(`[checkout] FCC carrier reg dispatch failed: HTTP ${dispatchRes.status}`);
}
} catch (err) {
console.error(`[checkout] FCC carrier reg dispatch error:`, err);
}
});
// Send confirmation email
try {
const { sendEmail } = await import("../email.js");
const custEmail = (order.customer_email as string) || "";
const custName = (order.customer_name as string) || "";
const firstName = custName.split(" ")[0] || custName;
await sendEmail({
to: custEmail,
subject: `FCC Carrier Registration — Order ${order_id} Confirmed`,
html: `<h2>Your FCC Carrier Registration is Underway</h2>
<p>Hi ${firstName},</p>
<p>Thank you for your order. We're now processing your carrier/ISP registration package.</p>
<p>You'll receive email updates as each step completes. If we need any additional information, we'll reach out.</p>
<p style="font-size:12px;color:#9ca3af;">Order: ${order_id}</p>
<p style="font-size:12px;color:#9ca3af;">Performance West Inc. | 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001 | 1-888-411-0383</p>`,
});
} catch {}
}
if (order_type === "compliance_batch") {
// A batch order is a set of compliance_orders sharing a batch_id.
// Create ERPNext Sales Order + Invoice, then dispatch each sub-order.
const batchId = order_id;
const workerUrl = process.env.WORKER_URL || "http://workers:8090";
try {
const batchRows = await pool.query(
`SELECT order_number, service_slug, service_name, service_fee_cents,
discount_cents, gov_fee_cents, erpnext_sales_order
FROM compliance_orders WHERE batch_id = $1`,
[batchId],
);
console.log(`[checkout] Batch ${batchId}: ${batchRows.rows.length} orders to dispatch`);
// ── Create ERPNext Sales Order for the batch (non-blocking) ──────
setImmediate(async () => {
try {
// Find or create ERPNext Customer
const custEmail = (order.customer_email as string) || "";
const custName = (order.customer_name as string) || custEmail;
let erpCustomer: string | undefined;
try {
const custResult = await findOrCreateCustomer(custEmail, custName);
erpCustomer = custResult.customerName;
// Link portal user to Customer so portal shows their orders
try {
await updateResource("Customer", erpCustomer!, {
portal_users: [{ user: custEmail }],
});
} catch { /* non-fatal — may already be linked */ }
} catch { /* non-fatal */ }
// Build line items for ERPNext — show full price with discount on each line
const items: Record<string, unknown>[] = [];
for (const bo of batchRows.rows as any[]) {
const priceCents = (bo.service_fee_cents || 0) + (bo.gov_fee_cents || 0);
const discCents = bo.discount_cents || 0;
items.push({
item_code: bo.service_slug,
item_name: bo.service_name || bo.service_slug,
qty: 1,
rate: priceCents / 100,
discount_amount: discCents / 100,
description: bo.service_name +
(bo.gov_fee_cents > 0 ? ` (includes $${(bo.gov_fee_cents / 100).toFixed(0)} gov filing fee)` : ""),
});
}
const soData: Record<string, unknown> = {
customer: erpCustomer || custName,
title: `FCC Compliance Services — ${batchId}`,
custom_external_order_id: batchId,
delivery_date: new Date(Date.now() + 10 * 86400000).toISOString().slice(0, 10),
items,
custom_payment_gateway: paymentMethod,
};
const so = await createResource("Sales Order", soData) as Record<string, any>;
const soName = so.name;
console.log(`[checkout] ERPNext Sales Order ${soName} created for batch ${batchId}`);
// Submit the Sales Order (fetch fresh doc first to avoid timestamp mismatch)
try {
const freshDoc = await getResource("Sales Order", soName) as Record<string, any>;
await callMethod("frappe.client.submit", { doc: JSON.stringify(freshDoc) });
} catch (submitErr) {
console.warn(`[checkout] SO submit failed for ${soName} (non-fatal):`, (submitErr as any)?.body?.exception || submitErr);
}
// Link the SO back to PG orders
await pool.query(
`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE batch_id = $2`,
[soName, batchId],
);
// Create Sales Invoice
try {
// Calculate batch total (sum of all line items minus discounts)
const batchTotalCents = (batchRows.rows as any[]).reduce((sum, bo) => {
return sum + (bo.service_fee_cents || 0) + (bo.gov_fee_cents || 0) - (bo.discount_cents || 0);
}, 0);
const invName = await createInvoiceFromSalesOrder({
salesOrderName: soName,
paymentGateway: paymentMethod,
surchargePercent: (order.surcharge_pct as number) || 0,
paymentReference: session_id,
paidAmountCents: batchTotalCents,
externalOrderId: batchId,
});
if (invName) {
console.log(`[checkout] ERPNext Invoice ${invName} created for batch ${batchId}`);
}
} catch (invErr) {
console.warn(`[checkout] Invoice creation failed for ${batchId} (non-fatal):`, invErr);
}
} catch (erpErr) {
console.warn(`[checkout] ERPNext SO creation failed for batch ${batchId} (non-fatal):`, erpErr);
}
});
for (const batchOrder of batchRows.rows) {
const bo = batchOrder as Record<string, unknown>;
const boNumber = bo.order_number as string;
const boSlug = bo.service_slug as string;
const boSO = bo.erpnext_sales_order as string | null;
// Advance ERPNext Sales Order workflow if linked
if (boSO) {
try {
await callMethod("frappe.client.set_value", {
doctype: "Sales Order",
name: boSO,
fieldname: "workflow_state",
value: "Service Queued",
});
} catch (wfErr) {
console.error(`[checkout] Batch ${batchId}: workflow advance failed for ${boNumber}:`, wfErr);
}
}
// Dispatch to worker
setImmediate(async () => {
try {
const res = await fetch(`${workerUrl}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "process_compliance_service",
order_name: boSO || boNumber,
order_number: boNumber,
service_slug: boSlug,
}),
});
if (!res.ok) {
console.error(`[checkout] Batch dispatch failed for ${boNumber}: HTTP ${res.status}`);
} else {
console.log(`[checkout] Batch dispatched: ${boNumber} (${boSlug})`);
}
} catch (err) {
console.error(`[checkout] Batch dispatch error for ${boNumber}:`, err);
}
});
}
} catch (err) {
console.error(`[checkout] Batch ${batchId}: failed to load sub-orders:`, err);
}
// ── Commission tracking for compliance batch orders ─────────────────
try {
const discRow = await pool.query(
`SELECT DISTINCT discount_code FROM compliance_orders WHERE batch_id = $1 AND discount_code IS NOT NULL LIMIT 1`,
[batchId],
);
const discountCode = discRow.rows[0]?.discount_code as string | null;
if (discountCode) {
const { createCommission } = await import("./agents.js");
const totalCents = (updated.rows as any[]).reduce((sum, r) =>
sum + (Number(r.service_fee_cents) || 0) + (Number(r.gov_fee_cents) || 0) - (Number(r.discount_cents) || 0), 0);
await createCommission({
agentCode: discountCode,
orderType: "service",
orderId: 0,
orderNumber: batchId,
serviceSlug: "compliance-batch",
customerName: (order.customer_name as string) || "",
customerEmail: (order.customer_email as string) || "",
orderAmountCents: totalCents,
discountCents: (updated.rows as any[]).reduce((sum, r) => sum + (Number(r.discount_cents) || 0), 0),
});
console.log(`[checkout] Commission created for batch ${batchId} via ${discountCode}`);
}
} catch (commErr) {
console.warn("[checkout] Commission creation failed (non-fatal):", commErr);
}
}
// ── Advance compliance order workflow (queues document generation) ────────
if (order_type === "compliance") {
const soName = (order.erpnext_sales_order as string) || null;
if (soName) {
try {
await callMethod("frappe.client.set_value", {
doctype: "Sales Order",
name: soName,
fieldname: "workflow_state",
value: "Service Queued",
});
console.log(`[checkout] Advanced compliance Sales Order ${soName} to Service Queued`);
} catch (wfErr) {
console.error(`[checkout] Compliance workflow advance failed for ${soName}:`, wfErr);
}
}
// ── Dispatch to worker directly (do not depend on an ERPNext Webhook) ──
// The worker service is the source of truth for fulfilment; we call it
// straight from here so a missing Frappe Webhook fixture never strands
// a paid order.
const serviceSlug = (order.service_slug as string) || "";
const workerUrl = process.env.WORKER_URL || "http://workers:8090";
setImmediate(async () => {
try {
const dispatchRes = await fetch(`${workerUrl}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "process_compliance_service",
order_name: soName,
order_number: order_id,
service_slug: serviceSlug,
}),
});
if (!dispatchRes.ok) {
console.error(
`[checkout] Worker dispatch failed for ${order_id}: HTTP ${dispatchRes.status}`,
);
} else {
console.log(`[checkout] Worker dispatched: ${order_id} (${serviceSlug})`);
}
} catch (dispatchErr) {
console.error(`[checkout] Worker dispatch error for ${order_id}:`, dispatchErr);
}
});
// Create invoice (non-blocking)
if (soName) {
setImmediate(async () => {
try {
const surchargePct = (order.surcharge_pct as number) || 0;
const totalCents = (order.service_fee_cents as number) || 0;
const invoiceName = await createInvoiceFromSalesOrder({
salesOrderName: soName,
paymentGateway: paymentMethod,
surchargePercent: surchargePct,
paymentReference: session_id,
paidAmountCents: totalCents,
externalOrderId: order_id,
});
if (invoiceName) {
console.log(`[checkout] Compliance invoice ${invoiceName} created for ${order_id}`);
}
// Close the Sales Order so the portal doesn't show "To Deliver"
// with a pay button. Services don't need delivery — once billed,
// the SO should be Closed.
try {
await callMethod(
"erpnext.selling.doctype.sales_order.sales_order.update_status",
{ status: "Closed", name: soName },
);
console.log(`[checkout] Closed SO ${soName} (no delivery needed for services)`);
} catch (closeErr) {
// Non-fatal — the SO will still work, just shows "To Deliver" in portal
console.warn(`[checkout] Could not close SO ${soName}:`, closeErr);
}
} catch (invErr) {
console.warn("[checkout] Compliance invoice creation failed (non-blocking):", invErr);
}
});
}
}
// ── Create ERPNext Sales Invoice + Payment Entry (non-blocking) ──────────
if (order_type === "canada_crtc") {
const soName = (order.erpnext_sales_order as string) || null;
const totalCents = (order.total_cents as number) || 0;
const surchargePct = (order.payment_method as string) === "card" ? 3
: (order.payment_method as string) === "klarna" ? 5
: (order.payment_method as string) === "paypal" ? 3
: 0;
if (soName) {
setImmediate(async () => {
try {
const invoiceName = await createInvoiceFromSalesOrder({
salesOrderName: soName,
paymentGateway: paymentMethod,
surchargePercent: surchargePct,
paymentReference: session_id,
paidAmountCents: totalCents,
externalOrderId: order_id,
});
if (invoiceName) {
console.log(`[checkout] Invoice ${invoiceName} created for ${order_id}`);
}
} catch (invErr) {
console.warn("[checkout] Invoice creation failed (non-blocking):", invErr);
}
});
}
}
// ── Send order confirmation email ──────────────────────────────────────
// Skip for compliance_batch — sendComplianceIntakeEmail already sent
// a combined confirmation + intake email above.
if (order_type === "compliance_batch") {
console.log(`[checkout] Skipping generic confirmation for ${order_id} — intake email already sent`);
} else try {
await sendOrderConfirmationEmail({
order_id,
order_type,
customer_email: (order.customer_email as string) || "",
customer_name: (order.customer_name as string) || "",
session_id,
});
} catch (emailErr) {
console.error("[checkout] Confirmation email failed (non-blocking):", emailErr);
}
}
// ── Stripe Issuing topup — transfer from available payments balance ──────────
export async function topupIssuingBalance(amountCents: number, orderId: string): Promise<string | null> {
if (!stripe) return null;
try {
const topup = await stripe.topups.create({
amount: amountCents,
currency: "usd",
description: `Fund Issuing for order ${orderId}`,
metadata: { order_id: orderId },
// destination_balance: "issuing" — available via raw API, not in SDK types yet
} as any);
console.log(`[checkout] Stripe Issuing topup ${topup.id}: $${(amountCents / 100).toFixed(2)} for ${orderId}`);
return topup.id;
} catch (err) {
console.error(`[checkout] Stripe Issuing topup failed for ${orderId}:`, err);
return null;
}
}
// ── Advance CRTC order to "Client Selection" after funds become available ────
export async function advanceToClientSelection(orderId: string): Promise<void> {
// Get order details
const { rows } = await pool.query(
`SELECT order_number, customer_email, customer_name, erpnext_sales_order, payment_method,
funds_available, amb_annual_price_cents
FROM canada_crtc_orders WHERE order_number = $1`,
[orderId],
);
if (!rows.length) return;
const order = rows[0] as Record<string, unknown>;
if (order.funds_available) return; // already done
// Mark funds available
await pool.query(
`UPDATE canada_crtc_orders SET funds_available = TRUE, funds_available_at = NOW() WHERE order_number = $1`,
[orderId],
);
// For Stripe payments: topup Issuing balance with vendor expense amount
const paymentMethod = (order.payment_method as string) || "card";
if (["card", "ach", "klarna"].includes(paymentMethod)) {
// Vendor expenses: BC Registry fee (~$277 USD) + annual mailbox cost
const bcRegistryEstimate = 27700; // ~C$350 → US$277 at 0.72 rate + buffer
const mailboxCost = (order.amb_annual_price_cents as number) || 0;
const topupAmount = bcRegistryEstimate + mailboxCost + 5000; // +$50 buffer
const topupId = await topupIssuingBalance(topupAmount, orderId);
if (topupId) {
await pool.query(
`UPDATE canada_crtc_orders SET stripe_topup_id = $1 WHERE order_number = $2`,
[topupId, orderId],
);
}
}
// Advance ERPNext workflow to "Client Selection"
const soName = (order.erpnext_sales_order as string) || null;
if (soName) {
try {
await callMethod("frappe.client.set_value", {
doctype: "Sales Order",
name: soName,
fieldname: "workflow_state",
value: "Client Selection",
});
console.log(`[checkout] Advanced Sales Order ${soName} to Client Selection`);
} catch (wfErr) {
console.error(`[checkout] Workflow advance to Client Selection failed:`, wfErr);
}
}
// Send portal setup email
try {
await sendClientSelectionEmail(orderId, order);
} catch (emailErr) {
console.error("[checkout] Client selection email failed:", emailErr);
}
console.log(`[checkout] Order ${orderId} advanced to Client Selection`);
}
// ── Send "Your carrier setup is ready" portal email ─────────────────────────
async function sendClientSelectionEmail(orderId: string, order: Record<string, unknown>): Promise<void> {
const email = (order.customer_email as string) || "";
const name = (order.customer_name as string) || "there";
if (!email) return;
// Generate portal token
const { generatePortalToken, portalUrl } = await import("../middleware/portalAuth.js");
const token = generatePortalToken(orderId, "canada_crtc", email);
const DOMAIN = `https://${process.env.DOMAIN || "performancewest.net"}`;
const setupUrl = `${DOMAIN}/portal/setup?order=${orderId}&token=${token}`;
const { sendEmail } = await import("../email.js");
await sendEmail({
to: email,
subject: `Your carrier setup is ready — choose your mailbox and phone number`,
html: `
<p>Hi ${name},</p>
<p>Great news — your order <strong>${orderId}</strong> has been funded and we're ready to begin setup.</p>
<p>Before we can proceed, we need you to select:</p>
<ul>
<li><strong>Your mailbox unit number</strong> at the BC location you chose</li>
<li><strong>Your Canadian phone number (DID)</strong> from available BC numbers</li>
</ul>
<p>Click the button below to make your selections:</p>
<p style="margin:24px 0"><a href="${setupUrl}" style="display:inline-block;background:#1e3a5f;color:#fff;font-size:15px;font-weight:600;padding:14px 32px;border-radius:8px;text-decoration:none;">Set Up My Carrier</a></p>
<p>This link expires in 72 hours. If you need a new link, contact us at <a href="${DOMAIN}/contact">our support page</a>.</p>
<p>Best regards,<br/>Performance West Inc.</p>
`,
text: `Hi ${name},\n\nYour order ${orderId} is ready for setup. Visit this link to choose your mailbox and phone number:\n${setupUrl}\n\nPerformance West Inc.`,
});
await pool.query(
`UPDATE canada_crtc_orders SET client_selection_email_sent_at = NOW() WHERE order_number = $1`,
[orderId],
);
console.log(`[checkout] Sent client selection email to ${email} for ${orderId}`);
}
// ── GET /api/v1/checkout/crypto-available — list available cryptocurrencies ───
router.get("/api/v1/checkout/crypto-available", async (_req, res) => {
const shkeeperUrl = process.env.SHKEEPER_URL || "http://127.0.0.1:5000";
try {
const r = await fetch(`${shkeeperUrl}/api/v1/crypto`);
const data = await r.json() as { crypto?: string[]; crypto_list?: Array<{ name: string; display_name: string }> };
const DISPLAY_NAMES: Record<string, string> = {
BTC: "Bitcoin", ETH: "Ethereum", LTC: "Litecoin", DOGE: "Dogecoin",
BNB: "BNB (BSC)", MATIC: "Polygon", TRX: "Tron",
"ETH-USDT": "USDT (Ethereum)", "ETH-USDC": "USDC (Ethereum)",
"TRX-USDT": "USDT (Tron)", "TRX-USDC": "USDC (Tron)",
"BNB-USDT": "USDT (BSC)", "BNB-USDC": "USDC (BSC)",
};
const list = (data.crypto_list || (data.crypto || []).map(c => ({ name: c, display_name: DISPLAY_NAMES[c] || c })));
// Sort: stablecoins first, then by preference
const ORDER = ["ETH-USDT","TRX-USDT","ETH-USDC","BNB-USDT","BTC","ETH","LTC","BNB","MATIC","DOGE","TRX"];
list.sort((a, b) => {
const ai = ORDER.indexOf(a.name); const bi = ORDER.indexOf(b.name);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
res.json({ cryptos: list });
} catch (err) {
console.error("[checkout] crypto-available error:", err);
res.status(502).json({ error: "Crypto gateway unavailable" });
}
});
// ── POST /api/v1/checkout/crypto-invoice — create invoice for chosen coin ────
router.post("/api/v1/checkout/crypto-invoice", async (req, res) => {
const { order_id, order_type, crypto_name, amount } = req.body as {
order_id?: string; order_type?: string; crypto_name?: string; amount?: string;
};
if (!order_id || !crypto_name || !amount) {
res.status(400).json({ error: "order_id, crypto_name, and amount required" }); return;
}
const shkeeperUrl = process.env.SHKEEPER_URL || "http://127.0.0.1:5000";
const shkeeperKey = process.env.SHKEEPER_API_KEY || "";
const DOMAIN = `https://${process.env.DOMAIN || "performancewest.net"}`;
const callbackUrl = `${DOMAIN}/api/v1/webhooks/shkeeper`;
try {
const invoiceRes = await fetch(`${shkeeperUrl}/api/v1/${crypto_name}/payment_request`, {
method: "POST",
headers: { "X-Shkeeper-Api-Key": shkeeperKey, "Content-Type": "application/json" },
body: JSON.stringify({ external_id: order_id, fiat: "USD", amount, callback_url: callbackUrl }),
});
const inv = await invoiceRes.json() as {
status?: string; id?: number; wallet?: string; amount?: string;
display_name?: string; exchange_rate?: string; recalculate_after?: number;
};
if (inv.status !== "success" || !inv.wallet) {
console.error("[checkout] SHKeeper invoice failed:", inv);
res.status(502).json({ error: "Failed to create invoice for this cryptocurrency. Please try another." }); return;
}
// Store details
const table = (order_type === "formation" ? "formation_orders"
: order_type === "bundle" ? "bundle_orders"
: "canada_crtc_orders");
await pool.query(
`UPDATE ${table} SET crypto_details = $1 WHERE order_number = $2`,
[JSON.stringify({
crypto_name, display_name: inv.display_name, amount: inv.amount,
wallet: inv.wallet, exchange_rate: inv.exchange_rate, invoice_id: inv.id,
total_cents: Math.round(parseFloat(amount) * 100),
}), order_id],
);
console.log(`[checkout] SHKeeper invoice: ${crypto_name} ${inv.amount}${inv.wallet} (order ${order_id})`);
res.json({
crypto_name,
display_name: inv.display_name,
amount: inv.amount,
wallet: inv.wallet,
exchange_rate: inv.exchange_rate,
});
} catch (err) {
console.error("[checkout] crypto-invoice error:", err);
res.status(502).json({ error: "Crypto gateway unavailable" });
}
});
// ── GET /api/v1/checkout/crypto-details — retrieve stored crypto payment info ─
router.get("/api/v1/checkout/crypto-details", async (req, res) => {
const order_id = (req.query.order_id as string) || "";
if (!order_id) { res.status(400).json({ error: "order_id required" }); return; }
try {
// Check all order tables
for (const table of ["canada_crtc_orders", "formation_orders", "bundle_orders"]) {
const { rows } = await pool.query(
`SELECT crypto_details, payment_status FROM ${table} WHERE order_number = $1`,
[order_id],
);
if (rows.length && rows[0].crypto_details) {
const details = rows[0].crypto_details as Record<string, unknown>;
res.json({
crypto: {
name: details.crypto_name,
display_name: details.display_name,
amount: details.amount,
wallet: details.wallet,
exchange_rate: details.exchange_rate,
},
total_cents: details.total_cents,
payment_status: rows[0].payment_status,
});
return;
}
}
res.status(404).json({ error: "Order not found or no crypto payment details" });
} catch (err: any) {
console.error("[checkout] crypto-details error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// ─── Compliance intake email ────────────────────────────────────────────────
async function sendComplianceIntakeEmail(
orderId: string,
orderType: string,
orders: Record<string, unknown>[],
): Promise<void> {
if (!orders.length) return;
const customerEmail = (orders[0].customer_email as string) || "";
const customerName = (orders[0].customer_name as string) || "";
if (!customerEmail) return;
const firstName = customerName.split(" ")[0] || customerName;
let intake: Record<string, unknown> = {};
if (orders[0].intake_data) {
try {
intake = typeof orders[0].intake_data === "string"
? JSON.parse(orders[0].intake_data as string)
: orders[0].intake_data as Record<string, unknown>;
} catch (parseErr) {
console.error(`[checkout] intake_data JSON parse failed for ${orderId}:`, parseErr);
}
}
const entityName = (intake.entity_name as string) || customerName;
const frn = (intake.frn as string) || "";
// Build service list
const serviceLines = orders
.map(o => `<li style="margin:4px 0;font-size:14px;color:#374151;">${o.service_name}</li>`)
.join("\n");
// Check if any 499-A service is included
const has499 = orders.some(o =>
((o.service_slug as string) || "").includes("499") ||
((o.service_name as string) || "").toLowerCase().includes("499"),
);
const SITE_DOMAIN = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "https://performancewest.net";
const confirmUrl = `${SITE_DOMAIN}/order/success?action=usac_delegation&order_id=${orderId}`;
const usacSection = has499 ? `
<div style="background:#fef3c7;border:1px solid #fcd34d;border-radius:8px;padding:16px 20px;margin:20px 0;">
<p style="margin:0 0 8px;font-size:14px;font-weight:700;color:#92400e;">Action Required: USAC E-File Delegation</p>
<p style="margin:0 0 12px;font-size:13px;color:#78350f;line-height:1.5;">
To prepare and file your Form 499-A, we need read access to your USAC E-File account.
Please delegate access to our filing agent:
</p>
<ol style="margin:0 0 12px;padding-left:20px;font-size:13px;color:#78350f;line-height:1.7;">
<li>Log in to <a href="https://forms.universalservice.org" style="color:#1e40af;">USAC E-File</a></li>
<li>Go to <strong>Account Settings → Delegate Access</strong></li>
<li>Add delegate email: <strong>filings@performancewest.net</strong></li>
<li>Grant <strong>read and file</strong> permissions</li>
</ol>
<p style="margin:12px 0;text-align:center;">
<a href="${confirmUrl}" style="display:inline-block;background:#1a2744;color:#ffffff;font-weight:700;padding:12px 28px;border-radius:8px;text-decoration:none;font-size:14px;">
I've completed the delegation &rarr;
</a>
</p>
<p style="margin:8px 0 0;font-size:12px;color:#92400e;text-align:center;">
Clicking this button notifies our team that delegation is complete so we can begin your filing.
</p>
<p style="margin:8px 0 0;font-size:12px;color:#92400e;">
If you have multiple years of missed filings, each year must be filed separately.
We will review your history and contact you with pricing for any additional years.
</p>
</div>` : "";
const { sendEmail } = await import("../email.js");
await sendEmail({
to: customerEmail,
subject: `Next Steps — ${entityName || "Your"} FCC Compliance Order`,
html: `<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#eef0f3;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f3;padding:20px 0;">
<tr><td align="center">
<table width="620" cellpadding="0" cellspacing="0" style="width:620px;max-width:620px;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<tr><td style="background:#1a2744;padding:18px 40px 14px;">
<span style="color:#8fa8d0;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;">Performance West — Compliance Services</span>
</td></tr>
<tr><td style="background:#059669;height:3px;font-size:0;">&nbsp;</td></tr>
<tr><td style="padding:32px 40px;">
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;">We're Getting Started</h1>
<p style="margin:0 0 20px;font-size:15px;color:#6b7280;">Hi ${firstName}, thank you for your order. Here's what happens next.</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;margin-bottom:20px;">
<tr><td style="padding:16px 20px;">
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;">Order</p>
<p style="margin:0;font-size:14px;font-weight:600;color:#1a2744;font-family:monospace;">${orderId}</p>
</td></tr>
${entityName ? `<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;">Entity</p>
<p style="margin:0;font-size:14px;color:#111827;">${entityName}${frn ? ` (FRN: ${frn})` : ""}</p>
</td></tr>` : ""}
<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
<p style="margin:0 0 8px;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;">Services Ordered</p>
<ul style="margin:0;padding-left:18px;">${serviceLines}</ul>
</td></tr>
</table>
${usacSection}
<h2 style="margin:24px 0 8px;font-size:16px;font-weight:700;color:#111827;">What to Expect</h2>
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">1.</span> We will review your compliance status within 1 business day.</p>
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">2.</span> If we need any additional information, we will email you.</p>
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">3.</span> You will receive a confirmation for each filing as it is completed.</p>
${has499 ? `<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">4.</span> For 499-A filings, complete the USAC delegation above and click the confirmation button.</p>` : ""}
<p style="margin:20px 0 0;font-size:13px;color:#9ca3af;">
Questions? Contact us at
<a href="mailto:info@performancewest.net" style="color:#1e40af;">info@performancewest.net</a>
or <a href="tel:+18884110383" style="color:#1e40af;">1-888-411-0383</a>.
</p>
</td></tr>
<tr><td style="background:#f4f5f7;border-top:1px solid #e8ecf0;padding:16px 40px;text-align:center;">
<p style="margin:0;font-size:11px;color:#9ca3af;">Performance West Inc. &middot; performancewest.net &middot; 1-888-411-0383</p>
</td></tr>
</table>
</td></tr></table>
</body></html>`,
});
console.log(`[checkout] Compliance intake email sent to ${customerEmail} for ${orderId}`);
}
export default router;