/** * Webhook Receivers * * 1. ERPNext webhooks — Formation Order / CRTC workflow state changes. * Security: X-Webhook-Secret header. * * 2. Stripe webhooks — payment_intent.succeeded / checkout.session.completed. * Security: Stripe-Signature header (HMAC-SHA256). * IMPORTANT: Must be registered with raw body parser (express.raw) — see index.ts. * Register at: https://dashboard.stripe.com/webhooks * Events: checkout.session.completed, payment_intent.succeeded, * payment_intent.payment_failed, charge.dispute.created, * balance.available */ import crypto from "node:crypto"; import { Router, type Request, type Response } from "express"; import Stripe from "stripe"; import { pool } from "../db.js"; import { handlePaymentComplete, advanceToClientSelection } from "./checkout.js"; import { sendEmail } from "../email.js"; const router = Router(); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "change-this-in-production"; const WORKER_URL = process.env.WORKER_URL || "http://workers:8090"; /** Verify the webhook secret. */ function verifySecret(req: any, res: any): boolean { const secret = req.headers["x-webhook-secret"]; if (!secret || secret !== WEBHOOK_SECRET) { console.warn("[webhooks] Invalid or missing webhook secret"); res.status(401).json({ error: "Unauthorized" }); return false; } return true; } /** Forward a job to the worker service. */ async function dispatchToWorker(action: string, payload: Record): Promise<{ ok: boolean; data?: unknown }> { try { const res = await fetch(`${WORKER_URL}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, ...payload }), }); const data = await res.json(); return { ok: res.ok, data }; } catch (err) { console.error(`[webhooks] Failed to dispatch ${action} to worker:`, err); return { ok: false }; } } // ========================================================================== // Formation Order Webhooks (triggered by ERPNext workflow state changes) // ========================================================================== /** * POST /api/v1/webhooks/formation/submitted * Triggered when a formation order is submitted. * Action: Start name availability search. */ router.post("/api/v1/webhooks/formation/submitted", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number, state_code, entity_name } = req.body; console.log(`[webhooks] Formation submitted: ${order_number} — ${entity_name} in ${state_code}`); // Advance ERPNext to "Name Check" state await advanceWorkflow(order_name, "Start Name Check"); // Dispatch name search to worker await dispatchToWorker("name_search", { order_name, order_number, state_code, entity_name }); res.json({ received: true, action: "name_search_dispatched" }); }); /** * POST /api/v1/webhooks/formation/name-available * Triggered when name is confirmed available. * Action: Start state portal filing. */ router.post("/api/v1/webhooks/formation/name-available", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] Name available: ${order_number} — starting filing`); await advanceWorkflow(order_name, "Start Filing"); await dispatchToWorker("file_entity", { order_name, order_number }); res.json({ received: true, action: "filing_dispatched" }); }); /** * POST /api/v1/webhooks/formation/filed-needs-ein * Triggered when entity is filed and EIN is requested. * Action: Start IRS EIN obtainment. */ router.post("/api/v1/webhooks/formation/filed-needs-ein", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] Filed, needs EIN: ${order_number}`); await advanceWorkflow(order_name, "Start EIN"); await dispatchToWorker("obtain_ein", { order_name, order_number }); res.json({ received: true, action: "ein_dispatched" }); }); /** * POST /api/v1/webhooks/formation/filed-skip-ein * Triggered when entity is filed but no EIN requested. * Action: Skip to document generation. */ router.post("/api/v1/webhooks/formation/filed-skip-ein", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] Filed, skipping EIN: ${order_number}`); await advanceWorkflow(order_name, "Skip EIN"); await dispatchToWorker("generate_docs", { order_name, order_number }); res.json({ received: true, action: "doc_gen_dispatched" }); }); /** * POST /api/v1/webhooks/formation/ein-obtained * Triggered when EIN is obtained. * Action: Start document generation. */ router.post("/api/v1/webhooks/formation/ein-obtained", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] EIN obtained: ${order_number} — generating docs`); await advanceWorkflow(order_name, "Generate Docs"); await dispatchToWorker("generate_docs", { order_name, order_number }); res.json({ received: true, action: "doc_gen_dispatched" }); }); /** * POST /api/v1/webhooks/formation/approved * Triggered when admin approves the review. * Action: Mark order ready for delivery. */ router.post("/api/v1/webhooks/formation/approved", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] Approved: ${order_number}`); await advanceWorkflow(order_name, "Mark Ready"); res.json({ received: true, action: "marked_ready" }); }); /** * POST /api/v1/webhooks/formation/ready * Triggered when order is ready for delivery. * Action: Email documents to customer. */ router.post("/api/v1/webhooks/formation/ready", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] Ready for delivery: ${order_number}`); await dispatchToWorker("deliver", { order_name, order_number }); res.json({ received: true, action: "delivery_dispatched" }); }); // ========================================================================== // Compliance Service Webhooks (same pattern, for non-formation orders) // ========================================================================== router.post("/api/v1/webhooks/service/queued", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number, service_slug: providedSlug } = req.body; // Resolve service_slug from compliance_orders if not provided by webhook let service_slug = providedSlug || ""; if (!service_slug && order_number) { try { const { rows } = await pool.query( "SELECT service_slug FROM compliance_orders WHERE order_number = $1", [order_number], ); if (rows.length > 0) service_slug = (rows[0] as Record).service_slug as string; } catch { /* table may not exist */ } } console.log(`[webhooks] Service queued: ${order_number} — ${service_slug}`); await dispatchToWorker("process_compliance_service", { order_name, order_number, service_slug }); res.json({ received: true, action: "service_processing_dispatched" }); }); router.post("/api/v1/webhooks/service/approved", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] Service approved: ${order_number}`); await dispatchToWorker("deliver", { order_name, order_number }); res.json({ received: true, action: "delivery_dispatched" }); }); // ========================================================================== // Canada CRTC Webhooks (triggered by ERPNext workflow state changes) // ========================================================================== /** * POST /api/v1/webhooks/crtc/awaiting-funds * Triggered when CRTC order enters Awaiting Funds state. * Records reservation intent; actual advance happens when Relay deposit detected. */ router.post("/api/v1/webhooks/crtc/awaiting-funds", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC awaiting funds: ${order_number}`); // Worker will pick this up when relay_deposit_monitor finds a deposit await dispatchToWorker("register_awaiting_funds", { order_name, order_number, order_type: "canada_crtc" }); res.json({ received: true, action: "registered_awaiting_funds" }); }); /** * POST /api/v1/webhooks/crtc/funds-available * Triggered when deposit monitor advances order to Incorporation state. * Dispatches the BC incorporation job to the worker. */ router.post("/api/v1/webhooks/crtc/funds-available", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC funds available — dispatching incorporation: ${order_number}`); await dispatchToWorker("file_bc_incorporation", { order_name, order_number }); res.json({ received: true, action: "incorporation_dispatched" }); }); /** * POST /api/v1/webhooks/crtc/incorporated * Triggered when BC incorporation completes. * Dispatches CRTC letter generation + binder compilation. */ router.post("/api/v1/webhooks/crtc/incorporated", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number, incorporation_number } = req.body; console.log(`[webhooks] CRTC incorporated: ${order_number} — #${incorporation_number}`); await dispatchToWorker("generate_crtc_docs", { order_name, order_number, incorporation_number }); res.json({ received: true, action: "crtc_docs_dispatched" }); }); /** * POST /api/v1/webhooks/crtc/ready-for-review * Triggered when binder compilation completes and order is in Review state. * Notifies admin that manual review is needed. */ router.post("/api/v1/webhooks/crtc/ready-for-review", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC ready for admin review: ${order_number}`); await dispatchToWorker("notify_admin_review", { order_name, order_number, order_type: "canada_crtc" }); res.json({ received: true, action: "admin_notified" }); }); /** * POST /api/v1/webhooks/crtc/approved * Triggered when admin approves the binder — order moves to Shipping. * Dispatches the physical binder print+ship instructions. */ router.post("/api/v1/webhooks/crtc/approved", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC approved for shipping: ${order_number}`); await dispatchToWorker("ship_binder", { order_name, order_number }); res.json({ received: true, action: "shipping_dispatched" }); }); /** * POST /api/v1/webhooks/crtc/delivered * Triggered when order is marked Delivered. * Starts 14-day commission holdback clock. */ router.post("/api/v1/webhooks/crtc/delivered", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC delivered: ${order_number}`); await dispatchToWorker("mark_delivered", { order_name, order_number, order_type: "canada_crtc" }); res.json({ received: true, action: "delivery_recorded" }); }); /** * POST /api/v1/webhooks/crtc/domain-ready * Triggered when domain + email provisioning completes. */ router.post("/api/v1/webhooks/crtc/domain-ready", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC domain ready: ${order_number}`); res.json({ received: true, action: "domain_ready_acknowledged" }); }); /** * POST /api/v1/webhooks/crtc/phone-ready * Triggered when Canadian DID is provisioned. */ router.post("/api/v1/webhooks/crtc/phone-ready", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC phone ready: ${order_number}`); res.json({ received: true, action: "phone_ready_acknowledged" }); }); /** * POST /api/v1/webhooks/crtc/banking-ready * Triggered when banking referral email is sent. */ router.post("/api/v1/webhooks/crtc/banking-ready", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC banking ready: ${order_number}`); res.json({ received: true, action: "banking_ready_acknowledged" }); }); /** * POST /api/v1/webhooks/crtc/bits-filed * Triggered when BITS Form 503 is submitted to CRTC DCS. */ router.post("/api/v1/webhooks/crtc/bits-filed", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC BITS filed: ${order_number}`); res.json({ received: true, action: "bits_filed_acknowledged" }); }); /** * POST /api/v1/webhooks/crtc/ccts-ready * Triggered when CCTS registration is complete. */ router.post("/api/v1/webhooks/crtc/ccts-ready", async (req, res) => { if (!verifySecret(req, res)) return; const { order_name, order_number } = req.body; console.log(`[webhooks] CRTC CCTS ready: ${order_number}`); res.json({ received: true, action: "ccts_ready_acknowledged" }); }); // ========================================================================== // Stripe Webhook // ========================================================================== const STRIPE_SECRET_KEY = (process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_SECRET_KEY?.trim()) || process.env.STRIPE_SECRET_KEY || ""; const STRIPE_WEBHOOK_SECRET = (process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_WEBHOOK_SECRET?.trim()) || process.env.STRIPE_WEBHOOK_SECRET || ""; const STRIPE_API_VERSION: Stripe.LatestApiVersion = "2026-03-25.dahlia"; const stripeClient = STRIPE_SECRET_KEY ? new Stripe(STRIPE_SECRET_KEY, { apiVersion: STRIPE_API_VERSION }) : null; /** * POST /api/v1/webhooks/stripe * * Receives Stripe events. Must be mounted with express.raw() body parser * (the raw Buffer is required for signature verification). * * Handled events: * checkout.session.completed — customer paid via Stripe Checkout * payment_intent.payment_failed — log failure for dunning */ router.post( "/api/v1/webhooks/stripe", async (req: Request, res: Response) => { if (!stripeClient || !STRIPE_WEBHOOK_SECRET) { console.warn("[webhooks/stripe] Stripe not configured — ignoring event"); res.json({ received: true }); return; } const sig = req.headers["stripe-signature"]; if (!sig) { res.status(400).json({ error: "Missing Stripe-Signature header" }); return; } let event: Stripe.Event; try { // req.body must be the raw Buffer — see index.ts for raw body parser setup const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? ""); event = stripeClient.webhooks.constructEvent( rawBody, sig, STRIPE_WEBHOOK_SECRET, ); } catch (err) { console.error("[webhooks/stripe] Signature verification failed:", err); res.status(400).json({ error: "Webhook signature verification failed" }); return; } console.log(`[webhooks/stripe] Event: ${event.type} — ${event.id}`); try { switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; const order_id = session.metadata?.order_id; const order_type = session.metadata?.order_type; if (!order_id || !order_type) { console.warn("[webhooks/stripe] checkout.session.completed missing metadata", session.id); break; } if (session.payment_status === "paid") { await handlePaymentComplete(order_id, order_type, session.id); } else { // ACH payments may be "unpaid" at session.complete — wait for payment_intent.succeeded console.log(`[webhooks/stripe] Session ${session.id} complete but payment_status=${session.payment_status} — waiting`); } break; } case "payment_intent.succeeded": { // Fired for ACH after funds clear. The checkout session completed event // fires first but payment_status was "unpaid" — this confirms funds. const pi = event.data.object as Stripe.PaymentIntent; const order_id = pi.metadata?.order_id; const order_type = pi.metadata?.order_type; const session_id = pi.metadata?.checkout_session_id ?? pi.id; if (order_id && order_type) { await handlePaymentComplete(order_id, order_type, session_id); } break; } case "payment_intent.payment_failed": { const pi = event.data.object as Stripe.PaymentIntent; const failOrderId = pi.metadata?.order_id; const failReason = pi.last_payment_error?.message ?? "unknown error"; console.warn( `[webhooks/stripe] Payment failed for order ${failOrderId}: ${failReason}`, ); // Alert admin for ACH failures (NSF, account closed, etc.) if (failOrderId) { handlePaymentFailure(failOrderId, failReason).catch(err => console.error("[webhooks/stripe] payment failure handler error:", err), ); } break; } case "charge.dispute.created": { // ACH returns show up as disputes (NSF, unauthorized, etc.) const dispute = event.data.object as Stripe.Dispute; const disputePI = dispute.payment_intent as string; const disputeReason = dispute.reason || "unknown"; const disputeAmount = dispute.amount; console.warn( `[webhooks/stripe] ACH DISPUTE: ${disputeReason} — $${(disputeAmount / 100).toFixed(2)} — PI: ${disputePI}`, ); // Look up the order from the payment intent metadata handleACHDispute(disputePI, disputeReason, disputeAmount).catch(err => console.error("[webhooks/stripe] dispute handler error:", err), ); break; } case "balance.available": { // Stripe balance settled — check if any CRTC orders can advance console.log("[webhooks/stripe] balance.available event received"); handleBalanceAvailable().catch(err => console.error("[webhooks/stripe] balance.available handler error:", err), ); break; } default: // Ignore unhandled event types break; } } catch (err) { console.error(`[webhooks/stripe] Error handling event ${event.type}:`, err); // Return 200 anyway so Stripe doesn't retry — we log the error } res.json({ received: true }); }, ); // ========================================================================== // Helper: Advance ERPNext Workflow // ========================================================================== async function advanceWorkflow(docName: string, action: string, doctype = "Formation Order"): Promise { const erpnextUrl = process.env.ERPNEXT_URL || "http://erpnext:8000"; const apiKey = process.env.ERPNEXT_API_KEY || ""; const apiSecret = process.env.ERPNEXT_API_SECRET || ""; const siteName = process.env.ERPNEXT_SITE_NAME || process.env.ERPNEXT_HOST_HEADER || "performancewest.net"; try { const res = await fetch(`${erpnextUrl}/api/method/frappe.client.call`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `token ${apiKey}:${apiSecret}`, "X-Frappe-Site-Name": siteName, }, body: JSON.stringify({ cmd: "frappe.model.workflow.apply_workflow", doc: JSON.stringify({ doctype, name: docName }), action, }), }); if (!res.ok) { const errText = await res.text(); console.error(`[webhooks] Failed to advance workflow: ${action} — ${res.status}: ${errText.slice(0, 300)}`); return false; } console.log(`[webhooks] Workflow advanced: ${docName} → ${action}`); return true; } catch (err) { console.error(`[webhooks] Workflow advance error: ${action} —`, err); return false; } } // One-time warning flag so the "SHKEEPER_API_KEY not set" message only // fires once per process instead of on every request. let _shkeeperKeyMissingWarned = false; // ═══════════════════════════════════════════════════════════════════════════════ // 4. SHKeeper (crypto) webhook — payment notifications // ═══════════════════════════════════════════════════════════════════════════════ /** * POST /api/v1/webhooks/shkeeper * Called by SHKeeper when a crypto transaction is received for an invoice. * Must return HTTP 202 Accepted to acknowledge — anything else causes retry. * * Callback payload: * external_id, crypto, addr, fiat, balance_fiat, balance_crypto, * paid (bool), status (PARTIAL|PAID|OVERPAID), transactions[] */ router.post("/api/v1/webhooks/shkeeper", async (req, res) => { try { // ── Signature check — mirror frappe_crypto/api.py:51 ──────────────── // SHKeeper sends its configured API key in the X-Shkeeper-Api-Key // header; we verify with timingSafeEqual to avoid leaks. const expected = process.env.SHKEEPER_API_KEY || ""; const supplied = String(req.headers["x-shkeeper-api-key"] || ""); if (expected) { const ok = supplied.length === expected.length && crypto.timingSafeEqual( Buffer.from(supplied), Buffer.from(expected), ); if (!ok) { console.warn("[shkeeper] API key mismatch — rejecting webhook"); res.status(401).json({ error: "invalid api key" }); return; } } else if (!_shkeeperKeyMissingWarned) { // Warn once per process so we don't spam logs on every request. console.warn("[shkeeper] SHKEEPER_API_KEY not set — accepting without signature check"); _shkeeperKeyMissingWarned = true; } const { external_id, crypto: cryptoName, balance_fiat, balance_crypto, paid, status: invoiceStatus, transactions, } = req.body ?? {}; console.log(`[shkeeper] Callback: order=${external_id} crypto=${cryptoName} status=${invoiceStatus} paid=${paid} fiat=${balance_fiat}`); if (!external_id) { res.status(202).json({ received: true }); return; } // Only process when fully paid or overpaid if (paid === true && (invoiceStatus === "PAID" || invoiceStatus === "OVERPAID")) { const orderId = String(external_id); // Determine order type from prefix const orderType = orderId.startsWith("CA-") ? "canada_crtc" : orderId.startsWith("FO-") ? "formation" : orderId.startsWith("BU-") ? "bundle" : orderId.startsWith("CO-") ? "compliance" : "canada_crtc"; const txid = transactions?.[0]?.txid || `shkeeper-${cryptoName}-${Date.now()}`; // ── Treasury pipeline enqueue (migration 065) ──────────────────── // Enqueue a crypto_payment_jobs row (idempotent ON CONFLICT DO NOTHING). // The crypto_payment_worker polls this table and drives the // received → sizing → offramping → funds_at_relay → ready → settled // state machine. SHKeeper webhook retries are safe — same // (order_id, txid) produces the same idempotency_key. try { const { pool } = await import("../db.js"); const coinUpper = String(cryptoName || "").toUpperCase(); const balanceCoin = balance_crypto ? String(balance_crypto) : "0"; const balanceCents = Math.round(Number(balance_fiat || 0) * 100); const idemKey = `shkeeper-settle:${orderId}:${txid}`; await pool.query( `INSERT INTO crypto_payment_jobs ( order_id, order_type, state, coin, amount_coin, amount_usd_cents, idempotency_key, received_at ) VALUES ($1, $2, 'received', $3, $4::numeric, $5, $6, NOW()) ON CONFLICT (order_id) DO UPDATE SET -- Same order paid in multiple txs (overpaid case): refresh -- the amounts but only if we're still in 'received'. amount_coin = EXCLUDED.amount_coin, amount_usd_cents = EXCLUDED.amount_usd_cents, updated_at = NOW() WHERE crypto_payment_jobs.state = 'received'`, [orderId, orderType, coinUpper, balanceCoin, balanceCents, idemKey], ); // Record the immutable 'receive' ledger row (also idempotent via // UNIQUE on idempotency_key). await pool.query( `INSERT INTO crypto_payment_ledger ( order_id, order_type, coin, movement_type, amount_coin, amount_usd_cents, provider, provider_ref, provider_status, state, idempotency_key, acquired_at, notes ) VALUES ($1, $2, $3, 'receive', $4::numeric, $5, 'shkeeper', $6, $7, 'confirmed', $8, NOW(), $9) ON CONFLICT (idempotency_key) DO NOTHING`, [ orderId, orderType, coinUpper, balanceCoin, balanceCents, txid, invoiceStatus, `shkeeper:${txid}`, `SHKeeper ${invoiceStatus} — ${balanceCoin} ${coinUpper} @ $${balance_fiat}`, ], ); console.log(`[shkeeper] Enqueued treasury job for ${orderId}`); } catch (err) { console.error(`[shkeeper] Failed to enqueue treasury job for ${orderId}:`, err); // Non-fatal — continue to handlePaymentComplete so customer-facing // side still advances; the treasury worker can retry. } try { await handlePaymentComplete(orderId, orderType, `shkeeper-${txid}`); console.log(`[shkeeper] Payment complete for ${orderId}: ${cryptoName} ${balance_fiat} USD`); } catch (err) { console.error(`[shkeeper] handlePaymentComplete failed for ${orderId}:`, err); } } else { console.log(`[shkeeper] Partial/pending payment for ${external_id}: ${invoiceStatus}`); } // Must return 202 to stop SHKeeper from retrying res.status(202).json({ received: true }); } catch (err) { console.error("[shkeeper] Webhook error:", err); // Still return 202 to prevent infinite retries res.status(202).json({ received: true, error: "internal" }); } }); // ═══════════════════════════════════════════════════════════════════════════════ // 5. Stripe balance.available — fund settlement detection for CRTC orders // ═══════════════════════════════════════════════════════════════════════════════ /** * Handled inline in the main Stripe webhook handler (section 1 above). * When `balance.available` fires, we check for CRTC orders in "Awaiting Funds" * that have been paid via Stripe (card/ACH/Klarna) and enough time has passed * for settlement. * * Card/Klarna: T+2 business days from payment capture * ACH: T+4 business days from payment capture * * For each eligible order: topup Issuing balance → advance to "Client Selection" */ const ADMIN_EMAIL = process.env.ADMIN_EMAIL || "ops@performancewest.net"; async function handlePaymentFailure(orderId: string, reason: string): Promise { try { // Flag the order for (const table of ["compliance_orders", "canada_crtc_orders", "formation_orders", "bundle_orders"]) { try { await pool.query( `UPDATE ${table} SET payment_status = 'failed', notes = COALESCE(notes, '') || $2 WHERE order_number = $1 AND payment_status IN ('paid', 'pending_payment')`, [orderId, `\nPayment failed: ${reason} (${new Date().toISOString()})`], ); } catch { /* table may not exist or order not in this table */ } } // Alert admin await sendEmail({ to: ADMIN_EMAIL, subject: `⚠️ Payment Failed — ${orderId}`, html: `

