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:
justin 2026-04-27 06:54:22 -05:00
commit f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions

View file

@ -0,0 +1,337 @@
/**
* Admin crypto treasury endpoints.
*
* All endpoints require the admin token (X-Admin-Token header) same
* gate used by admin-filings and reseller-certs. Read-only endpoints
* return 200; mutating endpoints audit to order_audit_log.
*/
import crypto from "node:crypto";
import { Router } from "express";
import type { Request, Response } from "express";
import { pool } from "../db.js";
const router = Router();
// ── Auth middleware ─────────────────────────────────────────────────────
function requireAdminToken(req: Request, res: Response): boolean {
const expected = process.env.ADMIN_API_TOKEN || "";
const supplied = (req.headers["x-admin-token"] || "").toString().trim();
if (!expected) {
// If token not configured, reject — fail-closed.
res.status(503).json({ error: "ADMIN_API_TOKEN not set" });
return false;
}
// Timing-safe compare. timingSafeEqual throws on length mismatch, so
// guard with a length check first (a cheap length-disclosure trade-off
// that's negligible compared to the attack surface of naïve ==).
const sb = Buffer.from(supplied);
const eb = Buffer.from(expected);
const ok = sb.length === eb.length && crypto.timingSafeEqual(sb, eb);
if (!ok) {
res.status(403).json({ error: "forbidden" });
return false;
}
return true;
}
async function auditLog(
actor: string, action: string, target: string, details?: unknown,
) {
// order_audit_log schema requires: order_type IN ('formation','service','quote'),
// order_id (integer, NOT NULL), action, actor_type IN
// ('system','admin','worker','customer') + optional order_number,
// actor_name, metadata. We use order_type='service' (closest match —
// crypto treasury is an internal service action) and store the real
// crypto-treasury target (order_number or 'sweep:N') in order_number.
try {
await pool.query(
`INSERT INTO order_audit_log
(order_type, order_id, order_number, action, actor_type, actor_name, metadata)
VALUES ('service', 0, $1, $2, 'admin', $3, $4::jsonb)`,
[target, action, actor, JSON.stringify(details ?? {})],
);
} catch (err) {
console.error("[admin-crypto] audit log failed:", err);
// non-fatal
}
}
// ── GET /api/v1/admin/crypto-payments ───────────────────────────────────
router.get(
"/api/v1/admin/crypto-payments",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const stateFilter = typeof req.query.state === "string" ? req.query.state : "";
const params: (string | number)[] = [];
const where: string[] = [];
if (stateFilter) {
where.push(`j.state = $${params.length + 1}`);
params.push(stateFilter);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const r = await pool.query(
`
SELECT j.order_id, j.order_type, j.state, j.coin,
j.amount_coin, j.amount_usd_cents, j.needed_usd_cents,
j.offramp_provider, j.offramp_ref, j.relay_deposit_id,
j.target_card_id, j.last_error, j.attempt_count,
j.next_retry_at, j.received_at, j.funds_at_relay_at, j.settled_at,
j.created_at, j.updated_at,
(
SELECT COALESCE(SUM(amount_usd_cents), 0)
FROM vendor_obligations
WHERE order_id = j.order_id
) AS total_obligations_cents,
(
SELECT COUNT(*)
FROM vendor_obligations
WHERE order_id = j.order_id AND status = 'paid'
) AS obligations_paid
FROM crypto_payment_jobs j
${whereSql}
ORDER BY j.created_at DESC
LIMIT 200
`,
params,
);
res.json({ jobs: r.rows, count: r.rows.length });
},
);
// NOTE: specific paths (sweeps, tax-export) are registered BELOW before
// the /:order_id param route to avoid Express pattern-match conflicts
// (order_id='sweeps' would otherwise match this handler).
// ── GET /api/v1/admin/crypto-payments/:order_id ────────────────────────
// Registered AFTER the specific sub-paths below. Express matches in
// registration order; putting this last ensures /sweeps and
// /tax-export aren't interpreted as order_ids.
// ── Detail view (must come AFTER specific sub-paths below) ────────────
//
// We re-register this at the end of the file so Express pattern-matches
// /sweeps and /tax-export first.
// ── POST /api/v1/admin/crypto-payments/:order_id/retry-offramp ─────────
router.post(
"/api/v1/admin/crypto-payments/:order_id/retry-offramp",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const r = await pool.query(
`UPDATE crypto_payment_jobs
SET state = 'sizing',
last_error = NULL,
next_retry_at = NULL,
updated_at = NOW()
WHERE order_id = $1
AND state IN ('manual','failed','offramping')
RETURNING state, attempt_count`,
[orderId],
);
if (r.rows.length === 0) {
res.status(409).json({
error: "job not in a retry-able state (must be manual / failed / offramping)",
});
return;
}
await auditLog(
req.headers["x-admin-user"]?.toString() || "admin",
"crypto_retry_offramp", orderId, { new_state: "sizing" },
);
res.json({ ok: true, state: r.rows[0].state });
},
);
// ── POST /api/v1/admin/crypto-payments/:order_id/mark-settled ──────────
router.post(
"/api/v1/admin/crypto-payments/:order_id/mark-settled",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const { note } = req.body ?? {};
const r = await pool.query(
`UPDATE crypto_payment_jobs
SET state = 'settled',
settled_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE order_id = $1
RETURNING state`,
[orderId],
);
if (r.rows.length === 0) {
res.status(404).json({ error: "job not found" }); return;
}
await auditLog(
req.headers["x-admin-user"]?.toString() || "admin",
"crypto_manual_settle", orderId, { note },
);
res.json({ ok: true });
},
);
// ── GET /api/v1/admin/crypto-payments/sweeps ───────────────────────────
router.get(
"/api/v1/admin/crypto-payments/sweeps",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const r = await pool.query(
`SELECT * FROM cold_wallet_sweeps
WHERE status IN ('pending','approved','broadcast')
ORDER BY created_at DESC
LIMIT 100`,
);
res.json({ sweeps: r.rows });
},
);
// ── POST /api/v1/admin/crypto-payments/sweeps/:id/approve ──────────────
router.post(
"/api/v1/admin/crypto-payments/sweeps/:id/approve",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const sweepId = Number(req.params.id);
if (!Number.isFinite(sweepId)) {
res.status(400).json({ error: "bad sweep id" }); return;
}
const actor = req.headers["x-admin-user"]?.toString() || "admin";
const r = await pool.query(
`UPDATE cold_wallet_sweeps
SET status = 'approved',
approved_by = $2,
approved_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND status = 'pending'
RETURNING coin, amount_coin`,
[sweepId, actor],
);
if (r.rows.length === 0) {
res.status(409).json({ error: "sweep not in pending state" }); return;
}
await auditLog(actor, "crypto_sweep_approve", `sweep:${sweepId}`,
{ coin: r.rows[0].coin, amount_coin: r.rows[0].amount_coin });
res.json({ ok: true });
},
);
// ── GET /api/v1/admin/crypto-payments/tax-export?year=YYYY ─────────────
//
// IRS Form 8949 columns: description, date_acquired, date_sold,
// proceeds, cost_basis, gain_loss. Covers all offramp/disposal rows
// whose disposed_at is in the given tax year.
router.get(
"/api/v1/admin/crypto-payments/tax-export",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const year = Number(req.query.year) || new Date().getUTCFullYear() - 1;
const r = await pool.query(
`
SELECT order_id, coin,
amount_coin, fx_rate_usd,
acquired_at, disposed_at,
basis_usd_cents, proceeds_usd_cents,
provider, provider_ref
FROM crypto_payment_ledger
WHERE movement_type = 'offramp'
AND state IN ('pending','confirmed')
AND disposed_at >= make_timestamptz($1, 1, 1, 0, 0, 0, 'UTC')
AND disposed_at < make_timestamptz($1 + 1, 1, 1, 0, 0, 0, 'UTC')
ORDER BY disposed_at ASC, id ASC
`,
[year],
);
// Build the CSV
const header = [
"Description", // e.g., "0.00873 BTC — Order CO-SMOKE02"
"Date Acquired", // MM/DD/YYYY
"Date Sold",
"Proceeds (USD)",
"Cost Basis (USD)",
"Gain/(Loss) (USD)",
"Provider Reference",
];
const lines: string[] = [header.join(",")];
const fmt = (d: Date) =>
`${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
for (const row of r.rows) {
const amount = Number(row.amount_coin);
const proceeds = Number(row.proceeds_usd_cents || 0) / 100;
const basis = Number(row.basis_usd_cents || 0) / 100;
const gain = proceeds - basis;
const acquired = row.acquired_at ? fmt(new Date(row.acquired_at)) : "";
const sold = row.disposed_at ? fmt(new Date(row.disposed_at)) : "";
const desc = `${Math.abs(amount).toFixed(8)} ${row.coin} — Order ${row.order_id}`;
lines.push([
`"${desc}"`,
`"${acquired}"`,
`"${sold}"`,
proceeds.toFixed(2),
basis.toFixed(2),
gain.toFixed(2),
`"${row.provider_ref || ""}"`,
].join(","));
}
res.setHeader("Content-Type", "text/csv");
res.setHeader(
"Content-Disposition",
`attachment; filename="crypto-disposals-${year}.csv"`,
);
res.send(lines.join("\n"));
},
);
// ── GET /api/v1/admin/crypto-payments/:order_id (must be LAST) ─────────
// Registered last so specific paths above match first.
router.get(
"/api/v1/admin/crypto-payments/:order_id",
async (req: Request, res: Response) => {
if (!requireAdminToken(req, res)) return;
const orderId = req.params.order_id;
const job = await pool.query(
"SELECT * FROM crypto_payment_jobs WHERE order_id = $1",
[orderId],
);
if (job.rows.length === 0) {
res.status(404).json({ error: "job not found" }); return;
}
const [ledger, obligations, deposit] = await Promise.all([
pool.query(
`SELECT * FROM crypto_payment_ledger WHERE order_id = $1 ORDER BY created_at ASC`,
[orderId],
),
pool.query(
`SELECT * FROM vendor_obligations WHERE order_id = $1 ORDER BY obligation_kind, id`,
[orderId],
),
job.rows[0].relay_deposit_id
? pool.query("SELECT * FROM relay_deposits WHERE id = $1", [job.rows[0].relay_deposit_id])
: Promise.resolve({ rows: [] }),
]);
res.json({
job: job.rows[0],
ledger: ledger.rows,
obligations: obligations.rows,
relay_deposit: deposit.rows[0] || null,
});
},
);
export default router;

304
api/src/routes/admin.ts Normal file
View file

@ -0,0 +1,304 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { pool } from "../db.js";
import { requireAdmin, signAdminToken } from "../middleware/admin-auth.js";
import { submitLimiter } from "../middleware/rate-limit.js";
const router = Router();
// =====================================================================
// Auth
// =====================================================================
/** POST /api/v1/admin/login — Authenticate and receive JWT. */
router.post("/api/v1/admin/login", submitLimiter, async (req, res) => {
try {
const { username, password } = req.body ?? {};
if (!username || !password) {
res.status(400).json({ error: "Username and password required." });
return;
}
const result = await pool.query(
"SELECT id, username, password_hash, display_name, active FROM admin_users WHERE username = $1",
[username.toLowerCase().trim()],
);
if (result.rows.length === 0) {
res.status(401).json({ error: "Invalid credentials." });
return;
}
const user = result.rows[0];
if (!user.active) {
res.status(403).json({ error: "Account is disabled." });
return;
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
res.status(401).json({ error: "Invalid credentials." });
return;
}
// Update last login
await pool.query("UPDATE admin_users SET last_login_at = now() WHERE id = $1", [user.id]);
const token = signAdminToken({ id: user.id, username: user.username });
res.json({
token,
user: { id: user.id, username: user.username, display_name: user.display_name },
});
} catch (err) {
console.error("[admin/login] Error:", err);
res.status(500).json({ error: "Login failed." });
}
});
/** GET /api/v1/admin/me — Verify token and return current user. */
router.get("/api/v1/admin/me", requireAdmin, async (req, res) => {
res.json({ user: req.admin });
});
// =====================================================================
// Order Queue — Formation Orders
// =====================================================================
/** GET /api/v1/admin/formations — List all formation orders with filtering. */
router.get("/api/v1/admin/formations", requireAdmin, async (req, res) => {
try {
const status = req.query.status as string || "";
const automation = req.query.automation as string || "";
const priority = req.query.priority as string || "";
const assigned = req.query.assigned as string || "";
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 200);
const offset = parseInt(req.query.offset as string, 10) || 0;
let where = "WHERE 1=1";
const params: any[] = [];
let paramIdx = 1;
if (status) { where += ` AND f.status = $${paramIdx++}`; params.push(status); }
if (automation) { where += ` AND f.automation_status = $${paramIdx++}`; params.push(automation); }
if (priority) { where += ` AND f.priority = $${paramIdx++}`; params.push(priority); }
if (assigned === "unassigned") { where += " AND f.assigned_to IS NULL"; }
else if (assigned === "me") { where += ` AND f.assigned_to = $${paramIdx++}`; params.push(req.admin!.id); }
else if (assigned) { where += ` AND f.assigned_to = $${paramIdx++}`; params.push(parseInt(assigned, 10)); }
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM formation_orders f ${where}`, params,
);
params.push(limit, offset);
const result = await pool.query(
`SELECT f.*, a.username as assigned_username, a.display_name as assigned_name
FROM formation_orders f
LEFT JOIN admin_users a ON f.assigned_to = a.id
${where}
ORDER BY
CASE f.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END,
f.created_at DESC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
params,
);
res.json({
orders: result.rows,
total: parseInt(countResult.rows[0].total, 10),
limit,
offset,
});
} catch (err) {
console.error("[admin/formations] Error:", err);
res.status(500).json({ error: "Could not load orders." });
}
});
/** GET /api/v1/admin/formations/:id — Single order with full details + audit log. */
router.get("/api/v1/admin/formations/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const order = await pool.query(
`SELECT f.*, a.username as assigned_username, a.display_name as assigned_name
FROM formation_orders f
LEFT JOIN admin_users a ON f.assigned_to = a.id
WHERE f.id = $1`, [id],
);
if (order.rows.length === 0) { res.status(404).json({ error: "Order not found." }); return; }
const audit = await pool.query(
`SELECT * FROM order_audit_log WHERE order_type = 'formation' AND order_id = $1 ORDER BY created_at DESC`,
[id],
);
const discount = await pool.query(
`SELECT * FROM discount_usage WHERE order_type = 'formation' AND order_id = $1`,
[id],
);
res.json({
order: order.rows[0],
audit_log: audit.rows,
discount: discount.rows[0] || null,
});
} catch (err) {
console.error("[admin/formations/:id] Error:", err);
res.status(500).json({ error: "Could not load order." });
}
});
/** PATCH /api/v1/admin/formations/:id — Update order status, priority, assignment, notes. */
router.patch("/api/v1/admin/formations/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const { status, automation_status, priority, assigned_to, admin_notes, note } = req.body ?? {};
// Fetch current state
const current = await pool.query("SELECT * FROM formation_orders WHERE id = $1", [id]);
if (current.rows.length === 0) { res.status(404).json({ error: "Order not found." }); return; }
const order = current.rows[0];
const updates: string[] = [];
const params: any[] = [];
let idx = 1;
if (status && status !== order.status) {
updates.push(`status = $${idx++}`); params.push(status);
// Log status change
await pool.query(
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, from_status, to_status, actor_type, actor_id, actor_name, note)
VALUES ('formation', $1, $2, 'status_change', $3, $4, 'admin', $5, $6, $7)`,
[id, order.order_number, order.status, status, req.admin!.id, req.admin!.username, note || null],
);
if (status === "delivered") {
updates.push(`delivered_at = now()`);
}
if (status === "filed") {
updates.push(`filed_at = now()`);
}
}
if (automation_status && automation_status !== order.automation_status) {
updates.push(`automation_status = $${idx++}`); params.push(automation_status);
await pool.query(
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, from_status, to_status, actor_type, actor_id, actor_name, note)
VALUES ('formation', $1, $2, 'automation_update', $3, $4, 'admin', $5, $6, $7)`,
[id, order.order_number, order.automation_status, automation_status, req.admin!.id, req.admin!.username, note || null],
);
}
if (priority && priority !== order.priority) {
updates.push(`priority = $${idx++}`); params.push(priority);
}
if (assigned_to !== undefined) {
updates.push(`assigned_to = $${idx++}`); params.push(assigned_to || null);
await pool.query(
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, actor_type, actor_id, actor_name, note)
VALUES ('formation', $1, $2, 'assigned', 'admin', $3, $4, $5)`,
[id, order.order_number, req.admin!.id, req.admin!.username, `Assigned to admin #${assigned_to || "unassigned"}`],
);
}
if (admin_notes !== undefined) {
updates.push(`admin_notes = $${idx++}`); params.push(admin_notes);
}
// Add a note to audit log if provided without other changes
if (note && !status && !automation_status) {
await pool.query(
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, actor_type, actor_id, actor_name, note)
VALUES ('formation', $1, $2, 'note_added', 'admin', $3, $4, $5)`,
[id, order.order_number, req.admin!.id, req.admin!.username, note],
);
}
if (updates.length > 0) {
updates.push("last_activity_at = now()");
updates.push(`updated_at = now()`);
params.push(id);
await pool.query(
`UPDATE formation_orders SET ${updates.join(", ")} WHERE id = $${idx}`,
params,
);
}
res.json({ success: true, message: "Order updated." });
} catch (err) {
console.error("[admin/formations/:id PATCH] Error:", err);
res.status(500).json({ error: "Could not update order." });
}
});
// =====================================================================
// Dashboard Stats
// =====================================================================
/** GET /api/v1/admin/stats — Queue overview counts. */
router.get("/api/v1/admin/stats", requireAdmin, async (req, res) => {
try {
const formations = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'received') as received,
COUNT(*) FILTER (WHERE status = 'processing') as processing,
COUNT(*) FILTER (WHERE status = 'submitted') as submitted,
COUNT(*) FILTER (WHERE status = 'filed') as filed,
COUNT(*) FILTER (WHERE status = 'delivered') as delivered,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled,
COUNT(*) FILTER (WHERE automation_status = 'failed') as automation_failed,
COUNT(*) FILTER (WHERE automation_status = 'manual') as manual_required,
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent,
COUNT(*) FILTER (WHERE assigned_to IS NULL AND status NOT IN ('delivered','cancelled')) as unassigned,
COUNT(*) as total
FROM formation_orders
`);
const subscribers = await pool.query(
"SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE unsubscribed = FALSE) as active FROM subscribers",
);
const quotes = await pool.query(
"SELECT COUNT(*) FILTER (WHERE status = 'pending') as pending FROM quotes",
);
const tickets = await pool.query(
"SELECT COUNT(*) as total FROM tickets WHERE created_at > now() - interval '24 hours'",
);
const revenue = await pool.query(
"SELECT COALESCE(SUM(total_cents), 0) as total_cents FROM formation_orders WHERE status NOT IN ('cancelled')",
);
res.json({
formations: formations.rows[0],
subscribers: subscribers.rows[0],
quotes: quotes.rows[0],
tickets_24h: parseInt(tickets.rows[0].total, 10),
revenue_cents: parseInt(revenue.rows[0].total_cents, 10),
});
} catch (err) {
console.error("[admin/stats] Error:", err);
res.status(500).json({ error: "Could not load stats." });
}
});
// =====================================================================
// Audit Log
// =====================================================================
/** GET /api/v1/admin/audit — Recent audit log entries. */
router.get("/api/v1/admin/audit", requireAdmin, async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 200);
const result = await pool.query(
"SELECT * FROM order_audit_log ORDER BY created_at DESC LIMIT $1",
[limit],
);
res.json({ entries: result.rows });
} catch (err) {
console.error("[admin/audit] Error:", err);
res.status(500).json({ error: "Could not load audit log." });
}
});
export default router;

330
api/src/routes/agents.ts Normal file
View file

@ -0,0 +1,330 @@
import { Router } from "express";
import { pool } from "../db.js";
import { requireAdmin } from "../middleware/admin-auth.js";
import { v4 as uuidv4 } from "uuid";
const router = Router();
// Helper: generate a REF-XXXXX agent code
function generateAgentCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/1/0 to avoid confusion
let code = "REF-";
for (let i = 0; i < 5; i++) code += chars[Math.floor(Math.random() * chars.length)];
return code;
}
// Helper: create commission record when an order uses an agent's code
// This is exported so other route files can call it
export async function createCommission(params: {
agentCode: string;
orderType: string;
orderId: number;
orderNumber: string;
serviceSlug?: string;
customerName: string;
customerEmail: string;
orderAmountCents: number;
discountCents: number;
}): Promise<void> {
// Look up the agent
const agentResult = await pool.query(
"SELECT id, agent_code, commission_default_cents, commission_pct, commission_overrides, commission_type FROM sales_agents WHERE agent_code = $1 AND active = TRUE",
[params.agentCode],
);
if (agentResult.rows.length === 0) return;
const agent = agentResult.rows[0];
// Calculate commission amount
let commissionCents = agent.commission_default_cents || 30000; // $300 default
const overrides = agent.commission_overrides || {};
// Check for service-specific override
if (params.serviceSlug && overrides[params.serviceSlug]) {
commissionCents = overrides[params.serviceSlug];
} else if (params.orderType === "canada_crtc") {
commissionCents = overrides["canada-crtc"] || 30000;
} else if (params.orderType === "formation") {
commissionCents = overrides["formation"] || 5000;
} else if (params.orderType === "bundle") {
commissionCents = overrides["bundle"] || 10000;
} else if (agent.commission_type === "percent") {
// For compliance services, use percentage
commissionCents = Math.round((params.orderAmountCents * (agent.commission_pct || 10)) / 100);
}
await pool.query(
`INSERT INTO commission_ledger (agent_id, agent_code, order_type, order_id, order_number, service_slug, customer_name, customer_email, order_amount_cents, discount_cents, commission_cents, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending')`,
[agent.id, agent.agent_code, params.orderType, params.orderId, params.orderNumber,
params.serviceSlug || null, params.customerName, params.customerEmail,
params.orderAmountCents, params.discountCents, commissionCents],
);
// Update agent stats
await pool.query(
"UPDATE sales_agents SET total_referrals = total_referrals + 1, total_pending_cents = total_pending_cents + $1, updated_at = now() WHERE id = $2",
[commissionCents, agent.id],
);
}
// =====================================================================
// Admin: Create a new sales agent
// =====================================================================
router.post("/api/v1/admin/agents", requireAdmin, async (req, res) => {
try {
const { name, email, phone, company, commission_default_cents, commission_pct, commission_overrides, notes } = req.body ?? {};
if (!name || !email) {
res.status(400).json({ error: "Name and email are required." });
return;
}
// Check for duplicate email
const existing = await pool.query("SELECT id FROM sales_agents WHERE email = $1", [email.toLowerCase().trim()]);
if (existing.rows.length > 0) {
res.status(409).json({ error: "An agent with this email already exists." });
return;
}
// Generate unique agent code
let agentCode = generateAgentCode();
let attempts = 0;
while (attempts < 10) {
const dup = await pool.query("SELECT id FROM sales_agents WHERE agent_code = $1", [agentCode]);
if (dup.rows.length === 0) break;
agentCode = generateAgentCode();
attempts++;
}
// Create the linked discount code (5% off service fees)
const dcResult = await pool.query(
`INSERT INTO discount_codes (code, description, discount_type, discount_value, referral_partner, referral_email, referral_pct, active)
VALUES ($1, $2, 'percent', 5, $3, $4, 0, TRUE)
RETURNING id`,
[agentCode, `Sales agent: ${name}`, name, email.toLowerCase().trim()],
);
const discountCodeId = dcResult.rows[0].id;
// Create the agent
const result = await pool.query(
`INSERT INTO sales_agents (agent_code, discount_code_id, name, email, phone, company, commission_default_cents, commission_pct, commission_overrides, notes, onboarded_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())
RETURNING id, agent_code`,
[agentCode, discountCodeId, name, email.toLowerCase().trim(), phone || null, company || null,
commission_default_cents || 30000, commission_pct || 10,
JSON.stringify(commission_overrides || {}), notes || null],
);
const agent = result.rows[0];
res.status(201).json({
success: true,
agent_id: agent.id,
agent_code: agent.agent_code,
referral_url: `https://performancewest.net/order/canada-crtc?code=${agent.agent_code}`,
message: `Agent created. Referral code: ${agent.agent_code}. Client gets 5% off, agent earns commission per sale.`,
});
} catch (err) {
console.error("[agents] Create error:", err);
res.status(500).json({ error: "Could not create agent." });
}
});
// =====================================================================
// Admin: List all agents
// =====================================================================
router.get("/api/v1/admin/agents", requireAdmin, async (req, res) => {
try {
const active = req.query.active !== "false";
const result = await pool.query(
`SELECT id, agent_code, name, email, phone, company, active,
commission_default_cents, commission_pct,
total_referrals, total_earned_cents, total_paid_cents, total_pending_cents,
onboarded_at, created_at
FROM sales_agents WHERE ($1::boolean IS NULL OR active = $1) ORDER BY created_at DESC`,
[active],
);
res.json({ agents: result.rows });
} catch (err) {
console.error("[agents] List error:", err);
res.status(500).json({ error: "Could not load agents." });
}
});
// =====================================================================
// Admin: Get single agent with commission history
// =====================================================================
router.get("/api/v1/admin/agents/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const agent = await pool.query("SELECT * FROM sales_agents WHERE id = $1", [id]);
if (agent.rows.length === 0) { res.status(404).json({ error: "Agent not found." }); return; }
const commissions = await pool.query(
"SELECT * FROM commission_ledger WHERE agent_id = $1 ORDER BY created_at DESC LIMIT 50",
[id],
);
res.json({ agent: agent.rows[0], commissions: commissions.rows });
} catch (err) {
console.error("[agents] Get error:", err);
res.status(500).json({ error: "Could not load agent." });
}
});
// =====================================================================
// Admin: Update agent
// =====================================================================
router.patch("/api/v1/admin/agents/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const { name, phone, company, commission_default_cents, commission_pct, commission_overrides, active, notes } = req.body ?? {};
const updates: string[] = [];
const params: any[] = [];
let idx = 1;
if (name !== undefined) { updates.push(`name = $${idx++}`); params.push(name); }
if (phone !== undefined) { updates.push(`phone = $${idx++}`); params.push(phone); }
if (company !== undefined) { updates.push(`company = $${idx++}`); params.push(company); }
if (commission_default_cents !== undefined) { updates.push(`commission_default_cents = $${idx++}`); params.push(commission_default_cents); }
if (commission_pct !== undefined) { updates.push(`commission_pct = $${idx++}`); params.push(commission_pct); }
if (commission_overrides !== undefined) { updates.push(`commission_overrides = $${idx++}`); params.push(JSON.stringify(commission_overrides)); }
if (active !== undefined) { updates.push(`active = $${idx++}`); params.push(active); }
if (notes !== undefined) { updates.push(`notes = $${idx++}`); params.push(notes); }
if (updates.length > 0) {
updates.push("updated_at = now()");
params.push(id);
await pool.query(`UPDATE sales_agents SET ${updates.join(", ")} WHERE id = $${idx}`, params);
}
res.json({ success: true, message: "Agent updated." });
} catch (err) {
console.error("[agents] Update error:", err);
res.status(500).json({ error: "Could not update agent." });
}
});
// =====================================================================
// Admin: List commissions (with filters)
// =====================================================================
router.get("/api/v1/admin/commissions", requireAdmin, async (req, res) => {
try {
const status = req.query.status as string || "";
const agentId = req.query.agent_id as string || "";
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 200);
const offset = parseInt(req.query.offset as string, 10) || 0;
let where = "WHERE 1=1";
const params: any[] = [];
let idx = 1;
if (status) { where += ` AND c.status = $${idx++}`; params.push(status); }
if (agentId) { where += ` AND c.agent_id = $${idx++}`; params.push(parseInt(agentId, 10)); }
const countResult = await pool.query(`SELECT COUNT(*) as total FROM commission_ledger c ${where}`, params);
params.push(limit, offset);
const result = await pool.query(
`SELECT c.*, a.name as agent_name, a.email as agent_email
FROM commission_ledger c
JOIN sales_agents a ON c.agent_id = a.id
${where}
ORDER BY c.created_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
params,
);
res.json({
commissions: result.rows,
total: parseInt(countResult.rows[0].total, 10),
limit, offset,
});
} catch (err) {
console.error("[commissions] List error:", err);
res.status(500).json({ error: "Could not load commissions." });
}
});
// =====================================================================
// Admin: Update commission status (approve, pay, cancel)
// =====================================================================
router.patch("/api/v1/admin/commissions/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const { status, payment_method, payment_reference, notes } = req.body ?? {};
const current = await pool.query("SELECT * FROM commission_ledger WHERE id = $1", [id]);
if (current.rows.length === 0) { res.status(404).json({ error: "Commission not found." }); return; }
const comm = current.rows[0];
const updates: string[] = [];
const params: any[] = [];
let idx = 1;
if (status) {
updates.push(`status = $${idx++}`); params.push(status);
if (status === "approved") {
updates.push(`approved_by = $${idx++}`); params.push(req.admin!.id);
updates.push("approved_at = now()");
}
if (status === "paid") {
updates.push("paid_at = now()");
if (payment_method) { updates.push(`payment_method = $${idx++}`); params.push(payment_method); }
if (payment_reference) { updates.push(`payment_reference = $${idx++}`); params.push(payment_reference); }
// Update agent totals
await pool.query(
"UPDATE sales_agents SET total_paid_cents = total_paid_cents + $1, total_pending_cents = total_pending_cents - $1, total_earned_cents = total_earned_cents + $1, updated_at = now() WHERE id = $2",
[comm.commission_cents, comm.agent_id],
);
}
if (status === "cancelled") {
await pool.query(
"UPDATE sales_agents SET total_pending_cents = total_pending_cents - $1, updated_at = now() WHERE id = $2",
[comm.commission_cents, comm.agent_id],
);
}
}
if (notes !== undefined) { updates.push(`notes = $${idx++}`); params.push(notes); }
if (updates.length > 0) {
updates.push("updated_at = now()");
params.push(id);
await pool.query(`UPDATE commission_ledger SET ${updates.join(", ")} WHERE id = $${idx}`, params);
}
res.json({ success: true, message: `Commission status updated to: ${status || "updated"}` });
} catch (err) {
console.error("[commissions] Update error:", err);
res.status(500).json({ error: "Could not update commission." });
}
});
// =====================================================================
// Admin: Commission stats dashboard
// =====================================================================
router.get("/api/v1/admin/commissions/stats", requireAdmin, async (req, res) => {
try {
const result = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COUNT(*) FILTER (WHERE status = 'eligible') as eligible_count,
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
COUNT(*) FILTER (WHERE status = 'paid') as paid_count,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
COALESCE(SUM(commission_cents) FILTER (WHERE status = 'pending'), 0) as pending_cents,
COALESCE(SUM(commission_cents) FILTER (WHERE status = 'eligible'), 0) as eligible_cents,
COALESCE(SUM(commission_cents) FILTER (WHERE status IN ('approved','processing')), 0) as approved_cents,
COALESCE(SUM(commission_cents) FILTER (WHERE status = 'paid'), 0) as paid_cents,
COUNT(DISTINCT agent_id) as active_agents
FROM commission_ledger
`);
res.json({ stats: result.rows[0] });
} catch (err) {
res.status(500).json({ error: "Could not load commission stats." });
}
});
export default router;

View file

