new-site/api/src/routes/checkout.ts
justin 9c87759501 auth: make ERPNext the single source of truth for customer passwords
Customer portal login previously checked a bcrypt customers.password_hash
in Postgres, while portal.performancewest.net validated against ERPNext —
two stores that drifted (the Paul Wilson lockout). Consolidate on ERPNext:

- erpnext-client: add verifyWebsiteUserPassword() — delegates the credential
  check to Frappe /api/method/login (Host header = site name; 200=ok,401=bad).
- portal-auth /login: verify against ERPNext, then mint the pw_customer cookie.
- portal-auth /register: create+set the ERPNext password (authority) and upsert
  a password-less customers profile row; takeover guard still honors any legacy
  PG password until the column is dropped.
- portal-auth /reset-password + /forgot-password: write the new password to
  ERPNext; forgot-password now also works for ERPNext-only users (creates the
  PG profile row on demand).
- Legacy customers with only a PG bcrypt password reset via forgot-password.
- checkout: refresh the stale comment (customers row is now a profile, no pw).

Build + typecheck green.
2026-06-17 10:09:32 -05:00

2777 lines
121 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 formation_orders ADD COLUMN IF NOT EXISTS erpnext_sales_order TEXT`);
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 };
}
/**
* Ensure a compliance customer has an ERPNext portal account and persist the
* `portal_user_created` flag on their order rows.
*
* ERPNext (portal.performancewest.net) is the single customer portal. Normal
* Stripe checkout provisions the Website User up-front via findOrCreateCustomer,
* but PayPal / crypto / remediation-pipeline orders reach handlePaymentComplete
* without ever creating one — leaving those customers unable to log in. This
* runs in that shared post-payment path so EVERY paid compliance order gets a
* portal account regardless of how it was created or paid. Fully idempotent:
* ensureWebsiteUser no-ops if the User already exists, and we only flip the
* flag (which gates the delivery worker's set-password invite) the first time.
*/
async function ensureCompliancePortalUser(
orderId: string,
orderType: string,
rows: Record<string, unknown>[],
): Promise<void> {
const first = rows[0];
if (!first) return;
const email = ((first.customer_email as string) || "").toLowerCase().trim();
const name = (first.customer_name as string) || email.split("@")[0] || "Customer";
// Company: compliance_orders has no customer_company column; it lives in
// intake_data. Fall back gracefully across order types.
let company: string | null = (first.customer_company as string) || null;
if (!company && first.intake_data) {
try {
const intake = typeof first.intake_data === "string"
? JSON.parse(first.intake_data as string)
: (first.intake_data as Record<string, unknown>);
company = (intake.company as string) || (intake.legal_name as string)
|| (intake.entity_name as string) || null;
} catch { /* ignore */ }
}
if (!email) return;
// (No address suppression here. `synthetic@pipeline.com` is a real customer
// address (EarthLink/pipeline.com), not a placeholder -- provisioning + email
// proceed normally. Only RFC-reserved test domains are rejected upstream at
// order creation (emailError in compliance-orders.ts).)
// ── Portal profile row (Postgres `customers`) ───────────────────────
// ERPNext is the single source of truth for portal passwords (see
// portal-auth.ts). This Postgres `customers` row is just the PG-side
// profile + order-linking record (customer_id FK) and carries NO password.
// We create it here so the customer can immediately register/reset (which
// writes the password to ERPNext) and so order routes that join on
// customer_id work. The Stripe path historically relied on the customer
// self-registering; PayPal/crypto orders reach here directly and otherwise
// had NO customers row, which is why PayPal customers could not log in or
// reset their password. Idempotent. See docs / Paul Wilson incident
// 2026-06-09.
try {
await pool.query(
`INSERT INTO customers (email, name, company)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET
name = COALESCE(customers.name, EXCLUDED.name),
company = COALESCE(customers.company, EXCLUDED.company)`,
[email, name, company],
);
} catch (custErr) {
console.warn("[checkout] Could not upsert portal customers row:", custErr);
}
// Provision the ERPNext Website User (idempotent) and link to the Customer.
const { portalUserCreated } = await findOrCreateCustomer(email, name);
// Persist the flag so the success page + delivery worker surface the
// set-password onboarding. Set it whenever the account is new; harmless to
// re-set. Update every row for the batch (match by batch_id) or the single
// order.
if (portalUserCreated) {
const table = orderType === "compliance_batch" || orderType === "compliance"
? "compliance_orders"
: null;
if (table) {
try {
if (orderType === "compliance_batch") {
await pool.query(
`UPDATE ${table} SET portal_user_created = TRUE WHERE batch_id = $1`,
[orderId],
);
} else {
await pool.query(
`UPDATE ${table} SET portal_user_created = TRUE WHERE order_number = $1`,
[orderId],
);
}
} catch (pgErr) {
console.warn("[checkout] Could not persist portal_user_created flag:", pgErr);
}
}
}
}
/**
* 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",
]);
/**
* Create the ERPNext Sales Order for a compliance / compliance_batch order on
* payment completion. Idempotent: skips if the order(s) already have
* erpnext_sales_order set. Called from handlePaymentComplete so EVERY payment
* method (Stripe webhook, PayPal, crypto) creates the SO -- previously only the
* /checkout/create-session path did, so webhook-confirmed card orders had no SO
* and the workers logged "Sales Order not found 404".
*/
export async function ensureComplianceSalesOrder(
orderId: string,
orderType: string,
rows: Record<string, unknown>[],
paymentMethod: string,
): Promise<void> {
if (orderType !== "compliance" && orderType !== "compliance_batch") return;
if (!rows.length) return;
// Already created? (any row carrying the SO id) -> idempotent skip.
if (rows.some(r => r.erpnext_sales_order)) return;
const first = rows[0];
const email = ((first.customer_email as string) || "").toLowerCase().trim();
const name = (first.customer_name as string) || email.split("@")[0] || "Customer";
// NOTE: unlike portal provisioning, we do NOT skip the FMCSA-census placeholder
// email here -- a Sales Order is internal bookkeeping (not an outbound email),
// and some real customers genuinely use that address. We only need a non-empty
// email to attach an ERPNext Customer.
if (!email) return;
const { customerName: erpnextCustomer } = await findOrCreateCustomer(email, name);
if (!erpnextCustomer) return;
const { COMPLIANCE_SERVICES } = await import("./compliance-orders.js");
const surchargePct = Number(first.surcharge_pct || 0);
let surchargeCents = 0;
let discountCents = 0;
const lineItems = rows.map((o: Record<string, any>) => {
const info = COMPLIANCE_SERVICES[(o.service_slug as string) || ""];
surchargeCents += Number(o.surcharge_cents || 0);
discountCents += Number(o.discount_cents || 0);
const items: Array<{ item_code: string; description: string; qty: number; rate: number }> = [{
item_code: info?.erpnext_item || "COMPLIANCE-SERVICE",
description: (o.service_name as string) || info?.name || "Compliance Service",
qty: 1,
rate: toDollars((o.service_fee_cents as number) || 0),
}];
const govCents = (o.gov_fee_cents as number) || 0;
if (govCents > 0) {
items.push({
item_code: "GOVERNMENT-FILING-FEE",
description: (o.gov_fee_label as string) || "Government filing fee",
qty: 1,
rate: toDollars(govCents),
});
}
return items;
}).flat();
if (surchargeCents > 0) {
lineItems.push({
item_code: "PAYMENT-PROCESSING-FEE",
description: `${GATEWAY_LABELS[paymentMethod] || paymentMethod} surcharge`,
qty: 1,
rate: toDollars(surchargeCents),
});
}
const so = (await createResource("Sales Order", {
customer: erpnextCustomer,
delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
custom_external_order_id: orderId,
custom_order_type: "compliance",
custom_payment_gateway: GATEWAY_LABELS[paymentMethod] || paymentMethod,
custom_surcharge_pct: surchargePct,
workflow_state: "Received",
items: lineItems,
// Reflect the promo/bundle discount so the SO grand total matches what the
// customer actually paid (line items are full price; discount applied here).
...(discountCents > 0 ? { apply_discount_on: "Grand Total", discount_amount: toDollars(discountCents) } : {}),
}).catch(async (e: unknown) => {
// Resilience: if a service's ERPNext Item is missing, the SO would 404.
// Retry once with every line item remapped to the generic COMPLIANCE-SERVICE
// item so a missing catalog Item never strands a paid order's SO. (The
// catalog Item should still be created; this is a safety net.)
const msg = String((e as { body?: { _server_messages?: string } })?.body?._server_messages || e);
if (/not found/i.test(msg)) {
console.warn(`[checkout] SO item missing for ${orderId}, retrying with generic item:`, msg.slice(0, 120));
const fallback = lineItems.map(li => ({ ...li, item_code: "COMPLIANCE-SERVICE" }));
return createResource("Sales Order", {
customer: erpnextCustomer,
delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
custom_external_order_id: orderId,
custom_order_type: "compliance",
custom_payment_gateway: GATEWAY_LABELS[paymentMethod] || paymentMethod,
custom_surcharge_pct: surchargePct,
workflow_state: "Received",
items: fallback,
...(discountCents > 0 ? { apply_discount_on: "Grand Total", discount_amount: toDollars(discountCents) } : {}),
});
}
throw e;
})) 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 */ }
// Link the SO back to the order row(s): batch by batch_id, single by order_number.
if (orderType === "compliance_batch") {
await pool.query(`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE batch_id = $2`, [so.name, orderId]);
} else {
await pool.query(`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE order_number = $2`, [so.name, orderId]);
}
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for ${orderType} ${orderId} (${lineItems.length} line items)`);
}
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) {
// Check if the order exists but is already paid (e.g. free order, duplicate submit)
try {
const tables: Record<string, {table: string; col: string}> = {
canada_crtc: { table: "canada_crtc_orders", col: "order_number" },
formation: { table: "formation_orders", col: "order_number" },
bundle: { table: "bundle_orders", col: "order_number" },
compliance: { table: "compliance_orders", col: "order_number" },
compliance_batch: { table: "compliance_orders", col: "batch_id" },
};
const t = tables[order_type];
if (t) {
const { rows } = await pool.query(
`SELECT payment_status, payment_method FROM ${t.table} WHERE ${t.col} = $1 LIMIT 1`,
[order_id],
);
if (rows.length > 0 && rows[0].payment_status === "paid") {
// Already paid — redirect to success
const isFree = rows[0].payment_method === "free";
console.log(`[checkout] Order ${order_id} already paid (${rows[0].payment_method}) — returning success redirect`);
res.json({
checkout_url: `${DOMAIN}/order/success?order_id=${order_id}&order_type=${order_type}${isFree ? "&free=1" : ""}`,
session_id: null,
total_cents: 0,
surcharge_cents: 0,
surcharge_pct: 0,
});
return;
}
}
} catch { /* fall through to 404 */ }
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 ERPNext Sales Order (compliance BATCH) ───────────────────────
// A batch (CB-XXXX) is one customer paying for several services at once, so
// it becomes ONE Sales Order with a line item per service (plus the
// processing-fee line). Previously batches created no SO at all, so trucking
// new-carrier orders (which always use the batch path) never reached ERPNext.
if (order_type === "compliance_batch" && erpnextCustomer) {
try {
const { COMPLIANCE_SERVICES } = await import("./compliance-orders.js");
const { rows: batchRows } = await pool.query(
`SELECT * FROM compliance_orders WHERE batch_id = $1 ORDER BY created_at`,
[order_id],
);
const lineItems = batchRows.map((o: Record<string, any>) => {
const info = COMPLIANCE_SERVICES[(o.service_slug as string) || ""];
const svcCents = (o.service_fee_cents as number) || 0;
const govCents = (o.gov_fee_cents as number) || 0;
const items: Array<{ item_code: string; description: string; qty: number; rate: number }> = [{
item_code: info?.erpnext_item || "COMPLIANCE-SERVICE",
description: (o.service_name as string) || info?.name || "Compliance Service",
qty: 1,
rate: toDollars(svcCents),
}];
if (govCents > 0) {
items.push({
item_code: "GOVERNMENT-FILING-FEE",
description: (o.gov_fee_label as string) || "Government filing fee",
qty: 1,
rate: toDollars(govCents),
});
}
return items;
}).flat();
if (surcharge_cents > 0) {
lineItems.push({
item_code: "PAYMENT-PROCESSING-FEE",
description: `${GATEWAY_LABELS[payment_method] || payment_method} ${surcharge_pct}%`,
qty: 1,
rate: toDollars(surcharge_cents),
});
}
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: lineItems,
})) 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 batch_id = $2`,
[so.name, order_id],
);
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for batch ${order_id} (${lineItems.length} line items)`);
} catch (soErr) {
console.warn("[checkout] Batch Sales Order creation failed (non-blocking):", soErr);
}
}
// ── Create ERPNext Sales Order (US business formation) ──────────────────
if (order_type === "formation" && erpnextCustomer) {
try {
const pgOrder = orderData.order as Record<string, any>;
const entityType = (pgOrder.entity_type as string) || "LLC";
const stateCode = (pgOrder.state_code as string) || "";
const stateFeeCents = (pgOrder.state_fee_cents as number) || 0;
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: "formation",
custom_payment_gateway: GATEWAY_LABELS[payment_method] || payment_method,
custom_surcharge_pct: surcharge_pct,
workflow_state: "Received",
items: [{
item_code: "BUSINESS-FORMATION",
description: `${entityType} Formation${stateCode ? `${stateCode}` : ""}`,
qty: 1,
rate: toDollars((pgOrder.service_fee_cents as number) || base_cents - stateFeeCents - surcharge_cents),
}, ...(stateFeeCents > 0 ? [{
item_code: "STATE-FILING-FEE",
description: `${stateCode} state filing fee (government fee)`,
qty: 1,
rate: toDollars(stateFeeCents),
}] : []), ...(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 formation_orders SET erpnext_sales_order = $1 WHERE order_number = $2`,
[so.name, order_id],
);
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for formation ${order_id}`);
} catch (soErr) {
console.warn("[checkout] Formation Sales Order creation failed (non-blocking):", soErr);
}
}
// ── Create ERPNext Sales Order (FCC carrier / ISP registration) ─────────
if (order_type === "fcc_carrier_registration" && erpnextCustomer) {
try {
const pgOrder = orderData.order as Record<string, any>;
const items: Array<Record<string, unknown>> = [{
item_code: "FCC-CARRIER-REGISTRATION",
description: "FCC Carrier / ISP Registration",
qty: 1,
rate: toDollars((pgOrder.service_fee_cents as number) || 129900),
}];
const formationFee = ((pgOrder.formation_fee_cents as number) || 0) + ((pgOrder.state_fee_cents as number) || 0);
if (formationFee > 0) {
items.push({
item_code: "BUSINESS-FORMATION",
description: `Business Formation (${pgOrder.formation_state || "?"} ${((pgOrder.entity_type as string) || "LLC").toUpperCase()})`,
qty: 1,
rate: toDollars(formationFee),
});
}
if (pgOrder.include_stir_shaken) {
items.push({ item_code: "STIR-SHAKEN", description: "STIR/SHAKEN Implementation", qty: 1, rate: toDollars(49900) });
}
if (pgOrder.include_ocn) {
items.push({ item_code: "NECA-OCN", description: "NECA OCN Registration", qty: 1, rate: toDollars(265000) });
}
const pucCents = (pgOrder.puc_fee_cents as number) || 0;
if (pucCents > 0) {
const stateCount = ((pgOrder.state_puc_states as string[]) || []).length;
items.push({ item_code: "STATE-PUC-REGISTRATION", description: `State PUC Registration (${stateCount} state${stateCount !== 1 ? "s" : ""})`, qty: 1, rate: toDollars(pucCents) });
}
if (surcharge_cents > 0) {
items.push({ item_code: "PAYMENT-PROCESSING-FEE", description: `${GATEWAY_LABELS[payment_method] || payment_method} ${surcharge_pct}%`, qty: 1, rate: toDollars(surcharge_cents) });
}
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: "fcc_carrier_registration",
custom_payment_gateway: GATEWAY_LABELS[payment_method] || payment_method,
custom_surcharge_pct: surcharge_pct,
workflow_state: "Received",
items,
})) 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 fcc_carrier_registrations SET erpnext_sales_order = $1 WHERE order_number = $2`,
[so.name, order_id],
);
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for fcc_carrier_registration ${order_id}`);
} catch (soErr) {
console.warn("[checkout] FCC 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', paid_at = NOW(), 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})`);
// Send intake/confirmation email for free compliance orders (normally triggered by Stripe webhook)
if (order_type === "compliance_batch" || order_type === "compliance") {
try {
const { rows: updatedOrders } = await pool.query(
`SELECT * FROM compliance_orders WHERE ${order_type === "compliance_batch" ? "batch_id" : "order_number"} = $1`,
[order_id],
);
if (updatedOrders.length > 0) {
await sendComplianceIntakeEmail(order_id, order_type, updatedOrders);
}
} catch (emailErr) {
console.error("[checkout] Free order intake email failed (non-fatal):", emailErr);
}
}
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 Financial Connections: collect bank account details only.
// (We intentionally do NOT request the 'balances' permission: that
// requires activating the Financial Connections "balances" product in the
// Stripe dashboard, and without it Stripe rejects the whole session with
// an invalid_request_error. Plain payment_method collection is enough to
// charge ACH; verification_method:instant still does microdeposit-free
// instant verification where supported.)
...(payment_method === "ach" ? {
payment_method_options: {
us_bank_account: {
financial_connections: {
permissions: ["payment_method"],
},
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";
if (order_type === "compliance_batch") {
// A batch has ONE surcharge for the whole order, but it is stored per
// row. Writing the full surcharge_cents to every row makes anything that
// SUMS the per-row field (e.g. the Telegram order notification) over-
// count by Nx. Split the single surcharge across the rows so the per-row
// values sum to the true total (remainder on the first row).
const { rows: brows } = await pool.query(
`SELECT order_number FROM ${table} WHERE batch_id = $1 ORDER BY created_at`,
[order_id],
);
const n = brows.length || 1;
const per = Math.floor(surcharge_cents / n);
const remainder = surcharge_cents - per * n;
for (let i = 0; i < brows.length; i++) {
const rowSurcharge = per + (i === 0 ? remainder : 0);
await pool.query(
`UPDATE ${table}
SET stripe_session_id = $1, payment_method = $2,
surcharge_pct = $3, surcharge_cents = $4
WHERE order_number = $5`,
[session.id, payment_method, surcharge_pct, rowSurcharge, brows[i].order_number],
);
}
} else {
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}`);
// Prefer the payment gateway's recorded total when available. This keeps
// downstream notifications honest even if older rows still carry stale
// per-line surcharge values from before the batch-surcharge split fix.
let actualPaidCents: number | null = null;
if (stripe && session_id && !session_id.startsWith("crypto-")) {
try {
const session = await stripe.checkout.sessions.retrieve(session_id);
if (typeof session.amount_total === "number") {
actualPaidCents = session.amount_total;
}
} catch (stripeErr) {
console.warn(`[checkout] Could not retrieve Stripe session ${session_id} for notification totals:`, stripeErr);
}
}
// ── Telegram order notification ──────────────────────────────────────
try {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (botToken && chatId) {
// Aggregate the WHOLE batch — updated.rows holds every line item, not just one.
const rows = updated.rows as Record<string, unknown>[];
const customerName = (order.customer_name as string) || "Unknown";
const customerEmail = (order.customer_email as string) || "";
let subtotalCents = 0;
let discountCents = 0;
let surchargeCents = 0;
const serviceNames: string[] = [];
for (const r of rows) {
subtotalCents += Number(r.service_fee_cents || r.total_cents || 0) + Number(r.gov_fee_cents || 0);
discountCents += Number(r.discount_cents || 0);
surchargeCents += Number(r.surcharge_cents || 0);
serviceNames.push((r.service_name as string) || (r.service_slug as string) || "");
}
const totalCents = actualPaidCents ?? (subtotalCents - discountCents + surchargeCents);
const derivedSurchargeCents = totalCents - subtotalCents + discountCents;
if (derivedSurchargeCents >= 0) {
surchargeCents = derivedSurchargeCents;
}
const totalDollars = (totalCents / 100).toFixed(2);
const serviceLine = serviceNames.length <= 1
? `Service: ${serviceNames[0] || order_type}\n`
: `Services (${serviceNames.length}):\n • ${serviceNames.join("\n • ")}\n`;
const subtotalLine = `Subtotal: $${(subtotalCents / 100).toFixed(2)}\n`;
const discountLine = discountCents > 0
? `Discount: -$${(discountCents / 100).toFixed(2)} (${order.discount_code || "promo"})\n`
: "";
const surchargeLine = surchargeCents > 0
? `Card surcharge: +$${(surchargeCents / 100).toFixed(2)}\n`
: "";
// Show DOT# or FRN/CORES ID depending on service type
const intake = (typeof order.intake_data === "object" && order.intake_data) || {};
const dotNumber = (intake as any).dot_number || "";
const frn = (intake as any).frn || "";
const idLine = dotNumber ? `DOT#: ${dotNumber}\n`
: frn ? `FRN: ${frn}\n`
: "";
// Campaign-source hint: an order carrying the daily campaign coupon (or any
// discount code) almost certainly came from an email campaign. Surface it so
// you can see the IFTA/UCR/CLIA/cold-email pipelines actually converting.
const srcLine = order.discount_code
? `Source: campaign (code ${order.discount_code})\n`
: "";
const msg = `💰 NEW ORDER\n\n`
+ `Customer: ${customerName}\n`
+ `Email: ${customerEmail}\n`
+ idLine
+ serviceLine
+ srcLine
+ subtotalLine
+ discountLine
+ surchargeLine
+ `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") {
// ── Government-fee child order paid → resume the parent's filing ──────
// At-cost services (IRP/IFTA/intrastate) bill the state fee via a child
// order (parent_order_number set). When that child is paid, re-dispatch the
// PARENT to the worker with gov_fee_paid=true so it proceeds to file. The
// child itself needs none of the normal compliance post-processing.
const parentNo = (order.parent_order_number as string) || "";
if (parentNo) {
try {
const { rows: prows } = await pool.query(
"SELECT service_slug FROM compliance_orders WHERE order_number = $1",
[parentNo],
);
const parentSlug = prows[0]?.service_slug || "";
const workerUrl = process.env.WORKER_URL || "http://workers:8090";
await fetch(`${workerUrl}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "process_compliance_service",
order_name: parentNo,
order_number: parentNo,
service_slug: parentSlug,
client_approved: true, // authorization already signed earlier
gov_fee_paid: true,
}),
});
console.log(`[checkout] Gov-fee ${order_id} paid → re-dispatched parent ${parentNo} to file`);
} catch (e) {
console.error(`[checkout] Failed to resume parent after gov-fee ${order_id}:`, e);
}
return; // child order needs no further compliance processing
}
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);
}
// Ensure the customer has an ERPNext portal account so they can log in
// at portal.performancewest.net and see this order. ERPNext is the single
// portal. The Stripe checkout path creates the Website User up-front in
// findOrCreateCustomer(), but PayPal / crypto / remediation-pipeline paths
// come straight here and would otherwise skip it (this was the cause of
// customers who paid via PayPal being unable to log in). Idempotent.
try {
await ensureCompliancePortalUser(order_id, order_type, updated.rows);
} catch (portalErr) {
console.error("[checkout] Compliance portal-user provisioning failed (non-fatal):", portalErr);
}
// ── Create the ERPNext Sales Order (idempotent) ──────────────────────
// The /checkout/create-session endpoint creates the SO for flows that
// confirm there, but card payments confirm via the Stripe WEBHOOK -> this
// function, which previously did NOT create the SO. Result: every webhook-
// confirmed compliance order had erpnext_sales_order=NULL and the workers
// logged "Sales Order ... not found 404" and fell back to building from PG.
// Create it here for all payment methods. Skips if one already exists.
try {
await ensureComplianceSalesOrder(order_id, order_type, updated.rows, paymentMethod);
} catch (soErr) {
console.error("[checkout] Compliance Sales Order creation failed (non-fatal):", soErr);
}
}
// ── 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 { COMPLIANCE_SERVICES } = await import("./compliance-orders.js");
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;
// Map slug -> ERPNext item code (items use erpnext_item codes, not slugs)
const itemCode = (COMPLIANCE_SERVICES as any)[bo.service_slug as string]?.erpnext_item || "COMPLIANCE-SERVICE";
items.push({
item_code: itemCode,
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 the actual paid amount for the batch: service + gov fees
// - discounts + surcharge. Older versions passed only the pre-
// surcharge total, which understated the Payment Entry amount.
const batchBaseCents = (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 batchTotalCents = batchBaseCents + ((Number(order.surcharge_cents) || 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 ────────────────────────────────────────────────
export 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"),
);
// PECOS / NPPES orders need CMS I&A surrogate access (the healthcare analog
// of USAC E-File delegation). OIG/SAM screening needs no access (public DBs).
const PECOS_ACCESS_SLUGS = new Set<string>([
"npi-revalidation", "npi-reactivation", "nppes-update",
"medicare-enrollment", "provider-compliance-bundle",
]);
const npiAccessOrders = orders.filter(o => PECOS_ACCESS_SLUGS.has(o.service_slug as string));
const hasNpiAccess = npiAccessOrders.length > 0;
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>` : "";
// CMS filing for PECOS / NPPES orders. We file everything for the provider;
// the ONLY client-facing choice is the optional surrogate question, framed
// positively. We NEVER expose the mechanics (paper, CMS-855/10114, MAC, Fargo)
// and we NEVER tell them "the alternative is paper". If they grant I&A
// Surrogate access we file online same-day; if not, we file it for them via our
// Standard path silently. Same price either way — surrogate is just faster for
// us (fewer steps, lets us bulk-file). We never ask for their password.
const npiConfirmUrl = `${SITE_DOMAIN}/order/success?action=ia_surrogacy&order_id=${orderId}`;
// Did the provider opt in to granting CMS I&A Surrogate access on the intake
// form? If yes, surface the how-to prominently (it confirms the step they
// committed to). If no / undecided, we still make it available, but collapsed
// behind a "Show me how" expander and placed lower so it doesn't nag someone
// who already chose to let us handle everything.
const grantedSurrogate = String(intake.surrogate_access || "").toLowerCase() === "yes";
const surrogateSteps = `
<ol style="margin:0 0 12px;padding-left:20px;font-size:13px;color:#134e4a;line-height:1.7;">
<li>Log in to <a href="https://nppes.cms.hhs.gov/IAWeb/login.do" style="color:#0f766e;">CMS Identity &amp; Access (I&amp;A)</a></li>
<li>Go to <strong>My Connections &rarr; Add Surrogate</strong></li>
<li>Add surrogate organization: <strong>Performance West Inc.</strong> (email <strong>filings@performancewest.net</strong>)</li>
<li>Grant access for your NPI, then approve our request</li>
</ol>
<p style="margin:12px 0;text-align:center;">
<a href="${npiConfirmUrl}" style="display:inline-block;background:#0f766e;color:#ffffff;font-weight:700;padding:12px 28px;border-radius:8px;text-decoration:none;font-size:14px;">
I've granted surrogate access &rarr;
</a>
</p>`;
const npiSection = !hasNpiAccess ? "" : grantedSurrogate ? `
<div style="background:#ccfbf1;border:1px solid #5eead4;border-radius:8px;padding:16px 20px;margin:20px 0;">
<p style="margin:0 0 10px;font-size:14px;font-weight:700;color:#115e59;">We're handling your filing</p>
<p style="margin:0 0 12px;font-size:13px;color:#134e4a;line-height:1.5;">
You're all set &mdash; we prepare and submit your filing and track it to
confirmation. The only thing we may need from you is a quick signature on
a secure link we'll send.
</p>
<p style="margin:0 0 6px;font-size:13px;font-weight:700;color:#115e59;">You chose to grant Surrogate access &mdash; here's how (takes ~2 minutes)</p>
<p style="margin:0 0 12px;font-size:13px;color:#134e4a;line-height:1.5;">
This lets us file faster. You never share your password; you simply
authorize us as a Surrogate for your NPI.
</p>
${surrogateSteps}
<p style="margin:8px 0 0;font-size:12px;color:#115e59;text-align:center;">
Changed your mind? No problem &mdash; we'll handle your filing without it.
</p>
</div>` : `
<div style="background:#ccfbf1;border:1px solid #5eead4;border-radius:8px;padding:16px 20px;margin:20px 0;">
<p style="margin:0 0 10px;font-size:14px;font-weight:700;color:#115e59;">We're handling your filing</p>
<p style="margin:0;font-size:13px;color:#134e4a;line-height:1.5;">
You're all set &mdash; we prepare and submit your filing and track it to
confirmation. The only thing we may need from you is a quick signature on
a secure link we'll send. <strong>Nothing else to do.</strong>
</p>
</div>`;
// Optional speed-up block, shown lower in the email ONLY when they didn't
// already grant access. Collapsed behind a "Show me how" expander.
const npiSpeedupSection = (hasNpiAccess && !grantedSurrogate) ? `
<div style="background:#f0fdfa;border:1px solid #99f6e4;border-radius:8px;padding:16px 20px;margin:20px 0;">
<p style="margin:0 0 6px;font-size:13px;font-weight:700;color:#115e59;">Optional: want it filed even faster?</p>
<p style="margin:0 0 10px;font-size:13px;color:#134e4a;line-height:1.5;">
If you can electronically grant us <strong>CMS I&amp;A Surrogate access</strong>,
we can process your filing right away. It's never required &mdash; we'll file
it for you either way, at the same price. You never share your password.
</p>
<details style="margin:0;">
<summary style="cursor:pointer;color:#0f766e;font-weight:700;font-size:13px;">Show me how &rarr;</summary>
<div style="margin-top:10px;">
${surrogateSteps}
</div>
</details>
</div>` : "";
// Fully admin-assisted services with NO customer intake form. State-level
// trucking + hazmat/emissions now have a dedicated intake step, so they are
// NO LONGER in this set — customers get an intake link like other services.
const ADMIN_ASSISTED_SLUGS = new Set<string>([
// (reserved for any future no-intake services)
]);
const dotOrders = orders.filter(o => ADMIN_ASSISTED_SLUGS.has(o.service_slug as string));
const fccOrders = orders.filter(o => !ADMIN_ASSISTED_SLUGS.has(o.service_slug as string));
const isDotOnly = fccOrders.length === 0;
// Build intake form links for FCC services only
const intakeLinks = fccOrders.map(o => {
const slug = o.service_slug as string;
const orderNum = o.order_number as string;
const name = o.service_name as string;
const intakeUrl = `${SITE_DOMAIN}/order/${slug}?order=${orderNum}`;
return `<li style="margin:6px 0;"><a href="${intakeUrl}" style="color:#1e40af;font-weight:600;font-size:14px;text-decoration:underline;">${name}</a></li>`;
}).join("\n");
const intakeSection = fccOrders.length > 0 ? `
<div style="background:#eff6ff;border:2px solid #3b82f6;border-radius:8px;padding:20px;margin:20px 0;">
<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#1e3a5f;">Action Required: Complete Your Intake Form</p>
<p style="margin:0 0 12px;font-size:13px;color:#374151;line-height:1.5;">
Please fill out the intake form for each service so we have the information we need to prepare your filing.
</p>
<ul style="margin:0 0 12px;padding-left:18px;">${intakeLinks}</ul>
<p style="margin:0;font-size:12px;color:#6b7280;">This usually takes 2-5 minutes. We cannot begin your filing until the intake form is complete.</p>
</div>` : "";
const dotSection = dotOrders.length > 0 ? `
<div style="background:#fff7ed;border:2px solid #fdba74;border-radius:8px;padding:20px;margin:20px 0;">
<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#9a3412;">We're Working On It</p>
<p style="margin:0 0 12px;font-size:13px;color:#374151;line-height:1.5;">
The following services are being processed by our team. No further action is needed from you.
</p>
<ul style="margin:0 0 12px;padding-left:18px;">${dotOrders.map(o =>
`<li style="margin:4px 0;font-size:14px;color:#374151;">${o.service_name}</li>`
).join("\n")}</ul>
<p style="margin:0;font-size:12px;color:#6b7280;">You'll receive a confirmation email when your filing is complete, typically within 1 business day.</p>
</div>` : "";
const { sendEmail } = await import("../email.js");
await sendEmail({
to: customerEmail,
subject: isDotOnly
? `Order Confirmed — ${entityName || "Your"} DOT Compliance Order`
: `Action Required — ${entityName || "Your"} 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}
${npiSection}
${dotSection}
${intakeSection}
${npiSpeedupSection}
<h2 style="margin:24px 0 8px;font-size:16px;font-weight:700;color:#111827;">What to Expect</h2>
${isDotOnly ? `
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">1.</span> Our team is already working on your filing.</p>
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">2.</span> Most DOT filings are completed within 1 business day.</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 email when everything is filed.</p>
` : `
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">1.</span> Complete the intake form above so we have the details we need.</p>
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">2.</span> We will prepare your filing within 3-7 business days.</p>
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">3.</span> You will receive the document for review and electronic signature.</p>
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">4.</span> Once signed, we file it and send you confirmation.</p>
${has499 ? `<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">5.</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;