Wire fulfillment alerts to Telegram + surface order progress in portal + even out ERPNext sync
Telegram notifications: - Add shared scripts/workers/telegram_notify.py (send_telegram, notify_fulfillment_todo, create_admin_todo) so every worker alerts the operator the same way; fire-and-forget. - Fire notify_fulfillment_todo after each admin_todos insert across all 8 service handlers (9 sites) so no fulfillment task waits unseen. (Orders + quotes + tickets already notified via checkout/quotes/tickets routes.) Client portal order progress: - order-timeline: derive real per-step status from live signals (payment paid, e-signature signed, fulfillment_status) instead of a static template; add current_step to the response. - Extract pure applyLiveStatus into order-timeline-status.ts (DB-free) + unit test (api/test/test_timeline_status.ts, 8 cases). - portal /me now returns compliance_orders.fulfillment_status. - Dashboard renders a client-safe Progress badge (In progress / Action needed / Filed-awaiting-confirmation / Completed); batches show the most actionable status. No back-office mechanics exposed. ERPNext sync parity: - Create a Sales Order for formation and fcc_carrier_registration orders (previously only canada_crtc + compliance synced); write erpnext_sales_order back to each table. Non-blocking, matches existing pattern. Verified: API tsc clean, timeline unit tests 8/8, Astro build 58 pages, cms10114/ink/paper_batch Python tests still green, no mechanics leaks.
This commit is contained in:
parent
41df4d9553
commit
28b1af341d
15 changed files with 706 additions and 73 deletions
|
|
@ -93,6 +93,7 @@ async function ensureColumns(): Promise<void> {
|
|||
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`);
|
||||
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment'`);
|
||||
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS erpnext_sales_order TEXT`);
|
||||
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`);
|
||||
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_method TEXT`);
|
||||
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_pct NUMERIC`);
|
||||
|
|
@ -965,6 +966,115 @@ router.post("/api/v1/checkout/create-session", async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Create ERPNext Sales Order (US business formation) ──────────────────
|
||||
if (order_type === "formation" && erpnextCustomer) {
|
||||
try {
|
||||
const pgOrder = orderData.order as Record<string, any>;
|
||||
const entityType = (pgOrder.entity_type as string) || "LLC";
|
||||
const stateCode = (pgOrder.state_code as string) || "";
|
||||
const stateFeeCents = (pgOrder.state_fee_cents as number) || 0;
|
||||
|
||||
const so = (await createResource("Sales Order", {
|
||||
customer: erpnextCustomer,
|
||||
delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
|
||||
custom_external_order_id: order_id,
|
||||
custom_order_type: "formation",
|
||||
custom_payment_gateway: GATEWAY_LABELS[payment_method] || payment_method,
|
||||
custom_surcharge_pct: surcharge_pct,
|
||||
workflow_state: "Received",
|
||||
items: [{
|
||||
item_code: "BUSINESS-FORMATION",
|
||||
description: `${entityType} Formation${stateCode ? ` — ${stateCode}` : ""}`,
|
||||
qty: 1,
|
||||
rate: toDollars((pgOrder.service_fee_cents as number) || base_cents - stateFeeCents - surcharge_cents),
|
||||
}, ...(stateFeeCents > 0 ? [{
|
||||
item_code: "STATE-FILING-FEE",
|
||||
description: `${stateCode} state filing fee (government fee)`,
|
||||
qty: 1,
|
||||
rate: toDollars(stateFeeCents),
|
||||
}] : []), ...(surcharge_cents > 0 ? [{
|
||||
item_code: "PAYMENT-PROCESSING-FEE",
|
||||
description: `${GATEWAY_LABELS[payment_method] || payment_method} ${surcharge_pct}%`,
|
||||
qty: 1,
|
||||
rate: toDollars(surcharge_cents),
|
||||
}] : [])],
|
||||
})) as { name: string };
|
||||
|
||||
try {
|
||||
await callMethod("frappe.client.submit", { doc: { doctype: "Sales Order", name: so.name } });
|
||||
} catch { /* submit may fail if workflow doesn't require it */ }
|
||||
|
||||
await pool.query(
|
||||
`UPDATE formation_orders SET erpnext_sales_order = $1 WHERE order_number = $2`,
|
||||
[so.name, order_id],
|
||||
);
|
||||
|
||||
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for formation ${order_id}`);
|
||||
} catch (soErr) {
|
||||
console.warn("[checkout] Formation Sales Order creation failed (non-blocking):", soErr);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create ERPNext Sales Order (FCC carrier / ISP registration) ─────────
|
||||
if (order_type === "fcc_carrier_registration" && erpnextCustomer) {
|
||||
try {
|
||||
const pgOrder = orderData.order as Record<string, any>;
|
||||
const items: Array<Record<string, unknown>> = [{
|
||||
item_code: "FCC-CARRIER-REGISTRATION",
|
||||
description: "FCC Carrier / ISP Registration",
|
||||
qty: 1,
|
||||
rate: toDollars((pgOrder.service_fee_cents as number) || 129900),
|
||||
}];
|
||||
const formationFee = ((pgOrder.formation_fee_cents as number) || 0) + ((pgOrder.state_fee_cents as number) || 0);
|
||||
if (formationFee > 0) {
|
||||
items.push({
|
||||
item_code: "BUSINESS-FORMATION",
|
||||
description: `Business Formation (${pgOrder.formation_state || "?"} ${((pgOrder.entity_type as string) || "LLC").toUpperCase()})`,
|
||||
qty: 1,
|
||||
rate: toDollars(formationFee),
|
||||
});
|
||||
}
|
||||
if (pgOrder.include_stir_shaken) {
|
||||
items.push({ item_code: "STIR-SHAKEN", description: "STIR/SHAKEN Implementation", qty: 1, rate: toDollars(49900) });
|
||||
}
|
||||
if (pgOrder.include_ocn) {
|
||||
items.push({ item_code: "NECA-OCN", description: "NECA OCN Registration", qty: 1, rate: toDollars(265000) });
|
||||
}
|
||||
const pucCents = (pgOrder.puc_fee_cents as number) || 0;
|
||||
if (pucCents > 0) {
|
||||
const stateCount = ((pgOrder.state_puc_states as string[]) || []).length;
|
||||
items.push({ item_code: "STATE-PUC-REGISTRATION", description: `State PUC Registration (${stateCount} state${stateCount !== 1 ? "s" : ""})`, qty: 1, rate: toDollars(pucCents) });
|
||||
}
|
||||
if (surcharge_cents > 0) {
|
||||
items.push({ item_code: "PAYMENT-PROCESSING-FEE", description: `${GATEWAY_LABELS[payment_method] || payment_method} ${surcharge_pct}%`, qty: 1, rate: toDollars(surcharge_cents) });
|
||||
}
|
||||
|
||||
const so = (await createResource("Sales Order", {
|
||||
customer: erpnextCustomer,
|
||||
delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
|
||||
custom_external_order_id: order_id,
|
||||
custom_order_type: "fcc_carrier_registration",
|
||||
custom_payment_gateway: GATEWAY_LABELS[payment_method] || payment_method,
|
||||
custom_surcharge_pct: surcharge_pct,
|
||||
workflow_state: "Received",
|
||||
items,
|
||||
})) as { name: string };
|
||||
|
||||
try {
|
||||
await callMethod("frappe.client.submit", { doc: { doctype: "Sales Order", name: so.name } });
|
||||
} catch { /* submit may fail if workflow doesn't require it */ }
|
||||
|
||||
await pool.query(
|
||||
`UPDATE fcc_carrier_registrations SET erpnext_sales_order = $1 WHERE order_number = $2`,
|
||||
[so.name, order_id],
|
||||
);
|
||||
|
||||
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for fcc_carrier_registration ${order_id}`);
|
||||
} catch (soErr) {
|
||||
console.warn("[checkout] FCC Sales Order creation failed (non-blocking):", soErr);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create Stripe Checkout Session ─────────────────────────────────────
|
||||
const STRIPE_PAYMENT_METHOD_MAP: Record<string, Stripe.Checkout.SessionCreateParams.PaymentMethodType[]> = {
|
||||
card: ["card"],
|
||||
|
|
|
|||
72
api/src/routes/order-timeline-status.ts
Normal file
72
api/src/routes/order-timeline-status.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Pure live-progress derivation for the order timeline.
|
||||
*
|
||||
* Kept free of DB/config imports so it can be unit-tested in isolation and
|
||||
* reused. The route (order-timeline.ts) imports applyLiveStatus from here.
|
||||
*
|
||||
* We translate real signals (payment, e-signature, fulfillment_status) into
|
||||
* per-step "completed | in_progress | pending" so the client portal shows true
|
||||
* progress rather than a static template.
|
||||
*/
|
||||
|
||||
/** Step-name matchers used to find phase boundaries within any timeline. */
|
||||
export const SIGNATURE_STEP_RE = /signature|e-?sign/i;
|
||||
export const FILED_STEP_RE = /filed|filing|submitted|application filed|registration/i;
|
||||
|
||||
// fulfillment_status values that mean "the filing has been submitted to the agency".
|
||||
export const FILED_STATUSES = new Set<string>([
|
||||
"filed_waiting_state",
|
||||
"ready_to_file", // queued for ops to file — treat prep+signature as done
|
||||
]);
|
||||
// fulfillment_status values that mean "fully done".
|
||||
export const COMPLETED_STATUSES = new Set<string>(["completed", "complete"]);
|
||||
|
||||
export interface ProgressSignals {
|
||||
paid: boolean;
|
||||
signed: boolean;
|
||||
fulfillmentStatus: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute each step's status from live signals. Returns a new step array.
|
||||
*
|
||||
* We resolve a "reached index" — the index of the furthest step we can prove is
|
||||
* done — then mark everything up to it completed, the next in_progress, the
|
||||
* rest pending. We never regress a step that the static definition already
|
||||
* marked completed.
|
||||
*/
|
||||
export function applyLiveStatus<T extends { name: string; status: string }>(
|
||||
steps: T[],
|
||||
sig: ProgressSignals,
|
||||
): T[] {
|
||||
const sigIdx = steps.findIndex((s) => SIGNATURE_STEP_RE.test(s.name));
|
||||
const filedIdx = steps.findIndex((s) => FILED_STEP_RE.test(s.name));
|
||||
const confirmIdx = steps.length - 1; // last step is always the terminal one
|
||||
|
||||
let reached = -1;
|
||||
|
||||
// Payment confirmed → at minimum "Order Received" (index 0) is done.
|
||||
if (sig.paid) reached = Math.max(reached, 0);
|
||||
|
||||
// Signature captured → the signature step (and everything before it) is done.
|
||||
if (sig.signed && sigIdx >= 0) reached = Math.max(reached, sigIdx);
|
||||
|
||||
// Filing submitted to agency → the "Filed" step (and everything before) done.
|
||||
if (sig.fulfillmentStatus && FILED_STATUSES.has(sig.fulfillmentStatus) && filedIdx >= 0) {
|
||||
reached = Math.max(reached, filedIdx);
|
||||
}
|
||||
|
||||
// Fully completed → everything done.
|
||||
if (sig.fulfillmentStatus && COMPLETED_STATUSES.has(sig.fulfillmentStatus)) {
|
||||
reached = confirmIdx;
|
||||
}
|
||||
|
||||
return steps.map((step, i) => {
|
||||
const staticallyDone = step.status === "completed";
|
||||
let status: "pending" | "in_progress" | "completed";
|
||||
if (i <= reached || staticallyDone) status = "completed";
|
||||
else if (i === reached + 1) status = "in_progress";
|
||||
else status = "pending";
|
||||
return { ...step, status };
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { pool } from "../db.js";
|
||||
import { applyLiveStatus } from "./order-timeline-status.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -163,32 +164,65 @@ const WET_SIGNATURE_SLUGS = new Set<string>([
|
|||
"provider-compliance-bundle",
|
||||
]);
|
||||
|
||||
// ── Live-progress derivation ──────────────────────────────────────────────
|
||||
// applyLiveStatus (pure, DB-free, unit-tested in order-timeline-status.ts)
|
||||
// overrides each step's static status from real signals (payment, e-signature,
|
||||
// fulfillment_status) so the client portal shows true progress.
|
||||
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>[];
|
||||
} 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<string, unknown>[];
|
||||
// Try single order first, then batch. We select fulfillment_status when
|
||||
// present (column may not exist on older DBs — fall back gracefully).
|
||||
const COLS = "order_number, service_slug, service_name, created_at, payment_status, fulfillment_status";
|
||||
const COLS_FALLBACK = "order_number, service_slug, service_name, created_at, payment_status";
|
||||
|
||||
async function loadOrders(): Promise<Record<string, unknown>[]> {
|
||||
const trySelect = async (cols: string) => {
|
||||
const single = await pool.query(
|
||||
`SELECT ${cols} FROM compliance_orders WHERE order_number = $1`,
|
||||
[orderId],
|
||||
);
|
||||
if (single.rows.length > 0) return single.rows as Record<string, unknown>[];
|
||||
const batch = await pool.query(
|
||||
`SELECT ${cols} FROM compliance_orders WHERE batch_id = $1 ORDER BY created_at`,
|
||||
[orderId],
|
||||
);
|
||||
return batch.rows as Record<string, unknown>[];
|
||||
};
|
||||
try {
|
||||
return await trySelect(COLS);
|
||||
} catch {
|
||||
// fulfillment_status column not present yet — retry without it.
|
||||
return await trySelect(COLS_FALLBACK);
|
||||
}
|
||||
}
|
||||
|
||||
const orders = await loadOrders();
|
||||
|
||||
if (orders.length === 0) {
|
||||
res.status(404).json({ error: "Order not found." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Which of these orders have a signed e-signature on file? One query for
|
||||
// all order_numbers in the batch. Defensive: a missing table must not break
|
||||
// the timeline (clients still see estimated dates).
|
||||
const orderNumbers = orders.map((o) => o.order_number as string);
|
||||
const signedSet = new Set<string>();
|
||||
try {
|
||||
const sig = await pool.query(
|
||||
"SELECT DISTINCT order_number FROM esign_records WHERE order_number = ANY($1) AND status = 'signed'",
|
||||
[orderNumbers],
|
||||
);
|
||||
for (const r of sig.rows as Record<string, unknown>[]) {
|
||||
signedSet.add(r.order_number as string);
|
||||
}
|
||||
} catch {
|
||||
// esign_records absent — treat all as unsigned.
|
||||
}
|
||||
|
||||
const timelines = orders.map((order) => {
|
||||
const slug = order.service_slug as string;
|
||||
const startDate = new Date(order.created_at as string);
|
||||
|
|
@ -196,7 +230,7 @@ router.get("/api/v1/order-timeline/:order_id", async (req: Request, res: Respons
|
|||
// onward, so we always have time to produce + mail the original ink form.
|
||||
const isWetSig = WET_SIGNATURE_SLUGS.has(slug);
|
||||
let pastSignature = false;
|
||||
const steps = (SERVICE_TIMELINES[slug] || DEFAULT_TIMELINE).map((step) => {
|
||||
const dated = (SERVICE_TIMELINES[slug] || DEFAULT_TIMELINE).map((step) => {
|
||||
if (isWetSig && /signature/i.test(step.name)) {
|
||||
pastSignature = true;
|
||||
}
|
||||
|
|
@ -209,11 +243,25 @@ router.get("/api/v1/order-timeline/:order_id", async (req: Request, res: Respons
|
|||
};
|
||||
});
|
||||
|
||||
// Overlay live progress so the portal reflects reality, not just dates.
|
||||
const paymentStatus = String(order.payment_status || "").toLowerCase();
|
||||
const steps = applyLiveStatus(dated, {
|
||||
paid: paymentStatus === "paid" || paymentStatus === "completed",
|
||||
signed: signedSet.has(order.order_number as string),
|
||||
fulfillmentStatus: (order.fulfillment_status as string) || null,
|
||||
});
|
||||
|
||||
// The current step is the first in_progress one, else the last completed.
|
||||
const inProgress = steps.find((s) => s.status === "in_progress");
|
||||
const lastCompleted = [...steps].reverse().find((s) => s.status === "completed");
|
||||
const currentStep = (inProgress || lastCompleted || steps[0]).name;
|
||||
|
||||
return {
|
||||
order_number: order.order_number,
|
||||
service_slug: slug,
|
||||
service_name: order.service_name,
|
||||
steps,
|
||||
current_step: currentStep,
|
||||
estimated_completion: steps[steps.length - 1].estimated_date,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ router.get("/me", async (req: Request, res: Response) => {
|
|||
`SELECT order_number, batch_id, service_slug, service_name,
|
||||
service_fee_cents, discount_cents, discount_code,
|
||||
surcharge_cents, payment_status, payment_method,
|
||||
fulfillment_status,
|
||||
created_at, paid_at
|
||||
FROM compliance_orders
|
||||
WHERE customer_email = $1
|
||||
|
|
|
|||
92
api/test/test_timeline_status.ts
Normal file
92
api/test/test_timeline_status.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Verifies applyLiveStatus: the live-progress overlay used by the client
|
||||
* portal order timeline. Run: npx tsx api/test/test_timeline_status.ts
|
||||
*/
|
||||
import { applyLiveStatus } from "../src/routes/order-timeline-status.js";
|
||||
|
||||
type Step = { name: string; description: string; business_days: number; status: string };
|
||||
|
||||
const npiSteps: Step[] = [
|
||||
{ name: "Order Received", description: "", business_days: 0, status: "completed" },
|
||||
{ name: "Document Preparation", description: "", business_days: 1, status: "pending" },
|
||||
{ name: "Signature Required", description: "", business_days: 1, status: "pending" },
|
||||
{ name: "Filed with CMS", description: "", business_days: 2, status: "pending" },
|
||||
{ name: "CMS Confirmation", description: "", business_days: 10, status: "pending" },
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
function check(label: string, got: string[], want: string[]) {
|
||||
const ok = JSON.stringify(got) === JSON.stringify(want);
|
||||
if (!ok) {
|
||||
failures++;
|
||||
console.error(`FAIL: ${label}\n got: ${got.join(",")}\n want: ${want.join(",")}`);
|
||||
} else {
|
||||
console.log(`ok: ${label}`);
|
||||
}
|
||||
}
|
||||
const statuses = (s: Step[]) => s.map((x) => x.status);
|
||||
|
||||
// 1. Unpaid, nothing reached → only the statically-completed step 0 is done.
|
||||
check(
|
||||
"unpaid",
|
||||
statuses(applyLiveStatus(npiSteps, { paid: false, signed: false, fulfillmentStatus: null })),
|
||||
["completed", "pending", "pending", "pending", "pending"],
|
||||
);
|
||||
|
||||
// 2. Paid only → Order Received done, prep in progress
|
||||
check(
|
||||
"paid only",
|
||||
statuses(applyLiveStatus(npiSteps, { paid: true, signed: false, fulfillmentStatus: null })),
|
||||
["completed", "in_progress", "pending", "pending", "pending"],
|
||||
);
|
||||
|
||||
// 3. Paid + signed → through Signature done, Filed in progress
|
||||
check(
|
||||
"paid+signed",
|
||||
statuses(applyLiveStatus(npiSteps, { paid: true, signed: true, fulfillmentStatus: null })),
|
||||
["completed", "completed", "completed", "in_progress", "pending"],
|
||||
);
|
||||
|
||||
// 4. Paid + signed + filed → through Filed done, Confirmation in progress
|
||||
check(
|
||||
"paid+signed+filed",
|
||||
statuses(applyLiveStatus(npiSteps, { paid: true, signed: true, fulfillmentStatus: "filed_waiting_state" })),
|
||||
["completed", "completed", "completed", "completed", "in_progress"],
|
||||
);
|
||||
|
||||
// 5. Completed → everything done
|
||||
check(
|
||||
"completed",
|
||||
statuses(applyLiveStatus(npiSteps, { paid: true, signed: true, fulfillmentStatus: "completed" })),
|
||||
["completed", "completed", "completed", "completed", "completed"],
|
||||
);
|
||||
|
||||
// 6. ready_to_file without explicit signed → still advances to Filed step
|
||||
check(
|
||||
"ready_to_file",
|
||||
statuses(applyLiveStatus(npiSteps, { paid: true, signed: false, fulfillmentStatus: "ready_to_file" })),
|
||||
["completed", "completed", "completed", "completed", "in_progress"],
|
||||
);
|
||||
|
||||
// 7. A timeline with no signature step (e.g. boc3) still works off paid + fulfillment
|
||||
const boc3: Step[] = [
|
||||
{ name: "Order Received", description: "", business_days: 0, status: "completed" },
|
||||
{ name: "Process Agent Filing", description: "", business_days: 1, status: "pending" },
|
||||
{ name: "FMCSA Registration", description: "", business_days: 3, status: "pending" },
|
||||
];
|
||||
check(
|
||||
"boc3 paid",
|
||||
statuses(applyLiveStatus(boc3, { paid: true, signed: false, fulfillmentStatus: null })),
|
||||
["completed", "in_progress", "pending"],
|
||||
);
|
||||
check(
|
||||
"boc3 filed",
|
||||
statuses(applyLiveStatus(boc3, { paid: true, signed: false, fulfillmentStatus: "filed_waiting_state" })),
|
||||
["completed", "completed", "in_progress"], // "Process Agent Filing" matches FILED_STEP_RE
|
||||
);
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} test(s) failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nAll timeline status tests passed");
|
||||
Loading…
Add table
Add a link
Reference in a new issue