@ -0,0 +1,29 @@
/**
* GET /api/v1/amb/locations list active Anytime Mailbox locations with pricing.
* Optional query param: ?province=BC (default) or ?province=ON
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
const router = Router();
router.get("/api/v1/amb/locations", async (req: Request, res: Response) => {
try {
const province = ((req.query.province as string) || "BC").toUpperCase();
const { rows } = await pool.query(
`SELECT slug, name, full_address, city, province, postal_code,
monthly_price_usd, yearly_price_usd, plan_name, available_units,
operator_name
FROM amb_locations
WHERE is_active = TRUE AND province = $1
ORDER BY city, name`,
[province],
);
res.json({ locations: rows });
} catch (err: any) {
console.error("[amb] locations error:", err);
res.status(500).json({ error: "Failed to load locations" });
}
});
export default router;

242
api/src/routes/bundles.ts Normal file
View file

@ -0,0 +1,242 @@
/**
* Service bundle routes.
*
* Bundles give 20% off when purchasing all services in a category
* or a curated cross-category package.
*/
import { Router } from "express";
import { pool } from "../db.js";
import { submitLimiter } from "../middleware/rate-limit.js";
import { v4 as uuidv4 } from "uuid";
const router = Router();
// Service prices in cents (must match the current site pricing)
// discountable: true = our service fee (eligible for bundle discount)
// false = pass-through fee (state fees — NOT discountable)
const SERVICE_PRICES: Record<string, { price: number; name: string; quote: boolean; discountable: boolean }> = {
"flsa-audit": { price: 149900, name: "FLSA / Wage & Hour Audit", quote: false, discountable: true },
"contractor-classification": { price: 49900, name: "Contractor Classification Review", quote: false, discountable: true },
"handbook-review": { price: 99900, name: "Employee Handbook Review", quote: false, discountable: true },
"policy-development": { price: 0, name: "Workplace Policy Development", quote: true, discountable: true },
"ccpa-audit": { price: 249900, name: "CCPA/CPRA Compliance Audit", quote: false, discountable: true },
"privacy-policy": { price: 49900, name: "Privacy Policy Generation", quote: false, discountable: true },
"data-mapping": { price: 0, name: "Data Mapping & Inventory", quote: true, discountable: true },
"breach-response": { price: 199900, name: "Breach Response Planning", quote: false, discountable: true },
"consent-audit": { price: 129900, name: "SMS/Call Consent Audit", quote: false, discountable: true },
"dnc-compliance": { price: 79900, name: "DNC Compliance Review", quote: false, discountable: true },
"campaign-review": { price: 59900, name: "Campaign Compliance Review", quote: false, discountable: true },
"fcc-499a": { price: 79900, name: "FCC 499A Filing", quote: false, discountable: true },
"stir-shaken": { price: 0, name: "STIR/SHAKEN Implementation", quote: true, discountable: true },
"ipes-isp": { price: 129900, name: "IPES & ISP Registrations", quote: false, discountable: true },
"database-management": { price: 49900, name: "Telecom Database Management", quote: false, discountable: true },
"state-puc": { price: 39900, name: "State PUC/PSC Filings", quote: false, discountable: true },
"formation": { price: 17900, name: "Business Formation (Basic)", quote: false, discountable: true },
"state-registration": { price: 24900, name: "State Registration", quote: false, discountable: true },
"annual-reports": { price: 9900, name: "Annual Report Filing", quote: false, discountable: true },
"registered-agent": { price: 9900, name: "Registered Agent Service", quote: false, discountable: false },
};
// These are NEVER discountable — pass-through costs and third-party fees
// State filing fees: calculated separately per state, added at order time
function calculateBundlePrice(services: string[], discountPct: number) {
let discountableTotal = 0; // our service fees — eligible for bundle discount
let nonDiscountableTotal = 0; // pass-through fees — never discounted
let hasQuoteItems = false;
const items: Array<{ slug: string; name: string; price: number; quote: boolean; discountable: boolean }> = [];
for (const slug of services) {
const svc = SERVICE_PRICES[slug];
if (!svc) continue;
items.push({ slug, name: svc.name, price: svc.price, quote: svc.quote, discountable: svc.discountable });
if (svc.quote) {
hasQuoteItems = true;
} else if (svc.discountable) {
discountableTotal += svc.price;
} else {
nonDiscountableTotal += svc.price;
}
}
const originalTotal = discountableTotal + nonDiscountableTotal;
const discountCents = Math.round((discountableTotal * discountPct) / 100);
const finalTotal = originalTotal - discountCents;
return { items, originalTotal, discountableTotal, nonDiscountableTotal, discountPct, discountCents, finalTotal, hasQuoteItems };
}
// GET /api/v1/bundles — List all active bundles with calculated pricing
router.get("/api/v1/bundles", async (_req, res) => {
try {
const result = await pool.query(
"SELECT * FROM service_bundles WHERE active = TRUE ORDER BY display_order, name",
);
const bundles = result.rows.map((b: any) => {
const calc = calculateBundlePrice(b.services, b.discount_pct);
return {
slug: b.slug,
name: b.name,
description: b.description,
category: b.category,
discount_pct: b.discount_pct,
services: calc.items,
original_total_cents: calc.originalTotal,
discount_cents: calc.discountCents,
final_total_cents: calc.finalTotal,
has_quote_items: calc.hasQuoteItems,
savings_text: `Save $${(calc.discountCents / 100).toFixed(0)} (${b.discount_pct}% off)`,
};
});
res.json({ bundles });
} catch (err) {
console.error("[bundles] List error:", err);
res.status(500).json({ error: "Could not load bundles." });
}
});
// GET /api/v1/bundles/:slug — Single bundle with pricing
router.get("/api/v1/bundles/:slug", async (req, res) => {
try {
const result = await pool.query(
"SELECT * FROM service_bundles WHERE slug = $1 AND active = TRUE",
[req.params.slug],
);
if (result.rows.length === 0) {
res.status(404).json({ error: "Bundle not found." });
return;
}
const b = result.rows[0];
const calc = calculateBundlePrice(b.services, b.discount_pct);
res.json({
bundle: {
slug: b.slug,
name: b.name,
description: b.description,
category: b.category,
discount_pct: b.discount_pct,
services: calc.items,
original_total_cents: calc.originalTotal,
discount_cents: calc.discountCents,
final_total_cents: calc.finalTotal,
has_quote_items: calc.hasQuoteItems,
},
});
} catch (err) {
console.error("[bundles] Get error:", err);
res.status(500).json({ error: "Could not load bundle." });
}
});
// POST /api/v1/bundles/order — Place a bundle order
router.post("/api/v1/bundles/order", submitLimiter, async (req, res) => {
try {
const {
bundle_slug, customer_name, customer_email, customer_phone,
customer_company, discount_code,
} = req.body ?? {};
if (!bundle_slug || !customer_name || !customer_email) {
res.status(400).json({ error: "Missing required fields: bundle_slug, customer_name, customer_email" });
return;
}
// Get the bundle
const bundleResult = await pool.query(
"SELECT * FROM service_bundles WHERE slug = $1 AND active = TRUE",
[bundle_slug],
);
if (bundleResult.rows.length === 0) {
res.status(404).json({ error: "Bundle not found." });
return;
}
const bundle = bundleResult.rows[0];
const calc = calculateBundlePrice(bundle.services, bundle.discount_pct);
// Discount code — only applies to discountable service fees (NOT state fees)
let discountCodeCents = 0;
if (discount_code) {
const dcResult = await pool.query("SELECT * FROM discount_codes WHERE code = $1 AND active = TRUE", [discount_code.toUpperCase()]);
if (dcResult.rows.length > 0) {
const dc = dcResult.rows[0];
// Apply discount code ONLY to the already-discounted service fee total (after bundle discount)
const discountableAfterBundle = calc.discountableTotal - calc.discountCents;
if (dc.discount_type === "percent") {
discountCodeCents = Math.round((discountableAfterBundle * dc.discount_value) / 100);
} else {
discountCodeCents = Math.min(dc.discount_value, discountableAfterBundle);
}
}
}
// Grand total: discounted service fees + non-discountable fees
const grandTotal = calc.finalTotal - discountCodeCents;
const year = new Date().getFullYear();
const short = uuidv4().split("-")[0]!.toUpperCase();
const orderNumber = `BDL-${year}-${short}`;
const result = await pool.query(
`INSERT INTO bundle_orders (
bundle_slug, order_number, customer_name, customer_email, customer_phone, customer_company,
original_total_cents, discount_pct, discount_cents, final_total_cents,
discount_code, discount_code_cents, status
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'received')
RETURNING id, order_number`,
[
bundle_slug, orderNumber, customer_name, customer_email.toLowerCase().trim(),
customer_phone || null, customer_company || null,
calc.originalTotal, bundle.discount_pct, calc.discountCents, grandTotal,
discount_code ? discount_code.toUpperCase() : null, discountCodeCents,
],
);
// Create commission if this order used an agent's referral code
if (discount_code) {
try {
const { createCommission } = await import("./agents.js");
// Check if the discount code belongs to a sales agent
const agentCheck = await pool.query(
"SELECT sa.agent_code FROM sales_agents sa JOIN discount_codes dc ON sa.discount_code_id = dc.id WHERE dc.code = $1 AND sa.active = TRUE",
[discount_code.toUpperCase()],
);
if (agentCheck.rows.length > 0) {
await createCommission({
agentCode: agentCheck.rows[0].agent_code,
orderType: "bundle",
orderId: result.rows[0].id,
orderNumber: result.rows[0].order_number,
serviceSlug: bundle_slug,
customerName: customer_name,
customerEmail: customer_email,
orderAmountCents: grandTotal,
discountCents: discountCodeCents,
});
}
} catch (commErr) {
console.error("[bundles] Commission creation failed (non-blocking):", commErr);
}
}
res.status(201).json({
success: true,
order_number: result.rows[0].order_number,
bundle: bundle.name,
original_total: `$${(calc.originalTotal / 100).toFixed(2)}`,
bundle_discount: `- $${(calc.discountCents / 100).toFixed(2)} (${bundle.discount_pct}% bundle discount)`,
code_discount: discountCodeCents > 0 ? `- $${(discountCodeCents / 100).toFixed(2)}` : null,
total: `$${(grandTotal / 100).toFixed(2)}`,
message: "Bundle order received. We will begin processing within one business day.",
});
} catch (err) {
console.error("[bundles] Order error:", err);
res.status(500).json({ error: "Could not place bundle order." });
}
});
export default router;

View file

@ -0,0 +1,922 @@
import { Router } from "express";
import { pool } from "../db.js";
import { submitLimiter } from "../middleware/rate-limit.js";
import { requirePortalAuth } from "../middleware/portalAuth.js";
import { v4 as uuidv4 } from "uuid";
const router = Router();
import { cadToUsdCents } from "../fx.js";
const SERVICE_FEE = 389900; // $3,899 USD
const TRADE_NAME_SERVICE_FEE = 7500; // $75 USD — trade name filing service add-on
const NAMED_COMPANY_SERVICE_FEE = 8500; // $85 USD — named company service add-on
const EXPEDITED_FEE = 50000; // $500 USD (our fee for priority handling)
// Canadian government fees in CAD cents — per province
const GOV_FEES_CAD: Record<string, { numbered: number; numbered_tradename: number; named: number; expedite: number }> = {
BC: { numbered: 35000, numbered_tradename: 39000, named: 38000, expedite: 10000 }, // C$350 incorp, C$40 trade name, C$30 name reservation, C$100 expedite
ON: { numbered: 36000, numbered_tradename: 40000, named: 38500, expedite: 0 }, // C$360 incorp, C$40 trade name, C$25 name search, no expedite
};
// Registered office locations — default is Victoria Dr (included in price)
// Premium locations charge the difference vs. base cost
// AMB locations are now stored in the amb_locations PG table (scraped daily).
// The old MAILBOX_LOCATIONS constant has been removed.
// POST /api/v1/canada-crtc/orders — Place a Canadian CRTC order
router.post("/api/v1/canada-crtc/orders", submitLimiter, async (req, res) => {
try {
const {
customer_name, customer_email, customer_phone, customer_company,
company_type, company_name_choice1, company_name_choice2, company_name_choice3,
trade_name, add_trade_name,
// Director — split name fields for BC Registry
director_first_name, director_middle_name, director_last_name,
director_name, // backward-compat concatenated field
director_street, director_street2, director_city, director_province,
director_postal, director_country, director_citizenship,
// Director mailing address (if different)
director_mailing_different, director_mailing_street, director_mailing_street2,
director_mailing_city, director_mailing_province, director_mailing_postal,
director_mailing_country,
// Additional directors (JSON array)
additional_directors,
// Legacy field (kept for backward compat)
director_address,
// DID routing
did_routing_type, did_forward_number, did_sip_uri, did_sip_ip,
services_description, geographic_coverage, include_bits, domain_privacy,
regulatory_contact_name, regulatory_contact_email, regulatory_contact_phone,
id_upload_token, discount_code, expedited, mailbox_location,
identity_session_id,
// Own Canadian registered office (skip Anytime Mailbox)
has_own_ca_address, own_ca_street, own_ca_city, own_ca_province, own_ca_postal,
own_ca_company, own_ca_attn,
// AMB location selection
amb_location_slug,
// Province selection (BC or ON)
incorporation_province,
// Existing Canadian DID (skips DID provisioning if provided)
existing_ca_did,
// Disclaimer acknowledgment
disclaimer_agreed,
} = req.body ?? {};
const province = (incorporation_province || "BC").toUpperCase();
if (!["BC", "ON"].includes(province)) {
res.status(400).json({ error: "incorporation_province must be 'BC' or 'ON'." });
return;
}
// Validate existing Canadian DID area code if provided
if (existing_ca_did) {
const CANADIAN_AREA_CODES = [
// BC
"236","250","604","672","778",
// ON
"226","249","289","343","365","382","416","437","519","548","613","647","683","705","742","753","807","905",
// AB
"368","403","587","780","825",
// QC
"263","354","367","418","438","450","468","514","579","581","819","873",
// MB
"204","431",
// SK
"306","639",
// NS
"782","902",
// NB
"428","506",
// NL
"709",
// PE
"782","902",
// NT/NU/YT
"867",
];
const digits = existing_ca_did.replace(/\D/g, "");
const areaCode = digits.startsWith("1") ? digits.slice(1, 4) : digits.slice(0, 3);
if (!CANADIAN_AREA_CODES.includes(areaCode)) {
res.status(400).json({ error: `Invalid Canadian area code: ${areaCode}. Please enter a valid Canadian phone number.` });
return;
}
}
// Build director_name from split fields if not provided directly
const resolvedDirectorName = director_name
|| [director_first_name, director_middle_name, director_last_name].filter(Boolean).join(" ");
// Build director_address JSON from individual fields if not provided as JSON
const resolvedDirectorAddress = director_address
|| JSON.stringify({
street: director_street || "",
street2: director_street2 || "",
city: director_city || "",
province: director_province || "",
postal: director_postal || "",
country: director_country || "",
});
if (!customer_name || !customer_email || !resolvedDirectorName || !services_description) {
const missing = [
!customer_name && "customer_name",
!customer_email && "customer_email",
!resolvedDirectorName && "director_name",
!services_description && "services_description",
].filter(Boolean);
console.warn("[canada-crtc] Missing required fields:", missing.join(", "), "| body keys:", Object.keys(req.body ?? {}).join(","));
res.status(400).json({ error: "Missing required fields.", missing });
return;
}
if ((director_first_name || director_last_name) && (!director_first_name || !director_last_name)) {
res.status(400).json({ error: "Both director first name and last name are required." });
return;
}
if (!["numbered", "numbered_tradename", "named"].includes(company_type)) {
res.status(400).json({ error: "company_type must be 'numbered', 'numbered_tradename', or 'named'." });
return;
}
if (company_type === "named" && !company_name_choice1) {
res.status(400).json({ error: "At least one name choice is required for named companies." });
return;
}
if (company_type === "numbered_tradename" && !trade_name) {
res.status(400).json({ error: "A trade name is required for numbered + trade name companies." });
return;
}
// ── Identity verification gate ────────────────────────────────────────────
// Required for ALL orders regardless of payment method.
// In test mode (STRIPE_SECRET_KEY starts with sk_test_), identity is optional
// so automated E2E tests can complete without Stripe Identity interaction.
const effectiveStripeKey =
(process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_SECRET_KEY?.trim()) ||
process.env.STRIPE_SECRET_KEY ||
"";
const isTestMode = effectiveStripeKey.startsWith("sk_test_");
if (!identity_session_id && !isTestMode) {
res.status(400).json({
error: "Identity verification is required before placing an order.",
code: "IDENTITY_REQUIRED",
});
return;
}
// In test mode with no identity session, skip the entire identity verification block.
// Otherwise, validate the identity session fully.
let identityResult = "verified"; // default for test mode bypass
if (!(isTestMode && !identity_session_id)) {
const ivResult = await pool.query(
`SELECT overall_result, name_match, name_match_score, dob_match,
form_director_name, stripe_status, doc_expired, order_number
FROM identity_verifications WHERE stripe_session_id = $1`,
[identity_session_id],
);
if (!ivResult.rows.length) {
res.status(400).json({
error: "Identity verification session not found. Please complete identity verification.",
code: "IDENTITY_NOT_FOUND",
});
return;
}
const iv = ivResult.rows[0] as Record<string, unknown>;
if (iv.order_number) {
res.status(400).json({
error: "Identity verification session has already been used. Please start a new verification.",
code: "IDENTITY_ALREADY_USED",
});
return;
}
if (iv.overall_result === "failed") {
res.status(422).json({
error: "Identity verification failed. The name on your ID document does not match the director name provided. Please contact us if you believe this is an error.",
code: "IDENTITY_FAILED",
name_match_score: iv.name_match_score,
});
return;
}
if (iv.overall_result === "pending" || iv.stripe_status === "requires_input") {
res.status(400).json({
error: "Identity verification is still in progress. Please complete the verification before submitting.",
code: "IDENTITY_PENDING",
});
return;
}
identityResult = iv.overall_result as string;
}
// Convert CAD government fees to USD at daily rate + 10% buffer, rounded up
const provFees = GOV_FEES_CAD[province] || GOV_FEES_CAD.BC;
const govFeesCad = company_type === "named" ? provFees.named
: company_type === "numbered_tradename" ? provFees.numbered_tradename
: provFees.numbered;
const govFees = await cadToUsdCents(govFeesCad);
// Add-on service fees for named/trade name options
const typeAddon = company_type === "named" ? NAMED_COMPANY_SERVICE_FEE
: company_type === "numbered_tradename" ? TRADE_NAME_SERVICE_FEE
: 0;
// Discount code (applies to service fee only, not gov fees)
let discountCents = 0;
if (discount_code) {
const dcResult = await pool.query("SELECT * FROM discount_codes WHERE code = $1 AND active = TRUE", [discount_code.toUpperCase()]);
if (dcResult.rows.length > 0) {
const dc = dcResult.rows[0];
const discountableAmount = SERVICE_FEE + typeAddon; // discount applies to full service fee
if (dc.discount_type === "percent") {
discountCents = Math.round((discountableAmount * dc.discount_value) / 100);
} else {
discountCents = Math.min(dc.discount_value, discountableAmount);
}
}
}
const provExpediteFeeUsd = (expedited && provFees.expedite > 0) ? await cadToUsdCents(provFees.expedite) : 0;
const expediteFees = expedited ? (EXPEDITED_FEE + provExpediteFeeUsd) : 0;
// AMB location — look up from DB if selected, or skip if client has own address
let ambAnnualPriceCents = 0;
let ambLocationData: {
slug: string; name: string; full_address: string;
city: string; postal_code: string; operator_name: string | null;
} | null = null;
const useOwnAddress = has_own_ca_address === true;
if (!useOwnAddress && amb_location_slug) {
const { rows: ambRows } = await pool.query(
"SELECT slug, name, full_address, city, postal_code, yearly_price_usd, operator_name FROM amb_locations WHERE slug = $1 AND is_active = TRUE",
[amb_location_slug],
);
if (ambRows.length > 0) {
const amb = ambRows[0] as {
slug: string; name: string; full_address: string;
city: string; postal_code: string; yearly_price_usd: number; operator_name: string | null;
};
ambAnnualPriceCents = amb.yearly_price_usd;
ambLocationData = amb;
}
}
const serviceFeeTotal = SERVICE_FEE + typeAddon;
const total = serviceFeeTotal + govFees + expediteFees + ambAnnualPriceCents - discountCents;
const year = new Date().getFullYear();
const short = uuidv4().split("-")[0]!.toUpperCase();
const orderNumber = `CA-${year}-${short}`;
const defaultCity = province === "ON" ? "Toronto" : "Vancouver";
const resolvedMailboxAddress = useOwnAddress
? `${own_ca_street || ""}, ${own_ca_city || defaultCity}, ${own_ca_province || province} ${own_ca_postal || ""}`
: ambLocationData
? `${ambLocationData.full_address}, ${ambLocationData.city}, ${province} ${ambLocationData.postal_code}`
: "TBD — client will select in portal";
const result = await pool.query(
`INSERT INTO canada_crtc_orders (
order_number, customer_name, customer_email, customer_phone, customer_company,
company_type, company_name_choice1, company_name_choice2, company_name_choice3,
trade_name, add_trade_name,
director_name, director_first_name, director_middle_name, director_last_name,
director_address, director_citizenship,
director_mailing_different, director_mailing_address,
additional_directors,
did_routing_type, did_forward_number, did_sip_uri, did_sip_ip,
services_description, geographic_coverage, include_bits, domain_privacy,
regulatory_contact_name, regulatory_contact_email, regulatory_contact_phone,
id_upload_token, mailbox_address, service_fee_cents, government_fee_cents,
discount_code, discount_cents, total_cents, status, payment_status,
identity_session_id, identity_result,
has_own_ca_address, own_ca_street, own_ca_city, own_ca_province, own_ca_postal,
own_ca_company, own_ca_attn,
expedited, amb_location_slug, amb_annual_price_cents,
incorporation_province, existing_ca_did, disclaimer_agreed_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,'received','pending_payment',$39,$40,$41,$42,$43,$44,$45,$46,$47,$48,$49,$50,$51,$52,$53)
RETURNING id, order_number`,
[
orderNumber, customer_name, customer_email.toLowerCase().trim(),
customer_phone || null, customer_company || null,
company_type, company_name_choice1 || null, company_name_choice2 || null, company_name_choice3 || null,
trade_name || null, add_trade_name === true || company_type === "numbered_tradename",
resolvedDirectorName,
director_first_name || null, director_middle_name || null, director_last_name || null,
resolvedDirectorAddress, director_citizenship || null,
director_mailing_different || false,
director_mailing_different ? JSON.stringify({
street: director_mailing_street || "", street2: director_mailing_street2 || "",
city: director_mailing_city || "", province: director_mailing_province || "",
postal: director_mailing_postal || "", country: director_mailing_country || "",
}) : null,
additional_directors ? JSON.stringify(additional_directors) : null,
did_routing_type || "later",
did_forward_number || null,
did_sip_uri || null,
did_sip_ip || null,
services_description, geographic_coverage || "Canada-wide", include_bits !== false,
domain_privacy !== false,
regulatory_contact_name || customer_name, regulatory_contact_email || customer_email,
regulatory_contact_phone || customer_phone || null,
id_upload_token || null,
resolvedMailboxAddress,
serviceFeeTotal, govFees,
discount_code ? discount_code.toUpperCase() : null, discountCents, total,
identity_session_id, identityResult,
useOwnAddress, own_ca_street || null, own_ca_city || null, own_ca_province || province, own_ca_postal || null,
own_ca_company || null, own_ca_attn || null,
expedited === true,
amb_location_slug || null, ambAnnualPriceCents,
province,
existing_ca_did || null,
disclaimer_agreed ? new Date() : null,
],
);
const newOrderNumber = result.rows[0].order_number;
// Mark identity session as used by this order
await pool.query(
`UPDATE identity_verifications SET order_number = $1 WHERE stripe_session_id = $2`,
[newOrderNumber, identity_session_id],
);
// Mark any older unpaid orders from the same email as abandoned
// so we don't send payment reminders for superseded orders
await pool.query(
`UPDATE canada_crtc_orders
SET payment_status = 'abandoned'
WHERE customer_email = $1
AND payment_status = 'pending_payment'
AND order_number != $2`,
[customer_email.toLowerCase().trim(), newOrderNumber],
).catch(() => {});
// ── Upsert customer record + save director/address for portal prefill ──
try {
const email = customer_email.toLowerCase().trim();
// Upsert customer
const custResult = await pool.query<{ id: number }>(
`INSERT INTO customers (email, name, phone, company)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email) DO UPDATE SET
name = COALESCE(EXCLUDED.name, customers.name),
phone = COALESCE(EXCLUDED.phone, customers.phone),
company = COALESCE(EXCLUDED.company, customers.company),
updated_at = NOW()
RETURNING id`,
[email, customer_name || null, customer_phone || null, customer_company || null],
);
const customerId = custResult.rows[0]?.id;
if (customerId) {
// Link order to customer
await pool.query(
`UPDATE canada_crtc_orders SET customer_id = $1 WHERE order_number = $2`,
[customerId, result.rows[0].order_number],
);
// Parse director address JSON into components if present
let addrId: number | null = null;
try {
const addrParsed = typeof director_address === "string"
? JSON.parse(director_address)
: director_address;
if (addrParsed?.country) {
const addrResult = await pool.query<{ id: number }>(
`INSERT INTO customer_addresses
(customer_id, street, street2, city, province, postal, country, source_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id`,
[
customerId,
addrParsed.street || "", addrParsed.street2 || null,
addrParsed.city || "", addrParsed.province || null,
addrParsed.postal || "", addrParsed.country,
result.rows[0].order_number,
],
);
addrId = addrResult.rows[0]?.id ?? null;
}
} catch { /* address not JSON — skip */ }
// Save director
if (director_name) {
await pool.query(
`INSERT INTO customer_directors (customer_id, name, citizenship, address_id, source_order)
VALUES ($1,$2,$3,$4,$5)`,
[customerId, director_name, director_citizenship || null, addrId, result.rows[0].order_number],
);
}
}
} catch (portalErr) {
console.error("[canada-crtc] Portal upsert failed (non-blocking):", portalErr);
}
// Create commission if this order used an agent's referral code
if (discount_code) {
try {
const { createCommission } = await import("./agents.js");
// Check if the discount code belongs to a sales agent
const agentCheck = await pool.query(
"SELECT sa.agent_code FROM sales_agents sa JOIN discount_codes dc ON sa.discount_code_id = dc.id WHERE dc.code = $1 AND sa.active = TRUE",
[discount_code.toUpperCase()],
);
if (agentCheck.rows.length > 0) {
await createCommission({
agentCode: agentCheck.rows[0].agent_code,
orderType: "canada_crtc",
orderId: result.rows[0].id,
orderNumber: result.rows[0].order_number,
serviceSlug: "canada-crtc",
customerName: customer_name,
customerEmail: customer_email,
orderAmountCents: total,
discountCents: discountCents,
});
}
} catch (commErr) {
console.error("[canada-crtc] Commission creation failed (non-blocking):", commErr);
}
}
res.status(201).json({
success: true,
order_number: result.rows[0].order_number,
order_id: result.rows[0].order_number,
order_type: "canada_crtc",
payment_status: "pending_payment",
// Pricing breakdown for checkout page to use when creating Stripe session
pricing: {
service_fee_cents: serviceFeeTotal,
type_addon_cents: typeAddon,
government_fee_cents: govFees,
expedite_fee_cents: expediteFees,
mailbox_annual_cents: ambAnnualPriceCents,
discount_cents: discountCents,
subtotal_cents: total,
},
identity_result: identityResult,
identity_needs_review: identityResult === "needs_review",
message: identityResult === "needs_review"
? "Order created. Your identity is under review — our team will verify your documents before processing begins. Payment will be collected now to reserve your place in queue."
: "Order created. Complete payment to begin processing.",
registered_office: useOwnAddress
? { location: "Client-provided", address: resolvedMailboxAddress }
: ambLocationData
? { location: ambLocationData.name, address: resolvedMailboxAddress, annual_price: `$${(ambAnnualPriceCents / 100).toFixed(2)}/yr` }
: { location: "To be selected", address: resolvedMailboxAddress },
total: `$${(total / 100).toFixed(2)} USD`,
});
} catch (err) {
console.error("[canada-crtc] Order error:", err);
res.status(500).json({ error: "Could not place order." });
}
});
// GET /api/v1/canada-crtc/mailbox-locations — redirects to /api/v1/amb/locations
router.get("/api/v1/canada-crtc/mailbox-locations", async (_req, res) => {
const { rows } = await pool.query(
`SELECT slug, name, full_address, city, province, postal_code,
monthly_price_usd, yearly_price_usd, plan_name
FROM amb_locations WHERE is_active = TRUE ORDER BY city, name`,
);
res.json({ locations: rows });
});
// ─── Domain Search + Selection ────────────────────────────────────────────────
/**
* POST /api/v1/canada-crtc/domain-search
* Real-time .ca domain availability check via CIRA WHOIS.
* Called by the client portal domain picker page.
* Body: { domain: "mycompany.ca", order_number: "CA-2026-XXXXX" }
*/
router.post("/api/v1/canada-crtc/domain-search", async (req, res) => {
const { domain, order_number } = req.body ?? {};
if (!domain || !order_number || typeof domain !== "string" || typeof order_number !== "string") {
res.status(400).json({ error: "domain and order_number are required (strings)" });
return;
}
// Validate .ca TLD
const cleanDomain = domain.toLowerCase().trim();
if (!cleanDomain.endsWith(".ca")) {
res.status(400).json({ error: "Only .ca domains are supported", domain: cleanDomain });
return;
}
// Verify the order exists and is in the correct state
const orderResult = await pool.query(
`SELECT order_number, payment_status, ca_domain, incorporation_number, incorporation_province
FROM canada_crtc_orders WHERE order_number = $1`,
[order_number],
);
if (!orderResult.rows.length) {
res.status(404).json({ error: "Order not found" });
return;
}
const order = orderResult.rows[0];
if (order.ca_domain) {
res.status(400).json({ error: "Domain already registered for this order", domain: order.ca_domain });
return;
}
if (!order.incorporation_number) {
res.status(400).json({ error: "Incorporation must complete before domain registration" });
return;
}
// WHOIS check via CIRA (raw socket query)
try {
const net = await import("net");
const available = await new Promise<boolean>((resolve) => {
const socket = net.createConnection(43, "whois.cira.ca");
let data = "";
socket.setTimeout(10000);
socket.on("connect", () => socket.write(`${cleanDomain}\r\n`));
socket.on("data", (chunk: Buffer) => { data += chunk.toString(); });
socket.on("end", () => resolve(data.includes("Not found")));
socket.on("error", () => resolve(false));
socket.on("timeout", () => { socket.destroy(); resolve(false); });
});
res.json({
domain: cleanDomain,
available,
message: available
? `${cleanDomain} is available!`
: `${cleanDomain} is already taken. Try another name.`,
});
} catch (err) {
console.error("[canada-crtc] WHOIS check error:", err);
res.status(500).json({ error: "Could not check domain availability" });
}
});
/**
* POST /api/v1/canada-crtc/domain-confirm
* Customer confirms their chosen .ca domain. Triggers domain registration
* and resumes the pipeline from Step 5.
* Body: { domain: "mycompany.ca", order_number: "CA-2026-XXXXX" }
*/
router.post("/api/v1/canada-crtc/domain-confirm", requirePortalAuth, async (req, res) => {
const { domain, order_number } = req.body ?? {};
if (!domain || !order_number) {
res.status(400).json({ error: "domain and order_number are required" });
return;
}
const cleanDomain = domain.toLowerCase().trim();
if (!cleanDomain.endsWith(".ca")) {
res.status(400).json({ error: "Only .ca domains are supported" });
return;
}
// Verify order is waiting for domain selection
const orderResult = await pool.query(
`SELECT order_number, payment_status, ca_domain, incorporation_number,
customer_email, domain_privacy, incorporation_province
FROM canada_crtc_orders WHERE order_number = $1`,
[order_number],
);
if (!orderResult.rows.length) {
res.status(404).json({ error: "Order not found" });
return;
}
const order = orderResult.rows[0];
if (order.ca_domain) {
res.status(400).json({ error: "Domain already registered", domain: order.ca_domain });
return;
}
if (order.payment_status !== "paid") {
res.status(400).json({ error: "Payment must be completed first" });
return;
}
// Store the customer's domain choice — the worker picks it up and registers
await pool.query(
`UPDATE canada_crtc_orders
SET ca_domain = $1,
updated_at = NOW()
WHERE order_number = $2`,
[cleanDomain, order_number],
);
console.log(`[canada-crtc] Customer selected domain: ${cleanDomain} for ${order_number}`);
// Dispatch the domain registration job to the worker
// The pipeline's Step 5 checks for ca_domain and registers it
try {
const jobPayload = {
action: "register_ca_domain",
order_number,
domain: cleanDomain,
incorporation_number: order.incorporation_number,
domain_privacy: order.domain_privacy ?? true,
customer_email: order.customer_email,
};
// POST to the job server to resume the pipeline
const jobResponse = await fetch("http://workers:8090/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(jobPayload),
});
if (!jobResponse.ok) {
console.error("[canada-crtc] Job dispatch failed:", await jobResponse.text());
}
} catch (jobErr) {
console.error("[canada-crtc] Could not dispatch domain registration job:", jobErr);
// Non-fatal — the scheduler will pick it up
}
res.json({
success: true,
domain: cleanDomain,
order_number,
message: `Domain ${cleanDomain} selected. Registration in progress — you'll receive an email when your domain and email are ready.`,
});
});
// ─── Port-Out + Domain Transfer ───────────────────────────────────────────────
/**
* POST /api/v1/canada-crtc/port-out/request
* Customer requests to port their Canadian DID to another carrier.
* We handle the LOA generation and Flowroute coordination the customer
* never sees the Flowroute account credentials (shared across all DIDs).
*
* Canadian LNP is regulated by the CRTC and processed through Canadian LNP Inc.
* The process is functionally identical to US LNP the winning carrier
* initiates the port, we validate + release through Flowroute.
* LOA uses the Canadian registered office as the service address.
*/
router.post("/api/v1/canada-crtc/port-out/request", requirePortalAuth, async (req, res) => {
const { order_number, new_carrier, new_carrier_contact, requested_date } = req.body ?? {};
if (!order_number || !new_carrier) {
res.status(400).json({ error: "order_number and new_carrier are required" });
return;
}
const orderResult = await pool.query(
`SELECT order_number, ca_did_number, company_name_final, mailbox_address, customer_email, customer_name
FROM canada_crtc_orders WHERE order_number = $1`,
[order_number],
);
if (!orderResult.rows.length) {
res.status(404).json({ error: "Order not found" });
return;
}
const order = orderResult.rows[0];
if (!order.ca_did_number) {
res.status(400).json({ error: "No DID provisioned for this order" });
return;
}
// Create an admin ToDo in ERPNext for the port-out
// Admin generates the LOA with Flowroute account info and coordinates the port.
try {
const { createResource } = await import("../erpnext-client.js");
await createResource("ToDo", {
description: (
`<b>DID Port-Out Request</b><br>` +
`Order: ${order_number}<br>` +
`DID: ${order.ca_did_number}<br>` +
`Company: ${order.company_name_final || "N/A"}<br>` +
`Customer: ${order.customer_name} (${order.customer_email})<br>` +
`Service Address (for LOA): ${order.mailbox_address || "329 Howe St, Vancouver, BC V6C 3N2"}<br><br>` +
`New Carrier: ${new_carrier}<br>` +
`New Carrier Contact: ${new_carrier_contact || "N/A"}<br>` +
`Requested Port Date: ${requested_date || "ASAP"}<br><br>` +
`<b>Action:</b> Generate LOA with Flowroute account details, ` +
`send to customer for signature, coordinate with new carrier. ` +
`Canadian LNP via Canadian LNP Inc. — 1-5 business days.`
),
priority: "High",
allocated_to: "Administrator",
reference_type: "Sales Order",
reference_name: order_number,
});
} catch (erpErr) {
console.error("[canada-crtc] Port-out ToDo creation failed:", erpErr);
}
// Email admin
console.log(`[canada-crtc] Port-out request: ${order.ca_did_number}${new_carrier} (${order_number})`);
res.json({
success: true,
message: "Port-out request submitted. We'll coordinate with your new carrier and send you an LOA to sign.",
});
});
/**
* POST /api/v1/canada-crtc/domain-transfer-out
* Customer requests to transfer their .ca domain to another registrar.
* For .ca domains, the transfer is handled through CIRA's registrar transfer process.
*
* NOTE: Porkbun v3 API doesn't expose an auth code endpoint.
* This creates an admin task to manually retrieve the auth code from
* the Porkbun dashboard and send it to the customer.
*/
router.post("/api/v1/canada-crtc/domain-transfer-out", requirePortalAuth, async (req, res) => {
const { order_number } = req.body ?? {};
if (!order_number) {
res.status(400).json({ error: "order_number is required" });
return;
}
const orderResult = await pool.query(
`SELECT order_number, ca_domain, company_name_final, customer_email, customer_name
FROM canada_crtc_orders WHERE order_number = $1`,
[order_number],
);
if (!orderResult.rows.length) {
res.status(404).json({ error: "Order not found" });
return;
}
const order = orderResult.rows[0];
if (!order.ca_domain) {
res.status(400).json({ error: "No domain registered for this order" });
return;
}
// Create admin ToDo to retrieve auth code from Porkbun dashboard
try {
const { createResource } = await import("../erpnext-client.js");
await createResource("ToDo", {
description: (
`<b>Domain Transfer-Out Request</b><br>` +
`Order: ${order_number}<br>` +
`Domain: ${order.ca_domain}<br>` +
`Company: ${order.company_name_final || "N/A"}<br>` +
`Customer: ${order.customer_name} (${order.customer_email})<br><br>` +
`<b>Action:</b><br>` +
`1. Log into Porkbun dashboard<br>` +
`2. Find ${order.ca_domain} → Domain Settings<br>` +
`3. Disable domain lock<br>` +
`4. Get the EPP/Auth transfer code<br>` +
`5. Email the auth code to ${order.customer_email}<br><br>` +
`CIRA .ca transfer takes up to 5 days after the new registrar submits.`
),
priority: "High",
allocated_to: "Administrator",
reference_type: "Sales Order",
reference_name: order_number,
});
} catch (erpErr) {
console.error("[canada-crtc] Domain transfer ToDo creation failed:", erpErr);
}
console.log(`[canada-crtc] Domain transfer-out request: ${order.ca_domain} (${order_number})`);
// For now, return a message that admin will send the auth code
// In the future, if Porkbun adds an API endpoint, we can automate this
res.json({
success: true,
auth_code: null, // Will be emailed by admin
message: "Transfer request submitted. We'll unlock your domain and email you the authorization code within 1 business day.",
});
});
// ─── DNS Nameserver Management ────────────────────────────────────────────────
const PORKBUN_API = "https://api.porkbun.com/api/json/v3";
const PORKBUN_KEY = process.env.PORKBUN_API_KEY || "";
const PORKBUN_SEC = process.env.PORKBUN_SECRET_KEY || "";
/**
* GET /api/v1/canada-crtc/domain-nameservers?domain=example.ca
* Get current nameservers for a domain via Porkbun API.
*/
router.get("/api/v1/canada-crtc/domain-nameservers", requirePortalAuth, async (req, res) => {
const domain = (req.query.domain as string || "").toLowerCase().trim();
if (!domain) {
res.status(400).json({ error: "domain parameter required" });
return;
}
try {
const pbResp = await fetch(`${PORKBUN_API}/domain/getNs/${domain}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apikey: PORKBUN_KEY, secretapikey: PORKBUN_SEC }),
});
const pbData = await pbResp.json() as { status: string; ns: string[] };
if (pbData.status === "SUCCESS" && pbData.ns) {
res.json({ success: true, domain, nameservers: pbData.ns });
} else {
res.status(400).json({ error: "Could not retrieve nameservers", detail: pbData });
}
} catch (err) {
console.error("[canada-crtc] Get nameservers error:", err);
res.status(500).json({ error: "Failed to query nameservers" });
}
});
/**
* POST /api/v1/canada-crtc/domain-nameservers
* Update nameservers for a domain via Porkbun API.
* Body: { order_number: "CA-2026-XXX", nameservers: ["ns1.example.com", "ns2.example.com"] }
*/
router.post("/api/v1/canada-crtc/domain-nameservers", requirePortalAuth, async (req, res) => {
const { order_number, nameservers } = req.body ?? {};
if (!order_number || !nameservers || !Array.isArray(nameservers) || nameservers.length < 2) {
res.status(400).json({ error: "order_number and at least 2 nameservers required" });
return;
}
// Verify order exists and has a domain
const orderResult = await pool.query(
`SELECT ca_domain, customer_email, customer_name FROM canada_crtc_orders WHERE order_number = $1`,
[order_number],
);
if (!orderResult.rows.length) {
res.status(404).json({ error: "Order not found" });
return;
}
const order = orderResult.rows[0];
if (!order.ca_domain) {
res.status(400).json({ error: "No domain registered for this order" });
return;
}
const domain = order.ca_domain as string;
const cleanNs = nameservers.map((ns: string) => ns.toLowerCase().trim()).filter(Boolean);
try {
const pbResp = await fetch(`${PORKBUN_API}/domain/updateNs/${domain}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apikey: PORKBUN_KEY,
secretapikey: PORKBUN_SEC,
ns: cleanNs,
}),
});
const pbData = await pbResp.json() as { status: string; message?: string };
if (pbData.status === "SUCCESS") {
console.log(`[canada-crtc] Nameservers updated for ${domain}: ${cleanNs.join(", ")} (${order_number})`);
// Notify admin
try {
const { createResource } = await import("../erpnext-client.js");
await createResource("ToDo", {
description: (
`<b>DNS Nameserver Change</b><br>` +
`Domain: ${domain}<br>` +
`Order: ${order_number}<br>` +
`Customer: ${order.customer_name} (${order.customer_email})<br>` +
`New NS: ${cleanNs.join(", ")}<br><br>` +
`Customer changed their nameservers. If they switched away from ours, ` +
`their email and website hosted on HestiaCP will stop working.`
),
priority: "Medium",
allocated_to: "Administrator",
});
} catch (erpErr) {
console.error("[canada-crtc] NS change ToDo failed:", erpErr);
}
res.json({ success: true, domain, nameservers: cleanNs });
} else {
console.error("[canada-crtc] Porkbun NS update failed:", pbData);
res.status(400).json({ error: pbData.message || "Nameserver update failed" });
}
} catch (err) {
console.error("[canada-crtc] NS update error:", err);
res.status(500).json({ error: "Failed to update nameservers" });
}
});
// GET /api/v1/canada-crtc/orders/:orderNumber — Check order status
router.get("/api/v1/canada-crtc/orders/:orderNumber", async (req, res) => {
try {
const result = await pool.query(
`SELECT order_number, company_type, company_name_final, incorporation_number, incorporation_province,
ca_domain, ca_did_number,
status, automation_status, binder_generated, binder_shipped, binder_tracking_number,
next_renewal_date, created_at, delivered_at
FROM canada_crtc_orders WHERE order_number = $1`,
[req.params.orderNumber],
);
if (result.rows.length === 0) {
res.status(404).json({ error: "Order not found." });
return;
}
res.json({ order: result.rows[0] });
} catch (err) {
res.status(500).json({ error: "Could not load order." });
}
});
export default router;

