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} + +
+Performance West +${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:

Or call us directly at (888) 411-0383.

+
+

Already took care of this quarter?

+I already filed it - stop reminding me +

Performance West Inc.
DOT Compliance Services

performancewest.net · (888) 411-0383
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