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

111
api/src/config.ts Normal file
View file

@ -0,0 +1,111 @@
// Environment configuration — singleton, loaded once at startup.
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
function optional(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}
export interface Config {
port: number;
nodeEnv: string;
postgres: {
connectionString: string;
};
erpnext: {
url: string;
apiKey: string;
apiSecret: string;
siteName: string;
};
listmonk: {
url: string;
user: string;
password: string;
};
minio: {
endpoint: string;
port: number;
accessKey: string;
secretKey: string;
};
stripe: {
secretKey: string;
webhookSecret: string;
};
smtp: {
host: string;
port: number;
user: string;
pass: string;
from: string;
};
}
// Env-var guard for production: refuse to boot with placeholder secrets.
// Catches the common footgun of deploying with `change-this-in-production`
// still in effect on ADMIN_JWT_SECRET, WEBHOOK_SECRET, etc.
function refuseInsecureProduction(): void {
if (process.env.NODE_ENV !== "production") return;
const bad: string[] = [];
const check = (name: string, sentinels: string[] = []) => {
const v = process.env[name] ?? "";
if (!v) bad.push(`${name} is unset`);
else if (sentinels.includes(v)) bad.push(`${name} is still set to a placeholder`);
};
check("ADMIN_JWT_SECRET", ["change-this-in-production"]);
check("WEBHOOK_SECRET", ["change-this-in-production"]);
check("SHKEEPER_API_KEY");
check("STRIPE_WEBHOOK_SECRET");
if (bad.length) {
throw new Error(
`[config] Refusing to start in production with insecure settings:\n` +
bad.map(b => ` - ${b}`).join("\n"),
);
}
}
function loadConfig(): Config {
refuseInsecureProduction();
return {
port: parseInt(optional("PORT", "3001"), 10),
nodeEnv: optional("NODE_ENV", "development"),
postgres: {
connectionString: required("DATABASE_URL"),
},
erpnext: {
url: optional("ERPNEXT_URL", "http://erpnext:8000"),
apiKey: optional("ERPNEXT_API_KEY", ""),
apiSecret: optional("ERPNEXT_API_SECRET", ""),
siteName: optional("ERPNEXT_SITE_NAME", optional("ERPNEXT_HOST_HEADER", "performancewest.net")),
},
listmonk: {
url: optional("LISTMONK_URL", "http://listmonk:9000"),
user: optional("LISTMONK_USER", "api"),
password: optional("LISTMONK_PASSWORD", ""),
},
minio: {
endpoint: optional("MINIO_ENDPOINT", "localhost"),
port: parseInt(optional("MINIO_PORT", "9000"), 10),
accessKey: optional("MINIO_ACCESS_KEY", ""),
secretKey: optional("MINIO_SECRET_KEY", ""),
},
stripe: {
secretKey: optional("STRIPE_SECRET_KEY", ""),
webhookSecret: optional("STRIPE_WEBHOOK_SECRET", ""),
},
smtp: {
host: optional("SMTP_HOST", "mail.smtp2go.com"),
port: parseInt(optional("SMTP_PORT", "587"), 10),
user: optional("SMTP_USER", ""),
pass: optional("SMTP_PASS", ""),
from: optional("SMTP_FROM", "Performance West <noreply@performancewest.net>"),
},
};
}
export const config = loadConfig();

56
api/src/create-admin.ts Normal file
View file

@ -0,0 +1,56 @@
/**
* Create an admin user for the dashboard.
*
* Usage:
* npx tsx src/create-admin.ts <username> <password> [display_name] [email]
*
* Example:
* npx tsx src/create-admin.ts justin MySecurePass123 "Justin" "justin@performancewest.net"
*
* Requires DATABASE_URL environment variable.
*/
import bcrypt from "bcryptjs";
import pg from "pg";
async function main() {
const [, , username, password, displayName, email] = process.argv;
if (!username || !password) {
console.error("Usage: npx tsx src/create-admin.ts <username> <password> [display_name] [email]");
process.exit(1);
}
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("DATABASE_URL environment variable is required.");
process.exit(1);
}
const pool = new pg.Pool({ connectionString: dbUrl });
try {
// Hash password with bcrypt (12 rounds)
const hash = await bcrypt.hash(password, 12);
const result = await pool.query(
`INSERT INTO admin_users (username, password_hash, display_name, email)
VALUES ($1, $2, $3, $4)
ON CONFLICT (username) DO UPDATE SET
password_hash = $2, display_name = $3, email = $4, active = TRUE
RETURNING id, username`,
[username.toLowerCase().trim(), hash, displayName || username, email || null],
);
const user = result.rows[0];
console.log(`Admin user created/updated: ${user.username} (id: ${user.id})`);
console.log(`Login at: https://performancewest.net/admin`);
} catch (err) {
console.error("Failed to create admin user:", err);
process.exit(1);
} finally {
await pool.end();
}
}
main();

19
api/src/db.ts Normal file
View file

@ -0,0 +1,19 @@
import pg from "pg";
import { config } from "./config.js";
export const pool = new pg.Pool({
connectionString: config.postgres.connectionString,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
/** Quick health check — resolves true if DB responds. */
export async function pgHealthy(): Promise<boolean> {
try {
await pool.query("SELECT 1");
return true;
} catch {
return false;
}
}

303
api/src/email.ts Normal file
View file

@ -0,0 +1,303 @@
/**
* Email utility outbound SMTP via nodemailer.
*
* Used for:
* - Order confirmation emails (sent immediately after payment)
* - Portal access link emails (JWT-signed links)
*
* All transactional emails go through Carbonio: co.carrierone.com:587
* (SMTP2GO is used only by Listmonk for mass-mail campaigns.)
* Env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
*/
import nodemailer from "nodemailer";
// ─── SMTP transport ───────────────────────────────────────────────────────────
const SMTP_HOST = process.env.SMTP_HOST || "co.carrierone.com";
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587", 10);
const SMTP_USER = process.env.SMTP_USER || "";
const SMTP_PASS = process.env.SMTP_PASS || "";
const SMTP_FROM = process.env.SMTP_FROM || "Performance West <noreply@performancewest.net>";
let _transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (!_transporter) {
_transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: false, // STARTTLS
auth: { user: SMTP_USER, pass: SMTP_PASS },
});
}
return _transporter;
}
// ─── Generic send ─────────────────────────────────────────────────────────────
export async function sendEmail(opts: { to: string; subject: string; html: string; text?: string }): Promise<void> {
const t = getTransporter();
await t.sendMail({
from: SMTP_FROM,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text || "",
});
}
// ─── Branded HTML wrapper ─────────────────────────────────────────────────────
function htmlEmail(title: string, body: string): string {
const logo = "https://performancewest.net/images/logo.png";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
</head>
<body style="margin:0;padding:0;background:#eef0f3;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f3;padding:20px 0;">
<tr><td align="center">
<table width="620" cellpadding="0" cellspacing="0" style="width:620px;max-width:620px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<!-- Header with logo -->
<tr>
<td style="background:#1a2744;padding:18px 40px 14px;">
<table cellpadding="0" cellspacing="0" border="0"><tr>
<td style="vertical-align:middle;padding-right:12px;"><img src="${logo}" width="90" alt="Performance West" style="display:block;width:90px;height:auto;"></td>
<td style="vertical-align:middle;border-left:1px solid #2d4e78;padding-left:12px;"><span style="color:#8fa8d0;font-family:Arial,sans-serif;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;">Telecom Compliance</span></td>
</tr></table>
</td>
</tr>
<tr><td style="background:#059669;height:3px;font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- Body -->
<tr>
<td style="padding:32px 40px;">
${body}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f4f5f7;border-top:1px solid #e8ecf0;padding:16px 40px;text-align:center;">
<img src="${logo}" width="60" alt="Performance West" style="display:block;margin:0 auto 8px;width:60px;height:auto;opacity:0.5;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:11px;color:#9ca3af;">
Performance West Inc. &middot; <a href="https://performancewest.net" style="color:#9ca3af;">performancewest.net</a> &middot; 1-888-411-0383
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// ─── Types ────────────────────────────────────────────────────────────────────
export interface OrderConfirmationParams {
order_id: string;
order_type: string;
customer_email: string;
customer_name: string;
session_id: string;
amount_cents?: number;
service_name?: string;
payment_method?: string;
}
// ─── Order confirmation email ─────────────────────────────────────────────────
const ORDER_TIMELINES: Record<string, string> = {
canada_crtc: "610 weeks",
formation: "35 business days",
bundle: "510 business days",
compliance: "110 business days (varies by service)",
compliance_batch: "110 business days (varies by service)",
};
const ORDER_LABELS: Record<string, string> = {
canada_crtc: "Canada CRTC Telecom Carrier Package",
formation: "Business Formation",
bundle: "Compliance Bundle",
compliance: "Compliance Service",
compliance_batch: "FCC Compliance Services",
};
const ORDER_NEXT_STEPS: Record<string, string[]> = {
canada_crtc: [
"We will begin your BC corporation incorporation within 12 business days.",
"You will receive an email to choose your .ca domain name once your corporation is registered.",
"Your CRTC registration letter will be prepared after incorporation.",
"Your complete corporate binder will be delivered by email when all steps are complete.",
"A Canadian business banking referral will be sent upon delivery.",
],
formation: [
"We will begin your state filing within 1 business day.",
"You will receive your filed documents by email when processing is complete.",
"If you ordered an EIN, we will obtain it from the IRS after filing.",
],
bundle: [
"Our team will review your order within 1 business day.",
"You will be contacted to schedule any required consultations.",
"Completed deliverables will be emailed to you as they are ready.",
],
compliance: [
"Our team will review your order within 1 business day.",
"You will be contacted if we need any additional information.",
"Your completed deliverable will be emailed when ready.",
],
compliance_batch: [
"We will begin processing your services within 1 business day.",
"Some services (CPNI, RMD) may require your review and approval before we submit to the FCC.",
"You will receive a separate confirmation email for each filing as it is completed.",
"If we need any additional information, we will contact you by email.",
],
};
export async function sendOrderConfirmationEmail(params: OrderConfirmationParams): Promise<void> {
const { order_id, order_type, customer_email, customer_name, session_id,
amount_cents, service_name: svcName, payment_method } = params;
if (!customer_email) {
console.warn("[email] sendOrderConfirmationEmail: no customer_email for", order_id);
return;
}
if (!SMTP_USER || !SMTP_PASS) {
console.warn("[email] SMTP not configured — skipping confirmation email for", order_id);
return;
}
const firstName = customer_name.split(" ")[0] || customer_name;
const label = svcName || ORDER_LABELS[order_type] || "Your Order";
const timeline = ORDER_TIMELINES[order_type] || "110 business days";
const nextSteps = ORDER_NEXT_STEPS[order_type] || [];
const stepsHtml = nextSteps
.map((s, i) => `<p style="margin:4px 0 0;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">${i + 1}.</span> ${s}</p>`)
.join("\n");
const body = `
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;">Order Confirmed</h1>
<p style="margin:0 0 24px;font-size:15px;color:#6b7280;">Hi ${firstName}, your payment has been received. Here is your order summary.</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;margin-bottom:24px;">
<tr>
<td style="padding:16px 20px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Service</p>
<p style="margin:4px 0 0;font-size:15px;font-weight:700;color:#111827;">${svcName || label}</p>
</td>
</tr>
<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Order Number</p>
<p style="margin:4px 0 0;font-size:15px;font-weight:600;color:#1a2744;font-family:monospace;">${order_id}</p>
</td></tr>
${amount_cents ? `<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Amount Paid</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#059669;">$${(amount_cents / 100).toFixed(2)}</p>
${payment_method ? `<p style="margin:2px 0 0;font-size:12px;color:#9ca3af;">via ${payment_method}</p>` : ""}
</td></tr>` : ""}
<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
<p style="margin:0;font-size:13px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;">Estimated Turnaround</p>
<p style="margin:4px 0 0;font-size:15px;color:#111827;">${timeline}</p>
</td></tr>
</table>
<p style="margin:0 0 16px;font-size:13px;color:#6b7280;line-height:1.5;">
FCC compliance fees are tax deductible as ordinary business expenses under IRC &sect; 162.
A formal receipt will be sent separately.
</p>
<h2 style="margin:0 0 12px;font-size:16px;font-weight:700;color:#111827;">What happens next</h2>
${stepsHtml}
<div style="margin:24px 0 16px;padding:16px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;">
<p style="margin:0 0 8px;font-size:14px;font-weight:700;color:#0c4a6e;">Set up your client portal</p>
<p style="margin:0 0 12px;font-size:13px;color:#0369a1;">Track your orders, download documents, and manage your services.</p>
<a href="https://portal.performancewest.net" style="display:inline-block;background:#1e3a5f;color:#fff;padding:8px 20px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;">Access Client Portal &rarr;</a>
<p style="margin:8px 0 0;font-size:11px;color:#64748b;">First time? <a href="https://portal.performancewest.net/login#forgot" style="color:#0369a1;">Set your password here</a></p>
</div>
<p style="margin:0;font-size:14px;color:#6b7280;">
Questions? Reply to this email or reach us at
<a href="mailto:support@performancewest.net" style="color:#1e3a5f;">support@performancewest.net</a>
or call <a href="tel:18884110383" style="color:#1e3a5f;">1-888-411-0383</a>.
</p>
`;
const transporter = getTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: customer_email,
subject: `Order Confirmed — ${order_id}${label}`,
html: htmlEmail("Order Confirmed — Performance West", body),
text: [
`Hi ${firstName},`,
``,
`Your payment has been received for order ${order_id}.`,
`Service: ${label}`,
`Estimated turnaround: ${timeline}`,
``,
`What happens next:`,
...nextSteps.map((s, i) => `${i + 1}. ${s}`),
``,
`Questions? Email support@performancewest.net or call 1-888-411-0383.`,
``,
`Performance West Inc.`,
].join("\n"),
});
console.log(`[email] Confirmation sent to ${customer_email} for ${order_id}`);
}
// ─── Portal access link email ─────────────────────────────────────────────────
export interface PortalLinkParams {
customer_email: string;
customer_name: string;
portal_url: string; // full signed URL
order_id: string;
link_purpose: string; // e.g. "domain selection", "manage your services"
}
export async function sendPortalLinkEmail(params: PortalLinkParams): Promise<void> {
const { customer_email, customer_name, portal_url, order_id, link_purpose } = params;
if (!customer_email || !SMTP_USER || !SMTP_PASS) return;
const firstName = customer_name.split(" ")[0] || customer_name;
const body = `
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;">Action Required</h1>
<p style="margin:0 0 24px;font-size:15px;color:#6b7280;">Hi ${firstName}, a step in your order requires your input.</p>
<p style="margin:0 0 16px;font-size:15px;color:#374151;">
Please click the button below to complete <strong>${link_purpose}</strong> for order
<strong style="font-family:monospace;">${order_id}</strong>.
</p>
<p style="margin:0 0 24px;">
<a href="${portal_url}"
style="display:inline-block;background:#1e3a5f;color:#ffffff;font-size:15px;font-weight:600;padding:12px 28px;border-radius:6px;text-decoration:none;">
Continue
</a>
</p>
<p style="font-size:12px;color:#9ca3af;">
This link expires in 72 hours. If you did not request this, please ignore this email.
</p>
`;
const transporter = getTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: customer_email,
subject: `Action needed for order ${order_id}${link_purpose}`,
html: htmlEmail("Action Needed — Performance West", body),
text: `Hi ${firstName},\n\nPlease complete ${link_purpose} for order ${order_id}:\n\n${portal_url}\n\nThis link expires in 72 hours.\n\nPerformance West Inc.`,
});
console.log(`[email] Portal link sent to ${customer_email} for ${order_id} (${link_purpose})`);
}

