Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
111
api/src/config.ts
Normal file
111
api/src/config.ts
Normal 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
56
api/src/create-admin.ts
Normal 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
19
api/src/db.ts
Normal 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
303
api/src/email.ts
Normal 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;"> </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. · <a href="https://performancewest.net" style="color:#9ca3af;">performancewest.net</a> · 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: "6–10 weeks",
|
||||
formation: "3–5 business days",
|
||||
bundle: "5–10 business days",
|
||||
compliance: "1–10 business days (varies by service)",
|
||||
compliance_batch: "1–10 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 1–2 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] || "1–10 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 § 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 →</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
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
118
api/src/fx.ts
Normal 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
184
api/src/index.ts
Normal 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);
|
||||
});
|
||||
244
api/src/lib/fcc_499_utils.ts
Normal file
244
api/src/lib/fcc_499_utils.ts
Normal 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 };
|
||||
}
|
||||
30
api/src/middleware/access-log.ts
Normal file
30
api/src/middleware/access-log.ts
Normal 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();
|
||||
}
|
||||
41
api/src/middleware/admin-auth.ts
Normal file
41
api/src/middleware/admin-auth.ts
Normal 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." });
|
||||
}
|
||||
}
|
||||
41
api/src/middleware/cors.ts
Normal file
41
api/src/middleware/cors.ts
Normal 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,
|
||||
});
|
||||
57
api/src/middleware/customer-auth.ts
Normal file
57
api/src/middleware/customer-auth.ts
Normal 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: "/" });
|
||||
}
|
||||
33
api/src/middleware/error-handler.ts
Normal file
33
api/src/middleware/error-handler.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
27
api/src/middleware/internal-auth.ts
Normal file
27
api/src/middleware/internal-auth.ts
Normal 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();
|
||||
}
|
||||
107
api/src/middleware/portalAuth.ts
Normal file
107
api/src/middleware/portalAuth.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
21
api/src/middleware/rate-limit.ts
Normal file
21
api/src/middleware/rate-limit.ts
Normal 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." },
|
||||
});
|
||||
38
api/src/middleware/security.ts
Normal file
38
api/src/middleware/security.ts
Normal 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();
|
||||
}
|
||||
337
api/src/routes/admin-crypto.ts
Normal file
337
api/src/routes/admin-crypto.ts
Normal 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
304
api/src/routes/admin.ts
Normal 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
330
api/src/routes/agents.ts
Normal 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;
|
||||
29
api/src/routes/amb-locations.ts
Normal file
29
api/src/routes/amb-locations.ts
Normal 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
242
api/src/routes/bundles.ts
Normal 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;
|
||||
922
api/src/routes/canada-crtc.ts
Normal file
922
api/src/routes/canada-crtc.ts
Normal 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
315
api/src/routes/cdr.ts
Normal 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
1695
api/src/routes/checkout.ts
Normal file
File diff suppressed because it is too large
Load diff
1439
api/src/routes/compliance-orders.ts
Normal file
1439
api/src/routes/compliance-orders.ts
Normal file
File diff suppressed because it is too large
Load diff
481
api/src/routes/corp-status.ts
Normal file
481
api/src/routes/corp-status.ts
Normal 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
143
api/src/routes/discounts.ts
Normal 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
191
api/src/routes/entities.ts
Normal 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;
|
||||
275
api/src/routes/fcc-filings.ts
Normal file
275
api/src/routes/fcc-filings.ts
Normal 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
1414
api/src/routes/fcc-lookup.ts
Normal file
File diff suppressed because it is too large
Load diff
227
api/src/routes/foreign-qualification.ts
Normal file
227
api/src/routes/foreign-qualification.ts
Normal 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;
|
||||
326
api/src/routes/formations.ts
Normal file
326
api/src/routes/formations.ts
Normal 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
66
api/src/routes/health.ts
Normal 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
270
api/src/routes/icc.ts
Normal 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
129
api/src/routes/id-upload.ts
Normal 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
562
api/src/routes/identity.ts
Normal 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;
|
||||
168
api/src/routes/lnpa-regions.ts
Normal file
168
api/src/routes/lnpa-regions.ts
Normal 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;
|
||||
135
api/src/routes/payment-methods.ts
Normal file
135
api/src/routes/payment-methods.ts
Normal 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
247
api/src/routes/paypal.ts
Normal 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;
|
||||
398
api/src/routes/portal-auth.ts
Normal file
398
api/src/routes/portal-auth.ts
Normal 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;
|
||||
210
api/src/routes/portal-esign.ts
Normal file
210
api/src/routes/portal-esign.ts
Normal 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;
|
||||
266
api/src/routes/portal-rmd-review.ts
Normal file
266
api/src/routes/portal-rmd-review.ts
Normal 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;
|
||||
251
api/src/routes/portal-setup.ts
Normal file
251
api/src/routes/portal-setup.ts
Normal 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
159
api/src/routes/portal.ts
Normal 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
253
api/src/routes/puc.ts
Normal 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
149
api/src/routes/quotes.ts
Normal 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
319
api/src/routes/refunds.ts
Normal 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;
|
||||
308
api/src/routes/reseller-certs.ts
Normal file
308
api/src/routes/reseller-certs.ts
Normal 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
142
api/src/routes/subscribe.ts
Normal 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;
|
||||
435
api/src/routes/telecom-entities.ts
Normal file
435
api/src/routes/telecom-entities.ts
Normal 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
100
api/src/routes/tickets.ts
Normal 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
852
api/src/routes/webhooks.ts
Normal file
|
|
@ -0,0 +1,852 @@
|
|||
/**
|
||||
* Webhook Receivers
|
||||
*
|
||||
* 1. ERPNext webhooks — Formation Order / CRTC workflow state changes.
|
||||
* Security: X-Webhook-Secret header.
|
||||
*
|
||||
* 2. Stripe webhooks — payment_intent.succeeded / checkout.session.completed.
|
||||
* Security: Stripe-Signature header (HMAC-SHA256).
|
||||
* IMPORTANT: Must be registered with raw body parser (express.raw) — see index.ts.
|
||||
* Register at: https://dashboard.stripe.com/webhooks
|
||||
* Events: checkout.session.completed, payment_intent.succeeded,
|
||||
* payment_intent.payment_failed, charge.dispute.created,
|
||||
* balance.available
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import Stripe from "stripe";
|
||||
import { pool } from "../db.js";
|
||||
import { handlePaymentComplete, advanceToClientSelection } from "./checkout.js";
|
||||
import { sendEmail } from "../email.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "change-this-in-production";
|
||||
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
|
||||
|
||||
/** Verify the webhook secret. */
|
||||
function verifySecret(req: any, res: any): boolean {
|
||||
const secret = req.headers["x-webhook-secret"];
|
||||
if (!secret || secret !== WEBHOOK_SECRET) {
|
||||
console.warn("[webhooks] Invalid or missing webhook secret");
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Forward a job to the worker service. */
|
||||
async function dispatchToWorker(action: string, payload: Record<string, unknown>): Promise<{ ok: boolean; data?: unknown }> {
|
||||
try {
|
||||
const res = await fetch(`${WORKER_URL}/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, ...payload }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { ok: res.ok, data };
|
||||
} catch (err) {
|
||||
console.error(`[webhooks] Failed to dispatch ${action} to worker:`, err);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Formation Order Webhooks (triggered by ERPNext workflow state changes)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/formation/submitted
|
||||
* Triggered when a formation order is submitted.
|
||||
* Action: Start name availability search.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/formation/submitted", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number, state_code, entity_name } = req.body;
|
||||
console.log(`[webhooks] Formation submitted: ${order_number} — ${entity_name} in ${state_code}`);
|
||||
|
||||
// Advance ERPNext to "Name Check" state
|
||||
await advanceWorkflow(order_name, "Start Name Check");
|
||||
|
||||
// Dispatch name search to worker
|
||||
await dispatchToWorker("name_search", { order_name, order_number, state_code, entity_name });
|
||||
|
||||
res.json({ received: true, action: "name_search_dispatched" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/formation/name-available
|
||||
* Triggered when name is confirmed available.
|
||||
* Action: Start state portal filing.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/formation/name-available", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] Name available: ${order_number} — starting filing`);
|
||||
|
||||
await advanceWorkflow(order_name, "Start Filing");
|
||||
await dispatchToWorker("file_entity", { order_name, order_number });
|
||||
|
||||
res.json({ received: true, action: "filing_dispatched" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/formation/filed-needs-ein
|
||||
* Triggered when entity is filed and EIN is requested.
|
||||
* Action: Start IRS EIN obtainment.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/formation/filed-needs-ein", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] Filed, needs EIN: ${order_number}`);
|
||||
|
||||
await advanceWorkflow(order_name, "Start EIN");
|
||||
await dispatchToWorker("obtain_ein", { order_name, order_number });
|
||||
|
||||
res.json({ received: true, action: "ein_dispatched" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/formation/filed-skip-ein
|
||||
* Triggered when entity is filed but no EIN requested.
|
||||
* Action: Skip to document generation.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/formation/filed-skip-ein", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] Filed, skipping EIN: ${order_number}`);
|
||||
|
||||
await advanceWorkflow(order_name, "Skip EIN");
|
||||
await dispatchToWorker("generate_docs", { order_name, order_number });
|
||||
|
||||
res.json({ received: true, action: "doc_gen_dispatched" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/formation/ein-obtained
|
||||
* Triggered when EIN is obtained.
|
||||
* Action: Start document generation.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/formation/ein-obtained", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] EIN obtained: ${order_number} — generating docs`);
|
||||
|
||||
await advanceWorkflow(order_name, "Generate Docs");
|
||||
await dispatchToWorker("generate_docs", { order_name, order_number });
|
||||
|
||||
res.json({ received: true, action: "doc_gen_dispatched" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/formation/approved
|
||||
* Triggered when admin approves the review.
|
||||
* Action: Mark order ready for delivery.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/formation/approved", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] Approved: ${order_number}`);
|
||||
|
||||
await advanceWorkflow(order_name, "Mark Ready");
|
||||
res.json({ received: true, action: "marked_ready" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/formation/ready
|
||||
* Triggered when order is ready for delivery.
|
||||
* Action: Email documents to customer.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/formation/ready", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] Ready for delivery: ${order_number}`);
|
||||
|
||||
await dispatchToWorker("deliver", { order_name, order_number });
|
||||
|
||||
res.json({ received: true, action: "delivery_dispatched" });
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Compliance Service Webhooks (same pattern, for non-formation orders)
|
||||
// ==========================================================================
|
||||
|
||||
router.post("/api/v1/webhooks/service/queued", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number, service_slug: providedSlug } = req.body;
|
||||
|
||||
// Resolve service_slug from compliance_orders if not provided by webhook
|
||||
let service_slug = providedSlug || "";
|
||||
if (!service_slug && order_number) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT service_slug FROM compliance_orders WHERE order_number = $1",
|
||||
[order_number],
|
||||
);
|
||||
if (rows.length > 0) service_slug = (rows[0] as Record<string, unknown>).service_slug as string;
|
||||
} catch { /* table may not exist */ }
|
||||
}
|
||||
|
||||
console.log(`[webhooks] Service queued: ${order_number} — ${service_slug}`);
|
||||
|
||||
await dispatchToWorker("process_compliance_service", { order_name, order_number, service_slug });
|
||||
|
||||
res.json({ received: true, action: "service_processing_dispatched" });
|
||||
});
|
||||
|
||||
router.post("/api/v1/webhooks/service/approved", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] Service approved: ${order_number}`);
|
||||
|
||||
await dispatchToWorker("deliver", { order_name, order_number });
|
||||
|
||||
res.json({ received: true, action: "delivery_dispatched" });
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Canada CRTC Webhooks (triggered by ERPNext workflow state changes)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/awaiting-funds
|
||||
* Triggered when CRTC order enters Awaiting Funds state.
|
||||
* Records reservation intent; actual advance happens when Relay deposit detected.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/awaiting-funds", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC awaiting funds: ${order_number}`);
|
||||
// Worker will pick this up when relay_deposit_monitor finds a deposit
|
||||
await dispatchToWorker("register_awaiting_funds", { order_name, order_number, order_type: "canada_crtc" });
|
||||
res.json({ received: true, action: "registered_awaiting_funds" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/funds-available
|
||||
* Triggered when deposit monitor advances order to Incorporation state.
|
||||
* Dispatches the BC incorporation job to the worker.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/funds-available", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC funds available — dispatching incorporation: ${order_number}`);
|
||||
await dispatchToWorker("file_bc_incorporation", { order_name, order_number });
|
||||
res.json({ received: true, action: "incorporation_dispatched" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/incorporated
|
||||
* Triggered when BC incorporation completes.
|
||||
* Dispatches CRTC letter generation + binder compilation.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/incorporated", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number, incorporation_number } = req.body;
|
||||
console.log(`[webhooks] CRTC incorporated: ${order_number} — #${incorporation_number}`);
|
||||
await dispatchToWorker("generate_crtc_docs", { order_name, order_number, incorporation_number });
|
||||
res.json({ received: true, action: "crtc_docs_dispatched" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/ready-for-review
|
||||
* Triggered when binder compilation completes and order is in Review state.
|
||||
* Notifies admin that manual review is needed.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/ready-for-review", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC ready for admin review: ${order_number}`);
|
||||
await dispatchToWorker("notify_admin_review", { order_name, order_number, order_type: "canada_crtc" });
|
||||
res.json({ received: true, action: "admin_notified" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/approved
|
||||
* Triggered when admin approves the binder — order moves to Shipping.
|
||||
* Dispatches the physical binder print+ship instructions.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/approved", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC approved for shipping: ${order_number}`);
|
||||
await dispatchToWorker("ship_binder", { order_name, order_number });
|
||||
res.json({ received: true, action: "shipping_dispatched" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/delivered
|
||||
* Triggered when order is marked Delivered.
|
||||
* Starts 14-day commission holdback clock.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/delivered", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC delivered: ${order_number}`);
|
||||
await dispatchToWorker("mark_delivered", { order_name, order_number, order_type: "canada_crtc" });
|
||||
res.json({ received: true, action: "delivery_recorded" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/domain-ready
|
||||
* Triggered when domain + email provisioning completes.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/domain-ready", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC domain ready: ${order_number}`);
|
||||
res.json({ received: true, action: "domain_ready_acknowledged" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/phone-ready
|
||||
* Triggered when Canadian DID is provisioned.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/phone-ready", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC phone ready: ${order_number}`);
|
||||
res.json({ received: true, action: "phone_ready_acknowledged" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/banking-ready
|
||||
* Triggered when banking referral email is sent.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/banking-ready", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC banking ready: ${order_number}`);
|
||||
res.json({ received: true, action: "banking_ready_acknowledged" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/bits-filed
|
||||
* Triggered when BITS Form 503 is submitted to CRTC DCS.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/bits-filed", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC BITS filed: ${order_number}`);
|
||||
res.json({ received: true, action: "bits_filed_acknowledged" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/crtc/ccts-ready
|
||||
* Triggered when CCTS registration is complete.
|
||||
*/
|
||||
router.post("/api/v1/webhooks/crtc/ccts-ready", async (req, res) => {
|
||||
if (!verifySecret(req, res)) return;
|
||||
const { order_name, order_number } = req.body;
|
||||
console.log(`[webhooks] CRTC CCTS ready: ${order_number}`);
|
||||
res.json({ received: true, action: "ccts_ready_acknowledged" });
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Stripe Webhook
|
||||
// ==========================================================================
|
||||
|
||||
const STRIPE_SECRET_KEY =
|
||||
(process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_SECRET_KEY?.trim()) ||
|
||||
process.env.STRIPE_SECRET_KEY ||
|
||||
"";
|
||||
const STRIPE_WEBHOOK_SECRET =
|
||||
(process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_WEBHOOK_SECRET?.trim()) ||
|
||||
process.env.STRIPE_WEBHOOK_SECRET ||
|
||||
"";
|
||||
const STRIPE_API_VERSION: Stripe.LatestApiVersion = "2026-03-25.dahlia";
|
||||
const stripeClient = STRIPE_SECRET_KEY
|
||||
? new Stripe(STRIPE_SECRET_KEY, { apiVersion: STRIPE_API_VERSION })
|
||||
: null;
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/stripe
|
||||
*
|
||||
* Receives Stripe events. Must be mounted with express.raw() body parser
|
||||
* (the raw Buffer is required for signature verification).
|
||||
*
|
||||
* Handled events:
|
||||
* checkout.session.completed — customer paid via Stripe Checkout
|
||||
* payment_intent.payment_failed — log failure for dunning
|
||||
*/
|
||||
router.post(
|
||||
"/api/v1/webhooks/stripe",
|
||||
async (req: Request, res: Response) => {
|
||||
if (!stripeClient || !STRIPE_WEBHOOK_SECRET) {
|
||||
console.warn("[webhooks/stripe] Stripe not configured — ignoring event");
|
||||
res.json({ received: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const sig = req.headers["stripe-signature"];
|
||||
if (!sig) {
|
||||
res.status(400).json({ error: "Missing Stripe-Signature header" });
|
||||
return;
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
// req.body must be the raw Buffer — see index.ts for raw body parser setup
|
||||
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");
|
||||
event = stripeClient.webhooks.constructEvent(
|
||||
rawBody,
|
||||
sig,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[webhooks/stripe] Signature verification failed:", err);
|
||||
res.status(400).json({ error: "Webhook signature verification failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[webhooks/stripe] Event: ${event.type} — ${event.id}`);
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const order_id = session.metadata?.order_id;
|
||||
const order_type = session.metadata?.order_type;
|
||||
|
||||
if (!order_id || !order_type) {
|
||||
console.warn("[webhooks/stripe] checkout.session.completed missing metadata", session.id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (session.payment_status === "paid") {
|
||||
await handlePaymentComplete(order_id, order_type, session.id);
|
||||
} else {
|
||||
// ACH payments may be "unpaid" at session.complete — wait for payment_intent.succeeded
|
||||
console.log(`[webhooks/stripe] Session ${session.id} complete but payment_status=${session.payment_status} — waiting`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "payment_intent.succeeded": {
|
||||
// Fired for ACH after funds clear. The checkout session completed event
|
||||
// fires first but payment_status was "unpaid" — this confirms funds.
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
const order_id = pi.metadata?.order_id;
|
||||
const order_type = pi.metadata?.order_type;
|
||||
const session_id = pi.metadata?.checkout_session_id ?? pi.id;
|
||||
|
||||
if (order_id && order_type) {
|
||||
await handlePaymentComplete(order_id, order_type, session_id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "payment_intent.payment_failed": {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
const failOrderId = pi.metadata?.order_id;
|
||||
const failReason = pi.last_payment_error?.message ?? "unknown error";
|
||||
console.warn(
|
||||
`[webhooks/stripe] Payment failed for order ${failOrderId}: ${failReason}`,
|
||||
);
|
||||
// Alert admin for ACH failures (NSF, account closed, etc.)
|
||||
if (failOrderId) {
|
||||
handlePaymentFailure(failOrderId, failReason).catch(err =>
|
||||
console.error("[webhooks/stripe] payment failure handler error:", err),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "charge.dispute.created": {
|
||||
// ACH returns show up as disputes (NSF, unauthorized, etc.)
|
||||
const dispute = event.data.object as Stripe.Dispute;
|
||||
const disputePI = dispute.payment_intent as string;
|
||||
const disputeReason = dispute.reason || "unknown";
|
||||
const disputeAmount = dispute.amount;
|
||||
console.warn(
|
||||
`[webhooks/stripe] ACH DISPUTE: ${disputeReason} — $${(disputeAmount / 100).toFixed(2)} — PI: ${disputePI}`,
|
||||
);
|
||||
// Look up the order from the payment intent metadata
|
||||
handleACHDispute(disputePI, disputeReason, disputeAmount).catch(err =>
|
||||
console.error("[webhooks/stripe] dispute handler error:", err),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "balance.available": {
|
||||
// Stripe balance settled — check if any CRTC orders can advance
|
||||
console.log("[webhooks/stripe] balance.available event received");
|
||||
handleBalanceAvailable().catch(err =>
|
||||
console.error("[webhooks/stripe] balance.available handler error:", err),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// Ignore unhandled event types
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[webhooks/stripe] Error handling event ${event.type}:`, err);
|
||||
// Return 200 anyway so Stripe doesn't retry — we log the error
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ==========================================================================
|
||||
// Helper: Advance ERPNext Workflow
|
||||
// ==========================================================================
|
||||
|
||||
async function advanceWorkflow(docName: string, action: string, doctype = "Formation Order"): Promise<boolean> {
|
||||
const erpnextUrl = process.env.ERPNEXT_URL || "http://erpnext:8000";
|
||||
const apiKey = process.env.ERPNEXT_API_KEY || "";
|
||||
const apiSecret = process.env.ERPNEXT_API_SECRET || "";
|
||||
const siteName = process.env.ERPNEXT_SITE_NAME || process.env.ERPNEXT_HOST_HEADER || "performancewest.net";
|
||||
|
||||
try {
|
||||
const res = await fetch(`${erpnextUrl}/api/method/frappe.client.call`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${apiKey}:${apiSecret}`,
|
||||
"X-Frappe-Site-Name": siteName,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cmd: "frappe.model.workflow.apply_workflow",
|
||||
doc: JSON.stringify({ doctype, name: docName }),
|
||||
action,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
console.error(`[webhooks] Failed to advance workflow: ${action} — ${res.status}: ${errText.slice(0, 300)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[webhooks] Workflow advanced: ${docName} → ${action}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[webhooks] Workflow advance error: ${action} —`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// One-time warning flag so the "SHKEEPER_API_KEY not set" message only
|
||||
// fires once per process instead of on every request.
|
||||
let _shkeeperKeyMissingWarned = false;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 4. SHKeeper (crypto) webhook — payment notifications
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST /api/v1/webhooks/shkeeper
|
||||
* Called by SHKeeper when a crypto transaction is received for an invoice.
|
||||
* Must return HTTP 202 Accepted to acknowledge — anything else causes retry.
|
||||
*
|
||||
* Callback payload:
|
||||
* external_id, crypto, addr, fiat, balance_fiat, balance_crypto,
|
||||
* paid (bool), status (PARTIAL|PAID|OVERPAID), transactions[]
|
||||
*/
|
||||
router.post("/api/v1/webhooks/shkeeper", async (req, res) => {
|
||||
try {
|
||||
// ── Signature check — mirror frappe_crypto/api.py:51 ────────────────
|
||||
// SHKeeper sends its configured API key in the X-Shkeeper-Api-Key
|
||||
// header; we verify with timingSafeEqual to avoid leaks.
|
||||
const expected = process.env.SHKEEPER_API_KEY || "";
|
||||
const supplied = String(req.headers["x-shkeeper-api-key"] || "");
|
||||
if (expected) {
|
||||
const ok = supplied.length === expected.length &&
|
||||
crypto.timingSafeEqual(
|
||||
Buffer.from(supplied), Buffer.from(expected),
|
||||
);
|
||||
if (!ok) {
|
||||
console.warn("[shkeeper] API key mismatch — rejecting webhook");
|
||||
res.status(401).json({ error: "invalid api key" });
|
||||
return;
|
||||
}
|
||||
} else if (!_shkeeperKeyMissingWarned) {
|
||||
// Warn once per process so we don't spam logs on every request.
|
||||
console.warn("[shkeeper] SHKEEPER_API_KEY not set — accepting without signature check");
|
||||
_shkeeperKeyMissingWarned = true;
|
||||
}
|
||||
|
||||
const {
|
||||
external_id,
|
||||
crypto: cryptoName,
|
||||
balance_fiat,
|
||||
balance_crypto,
|
||||
paid,
|
||||
status: invoiceStatus,
|
||||
transactions,
|
||||
} = req.body ?? {};
|
||||
|
||||
console.log(`[shkeeper] Callback: order=${external_id} crypto=${cryptoName} status=${invoiceStatus} paid=${paid} fiat=${balance_fiat}`);
|
||||
|
||||
if (!external_id) {
|
||||
res.status(202).json({ received: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process when fully paid or overpaid
|
||||
if (paid === true && (invoiceStatus === "PAID" || invoiceStatus === "OVERPAID")) {
|
||||
const orderId = String(external_id);
|
||||
|
||||
// Determine order type from prefix
|
||||
const orderType = orderId.startsWith("CA-") ? "canada_crtc"
|
||||
: orderId.startsWith("FO-") ? "formation"
|
||||
: orderId.startsWith("BU-") ? "bundle"
|
||||
: orderId.startsWith("CO-") ? "compliance"
|
||||
: "canada_crtc";
|
||||
|
||||
const txid = transactions?.[0]?.txid || `shkeeper-${cryptoName}-${Date.now()}`;
|
||||
|
||||
// ── Treasury pipeline enqueue (migration 065) ────────────────────
|
||||
// Enqueue a crypto_payment_jobs row (idempotent ON CONFLICT DO NOTHING).
|
||||
// The crypto_payment_worker polls this table and drives the
|
||||
// received → sizing → offramping → funds_at_relay → ready → settled
|
||||
// state machine. SHKeeper webhook retries are safe — same
|
||||
// (order_id, txid) produces the same idempotency_key.
|
||||
try {
|
||||
const { pool } = await import("../db.js");
|
||||
const coinUpper = String(cryptoName || "").toUpperCase();
|
||||
const balanceCoin = balance_crypto ? String(balance_crypto) : "0";
|
||||
const balanceCents = Math.round(Number(balance_fiat || 0) * 100);
|
||||
const idemKey = `shkeeper-settle:${orderId}:${txid}`;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO crypto_payment_jobs (
|
||||
order_id, order_type, state, coin, amount_coin,
|
||||
amount_usd_cents, idempotency_key, received_at
|
||||
) VALUES ($1, $2, 'received', $3, $4::numeric, $5, $6, NOW())
|
||||
ON CONFLICT (order_id) DO UPDATE SET
|
||||
-- Same order paid in multiple txs (overpaid case): refresh
|
||||
-- the amounts but only if we're still in 'received'.
|
||||
amount_coin = EXCLUDED.amount_coin,
|
||||
amount_usd_cents = EXCLUDED.amount_usd_cents,
|
||||
updated_at = NOW()
|
||||
WHERE crypto_payment_jobs.state = 'received'`,
|
||||
[orderId, orderType, coinUpper, balanceCoin, balanceCents, idemKey],
|
||||
);
|
||||
|
||||
// Record the immutable 'receive' ledger row (also idempotent via
|
||||
// UNIQUE on idempotency_key).
|
||||
await pool.query(
|
||||
`INSERT INTO crypto_payment_ledger (
|
||||
order_id, order_type, coin, movement_type,
|
||||
amount_coin, amount_usd_cents,
|
||||
provider, provider_ref, provider_status,
|
||||
state, idempotency_key, acquired_at, notes
|
||||
) VALUES ($1, $2, $3, 'receive',
|
||||
$4::numeric, $5,
|
||||
'shkeeper', $6, $7,
|
||||
'confirmed', $8, NOW(),
|
||||
$9)
|
||||
ON CONFLICT (idempotency_key) DO NOTHING`,
|
||||
[
|
||||
orderId, orderType, coinUpper,
|
||||
balanceCoin, balanceCents,
|
||||
txid, invoiceStatus,
|
||||
`shkeeper:${txid}`,
|
||||
`SHKeeper ${invoiceStatus} — ${balanceCoin} ${coinUpper} @ $${balance_fiat}`,
|
||||
],
|
||||
);
|
||||
|
||||
console.log(`[shkeeper] Enqueued treasury job for ${orderId}`);
|
||||
} catch (err) {
|
||||
console.error(`[shkeeper] Failed to enqueue treasury job for ${orderId}:`, err);
|
||||
// Non-fatal — continue to handlePaymentComplete so customer-facing
|
||||
// side still advances; the treasury worker can retry.
|
||||
}
|
||||
|
||||
try {
|
||||
await handlePaymentComplete(orderId, orderType, `shkeeper-${txid}`);
|
||||
console.log(`[shkeeper] Payment complete for ${orderId}: ${cryptoName} ${balance_fiat} USD`);
|
||||
} catch (err) {
|
||||
console.error(`[shkeeper] handlePaymentComplete failed for ${orderId}:`, err);
|
||||
}
|
||||
} else {
|
||||
console.log(`[shkeeper] Partial/pending payment for ${external_id}: ${invoiceStatus}`);
|
||||
}
|
||||
|
||||
// Must return 202 to stop SHKeeper from retrying
|
||||
res.status(202).json({ received: true });
|
||||
} catch (err) {
|
||||
console.error("[shkeeper] Webhook error:", err);
|
||||
// Still return 202 to prevent infinite retries
|
||||
res.status(202).json({ received: true, error: "internal" });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 5. Stripe balance.available — fund settlement detection for CRTC orders
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Handled inline in the main Stripe webhook handler (section 1 above).
|
||||
* When `balance.available` fires, we check for CRTC orders in "Awaiting Funds"
|
||||
* that have been paid via Stripe (card/ACH/Klarna) and enough time has passed
|
||||
* for settlement.
|
||||
*
|
||||
* Card/Klarna: T+2 business days from payment capture
|
||||
* ACH: T+4 business days from payment capture
|
||||
*
|
||||
* For each eligible order: topup Issuing balance → advance to "Client Selection"
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || "ops@performancewest.net";
|
||||
|
||||
async function handlePaymentFailure(orderId: string, reason: string): Promise<void> {
|
||||
try {
|
||||
// Flag the order
|
||||
for (const table of ["compliance_orders", "canada_crtc_orders", "formation_orders", "bundle_orders"]) {
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE ${table} SET payment_status = 'failed', notes = COALESCE(notes, '') || $2
|
||||
WHERE order_number = $1 AND payment_status IN ('paid', 'pending_payment')`,
|
||||
[orderId, `\nPayment failed: ${reason} (${new Date().toISOString()})`],
|
||||
);
|
||||
} catch { /* table may not exist or order not in this table */ }
|
||||
}
|
||||
|
||||
// Alert admin
|
||||
await sendEmail({
|
||||
to: ADMIN_EMAIL,
|
||||
subject: `⚠️ Payment Failed — ${orderId}`,
|
||||
html: `<h2>Payment Failed</h2>
|
||||
<p><strong>Order:</strong> ${orderId}</p>
|
||||
<p><strong>Reason:</strong> ${reason}</p>
|
||||
<p><strong>Time:</strong> ${new Date().toISOString()}</p>
|
||||
<p>If work has already been dispatched for this order, review whether to halt or continue.</p>`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[payment-failure] Handler error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleACHDispute(paymentIntentId: string, reason: string, amountCents: number): Promise<void> {
|
||||
try {
|
||||
// Try to find the order across tables using Stripe session/PI references
|
||||
let orderId = "unknown";
|
||||
for (const table of ["compliance_orders", "canada_crtc_orders", "formation_orders", "bundle_orders"]) {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`SELECT order_number, customer_email, customer_name, service_name
|
||||
FROM ${table} WHERE stripe_session_id LIKE $1 OR order_number IN (
|
||||
SELECT order_number FROM ${table} WHERE payment_status = 'paid'
|
||||
) LIMIT 1`,
|
||||
[`%${paymentIntentId}%`],
|
||||
);
|
||||
if (r.rows.length > 0) {
|
||||
orderId = r.rows[0].order_number as string;
|
||||
break;
|
||||
}
|
||||
} catch { /* table may not have these columns */ }
|
||||
}
|
||||
|
||||
// Flag the order as disputed
|
||||
for (const table of ["compliance_orders", "canada_crtc_orders", "formation_orders", "bundle_orders"]) {
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE ${table} SET payment_status = 'disputed', notes = COALESCE(notes, '') || $2
|
||||
WHERE order_number = $1`,
|
||||
[orderId, `\nACH dispute: ${reason} — $${(amountCents / 100).toFixed(2)} (${new Date().toISOString()})`],
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Alert admin — this is urgent
|
||||
await sendEmail({
|
||||
to: ADMIN_EMAIL,
|
||||
subject: `🚨 ACH Return/Dispute — $${(amountCents / 100).toFixed(2)} — ${orderId}`,
|
||||
html: `<h2>ACH Payment Returned</h2>
|
||||
<p><strong>Order:</strong> ${orderId}</p>
|
||||
<p><strong>Amount:</strong> $${(amountCents / 100).toFixed(2)}</p>
|
||||
<p><strong>Reason:</strong> ${reason}</p>
|
||||
<p><strong>Stripe Payment Intent:</strong> ${paymentIntentId}</p>
|
||||
<p><strong>Time:</strong> ${new Date().toISOString()}</p>
|
||||
<p><strong>Action required:</strong> If documents were already delivered for this order,
|
||||
determine whether to request payment via alternative method or write off the loss.
|
||||
Check Stripe Dashboard for dispute details and evidence submission deadline.</p>`,
|
||||
});
|
||||
|
||||
console.warn(`[ach-dispute] Alerted admin: ${orderId} — ${reason} — $${(amountCents / 100).toFixed(2)}`);
|
||||
} catch (err) {
|
||||
console.error("[ach-dispute] Handler error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBalanceAvailable(): Promise<void> {
|
||||
try {
|
||||
// Find CRTC orders that are paid via Stripe but funds not yet marked available
|
||||
const { rows } = await pool.query(`
|
||||
SELECT order_number, payment_method, paid_at, total_cents,
|
||||
amb_annual_price_cents, funds_available
|
||||
FROM canada_crtc_orders
|
||||
WHERE payment_status = 'paid'
|
||||
AND funds_available = FALSE
|
||||
AND payment_method IN ('card', 'ach', 'klarna')
|
||||
AND paid_at IS NOT NULL
|
||||
ORDER BY paid_at ASC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
if (!rows.length) {
|
||||
console.log("[balance.available] No pending CRTC orders awaiting fund settlement");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let advancedCount = 0;
|
||||
|
||||
for (const order of rows) {
|
||||
const paidAt = new Date(order.paid_at as string);
|
||||
const method = order.payment_method as string;
|
||||
const orderId = order.order_number as string;
|
||||
|
||||
// Calculate business days elapsed since payment
|
||||
let bizDays = 0;
|
||||
const d = new Date(paidAt);
|
||||
while (d < now) {
|
||||
d.setDate(d.getDate() + 1);
|
||||
const dow = d.getDay();
|
||||
if (dow !== 0 && dow !== 6) bizDays++;
|
||||
}
|
||||
|
||||
// Settlement timing thresholds
|
||||
const requiredDays = method === "ach" ? 4 : 2; // ACH=T+4, card/klarna=T+2
|
||||
|
||||
if (bizDays >= requiredDays) {
|
||||
console.log(`[balance.available] Order ${orderId} (${method}): ${bizDays} biz days since payment — advancing`);
|
||||
try {
|
||||
await advanceToClientSelection(orderId);
|
||||
advancedCount++;
|
||||
} catch (err) {
|
||||
console.error(`[balance.available] Failed to advance ${orderId}:`, err);
|
||||
}
|
||||
} else {
|
||||
console.log(`[balance.available] Order ${orderId} (${method}): ${bizDays}/${requiredDays} biz days — not yet`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[balance.available] Processed: ${advancedCount} orders advanced to Client Selection`);
|
||||
|
||||
} catch (err) {
|
||||
console.error("[balance.available] Handler error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Register in the main Stripe webhook dispatcher
|
||||
// The balance.available event is added to the switch in section 1.
|
||||
// Also expose for direct invocation (e.g. cron fallback).
|
||||
export { handleBalanceAvailable };
|
||||
|
||||
export default router;
|
||||
364
api/src/sanctions.ts
Normal file
364
api/src/sanctions.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue