ifta: 3-touch business-day cadence + 'I already filed it' suppression
- Multi-touch reminders at 10/7/4 BUSINESS days before each deadline (weekends skipped; biz-day math so a touch never lands purely on a weekend with no runway). Escalating tone soft -> urgent -> last-chance, with the 'almost too late to DIY, we can still file it' angle so it's a convenience sale, not a free reminder service. ifta_touch_no tracks the highest touch sent so each touch hits only carriers below that level; never repeats a touch. - 'I already filed it' one-click link: HMAC-tokenized GET /api/v1/ifta/filed (token matches between Python builder and api/src/routes/ifta.ts -- verified identical output), records ifta_self_filed_at, friendly confirmation page, stops further touches this cycle + gives DIY-vs-prospect signal. Builder excludes self-filed carriers. - migration 094 (ifta_touch_no) + 095 (ifta_self_filed_at); cycle reset clears both each new quarter. Verified: biz-day touch schedule, token cross-match.
This commit is contained in:
parent
872154ebf7
commit
3d4226e95c
6 changed files with 220 additions and 40 deletions
|
|
@ -1,11 +1,13 @@
|
|||
-- Track which interstate carriers have been sent the IFTA quarterly-return
|
||||
-- reminder this cycle, so the daily IFTA cron never double-sends within a quarter.
|
||||
-- The IFTA campaign builder resets this column at the start of each new quarter's
|
||||
-- reminder window (see build_ifta_quarterly_campaign.py).
|
||||
-- Track IFTA quarterly-return reminder touches per interstate carrier so the
|
||||
-- multi-touch cadence (10/7/4 business days before deadline) never repeats a
|
||||
-- touch and escalates correctly. Reset each new quarter by the IFTA builder.
|
||||
-- ifta_reminded_at : timestamp of the most recent IFTA touch (any)
|
||||
-- ifta_touch_no : highest touch number sent this cycle (1=10d, 2=7d, 3=4d)
|
||||
|
||||
ALTER TABLE fmcsa_carriers
|
||||
ADD COLUMN IF NOT EXISTS ifta_reminded_at TIMESTAMPTZ;
|
||||
ADD COLUMN IF NOT EXISTS ifta_reminded_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS ifta_touch_no SMALLINT;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fmcsa_carriers_ifta_reminded
|
||||
ON fmcsa_carriers (ifta_reminded_at)
|
||||
WHERE ifta_reminded_at IS NULL;
|
||||
ON fmcsa_carriers (ifta_touch_no)
|
||||
WHERE carrier_operation = 'A';
|
||||
|
|
|
|||
12
api/migrations/095_fmcsa_ifta_self_filed.sql
Normal file
12
api/migrations/095_fmcsa_ifta_self_filed.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- "I already filed it" suppression for IFTA quarterly reminders.
|
||||
-- When a carrier clicks the one-click "I already filed it" link in a reminder
|
||||
-- email, we record it here: it stops further touches THIS cycle (the IFTA
|
||||
-- builder excludes self-filed carriers) and gives us DIY-vs-prospect signal.
|
||||
-- Reset each new quarter alongside ifta_reminded_at.
|
||||
|
||||
ALTER TABLE fmcsa_carriers
|
||||
ADD COLUMN IF NOT EXISTS ifta_self_filed_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fmcsa_carriers_ifta_self_filed
|
||||
ON fmcsa_carriers (ifta_self_filed_at)
|
||||
WHERE ifta_self_filed_at IS NULL;
|
||||
|
|
@ -14,6 +14,7 @@ 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 iftaRouter from "./routes/ifta.js";
|
||||
import adminRouter from "./routes/admin.js";
|
||||
import webhooksRouter from "./routes/webhooks.js";
|
||||
import identityRouter from "./routes/identity.js";
|
||||
|
|
@ -92,6 +93,7 @@ app.use(ticketsRouter);
|
|||
app.use(quotesRouter);
|
||||
app.use(formationsRouter);
|
||||
app.use(discountsRouter);
|
||||
app.use(iftaRouter);
|
||||
app.use(adminRouter);
|
||||
app.use(webhooksRouter);
|
||||
app.use(refundsRouter);
|
||||
|
|
|
|||
85
api/src/routes/ifta.ts
Normal file
85
api/src/routes/ifta.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { Router } from "express";
|
||||
import crypto from "crypto";
|
||||
import { pool } from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// HMAC secret for the one-click "I already filed it" link. Reuses an existing
|
||||
// server secret so the token is unguessable but verifiable without DB state.
|
||||
const SECRET = process.env.ADMIN_JWT_SECRET
|
||||
|| process.env.CUSTOMER_JWT_SECRET
|
||||
|| process.env.APPROVE_FILE_TOKEN
|
||||
|| "pw-ifta-filed-fallback-secret";
|
||||
|
||||
/** Deterministic token for a DOT number (so the email can embed it, and we can
|
||||
* verify it on click without storing per-link state). */
|
||||
export function iftaFiledToken(dot: string): string {
|
||||
return crypto.createHmac("sha256", SECRET)
|
||||
.update(`ifta-filed:${dot}`)
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
function page(title: string, body: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title}</title></head>
|
||||
<body style="margin:0;font-family:-apple-system,system-ui,sans-serif;background:#eef0f3">
|
||||
<div style="max-width:520px;margin:48px auto;background:#fff;border-radius:12px;padding:32px;text-align:center;box-shadow:0 10px 30px rgba(0,0,0,.08)">
|
||||
<img src="https://performancewest.net/images/logo.png" alt="Performance West" style="height:40px;margin-bottom:16px">
|
||||
${body}
|
||||
<p style="margin-top:24px;font-size:12px;color:#94a3b8">Performance West Inc. · (888) 411-0383</p>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click "I already filed it" for IFTA quarterly reminders.
|
||||
* GET /api/v1/ifta/filed?dot=1234567&t=<token>
|
||||
* Records the suppression (stops further touches this cycle) + gives us
|
||||
* DIY-vs-prospect signal. Idempotent.
|
||||
*/
|
||||
router.get("/api/v1/ifta/filed", async (req, res) => {
|
||||
const dot = String(req.query.dot || "").trim();
|
||||
const token = String(req.query.t || "").trim();
|
||||
|
||||
res.set("Content-Type", "text/html; charset=utf-8");
|
||||
|
||||
if (!dot || !token) {
|
||||
res.status(400).send(page("Invalid link",
|
||||
`<h2 style="color:#b91c1c">That link looks incomplete.</h2>
|
||||
<p style="color:#475569">If you already filed your IFTA return, you can ignore the reminders. Questions? Call (888) 411-0383.</p>`));
|
||||
return;
|
||||
}
|
||||
|
||||
// constant-time token check
|
||||
const expected = iftaFiledToken(dot);
|
||||
const ok = token.length === expected.length
|
||||
&& crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
|
||||
if (!ok) {
|
||||
res.status(403).send(page("Invalid link",
|
||||
`<h2 style="color:#b91c1c">We couldn't verify that link.</h2>
|
||||
<p style="color:#475569">If you already filed your IFTA return, you can ignore the reminders. Questions? Call (888) 411-0383.</p>`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE fmcsa_carriers
|
||||
SET ifta_self_filed_at = COALESCE(ifta_self_filed_at, now())
|
||||
WHERE dot_number = $1`,
|
||||
[dot],
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[ifta/filed] db error:", err);
|
||||
// Still show success to the user; the suppression is best-effort.
|
||||
}
|
||||
|
||||
res.send(page("Thanks - you're all set",
|
||||
`<h2 style="color:#0f766e">Got it - thanks for letting us know.</h2>
|
||||
<p style="color:#475569;line-height:1.6">We'll stop reminding you about this quarter's IFTA return for DOT #${dot}.
|
||||
We'll check back when your next quarterly return comes due.</p>
|
||||
<p style="color:#475569;line-height:1.6">Want us to handle next quarter's filing so you don't have to?
|
||||
<a href="https://performancewest.net/order/ifta-quarterly" style="color:#0f766e;font-weight:700">See how it works →</a></p>`));
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Add table
Add a link
Reference in a new issue