/** * 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 = { 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 = { 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 { 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[], ): Promise { 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); company = (intake.company as string) || (intake.legal_name as string) || (intake.entity_name as string) || null; } catch { /* ignore */ } } if (!email) return; // Skip only the genuine FMCSA-census placeholder, never a real customer who // happens to use these (real) consumer domains. The census placeholder is // exactly "synthetic@pipeline.com"; treat that one string as non-deliverable // and anything else as a real address. if (email === "synthetic@pipeline.com") { console.warn(`[checkout] Skipping portal provisioning for ${orderId}: FMCSA-census placeholder email`); return; } // ── Portal login account (Postgres `customers` row) ────────────────── // The portal login + forgot-password read the Postgres `customers` table // (bcrypt password_hash), NOT ERPNext. We create the row here (no password) // so the customer can immediately register/reset to set a password and log // in to track their order. 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 (ON CONFLICT keeps any existing // password_hash). 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", ]); async function grantCDRStudyAccess( order: Record, order_id: string, ): Promise { 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) || {}; 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; 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; 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; 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; 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; // 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[] = []; try { const result = await pool.query( `SELECT * FROM compliance_orders WHERE order_number = $1`, [order_id], ); rows = result.rows as Record[]; } 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; 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[] = []; 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[]; } 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; 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 = { 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; 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; 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) => { 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; 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; const items: Array> = [{ 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 = { 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 = { 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 = { canada_crtc: "canada_crtc_orders", formation: "formation_orders", bundle: "bundle_orders", compliance: "compliance_orders", compliance_batch: "compliance_orders", }; const table = tableMap[order_type]; if (table) { // For batch orders, update by batch_id; otherwise by order_number const whereCol = order_type === "compliance_batch" ? "batch_id" : "order_number"; await pool.query( `UPDATE ${table} SET stripe_session_id = $1, payment_method = $2, surcharge_pct = $3, surcharge_cents = $4 WHERE ${whereCol} = $5`, [session.id, payment_method, surcharge_pct, surcharge_cents, order_id], ); } console.log(`[checkout] Stripe session ${session.id} created for ${order_type} ${order_id}`); res.json({ checkout_url: session.url, session_id: session.id, total_cents, surcharge_cents, surcharge_pct, }); } catch (err) { console.error("[checkout] create-session error:", err); res.status(500).json({ error: "Failed to create checkout session" }); } }); // ─── GET /api/v1/checkout/session/:session_id ───────────────────────────────── // Poll this after customer returns from Stripe checkout. router.get("/api/v1/checkout/session/:session_id", async (req, res) => { if (!stripe) { res.status(503).json({ error: "Payment gateway not configured" }); return; } const { session_id } = req.params; if (!session_id) { res.status(400).json({ error: "session_id required" }); return; } // Crypto pseudo-sessions don't go through Stripe if (session_id.startsWith("crypto-")) { const order_id = session_id.replace("crypto-", ""); res.json({ status: "pending", session_id, order_id, order_type: "unknown", total_cents: null }); return; } try { const session = await stripe.checkout.sessions.retrieve(session_id, { expand: ["payment_intent"], }); const statusMap: Record = { 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 { const tableMap: Record = { 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; const paymentMethod = (order.payment_method as string) || "card"; console.log(`[checkout] Payment confirmed: ${order_type} ${order_id} via ${paymentMethod}`); // ── Telegram order notification ────────────────────────────────────── try { const botToken = process.env.TELEGRAM_BOT_TOKEN; const chatId = process.env.TELEGRAM_CHAT_ID; if (botToken && chatId) { // Aggregate the WHOLE batch — updated.rows holds every line item, not just one. const rows = updated.rows as Record[]; 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 = subtotalCents - discountCents + surchargeCents; 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` : ""; const msg = `💰 NEW ORDER\n\n` + `Customer: ${customerName}\n` + `Email: ${customerEmail}\n` + idLine + serviceLine + 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") { 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); } } // ── 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: `

Your FCC Carrier Registration is Underway

Hi ${firstName},

Thank you for your order. We're now processing your carrier/ISP registration package.

You'll receive email updates as each step completes. If we need any additional information, we'll reach out.

Order: ${order_id}

Performance West Inc. | 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001 | 1-888-411-0383

`, }); } 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[] = []; 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 = { 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; 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; await callMethod("frappe.client.submit", { doc: JSON.stringify(freshDoc) }); } catch (submitErr) { console.warn(`[checkout] SO submit failed for ${soName} (non-fatal):`, (submitErr as any)?.body?.exception || submitErr); } // Link the SO back to PG orders await pool.query( `UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE batch_id = $2`, [soName, batchId], ); // Create Sales Invoice try { // Calculate batch total (sum of all line items minus discounts) const batchTotalCents = (batchRows.rows as any[]).reduce((sum, bo) => { return sum + (bo.service_fee_cents || 0) + (bo.gov_fee_cents || 0) - (bo.discount_cents || 0); }, 0); const invName = await createInvoiceFromSalesOrder({ salesOrderName: soName, paymentGateway: paymentMethod, surchargePercent: (order.surcharge_pct as number) || 0, paymentReference: session_id, paidAmountCents: batchTotalCents, externalOrderId: batchId, }); if (invName) { console.log(`[checkout] ERPNext Invoice ${invName} created for batch ${batchId}`); } } catch (invErr) { console.warn(`[checkout] Invoice creation failed for ${batchId} (non-fatal):`, invErr); } } catch (erpErr) { console.warn(`[checkout] ERPNext SO creation failed for batch ${batchId} (non-fatal):`, erpErr); } }); for (const batchOrder of batchRows.rows) { const bo = batchOrder as Record; 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 { 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 { // 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; 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): Promise { 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: `

Hi ${name},

Great news — your order ${orderId} has been funded and we're ready to begin setup.

Before we can proceed, we need you to select:

  • Your mailbox unit number at the BC location you chose
  • Your Canadian phone number (DID) from available BC numbers

Click the button below to make your selections:

Set Up My Carrier

This link expires in 72 hours. If you need a new link, contact us at our support page.

Best regards,
Performance West Inc.

`, 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 = { 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; 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[], ): Promise { 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 = {}; 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; } 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 => `
  • ${o.service_name}
  • `) .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([ "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 ? `

    Action Required: USAC E-File Delegation

    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:

    1. Log in to USAC E-File
    2. Go to Account Settings → Delegate Access
    3. Add delegate email: filings@performancewest.net
    4. Grant read and file permissions

    I've completed the delegation →

    Clicking this button notifies our team that delegation is complete so we can begin your filing.

    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.

    ` : ""; // 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 = `
    1. Log in to CMS Identity & Access (I&A)
    2. Go to My Connections → Add Surrogate
    3. Add surrogate organization: Performance West Inc. (email filings@performancewest.net)
    4. Grant access for your NPI, then approve our request

    I've granted surrogate access →

    `; const npiSection = !hasNpiAccess ? "" : grantedSurrogate ? `

    We're handling your filing

    You're all set — 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.

    You chose to grant Surrogate access — here's how (takes ~2 minutes)

    This lets us file faster. You never share your password; you simply authorize us as a Surrogate for your NPI.

    ${surrogateSteps}

    Changed your mind? No problem — we'll handle your filing without it.

    ` : `

    We're handling your filing

    You're all set — 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. Nothing else to do.

    `; // 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) ? `

    Optional: want it filed even faster?

    If you can electronically grant us CMS I&A Surrogate access, we can process your filing right away. It's never required — we'll file it for you either way, at the same price. You never share your password.

    Show me how →
    ${surrogateSteps}
    ` : ""; // 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([ // (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 `
  • ${name}
  • `; }).join("\n"); const intakeSection = fccOrders.length > 0 ? `

    Action Required: Complete Your Intake Form

    Please fill out the intake form for each service so we have the information we need to prepare your filing.

      ${intakeLinks}

    This usually takes 2-5 minutes. We cannot begin your filing until the intake form is complete.

    ` : ""; const dotSection = dotOrders.length > 0 ? `

    We're Working On It

    The following services are being processed by our team. No further action is needed from you.

      ${dotOrders.map(o => `
    • ${o.service_name}
    • ` ).join("\n")}

    You'll receive a confirmation email when your filing is complete, typically within 1 business day.

    ` : ""; 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: `
    Performance West — Compliance Services
     

    We're Getting Started

    Hi ${firstName}, thank you for your order. Here's what happens next.

    ${entityName ? `` : ""}

    Order

    ${orderId}

    Entity

    ${entityName}${frn ? ` (FRN: ${frn})` : ""}

    Services Ordered

      ${serviceLines}
    ${usacSection} ${npiSection} ${dotSection} ${intakeSection} ${npiSpeedupSection}

    What to Expect

    ${isDotOnly ? `

    1. Our team is already working on your filing.

    2. Most DOT filings are completed within 1 business day.

    3. You will receive a confirmation email when everything is filed.

    ` : `

    1. Complete the intake form above so we have the details we need.

    2. We will prepare your filing within 3-7 business days.

    3. You will receive the document for review and electronic signature.

    4. Once signed, we file it and send you confirmation.

    ${has499 ? `

    5. For 499-A filings, complete the USAC delegation above and click the confirmation button.

    ` : ""} `}

    Questions? Contact us at info@performancewest.net or 1-888-411-0383.

    Performance West Inc. · performancewest.net · 1-888-411-0383

    `, }); console.log(`[checkout] Compliance intake email sent to ${customerEmail} for ${orderId}`); } export default router;