1030
api/src/erpnext-client.ts Normal file

File diff suppressed because it is too large Load diff

118
api/src/fx.ts Normal file
View file

@ -0,0 +1,118 @@
/**
* CAD USD foreign exchange conversion.
*
* Source: Bank of Canada daily exchange rate (Valet API).
* Caches the rate for 24 hours.
*
* cadToUsdCents(cadCents):
* 1. Fetch current CAD/USD rate (e.g., 1 CAD = 0.73 USD)
* 2. Convert CAD cents to USD cents
* 3. Add 10% buffer to cover fluctuations
* 4. Round UP to the nearest whole dollar (100 cents)
*/
let cachedRate: { rate: number; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const BUFFER_PCT = 0.10; // 10% buffer on top of spot rate
const FALLBACK_RATE = 0.72; // conservative fallback if API is unreachable
/**
* Fetch the current CAD/USD exchange rate from the Bank of Canada.
* Returns how many USD 1 CAD buys (e.g., 0.73).
*/
async function fetchCadUsdRate(): Promise<number> {
// Bank of Canada Valet API — FXCADUSD is the official daily rate
const url = "https://www.bankofcanada.ca/valet/observations/FXCADUSD/json?recent=1";
try {
const resp = await fetch(url, {
signal: AbortSignal.timeout(8000),
headers: { "Accept": "application/json" },
});
if (!resp.ok) {
throw new Error(`Bank of Canada API returned ${resp.status}`);
}
const data = await resp.json() as {
observations: Array<{ d: string; FXCADUSD: { v: string } }>;
};
const obs = data.observations;
if (!obs || obs.length === 0) {
throw new Error("No observations returned from Bank of Canada");
}
const rate = parseFloat(obs[obs.length - 1].FXCADUSD.v);
if (isNaN(rate) || rate <= 0 || rate > 2) {
throw new Error(`Invalid rate value: ${obs[obs.length - 1].FXCADUSD.v}`);
}
console.log(`[fx] Bank of Canada CAD/USD rate: ${rate} (date: ${obs[obs.length - 1].d})`);
return rate;
} catch (err) {
console.warn(`[fx] Failed to fetch Bank of Canada rate: ${err}. Using fallback ${FALLBACK_RATE}`);
return FALLBACK_RATE;
}
}
/**
* Get the current CAD/USD rate (cached for 24h).
*/
async function getCadUsdRate(): Promise<number> {
const now = Date.now();
if (cachedRate && (now - cachedRate.fetchedAt) < CACHE_TTL_MS) {
return cachedRate.rate;
}
const rate = await fetchCadUsdRate();
cachedRate = { rate, fetchedAt: now };
return rate;
}
/**
* Convert CAD cents to USD cents with 10% buffer, rounded UP to nearest whole dollar.
*
* Example: C$350 (35000 cents) at rate 0.73:
* 35000 * 0.73 = 25550 USD cents
* 25550 * 1.10 = 28105 (with 10% buffer)
* Round up to $282.00 = 28200 cents
*/
export async function cadToUsdCents(cadCents: number): Promise<number> {
const rate = await getCadUsdRate();
const usdCentsRaw = cadCents * rate;
const withBuffer = usdCentsRaw * (1 + BUFFER_PCT);
// Round UP to nearest 100 (whole dollar)
const rounded = Math.ceil(withBuffer / 100) * 100;
return rounded;
}
/**
* Synchronous version using cached rate (returns 0 if not yet fetched).
* Use for display purposes only call cadToUsdCents() for order pricing.
*/
export function cadToUsdCentsSync(cadCents: number): number {
if (!cachedRate) return 0;
const usdCentsRaw = cadCents * cachedRate.rate;
const withBuffer = usdCentsRaw * (1 + BUFFER_PCT);
return Math.ceil(withBuffer / 100) * 100;
}
/**
* Get the current cached rate info (for display/debugging).
*/
export async function getFxInfo(): Promise<{ rate: number; withBuffer: number; fetchedAt: string }> {
const rate = await getCadUsdRate();
return {
rate,
withBuffer: rate * (1 + BUFFER_PCT),
fetchedAt: cachedRate ? new Date(cachedRate.fetchedAt).toISOString() : "never",
};
}
/**
* Pre-warm the cache at startup.
*/
export async function warmFxCache(): Promise<void> {
await getCadUsdRate();
}

184
api/src/index.ts Normal file
View file

@ -0,0 +1,184 @@
import express from "express";
import cookieParser from "cookie-parser";
import { config } from "./config.js";
import { pool, pgHealthy } from "./db.js";
import { securityHeaders, extractClientIp } from "./middleware/security.js";
import { corsMiddleware } from "./middleware/cors.js";
import { globalLimiter } from "./middleware/rate-limit.js";
import { errorHandler } from "./middleware/error-handler.js";
import { accessLog } from "./middleware/access-log.js";
import healthRouter from "./routes/health.js";
import subscribeRouter from "./routes/subscribe.js";
import ticketsRouter from "./routes/tickets.js";
import quotesRouter from "./routes/quotes.js";
import formationsRouter from "./routes/formations.js";
import discountsRouter from "./routes/discounts.js";
import adminRouter from "./routes/admin.js";
import webhooksRouter from "./routes/webhooks.js";
import identityRouter from "./routes/identity.js";
import refundsRouter from "./routes/refunds.js";
import agentsRouter from "./routes/agents.js";
import bundlesRouter from "./routes/bundles.js";
import entitiesRouter from "./routes/entities.js";
import idUploadRouter from "./routes/id-upload.js";
import canadaCrtcRouter from "./routes/canada-crtc.js";
import checkoutRouter from "./routes/checkout.js";
import ambLocationsRouter from "./routes/amb-locations.js";
import paymentMethodsRouter from "./routes/payment-methods.js";
import paypalRouter from "./routes/paypal.js";
import portalAuthRouter from "./routes/portal-auth.js";
import portalRouter from "./routes/portal.js";
import portalSetupRouter from "./routes/portal-setup.js";
import portalEsignRouter from "./routes/portal-esign.js";
import fccLookupRouter from "./routes/fcc-lookup.js";
import telecomEntitiesRouter from "./routes/telecom-entities.js";
import complianceOrdersRouter from "./routes/compliance-orders.js";
import cdrRouter from "./routes/cdr.js";
import iccRouter from "./routes/icc.js";
import resellerCertsRouter from "./routes/reseller-certs.js";
import lnpaRegionsRouter from "./routes/lnpa-regions.js";
import fccFilingsRouter from "./routes/fcc-filings.js";
import adminCryptoRouter from "./routes/admin-crypto.js";
import foreignQualRouter from "./routes/foreign-qualification.js";
import corpStatusRouter from "./routes/corp-status.js";
import portalRmdReviewRouter from "./routes/portal-rmd-review.js";
import pucRouter from "./routes/puc.js";
const app = express();
// Trust first proxy (nginx) in production
if (config.nodeEnv === "production") {
app.set("trust proxy", 1);
}
// --- Middleware stack (order matters) ---
app.use(securityHeaders);
app.use(extractClientIp);
app.use(accessLog);
app.use(corsMiddleware);
app.use(cookieParser());
app.use(globalLimiter);
// Stripe webhook — raw body MUST be preserved for signature verification.
// Mount BEFORE express.json() so the Buffer is not parsed away.
app.use("/api/v1/webhooks/stripe", express.raw({ type: "application/json" }));
app.use(identityRouter); // identity webhook uses raw() internally on its specific route
app.use(express.json({ limit: "512kb" })); // 512kb for eSign signature PNG base64
// Reject non-JSON content types on POST/PUT/PATCH
app.use((req, res, next) => {
if (["POST", "PUT", "PATCH"].includes(req.method) && !req.is("json")) {
res.status(415).json({ error: "Content-Type must be application/json" });
return;
}
next();
});
// --- Routes ---
app.use(healthRouter);
app.use(subscribeRouter);
app.use(ticketsRouter);
app.use(quotesRouter);
app.use(formationsRouter);
app.use(discountsRouter);
app.use(adminRouter);
app.use(webhooksRouter);
app.use(refundsRouter);
app.use(agentsRouter);
app.use(bundlesRouter);
app.use(entitiesRouter);
app.use(idUploadRouter);
app.use(canadaCrtcRouter);
app.use(checkoutRouter);
app.use(ambLocationsRouter);
app.use(paymentMethodsRouter);
app.use(paypalRouter);
app.use("/api/v1/auth", portalAuthRouter);
app.use(portalSetupRouter);
app.use(portalEsignRouter);
app.use(portalRmdReviewRouter);
app.use("/api/v1/portal", portalRouter); // Must be AFTER specific portal routes (uses catch-all customer-auth)
app.use(fccLookupRouter);
app.use(corpStatusRouter);
app.use(telecomEntitiesRouter);
app.use(complianceOrdersRouter);
app.use(cdrRouter);
app.use(iccRouter);
app.use(resellerCertsRouter);
app.use(lnpaRegionsRouter);
app.use(fccFilingsRouter);
app.use(foreignQualRouter);
app.use(pucRouter);
app.use(adminCryptoRouter);
// Note: identityRouter mounted above express.json() for webhook route,
// but also handles non-webhook routes (create-session, poll) which work fine with json()
// --- Error handler (must be last) ---
app.use(errorHandler);
// --- Start ---
async function start() {
// Verify database connection
const dbOk = await pgHealthy();
if (dbOk) {
console.log(`[db] PostgreSQL connected`);
} else {
console.warn(`[db] PostgreSQL unreachable — API will start but DB-dependent routes will fail`);
}
// Pre-warm FX rate cache
import("./fx.js").then(fx => fx.warmFxCache()).catch(err => console.warn("[fx] warmup failed:", err));
// Log ERPNext/Listmonk configuration status (non-blocking)
if (config.erpnext.apiKey) {
console.log(`[erpnext] Configured at ${config.erpnext.url}`);
try {
const r = await fetch(`${config.erpnext.url.replace(/\/$/, "")}/api/method/ping`, {
headers: {
Authorization: `token ${config.erpnext.apiKey}:${config.erpnext.apiSecret}`,
"X-Frappe-Site-Name": config.erpnext.siteName,
},
});
if (r.ok) {
console.log("[erpnext] Connectivity check passed");
} else {
console.warn(`[erpnext] Connectivity check failed (HTTP ${r.status})`);
}
} catch (err) {
console.warn("[erpnext] Connectivity check failed (network error):", err);
}
} else {
console.warn(`[erpnext] No API key configured — ERPNext integration disabled`);
}
if (config.listmonk.password) {
console.log(`[listmonk] Configured at ${config.listmonk.url}`);
} else {
console.warn(`[listmonk] No credentials configured — Listmonk integration disabled`);
}
const host = "0.0.0.0"; // bind all interfaces — nginx on host proxies to Docker container port
app.listen(config.port, host, () => {
console.log(`[api] Performance West API listening on ${host}:${config.port} (${config.nodeEnv})`);
});
}
start().catch((err) => {
console.error("[api] Fatal startup error:", err);
process.exit(1);
});
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("[api] SIGTERM received, shutting down...");
await pool.end();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("[api] SIGINT received, shutting down...");
await pool.end();
process.exit(0);
});

