ifta: recurring quarterly-return reminder campaign (calendar-triggered)

IFTA returns are due on fixed dates (Apr30/Jul31/Oct31/Jan31) and every
interstate carrier (op code A, ~628k sendable) files 4x/year forever -- pure
recurring revenue, no per-carrier deadline data needed.

- build_ifta_quarterly_campaign.py: self-gates to the reminder window (~21d
  before each deadline), selects interstate carriers, mints the same-day coupon,
  builds+schedules the campaign reusing build_trucking_campaigns plumbing (DRY:
  one source of truth for sending/suppression/coupon). Per-quarter cycle reset
  (ifta_reminder_cycle marker) so each quarter re-reminds the full pool; marks
  ifta_reminded_at to avoid double-sends within a cycle.
- ifta_quarterly_reminder.html: deadline + penalties + 'we do the math' + coupon
  + CAN-SPAM. Listmonk source campaign id 469.
- migration 094: fmcsa_carriers.ifta_reminded_at column + partial index.
Verified: deadline/window logic correct, imports reuse tc helpers, migration
applied on prod.
This commit is contained in:
justin 2026-06-13 23:24:47 -05:00
parent 766e32e555
commit 19bbef3231
3 changed files with 284 additions and 0 deletions

View file

@ -0,0 +1,11 @@
-- 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).
ALTER TABLE fmcsa_carriers
ADD COLUMN IF NOT EXISTS ifta_reminded_at TIMESTAMPTZ;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fmcsa_carriers_ifta_reminded
ON fmcsa_carriers (ifta_reminded_at)
WHERE ifta_reminded_at IS NULL;

View file

@ -0,0 +1,38 @@
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:600px;margin:0 auto">
<div style="background:#1a2744;padding:24px;text-align:center;border-radius:12px 12px 0 0">
<img src="https://performancewest.net/images/logo.png" alt="Performance West" style="height:40px">
</div>
<div style="background:#fff;border:1px solid #e2e8f0;padding:32px;border-radius:0 0 12px 12px">
<p style="font-size:15px;color:#374151;line-height:1.6">{{ .Subscriber.Attribs.company }},<br>DOT# {{ .Subscriber.Attribs.dot_number }}</p>
<h1 style="font-size:22px;color:#dc2626;margin:16px 0">Your IFTA quarterly return is due {{ .Subscriber.Attribs.ifta_due_date }}.</h1>
<p style="font-size:15px;color:#374151;line-height:1.6">If you run interstate, your IFTA fuel-tax return for <strong>{{ .Subscriber.Attribs.ifta_quarter }}</strong> is coming due. Miss it and you face:</p>
<ul style="font-size:15px;color:#374151;line-height:1.8">
<li><strong>Late penalties</strong> ($50 or 10% of net tax due, whichever is greater)</li>
<li><strong>Interest</strong> on unpaid tax in every member jurisdiction</li>
<li>Risk of your <strong>IFTA license being suspended</strong> - which can put you out of service</li>
</ul>
{{ if .Subscriber.Attribs.coupon_code }}
<div style="background:#fff7ed;border:2px solid #f97316;border-radius:10px;padding:20px;margin:20px 0;text-align:center">
<p style="font-size:13px;font-weight:700;color:#9a3412;letter-spacing:.04em;margin:0 0 6px">TODAY ONLY - {{ .Subscriber.Attribs.coupon_pct }}% OFF</p>
<p style="font-size:18px;font-weight:700;color:#9a3412;margin:0 0 4px">We file your IFTA quarterly return for <span style="text-decoration:line-through;color:#c2410c;font-weight:600">$109</span> <span style="color:#15803d">$65</span>.</p>
<p style="font-size:14px;color:#9a3412;margin:0 0 4px">Use code <strong style="font-size:16px;letter-spacing:.08em">{{ .Subscriber.Attribs.coupon_code }}</strong> at checkout.</p>
<p style="font-size:12px;color:#b91c1c;font-weight:700;margin:0">Expires {{ .Subscriber.Attribs.coupon_expires }}.</p>
</div>
{{ else }}
<div style="background:#f0fdf4;border:2px solid #86efac;border-radius:10px;padding:20px;margin:20px 0;text-align:center">
<p style="font-size:18px;font-weight:700;color:#166534;margin:0 0 4px">We file your IFTA quarterly return for $109.</p>
<p style="font-size:14px;color:#15803d;margin:0">Send us your miles and fuel by state - we calculate and file. No portals, no math.</p>
</div>
{{ end }}
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:20px 0">
<p style="font-size:14px;color:#374151;margin:0 0 6px"><strong>We do the math for you.</strong></p>
<p style="font-size:13px;color:#64748b;margin:0;line-height:1.6">Send us your total miles and gallons by jurisdiction for the quarter. We calculate the tax owed for every state you ran in, prepare the return, and file it. You just review and we handle the rest - so you can get back to driving.</p>
</div>
<div style="text-align:center;margin:24px 0">
<a href="{{ .Subscriber.Attribs.lp_link }}&utm_source=listmonk&utm_medium=email&utm_campaign=ifta-quarterly@TrackLink" style="display:inline-block;padding:14px 36px;background:#f97316;color:#fff;font-weight:700;border-radius:8px;text-decoration:none;font-size:16px">10-4 - File My IFTA Return &rarr;</a>
</div>
<p style="font-size:14px;color:#64748b;line-height:1.6">Or call us directly at <a href="tel:8884110383" style="color:#f97316;font-weight:600">(888) 411-0383</a>.</p>
<p style="font-size:14px;color:#64748b">Performance West Inc.<br>DOT Compliance Services</p>
</div>
<div style="text-align:center;padding:16px;font-size:11px;color:#94a3b8"><a href="https://performancewest.net" style="color:#94a3b8">performancewest.net</a> &middot; (888) 411-0383</div><div style="text-align:center;padding:0 16px 18px;font-size:11px;color:#94a3b8;line-height:1.7">Gotta hit a 10-100 and pull off this channel? <a href="{{ UnsubscribeURL }}" style="color:#94a3b8;text-decoration:underline">Unsubscribe here</a>.<br>Performance West Inc. &middot; 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001</div>
</div>