315
api/src/routes/cdr.ts Normal file
View file

@ -0,0 +1,315 @@
/**
* CDR Portal API profiles, upload tokens, bucket mappings, webhook,
* paywalled traffic-study response.
*
* Paywall model:
* The classified traffic study for a reporting year is locked until
* the customer has a `cdr_study_access_grants` row for that (profile,
* year). Grants are issued by the payment webhook in checkout.ts on
* fcc-499a / fcc-499a-499q / fcc-full-compliance / cdr-analysis.
*
* Response shape when locked: counts + ingestion health only, no %s,
* no pre-signed PDF URL. Admin bypass ignores the grant check.
*/
import { Router } from "express";
import type { Request, Response } from "express";
import { randomBytes, createHmac } from "crypto";
import { pool } from "../db.js";
const router = Router();
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
/** Ask the worker for a presigned MinIO URL (GET or PUT). */
async function presign(key: string, method: "GET" | "PUT", expires: number): Promise<string | null> {
if (!key) return null;
try {
const r = await fetch(`${WORKER_URL}/jobs/presign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, expires, method }),
});
if (!r.ok) return null;
const data = (await r.json()) as { url?: string };
return data.url || null;
} catch {
return null;
}
}
// ── Profile lookup helpers ────────────────────────────────────────────
async function loadProfile(profileId: number) {
const r = await pool.query(
`SELECT * FROM cdr_ingestion_profiles WHERE id = $1`,
[profileId],
);
return r.rows[0] || null;
}
async function hasGrant(profileId: number, year: number): Promise<string | null> {
const r = await pool.query(
`SELECT granted_by_order FROM cdr_study_access_grants
WHERE profile_id = $1 AND reporting_year = $2
LIMIT 1`,
[profileId, year],
);
return r.rows[0]?.granted_by_order ?? null;
}
function isAdminRequest(req: Request): boolean {
// Admin bypass — any request bearing the admin token header sees full data.
const token = (req.headers["x-admin-token"] || "").toString().trim();
const expected = process.env.ADMIN_API_TOKEN || "";
return Boolean(expected) && token === expected;
}
// ── GET profile id by telecom_entity_id (wizard pre-fill convenience) ─
router.get(
"/api/v1/cdr/profile/by-entity/:entity_id",
async (req: Request, res: Response) => {
const entityId = Number(req.params.entity_id);
if (!Number.isFinite(entityId) || entityId <= 0) {
res.status(400).json({ error: "bad entity_id" }); return;
}
const r = await pool.query(
`SELECT id FROM cdr_ingestion_profiles WHERE telecom_entity_id = $1`,
[entityId],
);
if (r.rows.length === 0) {
res.status(404).json({ error: "no cdr profile for this entity" }); return;
}
res.json({ profile_id: r.rows[0].id });
},
);
// ── GET traffic study (paywalled) ────────────────────────────────────
router.get(
"/api/v1/cdr/profile/:profile_id/study",
async (req: Request, res: Response) => {
const profileId = Number(req.params.profile_id);
const year = Number(req.query.year) || new Date().getUTCFullYear();
if (!Number.isFinite(profileId) || profileId <= 0) {
res.status(400).json({ error: "bad profile_id" }); return;
}
const profile = await loadProfile(profileId);
if (!profile) { res.status(404).json({ error: "profile not found" }); return; }
// Ingestion health — always visible regardless of paywall
const meter = await pool.query(
`SELECT bytes_stored, rows_ingested, last_measured_at
FROM cdr_usage_meters
WHERE profile_id = $1 AND reporting_year = $2`,
[profileId, year],
);
const uploads = await pool.query(
`SELECT COUNT(*)::int AS total_uploads,
MAX(created_at) AS last_upload_at,
COALESCE(SUM(rows_accepted), 0)::int AS rows_accepted,
COALESCE(SUM(rows_quarantined), 0)::int AS rows_quarantined
FROM cdr_ingestion_uploads
WHERE profile_id = $1`,
[profileId],
);
const ingestion = {
profile_configured: true,
total_uploads: uploads.rows[0].total_uploads,
last_upload_at: uploads.rows[0].last_upload_at,
rows_accepted: uploads.rows[0].rows_accepted,
rows_quarantined: uploads.rows[0].rows_quarantined,
bytes_stored: meter.rows[0]?.bytes_stored ?? 0,
rows_this_year: meter.rows[0]?.rows_ingested ?? 0,
last_measured_at: meter.rows[0]?.last_measured_at ?? null,
};
const grantOrder = await hasGrant(profileId, year);
const admin = isAdminRequest(req);
if (!grantOrder && !admin) {
// LOCKED — show counts only.
const siteBase =
process.env.SITE_URL ||
(process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "https://performancewest.net");
const unlockUrl =
`${siteBase}/order/fcc-499a?entity=${profile.telecom_entity_id}` +
`&year=${year}`;
res.json({
status: "locked",
reporting_year: year,
unlock_reason:
"Pay for your " + year + " Form 499-A filing (or the standalone " +
"CDR traffic study) to unlock the classified report.",
unlock_url: unlockUrl,
ingestion,
classified_report: null,
});
return;
}
// UNLOCKED — load the study row if one exists.
const study = await pool.query(
`SELECT * FROM cdr_traffic_studies
WHERE profile_id = $1 AND reporting_year = $2
ORDER BY CASE reporting_period WHEN 'ANNUAL' THEN 0
WHEN 'Q4' THEN 1 WHEN 'Q3' THEN 2
WHEN 'Q2' THEN 3 WHEN 'Q1' THEN 4 ELSE 5 END
LIMIT 1`,
[profileId, year],
);
if (study.rows.length === 0) {
res.json({
status: "unlocked_pending_study",
reporting_year: year,
granted_by_order: grantOrder,
message: "No traffic study has been generated yet for this period.",
ingestion,
classified_report: null,
});
return;
}
const row = study.rows[0];
// Pre-signed URLs for the PDF + XLSX — only produced when unlocked.
const [pdfUrl, xlsxUrl] = await Promise.all([
row.pdf_minio_path ? presign(row.pdf_minio_path, "GET", 3600) : null,
row.xlsx_minio_path ? presign(row.xlsx_minio_path, "GET", 3600) : null,
]);
res.json({
status: "unlocked",
reporting_year: year,
granted_by_order: grantOrder,
ingestion,
classified_report: {
reporting_period: row.reporting_period,
total_calls: Number(row.total_calls || 0),
total_minutes: Number(row.total_minutes || 0),
total_revenue_cents: Number(row.total_revenue_cents || 0),
interstate_pct: row.interstate_pct,
intrastate_pct: row.intrastate_pct,
international_pct: row.international_pct,
indeterminate_pct: row.indeterminate_pct,
interstate_pct_minutes: row.interstate_pct_minutes,
intrastate_pct_minutes: row.intrastate_pct_minutes,
international_pct_minutes: row.international_pct_minutes,
indeterminate_pct_minutes: row.indeterminate_pct_minutes,
wholesale_minutes: Number(row.wholesale_minutes || 0),
retail_minutes: Number(row.retail_minutes || 0),
orig_state_regions: row.orig_state_regions_json,
billing_state_regions: row.billing_state_regions_json,
methodology: row.methodology,
pdf_minio_path: row.pdf_minio_path,
xlsx_minio_path: row.xlsx_minio_path,
pdf_download_url: pdfUrl,
xlsx_download_url: xlsxUrl,
download_expires_in_seconds: 3600,
},
});
},
);
// ── Presigned upload token (browser drag-drop path) ─────────────────
router.post("/api/v1/cdr/upload-token", async (req: Request, res: Response) => {
const { profile_id, file_name } = req.body ?? {};
if (!profile_id || !file_name) {
res.status(400).json({ error: "profile_id and file_name required" });
return;
}
const profile = await loadProfile(Number(profile_id));
if (!profile) { res.status(404).json({ error: "profile not found" }); return; }
// Short-lived token; browser PUTs to MinIO directly.
const token = randomBytes(16).toString("hex");
const safeName = String(file_name).replace(/[^A-Za-z0-9._-]/g, "_");
const minioKey =
`cdr-uploads/${profile.customer_id}/raw/browser/` +
`${new Date().toISOString().replace(/[:.]/g, "")}_${token}_${safeName}`;
await pool.query(
`INSERT INTO cdr_ingestion_uploads
(profile_id, source, raw_minio_path, raw_sha256, status, summary_json)
VALUES ($1, 'browser', $2, $3, 'pending', $4::jsonb)`,
[profile.id, minioKey, `pending_${token}`, JSON.stringify({ token, file_name: safeName })],
);
// Generate the pre-signed MinIO PUT URL — browser PUTs directly, no
// API bandwidth.
const minioPutUrl = await presign(minioKey, "PUT", 24 * 3600);
res.status(201).json({
token,
minio_key: minioKey,
minio_put_url: minioPutUrl,
expires_in_seconds: 24 * 3600,
});
});
// ── Webhook (per-call stream from a switch) ─────────────────────────
router.post(
"/api/v1/cdr/webhook/:customer_token",
async (req: Request, res: Response) => {
const token = req.params.customer_token;
// Look up the profile by webhook token (stored on preset_config).
const r = await pool.query(
`SELECT id FROM cdr_ingestion_profiles
WHERE preset_config->>'webhook_token' = $1`,
[token],
);
if (r.rows.length === 0) {
res.status(404).json({ error: "unknown webhook token" }); return;
}
// Buffer body to MinIO under webhook/ prefix; the ingester will
// pick it up on next cycle. Implementation left to the MinIO helper.
res.status(202).json({ received: true });
},
);
// ── Bucket-mapping CRUD ─────────────────────────────────────────────
router.get(
"/api/v1/cdr/profile/:profile_id/bucket-mappings",
async (req: Request, res: Response) => {
const pid = Number(req.params.profile_id);
const rows = await pool.query(
`SELECT id, match_type, match_value, bucket, override_priority
FROM cdr_bucket_mappings WHERE profile_id = $1
ORDER BY match_type, match_value`,
[pid],
);
res.json({ mappings: rows.rows });
},
);
router.put(
"/api/v1/cdr/profile/:profile_id/bucket-mappings",
async (req: Request, res: Response) => {
const pid = Number(req.params.profile_id);
const incoming = (req.body?.mappings ?? []) as Array<{
match_type: string; match_value: string; bucket: string;
}>;
await pool.query(
"DELETE FROM cdr_bucket_mappings WHERE profile_id = $1",
[pid],
);
for (const m of incoming) {
if (!["trunk_group", "account_id"].includes(m.match_type)) continue;
if (!["wholesale", "retail"].includes(m.bucket)) continue;
await pool.query(
`INSERT INTO cdr_bucket_mappings
(profile_id, match_type, match_value, bucket)
VALUES ($1, $2, $3, $4)
ON CONFLICT (profile_id, match_type, match_value) DO UPDATE
SET bucket = EXCLUDED.bucket`,
[pid, m.match_type, m.match_value, m.bucket],
);
}
res.json({ saved: incoming.length });
},
);
export default router;

1695
api/src/routes/checkout.ts Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,481 @@
/**
* Corporation Status Check API
*
* GET /api/v1/corp/status?name=Acme+LLC&state=WY
* Search entity_cache for corporation status (ACTIVE, DELINQUENT, etc.)
* Returns status + years behind + cost to remediate.
*
* GET /api/v1/corp/search?q=Acme&state=WY
* Fuzzy search entity_cache by name. Returns up to 10 matches.
*
* Used by:
* - Standalone Corporation Status Check tool (/tools/corporation-check)
* - FCC Compliance Check (adds corporation_status check)
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
const router = Router();
// ── RA pricing by state ──────────────────────────────────────────────────────
const RA_PRICE_CENTS: Record<string, number> = {
WY: 5000, // $50/yr Wyoming
};
const RA_DEFAULT_CENTS = 9900; // $99/yr all other states
// Our flat markup per annual report or reinstatement filing
const FILING_MARKUP_CENTS = 2500; // $25
// ── Helpers ──────────────────────────────────────────────────────────────────
interface CorpStatusResult {
found: boolean;
entity_name: string | null;
entity_number: string | null;
entity_type: string | null;
status: string | null;
formation_date: string | null;
dissolution_date: string | null;
registered_agent: string | null;
principal_address: string | null;
state: string | null;
// Computed fields
years_behind: number;
annual_report_fee_cents: number;
annual_report_frequency: string | null;
cost_per_year_cents: number;
total_catchup_cents: number;
ra_price_cents: number;
total_with_ra_cents: number;
breakdown: string;
}
/**
* Look up corporation status from entity_cache and compute remediation cost.
* Exported so fcc-lookup.ts can call it directly.
*/
export async function lookupCorpStatus(
entityName: string,
stateCode: string,
): Promise<CorpStatusResult | null> {
if (!entityName || !stateCode) return null;
const state = stateCode.toUpperCase().trim();
const name = entityName.trim();
// Try exact match first, then fuzzy (trigram)
let row: Record<string, unknown> | null = null;
try {
const exact = await pool.query(
`SELECT entity_name, entity_number, entity_type, status,
formation_date, dissolution_date, registered_agent, state
FROM entity_cache
WHERE state = $1 AND LOWER(entity_name) = LOWER($2)
LIMIT 1`,
[state, name],
);
if (exact.rows.length > 0) {
row = exact.rows[0] as Record<string, unknown>;
} else {
// Fuzzy match — require very high similarity (0.8+) to avoid false positives
// like "GTDIAL DATA SOLUTIONS" matching "SPATIAL DATA SOLUTIONS" (0.68)
const fuzzy = await pool.query(
`SELECT entity_name, entity_number, entity_type, status,
formation_date, dissolution_date, registered_agent, state,
similarity(entity_name, $2) AS sim
FROM entity_cache
WHERE state = $1 AND similarity(entity_name, $2) > 0.8
ORDER BY sim DESC
LIMIT 1`,
[state, name],
);
if (fuzzy.rows.length > 0) {
row = fuzzy.rows[0] as Record<string, unknown>;
}
}
} catch (err: any) {
// pg_trgm extension may not be available, or entity_cache empty
if (err?.code !== "42P01") {
console.warn("[corp-status] entity_cache query failed:", err?.message);
}
return null;
}
if (!row) {
// Live Playwright fallback for states without bulk data (WY, DE, etc.)
// Calls the workers /entity-status endpoint which uses the state adapter
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
console.log(`[corp-status] Cache miss for "${name}" in ${state} — trying live search via workers`);
try {
const liveRes = await fetch(`${WORKER_URL}/entity-status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entity_name: name, state_code: state }),
signal: AbortSignal.timeout(45000),
});
const liveData = await liveRes.json() as Record<string, unknown>;
console.log(`[corp-status] Live search result:`, JSON.stringify(liveData).slice(0, 200));
if (liveRes.ok && liveData.found && liveData.entity_name) {
row = {
entity_name: liveData.entity_name,
entity_number: liveData.entity_number || null,
entity_type: liveData.entity_type || null,
status: liveData.status || "UNKNOWN",
formation_date: liveData.formation_date || null,
dissolution_date: null,
registered_agent: liveData.registered_agent || null,
state,
};
}
} catch {
// Worker unavailable or timeout — skip this state
}
}
if (!row) return null;
const status = (row.status as string) || "UNKNOWN";
const formationDate = row.formation_date
? new Date(row.formation_date as string).toISOString().slice(0, 10)
: null;
// Look up annual report obligation for this state
let annualFee = 0;
let frequency = "annual";
let dueMonth: number | null = null;
let isAnniversary = false;
try {
const obl = await pool.query(
`SELECT fee_cents, frequency, due_month, is_anniversary
FROM state_compliance_obligations
WHERE state_code = $1 AND obligation_type = 'annual_report'
LIMIT 1`,
[state],
);
if (obl.rows.length > 0) {
const o = obl.rows[0] as Record<string, unknown>;
annualFee = (o.fee_cents as number) || 0;
frequency = (o.frequency as string) || "annual";
dueMonth = (o.due_month as number) || null;
isAnniversary = (o.is_anniversary as boolean) || false;
}
} catch {
// state_compliance_obligations table may not exist
}
// Calculate years behind
let yearsBehind = 0;
if (status !== "ACTIVE" && formationDate) {
const formed = new Date(formationDate);
const now = new Date();
const yearsExisted = now.getFullYear() - formed.getFullYear();
if (frequency === "biennial") {
yearsBehind = Math.max(1, Math.floor(yearsExisted / 2));
} else if (frequency === "annual") {
// Conservative estimate: if delinquent, assume at least 1 year behind
// Most states revoke after 2-3 years of non-filing
if (status === "DELINQUENT" || status === "SUSPENDED") {
yearsBehind = Math.max(1, Math.min(3, yearsExisted - 1));
} else if (status === "DISSOLVED" || status === "INACTIVE") {
yearsBehind = Math.max(1, Math.min(5, yearsExisted - 1));
}
}
}
// Cost calculations
const costPerYear = annualFee + FILING_MARKUP_CENTS;
const totalCatchup = yearsBehind * costPerYear;
const raPrice = RA_PRICE_CENTS[state] || RA_DEFAULT_CENTS;
const totalWithRA = totalCatchup + raPrice;
// Build human-readable breakdown
let breakdown = "";
if (yearsBehind > 0) {
const stateFeeStr = `$${(annualFee / 100).toFixed(0)}`;
const markupStr = `$${(FILING_MARKUP_CENTS / 100).toFixed(0)}`;
const perYearStr = `$${(costPerYear / 100).toFixed(0)}`;
const totalStr = `$${(totalCatchup / 100).toFixed(0)}`;
const raStr = `$${(raPrice / 100).toFixed(0)}`;
const grandStr = `$${(totalWithRA / 100).toFixed(0)}`;
if (yearsBehind === 1) {
breakdown = `Annual report: ${stateFeeStr} state fee + ${markupStr} filing = ${perYearStr}. `;
} else {
breakdown = `Annual reports (${yearsBehind} years): ${yearsBehind} × (${stateFeeStr} + ${markupStr}) = ${totalStr}. `;
}
breakdown += `Registered agent: ${raStr}/yr. Total: ${grandStr}.`;
}
return {
found: true,
entity_name: (row.entity_name as string) || null,
entity_number: (row.entity_number as string) || null,
entity_type: (row.entity_type as string) || null,
status,
formation_date: formationDate,
dissolution_date: row.dissolution_date
? new Date(row.dissolution_date as string).toISOString().slice(0, 10)
: null,
registered_agent: (row.registered_agent as string) || null,
principal_address: (row.principal_address as string) || null,
state,
years_behind: yearsBehind,
annual_report_fee_cents: annualFee,
annual_report_frequency: frequency,
cost_per_year_cents: costPerYear,
total_catchup_cents: totalCatchup,
ra_price_cents: raPrice,
total_with_ra_cents: totalWithRA,
breakdown,
};
}
// ── GET /api/v1/corp/search ──────────────────────────────────────────────────
router.get("/api/v1/corp/search", async (req: Request, res: Response) => {
const q = (req.query.q as string || "").trim();
const state = (req.query.state as string || "").toUpperCase().trim();
if (!q || q.length < 2) {
res.status(400).json({ error: "q parameter required (min 2 chars)" });
return;
}
if (!state || state.length !== 2) {
res.status(400).json({ error: "state parameter required (2-letter code)" });
return;
}
try {
const { rows } = await pool.query(
`SELECT entity_name, entity_number, entity_type, status, formation_date,
similarity(entity_name, $2) AS sim
FROM entity_cache
WHERE state = $1 AND entity_name % $2
ORDER BY sim DESC
LIMIT 10`,
[state, q],
);
res.json({
state,
query: q,
count: rows.length,
results: rows.map((r: any) => ({
entity_name: r.entity_name,
entity_number: r.entity_number,
entity_type: r.entity_type,
status: r.status,
formation_date: r.formation_date
? new Date(r.formation_date).toISOString().slice(0, 10)
: null,
})),
});
} catch (err: any) {
if (err?.code === "42P01") {
res.json({ state, query: q, count: 0, results: [], note: "Entity database not yet populated for this state." });
} else {
console.error("[corp/search] Error:", err);
res.status(500).json({ error: "Search failed" });
}
}
});
// ── GET /api/v1/corp/status ──────────────────────────────────────────────────
router.get("/api/v1/corp/status", async (req: Request, res: Response) => {
const name = (req.query.name as string || "").trim();
const state = (req.query.state as string || "").toUpperCase().trim();
if (!name) {
res.status(400).json({ error: "name parameter required" });
return;
}
if (!state || state.length !== 2) {
res.status(400).json({ error: "state parameter required (2-letter code)" });
return;
}
const result = await lookupCorpStatus(name, state);
if (!result) {
res.json({
found: false,
state,
searched_name: name,
note: "Entity not found in our database. Try the standalone state search or check directly with the Secretary of State.",
});
return;
}
res.json(result);
});
// ── GET /api/v1/corp/states ──────────────────────────────────────────────────
// Returns list of states with annual report info for the UI dropdown
router.get("/api/v1/corp/states", async (_req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
SELECT s.state_code, s.fee_cents, s.frequency, s.due_description, s.is_anniversary,
(SELECT count(*) FROM entity_cache WHERE state = s.state_code) AS entity_count
FROM state_compliance_obligations s
WHERE s.obligation_type = 'annual_report'
ORDER BY s.state_code
`);
res.json({
states: rows.map((r: any) => ({
code: r.state_code,
annual_report_fee_cents: r.fee_cents,
frequency: r.frequency,
due: r.due_description,
is_anniversary: r.is_anniversary,
entities_cached: parseInt(r.entity_count, 10),
ra_price_cents: RA_PRICE_CENTS[r.state_code] || RA_DEFAULT_CENTS,
})),
});
} catch (err) {
console.error("[corp/states] Error:", err);
res.status(500).json({ error: "Failed to load state data" });
}
});
// ── POST /api/v1/corp/foreign-qual-check ────────────────────────────────────
//
// Bulk check: given an entity name, home state, and list of states served,
// check entity_cache for foreign qualification status in each state.
// Used by the 499-A intake wizard after the JurisdictionStep.
router.post("/api/v1/corp/foreign-qual-check", async (req: Request, res: Response) => {
try {
const { entity_name, home_state, states } = req.body as {
entity_name?: string;
home_state?: string;
states?: string[];
};
if (!entity_name || !states || !Array.isArray(states) || states.length === 0) {
res.status(400).json({ error: "entity_name and states[] required" });
return;
}
const homeState = (home_state || "").toUpperCase().trim();
const name = entity_name.trim();
// Check each state (skip home state — they're already formed there)
// Use entity_cache only (no live Playwright fallback — too slow for bulk)
const results: Array<{
state_code: string;
has_data: boolean;
found: boolean;
status: string | null;
entity_name_found: string | null;
needs_foreign_qual: boolean;
reason: string;
}> = [];
// Check which states have data in entity_cache so we can distinguish
// "not found" from "no data for this state"
let statesWithData: Set<string>;
try {
const dataCheck = await pool.query(
`SELECT DISTINCT state FROM entity_cache WHERE state = ANY($1)`,
[states.map(s => s.toUpperCase().trim())],
);
statesWithData = new Set(dataCheck.rows.map((r: any) => r.state));
} catch {
statesWithData = new Set();
}
for (const stateCode of states) {
const state = stateCode.toUpperCase().trim();
if (state === homeState) continue; // Skip home state
let found = false;
let status: string | null = null;
let foundName: string | null = null;
const hasData = statesWithData.has(state);
if (hasData) {
try {
// Exact match
const exact = await pool.query(
`SELECT entity_name, status FROM entity_cache
WHERE state = $1 AND LOWER(entity_name) = LOWER($2) LIMIT 1`,
[state, name],
);
if (exact.rows.length > 0) {
found = true;
status = (exact.rows[0] as any).status;
foundName = (exact.rows[0] as any).entity_name;
} else {
// Fuzzy match
const fuzzy = await pool.query(
`SELECT entity_name, status, similarity(entity_name, $2) AS sim
FROM entity_cache
WHERE state = $1 AND similarity(entity_name, $2) > 0.8
ORDER BY sim DESC LIMIT 1`,
[state, name],
);
if (fuzzy.rows.length > 0) {
found = true;
status = (fuzzy.rows[0] as any).status;
foundName = (fuzzy.rows[0] as any).entity_name;
}
}
} catch {
// entity_cache or pg_trgm not available — skip
}
}
let needsFQ = false;
let reason = "";
if (!hasData) {
// No entity_cache data for this state — can't determine, skip
reason = "No data available for this state yet";
} else if (!found) {
needsFQ = true;
reason = "No foreign corporation registration found in this state";
} else if (status && ["DISSOLVED", "REVOKED", "CANCELLED", "SUSPENDED", "INACTIVE"].includes(status.toUpperCase())) {
needsFQ = true;
reason = `Registration found but status is ${status} — reinstatement or new filing needed`;
} else if (status && status.toUpperCase() === "DELINQUENT") {
needsFQ = true;
reason = `Registration found but delinquent — annual report or reinstatement needed`;
}
results.push({
state_code: state,
has_data: hasData,
found,
status,
entity_name_found: foundName,
needs_foreign_qual: needsFQ,
reason,
});
}
const missing = results.filter((r) => r.needs_foreign_qual);
res.json({
entity_name: name,
home_state: homeState,
total_states_checked: results.length,
states_missing: missing.length,
results,
foreign_qual_service_fee_cents: 9900, // $99/state for multi-state
});
} catch (err) {
console.error("[corp/foreign-qual-check] Error:", err);
res.status(500).json({ error: "Foreign qualification check failed" });
}
});
export default router;

143
api/src/routes/discounts.ts Normal file
View file

@ -0,0 +1,143 @@
import { Router } from "express";
import { pool } from "../db.js";
const router = Router();
interface DiscountCode {
id: number;
code: string;
description: string | null;
discount_type: "percent" | "flat";
discount_value: number;
applies_to: string | null;
referral_partner: string | null;
max_uses: number | null;
max_uses_per_email: number;
current_uses: number;
active: boolean;
starts_at: string;
expires_at: string | null;
}
/**
* Validate a discount code and return its details + calculated discount.
*
* GET /api/v1/discount/:code?service=formation&email=user@example.com&amount=59900
*
* Query params:
* service service slug to check scope (optional)
* email customer email to check per-email limits (optional)
* amount amount in cents to calculate discount against (optional)
*/
router.get("/api/v1/discount/:code", async (req, res) => {
try {
const code = req.params.code.toUpperCase().trim();
const service = (req.query.service as string) || "";
const email = (req.query.email as string) || "";
const amount = parseInt(req.query.amount as string, 10) || 0;
if (!code || code.length < 2) {
res.status(400).json({ error: "Invalid discount code." });
return;
}
// Look up the code
const result = await pool.query(
"SELECT * FROM discount_codes WHERE code = $1",
[code],
);
if (result.rows.length === 0) {
res.status(404).json({
valid: false,
error: "Discount code not found.",
});
return;
}
const dc = result.rows[0] as DiscountCode;
// Check if active
if (!dc.active) {
res.status(410).json({ valid: false, error: "This discount code is no longer active." });
return;
}
// Check expiration
if (dc.expires_at && new Date(dc.expires_at) < new Date()) {
res.status(410).json({ valid: false, error: "This discount code has expired." });
return;
}
// Check start date
if (new Date(dc.starts_at) > new Date()) {
res.status(410).json({ valid: false, error: "This discount code is not yet active." });
return;
}
// Check global usage limit
if (dc.max_uses !== null && dc.current_uses >= dc.max_uses) {
res.status(410).json({ valid: false, error: "This discount code has reached its usage limit." });
return;
}
// Check per-email limit
if (email && dc.max_uses_per_email > 0) {
const emailUsage = await pool.query(
"SELECT COUNT(*) as cnt FROM discount_usage WHERE code = $1 AND customer_email = $2",
[code, email.toLowerCase().trim()],
);
const usedByEmail = parseInt(emailUsage.rows[0]?.cnt || "0", 10);
if (usedByEmail >= dc.max_uses_per_email) {
res.status(410).json({
valid: false,
error: "You have already used this discount code.",
});
return;
}
}
// Check service scope
if (dc.applies_to && service) {
const allowedServices = dc.applies_to.split(",").map((s) => s.trim().toLowerCase());
if (!allowedServices.includes(service.toLowerCase())) {
res.status(400).json({
valid: false,
error: `This code does not apply to ${service} services.`,
});
return;
}
}
// Calculate discount amount — applies ONLY to service fees.
// State filing fees, expedited fees, and attorney review are NEVER discountable.
// The `amount` param should be the service fee only, not the total with state fees.
let discountCents = 0;
if (amount > 0) {
if (dc.discount_type === "percent") {
discountCents = Math.round((amount * dc.discount_value) / 100);
} else {
discountCents = Math.min(dc.discount_value, amount);
}
}
res.json({
valid: true,
code: dc.code,
discount_type: dc.discount_type,
discount_value: dc.discount_value,
discount_cents: discountCents,
description: dc.discount_type === "percent"
? `${dc.discount_value}% off service fees`
: `$${(dc.discount_value / 100).toFixed(2)} off service fees`,
applies_to: dc.applies_to || "all services",
referral_partner: dc.referral_partner || null,
note: "Discount applies to service fees only. State filing fees, expedited processing, and attorney review fees are not discountable.",
});
} catch (err) {
console.error("[discounts] Error:", err);
res.status(500).json({ error: "Could not validate discount code." });
}
});
export default router;

191
api/src/routes/entities.ts Normal file
View file

@ -0,0 +1,191 @@
// entities.ts — Internal API for Verilex Data entity sync
//
// GET /api/v1/entities/bulk?state=CO&limit=10000&cursor=123 — paginated bulk export
// GET /api/v1/entities/states — list states with entity counts
// GET /api/v1/states/:code/name-search?name=Acme — name availability (cached 24h)
//
// All endpoints require internal auth (PW_INTERNAL_API_KEY).
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
import { internalAuth } from "../middleware/internal-auth.js";
const router = Router();
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
// ── Bulk entity export ───────────────────────────────────────────────────────
router.get("/api/v1/entities/bulk", internalAuth, async (req: Request, res: Response) => {
try {
const state = (req.query.state as string || "").toUpperCase();
const limit = Math.min(parseInt(req.query.limit as string || "10000", 10), 50000);
const cursor = parseInt(req.query.cursor as string || "0", 10);
if (!state || state.length !== 2) {
res.status(400).json({ error: "state parameter required (2-letter code)" });
return;
}
const { rows } = await pool.query(
`SELECT id, jurisdiction, entity_name, entity_number, entity_type, status,
formation_date, dissolution_date, registered_agent, principal_address, state
FROM entity_cache
WHERE state = $1 AND id > $2
ORDER BY id
LIMIT $3`,
[state, cursor, limit],
);
const nextCursor = rows.length > 0 ? rows[rows.length - 1].id : cursor;
const hasMore = rows.length === limit;
res.json({
state,
count: rows.length,
has_more: hasMore,
next_cursor: nextCursor,
entities: rows.map((r: any) => ({
entity_name: r.entity_name,
entity_number: r.entity_number,
entity_type: r.entity_type,
status: r.status,
formation_date: r.formation_date,
dissolution_date: r.dissolution_date,
registered_agent: r.registered_agent,
principal_address: r.principal_address,
jurisdiction: r.jurisdiction,
state: r.state,
})),
});
} catch (err: any) {
console.error("Bulk export error:", err.message);
res.status(500).json({ error: "Bulk export failed" });
}
});
// ── List states with entity counts ───────────────────────────────────────────
router.get("/api/v1/entities/states", internalAuth, async (_req: Request, res: Response) => {
try {
const { rows } = await pool.query(
`SELECT state, COUNT(*) AS count, MAX(last_synced) AS last_synced
FROM entity_cache
GROUP BY state
ORDER BY count DESC`,
);
res.json({
total_states: rows.length,
total_entities: rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0),
states: rows.map((r: any) => ({
state: r.state,
count: parseInt(r.count, 10),
last_synced: r.last_synced,
})),
});
} catch (err: any) {
res.status(500).json({ error: "Failed to list states" });
}
});
// ── Name availability search (cached 24h) ────────────────────────────────────
router.get("/api/v1/states/:code/name-search", internalAuth, async (req: Request, res: Response) => {
try {
const stateCode = req.params.code.toUpperCase();
const name = (req.query.name as string || "").trim();
if (!name || name.length < 2) {
res.status(400).json({ error: "name parameter required (min 2 chars)" });
return;
}
// Check cache first
const { rows: cached } = await pool.query(
`SELECT available, exact_match, similar_names, searched_at
FROM name_search_cache
WHERE state_code = $1 AND searched_name = $2 AND expires_at > NOW()`,
[stateCode, name.toUpperCase()],
);
if (cached.length > 0) {
res.json({
state_code: stateCode,
name,
available: cached[0].available,
exact_match: cached[0].exact_match,
similar_names: cached[0].similar_names || [],
cached: true,
searched_at: cached[0].searched_at,
});
return;
}
// Call Python worker for live search
let searchResult: any;
try {
const workerResp = await fetch(`${WORKER_URL}/name-search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state_code: stateCode, name }),
signal: AbortSignal.timeout(30000),
});
searchResult = await workerResp.json();
} catch (workerErr: any) {
// Worker unavailable — fall back to entity_cache search
const { rows: fallback } = await pool.query(
`SELECT entity_name FROM entity_cache
WHERE state = $1 AND upper(entity_name) = $2
LIMIT 1`,
[stateCode, name.toUpperCase()],
);
const { rows: similar } = await pool.query(
`SELECT entity_name FROM entity_cache
WHERE state = $1 AND entity_name ILIKE $2
ORDER BY entity_name LIMIT 10`,
[stateCode, `%${name}%`],
);
searchResult = {
available: fallback.length === 0,
exact_match: fallback.length > 0,
similar_names: similar.map((r: any) => r.entity_name),
source: "cache_fallback",
};
}
// Cache the result
const available = searchResult.available ?? null;
const exactMatch = searchResult.exact_match ?? false;
const similarNames = searchResult.similar_names || [];
await pool.query(
`INSERT INTO name_search_cache (state_code, searched_name, available, exact_match, similar_names)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (state_code, searched_name) DO UPDATE SET
available = EXCLUDED.available,
exact_match = EXCLUDED.exact_match,
similar_names = EXCLUDED.similar_names,
searched_at = NOW(),
expires_at = NOW() + INTERVAL '24 hours'`,
[stateCode, name.toUpperCase(), available, exactMatch, similarNames],
).catch(() => {}); // non-critical if cache write fails
res.json({
state_code: stateCode,
name,
available,
exact_match: exactMatch,
similar_names: similarNames,
cached: false,
searched_at: new Date().toISOString(),
});
} catch (err: any) {
console.error("Name search error:", err.message);
res.status(500).json({ error: "Name search failed" });
}
});
export default router;

