new-site/api/src/routes/webhooks.ts
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

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

852 lines
33 KiB
TypeScript

/**
* 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<string, unknown>): 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<string, unknown>).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<boolean> {
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<void> {
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: `<h2>Payment Failed</h2>
<p><strong>Order:</strong> ${orderId}</p>
<p><strong>Reason:</strong> ${reason}</p>
<p><strong>Time:</strong> ${new Date().toISOString()}</p>
<p>If work has already been dispatched for this order, review whether to halt or continue.</p>`,
});
} catch (err) {
console.error("[payment-failure] Handler error:", err);
}
}
async function handleACHDispute(paymentIntentId: string, reason: string, amountCents: number): Promise<void> {
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: `<h2>ACH Payment Returned</h2>
<p><strong>Order:</strong> ${orderId}</p>
<p><strong>Amount:</strong> $${(amountCents / 100).toFixed(2)}</p>
<p><strong>Reason:</strong> ${reason}</p>
<p><strong>Stripe Payment Intent:</strong> ${paymentIntentId}</p>
<p><strong>Time:</strong> ${new Date().toISOString()}</p>
<p><strong>Action required:</strong> 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.</p>`,
});
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<void> {
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;