diff --git a/api/migrations/094_fmcsa_ifta_reminded.sql b/api/migrations/094_fmcsa_ifta_reminded.sql new file mode 100644 index 0000000..94838bf --- /dev/null +++ b/api/migrations/094_fmcsa_ifta_reminded.sql @@ -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; diff --git a/data/trucking_campaigns/ifta_quarterly_reminder.html b/data/trucking_campaigns/ifta_quarterly_reminder.html new file mode 100644 index 0000000..f8d12ab --- /dev/null +++ b/data/trucking_campaigns/ifta_quarterly_reminder.html @@ -0,0 +1,38 @@ +
+
+ Performance West +
+
+

{{ .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:

+ +{{ if .Subscriber.Attribs.coupon_code }} +
+

TODAY ONLY - {{ .Subscriber.Attribs.coupon_pct }}% OFF

+

We file your IFTA quarterly return for $109 $65.

+

Use code {{ .Subscriber.Attribs.coupon_code }} at checkout.

+

Expires {{ .Subscriber.Attribs.coupon_expires }}.

+
+{{ else }} +
+

We file your IFTA quarterly return for $109.

+

Send us your miles and fuel by state - we calculate and file. No portals, no math.

+
+{{ end }} +
+

We do the math for you.

+

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.

+
+
+10-4 - File My IFTA Return → +
+

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

+

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 new file mode 100644 index 0000000..bb56c4c --- /dev/null +++ b/scripts/build_ifta_quarterly_campaign.py @@ -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())