View file

@ -0,0 +1,244 @@
// FCC Form 499-A shared utilities (TypeScript / API side)
//
// Mirrors scripts/workers/services/telecom/fcc_499_utils.py — same logic
// is available to both the API (for /validate dry-run) and the worker
// handler (for actual form submission). Keep in sync.
//
// Authority: 2026 Form 499-A Instructions.
import { pool } from "../db.js";
// ── Line 105 box-tick derivation ──────────────────────────────────────────
export const LINE_105_BOX_NUMBERS: Record<string, number> = {
voip_interconnected: 1,
voip_non_interconnected: 2,
clec: 3,
ilec: 4,
local_reseller: 5,
toll_reseller: 6,
ixc: 7,
wireless: 8,
mvno: 9,
prepaid_calling_card: 10,
private_line: 11,
satellite: 12,
payphone: 13,
osp: 14,
shared_tenant: 15,
audio_bridging: 16,
toll_free: 17,
paging: 18,
smr: 19,
fixed_wireless: 20,
mobile_satellite: 21,
other: 22,
};
export interface Line105Entry {
id: string;
rank: number;
infra_type?: "facilities" | "reseller" | "mvno";
is_tdm_service?: boolean;
}
export function derivedLine105Boxes(
categoryId: string,
infraType: string | undefined,
): number[] {
const boxes: number[] = [];
if (infraType === "reseller") {
if (categoryId === "clec") boxes.push(5);
if (categoryId === "ixc") boxes.push(6);
}
if (infraType === "mvno" && categoryId === "wireless") boxes.push(9);
return boxes;
}
export function allLine105BoxesToTick(categories: Line105Entry[]): number[] {
const boxes = new Set<number>();
for (const cat of categories ?? []) {
if (cat.id && LINE_105_BOX_NUMBERS[cat.id] !== undefined) {
boxes.add(LINE_105_BOX_NUMBERS[cat.id]);
}
for (const b of derivedLine105Boxes(cat.id, cat.infra_type)) {
boxes.add(b);
}
}
return Array.from(boxes).sort((a, b) => a - b);
}
// ── Safe harbor lookup ────────────────────────────────────────────────────
export const SAFE_HARBOR_DISALLOWED = new Set(["voip_non_interconnected"]);
export function safeHarborAllowed(categoryId: string): boolean {
return !SAFE_HARBOR_DISALLOWED.has(categoryId);
}
export async function loadSafeHarborPct(
formYear: number,
categoryId: string,
): Promise<number | null> {
if (SAFE_HARBOR_DISALLOWED.has(categoryId)) return null;
const r = await pool.query(
`SELECT interstate_pct FROM fcc_safe_harbor_percentages
WHERE form_year = $1 AND line_105_category = $2`,
[formYear, categoryId],
);
return r.rows[0] ? Number(r.rows[0].interstate_pct) : null;
}
// ── De minimis calculator (Appendix A) ───────────────────────────────────
export interface DeMinimisWorksheet {
form_year: number;
line_1_filer_interstate_cents: number;
line_2_filer_intl_cents: number;
line_3_affiliates_interstate_cents: number;
line_4_affiliates_intl_cents: number;
line_5_consolidated_interstate_cents: number;
line_6_consolidated_total_cents: number;
line_7_interstate_pct: number;
line_8_lire_exempt: boolean;
line_9_contribution_base_cents: number;
line_10_factor: number;
line_11_estimated_contrib_cents: number;
is_de_minimis: boolean;
threshold_usd: number;
notes: string[];
}
export interface AffiliateRevenue {
total_revenue_cents: number;
interstate_pct: number;
international_pct: number;
}
export async function loadDeMinimisFactor(formYear: number): Promise<number> {
const r = await pool.query(
`SELECT factor FROM fcc_deminimis_factors WHERE form_year = $1`,
[formYear],
);
if (!r.rows[0]) {
throw new Error(`No de minimis factor configured for form year ${formYear}`);
}
return Number(r.rows[0].factor);
}
export async function calculateDeMinimis(opts: {
form_year: number;
filer_total_revenue_cents: number;
filer_interstate_pct: number;
filer_international_pct: number;
affiliates?: AffiliateRevenue[];
}): Promise<DeMinimisWorksheet> {
const affiliates = opts.affiliates ?? [];
const notes: string[] = [];
const line_1 = Math.round(
opts.filer_total_revenue_cents * (opts.filer_interstate_pct / 100),
);
const line_2 = Math.round(
opts.filer_total_revenue_cents * (opts.filer_international_pct / 100),
);
let line_3 = 0;
let line_4 = 0;
for (const a of affiliates) {
line_3 += Math.round(a.total_revenue_cents * a.interstate_pct / 100);
line_4 += Math.round(a.total_revenue_cents * a.international_pct / 100);
}
const line_5 = line_1 + line_3;
const intl_total = line_2 + line_4;
const line_6 = line_5 + intl_total;
const line_7 = line_6 > 0
? Math.round(100 * line_5 / line_6 * 10000) / 10000
: 0;
const line_8 = line_7 <= 12.0;
const line_9 = line_5 + (line_8 ? 0 : intl_total);
const line_10 = await loadDeMinimisFactor(opts.form_year);
const line_11 = Math.round(line_9 * line_10);
const threshold_usd = 10000;
const is_de_minimis = line_11 < threshold_usd * 100;
if (is_de_minimis) {
notes.push(
`De minimis: estimated contribution $${(line_11 / 100).toFixed(2)} < ` +
`$${threshold_usd.toLocaleString()} threshold.`,
);
} else {
notes.push(
`NOT de minimis: estimated contribution $${(line_11 / 100).toFixed(2)} ` +
`$${threshold_usd.toLocaleString()} threshold.`,
);
}
if (line_8) {
notes.push(
`LIRE exempt: interstate (${line_7.toFixed(2)}%) ≤ 12% of combined` +
` interstate+intl — international revenue excluded.`,
);
}
return {
form_year: opts.form_year,
line_1_filer_interstate_cents: line_1,
line_2_filer_intl_cents: line_2,
line_3_affiliates_interstate_cents: line_3,
line_4_affiliates_intl_cents: line_4,
line_5_consolidated_interstate_cents: line_5,
line_6_consolidated_total_cents: line_6,
line_7_interstate_pct: line_7,
line_8_lire_exempt: line_8,
line_9_contribution_base_cents: line_9,
line_10_factor: line_10,
line_11_estimated_contrib_cents: line_11,
is_de_minimis,
threshold_usd,
notes,
};
}
// ── Line 612 filing type ─────────────────────────────────────────────────
export type FilingType =
| "original_april_1"
| "registration_new_filer"
| "revised_registration"
| "revised_revenue";
export function detectFilingType(opts: {
entity: { filer_id_499?: string | null };
current_year_filing_exists?: boolean;
revised_reason?: "registration" | "revenue" | null;
}): FilingType {
if (!opts.entity.filer_id_499) return "registration_new_filer";
if (opts.current_year_filing_exists) {
if (opts.revised_reason === "registration") return "revised_registration";
if (opts.revised_reason === "revenue") return "revised_revenue";
}
return "original_april_1";
}
// ── TRS contribution base (Lines 512-514) ────────────────────────────────
export const TRS_BASE_LINE_KEYS = [
"line_403", "line_404", "line_404_1", "line_404_3",
"line_405", "line_406", "line_407", "line_408",
"line_409", "line_410", "line_411", "line_412",
"line_413", "line_414_1", "line_414_2",
"line_415", "line_416", "line_417",
"line_418_4",
] as const;
export function computeTrsContributionBase(
revenueLines: Record<string, number | undefined | null>,
): { line_512: number; line_513: number; line_514: number } {
const sum = TRS_BASE_LINE_KEYS.reduce(
(acc, k) => acc + (Number(revenueLines[k]) || 0),
0,
);
const line_512 = sum - (Number(revenueLines.line_511) || 0);
const line_513 = Number(revenueLines.line_513) || 0;
const line_514 = line_512 - line_513;
return { line_512, line_513, line_514 };
}

View file

@ -0,0 +1,30 @@
import type { Request, Response, NextFunction } from "express";
/**
* Structured access logger. Outputs one JSON line per request to stdout
* prefixed with [ACCESS] for easy parsing by fail2ban and log aggregation.
*/
export function accessLog(req: Request, res: Response, next: NextFunction): void {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
const log = {
ts: new Date().toISOString(),
ip: (req as any).clientIp || req.ip || "-",
method: req.method,
path: req.originalUrl,
status: res.statusCode,
ms,
ua: req.headers["user-agent"]?.slice(0, 200) || "-",
bytes: parseInt(res.getHeader("content-length") as string, 10) || 0,
};
// Only log non-health requests (reduces noise)
if (req.originalUrl !== "/api/v1/status") {
console.log(`[ACCESS] ${JSON.stringify(log)}`);
}
});
next();
}

View file

@ -0,0 +1,41 @@
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { config } from "../config.js";
const JWT_SECRET = process.env.ADMIN_JWT_SECRET || "change-this-in-production";
export interface AdminPayload {
id: number;
username: string;
}
declare global {
namespace Express {
interface Request {
admin?: AdminPayload;
}
}
}
/** Sign a JWT for an admin user. */
export function signAdminToken(payload: AdminPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: "8h" });
}
/** Verify admin JWT from Authorization: Bearer <token> header. */
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
res.status(401).json({ error: "Authentication required." });
return;
}
const token = header.slice(7);
try {
const decoded = jwt.verify(token, JWT_SECRET) as AdminPayload;
req.admin = decoded;
next();
} catch {
res.status(401).json({ error: "Invalid or expired token." });
}
}

