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:
parent
766e32e555
commit
19bbef3231
3 changed files with 284 additions and 0 deletions
235
scripts/build_ifta_quarterly_campaign.py
Normal file
235
scripts/build_ifta_quarterly_campaign.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue