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>
852 lines
33 KiB
TypeScript
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;
|