View file

@ -0,0 +1,41 @@
import cors from "cors";
import { config } from "../config.js";
const PRODUCTION_ORIGINS = [
"https://performancewest.net",
"https://www.performancewest.net",
"https://dev.performancewest.net",
"http://192.168.7.4:4322",
];
const DEV_ORIGINS = [
"http://localhost:4322",
"http://localhost:3001",
"http://127.0.0.1:4322",
"http://127.0.0.1:3001",
];
// In dev mode, also allow any origin on common dev ports (LAN access)
const isDev = config.nodeEnv !== "production";
const allowedOrigins =
config.nodeEnv === "production"
? PRODUCTION_ORIGINS
: [...PRODUCTION_ORIGINS, ...DEV_ORIGINS];
export const corsMiddleware = cors({
origin: (origin, cb) => {
// Allow requests with no origin (server-to-server, curl, etc.)
if (!origin) { cb(null, true); return; }
if (allowedOrigins.includes(origin)) { cb(null, true); return; }
// In dev mode, allow any origin on known dev ports (LAN access from other machines)
if (isDev && /^http:\/\/[\d.]+:(4322|3001)$/.test(origin)) { cb(null, true); return; }
if (isDev && /^http:\/\/192\.168\./.test(origin)) { cb(null, true); return; }
cb(new Error(`Origin ${origin} not allowed by CORS`));
},
methods: ["GET", "POST", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset"],
credentials: true,
maxAge: 86_400,
});

View file

@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.ADMIN_JWT_SECRET || "changeme";
const COOKIE_NAME = "pw_customer";
export interface CustomerPayload {
customerId: number;
email: string;
}
declare global {
namespace Express {
interface Request {
customer?: CustomerPayload;
}
}
}
/** Middleware: attach customer from cookie JWT. Never blocks — sets req.customer if valid. */
export function optionalCustomerAuth(req: Request, _res: Response, next: NextFunction) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return next();
try {
req.customer = jwt.verify(token, JWT_SECRET) as CustomerPayload;
} catch {
// expired or invalid — ignore, let route decide
}
next();
}
/** Middleware: require valid customer session. Returns 401 if not logged in. */
export function requireCustomerAuth(req: Request, res: Response, next: NextFunction) {
optionalCustomerAuth(req, res, () => {
if (!req.customer) {
return res.status(401).json({ error: "Login required", code: "UNAUTHENTICATED" });
}
next();
});
}
/** Issue a customer session JWT cookie (7-day). */
export function issueCustomerCookie(res: Response, payload: CustomerPayload) {
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000,
path: "/",
});
}
/** Clear the customer session cookie. */
export function clearCustomerCookie(res: Response) {
res.clearCookie(COOKIE_NAME, { path: "/" });
}

View file

@ -0,0 +1,33 @@
import type { Request, Response, NextFunction } from "express";
interface AppError extends Error {
statusCode?: number;
details?: unknown;
}
/** Create a typed error with status code. */
export function createError(message: string, statusCode: number, details?: unknown): AppError {
const err: AppError = new Error(message);
err.statusCode = statusCode;
err.details = details;
return err;
}
/** Express error-handling middleware — must be mounted last. */
export function errorHandler(
err: AppError,
_req: Request,
res: Response,
_next: NextFunction,
): void {
const status = err.statusCode || 500;
const isDev = process.env.NODE_ENV !== "production";
console.error(`[ERROR] ${status} ${err.message}`, isDev ? err.stack : "");
res.status(status).json({
error: err.message || "Internal server error",
...(err.details ? { details: err.details } : {}),
...(isDev && err.stack ? { stack: err.stack } : {}),
});
}

View file

@ -0,0 +1,27 @@
// internal-auth.ts — Shared-secret authentication for internal API endpoints
// Used by Verilex Data to access bulk entity export and name search endpoints.
import type { Request, Response, NextFunction } from "express";
const INTERNAL_API_KEY = process.env.PW_INTERNAL_API_KEY || "";
export function internalAuth(req: Request, res: Response, next: NextFunction): void {
if (!INTERNAL_API_KEY) {
res.status(503).json({ error: "Internal API not configured" });
return;
}
const authHeader = req.headers.authorization || "";
if (!authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing Authorization header" });
return;
}
const token = authHeader.slice(7);
if (token !== INTERNAL_API_KEY) {
res.status(401).json({ error: "Invalid API key" });
return;
}
next();
}

View file

@ -0,0 +1,107 @@
/**
* Portal authentication middleware.
*
* Customer portal pages (/portal/*) are accessed via signed JWT links that
* are emailed to customers. No password is needed the link IS the credential.
*
* Token format (JWT, HS256):
* payload: { order_id, order_type, email, iat, exp }
* secret: CUSTOMER_JWT_SECRET env var
*
* Token is passed as:
* 1. Query param: ?token=... (email links)
* 2. Authorization header: Bearer ... (XHR/fetch from portal page)
* 3. Cookie: pw_portal_token=... (set by the portal page on first load)
*
* Helper: generatePortalToken(order_id, order_type, email) signed JWT
*/
import { type Request, type Response, type NextFunction } from "express";
import jwt from "jsonwebtoken";
const CUSTOMER_JWT_SECRET = process.env.CUSTOMER_JWT_SECRET || "changeme_long_random_string";
const TOKEN_TTL_SECONDS = 72 * 60 * 60; // 72 hours
export interface PortalTokenPayload {
order_id: string;
order_type: string;
email: string;
}
// ─── Generate a signed portal link token ─────────────────────────────────────
export function generatePortalToken(
order_id: string,
order_type: string,
email: string,
): string {
return jwt.sign(
{ order_id, order_type, email } satisfies PortalTokenPayload,
CUSTOMER_JWT_SECRET,
{ expiresIn: TOKEN_TTL_SECONDS },
);
}
// ─── Build a signed portal URL ────────────────────────────────────────────────
export function portalUrl(
path: string, // e.g. "/portal/domain-search"
order_id: string,
order_type: string,
email: string,
): string {
const token = generatePortalToken(order_id, order_type, email);
const domain = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "http://localhost:4321";
return `${domain}${path}?token=${encodeURIComponent(token)}`;
}
// ─── Middleware: verify portal token ─────────────────────────────────────────
// Attaches req.portalAuth = { order_id, order_type, email } on success.
// Returns 401 if token is missing, 403 if invalid/expired.
declare global {
namespace Express {
interface Request {
portalAuth?: PortalTokenPayload;
}
}
}
export function requirePortalAuth(req: Request, res: Response, next: NextFunction): void {
// 1. Query param (email link on first load)
let rawToken = (req.query.token as string) || null;
// 2. Authorization header (XHR from portal page after first load)
if (!rawToken) {
const authHeader = req.headers.authorization || "";
if (authHeader.startsWith("Bearer ")) {
rawToken = authHeader.slice(7);
}
}
// 3. Cookie (set by portal page JS after extracting from URL)
if (!rawToken) {
rawToken = (req.cookies?.pw_portal_token as string) || null;
}
if (!rawToken) {
res.status(401).json({ error: "Authentication required. Please use the link from your email.", code: "AUTH_REQUIRED" });
return;
}
try {
const payload = jwt.verify(rawToken, CUSTOMER_JWT_SECRET) as PortalTokenPayload & { iat: number; exp: number };
req.portalAuth = {
order_id: payload.order_id,
order_type: payload.order_type,
email: payload.email,
};
next();
} catch (err: any) {
if (err?.name === "TokenExpiredError") {
res.status(403).json({ error: "Your portal link has expired. Please request a new one.", code: "TOKEN_EXPIRED" });
} else {
res.status(403).json({ error: "Invalid portal link.", code: "TOKEN_INVALID" });
}
}
}

View file

@ -0,0 +1,21 @@
import rateLimit from "express-rate-limit";
/** Global rate limiter — 200 requests per minute per IP. */
export const globalLimiter = rateLimit({
windowMs: 60_000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => (req as any).clientIp || req.ip || "unknown",
message: { error: "Too many requests. Please wait and try again." },
});
/** Strict limiter for form submissions — 5 per minute per IP (50 in dev/test). */
export const submitLimiter = rateLimit({
windowMs: 60_000,
max: process.env.NODE_ENV === "production" ? 5 : 50,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => (req as any).clientIp || req.ip || "unknown",
message: { error: "Too many submissions. Please wait a moment." },
});

View file

@ -0,0 +1,38 @@
import helmet from "helmet";
import type { Request, Response, NextFunction } from "express";
// Strict security headers via Helmet.
export const securityHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
hsts: { maxAge: 31_536_000, includeSubDomains: true, preload: true },
frameguard: { action: "deny" },
noSniff: true,
referrerPolicy: { policy: "no-referrer" },
hidePoweredBy: true,
// This is a public API accessed cross-origin — must be cross-origin not same-origin
crossOriginResourcePolicy: { policy: "cross-origin" },
// Allow cross-origin opener for Stripe Identity redirect flows
crossOriginOpenerPolicy: { policy: "unsafe-none" },
});
// Attach normalised client IP to req (handles IPv6-mapped IPv4).
declare global {
namespace Express {
interface Request {
clientIp?: string;
}
}
}
export function extractClientIp(req: Request, _res: Response, next: NextFunction): void {
let ip = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "";
// Normalise ::ffff:127.0.0.1 → 127.0.0.1
if (ip.startsWith("::ffff:")) ip = ip.slice(7);
req.clientIp = ip;
next();
}

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;

364
api/src/sanctions.ts Normal file
View file

