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:
justin 2026-06-13 23:41:14 -05:00
parent 872154ebf7
commit 3d4226e95c
6 changed files with 220 additions and 40 deletions

View file

@ -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