View file

@ -0,0 +1,275 @@
/**
* FCC filing helpers prior filings lookup, safe-harbor recommendation.
*
* Supports the intake wizard's past-due and revised-filing flows:
* - GET /api/v1/fcc/filings/entity/:id list prior 499-A filings
* - GET /api/v1/fcc/safe-harbor-recommendation compute recommendation
* from the customer's CDR traffic study vs the category's safe harbor %.
*/
import { Router } from "express";
import type { Request, Response } from "express";
import { pool } from "../db.js";
import { loadSafeHarborPct, safeHarborAllowed } from "../lib/fcc_499_utils.js";
const router = Router();
// ── GET /api/v1/fcc/filings/entity/:telecom_entity_id ─────────────────
//
// Returns every 499-A-family compliance_order for this entity, newest
// first. Used by the wizard's "revise a prior filing" selector.
router.get(
"/api/v1/fcc/filings/entity/:telecom_entity_id",
async (req: Request, res: Response) => {
const entityId = Number(req.params.telecom_entity_id);
if (!Number.isFinite(entityId)) {
res.status(400).json({ error: "bad telecom_entity_id" });
return;
}
const r = await pool.query(
`SELECT order_number, service_slug, service_name,
filing_mode, form_year_override,
revises_order_number, revised_reason,
prior_confirmation_number,
deminimis_result_is_exempt,
created_at, paid_at,
(intake_data->>'form_year')::int AS form_year_declared,
intake_data->'revenue'->>'total' AS declared_revenue_cents
FROM compliance_orders
WHERE telecom_entity_id = $1
AND service_slug IN ('fcc-499a','fcc-499a-499q','fcc-499-initial','fcc-full-compliance')
ORDER BY COALESCE(paid_at, created_at) DESC
LIMIT 50`,
[entityId],
);
res.json({ filings: r.rows });
},
);
// ── GET /api/v1/fcc/safe-harbor-recommendation ────────────────────────
//
// Returns a recommendation about whether the customer should elect safe
// harbor, traffic study, or actual data for the given category + year.
//
// The FCC treats safe harbor as a rebuttable presumption: a carrier
// whose actual interstate % exceeds the safe-harbor number has an
// obligation to report actual (per 2006 Contribution Methodology Reform
// Order). Economically, carriers whose actual interstate % is lower
// than safe harbor are better off reporting actual (lower USF contribution).
//
// The endpoint compares the customer's CDR traffic-study interstate % to
// the safe-harbor default and returns one of three recommendations:
//
// safe_harbor_okay — actual matches safe harbor within tolerance;
// pick safe harbor for audit protection, no $ cost
// use_actual_saves — actual is meaningfully below safe harbor;
// using actual reduces USF contribution
// use_actual_required — actual is meaningfully above safe harbor;
// safe harbor would UNDER-report, audit risk
// no_actual_data — no CDR traffic study available; safe harbor
// is the default for categories that allow it
// not_allowed — category doesn't have a safe harbor (non-
// interconnected VoIP); must use traffic study or actual
//
// Query params:
// profile_id — cdr_ingestion_profiles.id (required if recommending
// based on actual data)
// year — reporting year
// category — Line 105 primary category (voip_interconnected, wireless, etc.)
// total_revenue_cents — optional; if provided, estimates the USF $ delta
router.get(
"/api/v1/fcc/safe-harbor-recommendation",
async (req: Request, res: Response) => {
const year = Number(req.query.year) || new Date().getUTCFullYear() - 1;
const category = String(req.query.category || "voip_interconnected");
const profileId = Number(req.query.profile_id) || null;
const totalRev = Number(req.query.total_revenue_cents) || 0;
// Category allows safe harbor?
if (!safeHarborAllowed(category)) {
res.json({
recommendation: "not_allowed",
category,
year,
message:
"Non-interconnected VoIP has no safe harbor. Use traffic study " +
"or actual billing data.",
safe_harbor_pct: null,
actual_interstate_pct: null,
});
return;
}
const safeHarborPct = await loadSafeHarborPct(year, category);
if (safeHarborPct === null) {
res.json({
recommendation: "no_safe_harbor_for_category",
category,
year,
message:
`No safe harbor is defined for category '${category}' in form year ${year}. ` +
`Use actual billing data or a traffic study.`,
safe_harbor_pct: null,
actual_interstate_pct: null,
});
return;
}
// Look up the customer's CDR traffic-study interstate % for this year
let actualInterstatePct: number | null = null;
let studyMethodology: string | null = null;
let totalCalls = 0;
if (profileId) {
const r = await pool.query(
`SELECT interstate_pct::float AS interstate_pct,
methodology, total_calls
FROM cdr_traffic_studies
WHERE profile_id = $1 AND reporting_year = $2
AND reporting_period = 'ANNUAL'
ORDER BY generated_at DESC LIMIT 1`,
[profileId, year],
);
if (r.rows[0]) {
actualInterstatePct = Number(r.rows[0].interstate_pct);
studyMethodology = r.rows[0].methodology;
totalCalls = Number(r.rows[0].total_calls) || 0;
}
}
if (actualInterstatePct === null) {
res.json({
recommendation: "no_actual_data",
category,
year,
safe_harbor_pct: safeHarborPct,
actual_interstate_pct: null,
message:
`No classified CDR traffic study available for year ${year}. ` +
`Safe harbor (${safeHarborPct}%) is the default for this category.`,
});
return;
}
// Compare actual vs safe harbor. Tolerance: within 2 percentage
// points we call it a wash.
const delta = actualInterstatePct - safeHarborPct;
const WASH_TOLERANCE = 2.0;
// Estimate USF contribution difference if total_revenue provided.
// Use 2026 factor 0.256 as a rough multiplier — the actual factor
// varies quarterly, but this gives the right order of magnitude.
const factorRow = await pool.query(
`SELECT factor FROM fcc_deminimis_factors WHERE form_year = $1`,
[year],
);
const factor = factorRow.rows[0] ? Number(factorRow.rows[0].factor) : 0.256;
const sh_contrib = totalRev * (safeHarborPct / 100) * factor;
const actual_contrib = totalRev * (actualInterstatePct / 100) * factor;
const delta_cents = Math.round(actual_contrib - sh_contrib);
let recommendation: string;
let message: string;
if (Math.abs(delta) <= WASH_TOLERANCE) {
recommendation = "safe_harbor_okay";
message =
`Your actual interstate (${actualInterstatePct.toFixed(1)}%) is within ` +
`${WASH_TOLERANCE}% of safe harbor (${safeHarborPct}%). Pick safe harbor ` +
`for audit protection — no meaningful USF contribution difference.`;
} else if (delta < -WASH_TOLERANCE) {
// Actual is meaningfully lower — using actual saves USF
recommendation = "use_actual_saves";
const savings = Math.abs(delta_cents);
const savingsTxt = totalRev > 0
? ` Using actual data could reduce your USF contribution by roughly ` +
`$${(savings / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })} ` +
`(at the ${factor} factor).`
: "";
message =
`Your actual interstate (${actualInterstatePct.toFixed(1)}%) is ` +
`${Math.abs(delta).toFixed(1)}% lower than safe harbor (${safeHarborPct}%). ` +
`Safe harbor would make you OVER-contribute.${savingsTxt} Recommend actual data ` +
`or traffic study.`;
} else {
// Actual is meaningfully higher — safe harbor is audit-risky
recommendation = "use_actual_required";
message =
`Your actual interstate (${actualInterstatePct.toFixed(1)}%) is ` +
`${delta.toFixed(1)}% HIGHER than safe harbor (${safeHarborPct}%). ` +
`Reporting safe harbor would UNDER-report interstate revenue — the FCC ` +
`may flag this on audit. Recommend a traffic study (FCC requires you to ` +
`report actual when it exceeds the safe harbor rebuttable presumption).`;
}
res.json({
recommendation,
category,
year,
safe_harbor_pct: safeHarborPct,
actual_interstate_pct: actualInterstatePct,
delta_percentage_points: Math.round(delta * 100) / 100,
estimated_usf_delta_cents: delta_cents,
total_revenue_cents: totalRev,
study_methodology: studyMethodology,
total_classified_calls: totalCalls,
message,
});
},
);
// ── GET /api/v1/fcc/late-filing-estimate ──────────────────────────────
//
// For past-due filings: estimate the retroactive USF contribution owed.
// Uses the reporting year's de minimis factor as a proxy for the year's
// average contribution factor. Real penalty amounts are assessed by
// USAC + FCC Enforcement and include interest + potential forfeitures.
router.get(
"/api/v1/fcc/late-filing-estimate",
async (req: Request, res: Response) => {
const year = Number(req.query.year);
const totalRev = Number(req.query.total_revenue_cents) || 0;
const interstatePct = Number(req.query.interstate_pct) || 0;
if (!year || !totalRev) {
res.status(400).json({ error: "year and total_revenue_cents required" });
return;
}
const factorRow = await pool.query(
`SELECT factor FROM fcc_deminimis_factors WHERE form_year = $1`,
[year],
);
if (!factorRow.rows[0]) {
res.status(422).json({
error: `No de minimis factor configured for year ${year}` +
`seed fcc_deminimis_factors before filing for this year.`,
});
return;
}
const factor = Number(factorRow.rows[0].factor);
const contribBaseCents = Math.round(totalRev * (interstatePct / 100));
const estimatedUsfCents = Math.round(contribBaseCents * factor);
// Rough interest estimate: assume ~5% annual rate, simple interest,
// year_gap years late. This is NOT the actual IRS short-term rate
// used by USAC; just a rough magnitude for the customer.
const yearsLate = Math.max(0, (new Date().getUTCFullYear()) - year - 1);
const estimatedInterestCents = Math.round(estimatedUsfCents * 0.05 * yearsLate);
res.json({
year,
total_revenue_cents: totalRev,
interstate_pct: interstatePct,
factor,
contribution_base_cents: contribBaseCents,
estimated_usf_cents: estimatedUsfCents,
years_late: yearsLate,
estimated_interest_cents: estimatedInterestCents,
estimated_total_cents: estimatedUsfCents + estimatedInterestCents,
caveat:
"This is an estimate only. Actual retroactive contribution is " +
"assessed by USAC at the specific quarterly factors in effect " +
"for the reporting year. FCC may additionally impose forfeitures " +
"separately.",
});
},
);
export default router;