@ -0,0 +1,364 @@
/**
* CASL Sanctions Screening
*
* Screens director names against Canada's Consolidated Autonomous Sanctions List (CASL).
* List source: Global Affairs Canada, updated regularly under SEMA + JVCFOA.
* XML: https://www.international.gc.ca/world-monde/assets/office_docs/
* international_relations-relations_internationales/sanctions/sema-lmes.xml
*
* Matching strategy (three tiers):
* TIER 1 Exact match (case-insensitive, normalized): result = 'hit' block order
* TIER 2 High fuzzy match (score 85): result = 'hit' block order
* TIER 3 Moderate fuzzy match (score 70-84): result = 'possible_match' manual review queue
* Below 70: result = 'clear' proceed
*
* The list is cached in memory for CACHE_TTL_MS to avoid hammering Global Affairs Canada.
* Cache is refreshed on each server restart and on TTL expiry.
*
* This check runs BEFORE the order is saved to the database and BEFORE any payment
* is collected no Stripe session is created for a sanctioned individual.
*/
import { pool } from "./db.js";
const CASL_XML_URL =
"https://www.international.gc.ca/world-monde/assets/office_docs/international_relations-relations_internationales/sanctions/sema-lmes.xml";
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
const HIT_THRESHOLD = 85; // score ≥ 85 → definite hit, block
const POSSIBLE_MATCH_THRESHOLD = 70; // score 70-84 → possible match, manual review
// ─── In-memory cache ──────────────────────────────────────────────────────────
interface CaslEntry {
last_name: string; // normalized (lowercase, no diacritics)
given_name: string;
aliases: string[];
country: string;
schedule: string;
item: string;
date_of_birth?: string;
date_of_listing: string;
// Raw for logging
raw_last_name: string;
raw_given_name: string;
}
interface CaslCache {
entries: CaslEntry[];
fetched_at: Date;
list_date: string;
}
let _cache: CaslCache | null = null;
let _fetching = false;
let _fetchQueue: Array<(c: CaslCache) => void> = [];
// ─── XML parsing ─────────────────────────────────────────────────────────────
function extractXmlTag(xml: string, tag: string): string {
const m = new RegExp(`<${tag}>(.*?)</${tag}>`, "s").exec(xml);
return m ? m[1].trim() : "";
}
function extractAllRecords(xml: string): string[] {
const records: string[] = [];
const re = /<record>([\s\S]*?)<\/record>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) records.push(m[1]);
return records;
}
/** Remove diacritics and normalize to ASCII lowercase for 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 parseRecord(recordXml: string): CaslEntry | null {
const lastName = extractXmlTag(recordXml, "LastName");
const givenName = extractXmlTag(recordXml, "GivenName");
if (!lastName && !givenName) return null; // ships/entities without names skip via entity check
const aliasesRaw = extractXmlTag(recordXml, "Aliases");
const aliases = aliasesRaw
? aliasesRaw.split(/[;,]/).map(a => normalize(a.trim())).filter(Boolean)
: [];
return {
last_name: normalize(lastName),
given_name: normalize(givenName),
aliases,
country: extractXmlTag(recordXml, "Country"),
schedule: extractXmlTag(recordXml, "Schedule"),
item: extractXmlTag(recordXml, "Item"),
date_of_birth: extractXmlTag(recordXml, "DateOfBirthOrShipBuildDate") || undefined,
date_of_listing: extractXmlTag(recordXml, "DateOfListing"),
raw_last_name: lastName,
raw_given_name: givenName,
};
}
function parseXml(xml: string): { entries: CaslEntry[]; list_date: string } {
const records = extractAllRecords(xml);
const entries: CaslEntry[] = [];
for (const r of records) {
const entry = parseRecord(r);
if (entry) entries.push(entry);
}
// Extract list date from XML header comment or first record's DateOfListing
const dateMatch = /updated on (\w+ \d+, \d{4})/.exec(xml);
const list_date = dateMatch ? dateMatch[1] : new Date().toISOString().slice(0, 10);
return { entries, list_date };
}
// ─── Cache management ─────────────────────────────────────────────────────────
async function fetchAndParse(): Promise<CaslCache> {
console.log("[sanctions] Fetching CASL XML from Global Affairs Canada…");
const resp = await fetch(CASL_XML_URL, {
headers: { "Accept": "application/xml, text/xml", "User-Agent": "PerformanceWest-ComplianceCheck/1.0" },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`CASL XML fetch failed: ${resp.status}`);
const xml = await resp.text();
const { entries, list_date } = parseXml(xml);
console.log(`[sanctions] Loaded ${entries.length} CASL entries (list date: ${list_date})`);
return { entries, fetched_at: new Date(), list_date };
}
async function getCache(): Promise<CaslCache> {
if (_cache && Date.now() - _cache.fetched_at.getTime() < CACHE_TTL_MS) {
return _cache;
}
if (_fetching) {
// Another request is already fetching — wait for it
return new Promise(resolve => _fetchQueue.push(resolve));
}
_fetching = true;
try {
const cache = await fetchAndParse();
_cache = cache;
_fetchQueue.forEach(resolve => resolve(cache));
_fetchQueue = [];
return cache;
} catch (err) {
_fetching = false;
_fetchQueue = [];
if (_cache) {
console.error("[sanctions] CASL refresh failed, using stale cache:", err);
return _cache;
}
throw err;
} finally {
_fetching = false;
}
}
// ─── Fuzzy matching ──────────────────────────────────────────────────────────
/**
* Levenshtein edit distance between two strings.
*/
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: number[][] = 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];
}
/**
* Similarity score 0-100 between two strings (higher = more similar).
*/
function similarity(a: string, b: string): number {
if (!a && !b) return 100;
if (!a || !b) return 0;
const dist = levenshtein(a, b);
const maxLen = Math.max(a.length, b.length);
return Math.round((1 - dist / maxLen) * 100);
}
/**
* Score a query name against a CASL entry.
*
* Tries multiple combinations:
* - "firstName lastName" vs entry's given+last
* - "lastName, firstName" vs entry
* - Last name only (high-weight partial match)
* - Any alias match
*/
function scoreEntry(queryNorm: string, entry: CaslEntry): number {
const queryParts = queryNorm.split(" ").filter(Boolean);
const queryLast = queryParts[queryParts.length - 1] ?? "";
const queryFirst = queryParts.slice(0, -1).join(" ");
// Full name combinations
const entryFull1 = `${entry.given_name} ${entry.last_name}`.trim();
const entryFull2 = `${entry.last_name} ${entry.given_name}`.trim();
const entryFull3 = `${entry.last_name}, ${entry.given_name}`.trim();
const scores: number[] = [
similarity(queryNorm, entryFull1),
similarity(queryNorm, entryFull2),
similarity(queryNorm, entryFull3),
];
// Last-name-only match (strong signal — surnames are more distinctive than given names)
if (queryLast && entry.last_name) {
const lastScore = similarity(queryLast, entry.last_name);
// If last names are very similar (≥ 90) and first names partially match, boost
if (lastScore >= 90 && queryFirst) {
const firstScore = similarity(queryFirst, entry.given_name);
scores.push(Math.round(lastScore * 0.6 + firstScore * 0.4));
} else {
scores.push(Math.round(lastScore * 0.7)); // last-name-only is penalized
}
}
// Alias matching
for (const alias of entry.aliases) {
scores.push(similarity(queryNorm, alias));
}
return Math.max(...scores);
}
// ─── Public screening API ────────────────────────────────────────────────────
export interface ScreeningMatch {
score: number;
last_name: string;
given_name: string;
country: string;
schedule: string;
item: string;
date_of_birth?: string;
date_of_listing: string;
}
export interface ScreeningResult {
result: "clear" | "hit" | "possible_match" | "error";
score: number | null; // best match score (null if clear/error)
match: ScreeningMatch | null;
list_date: string;
screened_name: string;
error?: string;
}
/**
* Screen a full name against the CASL.
*
* @param fullName Director's full legal name as entered on the order form.
* @returns ScreeningResult with result tier, best match, and list date.
*/
export async function screenName(fullName: string): Promise<ScreeningResult> {
const normalized = normalize(fullName);
if (!normalized || normalized.length < 2) {
return { result: "error", score: null, match: null, list_date: "", screened_name: fullName, error: "Name too short to screen" };
}
let cache: CaslCache;
try {
cache = await getCache();
} catch (err) {
console.error("[sanctions] Could not load CASL list:", err);
// FAIL OPEN with logging — do not block order if list is unreachable,
// but log prominently so admin can review
return { result: "error", score: null, match: null, list_date: "", screened_name: fullName, error: "CASL list unavailable" };
}
let bestScore = 0;
let bestEntry: CaslEntry | null = null;
for (const entry of cache.entries) {
const score = scoreEntry(normalized, entry);
if (score > bestScore) {
bestScore = score;
bestEntry = entry;
if (bestScore === 100) break; // exact match — stop early
}
}
const match: ScreeningMatch | null = bestEntry && bestScore >= POSSIBLE_MATCH_THRESHOLD ? {
score: bestScore,
last_name: bestEntry.raw_last_name,
given_name: bestEntry.raw_given_name,
country: bestEntry.country,
schedule: bestEntry.schedule,
item: bestEntry.item,
date_of_birth: bestEntry.date_of_birth,
date_of_listing: bestEntry.date_of_listing,
} : null;
let result: ScreeningResult["result"];
if (bestScore >= HIT_THRESHOLD) {
result = "hit";
} else if (bestScore >= POSSIBLE_MATCH_THRESHOLD) {
result = "possible_match";
} else {
result = "clear";
}
return {
result,
score: match ? bestScore : null,
match,
list_date: cache.list_date,
screened_name: fullName,
};
}
/**
* Log a screening result to the database for audit purposes.
*/
export async function logScreening(
result: ScreeningResult,
opts: { order_number?: string; ip_address?: string; user_agent?: string },
): Promise<void> {
try {
await pool.query(
`INSERT INTO sanctions_screenings
(screened_name, order_number, result, match_score, matched_entry, list_date, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
result.screened_name,
opts.order_number ?? null,
result.result,
result.score ?? null,
result.match ? JSON.stringify(result.match) : null,
result.list_date || null,
opts.ip_address ?? null,
opts.user_agent ?? null,
],
);
} catch (err) {
// Non-blocking — log failure but don't break the order flow
console.error("[sanctions] Failed to log screening result:", err);
}
}
/**
* Force-refresh the CASL cache (e.g. from an admin endpoint).
*/
export async function refreshCache(): Promise<{ entries: number; list_date: string }> {
_cache = null;
const cache = await getCache();
return { entries: cache.entries.length, list_date: cache.list_date };
}