View file

@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Build the IFTA quarterly-return reminder campaign.
IFTA returns are due on a FIXED calendar (no per-carrier data needed):
Q1 (Jan-Mar) -> Apr 30
Q2 (Apr-Jun) -> Jul 31
Q3 (Jul-Sep) -> Oct 31
Q4 (Oct-Dec) -> Jan 31
Every interstate carrier (FMCSA operation code 'A') files IFTA 4x/year, forever,
so this is pure recurring revenue. We email the interstate population ~3 weeks
before each deadline with the ifta-quarterly filing CTA + the same-day coupon.
Reuses the listmonk + coupon plumbing from build_trucking_campaigns so there is
one source of truth for sending, suppression, warmup caps and the daily coupon.
Cron (run daily; it self-gates to the reminder window):
python3 scripts/build_ifta_quarterly_campaign.py --start-campaign
Manual / preview:
python3 scripts/build_ifta_quarterly_campaign.py --preview
python3 scripts/build_ifta_quarterly_campaign.py --date 2026-04-09 --dry-run
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import date, datetime, timedelta, timezone
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import psycopg2 # noqa: E402
# Reuse the trucking sender plumbing (single source of truth).
import scripts.build_trucking_campaigns as tc # noqa: E402
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"))
# Listmonk source campaign holding the IFTA reminder body/subject/template.
SOURCE_ENV = "CAMPAIGN_IFTA_QUARTERLY_ID"
def ifta_deadlines(year: int) -> list[tuple[str, date, str]]:
"""(quarter_label, due_date, period_label) for a given year."""
return [
("Q4", date(year, 1, 31), f"Q4 {year-1} (Oct-Dec)"),
("Q1", date(year, 4, 30), f"Q1 {year} (Jan-Mar)"),
("Q2", date(year, 7, 31), f"Q2 {year} (Apr-Jun)"),
("Q3", date(year, 10, 31), f"Q3 {year} (Jul-Sep)"),
]
def next_deadline(today: date) -> tuple[str, date, str]:
"""The soonest upcoming IFTA deadline (this year or next)."""
cands = ifta_deadlines(today.year) + ifta_deadlines(today.year + 1)
upcoming = [d for d in cands if d[1] >= today]
return min(upcoming, key=lambda d: d[1])
def in_reminder_window(today: date) -> tuple[bool, tuple[str, date, str] | None]:
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)
def _reset_cycle_if_new(conn, quarter: str, due: date) -> None:
"""Clear ifta_reminded_at once per quarter so each cycle reminds the full pool.
A tiny marker table records the last cycle key (quarter + due year). When the
builder enters a new quarter's window for the first time, it nulls the column.
"""
cycle_key = f"{quarter}-{due.year}"
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS ifta_reminder_cycle (
id boolean PRIMARY KEY DEFAULT true,
cycle_key text NOT NULL,
reset_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ifta_reminder_cycle_singleton CHECK (id)
)
""")
cur.execute("SELECT cycle_key FROM ifta_reminder_cycle WHERE id = true")
row = cur.fetchone()
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")
cleared = cur.rowcount
cur.execute("""
INSERT INTO ifta_reminder_cycle (id, cycle_key, reset_at)
VALUES (true, %s, now())
ON CONFLICT (id) DO UPDATE SET cycle_key = EXCLUDED.cycle_key, reset_at = now()
""", (cycle_key,))
conn.commit()
LOG.info("[ifta] new cycle %s -- cleared %d prior ifta_reminded_at marks", cycle_key, cleared)
SELECT_SQL = f"""
SELECT dot_number, email_address, legal_name, phy_state
FROM fmcsa_carriers
WHERE carrier_operation = 'A' -- interstate => files IFTA
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
ORDER BY dot_number
LIMIT %s
"""
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--date", help="override 'today' (YYYY-MM-DD) for testing")
ap.add_argument("--start-campaign", action="store_true",
help="schedule/start the campaign (default builds a draft)")
ap.add_argument("--preview", action="store_true",
help="send only to the owner test address with sample attribs")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--limit", type=int, default=int(os.getenv("IFTA_DAILY_CAP", "2000")),
help="max recipients this run (warmup-safe default 2000)")
ap.add_argument("--ignore-window", action="store_true",
help="build even if not in the reminder window (manual sends)")
args = ap.parse_args()
logging_level = os.getenv("LOG_LEVEL", "INFO")
import logging
logging.basicConfig(level=logging_level, format="%(asctime)s %(levelname)s %(message)s")
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))
return 0
src = os.getenv(SOURCE_ENV)
if not src:
LOG.error("[ifta] %s not set -- create the IFTA source campaign first", SOURCE_ENV)
return 2
base = tc.get_base_campaign(int(src))
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.
if args.start_campaign and not args.preview and not args.dry_run:
_reset_cycle_if_new(conn, q, due)
coupon = None
if args.start_campaign and not args.preview and not args.dry_run:
try:
coupon = tc.get_or_create_daily_coupon(conn, today)
except Exception as exc: # noqa: BLE001
LOG.warning("[ifta] coupon mint failed: %s (sending without)", exc)
cur = conn.cursor()
cur.execute(SELECT_SQL, [list(tc.BLOCKED_EMAIL_DOMAINS), args.limit])
rows = cur.fetchall()
LOG.info("[ifta] %s due %s | %d candidate interstate carriers", q, due, len(rows))
if not rows and not args.preview:
LOG.info("[ifta] no candidates; exiting")
return 0
due_human = due.strftime("%B %-d, %Y")
def attribs(dot, company, state):
lp = f"{LP_LINK}?code={coupon}" if coupon else LP_LINK
a = {
"dot_number": dot or "", "company": company or "", "state": state or "",
"lp_link": lp,
"ifta_due_date": due_human,
"ifta_quarter": period,
}
a.update(tc.coupon_attribs(coupon))
return a
if args.preview:
subs = [{
"email": tc.TEST_EMAIL, "name": "Sample Carrier",
"attribs": attribs("0000000", "Sample Carrier LLC", "TX"),
}]
else:
subs = [{
"email": r[1], "name": r[2] or r[1],
"attribs": attribs(r[0], r[2], r[3]),
} 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')}"
if args.dry_run:
LOG.info("[ifta] DRY RUN -- would send to %d carriers (coupon=%s, due=%s)",
len(subs), coupon, due_human)
return 0
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))
if added == 0:
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.
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],))
conn.commit()
LOG.info("[ifta] marked %d carriers ifta_reminded_at", len(rows))
conn.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())