Payment Failed

Order: ${orderId}

Reason: ${reason}

Time: ${new Date().toISOString()}

If work has already been dispatched for this order, review whether to halt or continue.

`, }); } catch (err) { console.error("[payment-failure] Handler error:", err); } } async function handleACHDispute(paymentIntentId: string, reason: string, amountCents: number): Promise { try { // Try to find the order across tables using Stripe session/PI references let orderId = "unknown"; for (const table of ["compliance_orders", "canada_crtc_orders", "formation_orders", "bundle_orders"]) { try { const r = await pool.query( `SELECT order_number, customer_email, customer_name, service_name FROM ${table} WHERE stripe_session_id LIKE $1 OR order_number IN ( SELECT order_number FROM ${table} WHERE payment_status = 'paid' ) LIMIT 1`, [`%${paymentIntentId}%`], ); if (r.rows.length > 0) { orderId = r.rows[0].order_number as string; break; } } catch { /* table may not have these columns */ } } // Flag the order as disputed for (const table of ["compliance_orders", "canada_crtc_orders", "formation_orders", "bundle_orders"]) { try { await pool.query( `UPDATE ${table} SET payment_status = 'disputed', notes = COALESCE(notes, '') || $2 WHERE order_number = $1`, [orderId, `\nACH dispute: ${reason} — $${(amountCents / 100).toFixed(2)} (${new Date().toISOString()})`], ); } catch {} } // Alert admin — this is urgent await sendEmail({ to: ADMIN_EMAIL, subject: `🚨 ACH Return/Dispute — $${(amountCents / 100).toFixed(2)} — ${orderId}`, html: `