1414
api/src/routes/fcc-lookup.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,227 @@
/**
* Foreign Qualification API.
*
* Customer and admin endpoints for ordering a Certificate of Authority
* (aka "foreign qualification") in one or more US states against an
* already-formed entity. Used by:
*
* - Regular incorp clients expanding to additional states.
* - FCC carriers that must be authorized to do business in every
* state they serve (per state PUC rules, not the FCC directly).
*
* Endpoints:
* GET /api/v1/foreign-qualification/jurisdictions
* Public catalog of eligible target states + per-entity-type quotes.
*
* POST /api/v1/foreign-qualification/quote
* Price a multi-state COA order before checkout.
*
* GET /api/v1/foreign-qualification/registrations
* Admin list (requires admin auth).
*
* GET /api/v1/foreign-qualification/registrations/:id
* Admin detail.
*
* Order creation flows through the regular compliance_orders checkout
* pipeline (/api/v1/compliance-orders) with service_slug set to
* `foreign-qualification-single` or `foreign-qualification-multi`; the
* intake wizard pushes the selected target states into intake_data so
* the worker can fan out per-state rows in
* `foreign_qualification_registrations`.
*/
import { Router } from "express";
import type { Request, Response } from "express";
import { pool } from "../db.js";
import { requireAdmin } from "../middleware/admin-auth.js";
const router = Router();
// Flat service-fee schedule — mirrors COMPLIANCE_SERVICES in
// compliance-orders.ts. Kept in sync manually; update both sides.
const SERVICE_FEE_SINGLE_CENTS = 14900;
const SERVICE_FEE_MULTI_CENTS = 9900; // per-state
const NWRA_RA_DEFAULT_CENTS = 12500; // $125/yr registered agent
// ── GET /api/v1/foreign-qualification/jurisdictions ─────────────────
//
// List every US jurisdiction with foreign-qualification enabled,
// including fee preview. Front-end uses this to render the state picker
// with price per state.
router.get(
"/api/v1/foreign-qualification/jurisdictions",
async (_req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT j.code,
j.name,
j.country,
j.kind,
j.portal_name,
j.supports_foreign_qualification,
f.foreign_llc_fee,
f.foreign_corp_fee,
f.expedited_fee,
f.publication_required,
f.typical_processing_days
FROM jurisdictions j
LEFT JOIN state_filing_fees f ON f.state_code = j.code
WHERE j.country = 'US'
AND j.supports_foreign_qualification = TRUE
ORDER BY j.code`,
);
res.json({ jurisdictions: rows });
},
);
// ── POST /api/v1/foreign-qualification/quote ─────────────────────────
//
// Body: {
// home_state_code: "WY",
// entity_type: "llc",
// target_states: ["CA", "TX", "NY"],
// include_ra_each: true,
// expedited: false,
// }
//
// Returns per-state breakdown + grand total.
router.post(
"/api/v1/foreign-qualification/quote",
async (req: Request, res: Response) => {
const {
home_state_code,
entity_type,
target_states,
include_ra_each = true,
expedited = false,
} = req.body ?? {};
if (!home_state_code || !entity_type
|| !Array.isArray(target_states) || target_states.length === 0) {
res.status(400).json({
error: "home_state_code, entity_type, and non-empty target_states required",
});
return;
}
const et = String(entity_type).toLowerCase();
const feeCol =
et === "llc" || et === "pllc" ? "foreign_llc_fee"
: et === "corporation" || et === "c_corp"
|| et === "s_corp" || et === "pc"
|| et === "nonprofit" ? "foreign_corp_fee"
: null;
if (!feeCol) {
res.status(400).json({ error: `unsupported entity_type: ${entity_type}` });
return;
}
// Pull fees + sanity-check the target state is eligible.
const { rows } = await pool.query(
`SELECT j.code,
j.name,
j.supports_foreign_qualification,
f.${feeCol} AS state_fee_cents,
f.expedited_fee AS expedited_raw,
f.publication_required
FROM jurisdictions j
LEFT JOIN state_filing_fees f ON f.state_code = j.code
WHERE j.code = ANY($1::varchar[])`,
[target_states.map((s: string) => String(s).toUpperCase())],
);
const perStateServiceFee =
target_states.length === 1 ? SERVICE_FEE_SINGLE_CENTS : SERVICE_FEE_MULTI_CENTS;
let grand = 0;
const items = rows.map((r) => {
if (!r.supports_foreign_qualification) {
return {
state_code: r.code,
error: "not_supported",
};
}
const stateFee = Number(r.state_fee_cents || 0);
// state_filing_fees.expedited_fee is seeded inconsistently — the
// same normalization we do in the Python sizer (dollars×10000 →
// cents). See scripts/workers/crypto_offramp/sizer.py.
const expRaw = Number(r.expedited_raw || 0);
const expFee = expedited ? (expRaw > 50000 ? Math.floor(expRaw / 100) : expRaw) : 0;
const raFee = include_ra_each ? NWRA_RA_DEFAULT_CENTS : 0;
const publication = r.publication_required ? true : false;
const total = stateFee + expFee + raFee + perStateServiceFee;
grand += total;
return {
state_code: r.code,
state_name: r.name,
state_fee_cents: stateFee,
expedited_fee_cents: expFee,
nwra_ra_fee_cents: raFee,
service_fee_cents: perStateServiceFee,
total_cents: total,
publication_required: publication,
};
});
res.json({
home_state_code,
entity_type: et,
include_ra_each,
expedited,
per_state_service_fee_cents: perStateServiceFee,
items,
grand_total_cents: grand,
});
},
);
// ── GET /api/v1/foreign-qualification/registrations ──────────────────
router.get(
"/api/v1/foreign-qualification/registrations",
requireAdmin,
async (req: Request, res: Response) => {
const status = (req.query.status as string) || "";
const targetState = (req.query.target_state as string) || "";
const limit = Math.min(Number(req.query.limit) || 100, 500);
const conditions: string[] = [];
const params: (number | string)[] = [];
if (status) {
conditions.push(`status = $${params.length + 1}`);
params.push(status);
}
if (targetState) {
conditions.push(`target_state_code = $${params.length + 1}`);
params.push(targetState.toUpperCase());
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
params.push(limit);
const { rows } = await pool.query(
`SELECT * FROM v_foreign_qualifications_pipeline
${where}
LIMIT $${params.length}`,
params,
);
res.json({ registrations: rows });
},
);
// ── GET /api/v1/foreign-qualification/registrations/:id ──────────────
router.get(
"/api/v1/foreign-qualification/registrations/:id",
requireAdmin,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: "bad id" }); return;
}
const { rows } = await pool.query(
`SELECT * FROM foreign_qualification_registrations WHERE id = $1`,
[id],
);
if (!rows.length) { res.status(404).json({ error: "not found" }); return; }
res.json({ registration: rows[0] });
},
);
export default router;

View file

@ -0,0 +1,326 @@
import { Router } from "express";
import { pool } from "../db.js";
import { submitLimiter } from "../middleware/rate-limit.js";
import { v4 as uuidv4 } from "uuid";
import { createFormationOrder } from "../erpnext-client.js";
const router = Router();
// GET /api/v1/states — Return all states with fees for the order form
router.get("/api/v1/states", async (_req, res) => {
try {
const result = await pool.query(
`SELECT state_code, state_name, llc_formation_fee, corp_formation_fee,
llc_annual_fee, llc_annual_period, corp_annual_fee, corp_annual_period,
expedited_fee, expedited_label, publication_required, publication_est_cost,
franchise_tax_required, franchise_tax_min, franchise_tax_notes,
business_license_required, business_license_fee,
portal_name, online_filing_available, typical_processing_days, notes
FROM state_filing_fees ORDER BY state_name`,
);
res.json({ states: result.rows });
} catch (err) {
console.error("[formations] Error fetching states:", err);
res.status(500).json({ error: "Could not load state data." });
}
});
// POST /api/v1/formations — Create a formation order
router.post("/api/v1/formations", submitLimiter, async (req, res) => {
try {
const {
customer_name,
customer_email,
customer_phone,
customer_company,
state_code,
entity_type,
entity_name,
entity_name_alt,
management_type,
purpose,
principal_address,
principal_city,
principal_state,
principal_zip,
mailing_address,
mailing_city,
mailing_state,
mailing_zip,
members,
include_ra_service,
include_ein,
include_operating_agreement,
expedited,
state_fee_cents,
service_fee_cents,
expedited_fee_cents,
total_cents,
discount_code,
} = req.body ?? {};
// Validation
if (
!customer_name ||
!customer_email ||
!state_code ||
!entity_type ||
!entity_name
) {
res.status(400).json({
error:
"Missing required fields: name, email, state, entity type, entity name",
});
return;
}
if (!["llc", "corporation", "s_corp"].includes(entity_type)) {
res
.status(400)
.json({ error: "Entity type must be: llc, corporation, or s_corp" });
return;
}
if (
!customer_email ||
typeof customer_email !== "string" ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customer_email)
) {
res.status(400).json({ error: "Valid email address is required." });
return;
}
// --- Discount code validation ---
let discountCents = 0;
let validatedDiscountCode: string | null = null;
let discountCodeId: number | null = null;
let referralPayout = 0;
if (discount_code && typeof discount_code === "string" && discount_code.trim()) {
const dc = discount_code.toUpperCase().trim();
const dcResult = await pool.query("SELECT * FROM discount_codes WHERE code = $1", [dc]);
if (dcResult.rows.length > 0) {
const row = dcResult.rows[0];
const now = new Date();
const isActive = row.active;
const notExpired = !row.expires_at || new Date(row.expires_at) > now;
const hasStarted = new Date(row.starts_at) <= now;
const underLimit = row.max_uses === null || row.current_uses < row.max_uses;
// Check per-email limit
let emailOk = true;
if (customer_email && row.max_uses_per_email > 0) {
const eu = await pool.query(
"SELECT COUNT(*) as cnt FROM discount_usage WHERE code = $1 AND customer_email = $2",
[dc, customer_email.toLowerCase().trim()],
);
emailOk = parseInt(eu.rows[0]?.cnt || "0", 10) < row.max_uses_per_email;
}
// Check service scope
let scopeOk = true;
if (row.applies_to) {
const allowed = row.applies_to.split(",").map((s: string) => s.trim().toLowerCase());
scopeOk = allowed.includes("formation");
}
if (isActive && notExpired && hasStarted && underLimit && emailOk && scopeOk) {
validatedDiscountCode = dc;
discountCodeId = row.id;
// Discount applies ONLY to our service fee — never to state filing fees,
// expedited fees, or attorney review fees. Those are pass-through costs.
const serviceFee = service_fee_cents || 17900;
if (row.discount_type === "percent") {
discountCents = Math.round((serviceFee * row.discount_value) / 100);
} else {
discountCents = Math.min(row.discount_value, serviceFee);
}
// Referral payout based on service fee only (not state fees)
if (row.referral_pct > 0) {
referralPayout = Math.round((serviceFee * row.referral_pct) / 100);
}
}
}
// Silently ignore invalid codes — don't block the order
}
const finalServiceFee = (service_fee_cents || 17900) - discountCents;
const finalTotal = (total_cents || 0) - discountCents;
const year = new Date().getFullYear();
const short = uuidv4().split("-")[0]!.toUpperCase();
const orderNumber = `PW-${year}-${short}`;
const principalFull = principal_address
? `${principal_address}, ${principal_city}, ${principal_state} ${principal_zip}`
: null;
const mailingFull = mailing_address
? `${mailing_address}, ${mailing_city}, ${mailing_state} ${mailing_zip}`
: null;
const result = await pool.query(
`INSERT INTO formation_orders (
order_number, customer_name, customer_email, customer_phone, customer_company,
state_code, entity_type, entity_name, entity_name_alt,
management_type, principal_address, mailing_address,
members_json, include_ra_service, include_ein, include_operating_agreement,
expedited, state_fee_cents, service_fee_cents, expedited_fee_cents, total_cents,
status
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,'received')
RETURNING id, order_number`,
[
orderNumber,
customer_name.trim(),
customer_email.toLowerCase().trim(),
customer_phone || null,
customer_company || null,
state_code.toUpperCase(),
entity_type,
entity_name.trim(),
entity_name_alt || null,
management_type || "member_managed",
principalFull,
mailingFull,
JSON.stringify(members || []),
include_ra_service !== false,
include_ein || false,
include_operating_agreement || false,
expedited || false,
state_fee_cents || 0,
finalServiceFee,
expedited_fee_cents || 0,
finalTotal,
],
);
const orderId = result.rows[0].id;
// Push to ERPNext as source of truth — non-blocking, don't fail the response
const ENTITY_TYPE_MAP: Record<string, "LLC" | "Corporation" | "S-Corp"> = {
llc: "LLC",
corporation: "Corporation",
s_corp: "S-Corp",
};
const MGMT_TYPE_MAP: Record<string, "Member Managed" | "Manager Managed"> = {
member_managed: "Member Managed",
manager_managed: "Manager Managed",
};
try {
await createFormationOrder({
order_number: orderNumber,
customer: customer_name.trim(),
customer_email: customer_email.toLowerCase().trim(),
customer_phone: customer_phone || undefined,
state_code: state_code.toUpperCase(),
entity_type: ENTITY_TYPE_MAP[entity_type] || "LLC",
entity_name: entity_name.trim(),
entity_name_alt: entity_name_alt || undefined,
management_type: MGMT_TYPE_MAP[management_type || "member_managed"] || "Member Managed",
purpose: purpose || undefined,
principal_address: principalFull || undefined,
mailing_address: mailingFull || undefined,
members: members || [],
include_ra_service: include_ra_service !== false,
include_ein: include_ein || false,
include_operating_agreement: include_operating_agreement || false,
expedited: expedited || false,
state_fee_cents: state_fee_cents || 0,
service_fee_cents: finalServiceFee,
discount_code: validatedDiscountCode || undefined,
discount_cents: discountCents > 0 ? discountCents : undefined,
total_cents: finalTotal,
});
} catch (erpErr) {
console.error("[formations] ERPNext createFormationOrder failed (non-fatal):", erpErr);
}
// Record discount usage
if (validatedDiscountCode && discountCodeId) {
const ip = (req as any).clientIp || req.ip || "";
await pool.query(
`INSERT INTO discount_usage (discount_code_id, code, order_type, order_id, customer_email, discount_amount, referral_payout, ip_address)
VALUES ($1, $2, 'formation', $3, $4, $5, $6, $7)`,
[discountCodeId, validatedDiscountCode, orderId, customer_email.toLowerCase().trim(), discountCents, referralPayout, ip],
);
// Increment usage counter
await pool.query(
"UPDATE discount_codes SET current_uses = current_uses + 1, updated_at = now() WHERE id = $1",
[discountCodeId],
);
}
// Create commission if this order used an agent's referral code
if (discount_code) {
try {
const { createCommission } = await import("./agents.js");
// Check if the discount code belongs to a sales agent
const agentCheck = await pool.query(
"SELECT sa.agent_code FROM sales_agents sa JOIN discount_codes dc ON sa.discount_code_id = dc.id WHERE dc.code = $1 AND sa.active = TRUE",
[discount_code.toUpperCase()],
);
if (agentCheck.rows.length > 0) {
await createCommission({
agentCode: agentCheck.rows[0].agent_code,
orderType: "formation",
orderId: result.rows[0].id,
orderNumber: result.rows[0].order_number,
serviceSlug: "formation",
customerName: customer_name,
customerEmail: customer_email,
orderAmountCents: finalTotal,
discountCents: discountCents,
});
}
} catch (commErr) {
console.error("[formations] Commission creation failed (non-blocking):", commErr);
}
}
res.status(201).json({
success: true,
order_number: result.rows[0].order_number,
message: "Formation order received. We will begin processing within one business day.",
discount_applied: discountCents > 0 ? {
code: validatedDiscountCode,
discount_cents: discountCents,
service_fee_after_discount: finalServiceFee,
} : null,
});
} catch (err) {
console.error("[formations] Error:", err);
res
.status(500)
.json({ error: "Could not place your order. Please try again." });
}
});
// GET /api/v1/formations/:orderNumber — Check order status
router.get("/api/v1/formations/:orderNumber", async (req, res) => {
try {
const { orderNumber } = req.params;
const result = await pool.query(
`SELECT order_number, state_code, entity_type, entity_name, status,
state_filing_number, filed_at, delivered_at, created_at
FROM formation_orders WHERE order_number = $1`,
[orderNumber],
);
if (result.rows.length === 0) {
res.status(404).json({ error: "Order not found" });
return;
}
res.json({ order: result.rows[0] });
} catch (err) {
console.error("[formations] Error fetching order:", err);
res
.status(500)
.json({ error: "Could not retrieve order. Please try again." });
}
});
export default router;

66
api/src/routes/health.ts Normal file
View file

@ -0,0 +1,66 @@
import { Router } from "express";
import { pgHealthy } from "../db.js";
import { cadToUsdCents, getFxInfo } from "../fx.js";
const router = Router();
router.get("/api/v1/status", async (_req, res) => {
const db = await pgHealthy();
const status = db ? "ok" : "degraded";
res.status(db ? 200 : 503).json({
status,
version: "0.1.0",
service: "performancewest-api",
db: db ? "connected" : "unreachable",
timestamp: new Date().toISOString(),
});
});
/**
* GET /api/v1/fx/ca-provinces
*
* Returns Canadian province incorporation fees in both CAD and USD.
* USD = CAD converted at Bank of Canada daily rate + 10% buffer, rounded up to nearest dollar.
* Used by the formation order page to populate the province dropdown.
*/
const CA_PROVINCES_CAD: Array<{ code: string; name: string; feeCad: number; annualCad: number }> = [
{ code: "BC", name: "British Columbia", feeCad: 35000, annualCad: 4200 },
{ code: "AB", name: "Alberta", feeCad: 27500, annualCad: 2000 },
{ code: "ON", name: "Ontario", feeCad: 36000, annualCad: 0 },
{ code: "QC", name: "Quebec", feeCad: 37900, annualCad: 8800 },
{ code: "MB", name: "Manitoba", feeCad: 35000, annualCad: 0 },
{ code: "SK", name: "Saskatchewan", feeCad: 26600, annualCad: 0 },
{ code: "NS", name: "Nova Scotia", feeCad: 42682, annualCad: 11400 },
{ code: "NB", name: "New Brunswick", feeCad: 26200, annualCad: 6700 },
{ code: "PE", name: "Prince Edward Island", feeCad: 31000, annualCad: 0 },
{ code: "NL", name: "Newfoundland and Labrador", feeCad: 30000, annualCad: 2500 },
];
router.get("/api/v1/fx/ca-provinces", async (_req, res) => {
try {
const fxInfo = await getFxInfo();
const provinces = await Promise.all(
CA_PROVINCES_CAD.map(async (p) => ({
code: p.code,
name: p.name,
feeCad: p.feeCad,
feeUsd: await cadToUsdCents(p.feeCad),
annualCad: p.annualCad,
annualUsd: p.annualCad > 0 ? await cadToUsdCents(p.annualCad) : 0,
})),
);
res.json({
provinces,
fx: {
cadUsdRate: fxInfo.rate,
bufferPct: 10,
fetchedAt: fxInfo.fetchedAt,
},
});
} catch (err) {
console.error("[fx] ca-provinces error:", err);
res.status(500).json({ error: "Failed to fetch exchange rates" });
}
});
export default router;

270
api/src/routes/icc.ts Normal file
View file

@ -0,0 +1,270 @@
/**
* Inter-Carrier Compensation (ICC) revenue import API.
*
* Customer-facing endpoints for uploading carrier invoice files (CABS BOS,
* EDI 810, iconectiv 8YY, international settlement, wholesale SIP CSV) +
* reading back the parsed revenue summary. Parsing is done asynchronously
* by scripts/workers/icc_ingester.py polling `icc_ingestion_uploads
* WHERE status='pending'`.
*
* Mirrors the CDR ingestion pattern (migration 050) pre-signed MinIO PUT,
* ingester worker parses, rows land in icc_revenue_lines, deduped by
* natural_key_hash.
*/
import { Router } from "express";
import type { Request, Response } from "express";
import { randomBytes, createHash } from "crypto";
import { pool } from "../db.js";
const router = Router();
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
/** Ask the worker for a presigned MinIO PUT URL. Returns null on failure. */
async function presignPut(key: string, expires = 24 * 3600): Promise<string | null> {
try {
const r = await fetch(`${WORKER_URL}/jobs/presign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, expires, method: "PUT" }),
});
if (!r.ok) return null;
const data = (await r.json()) as { url?: string };
return data.url || null;
} catch {
return null;
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
async function loadProfile(profileId: number) {
const r = await pool.query(
`SELECT * FROM cdr_ingestion_profiles WHERE id = $1`,
[profileId],
);
return r.rows[0] || null;
}
function detectFormatByExtension(fileName: string): string | null {
const f = fileName.toLowerCase();
if (f.endsWith(".bos") || f.endsWith(".bos.gz")) return "cabs_bos";
if (f.endsWith(".810") || f.endsWith(".edi") || f.endsWith(".x12")) return "edi_810";
if (f.endsWith(".qry") || (f.endsWith(".xml") && f.includes("8yy"))) return "8yy_qry";
if (f.endsWith(".tas")) return "itu_tas";
if (f.endsWith(".icss")) return "icss";
if (f.endsWith(".csv")) return "wholesale_sip_csv";
if (f.endsWith(".pdf")) return "carrier_invoice_pdf";
return null;
}
// ── POST /api/v1/icc/upload-token ──────────────────────────────────────
//
// Returns a presigned MinIO PUT URL + token. The portal PUTs the file
// directly to MinIO, then the ingester picks it up from
// `icc_ingestion_uploads WHERE status='pending'`.
router.post("/api/v1/icc/upload-token", async (req: Request, res: Response) => {
const { profile_id, file_name, source_format } = req.body ?? {};
if (!profile_id || !file_name) {
res.status(400).json({ error: "profile_id and file_name required" });
return;
}
const profile = await loadProfile(Number(profile_id));
if (!profile) { res.status(404).json({ error: "profile not found" }); return; }
const detected = source_format || detectFormatByExtension(String(file_name));
if (!detected) {
res.status(400).json({
error: "unable to detect source format; supply source_format explicitly",
});
return;
}
const token = randomBytes(16).toString("hex");
const safeName = String(file_name).replace(/[^A-Za-z0-9._-]/g, "_");
const minioKey =
`icc-uploads/${profile.customer_id}/raw/` +
`${new Date().toISOString().replace(/[:.]/g, "")}_${token}_${safeName}`;
// Placeholder sha256 until the ingester reads the uploaded file; uniqueness
// constraint enforces dedup then.
const placeholder = createHash("sha256")
.update(`${profile.id}_${token}_${safeName}`).digest("hex");
const insert = await pool.query(
`INSERT INTO icc_ingestion_uploads
(profile_id, customer_id, source_format, raw_minio_path, raw_sha256,
status, summary_json)
VALUES ($1, $2, $3, $4, $5, 'pending', $6::jsonb)
RETURNING id`,
[profile.id, profile.customer_id, detected, minioKey, placeholder,
JSON.stringify({ token, file_name: safeName, submitted_at: new Date().toISOString() })],
);
const minioPutUrl = await presignPut(minioKey, 24 * 3600);
res.status(201).json({
upload_id: insert.rows[0].id,
token,
minio_key: minioKey,
minio_put_url: minioPutUrl,
source_format: detected,
expires_in_seconds: 24 * 3600,
});
});
// ── GET /api/v1/icc/profile/:id/uploads ────────────────────────────────
//
// List uploads for a profile with parse status + row counts.
router.get(
"/api/v1/icc/profile/:profile_id/uploads",
async (req: Request, res: Response) => {
const profileId = Number(req.params.profile_id);
if (!Number.isFinite(profileId)) {
res.status(400).json({ error: "bad profile_id" }); return;
}
const r = await pool.query(
`SELECT id, source_format, status, rows_accepted, rows_rejected,
error_message, created_at, parsed_at, summary_json
FROM icc_ingestion_uploads
WHERE profile_id = $1
ORDER BY created_at DESC
LIMIT 100`,
[profileId],
);
res.json({ uploads: r.rows });
},
);
// ── GET /api/v1/icc/profile/:id/summary?year=YYYY ──────────────────────
//
// Aggregate parsed revenue lines by icc_category for the given reporting
// year, and map to the corresponding Form 499-A lines via
// icc_499a_line_mapping. Used by RevenueStep.astro to pre-fill Lines 404,
// 404.1, 404.3, and 418.
router.get(
"/api/v1/icc/profile/:profile_id/summary",
async (req: Request, res: Response) => {
const profileId = Number(req.params.profile_id);
const year = Number(req.query.year) || new Date().getUTCFullYear() - 1;
if (!Number.isFinite(profileId)) {
res.status(400).json({ error: "bad profile_id" }); return;
}
const r = await pool.query(
`SELECT icc.icc_category,
m.form_499a_line,
m.jurisdiction_split,
SUM(icc.revenue_cents)::bigint AS revenue_cents,
COALESCE(SUM(icc.minutes_of_use), 0)::bigint AS minutes_of_use,
COUNT(*)::int AS line_count
FROM icc_revenue_lines icc
JOIN icc_499a_line_mapping m ON m.icc_category = icc.icc_category
WHERE icc.profile_id = $1
AND icc.reporting_year = $2
GROUP BY icc.icc_category, m.form_499a_line, m.jurisdiction_split
ORDER BY revenue_cents DESC`,
[profileId, year],
);
// Aggregate by 499-A line for the "pre-fill Line 404 with $X" summary
const byLine: Record<string, { revenue_cents: number; minutes: number }> = {};
for (const row of r.rows) {
const line = row.form_499a_line;
byLine[line] ||= { revenue_cents: 0, minutes: 0 };
byLine[line].revenue_cents += Number(row.revenue_cents);
byLine[line].minutes += Number(row.minutes_of_use);
}
res.json({
reporting_year: year,
categories: r.rows.map((row) => ({
icc_category: row.icc_category,
form_499a_line: row.form_499a_line,
jurisdiction_split: row.jurisdiction_split,
revenue_cents: Number(row.revenue_cents),
minutes_of_use: Number(row.minutes_of_use),
line_count: row.line_count,
})),
by_form_line: byLine,
grand_total_cents:
r.rows.reduce((acc, row) => acc + Number(row.revenue_cents), 0),
});
},
);
// ── POST /api/v1/icc/profile/:id/reparse/:upload_id ────────────────────
//
// Admin-only: re-run the adapter on an already-uploaded file (e.g.,
// adapter bug was fixed). Flips the upload back to pending; the ingester
// picks it up on next poll. Authorization is the admin header used by
// the rest of the admin endpoints.
router.post(
"/api/v1/icc/profile/:profile_id/reparse/:upload_id",
async (req: Request, res: Response) => {
const adminToken = (req.headers["x-admin-token"] || "").toString();
if (!process.env.ADMIN_API_TOKEN || adminToken !== process.env.ADMIN_API_TOKEN) {
res.status(403).json({ error: "admin token required" }); return;
}
const profileId = Number(req.params.profile_id);
const uploadId = Number(req.params.upload_id);
// Delete already-parsed rows for this upload so reparse is idempotent
await pool.query(
`DELETE FROM icc_revenue_lines WHERE source_upload_id = $1`,
[uploadId],
);
const r = await pool.query(
`UPDATE icc_ingestion_uploads
SET status = 'pending',
error_message = NULL,
rows_accepted = 0,
rows_rejected = 0,
parsed_at = NULL
WHERE id = $1 AND profile_id = $2
RETURNING id`,
[uploadId, profileId],
);
if (r.rows.length === 0) {
res.status(404).json({ error: "upload not found" }); return;
}
res.json({ ok: true, upload_id: uploadId, status: "pending" });
},
);
// ── GET /api/v1/icc/profile/:id/revenue-lines ──────────────────────────
//
// Paginated list of parsed ICC revenue lines. Used by the admin panel to
// audit individual invoice rows.
router.get(
"/api/v1/icc/profile/:profile_id/revenue-lines",
async (req: Request, res: Response) => {
const profileId = Number(req.params.profile_id);
const year = Number(req.query.year) || new Date().getUTCFullYear() - 1;
const category = (req.query.icc_category as string) || null;
const limit = Math.min(Number(req.query.limit) || 100, 500);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const conditions = ["profile_id = $1", "reporting_year = $2"];
const params: (number | string)[] = [profileId, year];
if (category) {
conditions.push(`icc_category = $${params.length + 1}`);
params.push(category);
}
params.push(limit, offset);
const r = await pool.query(
`SELECT id, reporting_quarter, icc_category, counterparty_legal_name,
counterparty_ocn, counterparty_country, revenue_cents,
minutes_of_use, source_upload_id, source_line_no, created_at
FROM icc_revenue_lines
WHERE ${conditions.join(" AND ")}
ORDER BY created_at DESC, id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params,
);
res.json({ lines: r.rows, limit, offset });
},
);
export default router;

129
api/src/routes/id-upload.ts Normal file
View file

@ -0,0 +1,129 @@
import { Router } from "express";
import { pool } from "../db.js";
import { v4 as uuidv4 } from "uuid";
const router = Router();
// POST /api/v1/id-upload/token — Generate a one-time upload token
// Called when client clicks "Upload from Phone (QR)" on the order form
router.post("/api/v1/id-upload/token", async (req, res) => {
try {
const { customer_email, customer_name, order_reference } = req.body ?? {};
if (!customer_email) {
res.status(400).json({ error: "Email is required." });
return;
}
const token = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
await pool.query(
`INSERT INTO id_upload_tokens (token, customer_email, customer_name, order_reference, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
[token, customer_email, customer_name || null, order_reference || null, expiresAt],
);
const uploadUrl = `${req.protocol}://${req.get("host")?.replace(/:\d+$/, "")}:4322/upload/id?token=${token}`;
// In production: https://performancewest.net/upload/id?token=${token}
res.status(201).json({
token,
upload_url: uploadUrl,
expires_at: expiresAt.toISOString(),
});
} catch (err) {
console.error("[id-upload] Token creation error:", err);
res.status(500).json({ error: "Could not create upload token." });
}
});
// POST /api/v1/id-upload/:token — Upload ID images (multipart/form-data)
// Called from both desktop file picker and mobile camera upload page
router.post("/api/v1/id-upload/:token", async (req, res) => {
try {
const { token } = req.params;
// Validate token
const tokenResult = await pool.query(
"SELECT * FROM id_upload_tokens WHERE token = $1",
[token],
);
if (tokenResult.rows.length === 0) {
res.status(404).json({ error: "Invalid upload token." });
return;
}
const tokenData = tokenResult.rows[0];
if (tokenData.used) {
res.status(410).json({ error: "This upload link has already been used." });
return;
}
if (new Date(tokenData.expires_at) < new Date()) {
res.status(410).json({ error: "This upload link has expired. Please request a new one." });
return;
}
// For now, store a placeholder — actual file upload handling requires
// multer middleware + MinIO upload (configured at deployment)
const updates: string[] = [];
const paths: Record<string, string> = tokenData.minio_paths || {};
// Check what was uploaded (multipart fields: front, back)
if (req.body.front_uploaded) {
updates.push("front_uploaded = TRUE");
paths.front = `identity-docs/${token}/front.jpg`;
}
if (req.body.back_uploaded) {
updates.push("back_uploaded = TRUE");
paths.back = `identity-docs/${token}/back.jpg`;
}
const bothDone = (tokenData.front_uploaded || req.body.front_uploaded) &&
(tokenData.back_uploaded || req.body.back_uploaded);
if (bothDone) {
updates.push("used = TRUE");
}
if (updates.length > 0) {
await pool.query(
`UPDATE id_upload_tokens SET ${updates.join(", ")}, minio_paths = $1 WHERE token = $2`,
[JSON.stringify(paths), token],
);
}
res.json({
success: true,
front_uploaded: tokenData.front_uploaded || !!req.body.front_uploaded,
back_uploaded: tokenData.back_uploaded || !!req.body.back_uploaded,
complete: bothDone,
});
} catch (err) {
console.error("[id-upload] Upload error:", err);
res.status(500).json({ error: "Upload failed." });
}
});
// GET /api/v1/id-upload/:token/status — Check upload status (for desktop polling)
router.get("/api/v1/id-upload/:token/status", async (req, res) => {
try {
const result = await pool.query(
"SELECT front_uploaded, back_uploaded, used, expires_at FROM id_upload_tokens WHERE token = $1",
[req.params.token],
);
if (result.rows.length === 0) {
res.status(404).json({ error: "Token not found." });
return;
}
const t = result.rows[0];
res.json({
front_uploaded: t.front_uploaded,
back_uploaded: t.back_uploaded,
complete: t.front_uploaded && t.back_uploaded,
expired: new Date(t.expires_at) < new Date(),
});
} catch (err) {
res.status(500).json({ error: "Could not check status." });
}
});
export default router;

562
api/src/routes/identity.ts Normal file
View file

@ -0,0 +1,562 @@
/**
* Stripe Identity Routes
*
* Provides director KYC via Stripe Identity for ALL order types and payment methods.
* Identity verification is a prerequisite to order creation and payment no exceptions.
*
* Flow:
* 1. Customer enters director name + DOB on the order form (step 2)
* 2. Step 4: "Verify Identity" button POST /api/v1/identity/create-session
* Returns { session_id, client_secret, url } client redirects to Stripe-hosted flow
* 3. Customer completes ID capture on Stripe's page
* 4. Stripe webhook fires identity.verification_session.verified (or .requires_input)
* We extract name + DOB from the report, compare to form values
* Store result in identity_verifications table
* 5. Order page polls GET /api/v1/identity/session/:id for result
* 6. On 'verified': customer proceeds to step 5 (review + payment method)
* On 'needs_review': order is submitted but held for admin payment collected,
* but pipeline doesn't start until admin clears
* On 'failed': customer is blocked; shown error
*
* Name matching tiers (against extracted ID name):
* exact score 100 verified (name_match: exact)
* fuzzy_pass score 85-99 verified (acceptable nickname/middle name variations)
* fuzzy_warn score 70-84 needs_review (possible typo or legal name difference)
* mismatch score < 70 failed (clearly different person)
*
* DOB matching:
* exact good
* no DOB on ID needs_review (some passports omit DOB field)
* mismatch needs_review (not a hard block DOB typos happen, but flag it)
*/
import express, { Router, raw } from "express";
import Stripe from "stripe";
import { pool } from "../db.js";
import { submitLimiter } from "../middleware/rate-limit.js";
import { callMethod } from "../erpnext-client.js";
const router = Router();
const STRIPE_SECRET_KEY =
(process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_SECRET_KEY?.trim()) ||
process.env.STRIPE_SECRET_KEY ||
"";
const STRIPE_IDENTITY_WEBHOOK_SECRET =
(process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_IDENTITY_WEBHOOK_SECRET?.trim()) ||
process.env.STRIPE_IDENTITY_WEBHOOK_SECRET ||
"";
const DOMAIN = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "http://localhost:4321";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stripe = STRIPE_SECRET_KEY ? new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2026-03-25.dahlia" as any }) : null;
// ─── Name comparison ─────────────────────────────────────────────────────────
function normalize(s: string): string {
return s
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) =>
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
);
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j-1], dp[i][j-1], dp[i-1][j]);
return dp[m][n];
}
function nameSimilarity(a: string, b: string): number {
if (!a && !b) return 100;
if (!a || !b) return 0;
const dist = levenshtein(a, b);
return Math.round((1 - dist / Math.max(a.length, b.length)) * 100);
}
type NameMatchResult = "exact" | "fuzzy_pass" | "fuzzy_warn" | "mismatch";
function compareNames(formName: string, idFirst: string, idLast: string): { score: number; result: NameMatchResult } {
const formNorm = normalize(formName);
const idFull1 = normalize(`${idFirst} ${idLast}`);
const idFull2 = normalize(`${idLast} ${idFirst}`);
const idFull3 = normalize(`${idLast}, ${idFirst}`);
const score = Math.max(
nameSimilarity(formNorm, idFull1),
nameSimilarity(formNorm, idFull2),
nameSimilarity(formNorm, idFull3),
);
let result: NameMatchResult;
if (score === 100) result = "exact";
else if (score >= 85) result = "fuzzy_pass";
else if (score >= 70) result = "fuzzy_warn";
else result = "mismatch";
return { score, result };
}
type DobMatchResult = "exact" | "no_dob_on_id" | "mismatch";
function compareDob(
formDob: string | null | undefined,
idYear: number | null,
idMonth: number | null,
idDay: number | null,
): DobMatchResult {
if (!idYear && !idMonth && !idDay) return "no_dob_on_id";
if (!formDob) return "no_dob_on_id";
const d = new Date(formDob);
if (isNaN(d.getTime())) return "no_dob_on_id";
if (
d.getFullYear() === idYear &&
(d.getMonth() + 1) === idMonth &&
d.getDate() === idDay
) return "exact";
return "mismatch";
}
function deriveOverallResult(
nameMatch: NameMatchResult,
dobMatch: DobMatchResult,
docExpired: boolean,
): "verified" | "needs_review" | "failed" {
if (nameMatch === "mismatch") return "failed";
if (docExpired) return "needs_review";
if (nameMatch === "fuzzy_warn") return "needs_review";
if (dobMatch === "mismatch") return "needs_review";
return "verified";
}
// ─── POST /api/v1/identity/create-session ────────────────────────────────────
/**
* Create a Stripe Identity VerificationSession for the director.
* Called when the customer clicks "Verify Identity" on step 4.
*
* Body: { director_name, director_dob?, customer_email, order_type }
* Returns: { session_id, client_secret, url }
*/
router.post("/api/v1/identity/create-session", express.json({ limit: "100kb" }), submitLimiter, async (req, res) => {
if (!stripe) {
res.status(503).json({ error: "Identity verification not configured (STRIPE_SECRET_KEY missing)" });
return;
}
const { director_name, director_dob, customer_email, order_type = "canada_crtc", order_name } = req.body ?? {};
if (!director_name || typeof director_name !== "string" || director_name.trim().length < 2) {
res.status(400).json({ error: "director_name is required" });
return;
}
try {
// Create Stripe Identity VerificationSession
const session = await stripe.identity.verificationSessions.create({
type: "document",
metadata: {
director_name: director_name.trim(),
director_dob: director_dob ?? "",
customer_email: customer_email ?? "",
order_type,
order_name: order_name ?? "",
},
options: {
document: {
// Accept passports, driver licenses, and national ID cards globally
allowed_types: ["driving_license", "passport", "id_card"],
require_id_number: false,
require_live_capture: true,
require_matching_selfie: false, // selfie optional — too much friction for our use case
},
},
return_url: `${DOMAIN}/order/identity-complete`,
});
// Store pending record in DB
await pool.query(
`INSERT INTO identity_verifications
(stripe_session_id, stripe_status, form_director_name, form_director_dob,
customer_email, order_type, ip_address, user_agent,
name_match, dob_match, overall_result)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', 'pending', 'pending')
ON CONFLICT (stripe_session_id) DO NOTHING`,
[
session.id,
session.status,
director_name.trim(),
director_dob ?? null,
customer_email ?? null,
order_type,
(req as unknown as Record<string, unknown>).clientIp ?? req.ip,
req.headers["user-agent"] ?? null,
],
);
res.json({
session_id: session.id,
client_secret: session.client_secret,
url: session.url,
status: session.status,
});
} catch (err) {
console.error("[identity] create-session error:", err);
res.status(500).json({ error: "Could not create identity verification session" });
}
});
// ─── GET /api/v1/identity/session/:id ────────────────────────────────────────
/**
* Poll the status of an identity verification session.
* The order form polls this every 3s after the customer returns from Stripe.
*/
router.get("/api/v1/identity/session/:id", async (req, res) => {
const { id } = req.params;
try {
const { rows } = await pool.query(
`SELECT stripe_session_id, stripe_status, overall_result,
name_match, name_match_score, dob_match, doc_expired,
id_doc_type, id_issuing_country, verified_at
FROM identity_verifications WHERE stripe_session_id = $1`,
[id],
);
if (!rows.length) {
// Not in DB yet — webhook may not have arrived. Check Stripe directly and
// process inline so the client isn't stuck polling forever.
if (!stripe) { res.status(404).json({ error: "Session not found" }); return; }
const session = await stripe.identity.verificationSessions.retrieve(id, {
expand: ["last_verification_report"],
});
// If Stripe already has a terminal status, process it now without waiting for webhook
if (session.status === "verified" || session.status === "requires_input" || session.status === "canceled") {
try {
await handleVerificationComplete({ type: "identity.verification_session." + (session.status === "verified" ? "verified" : "requires_input"), data: { object: session } } as unknown as Stripe.Event);
} catch (processErr) {
console.warn("[identity] inline process failed, returning status only:", processErr);
res.json({ session_id: id, status: session.status, overall_result: session.status === "verified" ? "verified" : "failed" });
return;
}
// Now fetch from DB — should be populated
const { rows: newRows } = await pool.query(
`SELECT stripe_session_id, stripe_status, overall_result,
name_match, name_match_score, dob_match, doc_expired,
id_doc_type, id_issuing_country, verified_at
FROM identity_verifications WHERE stripe_session_id = $1`,
[id],
);
if (newRows.length) {
const row = newRows[0] as Record<string, unknown>;
res.json({
session_id: row.stripe_session_id,
status: row.stripe_status,
overall_result: row.overall_result,
name_match: row.name_match,
name_match_score: row.name_match_score,
dob_match: row.dob_match,
doc_expired: row.doc_expired,
doc_type: row.id_doc_type,
issuing_country: row.id_issuing_country,
verified_at: row.verified_at,
});
return;
}
}
// Still processing on Stripe's side
res.json({ session_id: id, status: session.status, overall_result: "pending" });
return;
}
const row = rows[0] as Record<string, unknown>;
// If DB still shows pending but enough time has passed, re-check Stripe and
// process inline — handles the case where the webhook is delayed or missing.
if (row.overall_result === "pending" && stripe) {
try {
const session = await stripe.identity.verificationSessions.retrieve(id, {
expand: ["last_verification_report"],
});
if (session.status === "verified" || session.status === "requires_input" || session.status === "canceled") {
await handleVerificationComplete({ type: "identity.verification_session." + (session.status === "verified" ? "verified" : "requires_input"), data: { object: session } } as unknown as Stripe.Event);
// Re-fetch updated row
const { rows: updated } = await pool.query(
`SELECT stripe_session_id, stripe_status, overall_result,
name_match, name_match_score, dob_match, doc_expired,
id_doc_type, id_issuing_country, verified_at
FROM identity_verifications WHERE stripe_session_id = $1`,
[id],
);
if (updated.length) {
const r2 = updated[0] as Record<string, unknown>;
res.json({
session_id: r2.stripe_session_id,
status: r2.stripe_status,
overall_result: r2.overall_result,
name_match: r2.name_match,
name_match_score: r2.name_match_score,
dob_match: r2.dob_match,
doc_expired: r2.doc_expired,
doc_type: r2.id_doc_type,
issuing_country: r2.id_issuing_country,
verified_at: r2.verified_at,
});
return;
}
}
} catch (retryErr) {
console.warn("[identity] inline reprocess failed:", retryErr);
}
}
res.json({
session_id: row.stripe_session_id,
status: row.stripe_status,
overall_result: row.overall_result,
name_match: row.name_match,
name_match_score: row.name_match_score,
dob_match: row.dob_match,
doc_expired: row.doc_expired,
doc_type: row.id_doc_type,
issuing_country: row.id_issuing_country,
verified_at: row.verified_at,
});
} catch (err) {
console.error("[identity] session status error:", err);
res.status(500).json({ error: "Could not retrieve session status" });
}
});
// ─── POST /api/v1/webhooks/stripe-identity ────────────────────────────────────
/**
* Stripe Identity webhook handler.
* Separate secret from the payment webhook register a separate endpoint
* in Stripe Dashboard for identity events:
* identity.verification_session.verified
* identity.verification_session.requires_input
* identity.verification_session.canceled
*
* Must be mounted BEFORE express.json() middleware (needs raw Buffer).
*/
router.post(
"/api/v1/webhooks/stripe-identity",
raw({ type: "application/json" }),
async (req, res) => {
if (!stripe) { res.status(503).json({ error: "Stripe not configured" }); return; }
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body as Buffer,
req.headers["stripe-signature"] ?? "",
STRIPE_IDENTITY_WEBHOOK_SECRET,
);
} catch (err) {
console.error("[identity-webhook] Signature verification failed:", err);
res.status(400).json({ error: "Invalid signature" });
return;
}
try {
switch (event.type) {
case "identity.verification_session.verified":
case "identity.verification_session.requires_input": {
await handleVerificationComplete(event);
break;
}
case "identity.verification_session.canceled": {
const session = event.data.object as Stripe.Identity.VerificationSession;
await pool.query(
`UPDATE identity_verifications
SET stripe_status = 'canceled', overall_result = 'failed'
WHERE stripe_session_id = $1`,
[session.id],
);
break;
}
default: break;
}
res.json({ received: true });
} catch (err) {
console.error("[identity-webhook] Handler error:", err);
res.json({ received: true, error: "handler error" });
}
},
);
// ─── Webhook handler: process completed verification ─────────────────────────
async function handleVerificationComplete(event: Stripe.Event): Promise<void> {
const session = event.data.object as Stripe.Identity.VerificationSession;
const sessionId = session.id;
// Fetch the verification report for extracted document data
let report: Stripe.Identity.VerificationReport | null = null;
if (stripe && session.last_verification_report) {
try {
const reportId = typeof session.last_verification_report === "string"
? session.last_verification_report
: session.last_verification_report.id;
report = await stripe.identity.verificationReports.retrieve(reportId);
} catch (err) {
console.error("[identity-webhook] Could not fetch verification report:", err);
}
}
// Pull form values from metadata
const meta = session.metadata ?? {};
const formName = meta.director_name ?? "";
const formDob = meta.director_dob ?? null;
// Extract document fields from report
const doc = report?.document;
const idFirstName = doc?.first_name ?? null;
const idLastName = doc?.last_name ?? null;
const idDobYear = (doc?.dob as { year?: number } | null)?.year ?? null;
const idDobMonth = (doc?.dob as { month?: number } | null)?.month ?? null;
const idDobDay = (doc?.dob as { day?: number } | null)?.day ?? null;
const idDocType = doc?.type ?? null;
const idCountry = doc?.issuing_country ?? null;
const idDocNum = doc?.number ?? null; // stored then redacted
// Expiry check
const expiryYear = (doc?.expiration_date as { year?: number } | null)?.year ?? null;
const expiryMonth = (doc?.expiration_date as { month?: number } | null)?.month ?? null;
const expiryDay = (doc?.expiration_date as { day?: number } | null)?.day ?? null;
let docExpired = false;
if (expiryYear && expiryMonth && expiryDay) {
const expiry = new Date(expiryYear, expiryMonth - 1, expiryDay);
docExpired = expiry < new Date();
}
// Compare names + DOB
let nameMatch: NameMatchResult = "mismatch";
let nameScore = 0;
let dobMatch: DobMatchResult | "pending" = "pending";
if (idFirstName || idLastName) {
const nm = compareNames(formName, idFirstName ?? "", idLastName ?? "");
nameMatch = nm.result;
nameScore = nm.score;
dobMatch = compareDob(formDob, idDobYear, idDobMonth, idDobDay);
}
const stripeStatus = session.status;
// In test mode (sk_test_ key), Stripe always returns "Jenny Rosen" as the test
// identity — skip name/DOB comparison and auto-pass if Stripe verified the doc.
const isTestMode = STRIPE_SECRET_KEY.startsWith("sk_test_");
if (isTestMode && stripeStatus === "verified") {
nameMatch = "exact";
nameScore = 100;
dobMatch = "exact";
docExpired = false;
console.log("[identity-webhook] Test mode: bypassing name/DOB match (Stripe test identity is always Jenny Rosen)");
}
// If Stripe says 'requires_input', the document check failed — treat as failed
const effectiveName = stripeStatus === "verified" ? nameMatch : "mismatch" as NameMatchResult;
const overallResult = stripeStatus === "verified"
? deriveOverallResult(effectiveName, dobMatch as DobMatchResult, docExpired)
: "failed";
const idFullName = [idFirstName, idLastName].filter(Boolean).join(" ") || null;
await pool.query(
`UPDATE identity_verifications SET
stripe_status = $1,
stripe_report_id = $2,
id_first_name = $3,
id_last_name = $4,
id_full_name_extracted = $5,
id_dob_year = $6,
id_dob_month = $7,
id_dob_day = $8,
id_doc_type = $9,
id_issuing_country = $10,
id_expiry_year = $11,
id_expiry_month = $12,
id_expiry_day = $13,
doc_expired = $14,
name_match_score = $15,
name_match = $16,
dob_match = $17,
overall_result = $18,
verified_at = CASE WHEN $18 IN ('verified','needs_review') THEN NOW() ELSE NULL END
WHERE stripe_session_id = $19`,
[
stripeStatus,
report?.id ?? null,
idFirstName,
idLastName,
idFullName,
idDobYear,
idDobMonth,
idDobDay,
idDocType,
idCountry,
expiryYear,
expiryMonth,
expiryDay,
docExpired,
nameScore,
effectiveName,
(dobMatch === "pending" ? "no_dob_on_id" : dobMatch) as DobMatchResult,
overallResult,
sessionId,
],
);
console.log(
`[identity-webhook] Session ${sessionId}: stripe=${stripeStatus}, name=${effectiveName}(${nameScore}%), dob=${dobMatch}, expired=${docExpired}${overallResult}`,
);
// Sync identity status to ERPNext Sales Order (best-effort)
const orderName = meta.order_name || null;
if (orderName) {
try {
const erpnextStatus = overallResult === "verified" ? "Verified"
: overallResult === "needs_review" ? "Needs Review"
: overallResult === "failed" ? "Failed"
: "Pending";
await callMethod(
"performancewest_erpnext.api.update_identity_status",
{
order_name: orderName,
status: erpnextStatus,
session_id: sessionId,
},
);
console.log(`[identity] Synced identity status to ERPNext: ${orderName}${erpnextStatus}`);
} catch (err) {
// Log but don't fail — PG is source of truth for identity, ERPNext sync is best-effort
console.error("[identity] Failed to sync identity status to ERPNext:", err);
}
}
// If needs_review, alert admin
if (overallResult === "needs_review") {
console.warn(
`[identity-webhook] NEEDS REVIEW: session ${sessionId} — name score ${nameScore}%, dob=${dobMatch}, expired=${docExpired}. Form name: "${formName}", ID name: "${idFullName}"`,
);
// TODO: send admin alert email / ERPNext notification
}
}
export default router;

View file

@ -0,0 +1,168 @@
/**
* LNPA Region Allocations (Form 499-A Block 5 Lines 503-510).
*
* The 10 NANPA regions. For every filer with telecom revenue, Block 3
* (carrier's carrier) and Block 4 (end user) columns must each sum to
* 100% across the 10 rows. Stored per (entity, year, period) to preserve
* historical filings.
*/
import { Router } from "express";
import type { Request, Response } from "express";
import { pool } from "../db.js";
const router = Router();
const VALID_REGIONS = [
"NE", "MA", "SE", "SC", "TX", "MW", "IA", "RM", "NW", "WC",
];
const VALID_PERIODS = ["annual", "Q1", "Q2", "Q3", "Q4"];
// ── GET /api/v1/lnpa-regions/entity/:telecom_entity_id ─────────────────
//
// List allocations for an entity+year+period. Fills in zeros for regions
// without rows so the client always sees 10 rows.
router.get(
"/api/v1/lnpa-regions/entity/:telecom_entity_id",
async (req: Request, res: Response) => {
const entityId = Number(req.params.telecom_entity_id);
const year = Number(req.query.year) || new Date().getUTCFullYear() - 1;
const period = (req.query.period as string) || "annual";
if (!VALID_PERIODS.includes(period)) {
res.status(400).json({ error: `period must be ${VALID_PERIODS.join(" | ")}` }); return;
}
const r = await pool.query(
`SELECT region_code, block_3_pct, block_4_pct
FROM lnpa_region_allocations
WHERE telecom_entity_id = $1
AND reporting_year = $2
AND reporting_period = $3`,
[entityId, year, period],
);
const existing = new Map(r.rows.map((row) => [row.region_code, row]));
const rows = VALID_REGIONS.map((region) => {
const e = existing.get(region);
return {
region_code: region,
block_3_pct: e ? Number(e.block_3_pct) : 0,
block_4_pct: e ? Number(e.block_4_pct) : 0,
};
});
const b3Sum = rows.reduce((a, r) => a + r.block_3_pct, 0);
const b4Sum = rows.reduce((a, r) => a + r.block_4_pct, 0);
res.json({
reporting_year: year,
reporting_period: period,
allocations: rows,
block_3_pct_sum: Number(b3Sum.toFixed(2)),
block_4_pct_sum: Number(b4Sum.toFixed(2)),
valid: (Math.abs(b3Sum - 100) < 0.01 || b3Sum === 0) &&
(Math.abs(b4Sum - 100) < 0.01 || b4Sum === 0),
});
},
);
// ── PUT /api/v1/lnpa-regions/entity/:telecom_entity_id ─────────────────
//
// Replace the full set of allocations for a (year, period). Body:
// { reporting_year, reporting_period, allocations: [
// {region_code:"NE", block_3_pct: 12.5, block_4_pct: 10.0}, ...
// ]}
// Validates that each column sums to exactly 100.00% (or 0 if no revenue
// in that block — customer can leave one column at 0 if they don't
// report Block 3 resale revenue, for example).
router.put(
"/api/v1/lnpa-regions/entity/:telecom_entity_id",
async (req: Request, res: Response) => {
const entityId = Number(req.params.telecom_entity_id);
const { reporting_year, reporting_period, allocations } = req.body ?? {};
if (!reporting_year || !allocations || !Array.isArray(allocations)) {
res.status(400).json({
error: "reporting_year + allocations[] required",
}); return;
}
const period = reporting_period || "annual";
if (!VALID_PERIODS.includes(period)) {
res.status(400).json({ error: `reporting_period must be ${VALID_PERIODS.join(" | ")}` }); return;
}
// Validate every allocation shape
for (const a of allocations) {
if (!VALID_REGIONS.includes(a.region_code)) {
res.status(400).json({
error: `region_code ${a.region_code} not in ${VALID_REGIONS.join(", ")}`,
});
return;
}
const b3 = Number(a.block_3_pct);
const b4 = Number(a.block_4_pct);
if (Number.isNaN(b3) || b3 < 0 || b3 > 100) {
res.status(400).json({ error: `block_3_pct out of range for ${a.region_code}` }); return;
}
if (Number.isNaN(b4) || b4 < 0 || b4 > 100) {
res.status(400).json({ error: `block_4_pct out of range for ${a.region_code}` }); return;
}
}
// Validate column sums
const b3Sum = allocations.reduce((a: number, r: Record<string, unknown>) => a + Number(r.block_3_pct), 0);
const b4Sum = allocations.reduce((a: number, r: Record<string, unknown>) => a + Number(r.block_4_pct), 0);
const b3Valid = Math.abs(b3Sum - 100) < 0.01 || b3Sum === 0;
const b4Valid = Math.abs(b4Sum - 100) < 0.01 || b4Sum === 0;
if (!b3Valid || !b4Valid) {
res.status(422).json({
error: "Column sums must be exactly 100.00% or 0%",
block_3_pct_sum: Number(b3Sum.toFixed(2)),
block_4_pct_sum: Number(b4Sum.toFixed(2)),
block_3_valid: b3Valid,
block_4_valid: b4Valid,
});
return;
}
// Upsert each row in a transaction
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
`DELETE FROM lnpa_region_allocations
WHERE telecom_entity_id = $1
AND reporting_year = $2
AND reporting_period = $3`,
[entityId, reporting_year, period],
);
for (const a of allocations) {
await client.query(
`INSERT INTO lnpa_region_allocations
(telecom_entity_id, reporting_year, reporting_period,
region_code, block_3_pct, block_4_pct)
VALUES ($1, $2, $3, $4, $5, $6)`,
[entityId, reporting_year, period, a.region_code,
Number(a.block_3_pct), Number(a.block_4_pct)],
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
res.json({
saved: allocations.length,
block_3_pct_sum: Number(b3Sum.toFixed(2)),
block_4_pct_sum: Number(b4Sum.toFixed(2)),
});
},
);
export default router;

View file

@ -0,0 +1,135 @@
/**
* Payment method surcharge routes.
*
* GET /api/v1/payment-methods List available methods with surcharge rates
* POST /api/v1/payment-methods/calculate Calculate surcharge for a given amount + method
*
* Source of truth: ERPNext Payment Gateway Accounts.
* Falls back to hardcoded if ERPNext is unavailable.
*
* Gateways:
* Adyen-Card card 3% (Visa/MC/Amex + Apple Pay + Google Pay)
* Adyen-ACH ach 0% ($0.40 flat, absorbed)
* Adyen-Klarna klarna 5% (Adyen Klarna: 4.29%+$0.30)
* Adyen-CashApp cashapp 3% (2.90%+$0.30)
* Adyen-AmazonPay amazonpay 3% (~2.9-3.4%)
* Crypto crypto 0% (SHKeeper, self-hosted zero fees)
*/
import { Router } from "express";
import { getResource } from "../erpnext-client.js";
const router = Router();
// Static surcharge map (source of truth — matches ERPNext gateway config + payment_surcharges table)
const GATEWAY_SURCHARGES: Record<string, number> = {
"Adyen-Card": 3.0,
"Adyen-ACH": 0.0,
"Adyen-Klarna": 5.0,
"Adyen-CashApp": 3.0,
"Adyen-AmazonPay": 3.0,
"Crypto": 0.0, // frappe_crypto (SHKeeper backend)
};
// Map ERPNext Payment Gateway Account names → frontend method keys
const GATEWAY_TO_METHOD: Record<string, string> = {
"Adyen-Card": "card",
"Adyen-ACH": "ach",
"Adyen-Klarna": "klarna",
"Adyen-CashApp": "cashapp",
"Adyen-AmazonPay": "amazonpay",
"Crypto": "crypto",
};
const METHOD_LABELS: Record<string, string> = {
ach: "Bank Transfer (ACH)",
card: "Credit or Debit Card",
klarna: "Klarna — Pay in 4",
cashapp: "Cash App Pay",
amazonpay: "Amazon Pay",
crypto: "Cryptocurrency",
};
const METHOD_DESCRIPTIONS: Record<string, string> = {
ach: "No processing fee. US bank accounts only. 2-3 business day settlement.",
card: "3% processing surcharge. Visa, Mastercard, Amex, Apple Pay, Google Pay.",
klarna: "5% processing surcharge. Split into 4 interest-free installments.",
cashapp: "3% processing surcharge. Pay with your Cash App balance.",
amazonpay: "3% processing surcharge. Pay with your Amazon account.",
crypto: "No processing fee. BTC, ETH, USDC, USDT, MATIC, TRX, BNB, LTC, DOGE via SHKeeper.",
};
const HARDCODED_FALLBACK = [
{ method: "ach", label: METHOD_LABELS.ach, surcharge_pct: "0.00", description: METHOD_DESCRIPTIONS.ach },
{ method: "card", label: METHOD_LABELS.card, surcharge_pct: "3.00", description: METHOD_DESCRIPTIONS.card },
{ method: "klarna", label: METHOD_LABELS.klarna, surcharge_pct: "5.00", description: METHOD_DESCRIPTIONS.klarna },
{ method: "cashapp", label: METHOD_LABELS.cashapp, surcharge_pct: "3.00", description: METHOD_DESCRIPTIONS.cashapp },
{ method: "amazonpay", label: METHOD_LABELS.amazonpay, surcharge_pct: "3.00", description: METHOD_DESCRIPTIONS.amazonpay },
{ method: "crypto", label: METHOD_LABELS.crypto, surcharge_pct: "0.00", description: METHOD_DESCRIPTIONS.crypto },
];
const METHOD_ORDER = ["ach", "card", "klarna", "cashapp", "amazonpay", "crypto"];
router.get("/api/v1/payment-methods", async (_req, res) => {
try {
// Query ERPNext Payment Gateway Accounts
const accounts = await getResource(
"Payment Gateway Account",
undefined,
{ is_default: ["in", [0, 1]] }, // all active accounts
["name", "payment_gateway", "currency"],
20,
) as Array<{ name: string; payment_gateway: string; currency: string }>;
const methods = accounts
.filter(a => GATEWAY_TO_METHOD[a.name])
.map(a => {
const method = GATEWAY_TO_METHOD[a.name];
const pct = GATEWAY_SURCHARGES[a.name] ?? 0;
return {
method,
label: METHOD_LABELS[method] || a.name,
surcharge_pct: pct.toFixed(2),
description: METHOD_DESCRIPTIONS[method] || "",
gateway_account: a.name,
};
})
.sort((a, b) => METHOD_ORDER.indexOf(a.method) - METHOD_ORDER.indexOf(b.method));
if (methods.length === 0) {
res.json({ methods: HARDCODED_FALLBACK, source: "fallback" });
return;
}
res.json({ methods, source: "erpnext" });
} catch (_err) {
// ERPNext unavailable — use hardcoded fallback
res.json({ methods: HARDCODED_FALLBACK, source: "fallback" });
}
});
router.post("/api/v1/payment-methods/calculate", (req, res) => {
const { method, amount_cents } = req.body ?? {};
if (!method || !amount_cents || typeof amount_cents !== "number") {
res.status(400).json({ error: "method and amount_cents (number) are required." });
return;
}
const methodToGateway: Record<string, string> = {
ach: "Adyen-ACH",
card: "Adyen-Card",
klarna: "Adyen-Klarna",
cashapp: "Adyen-CashApp",
amazonpay: "Adyen-AmazonPay",
crypto: "Crypto",
};
const gateway = methodToGateway[method] || method;
const pct = GATEWAY_SURCHARGES[gateway] ?? 0;
const surcharge_cents = Math.round((amount_cents * pct) / 100);
const total_cents = amount_cents + surcharge_cents;
res.json({ method, subtotal_cents: amount_cents, surcharge_pct: pct, surcharge_cents, total_cents });
});
export default router;

247
api/src/routes/paypal.ts Normal file
View file

@ -0,0 +1,247 @@
/**
* PayPal direct integration capture, tracking, refund.
*
* POST /api/v1/paypal/capture Capture approved order (called from success page)
* POST /api/v1/paypal/tracking Add tracking number to captured order
* POST /api/v1/paypal/refund Refund a captured payment
* GET /api/v1/paypal/order/:id/status Check PayPal order status
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
import { handlePaymentComplete } from "./checkout.js";
const router = Router();
// ─── PayPal API helpers ───────────────────────────────────────────────────────
const PAYPAL_API_URL = process.env.PAYPAL_API_URL || "https://api-m.paypal.com";
const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || "";
const PAYPAL_CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET || "";
async function getAccessToken(): Promise<string> {
const res = await fetch(`${PAYPAL_API_URL}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
const data = await res.json() as { access_token?: string; error?: string };
if (!data.access_token) throw new Error(`PayPal auth failed: ${data.error || "no access_token"}`);
return data.access_token;
}
async function paypalFetch(method: string, path: string, body?: object): Promise<{ status: number; data: any }> {
const token = await getAccessToken();
const res = await fetch(`${PAYPAL_API_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
...(body ? { body: JSON.stringify(body) } : {}),
});
const data = await res.json().catch(() => ({}));
return { status: res.status, data };
}
// ─── Capture an approved PayPal order ─────────────────────────────────────────
// Called when the buyer is redirected back after approving payment on PayPal.
router.post("/api/v1/paypal/capture", async (req: Request, res: Response) => {
try {
const { paypal_order_id, order_id, order_type } = req.body as {
paypal_order_id?: string;
order_id?: string;
order_type?: string;
};
if (!paypal_order_id) {
res.status(400).json({ error: "paypal_order_id required" });
return;
}
const { status, data } = await paypalFetch("POST", `/v2/checkout/orders/${paypal_order_id}/capture`);
if (status >= 400) {
console.error("[paypal] Capture failed:", data);
// If already captured, treat as success
if (data?.details?.[0]?.issue === "ORDER_ALREADY_CAPTURED") {
const check = await paypalFetch("GET", `/v2/checkout/orders/${paypal_order_id}`);
if (check.data?.status === "COMPLETED") {
res.json({ success: true, status: "COMPLETED", already_captured: true });
return;
}
}
res.status(502).json({ error: "PayPal capture failed", details: data });
return;
}
if (data.status === "COMPLETED") {
console.log(`[paypal] Captured ${paypal_order_id} successfully`);
// Find the internal order and mark as paid
const resolvedOrderId = order_id || data.purchase_units?.[0]?.custom_id || data.purchase_units?.[0]?.reference_id;
const resolvedOrderType = order_type || "canada_crtc";
if (resolvedOrderId) {
try {
// Store PayPal capture details in the order before marking paid
const captureId = data.purchase_units?.[0]?.payments?.captures?.[0]?.id || "";
const payerEmail = data.payer?.email_address || "";
const table = resolvedOrderType === "formation" ? "formation_orders" : "canada_crtc_orders";
await pool.query(
`UPDATE ${table} SET paypal_order_id = $1 WHERE order_number = $2`,
[paypal_order_id, resolvedOrderId],
).catch(() => {});
await handlePaymentComplete(resolvedOrderId, resolvedOrderType, `paypal-${captureId}`);
} catch (err) {
console.error("[paypal] handlePaymentComplete failed (non-blocking):", err);
}
}
res.json({
success: true,
status: "COMPLETED",
capture_id: data.purchase_units?.[0]?.payments?.captures?.[0]?.id,
payer_email: data.payer?.email_address,
});
} else {
res.json({ success: false, status: data.status, details: data });
}
} catch (err: any) {
console.error("[paypal] Capture error:", err);
res.status(500).json({ error: err.message || "PayPal capture failed" });
}
});
// ─── Check PayPal order status ────────────────────────────────────────────────
router.get("/api/v1/paypal/order/:id/status", async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { status, data } = await paypalFetch("GET", `/v2/checkout/orders/${id}`);
if (status >= 400) {
res.status(status).json({ error: "PayPal order lookup failed", details: data });
return;
}
res.json({
paypal_order_id: data.id,
status: data.status,
payer_email: data.payer?.email_address,
amount: data.purchase_units?.[0]?.amount,
custom_id: data.purchase_units?.[0]?.custom_id,
});
} catch (err: any) {
console.error("[paypal] Status check error:", err);
res.status(500).json({ error: err.message });
}
});
// ─── Add tracking number to a captured order ──────────────────────────────────
router.post("/api/v1/paypal/tracking", async (req: Request, res: Response) => {
try {
const { capture_id, tracking_number, carrier, order_id } = req.body as {
capture_id?: string;
tracking_number?: string;
carrier?: string;
order_id?: string;
};
if (!capture_id || !tracking_number) {
res.status(400).json({ error: "capture_id and tracking_number required" });
return;
}
// PayPal tracking API — POST /v1/shipping/trackers-batch
const { status, data } = await paypalFetch("POST", "/v1/shipping/trackers-batch", {
trackers: [{
transaction_id: capture_id,
tracking_number,
status: "SHIPPED",
carrier: carrier || "OTHER",
}],
});
if (status >= 400) {
console.error("[paypal] Tracking update failed:", data);
res.status(502).json({ error: "PayPal tracking update failed", details: data });
return;
}
console.log(`[paypal] Tracking added for capture ${capture_id}: ${tracking_number}`);
res.json({ success: true, tracking_number, capture_id });
} catch (err: any) {
console.error("[paypal] Tracking error:", err);
res.status(500).json({ error: err.message });
}
});
// ─── Refund a captured payment ────────────────────────────────────────────────
router.post("/api/v1/paypal/refund", async (req: Request, res: Response) => {
try {
const { capture_id, amount, currency, reason, order_id } = req.body as {
capture_id?: string;
amount?: string;
currency?: string;
reason?: string;
order_id?: string;
};
if (!capture_id) {
res.status(400).json({ error: "capture_id required" });
return;
}
const refundBody: Record<string, any> = {};
if (amount) {
refundBody.amount = { value: amount, currency_code: currency || "USD" };
}
if (reason) {
refundBody.note_to_payer = reason;
}
const { status, data } = await paypalFetch(
"POST",
`/v2/payments/captures/${capture_id}/refund`,
Object.keys(refundBody).length > 0 ? refundBody : undefined,
);
if (status >= 400) {
console.error("[paypal] Refund failed:", data);
res.status(502).json({ error: "PayPal refund failed", details: data });
return;
}
console.log(`[paypal] Refund ${data.id} for capture ${capture_id}: ${data.status}`);
// Update order status if order_id provided
if (order_id) {
await pool.query(
`UPDATE canada_crtc_orders SET payment_status = 'refunded' WHERE order_number = $1`,
[order_id],
).catch(() => {});
await pool.query(
`UPDATE formation_orders SET payment_status = 'refunded' WHERE order_number = $1`,
[order_id],
).catch(() => {});
}
res.json({
success: true,
refund_id: data.id,
status: data.status,
amount: data.amount,
});
} catch (err: any) {
console.error("[paypal] Refund error:", err);
res.status(500).json({ error: err.message });
}
});
export default router;

View file

@ -0,0 +1,398 @@
/**
* Customer portal authentication email + password.
*
* POST /api/v1/auth/register { email, password, name? }
* POST /api/v1/auth/login { email, password }
* POST /api/v1/auth/logout
* GET /api/v1/auth/me
* POST /api/v1/auth/forgot-password { email }
* POST /api/v1/auth/reset-password { token, password }
*/
import { Router, Request, Response } from "express";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import nodemailer from "nodemailer";
import { pool } from "../db.js";
const SITE_URL = process.env.SITE_URL || "https://performancewest.net";
const RESET_TTL_MINUTES = 30;
async function sendEmail(opts: { to: string; subject: string; html: string; text: string }) {
const t = nodemailer.createTransport({
host: process.env.SMTP_HOST || "",
port: parseInt(process.env.SMTP_PORT || "587"),
secure: false,
auth: { user: process.env.SMTP_USER || "", pass: process.env.SMTP_PASS || "" },
});
await t.sendMail({ from: process.env.SMTP_FROM || "noreply@performancewest.net", ...opts });
}
import {
issueCustomerCookie,
clearCustomerCookie,
optionalCustomerAuth,
} from "../middleware/customer-auth.js";
import {
ensureWebsiteUser,
setWebsiteUserPassword,
linkUserToCustomer,
} from "../erpnext-client.js";
const router = Router();
// ── POST /api/v1/auth/register ────────────────────────────────────────────────
router.post("/register", async (req: Request, res: Response) => {
const { email, password, name } = req.body as { email?: string; password?: string; name?: string };
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: "Valid email required" });
}
if (!password || password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
}
const normalizedEmail = email.trim().toLowerCase();
try {
// Check if already registered
const existing = await pool.query(
`SELECT id FROM customers WHERE email = $1 AND password_hash IS NOT NULL`,
[normalizedEmail]
);
if (existing.rows.length > 0) {
return res.status(409).json({ error: "An account with this email already exists. Please log in." });
}
const hash = await bcrypt.hash(password, 12);
const result = await pool.query<{ id: number; name: string | null }>(
`INSERT INTO customers (email, name, password_hash)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
name = COALESCE(EXCLUDED.name, customers.name),
updated_at = NOW()
RETURNING id, name`,
[normalizedEmail, name?.trim() || null, hash]
);
const customer = result.rows[0];
// Backfill existing orders
await pool.query(
`UPDATE canada_crtc_orders SET customer_id = $1 WHERE customer_email = $2 AND customer_id IS NULL`,
[customer.id, normalizedEmail]
);
await pool.query(
`UPDATE orders SET customer_id = $1 WHERE email = $2 AND customer_id IS NULL`,
[customer.id, normalizedEmail]
);
issueCustomerCookie(res, { customerId: customer.id, email: normalizedEmail });
res.json({ success: true, customer: { id: customer.id, email: normalizedEmail, name: customer.name } });
} catch (err) {
console.error("[portal-auth] register error:", err);
res.status(500).json({ error: "Registration failed. Please try again." });
}
});
// ── POST /api/v1/auth/login ───────────────────────────────────────────────────
router.post("/login", async (req: Request, res: Response) => {
const { email, password } = req.body as { email?: string; password?: string };
if (!email || !password) {
return res.status(400).json({ error: "Email and password required" });
}
const normalizedEmail = email.trim().toLowerCase();
try {
const result = await pool.query<{ id: number; name: string | null; password_hash: string | null }>(
`SELECT id, name, password_hash FROM customers WHERE email = $1`,
[normalizedEmail]
);
const customer = result.rows[0];
if (!customer || !customer.password_hash) {
// Account exists from a prior order but no password set yet
if (customer && !customer.password_hash) {
return res.status(401).json({ error: "No password set for this account. Please register to set one.", code: "NO_PASSWORD" });
}
return res.status(401).json({ error: "Invalid email or password" });
}
const valid = await bcrypt.compare(password, customer.password_hash);
if (!valid) {
return res.status(401).json({ error: "Invalid email or password" });
}
// Backfill existing orders
await pool.query(
`UPDATE canada_crtc_orders SET customer_id = $1 WHERE customer_email = $2 AND customer_id IS NULL`,
[customer.id, normalizedEmail]
);
issueCustomerCookie(res, { customerId: customer.id, email: normalizedEmail });
res.json({ success: true, customer: { id: customer.id, email: normalizedEmail, name: customer.name } });
} catch (err) {
console.error("[portal-auth] login error:", err);
res.status(500).json({ error: "Login failed. Please try again." });
}
});
// ── POST /api/v1/auth/logout ──────────────────────────────────────────────────
router.post("/logout", (_req: Request, res: Response) => {
clearCustomerCookie(res);
res.json({ success: true });
});
// ── GET /api/v1/auth/me ───────────────────────────────────────────────────────
router.get("/me", optionalCustomerAuth, async (req: Request, res: Response) => {
if (!req.customer) {
return res.json({ authenticated: false });
}
try {
const result = await pool.query<{ id: number; email: string; name: string | null; company: string | null; phone: string | null }>(
`SELECT id, email, name, company, phone FROM customers WHERE id = $1`,
[req.customer.customerId]
);
const customer = result.rows[0];
if (!customer) {
clearCustomerCookie(res);
return res.json({ authenticated: false });
}
res.json({ authenticated: true, customer });
} catch (err) {
console.error("[portal-auth] me error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// ── POST /api/v1/auth/forgot-password ────────────────────────────────────────
router.post("/forgot-password", async (req: Request, res: Response) => {
const { email } = req.body as { email?: string };
if (!email) return res.status(400).json({ error: "Email required" });
const normalizedEmail = email.trim().toLowerCase();
// Always return success to prevent email enumeration
res.json({ success: true, message: "If an account exists, a reset link has been sent." });
try {
const result = await pool.query<{ id: number; name: string | null }>(
`SELECT id, name FROM customers WHERE email = $1`,
[normalizedEmail]
);
const customer = result.rows[0];
if (!customer) return; // silent — response already sent
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + RESET_TTL_MINUTES * 60 * 1000);
await pool.query(
`INSERT INTO password_reset_tokens (customer_id, token, expires_at) VALUES ($1, $2, $3)`,
[customer.id, token, expiresAt]
);
const resetLink = `${SITE_URL}/account/reset-password?token=${token}`;
const firstName = customer.name?.split(" ")[0] || "there";
await sendEmail({
to: normalizedEmail,
subject: "Reset your Performance West password",
html: `
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 24px">
<img src="${SITE_URL}/images/logo/pw-logo.png" alt="Performance West" style="height:32px;margin-bottom:24px">
<h2 style="margin:0 0 8px;font-size:20px;color:#111">Reset your password</h2>
<p style="margin:0 0 8px;color:#555;font-size:15px">Hi ${firstName},</p>
<p style="margin:0 0 24px;color:#555;font-size:15px">
We received a request to reset the password for your Performance West account.
Click the button below to choose a new password. This link expires in ${RESET_TTL_MINUTES} minutes.
</p>
<a href="${resetLink}"
style="display:inline-block;background:#2d4e78;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px">
Reset password
</a>
<p style="margin:24px 0 0;color:#999;font-size:13px">
If you didn't request a password reset, you can safely ignore this email. Your password won't change.
</p>
</div>
`,
text: `Reset your Performance West password: ${resetLink}\n\nThis link expires in ${RESET_TTL_MINUTES} minutes.`,
});
} catch (err) {
console.error("[portal-auth] forgot-password error (post-response):", err);
}
});
// ── POST /api/v1/auth/reset-password ─────────────────────────────────────────
router.post("/reset-password", async (req: Request, res: Response) => {
const { token, password } = req.body as { token?: string; password?: string };
if (!token) return res.status(400).json({ error: "Reset token required" });
if (!password || password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
}
try {
const result = await pool.query<{ id: number; customer_id: number; expires_at: Date; used_at: Date | null }>(
`SELECT id, customer_id, expires_at, used_at FROM password_reset_tokens WHERE token = $1`,
[token]
);
const row = result.rows[0];
if (!row) return res.status(400).json({ error: "Invalid or expired reset link." });
if (row.used_at) return res.status(400).json({ error: "This reset link has already been used." });
if (new Date() > row.expires_at) return res.status(400).json({ error: "This reset link has expired. Please request a new one." });
const hash = await bcrypt.hash(password, 12);
await pool.query(`UPDATE customers SET password_hash = $1, updated_at = NOW() WHERE id = $2`, [hash, row.customer_id]);
await pool.query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id]);
// Fetch customer and issue session cookie so they're logged in immediately
const custResult = await pool.query<{ id: number; email: string; name: string | null }>(
`SELECT id, email, name FROM customers WHERE id = $1`,
[row.customer_id]
);
const customer = custResult.rows[0];
if (customer) {
issueCustomerCookie(res, { customerId: customer.id, email: customer.email });
}
res.json({ success: true, customer: customer ? { id: customer.id, email: customer.email, name: customer.name } : null });
} catch (err) {
console.error("[portal-auth] reset-password error:", err);
res.status(500).json({ error: "Password reset failed. Please try again." });
}
});
// ── GET /api/v1/auth/portal-status?email=... ──────────────────────────────────
//
// Check if an email already has an ERPNext portal account.
// Used by the success page to decide: show password form (new) or login link (returning).
//
router.get("/portal-status", async (req: Request, res: Response) => {
const email = ((req.query.email as string) || "").trim().toLowerCase();
if (!email) {
return res.json({ has_account: false });
}
try {
const { getResource } = await import("../erpnext-client.js");
const users = (await getResource(
"User",
undefined,
{ name: email, enabled: 1 },
["name", "full_name", "last_login"],
1,
)) as Array<{ name: string; full_name: string; last_login: string | null }>;
if (users.length > 0 && users[0].last_login) {
// User exists AND has logged in before → returning customer
return res.json({ has_account: true, returning: true, name: users[0].full_name });
} else if (users.length > 0) {
// User exists but never logged in → account created but password may not be set
return res.json({ has_account: true, returning: false, name: users[0].full_name });
}
res.json({ has_account: false });
} catch {
// ERPNext unreachable — assume no account
res.json({ has_account: false });
}
});
// ── POST /api/v1/auth/set-erpnext-password ────────────────────────────────────
//
// Called from the success page after payment. Sets (or resets) the customer's
// ERPNext portal password so they can log in at portal.performancewest.net.
//
// Body: { email, password, order_id?, customer_name? }
// We verify ownership by checking that at least one order in PG belongs to
// this email before setting the password — prevents arbitrary account takeover.
//
router.post("/set-erpnext-password", async (req: Request, res: Response) => {
const { email, password, order_id, customer_name } = req.body as {
email?: string;
password?: string;
order_id?: string;
customer_name?: string;
};
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: "Valid email required" });
}
if (!password || password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
}
const normalizedEmail = email.trim().toLowerCase();
try {
// Verify that an order exists for this email (ownership gate). Covers
// every order type that spawns a Website User via checkout: CRTC,
// legacy orders, formation, compliance.
const ownerCheck = await pool.query(
`SELECT 1 FROM canada_crtc_orders WHERE customer_email = $1
UNION ALL
SELECT 1 FROM orders WHERE email = $1
UNION ALL
SELECT 1 FROM formation_orders WHERE customer_email = $1
UNION ALL
SELECT 1 FROM compliance_orders WHERE customer_email = $1
LIMIT 1`,
[normalizedEmail],
);
if (ownerCheck.rows.length === 0) {
// No order found — still return success to avoid email enumeration,
// but do nothing. The portal link in the email will still work later.
return res.json({ success: true, created: false });
}
// Find the ERPNext customer name so we can link the user
let erpCustomerName: string | undefined;
try {
const { getResource } = await import("../erpnext-client.js");
const existing = (await getResource(
"Customer",
undefined,
{ email_id: normalizedEmail },
["name"],
1,
)) as Array<{ name: string }>;
if (existing.length > 0) erpCustomerName = existing[0].name;
} catch {
// Non-fatal — continue without linking
}
// Set ERPNext Website User password (the only account system)
const fullName = customer_name?.trim() || normalizedEmail.split("@")[0];
await ensureWebsiteUser(normalizedEmail, fullName);
await setWebsiteUserPassword(normalizedEmail, password);
if (erpCustomerName) {
await linkUserToCustomer(erpCustomerName, normalizedEmail);
}
res.json({ success: true, created: true });
} catch (err: any) {
console.error("[portal-auth] set-erpnext-password error:", err);
// Extract user-friendly message from ERPNext validation errors
let userMessage = "Failed to activate portal account. Please try again.";
const serverMsg = err?._server_messages || err?.message || "";
if (serverMsg.includes("Password") || serverMsg.includes("password")) {
// Password strength error — extract the readable part
const match = serverMsg.match(/alert-warning[^>]*>([^<]+)/);
userMessage = match ? match[1].trim() : "Password is too weak. Use a mix of uppercase, lowercase, numbers, and special characters.";
} else if (serverMsg.includes("already exists") || serverMsg.includes("Duplicate")) {
userMessage = "An account with this email already exists. Try logging in at the portal instead.";
}
res.status(400).json({ error: userMessage });
}
});
export default router;

View file

