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>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
852
api/src/routes/webhooks.ts
Normal file
852
api/src/routes/webhooks.ts
Normal file
|
|
@ -0,0 +1,852 @@
|
|||
/**
|
||||
* 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue