/** * 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 bundle_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`); await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_method TEXT`); await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_pct NUMERIC`); await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_cents INTEGER`); await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment'`); await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ`); // compliance_orders columns are created by migration 044 — just ensure extras try { await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS paypal_order_id TEXT`); await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB`); } catch { /* table may not exist yet */ } schemaMigrated = true; } // ─── Helpers ────────────────────────────────────────────────────────────────── function toDollars(cents: number): number { return Math.round(cents) / 100; } /** * Find or create ERPNext Customer. * * Returns the customer's Frappe link key plus a `portal_user_created` flag * — true when this request was the one to create the ERPNext Website User * (i.e. the customer is new and needs a set-password step on the success * page). The flag is persisted back to the PG order row by the caller so * the success page and the delivery worker can surface the onboarding * password/magic-link UI. */ async function findOrCreateCustomer( email: string, fullName: string, ): Promise<{ customerName: string; portalUserCreated: boolean }> { // ERPNext Customer standard field is email_id const existing = (await getResource( "Customer", undefined, { email_id: email }, ["name"], 1, )) as Array<{ name: string }>; let customerName: string; if (existing.length > 0) { customerName = existing[0].name; } else { const created = (await createResource("Customer", { customer_name: fullName, customer_type: "Individual", customer_group: "Individual", territory: "All Territories", email_id: email, })) as { name: string }; customerName = created.name; } // Ensure matching ERPNext Website User exists (non-fatal if it fails). // `created` distinguishes first-touch customers from returning ones. let portalUserCreated = false; try { const { created } = await ensureWebsiteUser(email, fullName); portalUserCreated = created; await linkUserToCustomer(customerName, email); } catch (err) { console.warn("[checkout] ensureWebsiteUser warning (non-fatal):", err); } return { customerName, portalUserCreated }; } /** * Fetch order from PG and build Stripe line items + surcharge basis. * Returns null if order not found or not in pending_payment state. */ // Service slugs whose payment unlocks the classified CDR traffic study // for the reporting year (paywall — see plan file § I.). const CDR_STUDY_GRANTING_SLUGS = new Set([ "fcc-499a", "fcc-499a-499q", "fcc-full-compliance", "cdr-analysis", ]); async function grantCDRStudyAccess( order: Record, 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) { 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 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"]; if (payment_method === "crypto") { // Crypto orders bypass Stripe — create SHKeeper invoice via API const shkeeperUrl = process.env.SHKEEPER_URL || "http://127.0.0.1:5000"; const shkeeperPublic = process.env.SHKEEPER_PUBLIC_URL || "https://crypto.performancewest.net"; const shkeeperKey = process.env.SHKEEPER_API_KEY || ""; const callbackUrl = `${DOMAIN}/api/v1/webhooks/shkeeper`; if (!shkeeperKey) { res.status(503).json({ error: "Cryptocurrency payments are not configured. Please choose another payment method." }); return; } // Mark order as crypto and redirect to our crypto-pay page // The actual SHKeeper invoice is created when the user picks a coin const table = order_type === "canada_crtc" ? "canada_crtc_orders" : order_type === "formation" ? "formation_orders" : "bundle_orders"; await pool.query( `UPDATE ${table} SET payment_method = 'crypto' WHERE order_number = $1`, [order_id], ); const paymentPageUrl = `${DOMAIN}/order/crypto-pay?order_id=${order_id}&order_type=${order_type}&amount=${toDollars(total_cents)}${order.expedited ? "&expedited=1" : ""}`; res.json({ checkout_url: paymentPageUrl, session_id: `crypto-${order_id}`, total_cents, surcharge_cents: 0, surcharge_pct: 0, }); return; } if (payment_method === "paypal") { // PayPal direct — redirect to PayPal checkout const paypalClientId = process.env.PAYPAL_CLIENT_ID || ""; const paypalBaseUrl = process.env.PAYPAL_API_URL || "https://api-m.paypal.com"; const paypalSecret = process.env.PAYPAL_CLIENT_SECRET || ""; if (!paypalClientId || !paypalSecret) { res.status(503).json({ error: "PayPal is not configured. Please choose another payment method." }); return; } try { // Get PayPal access token const authRes = await fetch(`${paypalBaseUrl}/v1/oauth2/token`, { method: "POST", headers: { Authorization: `Basic ${Buffer.from(`${paypalClientId}:${paypalSecret}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded", }, body: "grant_type=client_credentials", }); const authData = await authRes.json() as { access_token?: string }; if (!authData.access_token) throw new Error("PayPal auth failed"); // Create PayPal order const ppOrder = await fetch(`${paypalBaseUrl}/v2/checkout/orders`, { method: "POST", headers: { Authorization: `Bearer ${authData.access_token}`, "Content-Type": "application/json", }, body: JSON.stringify({ intent: "CAPTURE", purchase_units: [{ reference_id: order_id, description: `Performance West — ${order_type === "canada_crtc" ? "Canada CRTC Package" : "Order"} ${order_id}`, amount: { currency_code: "USD", value: toDollars(total_cents), }, custom_id: order_id, }], payment_source: { paypal: { experience_context: { brand_name: "Performance West Inc.", return_url: `${DOMAIN}/order/success?paypal=1&order_id=${order_id}&order_type=${order_type}`, cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`, user_action: "PAY_NOW", landing_page: "LOGIN", }, }, }, }), }); const ppData = await ppOrder.json() as { id?: string; links?: Array<{ rel: string; href: string }> }; const approveLink = ppData.links?.find(l => l.rel === "payer-action")?.href || ppData.links?.find(l => l.rel === "approve")?.href; if (!approveLink) throw new Error("PayPal order creation failed — no approval URL"); // Store PayPal order ID for capture on return await pool.query( `UPDATE ${order_type === "canada_crtc" ? "canada_crtc_orders" : "formation_orders"} SET paypal_order_id = $1, payment_method = 'paypal' WHERE order_number = $2`, [ppData.id, order_id], ); res.json({ checkout_url: approveLink, session_id: `paypal-${ppData.id}`, total_cents, surcharge_cents, surcharge_pct, }); } catch (ppErr) { console.error("[checkout] PayPal order creation failed:", ppErr); res.status(502).json({ error: "PayPal checkout failed. Please try another payment method." }); } return; } const paymentIntentData: Stripe.Checkout.SessionCreateParams.PaymentIntentData = { metadata: { order_id, order_type, payment_method, }, ...(payment_method === "ach" ? { setup_future_usage: "off_session" } : {}), }; const session = await stripe.checkout.sessions.create({ mode: "payment", payment_method_types: paymentMethodTypes, line_items: allLineItems, ...(discounts ? { discounts } : {}), customer_email: customer_email || undefined, success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`, cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`, metadata: { order_id, order_type, payment_method, customer_email: customer_email || "", customer_name: customer_name || "", ...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}), }, payment_intent_data: paymentIntentData, // ACH via Plaid: verify account balance before accepting payment ...(payment_method === "ach" ? { payment_method_options: { us_bank_account: { financial_connections: { permissions: ["payment_method", "balances"], prefetch: ["balances"], }, verification_method: "instant", }, }, } : {}), }); // (Sales Order already created above, before gateway split) // ── Record Stripe session on PG order ────────────────────────────────── const tableMap: Record = { 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}`); // ── 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); } } // ── 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 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; items.push({ item_code: bo.service_slug, item_name: bo.service_name || bo.service_slug, qty: 1, rate: priceCents / 100, discount_amount: discCents / 100, description: bo.service_name + (bo.gov_fee_cents > 0 ? ` (includes $${(bo.gov_fee_cents / 100).toFixed(0)} gov filing fee)` : ""), }); } const soData: Record = { 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); } } // ── 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 ──────────────────────────────────────────────── 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"), ); 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.

    ` : ""; const { sendEmail } = await import("../email.js"); await sendEmail({ to: customerEmail, subject: `Next Steps — ${entityName || "Your"} FCC 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}

    What to Expect

    1. We will review your compliance status within 1 business day.

    2. If we need any additional information, we will email you.

    3. You will receive a confirmation for each filing as it is completed.

    ${has499 ? `

    4. 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;