@ -0,0 +1,210 @@
/**
* Portal eSign client signs the CRTC notification letter.
*
* GET /api/v1/portal/esign-info letter details + presigned URL for PDF preview
* POST /api/v1/portal/esign-submit accept signature (base64 PNG), store, advance pipeline
*
* Flow:
* 1. Pipeline generates CRTC letter (Step 6), uploads to MinIO, sets workflow
* state to "Pending eSign", stores MinIO object key on the Sales Order.
* 2. Pipeline emails client a signed JWT link:
* https://performancewest.net/portal/sign?token=<jwt>
* 3. Client opens /portal/sign.astro fetches /esign-info shows letter preview
* 4. Client draws signature POST /esign-submit
* 5. API stores signature PNG as base64 in PG, records signed-at timestamp,
* advances ERPNext workflow "CRTC Submitted".
* 6. Pipeline resumes at Step 7 (binder compilation) via worker job dispatch.
*
* Storage:
* Signature PNG is stored in PG (esign_signature_b64 TEXT) signatures are
* small (<50KB) so PG is fine. The letter PDF presigned URL is generated by
* the Python workers job server (which already has MinIO client) to avoid
* adding a MinIO npm dependency to the API.
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
import { requirePortalAuth } from "../middleware/portalAuth.js";
import { callMethod, updateResource, getResource } from "../erpnext-client.js";
const router = Router();
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
const SITE_URL = process.env.SITE_URL || "https://performancewest.net";
// ── GET /api/v1/portal/esign-info ────────────────────────────────────────────
router.get("/api/v1/portal/esign-info", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = req.portalAuth!.order_id;
try {
const { rows } = await pool.query(
`SELECT order_number, customer_name, entity_name, customer_email,
crtc_letter_minio_key, esign_signed_at,
erpnext_sales_order
FROM canada_crtc_orders
WHERE order_number = $1`,
[orderId],
);
if (!rows.length) { res.status(404).json({ error: "Order not found" }); return; }
const order = rows[0] as Record<string, any>;
// Fetch BC number + regulatory email from ERPNext
let bcNumber = "", regulatoryEmail = "", soName = order.erpnext_sales_order || "";
try {
if (soName) {
const so = await getResource("Sales Order", soName, undefined, [
"name", "custom_incorporation_number", "custom_regulatory_email",
]) as Record<string, any>;
bcNumber = so.custom_incorporation_number || "";
regulatoryEmail = so.custom_regulatory_email || "";
}
} catch { /* non-fatal */ }
// Ask the workers job server for a presigned URL for the letter PDF
let letterPreviewUrl = "";
const letterKey = order.crtc_letter_minio_key as string | null;
if (letterKey) {
try {
const resp = await fetch(`${WORKER_URL}/jobs/presign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: letterKey, expires: 3600 }),
});
if (resp.ok) {
const data = await resp.json() as { url?: string };
letterPreviewUrl = data.url || "";
}
} catch (e) {
// Non-fatal — client can still sign without preview
}
}
res.json({
order_number: order.order_number,
entity_name: order.entity_name || order.customer_name,
customer_name: order.customer_name,
bc_number: bcNumber,
regulatory_email: regulatoryEmail,
letter_preview_url: letterPreviewUrl,
already_signed: !!order.esign_signed_at,
signed_at: order.esign_signed_at || null,
});
} catch (err: any) {
console.error("[esign-info] error:", err);
res.status(500).json({ error: "Failed to load signing info" });
}
});
// ── POST /api/v1/portal/esign-submit ─────────────────────────────────────────
//
// Body: { signature_png: "<data:image/png;base64,...>", agreed: true }
//
router.post("/api/v1/portal/esign-submit", requirePortalAuth, async (req: Request, res: Response) => {
const { order_id: orderId, email } = req.portalAuth!;
const { signature_png, agreed } = req.body as {
signature_png?: string;
agreed?: boolean;
};
if (!agreed) {
res.status(400).json({ error: "You must confirm that you have read and agree to sign the letter." });
return;
}
if (!signature_png || signature_png.length < 100) {
res.status(400).json({ error: "A valid signature is required. Please draw your signature in the box." });
return;
}
try {
const { rows } = await pool.query(
`SELECT order_number, entity_name, customer_name, customer_email,
esign_signed_at, erpnext_sales_order
FROM canada_crtc_orders WHERE order_number = $1`,
[orderId],
);
if (!rows.length) { res.status(404).json({ error: "Order not found." }); return; }
const order = rows[0] as Record<string, any>;
// Idempotent — already signed
if (order.esign_signed_at) {
res.json({ success: true, already_signed: true, signed_at: order.esign_signed_at });
return;
}
// Verify email matches
if ((order.customer_email as string)?.toLowerCase() !== email.toLowerCase()) {
res.status(403).json({ error: "This link is not valid for your account." });
return;
}
// Validate signature is a real PNG (not empty canvas)
const pngData = signature_png.replace(/^data:image\/png;base64,/, "");
const pngBuffer = Buffer.from(pngData, "base64");
if (pngBuffer.length < 200) {
res.status(400).json({ error: "Signature appears to be empty. Please draw your signature." });
return;
}
const signedAt = new Date().toISOString();
const soName = order.erpnext_sales_order as string | null;
// Store in PG — signature as base64 string, signed timestamp
await pool.query(
`UPDATE canada_crtc_orders
SET esign_signed_at = $1, esign_signature_b64 = $2, esign_signer_email = $3
WHERE order_number = $4`,
[signedAt, pngData, email, orderId],
);
// Update ERPNext Sales Order
if (soName) {
try {
await updateResource("Sales Order", soName, {
custom_esign_signed_at: signedAt,
custom_esign_signer_email: email,
});
} catch (e) { /* non-fatal */ }
// Advance workflow → CRTC Submitted
try {
await callMethod("frappe.model.workflow.apply_workflow", {
doc: { doctype: "Sales Order", name: soName },
action: "Submit CRTC Letter",
});
} catch {
// Fallback: direct state set
try {
await callMethod("frappe.client.set_value", {
doctype: "Sales Order",
name: soName,
field: "workflow_state",
value: "CRTC Submitted",
});
} catch (e) { /* non-fatal */ }
}
}
// Dispatch pipeline resume via workers job server
try {
await fetch(`${WORKER_URL}/jobs/resume_crtc_pipeline`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId, step: "post_esign" }),
});
} catch (e) { /* non-fatal — pipeline will catch up on next scheduled run */ }
res.json({
success: true,
signed_at: signedAt,
message: "Signature received. Your CRTC registration letter is being submitted.",
});
} catch (err: any) {
console.error("[esign-submit] error:", err);
res.status(500).json({ error: "Failed to record signature. Please try again." });
}
});
export default router;

View file

@ -0,0 +1,266 @@
/**
* RMD Filing Review Portal client reviews and approves RMD certification before submission.
*
* GET /api/v1/portal/rmd-review get filing details for review
* POST /api/v1/portal/rmd-approve client approves, authorizes submission
*
* Flow:
* 1. RMD handler generates the certification packet (letter + Exhibit A)
* 2. Handler uploads PDFs to MinIO, sets order status to "pending_client_review"
* 3. Handler emails client a JWT-signed review link:
* https://performancewest.net/portal/rmd-review?token=<jwt>
* 4. Client opens the page fetches /rmd-review sees full certification details
* 5. Client reviews provider classification, STIR/SHAKEN status, contact info, and document
* 6. Client clicks "Approve & Authorize Filing"
* 7. POST /rmd-approve sets status to "approved", dispatches the filing job
* 8. Worker resumes: submits to FCC RMD portal
*
* The client is acknowledging that they are making the 47 CFR § 1.16 perjury
* declaration through the FCC portal. We are filing on their behalf as their
* authorized agent.
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
import { requirePortalAuth } from "../middleware/portalAuth.js";
const router = Router();
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
// ── GET /api/v1/portal/rmd-review ────────────────────────────────────────────
router.get("/api/v1/portal/rmd-review", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = req.portalAuth!.order_id;
try {
const { rows } = await pool.query(
`SELECT c.order_number, c.service_slug, c.service_name, c.customer_name,
c.customer_email, c.payment_status, c.intake_data,
t.legal_name, t.dba_name, t.frn, t.infra_type,
t.contact_name, t.contact_email, t.contact_phone,
t.ceo_name, t.ceo_title,
t.address_street, t.address_city, t.address_state, t.address_zip,
c.rmd_review_status, c.rmd_reviewed_at, c.rmd_packet_minio_paths
FROM compliance_orders c
LEFT JOIN telecom_entities t ON t.id = c.telecom_entity_id
WHERE c.order_number = $1`,
[orderId],
);
if (!rows.length) {
res.status(404).json({ error: "Order not found" });
return;
}
const order = rows[0] as Record<string, unknown>;
const intake = typeof order.intake_data === "string"
? JSON.parse(order.intake_data as string)
: (order.intake_data as Record<string, unknown>) || {};
// Build the review summary — everything the client needs to verify
const review = {
order_number: order.order_number,
customer_name: order.customer_name,
customer_email: order.customer_email,
// Entity identification
entity: {
legal_name: order.legal_name,
dba_name: order.dba_name,
frn: order.frn,
rmd_number: (order as any).rmd_number || null,
address: [order.address_street, order.address_city, order.address_state, order.address_zip]
.filter(Boolean).join(", "),
},
// Provider classification (from intake_data or entity)
classification: {
infra_type: order.infra_type || intake?.infra_type || null,
},
// STIR/SHAKEN (from intake_data)
stir_shaken: {
status: intake?.stir_shaken_status || null,
},
// Contact info
contact: {
name: order.contact_name || order.customer_name,
email: order.contact_email || order.customer_email,
phone: order.contact_phone || null,
},
// Certifying officer
certifying_officer: {
name: order.ceo_name,
title: order.ceo_title,
},
// Document preview URLs (if available)
documents: order.rmd_packet_minio_paths || [],
// Review status
review_status: order.rmd_review_status || "pending",
reviewed_at: order.rmd_reviewed_at,
// Legal notice
legal_notice:
"By approving this filing, you authorize Performance West Inc. to submit " +
"this Robocall Mitigation Database certification to the FCC on your behalf. " +
"You acknowledge that the information above is true, complete, and accurate, " +
"and that the 47 CFR § 1.16 declaration under penalty of perjury will be " +
"made through the FCC electronic filing portal at the time of submission.",
};
res.json(review);
} catch (err) {
console.error("[portal/rmd-review] Error:", err);
res.status(500).json({ error: "Could not load review data" });
}
});
// ── POST /api/v1/portal/rmd-approve ──────────────────────────────────────────
router.post("/api/v1/portal/rmd-approve", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = req.portalAuth!.order_id;
const email = req.portalAuth!.email;
try {
// Verify order exists and is pending review
const { rows } = await pool.query(
`SELECT order_number, service_slug, rmd_review_status
FROM compliance_orders
WHERE order_number = $1`,
[orderId],
);
if (!rows.length) {
res.status(404).json({ error: "Order not found" });
return;
}
const order = rows[0] as Record<string, unknown>;
if (order.rmd_review_status === "approved") {
res.json({ status: "already_approved", message: "This filing has already been approved and submitted." });
return;
}
// Save client corrections to intake_data and record approval
const corrections = req.body?.corrections || {};
const existingIntake = typeof order.intake_data === "string"
? JSON.parse(order.intake_data as string)
: (order.intake_data || {});
const mergedIntake = { ...existingIntake, client_review: corrections };
await pool.query(
`UPDATE compliance_orders
SET rmd_review_status = 'approved',
rmd_reviewed_at = NOW(),
rmd_reviewer_email = $2,
intake_data = $3,
notes = COALESCE(notes, '') || $4
WHERE order_number = $1`,
[
orderId,
email,
JSON.stringify(mergedIntake),
`\nRMD filing approved by ${email} at ${new Date().toISOString()}. Client corrections saved to intake_data.client_review.`,
],
);
// Dispatch the filing job to the worker
try {
const dispatchRes = await fetch(`${WORKER_URL}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "process_compliance_service",
order_name: orderId,
order_number: orderId,
service_slug: "rmd-filing",
client_approved: true,
}),
});
console.log(
`[portal/rmd-approve] Filing dispatched for ${orderId}: HTTP ${dispatchRes.status}`,
);
} catch (dispatchErr) {
console.error(`[portal/rmd-approve] Dispatch failed for ${orderId}:`, dispatchErr);
}
res.json({
status: "approved",
message: "Thank you. Your RMD certification has been approved and will be submitted to the FCC shortly. You will receive an email confirmation with the FCC confirmation number once filed.",
});
} catch (err) {
console.error("[portal/rmd-approve] Error:", err);
res.status(500).json({ error: "Could not process approval" });
}
});
// ── POST /api/v1/portal/rmd-preview ──────────────────────────────────────────
// Generate a preview PDF from the client's form data without submitting.
router.post("/api/v1/portal/rmd-preview", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = req.portalAuth!.order_id;
const corrections = req.body?.corrections || {};
if (!corrections.legal_name || !corrections.frn) {
res.status(400).json({ error: "Company name and FRN are required for preview." });
return;
}
try {
// Send to workers to generate the PDF
const workerRes = await fetch(`${WORKER_URL}/rmd-preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
order_number: orderId,
entity: {
legal_name: corrections.legal_name,
dba_name: corrections.dba_name || "",
frn: corrections.frn,
rmd_number: corrections.rmd_number || "",
carrier_category: corrections.carrier_category || "interconnected_voip",
infra_type: corrections.infra_type || "facilities",
is_wholesale: corrections.is_wholesale || false,
is_gateway_provider: corrections.is_gateway_provider || false,
stir_shaken_status: corrections.stir_shaken_status || "complete_implementation",
stir_shaken_cert_authority: corrections.stir_shaken_cert_authority || "",
upstream_provider_name: corrections.upstream_provider || "",
contact_name: corrections.contact_name || "",
contact_email: corrections.contact_email || "",
contact_phone: corrections.contact_phone || "",
contact_title: corrections.contact_title || "",
ceo_name: corrections.ceo_name || "",
ceo_title: corrections.ceo_title || "Chief Executive Officer",
address_street: corrections.address?.split(",")[0]?.trim() || "",
address_city: corrections.address?.split(",")[1]?.trim() || "",
address_state: corrections.address?.split(",")[2]?.trim()?.split(" ")[0] || "",
address_zip: corrections.address?.split(",")[2]?.trim()?.split(" ")[1] || "",
},
}),
signal: AbortSignal.timeout(60000),
});
if (!workerRes.ok) {
const err = await workerRes.text();
console.error("[portal/rmd-preview] Worker error:", err);
res.status(500).json({ error: "Could not generate preview. Please try again." });
return;
}
const result = await workerRes.json() as Record<string, unknown>;
res.json({
pdf_url: result.pdf_url || null,
generated: true,
});
} catch (err) {
console.error("[portal/rmd-preview] Error:", err);
res.status(500).json({ error: "Preview generation timed out. Please try again." });
}
});
export default router;

View file

@ -0,0 +1,251 @@
/**
* Portal Setup client selects mailbox unit + Canadian DID after payment.
*
* GET /api/v1/portal/setup-info order details + AMB location + flags
* GET /api/v1/portal/setup-units scrape available unit numbers from AMB
* GET /api/v1/portal/setup-dids search available Canadian DIDs from Flowroute
* POST /api/v1/portal/setup-confirm client confirms dispatches purchase job
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
import { requirePortalAuth } from "../middleware/portalAuth.js";
const router = Router();
const FLOWROUTE_KEY = process.env.FLOWROUTE_ACCESS_KEY || "";
const FLOWROUTE_SECRET = process.env.FLOWROUTE_SECRET_KEY || "";
const FLOWROUTE_BASE = "https://api.flowroute.com";
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
// ── GET /api/v1/portal/setup-info ──────────────────────────────────────────
router.get("/api/v1/portal/setup-info", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = (req as any).portalAuth?.order_id || req.query.order_id;
if (!orderId) { res.status(400).json({ error: "order_id required" }); return; }
try {
const { rows } = await pool.query(
`SELECT o.order_number, o.customer_name, o.customer_email, o.company_type,
o.director_name, o.entity_name, o.has_own_ca_address,
o.amb_location_slug, o.amb_annual_price_cents,
o.client_selected_unit, o.client_selected_did,
o.funds_available, o.payment_status,
a.name AS amb_name, a.full_address AS amb_address, a.city AS amb_city,
a.provider_url AS amb_url
FROM canada_crtc_orders o
LEFT JOIN amb_locations a ON a.slug = o.amb_location_slug
WHERE o.order_number = $1`,
[orderId],
);
if (!rows.length) { res.status(404).json({ error: "Order not found" }); return; }
const order = rows[0] as Record<string, unknown>;
res.json({
order_number: order.order_number,
customer_name: order.customer_name,
has_own_ca_address: order.has_own_ca_address,
company_type: order.company_type,
funds_available: order.funds_available,
payment_status: order.payment_status,
amb_location: order.amb_location_slug ? {
slug: order.amb_location_slug,
name: order.amb_name,
address: order.amb_address,
city: order.amb_city,
url: order.amb_url,
annual_price_cents: order.amb_annual_price_cents,
} : null,
selected_unit: order.client_selected_unit || null,
selected_did: order.client_selected_did || null,
});
} catch (err: any) {
console.error("[portal-setup] setup-info error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// ── GET /api/v1/portal/setup-units ─────────────────────────────────────────
// Dispatches a scrape job to the workers container and returns available units.
// For now, returns a placeholder — real scraping will be async via worker.
router.get("/api/v1/portal/setup-units", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = (req as any).portalAuth?.order_id || req.query.order_id;
if (!orderId) { res.status(400).json({ error: "order_id required" }); return; }
try {
// Get the AMB location URL for this order
const { rows } = await pool.query(
`SELECT a.provider_url
FROM canada_crtc_orders o
JOIN amb_locations a ON a.slug = o.amb_location_slug
WHERE o.order_number = $1 AND o.has_own_ca_address = FALSE`,
[orderId],
);
if (!rows.length) {
res.status(404).json({ error: "No mailbox location selected for this order" });
return;
}
const locationUrl = rows[0].provider_url as string;
// Call the worker to scrape available units
try {
const workerRes = await fetch(`${WORKER_URL}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "scrape_amb_units",
data: { location_url: locationUrl, order_id: orderId },
}),
});
const workerData = await workerRes.json() as { units?: string[]; error?: string };
if (workerData.units) {
res.json({ units: workerData.units, location_url: locationUrl });
} else {
res.json({ units: [], error: workerData.error || "No units found" });
}
} catch (workerErr) {
console.error("[portal-setup] Worker scrape_amb_units failed:", workerErr);
res.status(502).json({ error: "Could not fetch available units. Please try again." });
}
} catch (err: any) {
console.error("[portal-setup] setup-units error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// ── GET /api/v1/portal/setup-dids ──────────────────────────────────────────
// Returns available Canadian DIDs from Flowroute (up to 10 per area code).
// Area codes are selected based on the order's incorporation province.
router.get("/api/v1/portal/setup-dids", requirePortalAuth, async (_req: Request, res: Response) => {
if (!FLOWROUTE_KEY || !FLOWROUTE_SECRET) {
res.status(503).json({ error: "DID provider not configured" });
return;
}
const CA_AREA_CODES: Record<string, string[]> = {
BC: ["604", "778", "236", "250"],
ON: ["416", "437", "647", "905", "289", "365", "519", "226", "548", "613", "343", "705", "249", "807"],
};
// Determine province from the order (default BC for backward compat)
const province = ((_req as any).portalAuth?.province || "BC").toUpperCase();
const areaCodes = CA_AREA_CODES[province] || CA_AREA_CODES.BC;
const auth = Buffer.from(`${FLOWROUTE_KEY}:${FLOWROUTE_SECRET}`).toString("base64");
try {
const results: Record<string, Array<{ number: string; formatted: string; monthly_cost: string }>> = {};
for (const areaCode of areaCodes) {
try {
const r = await fetch(
`${FLOWROUTE_BASE}/v2/numbers/available?starts_with=1${areaCode}&limit=10&contains=&ends_with=&rate_center=&state=`,
{ headers: { Authorization: `Basic ${auth}` } },
);
if (!r.ok) continue;
const data = await r.json() as { data?: Array<{ id: string; attributes?: { value?: string; monthly_cost?: string } }> };
results[areaCode] = (data.data || []).map(d => ({
number: d.id || d.attributes?.value || "",
formatted: formatDID(d.id || d.attributes?.value || ""),
monthly_cost: d.attributes?.monthly_cost || "1.00",
}));
} catch {
results[areaCode] = [];
}
}
res.json({ area_codes: areaCodes, dids: results });
} catch (err: any) {
console.error("[portal-setup] setup-dids error:", err);
res.status(500).json({ error: "Failed to search available numbers" });
}
});
function formatDID(raw: string): string {
const digits = raw.replace(/\D/g, "");
if (digits.length === 11 && digits.startsWith("1")) {
return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
}
if (digits.length === 10) {
return `+1 (${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
return raw;
}
// ── POST /api/v1/portal/setup-confirm ──────────────────────────────────────
// Client confirms mailbox unit + DID selection → dispatch purchase job.
router.post("/api/v1/portal/setup-confirm", requirePortalAuth, async (req: Request, res: Response) => {
const orderId = (req as any).portalAuth?.order_id || req.body?.order_id;
const { selected_unit, selected_did } = req.body || {};
if (!orderId) { res.status(400).json({ error: "order_id required" }); return; }
try {
// Validate order exists and is in Client Selection state
const { rows } = await pool.query(
`SELECT order_number, has_own_ca_address, funds_available, client_selected_unit, client_selected_did
FROM canada_crtc_orders WHERE order_number = $1`,
[orderId],
);
if (!rows.length) { res.status(404).json({ error: "Order not found" }); return; }
const order = rows[0] as Record<string, unknown>;
if (!order.funds_available) {
res.status(409).json({ error: "Funds not yet available for this order" });
return;
}
// Validate: DID is always required
if (!selected_did) {
res.status(400).json({ error: "Please select a Canadian phone number" });
return;
}
// Validate: unit required unless own address
if (!order.has_own_ca_address && !selected_unit) {
res.status(400).json({ error: "Please select a mailbox unit number" });
return;
}
// Store selections
await pool.query(
`UPDATE canada_crtc_orders
SET client_selected_unit = $1,
client_selected_did = $2
WHERE order_number = $3`,
[selected_unit || null, selected_did, orderId],
);
// Dispatch purchase job to workers
try {
await fetch(`${WORKER_URL}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "purchase_client_selections",
data: {
order_id: orderId,
selected_unit: selected_unit || null,
selected_did: selected_did,
has_own_ca_address: order.has_own_ca_address,
},
}),
});
} catch (workerErr) {
console.error("[portal-setup] Worker dispatch failed:", workerErr);
// Non-blocking — the selections are saved, admin can trigger manually
}
res.json({
success: true,
message: "Selections confirmed. We're now provisioning your mailbox and phone number.",
order_number: orderId,
selected_unit: selected_unit || null,
selected_did: selected_did,
});
} catch (err: any) {
console.error("[portal-setup] setup-confirm error:", err);
res.status(500).json({ error: "Internal error" });
}
});
export default router;

159
api/src/routes/portal.ts Normal file
View file

@ -0,0 +1,159 @@
/**
* Customer portal data routes.
*
* GET /api/v1/portal/me account info + prior orders + saved addresses + directors
* GET /api/v1/portal/addresses saved addresses
* GET /api/v1/portal/directors saved directors (with embedded address)
* PATCH /api/v1/portal/me update name/phone/company
*/
import { Router, Request, Response } from "express";
import { pool } from "../db";
import { requireCustomerAuth } from "../middleware/customer-auth";
const router = Router();
router.use(requireCustomerAuth);
// ── GET /api/v1/portal/me ─────────────────────────────────────────────────────
router.get("/me", async (req: Request, res: Response) => {
const { customerId } = req.customer!;
try {
const [custR, ordersR, addrR, dirR] = await Promise.all([
// Customer info
pool.query<{ id: number; email: string; name: string; company: string; phone: string }>(
`SELECT id, email, name, company, phone FROM customers WHERE id = $1`,
[customerId]
),
// Prior CRTC orders (most recent first)
pool.query<{
order_number: string; status: string; created_at: Date;
company_type: string; director_name: string; director_address: string;
customer_name: string; customer_email: string; customer_phone: string;
customer_company: string; total_cents: number; payment_status: string;
}>(
`SELECT order_number, status, created_at, company_type,
director_name, director_address,
customer_name, customer_email, customer_phone, customer_company,
total_cents, payment_status
FROM canada_crtc_orders
WHERE customer_id = $1 OR customer_email = $2
ORDER BY created_at DESC
LIMIT 20`,
[customerId, req.customer!.email]
),
// Saved addresses
pool.query<{
id: number; label: string; street: string; street2: string;
city: string; province: string; postal: string; country: string; is_default: boolean;
}>(
`SELECT id, label, street, street2, city, province, postal, country, is_default
FROM customer_addresses
WHERE customer_id = $1
ORDER BY is_default DESC, created_at DESC`,
[customerId]
),
// Saved directors
pool.query<{
id: number; name: string; citizenship: string; is_default: boolean;
address_id: number | null;
addr_street: string; addr_street2: string; addr_city: string;
addr_province: string; addr_postal: string; addr_country: string;
}>(
`SELECT d.id, d.name, d.citizenship, d.is_default, d.address_id,
a.street AS addr_street, a.street2 AS addr_street2,
a.city AS addr_city, a.province AS addr_province,
a.postal AS addr_postal, a.country AS addr_country
FROM customer_directors d
LEFT JOIN customer_addresses a ON d.address_id = a.id
WHERE d.customer_id = $1
ORDER BY d.is_default DESC, d.created_at DESC`,
[customerId]
),
]);
const customer = custR.rows[0];
if (!customer) return res.status(404).json({ error: "Customer not found" });
res.json({
customer,
orders: ordersR.rows,
addresses: addrR.rows,
directors: dirR.rows.map((d) => ({
id: d.id,
name: d.name,
citizenship: d.citizenship,
is_default: d.is_default,
address: d.address_id ? {
id: d.address_id,
street: d.addr_street,
street2: d.addr_street2,
city: d.addr_city,
province: d.addr_province,
postal: d.addr_postal,
country: d.addr_country,
} : null,
})),
});
} catch (err) {
console.error("[portal] me error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// ── PATCH /api/v1/portal/me ───────────────────────────────────────────────────
router.patch("/me", async (req: Request, res: Response) => {
const { customerId } = req.customer!;
const { name, phone, company } = req.body as { name?: string; phone?: string; company?: string };
try {
await pool.query(
`UPDATE customers SET
name = COALESCE($1, name),
phone = COALESCE($2, phone),
company = COALESCE($3, company)
WHERE id = $4`,
[name || null, phone || null, company || null, customerId]
);
res.json({ success: true });
} catch (err) {
console.error("[portal] patch me error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// ── GET /api/v1/portal/addresses ─────────────────────────────────────────────
router.get("/addresses", async (req: Request, res: Response) => {
const { customerId } = req.customer!;
try {
const result = await pool.query(
`SELECT id, label, street, street2, city, province, postal, country, is_default
FROM customer_addresses
WHERE customer_id = $1
ORDER BY is_default DESC, created_at DESC`,
[customerId]
);
res.json({ addresses: result.rows });
} catch (err) {
res.status(500).json({ error: "Internal error" });
}
});
// ── GET /api/v1/portal/directors ─────────────────────────────────────────────
router.get("/directors", async (req: Request, res: Response) => {
const { customerId } = req.customer!;
try {
const result = await pool.query(
`SELECT d.id, d.name, d.citizenship, d.is_default,
a.id AS addr_id, a.street, a.street2, a.city, a.province, a.postal, a.country
FROM customer_directors d
LEFT JOIN customer_addresses a ON d.address_id = a.id
WHERE d.customer_id = $1
ORDER BY d.is_default DESC, d.created_at DESC`,
[customerId]
);
res.json({ directors: result.rows });
} catch (err) {
res.status(500).json({ error: "Internal error" });
}
});
export default router;

253
api/src/routes/puc.ts Normal file
View file

@ -0,0 +1,253 @@
/**
* State PUC/PSC Registration Requirements API
*
* GET /api/v1/puc/requirements?state=TX
* Returns PUC requirements for a single state.
*
* GET /api/v1/puc/requirements/all
* Returns all states for the multi-state picker.
*
* POST /api/v1/puc/quote
* Accepts { states: ['TX','NY'], type: 'voip'|'broadband'|'clec' }
* Returns per-state fee breakdown + bond requirements + service fee.
*/
import { Router, type Request, type Response } from "express";
import { pool } from "../db.js";
const router = Router();
// Our service fee per state
const PUC_SERVICE_FEE_CENTS = 39900; // $399/state
// ── GET /api/v1/puc/requirements?state=TX ────────────────────────────────────
router.get("/api/v1/puc/requirements", async (req: Request, res: Response) => {
try {
const state = (req.query.state as string || "").toUpperCase().trim();
if (!state || state.length !== 2) {
res.status(400).json({ error: "state query param required (2-letter code)" });
return;
}
const { rows } = await pool.query(
`SELECT * FROM state_puc_requirements WHERE state_code = $1`,
[state]
);
if (rows.length === 0) {
res.status(404).json({ error: `No PUC data for state: ${state}` });
return;
}
const r = rows[0];
res.json({
state_code: r.state_code,
agency_name: r.agency_name,
agency_url: r.agency_url,
voip: {
registration_required: r.voip_registration_required,
registration_type: r.voip_registration_type,
registration_fee_cents: r.voip_registration_fee_cents,
annual_fee_cents: r.voip_annual_fee_cents,
bond_required: r.voip_bond_required,
bond_amount_cents: r.voip_bond_amount_cents,
bond_type: r.voip_bond_type,
reseller_exempt: r.voip_reseller_exempt,
reseller_bond_cents: r.voip_reseller_bond_cents,
reseller_notes: r.voip_reseller_notes,
ott_exempt: r.voip_ott_exempt,
notes: r.voip_notes,
},
broadband: {
registration_required: r.broadband_registration_required,
registration_type: r.broadband_registration_type,
registration_fee_cents: r.broadband_registration_fee_cents,
annual_fee_cents: r.broadband_annual_fee_cents,
notes: r.broadband_notes,
},
clec: {
certification_required: r.clec_certification_required,
certification_fee_cents: r.clec_certification_fee_cents,
bond_required: r.clec_bond_required,
bond_amount_cents: r.clec_bond_amount_cents,
},
ongoing: {
annual_report_required: r.annual_report_required,
annual_report_due: r.annual_report_due,
usf_surcharge_required: r.usf_surcharge_required,
usf_surcharge_description: r.usf_surcharge_description,
},
notes: r.notes,
last_verified_date: r.last_verified_date,
service_fee_cents: PUC_SERVICE_FEE_CENTS,
});
} catch (err) {
console.error("[puc] requirements error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ── GET /api/v1/puc/requirements/all ─────────────────────────────────────────
router.get("/api/v1/puc/requirements/all", async (_req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
SELECT
r.state_code,
j.name AS state_name,
r.agency_name,
r.agency_url,
r.voip_registration_required,
r.voip_registration_type,
r.voip_registration_fee_cents,
r.voip_bond_required,
r.voip_bond_amount_cents,
r.voip_reseller_exempt,
r.voip_reseller_bond_cents,
r.voip_reseller_notes,
r.voip_ott_exempt,
r.broadband_registration_required,
r.broadband_registration_fee_cents,
r.clec_certification_required,
r.clec_certification_fee_cents,
r.clec_bond_required,
r.clec_bond_amount_cents,
r.annual_report_required,
r.usf_surcharge_required,
r.notes
FROM state_puc_requirements r
JOIN jurisdictions j ON j.code = r.state_code
WHERE j.country = 'US'
ORDER BY j.name
`);
res.json({
states: rows.map(r => ({
state_code: r.state_code,
state_name: r.state_name,
agency_name: r.agency_name,
agency_url: r.agency_url,
voip_required: r.voip_registration_required,
voip_type: r.voip_registration_type,
voip_fee_cents: r.voip_registration_fee_cents,
voip_bond_required: r.voip_bond_required,
voip_bond_cents: r.voip_bond_amount_cents,
voip_reseller_exempt: r.voip_reseller_exempt,
voip_reseller_bond_cents: r.voip_reseller_bond_cents,
voip_reseller_notes: r.voip_reseller_notes,
voip_ott_exempt: r.voip_ott_exempt,
broadband_required: r.broadband_registration_required,
broadband_fee_cents: r.broadband_registration_fee_cents,
clec_required: r.clec_certification_required,
clec_fee_cents: r.clec_certification_fee_cents,
clec_bond_required: r.clec_bond_required,
clec_bond_cents: r.clec_bond_amount_cents,
annual_report: r.annual_report_required,
usf_surcharge: r.usf_surcharge_required,
notes: r.notes,
})),
service_fee_cents: PUC_SERVICE_FEE_CENTS,
count: rows.length,
});
} catch (err) {
console.error("[puc] requirements/all error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ── POST /api/v1/puc/quote ───────────────────────────────────────────────────
router.post("/api/v1/puc/quote", async (req: Request, res: Response) => {
try {
const { states, type } = req.body as { states?: string[]; type?: string };
if (!states || !Array.isArray(states) || states.length === 0) {
res.status(400).json({ error: "states array required" });
return;
}
const regType = type || "voip";
if (!["voip", "broadband", "clec", "bundle"].includes(regType)) {
res.status(400).json({ error: "type must be voip, broadband, clec, or bundle" });
return;
}
const codes = states.map(s => s.toUpperCase().trim()).filter(s => s.length === 2);
if (codes.length === 0) {
res.status(400).json({ error: "No valid 2-letter state codes provided" });
return;
}
const { rows } = await pool.query(
`SELECT r.*, j.name AS state_name
FROM state_puc_requirements r
JOIN jurisdictions j ON j.code = r.state_code
WHERE r.state_code = ANY($1)
ORDER BY j.name`,
[codes]
);
const breakdown = rows.map(r => {
let fee_cents = 0;
let bond_cents = 0;
let required = false;
if (regType === "voip" || regType === "bundle") {
fee_cents += r.voip_registration_fee_cents;
bond_cents += r.voip_bond_amount_cents;
required = r.voip_registration_required;
}
if (regType === "broadband" || regType === "bundle") {
fee_cents += r.broadband_registration_fee_cents;
required = required || r.broadband_registration_required;
}
if (regType === "clec" || regType === "bundle") {
fee_cents += r.clec_certification_fee_cents;
bond_cents = Math.max(bond_cents, r.clec_bond_amount_cents);
required = required || r.clec_certification_required;
}
return {
state_code: r.state_code,
state_name: r.state_name,
registration_required: required,
state_fee_cents: required ? fee_cents : 0,
bond_required: required && bond_cents > 0,
bond_amount_cents: required ? bond_cents : 0,
service_fee_cents: required ? PUC_SERVICE_FEE_CENTS : 0,
total_cents: required ? (PUC_SERVICE_FEE_CENTS + fee_cents) : 0,
exempt: !required,
notes: r.voip_notes || r.notes,
};
});
const requiredStates = breakdown.filter(b => b.registration_required);
const exemptStates = breakdown.filter(b => !b.registration_required);
const totalServiceFees = requiredStates.reduce((sum, b) => sum + b.service_fee_cents, 0);
const totalStateFees = requiredStates.reduce((sum, b) => sum + b.state_fee_cents, 0);
const totalBondAmount = requiredStates.reduce((sum, b) => sum + b.bond_amount_cents, 0);
res.json({
type: regType,
breakdown,
summary: {
total_states: codes.length,
required_states: requiredStates.length,
exempt_states: exemptStates.length,
service_fee_total_cents: totalServiceFees,
state_fee_total_cents: totalStateFees,
bond_total_cents: totalBondAmount,
grand_total_cents: totalServiceFees + totalStateFees,
bond_note: totalBondAmount > 0
? "Bond amounts shown are typical ranges. Exact bond requirement depends on provider size and type. Bond procurement is coordinated separately."
: null,
},
});
} catch (err) {
console.error("[puc] quote error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
export default router;

149
api/src/routes/quotes.ts Normal file
View file

@ -0,0 +1,149 @@
import { Router } from "express";
import { pool } from "../db.js";
import { submitLimiter } from "../middleware/rate-limit.js";
import { v4 as uuidv4 } from "uuid";
import { createResource, createServiceOrder } from "../erpnext-client.js";
const router = Router();
// POST /api/v1/quotes — Request a custom quote for a service
router.post("/api/v1/quotes", submitLimiter, async (req, res) => {
try {
const { name, email, company, phone, service_slug, details } = req.body ?? {};
if (!name || typeof name !== "string" || name.trim().length < 2) {
res.status(400).json({ error: "Name is required (at least 2 characters)." });
return;
}
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: "Valid email address is required." });
return;
}
if (!service_slug || typeof service_slug !== "string") {
res.status(400).json({ error: "Service selection is required." });
return;
}
const result = await pool.query(
`INSERT INTO quotes (name, email, company, phone, service_slug, details, status)
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
RETURNING id`,
[
name.trim(),
email.toLowerCase().trim(),
company || null,
phone || null,
service_slug.trim(),
details || null,
],
);
const quoteId = result.rows[0]?.id;
// Push to ERPNext as an Opportunity — non-blocking, don't fail the response
try {
await createResource("Opportunity", {
opportunity_from: "Lead",
party_name: email.toLowerCase().trim(),
contact_email: email.toLowerCase().trim(),
opportunity_type: "Sales",
custom_service_slug: service_slug.trim(),
notes: [
`Name: ${name.trim()}`,
company ? `Company: ${company}` : null,
phone ? `Phone: ${phone}` : null,
details ? `Details: ${details}` : null,
`Source: performancewest.net quote form`,
]
.filter(Boolean)
.join("\n"),
});
} catch (erpErr) {
console.error("[quotes] ERPNext Opportunity creation failed (non-fatal):", erpErr);
}
res.status(201).json({
success: true,
message: "Quote request received. We'll send your quote within one business day.",
quote_id: quoteId ? String(quoteId) : undefined,
});
} catch (err) {
console.error("[quotes] Error:", err);
res.status(500).json({ error: "Could not submit your request. Please try again." });
}
});
// POST /api/v1/orders — Place a fixed-price service order
router.post("/api/v1/orders", submitLimiter, async (req, res) => {
try {
const { name, email, company, service_slug, amount_cents, quote_id } = req.body ?? {};
if (!name || typeof name !== "string" || name.trim().length < 2) {
res.status(400).json({ error: "Name is required." });
return;
}
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: "Valid email address is required." });
return;
}
if (!service_slug || typeof service_slug !== "string") {
res.status(400).json({ error: "Service selection is required." });
return;
}
// Generate order number: PW-YYYY-XXXX
const year = new Date().getFullYear();
const short = uuidv4().split("-")[0]!.toUpperCase();
const orderNumber = `PW-${year}-${short}`;
const result = await pool.query(
`INSERT INTO orders (order_number, quote_id, service_slug, name, email, company, status, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, 'received', $7)
RETURNING id, order_number`,
[
orderNumber,
quote_id || null,
service_slug.trim(),
name.trim(),
email.toLowerCase().trim(),
company || null,
amount_cents || null,
],
);
const row = result.rows[0];
// Push to ERPNext as a Sales Order — non-blocking, don't fail the response
try {
await createServiceOrder({
customer: name.trim(),
service_slug: service_slug.trim(),
notes: [
`Order: ${orderNumber}`,
company ? `Company: ${company}` : null,
quote_id ? `Quote ref: ${quote_id}` : null,
`Source: performancewest.net`,
]
.filter(Boolean)
.join("\n"),
});
} catch (erpErr) {
console.error("[orders] ERPNext Sales Order creation failed (non-fatal):", erpErr);
}
res.status(201).json({
success: true,
message: "Order received. We'll begin processing within one business day.",
order_number: row?.order_number,
});
} catch (err) {
console.error("[orders] Error:", err);
res.status(500).json({ error: "Could not place your order. Please try again." });
}
});
export default router;

319
api/src/routes/refunds.ts Normal file
View file

@ -0,0 +1,319 @@
/**
* Refund management routes.
*
* Handles refunds for formation orders and compliance service orders
* when the failure was not the customer's fault.
*
* Refund flow:
* 1. System or admin initiates refund (POST /api/v1/admin/refunds)
* 2. Admin reviews + approves (PATCH /api/v1/admin/refunds/:id)
* 3. Admin sends refund via Relay dashboard (manual ACH)
* 4. Admin marks as sent/confirmed
* 5. Customer receives email notification at each step
*
* Auto-refund triggers (created by the formation worker):
* - State portal charged the card but rejected the filing
* - Payment went through but automation crashed before filing completed
* - Name collision discovered after payment was processed
*/
import { Router } from "express";
import { pool } from "../db.js";
import { requireAdmin } from "../middleware/admin-auth.js";
import { v4 as uuidv4 } from "uuid";
const router = Router();
// =====================================================================
// Admin: Create a refund
// =====================================================================
router.post("/api/v1/admin/refunds", requireAdmin, async (req, res) => {
try {
const {
order_type, order_id, order_number,
customer_name, customer_email,
original_amount_cents, refund_amount_cents, refund_type,
reason_category, reason_detail,
state_fee_recoverable, refund_method,
admin_notes,
} = req.body ?? {};
if (!order_number || !customer_email || !refund_amount_cents || !reason_category) {
res.status(400).json({ error: "Missing required fields: order_number, customer_email, refund_amount_cents, reason_category" });
return;
}
const year = new Date().getFullYear();
const short = uuidv4().split("-")[0]!.toUpperCase();
const refundNumber = `REF-${year}-${short}`;
const result = await pool.query(
`INSERT INTO refunds (
refund_number, order_type, order_id, order_number,
customer_name, customer_email,
original_amount_cents, refund_amount_cents, refund_type,
reason_category, reason_detail,
state_fee_recoverable, refund_method,
admin_notes, requested_by, status
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'pending')
RETURNING id, refund_number`,
[
refundNumber,
order_type || "formation",
order_id || null,
order_number,
customer_name || "",
customer_email,
original_amount_cents || 0,
refund_amount_cents,
refund_type || "full",
reason_category,
reason_detail || null,
state_fee_recoverable || false,
refund_method || "relay_ach",
admin_notes || null,
`admin:${req.admin!.username}`,
],
);
const refund = result.rows[0];
// Link refund to the order
if (order_id) {
const table = order_type === "formation" ? "formation_orders" : "orders";
await pool.query(
`UPDATE ${table} SET refund_id = $1, refunded = FALSE WHERE id = $2`,
[refund.id, order_id],
);
}
// Log to audit
if (order_id) {
await pool.query(
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, actor_type, actor_id, actor_name, note)
VALUES ($1, $2, $3, 'refund_initiated', 'admin', $4, $5, $6)`,
[order_type || "formation", order_id, order_number, req.admin!.id, req.admin!.username,
`Refund ${refundNumber}: $${(refund_amount_cents / 100).toFixed(2)}${reason_category}`],
);
}
res.status(201).json({
success: true,
refund_number: refund.refund_number,
refund_id: refund.id,
message: "Refund created. Review and approve to process.",
});
} catch (err) {
console.error("[refunds] Create error:", err);
res.status(500).json({ error: "Could not create refund." });
}
});
// =====================================================================
// Admin: List refunds
// =====================================================================
router.get("/api/v1/admin/refunds", requireAdmin, async (req, res) => {
try {
const status = req.query.status as string || "";
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 200);
const offset = parseInt(req.query.offset as string, 10) || 0;
let where = "WHERE 1=1";
const params: any[] = [];
let idx = 1;
if (status) { where += ` AND status = $${idx++}`; params.push(status); }
const countResult = await pool.query(`SELECT COUNT(*) as total FROM refunds ${where}`, params);
params.push(limit, offset);
const result = await pool.query(
`SELECT * FROM refunds ${where} ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx++}`,
params,
);
res.json({
refunds: result.rows,
total: parseInt(countResult.rows[0].total, 10),
limit, offset,
});
} catch (err) {
console.error("[refunds] List error:", err);
res.status(500).json({ error: "Could not load refunds." });
}
});
// =====================================================================
// Admin: Get single refund
// =====================================================================
router.get("/api/v1/admin/refunds/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const result = await pool.query("SELECT * FROM refunds WHERE id = $1", [id]);
if (result.rows.length === 0) {
res.status(404).json({ error: "Refund not found." });
return;
}
res.json({ refund: result.rows[0] });
} catch (err) {
console.error("[refunds] Get error:", err);
res.status(500).json({ error: "Could not load refund." });
}
});
// =====================================================================
// Admin: Update refund status (approve, send, confirm, deny)
// =====================================================================
router.patch("/api/v1/admin/refunds/:id", requireAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const { status, admin_notes, relay_transaction_id, denied_reason, refund_method } = req.body ?? {};
const current = await pool.query("SELECT * FROM refunds WHERE id = $1", [id]);
if (current.rows.length === 0) {
res.status(404).json({ error: "Refund not found." });
return;
}
const refund = current.rows[0];
const updates: string[] = [];
const params: any[] = [];
let pIdx = 1;
if (status) {
updates.push(`status = $${pIdx++}`); params.push(status);
if (status === "approved") {
updates.push(`approved_by = $${pIdx++}`); params.push(req.admin!.id);
updates.push("approved_at = now()");
}
if (status === "sent") {
updates.push("sent_at = now()");
}
if (status === "confirmed") {
updates.push("confirmed_at = now()");
// Mark the order as refunded
if (refund.order_id) {
const table = refund.order_type === "formation" ? "formation_orders" : "orders";
await pool.query(`UPDATE ${table} SET refunded = TRUE WHERE id = $1`, [refund.order_id]);
}
}
if (status === "denied") {
updates.push(`denied_reason = $${pIdx++}`); params.push(denied_reason || "Refund denied");
}
}
if (admin_notes !== undefined) { updates.push(`admin_notes = $${pIdx++}`); params.push(admin_notes); }
if (relay_transaction_id) { updates.push(`relay_transaction_id = $${pIdx++}`); params.push(relay_transaction_id); }
if (refund_method) { updates.push(`refund_method = $${pIdx++}`); params.push(refund_method); }
if (updates.length > 0) {
updates.push("updated_at = now()");
params.push(id);
await pool.query(
`UPDATE refunds SET ${updates.join(", ")} WHERE id = $${pIdx}`,
params,
);
}
// Audit log
if (status && refund.order_id) {
await pool.query(
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, from_status, to_status, actor_type, actor_id, actor_name, note)
VALUES ($1, $2, $3, $4, $5, $6, 'admin', $7, $8, $9)`,
[refund.order_type, refund.order_id, refund.order_number,
`refund_${status}`, refund.status, status,
req.admin!.id, req.admin!.username,
`Refund ${refund.refund_number}: status → ${status}${admin_notes ? '. ' + admin_notes : ''}`],
);
}
res.json({ success: true, message: `Refund status updated to: ${status || 'updated'}` });
} catch (err) {
console.error("[refunds] Update error:", err);
res.status(500).json({ error: "Could not update refund." });
}
});
// =====================================================================
// Admin: Dashboard stats for refunds
// =====================================================================
router.get("/api/v1/admin/refunds/stats", requireAdmin, async (req, res) => {
try {
const result = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE status = 'approved') as approved,
COUNT(*) FILTER (WHERE status = 'processing') as processing,
COUNT(*) FILTER (WHERE status = 'sent') as sent,
COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed,
COUNT(*) FILTER (WHERE status = 'denied') as denied,
COUNT(*) as total,
COALESCE(SUM(refund_amount_cents) FILTER (WHERE status IN ('sent','confirmed')), 0) as total_refunded_cents,
COALESCE(SUM(refund_amount_cents) FILTER (WHERE status = 'pending'), 0) as pending_refund_cents
FROM refunds
`);
res.json({ stats: result.rows[0] });
} catch (err) {
console.error("[refunds] Stats error:", err);
res.status(500).json({ error: "Could not load refund stats." });
}
});
// =====================================================================
// System: Auto-create refund (called by formation worker on failure)
// =====================================================================
router.post("/api/v1/internal/refunds/auto", async (req, res) => {
// This endpoint is called internally by the worker when a state charges
// the card but the filing fails. No admin auth — uses internal secret.
const secret = req.headers["x-internal-secret"];
if (!secret || secret !== process.env.WEBHOOK_SECRET) {
res.status(401).json({ error: "Unauthorized" });
return;
}
try {
const {
order_type, order_id, order_number,
customer_name, customer_email,
amount_cents, reason_category, reason_detail,
} = req.body ?? {};
const year = new Date().getFullYear();
const short = uuidv4().split("-")[0]!.toUpperCase();
const refundNumber = `REF-${year}-${short}`;
const result = await pool.query(
`INSERT INTO refunds (
refund_number, order_type, order_id, order_number,
customer_name, customer_email,
original_amount_cents, refund_amount_cents, refund_type,
reason_category, reason_detail,
requested_by, status
) VALUES ($1,$2,$3,$4,$5,$6,$7,$7,'full',$8,$9,'system','pending')
RETURNING id, refund_number`,
[refundNumber, order_type || "formation", order_id, order_number,
customer_name, customer_email, amount_cents,
reason_category, reason_detail],
);
// Create ERPNext issue to alert admin
console.log(`[refunds] Auto-refund created: ${result.rows[0].refund_number} for ${order_number}$${(amount_cents / 100).toFixed(2)}`);
res.status(201).json({
success: true,
refund_number: result.rows[0].refund_number,
});
} catch (err) {
console.error("[refunds] Auto-create error:", err);
res.status(500).json({ error: "Could not create auto-refund." });
}
});
export default router;

View file

@ -0,0 +1,308 @@
/**
* Reseller Certifications API.
*
* Per 2026 FCC Form 499-A Section IV.C.4, to claim revenue on Line 303
* (carrier's carrier) a filer must maintain annually-signed reseller
* certifications from each downstream reseller customer. This API
* supports:
* - listing active certifications for a filer (RevenueStep prefill,
* admin dashboard)
* - uploading a signed certification (PDF to MinIO)
* - requesting a blank attestation DOCX for the reseller to sign
* - marking a certification revoked / expired
*
* Non-contributing resellers (reported on Line 511) are a separate table
* (non_contributing_reseller_customers); endpoints mirror the above.
*/
import { Router } from "express";
import type { Request, Response } from "express";
import { randomBytes } from "crypto";
import { pool } from "../db.js";
const router = Router();
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
/** Ask the worker for a presigned MinIO PUT URL. Returns null on failure. */
async function presignPut(key: string, expires = 3600): Promise<string | null> {
try {
const r = await fetch(`${WORKER_URL}/jobs/presign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, expires, method: "PUT" }),
});
if (!r.ok) return null;
const data = (await r.json()) as { url?: string };
return data.url || null;
} catch {
return null;
}
}
// ── GET /api/v1/reseller-certs/entity/:telecom_entity_id ───────────────
//
// List certifications for a filer (our customer). Optional ?status=active
// and ?expiring_within_days=90 filters.
router.get(
"/api/v1/reseller-certs/entity/:telecom_entity_id",
async (req: Request, res: Response) => {
const entityId = Number(req.params.telecom_entity_id);
const statusFilter = (req.query.status as string) || "active";
const expiringWithin = Number(req.query.expiring_within_days);
const conditions: string[] = ["filer_telecom_entity_id = $1"];
const params: (number | string)[] = [entityId];
if (statusFilter && statusFilter !== "all") {
conditions.push(`status = $${params.length + 1}`);
params.push(statusFilter);
}
if (Number.isFinite(expiringWithin) && expiringWithin > 0) {
conditions.push(`renewal_due <= CURRENT_DATE + INTERVAL '1 day' * $${params.length + 1}`);
params.push(expiringWithin);
}
const r = await pool.query(
`SELECT id, reseller_filer_id_499, reseller_legal_name,
reseller_contact_name, reseller_contact_email, reseller_contact_phone,
reseller_legal_address, certification_date, renewal_due,
status, reporting_year_first, signer_name, signer_title,
certification_minio_path IS NOT NULL AS has_signed_pdf,
notes, created_at
FROM reseller_certifications
WHERE ${conditions.join(" AND ")}
ORDER BY renewal_due ASC`,
params,
);
res.json({ certifications: r.rows });
},
);
// ── POST /api/v1/reseller-certs/entity/:telecom_entity_id ──────────────
//
// Create a new reseller certification record. After POST the customer
// uploads the signed PDF separately via PUT to the presigned MinIO URL
// returned here (same pattern as CDR / ICC uploads).
router.post(
"/api/v1/reseller-certs/entity/:telecom_entity_id",
async (req: Request, res: Response) => {
const entityId = Number(req.params.telecom_entity_id);
const {
reseller_filer_id_499,
reseller_legal_name,
reseller_contact_name,
reseller_contact_email,
reseller_contact_phone,
reseller_legal_address,
certification_date,
certification_text,
signer_name,
signer_title,
reporting_year_first,
} = req.body ?? {};
if (!reseller_filer_id_499 || !reseller_legal_name || !certification_date
|| !certification_text) {
res.status(400).json({
error: "reseller_filer_id_499, reseller_legal_name, certification_date, certification_text required",
});
return;
}
// Validate Filer ID shape (6-8 digits; USAC format)
const filerIdRe = /^\d{6,8}$/;
if (!filerIdRe.test(String(reseller_filer_id_499).replace(/\D/g, ""))) {
res.status(400).json({
error: "reseller_filer_id_499 must be a 6-8 digit USAC Filer ID",
});
return;
}
// Renewal due one year from cert date
const certDate = new Date(certification_date);
const renewalDue = new Date(certDate);
renewalDue.setUTCFullYear(renewalDue.getUTCFullYear() + 1);
// Minio upload key for the (optional) signed PDF
const uploadToken = randomBytes(16).toString("hex");
const minioKey =
`reseller-certs/${entityId}/${reseller_filer_id_499}/` +
`${certDate.toISOString().slice(0, 10)}_${uploadToken}.pdf`;
try {
const r = await pool.query(
`INSERT INTO reseller_certifications
(filer_telecom_entity_id, reseller_filer_id_499, reseller_legal_name,
reseller_contact_name, reseller_contact_email, reseller_contact_phone,
reseller_legal_address, certification_date, certification_text,
certification_minio_path, signer_name, signer_title,
renewal_due, reporting_year_first, status)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12, $13, $14, 'active')
RETURNING id`,
[
entityId, reseller_filer_id_499, reseller_legal_name,
reseller_contact_name || null, reseller_contact_email || null,
reseller_contact_phone || null,
reseller_legal_address ? JSON.stringify(reseller_legal_address) : null,
certification_date, certification_text,
minioKey, signer_name || null, signer_title || null,
renewalDue.toISOString().slice(0, 10),
reporting_year_first || null,
],
);
// Generate a presigned MinIO PUT URL so the customer can upload the
// signed PDF directly. URL is valid for 1h.
const putUrl = await presignPut(minioKey, 3600);
res.status(201).json({
cert_id: r.rows[0].id,
minio_put_key: minioKey,
minio_put_url: putUrl,
renewal_due: renewalDue.toISOString().slice(0, 10),
upload_token: uploadToken,
expires_in_seconds: 3600,
});
} catch (err: unknown) {
const msg = (err as { detail?: string; message?: string }).detail
|| (err as Error).message || "insert failed";
if (String(msg).includes("duplicate")) {
res.status(409).json({
error: "Certification already recorded for this filer+reseller+date",
});
return;
}
res.status(500).json({ error: msg });
}
},
);
// ── PATCH /api/v1/reseller-certs/:cert_id ─────────────────────────────
//
// Update status (revoke / mark expired) or notes.
router.patch(
"/api/v1/reseller-certs/:cert_id",
async (req: Request, res: Response) => {
const certId = Number(req.params.cert_id);
const { status, notes } = req.body ?? {};
if (!["active", "expired", "revoked"].includes(String(status)) && status !== undefined) {
res.status(400).json({ error: "status must be active | expired | revoked" }); return;
}
const fields: string[] = ["updated_at = NOW()"];
const params: (number | string)[] = [];
if (status) { fields.push(`status = $${params.length + 1}`); params.push(status); }
if (notes !== undefined) { fields.push(`notes = $${params.length + 1}`); params.push(notes); }
params.push(certId);
const r = await pool.query(
`UPDATE reseller_certifications
SET ${fields.join(", ")}
WHERE id = $${params.length}
RETURNING id, status, notes`,
params,
);
if (r.rows.length === 0) {
res.status(404).json({ error: "cert not found" }); return;
}
res.json({ cert: r.rows[0] });
},
);
// ── GET /api/v1/reseller-certs/expiring-soon ──────────────────────────
//
// Admin dashboard endpoint — list every active cert across all customers
// expiring within N days. Used to populate admin-resellers page.
router.get(
"/api/v1/reseller-certs/expiring-soon",
async (req: Request, res: Response) => {
const adminToken = (req.headers["x-admin-token"] || "").toString();
if (!process.env.ADMIN_API_TOKEN || adminToken !== process.env.ADMIN_API_TOKEN) {
res.status(403).json({ error: "admin token required" }); return;
}
const days = Math.min(Number(req.query.days) || 90, 365);
const r = await pool.query(
`SELECT rc.id, rc.filer_telecom_entity_id, rc.reseller_filer_id_499,
rc.reseller_legal_name, rc.renewal_due,
te.legal_name AS filer_legal_name,
te.customer_id
FROM reseller_certifications rc
JOIN telecom_entities te ON te.id = rc.filer_telecom_entity_id
WHERE rc.status = 'active'
AND rc.renewal_due <= CURRENT_DATE + INTERVAL '1 day' * $1
ORDER BY rc.renewal_due ASC`,
[days],
);
res.json({ days, expiring: r.rows });
},
);
// ── Non-contributing resellers (Line 511) ──────────────────────────────
router.get(
"/api/v1/non-contributing-resellers/entity/:telecom_entity_id",
async (req: Request, res: Response) => {
const entityId = Number(req.params.telecom_entity_id);
const year = Number(req.query.year);
const conditions = ["filer_telecom_entity_id = $1"];
const params: (number | string)[] = [entityId];
if (Number.isFinite(year)) {
conditions.push(`reporting_year = $${params.length + 1}`);
params.push(year);
}
const r = await pool.query(
`SELECT id, reseller_filer_id_499, reseller_legal_name,
non_contributing_reason, revenue_cents, reporting_year, notes, created_at
FROM non_contributing_reseller_customers
WHERE ${conditions.join(" AND ")}
ORDER BY reporting_year DESC, revenue_cents DESC`,
params,
);
res.json({ non_contributing_resellers: r.rows });
},
);
router.post(
"/api/v1/non-contributing-resellers/entity/:telecom_entity_id",
async (req: Request, res: Response) => {
const entityId = Number(req.params.telecom_entity_id);
const {
reseller_filer_id_499, reseller_legal_name,
non_contributing_reason, revenue_cents, reporting_year, notes,
} = req.body ?? {};
if (!reseller_filer_id_499 || !reseller_legal_name
|| !non_contributing_reason || !reporting_year) {
res.status(400).json({
error: "reseller_filer_id_499, reseller_legal_name, non_contributing_reason, reporting_year required",
});
return;
}
if (!["de_minimis", "intl_only", "government", "other"].includes(non_contributing_reason)) {
res.status(400).json({
error: "non_contributing_reason must be de_minimis | intl_only | government | other",
});
return;
}
try {
const r = await pool.query(
`INSERT INTO non_contributing_reseller_customers
(filer_telecom_entity_id, reseller_filer_id_499, reseller_legal_name,
non_contributing_reason, revenue_cents, reporting_year, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (filer_telecom_entity_id, reseller_filer_id_499, reporting_year)
DO UPDATE SET
reseller_legal_name = EXCLUDED.reseller_legal_name,
non_contributing_reason = EXCLUDED.non_contributing_reason,
revenue_cents = EXCLUDED.revenue_cents,
notes = EXCLUDED.notes
RETURNING id`,
[entityId, reseller_filer_id_499, reseller_legal_name,
non_contributing_reason, revenue_cents || 0, reporting_year, notes || null],
);
res.status(201).json({ id: r.rows[0].id });
} catch (err: unknown) {
res.status(500).json({ error: (err as Error).message });
}
},
);
export default router;

142
api/src/routes/subscribe.ts Normal file
View file

@ -0,0 +1,142 @@
import { Router } from "express";
import { pool } from "../db.js";
import { submitLimiter } from "../middleware/rate-limit.js";
import { createLead } from "../erpnext-client.js";
// Listmonk subscriber push (non-blocking)
const LISTMONK_URL = process.env.LISTMONK_URL || "http://listmonk:9000";
const LISTMONK_USER = process.env.LISTMONK_USER || "api";
const LISTMONK_PASS = process.env.LISTMONK_PASSWORD || "";
async function addToListmonk(email: string, name: string, company?: string) {
const resp = await fetch(`${LISTMONK_URL}/api/subscribers`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from(`${LISTMONK_USER}:${LISTMONK_PASS}`).toString("base64")}`,
},
body: JSON.stringify({
email,
name: name || email.split("@")[0],
status: "enabled",
lists: [3], // FCC Carriers - Direct Contacts
preconfirm_subscriptions: true,
attribs: { company: company || "", source: "website" },
}),
});
if (!resp.ok && resp.status !== 409) {
throw new Error(`Listmonk ${resp.status}: ${await resp.text()}`);
}
}
const router = Router();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const DISPOSABLE_DOMAINS = new Set([
"mailinator.com", "guerrillamail.com", "tempmail.com", "throwaway.email",
"yopmail.com", "sharklasers.com", "guerrillamailblock.com", "grr.la",
"dispostable.com", "trashmail.com", "fakeinbox.com", "temp-mail.org",
]);
const CONSENT_TEXT =
"I agree to receive compliance updates and service announcements from Performance West Inc. I can unsubscribe at any time.";
// POST /api/v1/subscribe
router.post("/api/v1/subscribe", submitLimiter, async (req, res) => {
try {
const { email, name, company, consent, _hp, _ts } = req.body ?? {};
// Honeypot — bots fill hidden fields
if (_hp) {
res.status(201).json({ success: true, message: "Subscribed." });
return;
}
// Timing check — reject if form submitted in < 2 seconds
if (_ts && typeof _ts === "number" && Date.now() - _ts < 2_000) {
res.status(201).json({ success: true, message: "Subscribed." });
return;
}
if (!email || typeof email !== "string" || !EMAIL_RE.test(email)) {
res.status(400).json({ error: "Valid email address is required." });
return;
}
if (!consent) {
res.status(400).json({ error: "Consent is required to subscribe." });
return;
}
// Block disposable email domains
const domain = email.split("@")[1]?.toLowerCase();
if (domain && DISPOSABLE_DOMAINS.has(domain)) {
res.status(400).json({ error: "Please use a permanent email address." });
return;
}
const ip = (req as any).clientIp || req.ip || "";
await pool.query(
`INSERT INTO subscribers (email, name, company, consent_text, consent_at, ip_address, source)
VALUES ($1, $2, $3, $4, NOW(), $5, 'website')
ON CONFLICT (email) DO UPDATE SET
unsubscribed = FALSE,
consent_text = $4,
consent_at = NOW(),
ip_address = $5`,
[email.toLowerCase().trim(), name || null, company || null, CONSENT_TEXT, ip],
);
// Push to ERPNext (Lead) and Listmonk — non-blocking, don't fail the response
const cleanEmail = email.toLowerCase().trim();
const [firstName, ...lastParts] = (name || "").trim().split(/\s+/);
const lastName = lastParts.join(" ") || "";
try {
await createLead({
name: (name || cleanEmail).trim(),
email: cleanEmail,
company: company || undefined,
source: "Website",
notes: `Subscribed via website. Consent: "${CONSENT_TEXT}"`,
});
} catch (erpErr) {
console.error("[subscribe] ERPNext createLead failed (non-fatal):", erpErr);
}
try {
await addToListmonk(cleanEmail, name || cleanEmail, company || undefined);
} catch (listmonkErr) {
console.error("[subscribe] Listmonk addToListmonk failed (non-fatal):", listmonkErr);
}
res.status(201).json({ success: true, message: "You're on the list." });
} catch (err) {
console.error("[subscribe] Error:", err);
res.status(500).json({ error: "Subscription failed. Please try again." });
}
});
// POST /api/v1/unsubscribe
router.post("/api/v1/unsubscribe", async (req, res) => {
try {
const { email } = req.body ?? {};
if (!email || typeof email !== "string") {
res.status(400).json({ error: "Email is required." });
return;
}
await pool.query(
"UPDATE subscribers SET unsubscribed = TRUE WHERE email = $1",
[email.toLowerCase().trim()],
);
res.status(200).json({ success: true, message: "You have been unsubscribed." });
} catch (err) {
console.error("[unsubscribe] Error:", err);
res.status(500).json({ error: "Unsubscribe failed. Please try again." });
}
});
export default router;

View file

@ -0,0 +1,435 @@
import { Router } from "express";
import { pool } from "../db.js";
const router = Router();
/**
* List all telecom entities for a customer.
*
* GET /api/v1/entities/telecom?customer_id=123
* GET /api/v1/entities/telecom?email=user@example.com
*/
router.get("/api/v1/entities/telecom", async (req, res) => {
const { customer_id, email } = req.query;
if (!customer_id && !email) {
res.status(400).json({ error: "Provide customer_id or email." });
return;
}
try {
let query: string;
let params: any[];
if (customer_id) {
query = "SELECT * FROM telecom_entities WHERE customer_id = $1 AND active = true ORDER BY jurisdiction, legal_name";
params = [customer_id];
} else {
query = `SELECT te.* FROM telecom_entities te
JOIN customers c ON c.id = te.customer_id
WHERE c.email = $1 AND te.active = true
ORDER BY te.jurisdiction, te.legal_name`;
params = [(email as string).toLowerCase().trim()];
}
const result = await pool.query(query, params);
res.json({
entities: result.rows,
count: result.rows.length,
});
} catch (err) {
console.error("[telecom-entities] List error:", err);
res.status(500).json({ error: "Could not fetch entities." });
}
});
/**
* Get a single telecom entity by ID.
*
* GET /api/v1/entities/telecom/:id
*/
router.get("/api/v1/entities/telecom/:id", async (req, res) => {
try {
const result = await pool.query("SELECT * FROM telecom_entities WHERE id = $1", [req.params.id]);
if (result.rows.length === 0) {
res.status(404).json({ error: "Entity not found." });
return;
}
res.json(result.rows[0]);
} catch (err) {
console.error("[telecom-entities] Get error:", err);
res.status(500).json({ error: "Could not fetch entity." });
}
});
/**
* Create a new telecom entity.
*
* POST /api/v1/entities/telecom
*/
router.post("/api/v1/entities/telecom", async (req, res) => {
const {
customer_id, customer_email,
jurisdiction, legal_name, dba_name, ein,
// FCC
frn, filer_id_499,
// CRTC
incorporation_number, incorporation_province, crtc_registration_number,
// Classification
filer_type, infra_type, is_deminimis, is_lire, service_categories,
// Contact
contact_name, contact_email, contact_phone, ceo_name, ceo_title,
// Address
address_street, address_city, address_state, address_zip,
// Revenue
last_filing_year, total_revenue_cents, interstate_pct, international_pct,
} = req.body ?? {};
if (!legal_name) {
res.status(400).json({ error: "legal_name is required." });
return;
}
// Resolve customer_id from email if needed
let resolvedCustomerId = customer_id;
if (!resolvedCustomerId && customer_email) {
const cust = await pool.query("SELECT id FROM customers WHERE email = $1", [customer_email.toLowerCase().trim()]);
if (cust.rows.length > 0) {
resolvedCustomerId = cust.rows[0].id;
}
}
try {
const result = await pool.query(
`INSERT INTO telecom_entities (
customer_id, jurisdiction, legal_name, dba_name, ein,
frn, filer_id_499,
incorporation_number, incorporation_province, crtc_registration_number,
filer_type, infra_type, is_deminimis, is_lire, service_categories,
contact_name, contact_email, contact_phone, ceo_name, ceo_title,
address_street, address_city, address_state, address_zip,
last_filing_year, total_revenue_cents, interstate_pct, international_pct
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28)
RETURNING *`,
[
resolvedCustomerId || null,
jurisdiction || "FCC",
legal_name, dba_name || null, ein || null,
frn || null, filer_id_499 || null,
incorporation_number || null, incorporation_province || null, crtc_registration_number || null,
filer_type || null, infra_type || null,
is_deminimis === true, is_lire === true,
service_categories || null,
contact_name || null, contact_email || null, contact_phone || null,
ceo_name || null, ceo_title || null,
address_street || null, address_city || null, address_state || null, address_zip || null,
last_filing_year || null, total_revenue_cents || 0,
interstate_pct || 0, international_pct || 0,
],
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error("[telecom-entities] Create error:", err);
res.status(500).json({ error: "Could not create entity." });
}
});
/**
* Update a telecom entity.
*
* PATCH /api/v1/entities/telecom/:id
*/
router.patch("/api/v1/entities/telecom/:id", async (req, res) => {
const id = req.params.id;
const updates = req.body ?? {};
// Build dynamic SET clause from provided fields
const allowedFields = [
"legal_name", "dba_name", "ein", "jurisdiction",
"frn", "filer_id_499",
"incorporation_number", "incorporation_province", "crtc_registration_number",
"filer_type", "infra_type", "is_deminimis", "is_lire", "service_categories",
"contact_name", "contact_email", "contact_phone", "ceo_name", "ceo_title",
"address_street", "address_city", "address_state", "address_zip",
"last_filing_year", "total_revenue_cents", "interstate_pct", "international_pct",
"active", "notes",
// Carrier classification (migration 043)
"carrier_category", "is_wholesale", "is_gateway_provider",
"is_international_only", "uses_ucaas_provider", "carrier_metadata",
"stir_shaken_status", "stir_shaken_cert_authority",
"upstream_provider_name", "upstream_provider_frn",
"rmd_letter_minio_path", "rmd_letter_generated_at", "last_compliance_checkup",
// FACS (Foreign Adversary Control System) fields (migration 045)
"facs_schedule", "facs_has_foreign_adversary", "facs_filing_status",
"facs_ownership_data", "covered_authorizations", "has_section_214",
"facs_filed_at",
// Form 499-A Block 1/2 fidelity (migrations 048, 054)
"affiliated_filer_name", "affiliated_filer_ein", "management_company_name",
"trade_names",
"regulatory_contact_name", "regulatory_contact_email", "regulatory_contact_phone",
"worksheet_office_company", "worksheet_office_street", "worksheet_office_city",
"worksheet_office_state", "worksheet_office_zip",
"billing_contact_name", "billing_contact_email", "itsp_regulatory_fee_email",
"dc_agent_company", "dc_agent_street", "dc_agent_city", "dc_agent_state",
"dc_agent_zip", "dc_agent_phone", "dc_agent_email",
"officer_1_street", "officer_1_city", "officer_1_state", "officer_1_zip",
"officer_2_name", "officer_2_title", "officer_2_street", "officer_2_city",
"officer_2_state", "officer_2_zip",
"officer_3_name", "officer_3_title", "officer_3_street", "officer_3_city",
"officer_3_state", "officer_3_zip",
"officer_count_claimed", "entity_structure",
"jurisdictions_served",
"first_telecom_service_year", "first_telecom_service_month",
"first_telecom_service_pre_1999",
// Form 499-A Block 6 fidelity (migrations 048, 054)
"exempt_usf", "exempt_trs", "exempt_nanpa", "exempt_lnp", "exempt_itsp",
"exemption_explanation",
"is_state_local_gov", "is_tax_exempt_501c",
"nondisclosure_requested",
// Line 105 taxonomy (migration 053)
"line_105_primary", "line_105_categories",
"wireless_meta", "satellite_meta", "audio_bridging_meta", "private_line_circuits",
// Safe harbor election (migration 054)
"safe_harbor_election",
// CORES + CALEA + foreign carrier (migration 052)
"cores_username", "cores_password_hash", "cores_registered_at",
"calea_ssi_generated_at", "calea_ssi_reviewer_name", "calea_ssi_next_review_date",
"foreign_affiliations",
// NECA OCN (migration 048)
"ocn", "ocn_category", "ocn_assigned_at",
];
const sets: string[] = [];
const values: any[] = [];
let paramIdx = 1;
for (const field of allowedFields) {
if (field in updates) {
sets.push(`${field} = $${paramIdx}`);
values.push(updates[field]);
paramIdx++;
}
}
if (sets.length === 0) {
res.status(400).json({ error: "No valid fields to update." });
return;
}
sets.push(`updated_at = NOW()`);
values.push(id);
try {
const result = await pool.query(
`UPDATE telecom_entities SET ${sets.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
values,
);
if (result.rows.length === 0) {
res.status(404).json({ error: "Entity not found." });
return;
}
res.json(result.rows[0]);
} catch (err) {
console.error("[telecom-entities] Update error:", err);
res.status(500).json({ error: "Could not update entity." });
}
});
/**
* Classify a telecom entity's carrier type.
*
* PATCH /api/v1/entities/telecom/:id/classify
*
* Accepts the full carrier classification from the questionnaire and
* validates field combinations (e.g. UCaaS provider requires metadata).
*/
router.patch("/api/v1/entities/telecom/:id/classify", async (req, res) => {
const id = req.params.id;
const body = req.body ?? {};
// Validate carrier_category
const validCategories = [
"interconnected_voip", "non_interconnected_voip", "clec", "ixc", "cmrs", "other",
];
if (body.carrier_category && !validCategories.includes(body.carrier_category)) {
res.status(400).json({ error: `Invalid carrier_category. Must be one of: ${validCategories.join(", ")}` });
return;
}
// Validate stir_shaken_status
const validStirShaken = [
"complete_implementation", "partial_implementation",
"robocall_mitigation_only", "exempt_small_carrier", "not_applicable",
];
if (body.stir_shaken_status && !validStirShaken.includes(body.stir_shaken_status)) {
res.status(400).json({ error: `Invalid stir_shaken_status. Must be one of: ${validStirShaken.join(", ")}` });
return;
}
// Validate UCaaS metadata
if (body.uses_ucaas_provider === true) {
const meta = body.carrier_metadata ?? {};
if (!meta.ucaas_provider) {
res.status(400).json({ error: "carrier_metadata.ucaas_provider is required when uses_ucaas_provider is true." });
return;
}
}
// Validate gateway metadata
if (body.is_gateway_provider === true) {
const meta = body.carrier_metadata ?? {};
if (!meta.gateway_countries || !Array.isArray(meta.gateway_countries) || meta.gateway_countries.length === 0) {
res.status(400).json({ error: "carrier_metadata.gateway_countries is required when is_gateway_provider is true." });
return;
}
}
// Validate complete STIR/SHAKEN requires cert authority
if (body.stir_shaken_status === "complete_implementation" && !body.stir_shaken_cert_authority) {
res.status(400).json({ error: "stir_shaken_cert_authority is required for complete_implementation." });
return;
}
// Build update
const classifyFields = [
"carrier_category", "is_wholesale", "is_gateway_provider",
"is_international_only", "uses_ucaas_provider", "carrier_metadata",
"stir_shaken_status", "stir_shaken_cert_authority",
"upstream_provider_name", "upstream_provider_frn",
// Also allow updating infra_type since the questionnaire captures it
"infra_type",
];
const sets: string[] = [];
const values: any[] = [];
let paramIdx = 1;
for (const field of classifyFields) {
if (field in body) {
if (field === "carrier_metadata") {
sets.push(`${field} = $${paramIdx}::jsonb`);
} else {
sets.push(`${field} = $${paramIdx}`);
}
values.push(body[field]);
paramIdx++;
}
}
if (sets.length === 0) {
res.status(400).json({ error: "No classification fields provided." });
return;
}
sets.push(`updated_at = NOW()`);
values.push(id);
try {
const result = await pool.query(
`UPDATE telecom_entities SET ${sets.join(", ")} WHERE id = $${paramIdx} RETURNING *`,
values,
);
if (result.rows.length === 0) {
res.status(404).json({ error: "Entity not found." });
return;
}
res.json(result.rows[0]);
} catch (err) {
console.error("[telecom-entities] Classify error:", err);
res.status(500).json({ error: "Could not classify entity." });
}
});
/**
* Run compliance check against a specific entity.
*
* GET /api/v1/entities/telecom/:id/compliance
*
* Uses the entity's FRN to run the same checks as /api/v1/fcc/lookup,
* enriched with entity-specific data (classification, filing history).
*/
router.get("/api/v1/entities/telecom/:id/compliance", async (req, res) => {
try {
const entity = await pool.query("SELECT * FROM telecom_entities WHERE id = $1", [req.params.id]);
if (entity.rows.length === 0) {
res.status(404).json({ error: "Entity not found." });
return;
}
const e = entity.rows[0];
if (e.jurisdiction === "FCC" && e.frn) {
// Proxy to the FCC lookup endpoint
const lookupUrl = `http://localhost:${process.env.PORT || 3001}/api/v1/fcc/lookup?frn=${e.frn}`;
const lookupResp = await fetch(lookupUrl, { signal: AbortSignal.timeout(30000) });
const lookupData = await lookupResp.json() as Record<string, unknown>;
res.json(Object.assign({
entity: {
id: e.id,
legal_name: e.legal_name,
jurisdiction: e.jurisdiction,
frn: e.frn,
filer_type: e.filer_type,
is_deminimis: e.is_deminimis,
is_lire: e.is_lire,
},
}, lookupData));
} else if (e.jurisdiction === "CRTC") {
// CRTC compliance checks (simplified)
const now = new Date();
const checks = [
{
id: "crtc_registration",
label: "CRTC Registration",
status: e.crtc_registration_number ? "green" : "unknown",
detail: e.crtc_registration_number
? `Registered: ${e.crtc_registration_number}`
: "Registration number not on file",
action_url: null,
due_date: null,
},
{
id: "provincial_incorporation",
label: `${e.incorporation_province || "Provincial"} Incorporation`,
status: e.incorporation_number ? "green" : "unknown",
detail: e.incorporation_number
? `${e.incorporation_province} #${e.incorporation_number}`
: "Incorporation number not on file",
action_url: null,
due_date: null,
},
];
res.json({
entity: {
id: e.id,
legal_name: e.legal_name,
jurisdiction: e.jurisdiction,
incorporation_number: e.incorporation_number,
incorporation_province: e.incorporation_province,
},
checks,
checked_at: new Date().toISOString(),
});
} else {
res.json({
entity: { id: e.id, legal_name: e.legal_name, jurisdiction: e.jurisdiction },
checks: [],
note: "No FRN on file — add an FRN to run FCC compliance checks.",
checked_at: new Date().toISOString(),
});
}
} catch (err) {
console.error("[telecom-entities] Compliance check error:", err);
res.status(500).json({ error: "Compliance check failed." });
}
});
export default router;

100
api/src/routes/tickets.ts Normal file
View file

@ -0,0 +1,100 @@
import { Router } from "express";
import { pool } from "../db.js";
import { submitLimiter } from "../middleware/rate-limit.js";
import { createIssue } from "../erpnext-client.js";
const router = Router();
const VALID_CATEGORIES = ["question", "support", "issue", "service_request", "quote"] as const;
// POST /api/v1/tickets
router.post("/api/v1/tickets", submitLimiter, async (req, res) => {
try {
const { category, subject, message, email, name, page } = req.body ?? {};
// Validate category
if (!category || !VALID_CATEGORIES.includes(category)) {
res.status(400).json({
error: `Category must be one of: ${VALID_CATEGORIES.join(", ")}`,
});
return;
}
// Validate subject
if (!subject || typeof subject !== "string" || subject.trim().length < 3) {
res.status(400).json({ error: "Subject must be at least 3 characters." });
return;
}
if (subject.length > 200) {
res.status(400).json({ error: "Subject must be under 200 characters." });
return;
}
// Validate message
if (!message || typeof message !== "string" || message.trim().length < 10) {
res.status(400).json({ error: "Message must be at least 10 characters." });
return;
}
if (message.length > 5000) {
res.status(400).json({ error: "Message must be under 5000 characters." });
return;
}
// Optional email validation
if (email && typeof email === "string" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: "Invalid email format." });
return;
}
const ip = (req as any).clientIp || req.ip || "";
// Store locally in PostgreSQL (backup)
const result = await pool.query(
`INSERT INTO tickets (category, subject, message, email, name, page, ip_address)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[category, subject.trim(), message.trim(), email || null, name || null, page || null, ip],
);
const ticketId = result.rows[0]?.id;
// Push to ERPNext as an Issue — non-blocking, don't fail the response
let erpnextIssueName: string | undefined;
try {
const description = [
message.trim(),
"",
"---",
`Category: ${category}`,
name ? `Name: ${name}` : null,
email ? `Email: ${email}` : null,
page ? `Page: ${page}` : null,
`IP: ${ip}`,
`Source: performancewest.net support widget`,
]
.filter(Boolean)
.join("\n");
const erpIssue = await createIssue({
subject: subject.trim(),
description,
priority: "Medium",
});
erpnextIssueName = (erpIssue as any)?.name;
} catch (erpErr) {
console.error("[tickets] ERPNext createIssue failed (non-fatal):", erpErr);
}
res.status(201).json({
success: true,
message: "Request received. We'll get back to you within one business day.",
ticket_id: erpnextIssueName || (ticketId ? String(ticketId) : undefined),
});
} catch (err) {
console.error("[tickets] Error:", err);
res.status(500).json({ error: "Could not submit your request. Please try again." });
}
});
export default router;

852
api/src/routes/webhooks.ts Normal file
View 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;