diff --git a/api/migrations/094_fmcsa_ifta_reminded.sql b/api/migrations/094_fmcsa_ifta_reminded.sql
index 94838bf..bcd46f4 100644
--- a/api/migrations/094_fmcsa_ifta_reminded.sql
+++ b/api/migrations/094_fmcsa_ifta_reminded.sql
@@ -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';
diff --git a/api/migrations/095_fmcsa_ifta_self_filed.sql b/api/migrations/095_fmcsa_ifta_self_filed.sql
new file mode 100644
index 0000000..6a533b3
--- /dev/null
+++ b/api/migrations/095_fmcsa_ifta_self_filed.sql
@@ -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;
diff --git a/api/src/index.ts b/api/src/index.ts
index a1739fb..846a7ca 100644
--- a/api/src/index.ts
+++ b/api/src/index.ts
@@ -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);
diff --git a/api/src/routes/ifta.ts b/api/src/routes/ifta.ts
new file mode 100644
index 0000000..172908b
--- /dev/null
+++ b/api/src/routes/ifta.ts
@@ -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 `
+
+${title}
+
+
+

+${body}
+
Performance West Inc. · (888) 411-0383
+
`;
+}
+
+/**
+ * One-click "I already filed it" for IFTA quarterly reminders.
+ * GET /api/v1/ifta/filed?dot=1234567&t=
+ * 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",
+ `That link looks incomplete.
+ If you already filed your IFTA return, you can ignore the reminders. Questions? Call (888) 411-0383.
`));
+ 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",
+ `We couldn't verify that link.
+ If you already filed your IFTA return, you can ignore the reminders. Questions? Call (888) 411-0383.
`));
+ 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",
+ `Got it - thanks for letting us know.
+ 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.
+ Want us to handle next quarter's filing so you don't have to?
+ See how it works →
`));
+});
+
+export default router;
diff --git a/data/trucking_campaigns/ifta_quarterly_reminder.html b/data/trucking_campaigns/ifta_quarterly_reminder.html
index f8d12ab..ab3a084 100644
--- a/data/trucking_campaigns/ifta_quarterly_reminder.html
+++ b/data/trucking_campaigns/ifta_quarterly_reminder.html
@@ -4,8 +4,9 @@
{{ .Subscriber.Attribs.company }},
DOT# {{ .Subscriber.Attribs.dot_number }}
-
Your IFTA quarterly return is due {{ .Subscriber.Attribs.ifta_due_date }}.
-
If you run interstate, your IFTA fuel-tax return for {{ .Subscriber.Attribs.ifta_quarter }} is coming due. Miss it and you face:
+
{{ .Subscriber.Attribs.ifta_headline }}
+
{{ .Subscriber.Attribs.ifta_urgency }}
+
Your IFTA fuel-tax return for {{ .Subscriber.Attribs.ifta_quarter }} is due {{ .Subscriber.Attribs.ifta_due_date }}. Miss it and you face:
- Late penalties ($50 or 10% of net tax due, whichever is greater)
- Interest on unpaid tax in every member jurisdiction
@@ -32,6 +33,10 @@
10-4 - File My IFTA Return →
Or call us directly at (888) 411-0383.
+
Performance West Inc.
DOT Compliance Services
Gotta hit a 10-100 and pull off this channel?
Unsubscribe here.
Performance West Inc. · 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001
diff --git a/scripts/build_ifta_quarterly_campaign.py b/scripts/build_ifta_quarterly_campaign.py
index bb56c4c..f67a8ec 100644
--- a/scripts/build_ifta_quarterly_campaign.py
+++ b/scripts/build_ifta_quarterly_campaign.py
@@ -39,10 +39,25 @@ LOG = tc.LOG
IFTA_SVC_SLUG = "ifta-quarterly"
LP_LINK = f"{tc.SITE_DOMAIN}/order/{IFTA_SVC_SLUG}"
-# Days before the deadline to send the reminder.
-REMINDER_LEAD_DAYS = int(os.getenv("IFTA_REMINDER_LEAD_DAYS", "21"))
-# Only send within this many days of the ideal lead day (so a daily cron fires once).
-REMINDER_WINDOW_DAYS = int(os.getenv("IFTA_REMINDER_WINDOW_DAYS", "2"))
+
+# Multi-touch cadence: days-before-deadline for each escalating touch.
+# Send late enough that DIY feels stressful (we sell convenience under pressure)
+# but early enough that we can still file in time (need ~3-5d + their e-signed
+# authorization). Each touch escalates tone: soft -> urgent -> last-chance.
+TOUCHES = [
+ # (days_before, touch_no, subject_suffix, headline, urgency_blurb)
+ (10, 1, "is due in about a week",
+ "Your IFTA quarterly return is due {due}.",
+ "It is coming up fast. We can file it for you so it is one less thing on your plate."),
+ (7, 2, "is due in one week - still time for us to file",
+ "One week left: your IFTA return is due {due}.",
+ "If you would rather not dig through your mileage and fuel records yourself, there is still time for us to handle it - but the window is closing."),
+ (4, 3, "is due in days - last chance for us to file it for you",
+ "Almost out of time: your IFTA return is due {due}.",
+ "This is about the last point we can still file your return in time. After this you would be filing it yourself, fast, or risking a late penalty. Let us take it off your plate today."),
+]
+# Only send within this many days of a touch day (so a daily cron fires once each).
+REMINDER_WINDOW_DAYS = int(os.getenv("IFTA_REMINDER_WINDOW_DAYS", "1"))
# Listmonk source campaign holding the IFTA reminder body/subject/template.
SOURCE_ENV = "CAMPAIGN_IFTA_QUARTERLY_ID"
@@ -64,12 +79,38 @@ def next_deadline(today: date) -> tuple[str, date, str]:
return min(upcoming, key=lambda d: d[1])
-def in_reminder_window(today: date) -> tuple[bool, tuple[str, date, str] | None]:
+def _is_business_day(d: date) -> bool:
+ # Mon-Fri. (Federal holidays are not adjusted for here; the +/-window and the
+ # 3-touch cadence give enough slack that a holiday only shifts a touch by a day.)
+ return d.weekday() < 5
+
+
+def minus_business_days(d: date, n: int) -> date:
+ """Return the date that is `n` business days before `d`."""
+ cur = d
+ while n > 0:
+ cur -= timedelta(days=1)
+ if _is_business_day(cur):
+ n -= 1
+ return cur
+
+
+def due_touch_today(today: date) -> tuple[tuple, tuple[str, date, str]] | tuple[None, tuple[str, date, str]]:
+ """Return (touch_tuple, (quarter, due, period)) if a touch fires today, else (None, ...).
+
+ Each touch fires on its business-day-before-deadline date (within a small
+ window so the daily cron catches it even if a day is skipped). Only fires on
+ business days.
+ """
q, due, period = next_deadline(today)
- ideal = due - timedelta(days=REMINDER_LEAD_DAYS)
- if abs((today - ideal).days) <= REMINDER_WINDOW_DAYS:
- return True, (q, due, period)
- return False, (q, due, period)
+ if not _is_business_day(today):
+ return None, (q, due, period)
+ for touch in TOUCHES:
+ bdays = touch[0]
+ send_on = minus_business_days(due, bdays)
+ if abs((today - send_on).days) <= REMINDER_WINDOW_DAYS:
+ return touch, (q, due, period)
+ return None, (q, due, period)
def _reset_cycle_if_new(conn, quarter: str, due: date) -> None:
@@ -93,7 +134,11 @@ def _reset_cycle_if_new(conn, quarter: str, due: date) -> None:
if row and row[0] == cycle_key:
conn.commit()
return # already reset for this cycle
- cur.execute("UPDATE fmcsa_carriers SET ifta_reminded_at = NULL WHERE ifta_reminded_at IS NOT NULL")
+ cur.execute("""
+ UPDATE fmcsa_carriers
+ SET ifta_reminded_at = NULL, ifta_touch_no = NULL, ifta_self_filed_at = NULL
+ WHERE ifta_touch_no IS NOT NULL OR ifta_self_filed_at IS NOT NULL
+ """)
cleared = cur.rowcount
cur.execute("""
INSERT INTO ifta_reminder_cycle (id, cycle_key, reset_at)
@@ -111,12 +156,29 @@ SELECT_SQL = f"""
AND email_address IS NOT NULL AND email_address <> ''
AND {tc.USABLE_FILTER}
AND lower(split_part(email_address, '@', 2)) <> ALL(%s)
- AND ifta_reminded_at IS NULL -- not yet reminded this cycle
+ AND ifta_self_filed_at IS NULL -- clicked "I already filed it"
+ AND COALESCE(ifta_touch_no, 0) < %s -- not yet sent THIS touch
ORDER BY dot_number
LIMIT %s
"""
+def _ifta_filed_token(dot: str) -> str:
+ """Must match api/src/routes/ifta.ts iftaFiledToken(): HMAC-SHA256, first 24 hex."""
+ import hmac
+ import hashlib
+ secret = (os.getenv("ADMIN_JWT_SECRET")
+ or os.getenv("CUSTOMER_JWT_SECRET")
+ or os.getenv("APPROVE_FILE_TOKEN")
+ or "pw-ifta-filed-fallback-secret")
+ return hmac.new(secret.encode(), f"ifta-filed:{dot}".encode(),
+ hashlib.sha256).hexdigest()[:24]
+
+
+def _filed_link(dot: str) -> str:
+ return f"{tc.SITE_DOMAIN}/api/v1/ifta/filed?dot={dot}&t={_ifta_filed_token(dot)}"
+
+
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--date", help="override 'today' (YYYY-MM-DD) for testing")
@@ -137,12 +199,13 @@ def main() -> int:
today = datetime.strptime(args.date, "%Y-%m-%d").date() if args.date else date.today()
- ok, dl = in_reminder_window(today)
- q, due, period = dl
- if not ok and not args.ignore_window and not args.preview:
- LOG.info("[ifta] not in reminder window (next %s due %s, ideal send %s); exiting",
- q, due, due - timedelta(days=REMINDER_LEAD_DAYS))
+ touch, (q, due, period) = due_touch_today(today)
+ if touch is None and not args.ignore_window and not args.preview:
+ LOG.info("[ifta] no touch fires today (next %s due %s); exiting", q, due)
return 0
+ if touch is None:
+ touch = TOUCHES[0] # preview/manual default to the first touch
+ days_before, touch_no, subj_suffix, headline_t, urgency = touch
src = os.getenv(SOURCE_ENV)
if not src:
@@ -152,9 +215,8 @@ def main() -> int:
conn = psycopg2.connect(tc.DB_URL)
- # New-cycle reset: at the start of each quarter's reminder window, clear the
- # prior cycle's marks so this quarter can remind the full interstate pool.
- # Keyed off a marker row so the reset runs exactly once per quarter.
+ # New-cycle reset: at the start of each quarter, clear prior touch/self-filed
+ # marks so this quarter starts fresh. Runs once per cycle.
if args.start_campaign and not args.preview and not args.dry_run:
_reset_cycle_if_new(conn, q, due)
@@ -166,14 +228,16 @@ def main() -> int:
LOG.warning("[ifta] coupon mint failed: %s (sending without)", exc)
cur = conn.cursor()
- cur.execute(SELECT_SQL, [list(tc.BLOCKED_EMAIL_DOMAINS), args.limit])
+ cur.execute(SELECT_SQL, [list(tc.BLOCKED_EMAIL_DOMAINS), touch_no, args.limit])
rows = cur.fetchall()
- LOG.info("[ifta] %s due %s | %d candidate interstate carriers", q, due, len(rows))
+ LOG.info("[ifta] %s due %s | touch %d (%d biz-days before) | %d candidate carriers",
+ q, due, touch_no, days_before, len(rows))
if not rows and not args.preview:
- LOG.info("[ifta] no candidates; exiting")
+ LOG.info("[ifta] no candidates for touch %d; exiting", touch_no)
return 0
due_human = due.strftime("%B %-d, %Y")
+ headline = headline_t.format(due=due_human)
def attribs(dot, company, state):
lp = f"{LP_LINK}?code={coupon}" if coupon else LP_LINK
@@ -182,6 +246,9 @@ def main() -> int:
"lp_link": lp,
"ifta_due_date": due_human,
"ifta_quarter": period,
+ "ifta_headline": headline,
+ "ifta_urgency": urgency,
+ "ifta_filed_link": _filed_link(dot) if dot else "",
}
a.update(tc.coupon_attribs(coupon))
return a
@@ -198,14 +265,18 @@ def main() -> int:
} for r in rows]
send_date = today.isoformat()
- list_name = f"IFTA Quarterly {q} {due.year} - {send_date}"
- campaign_name = f"IFTA Quarterly Reminder - {q} {due.year} - {today.strftime('%b %d %Y')}"
+ list_name = f"IFTA {q} {due.year} T{touch_no} - {send_date}"
+ campaign_name = f"IFTA Reminder {q} {due.year} - Touch {touch_no} ({days_before}bd) - {today.strftime('%b %d %Y')}"
if args.dry_run:
- LOG.info("[ifta] DRY RUN -- would send to %d carriers (coupon=%s, due=%s)",
- len(subs), coupon, due_human)
+ LOG.info("[ifta] DRY RUN -- touch %d would send to %d carriers (coupon=%s, due=%s)",
+ touch_no, len(subs), coupon, due_human)
return 0
+ # Per-touch subject override on the cloned campaign.
+ base = dict(base)
+ base["subject"] = f"DOT# {{{{ .Subscriber.Attribs.dot_number }}}} - Your IFTA quarterly return {subj_suffix}"
+
list_id = tc.create_list(list_name)
added = tc.import_subscribers(list_id, subs)
LOG.info("[ifta] list %d: %d/%d subscribers added", list_id, added, len(subs))
@@ -213,19 +284,22 @@ def main() -> int:
LOG.error("[ifta] 0 subscribers added; aborting")
return 1
- # Send ~9:30 UTC (early morning ET) the day after build, or now if preview.
send_at = datetime.now(timezone.utc) + timedelta(minutes=5)
cid = tc.create_and_schedule_campaign(
base, list_id, campaign_name, send_at, schedule=args.start_campaign and not args.preview)
LOG.info("[ifta] campaign %d created (%s)", cid,
"scheduled" if args.start_campaign and not args.preview else "draft")
- # Mark reminded so the next cron run does not re-hit the same carriers this cycle.
+ # Record the touch so the next touch only hits carriers who have not yet
+ # reached this touch level (and never re-hits within a touch).
if args.start_campaign and not args.preview and rows:
- cur.execute("UPDATE fmcsa_carriers SET ifta_reminded_at = now() WHERE dot_number = ANY(%s::text[])",
- ([r[0] for r in rows],))
+ cur.execute("""
+ UPDATE fmcsa_carriers
+ SET ifta_reminded_at = now(), ifta_touch_no = %s
+ WHERE dot_number = ANY(%s::text[])
+ """, (touch_no, [r[0] for r in rows]))
conn.commit()
- LOG.info("[ifta] marked %d carriers ifta_reminded_at", len(rows))
+ LOG.info("[ifta] marked %d carriers at touch %d", len(rows), touch_no)
conn.close()
return 0