ACH Payment Returned

Order: ${orderId}

Amount: $${(amountCents / 100).toFixed(2)}

Reason: ${reason}

Stripe Payment Intent: ${paymentIntentId}

Time: ${new Date().toISOString()}

Action required: If documents were already delivered for this order, determine whether to request payment via alternative method or write off the loss. Check Stripe Dashboard for dispute details and evidence submission deadline.

`, }); console.warn(`[ach-dispute] Alerted admin: ${orderId} — ${reason} — $${(amountCents / 100).toFixed(2)}`); } catch (err) { console.error("[ach-dispute] Handler error:", err); } } async function handleBalanceAvailable(): Promise { try { // Find CRTC orders that are paid via Stripe but funds not yet marked available const { rows } = await pool.query(` SELECT order_number, payment_method, paid_at, total_cents, amb_annual_price_cents, funds_available FROM canada_crtc_orders WHERE payment_status = 'paid' AND funds_available = FALSE AND payment_method IN ('card', 'ach', 'klarna') AND paid_at IS NOT NULL ORDER BY paid_at ASC LIMIT 20 `); if (!rows.length) { console.log("[balance.available] No pending CRTC orders awaiting fund settlement"); return; } const now = new Date(); let advancedCount = 0; for (const order of rows) { const paidAt = new Date(order.paid_at as string); const method = order.payment_method as string; const orderId = order.order_number as string; // Calculate business days elapsed since payment let bizDays = 0; const d = new Date(paidAt); while (d < now) { d.setDate(d.getDate() + 1); const dow = d.getDay(); if (dow !== 0 && dow !== 6) bizDays++; } // Settlement timing thresholds const requiredDays = method === "ach" ? 4 : 2; // ACH=T+4, card/klarna=T+2 if (bizDays >= requiredDays) { console.log(`[balance.available] Order ${orderId} (${method}): ${bizDays} biz days since payment — advancing`); try { await advanceToClientSelection(orderId); advancedCount++; } catch (err) { console.error(`[balance.available] Failed to advance ${orderId}:`, err); } } else { console.log(`[balance.available] Order ${orderId} (${method}): ${bizDays}/${requiredDays} biz days — not yet`); } } console.log(`[balance.available] Processed: ${advancedCount} orders advanced to Client Selection`); } catch (err) { console.error("[balance.available] Handler error:", err); } } // Register in the main Stripe webhook dispatcher // The balance.available event is added to the switch in section 1. // Also expose for direct invocation (e.g. cron fallback). export { handleBalanceAvailable }; export default router;