diff --git a/api/src/index.ts b/api/src/index.ts index 439b412..d3066b3 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -48,6 +48,7 @@ import pucRouter from "./routes/puc.js"; import fccCarrierRegRouter from "./routes/fcc-carrier-registration.js"; import dotLookupRouter from "./routes/dot-lookup.js"; import surveyRouter from "./routes/survey.js"; +import orderTimelineRouter from "./routes/order-timeline.js"; const app = express(); @@ -122,6 +123,7 @@ app.use(pucRouter); app.use(fccCarrierRegRouter); app.use(dotLookupRouter); app.use(surveyRouter); +app.use(orderTimelineRouter); 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() diff --git a/api/src/routes/order-timeline.ts b/api/src/routes/order-timeline.ts new file mode 100644 index 0000000..fbdf18b --- /dev/null +++ b/api/src/routes/order-timeline.ts @@ -0,0 +1,160 @@ +/** + * Order timeline estimator — shows step names + estimated completion dates. + * + * GET /api/v1/order-timeline/:order_id + * + * Returns a timeline of steps with estimated business-day completion dates, + * skipping weekends and US federal holidays. + */ + +import { Router, type Request, type Response } from "express"; +import { pool } from "../db.js"; + +const router = Router(); + +// US Federal Holidays — hardcoded for 2026-2027 (matches scripts/workers/business_days.py) +const US_HOLIDAYS = new Set([ + "2026-01-01","2026-01-19","2026-02-16","2026-05-25","2026-07-03", + "2026-09-07","2026-10-12","2026-11-11","2026-11-26","2026-12-25", + "2027-01-01","2027-01-18","2027-02-15","2027-05-31","2027-07-05", + "2027-09-06","2027-10-11","2027-11-11","2027-11-25","2027-12-25", +]); + +function addBusinessDays(start: Date, days: number): Date { + if (days === 0) return new Date(start); + const result = new Date(start); + let added = 0; + while (added < days) { + result.setDate(result.getDate() + 1); + const dow = result.getDay(); + const dateStr = result.toISOString().split("T")[0]; + if (dow !== 0 && dow !== 6 && !US_HOLIDAYS.has(dateStr)) { + added++; + } + } + return result; +} + +// Service-specific step definitions with business day estimates +interface TimelineStep { + name: string; + description: string; + business_days: number; // days from order start + status: "pending" | "in_progress" | "completed"; +} + +const SERVICE_TIMELINES: Record = { + "mcs150-update": [ + { name: "Order Received", description: "Payment confirmed, order in queue", business_days: 0, status: "completed" }, + { name: "Document Preparation", description: "Filling official MCS-150 form with your information", business_days: 1, status: "pending" }, + { name: "E-Sign Required", description: "Review and sign the perjury declaration", business_days: 1, status: "pending" }, + { name: "Filed with FMCSA", description: "Submitted electronically to FMCSA", business_days: 2, status: "pending" }, + { name: "Certificate of Filing", description: "Attestation document delivered to you", business_days: 2, status: "pending" }, + { name: "FMCSA Confirmation", description: "FMCSA processes and reflects update (5-10 business days)", business_days: 10, status: "pending" }, + ], + "boc3-filing": [ + { name: "Order Received", description: "Payment confirmed", business_days: 0, status: "completed" }, + { name: "Process Agent Filing", description: "Filing BOC-3 with Registered Agents Inc", business_days: 1, status: "pending" }, + { name: "FMCSA Registration", description: "BOC-3 on file with FMCSA", business_days: 3, status: "pending" }, + ], + "ucr-registration": [ + { name: "Order Received", description: "Payment confirmed", business_days: 0, status: "completed" }, + { name: "UCR Filing", description: "Registering with Unified Carrier Registration", business_days: 2, status: "pending" }, + { name: "Confirmation", description: "UCR registration receipt delivered", business_days: 3, status: "pending" }, + ], + "dot-registration": [ + { name: "Order Received", description: "Payment confirmed", business_days: 0, status: "completed" }, + { name: "Application Preparation", description: "Preparing USDOT application", business_days: 1, status: "pending" }, + { name: "Filed with FMCSA", description: "Application submitted to FMCSA", business_days: 2, status: "pending" }, + { name: "USDOT Issued", description: "FMCSA issues your USDOT number", business_days: 5, status: "pending" }, + ], + "mc-authority": [ + { name: "Order Received", description: "Payment confirmed", business_days: 0, status: "completed" }, + { name: "Application Filed", description: "Operating authority application submitted", business_days: 2, status: "pending" }, + { name: "Insurance Filing Required", description: "Insurance must be on file before authority activates", business_days: 5, status: "pending" }, + { name: "Authority Active", description: "Operating authority becomes active", business_days: 15, status: "pending" }, + ], + "dot-drug-alcohol": [ + { name: "Order Received", description: "Payment confirmed", business_days: 0, status: "completed" }, + { name: "Program Setup", description: "Enrolling in DOT-compliant D&A testing program", business_days: 2, status: "pending" }, + { name: "Materials Delivered", description: "Policy manual and enrollment confirmation", business_days: 3, status: "pending" }, + ], + "usdot-reactivation": [ + { name: "Order Received", description: "Payment confirmed", business_days: 0, status: "completed" }, + { name: "Reactivation Filed", description: "Request submitted to FMCSA", business_days: 1, status: "pending" }, + { name: "USDOT Reactivated", description: "FMCSA reactivates your USDOT number", business_days: 5, status: "pending" }, + ], + "emergency-temporary-authority": [ + { name: "Order Received", description: "Payment confirmed — PRIORITY processing", business_days: 0, status: "completed" }, + { name: "ETA Filed", description: "Emergency request submitted to FMCSA", business_days: 0, status: "pending" }, + { name: "FMCSA Response", description: "Awaiting FMCSA emergency authorization", business_days: 2, status: "pending" }, + ], + "entity-upgrade-bundle": [ + { name: "Order Received", description: "Payment confirmed", business_days: 0, status: "completed" }, + { name: "LLC Formation", description: "Filing LLC in your chosen state", business_days: 3, status: "pending" }, + { name: "EIN Application", description: "Applying for employer ID number with IRS", business_days: 4, status: "pending" }, + { name: "MCS-150 Update", description: "Updating FMCSA registration with new entity", business_days: 5, status: "pending" }, + { name: "Authority Transfer", description: "Transferring operating authority to new entity", business_days: 7, status: "pending" }, + { name: "BOC-3 Update", description: "Updating process agent filing", business_days: 8, status: "pending" }, + { name: "Complete", description: "All filings updated under your new entity", business_days: 10, status: "pending" }, + ], +}; + +// Default timeline for services without a specific definition +const DEFAULT_TIMELINE: TimelineStep[] = [ + { name: "Order Received", description: "Payment confirmed, order in queue", business_days: 0, status: "completed" }, + { name: "Processing", description: "Our team is working on your filing", business_days: 2, status: "pending" }, + { name: "Complete", description: "Filing completed and delivered", business_days: 5, status: "pending" }, +]; + +router.get("/api/v1/order-timeline/:order_id", async (req: Request, res: Response) => { + try { + const orderId = req.params.order_id; + + // Try single order first, then batch + let orders: Record[] = []; + const single = await pool.query( + "SELECT order_number, service_slug, service_name, created_at, payment_status FROM compliance_orders WHERE order_number = $1", + [orderId], + ); + if (single.rows.length > 0) { + orders = single.rows as Record[]; + } else { + // Try as batch_id + const batch = await pool.query( + "SELECT order_number, service_slug, service_name, created_at, payment_status FROM compliance_orders WHERE batch_id = $1 ORDER BY created_at", + [orderId], + ); + orders = batch.rows as Record[]; + } + + if (orders.length === 0) { + res.status(404).json({ error: "Order not found." }); + return; + } + + const timelines = orders.map((order) => { + const slug = order.service_slug as string; + const startDate = new Date(order.created_at as string); + const steps = (SERVICE_TIMELINES[slug] || DEFAULT_TIMELINE).map((step) => ({ + ...step, + estimated_date: addBusinessDays(startDate, step.business_days).toISOString().split("T")[0], + })); + + return { + order_number: order.order_number, + service_slug: slug, + service_name: order.service_name, + steps, + estimated_completion: steps[steps.length - 1].estimated_date, + }; + }); + + res.json({ timelines }); + } catch (err) { + console.error("[timeline] Error:", err); + res.status(500).json({ error: "Could not generate timeline." }); + } +}); + +export default router;