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:
parent
872154ebf7
commit
3d4226e95c
6 changed files with 220 additions and 40 deletions
|
|
@ -1,11 +1,13 @@
|
||||||
-- Track which interstate carriers have been sent the IFTA quarterly-return
|
-- Track IFTA quarterly-return reminder touches per interstate carrier so the
|
||||||
-- reminder this cycle, so the daily IFTA cron never double-sends within a quarter.
|
-- multi-touch cadence (10/7/4 business days before deadline) never repeats a
|
||||||
-- The IFTA campaign builder resets this column at the start of each new quarter's
|
-- touch and escalates correctly. Reset each new quarter by the IFTA builder.
|
||||||
-- reminder window (see build_ifta_quarterly_campaign.py).
|
-- 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
|
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
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_fmcsa_carriers_ifta_reminded
|
||||||
ON fmcsa_carriers (ifta_reminded_at)
|
ON fmcsa_carriers (ifta_touch_no)
|
||||||
WHERE ifta_reminded_at IS NULL;
|
WHERE carrier_operation = 'A';
|
||||||
|
|
|
||||||
12
api/migrations/095_fmcsa_ifta_self_filed.sql
Normal file
12
api/migrations/095_fmcsa_ifta_self_filed.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -14,6 +14,7 @@ import ticketsRouter from "./routes/tickets.js";
|
||||||
import quotesRouter from "./routes/quotes.js";
|
import quotesRouter from "./routes/quotes.js";
|
||||||
import formationsRouter from "./routes/formations.js";
|
import formationsRouter from "./routes/formations.js";
|
||||||
import discountsRouter from "./routes/discounts.js";
|
import discountsRouter from "./routes/discounts.js";
|
||||||
|
import iftaRouter from "./routes/ifta.js";
|
||||||
import adminRouter from "./routes/admin.js";
|
import adminRouter from "./routes/admin.js";
|
||||||
import webhooksRouter from "./routes/webhooks.js";
|
import webhooksRouter from "./routes/webhooks.js";
|
||||||
import identityRouter from "./routes/identity.js";
|
import identityRouter from "./routes/identity.js";
|
||||||
|
|
@ -92,6 +93,7 @@ app.use(ticketsRouter);
|
||||||
app.use(quotesRouter);
|
app.use(quotesRouter);
|
||||||
app.use(formationsRouter);
|
app.use(formationsRouter);
|
||||||
app.use(discountsRouter);
|
app.use(discountsRouter);
|
||||||
|
app.use(iftaRouter);
|
||||||
app.use(adminRouter);
|
app.use(adminRouter);
|
||||||
app.use(webhooksRouter);
|
app.use(webhooksRouter);
|
||||||
app.use(refundsRouter);
|
app.use(refundsRouter);
|
||||||
|
|
|
||||||
85
api/src/routes/ifta.ts
Normal file
85
api/src/routes/ifta.ts
Normal file
|
|
@ -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 `<!doctype html><html><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${title}</title></head>
|
||||||
|
<body style="margin:0;font-family:-apple-system,system-ui,sans-serif;background:#eef0f3">
|
||||||
|
<div style="max-width:520px;margin:48px auto;background:#fff;border-radius:12px;padding:32px;text-align:center;box-shadow:0 10px 30px rgba(0,0,0,.08)">
|
||||||
|
<img src="https://performancewest.net/images/logo.png" alt="Performance West" style="height:40px;margin-bottom:16px">
|
||||||
|
${body}
|
||||||
|
<p style="margin-top:24px;font-size:12px;color:#94a3b8">Performance West Inc. · (888) 411-0383</p>
|
||||||
|
</div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-click "I already filed it" for IFTA quarterly reminders.
|
||||||
|
* GET /api/v1/ifta/filed?dot=1234567&t=<token>
|
||||||
|
* 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",
|
||||||
|
`<h2 style="color:#b91c1c">That link looks incomplete.</h2>
|
||||||
|
<p style="color:#475569">If you already filed your IFTA return, you can ignore the reminders. Questions? Call (888) 411-0383.</p>`));
|
||||||
|
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",
|
||||||
|
`<h2 style="color:#b91c1c">We couldn't verify that link.</h2>
|
||||||
|
<p style="color:#475569">If you already filed your IFTA return, you can ignore the reminders. Questions? Call (888) 411-0383.</p>`));
|
||||||
|
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",
|
||||||
|
`<h2 style="color:#0f766e">Got it - thanks for letting us know.</h2>
|
||||||
|
<p style="color:#475569;line-height:1.6">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.</p>
|
||||||
|
<p style="color:#475569;line-height:1.6">Want us to handle next quarter's filing so you don't have to?
|
||||||
|
<a href="https://performancewest.net/order/ifta-quarterly" style="color:#0f766e;font-weight:700">See how it works →</a></p>`));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div style="background:#fff;border:1px solid #e2e8f0;padding:32px;border-radius:0 0 12px 12px">
|
<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>
|
<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>
|
<h1 style="font-size:22px;color:#dc2626;margin:16px 0">{{ .Subscriber.Attribs.ifta_headline }}</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>
|
<p style="font-size:15px;color:#374151;line-height:1.6">{{ .Subscriber.Attribs.ifta_urgency }}</p>
|
||||||
|
<p style="font-size:15px;color:#374151;line-height:1.6">Your IFTA fuel-tax return for <strong>{{ .Subscriber.Attribs.ifta_quarter }}</strong> is due <strong>{{ .Subscriber.Attribs.ifta_due_date }}</strong>. Miss it and you face:</p>
|
||||||
<ul style="font-size:15px;color:#374151;line-height:1.8">
|
<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>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><strong>Interest</strong> on unpaid tax in every member jurisdiction</li>
|
||||||
|
|
@ -32,6 +33,10 @@
|
||||||
<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 →</a>
|
<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 →</a>
|
||||||
</div>
|
</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;line-height:1.6">Or call us directly at <a href="tel:8884110383" style="color:#f97316;font-weight:600">(888) 411-0383</a>.</p>
|
||||||
|
<div style="text-align:center;margin:18px 0 4px;padding-top:14px;border-top:1px solid #e5e7eb">
|
||||||
|
<p style="font-size:13px;color:#94a3b8;margin:0 0 6px">Already took care of this quarter?</p>
|
||||||
|
<a href="{{ .Subscriber.Attribs.ifta_filed_link }}" style="font-size:13px;color:#64748b;text-decoration:underline">I already filed it - stop reminding me</a>
|
||||||
|
</div>
|
||||||
<p style="font-size:14px;color:#64748b">Performance West Inc.<br>DOT Compliance Services</p>
|
<p style="font-size:14px;color:#64748b">Performance West Inc.<br>DOT Compliance Services</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center;padding:16px;font-size:11px;color:#94a3b8"><a href="https://performancewest.net" style="color:#94a3b8">performancewest.net</a> · (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. · 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001</div>
|
<div style="text-align:center;padding:16px;font-size:11px;color:#94a3b8"><a href="https://performancewest.net" style="color:#94a3b8">performancewest.net</a> · (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. · 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001</div>
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,25 @@ LOG = tc.LOG
|
||||||
|
|
||||||
IFTA_SVC_SLUG = "ifta-quarterly"
|
IFTA_SVC_SLUG = "ifta-quarterly"
|
||||||
LP_LINK = f"{tc.SITE_DOMAIN}/order/{IFTA_SVC_SLUG}"
|
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"))
|
# Multi-touch cadence: days-before-deadline for each escalating touch.
|
||||||
# Only send within this many days of the ideal lead day (so a daily cron fires once).
|
# Send late enough that DIY feels stressful (we sell convenience under pressure)
|
||||||
REMINDER_WINDOW_DAYS = int(os.getenv("IFTA_REMINDER_WINDOW_DAYS", "2"))
|
# 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.
|
# Listmonk source campaign holding the IFTA reminder body/subject/template.
|
||||||
SOURCE_ENV = "CAMPAIGN_IFTA_QUARTERLY_ID"
|
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])
|
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)
|
q, due, period = next_deadline(today)
|
||||||
ideal = due - timedelta(days=REMINDER_LEAD_DAYS)
|
if not _is_business_day(today):
|
||||||
if abs((today - ideal).days) <= REMINDER_WINDOW_DAYS:
|
return None, (q, due, period)
|
||||||
return True, (q, due, period)
|
for touch in TOUCHES:
|
||||||
return False, (q, due, period)
|
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:
|
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:
|
if row and row[0] == cycle_key:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return # already reset for this cycle
|
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
|
cleared = cur.rowcount
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO ifta_reminder_cycle (id, cycle_key, reset_at)
|
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 email_address IS NOT NULL AND email_address <> ''
|
||||||
AND {tc.USABLE_FILTER}
|
AND {tc.USABLE_FILTER}
|
||||||
AND lower(split_part(email_address, '@', 2)) <> ALL(%s)
|
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
|
ORDER BY dot_number
|
||||||
LIMIT %s
|
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:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--date", help="override 'today' (YYYY-MM-DD) for testing")
|
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()
|
today = datetime.strptime(args.date, "%Y-%m-%d").date() if args.date else date.today()
|
||||||
|
|
||||||
ok, dl = in_reminder_window(today)
|
touch, (q, due, period) = due_touch_today(today)
|
||||||
q, due, period = dl
|
if touch is None and not args.ignore_window and not args.preview:
|
||||||
if not ok and not args.ignore_window and not args.preview:
|
LOG.info("[ifta] no touch fires today (next %s due %s); exiting", q, due)
|
||||||
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
|
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)
|
src = os.getenv(SOURCE_ENV)
|
||||||
if not src:
|
if not src:
|
||||||
|
|
@ -152,9 +215,8 @@ def main() -> int:
|
||||||
|
|
||||||
conn = psycopg2.connect(tc.DB_URL)
|
conn = psycopg2.connect(tc.DB_URL)
|
||||||
|
|
||||||
# New-cycle reset: at the start of each quarter's reminder window, clear the
|
# New-cycle reset: at the start of each quarter, clear prior touch/self-filed
|
||||||
# prior cycle's marks so this quarter can remind the full interstate pool.
|
# marks so this quarter starts fresh. Runs once per cycle.
|
||||||
# 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:
|
if args.start_campaign and not args.preview and not args.dry_run:
|
||||||
_reset_cycle_if_new(conn, q, due)
|
_reset_cycle_if_new(conn, q, due)
|
||||||
|
|
||||||
|
|
@ -166,14 +228,16 @@ def main() -> int:
|
||||||
LOG.warning("[ifta] coupon mint failed: %s (sending without)", exc)
|
LOG.warning("[ifta] coupon mint failed: %s (sending without)", exc)
|
||||||
|
|
||||||
cur = conn.cursor()
|
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()
|
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:
|
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
|
return 0
|
||||||
|
|
||||||
due_human = due.strftime("%B %-d, %Y")
|
due_human = due.strftime("%B %-d, %Y")
|
||||||
|
headline = headline_t.format(due=due_human)
|
||||||
|
|
||||||
def attribs(dot, company, state):
|
def attribs(dot, company, state):
|
||||||
lp = f"{LP_LINK}?code={coupon}" if coupon else LP_LINK
|
lp = f"{LP_LINK}?code={coupon}" if coupon else LP_LINK
|
||||||
|
|
@ -182,6 +246,9 @@ def main() -> int:
|
||||||
"lp_link": lp,
|
"lp_link": lp,
|
||||||
"ifta_due_date": due_human,
|
"ifta_due_date": due_human,
|
||||||
"ifta_quarter": period,
|
"ifta_quarter": period,
|
||||||
|
"ifta_headline": headline,
|
||||||
|
"ifta_urgency": urgency,
|
||||||
|
"ifta_filed_link": _filed_link(dot) if dot else "",
|
||||||
}
|
}
|
||||||
a.update(tc.coupon_attribs(coupon))
|
a.update(tc.coupon_attribs(coupon))
|
||||||
return a
|
return a
|
||||||
|
|
@ -198,14 +265,18 @@ def main() -> int:
|
||||||
} for r in rows]
|
} for r in rows]
|
||||||
|
|
||||||
send_date = today.isoformat()
|
send_date = today.isoformat()
|
||||||
list_name = f"IFTA Quarterly {q} {due.year} - {send_date}"
|
list_name = f"IFTA {q} {due.year} T{touch_no} - {send_date}"
|
||||||
campaign_name = f"IFTA Quarterly Reminder - {q} {due.year} - {today.strftime('%b %d %Y')}"
|
campaign_name = f"IFTA Reminder {q} {due.year} - Touch {touch_no} ({days_before}bd) - {today.strftime('%b %d %Y')}"
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
LOG.info("[ifta] DRY RUN -- would send to %d carriers (coupon=%s, due=%s)",
|
LOG.info("[ifta] DRY RUN -- touch %d would send to %d carriers (coupon=%s, due=%s)",
|
||||||
len(subs), coupon, due_human)
|
touch_no, len(subs), coupon, due_human)
|
||||||
return 0
|
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)
|
list_id = tc.create_list(list_name)
|
||||||
added = tc.import_subscribers(list_id, subs)
|
added = tc.import_subscribers(list_id, subs)
|
||||||
LOG.info("[ifta] list %d: %d/%d subscribers added", list_id, added, len(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")
|
LOG.error("[ifta] 0 subscribers added; aborting")
|
||||||
return 1
|
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)
|
send_at = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||||
cid = tc.create_and_schedule_campaign(
|
cid = tc.create_and_schedule_campaign(
|
||||||
base, list_id, campaign_name, send_at, schedule=args.start_campaign and not args.preview)
|
base, list_id, campaign_name, send_at, schedule=args.start_campaign and not args.preview)
|
||||||
LOG.info("[ifta] campaign %d created (%s)", cid,
|
LOG.info("[ifta] campaign %d created (%s)", cid,
|
||||||
"scheduled" if args.start_campaign and not args.preview else "draft")
|
"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:
|
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[])",
|
cur.execute("""
|
||||||
([r[0] for r in rows],))
|
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()
|
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()
|
conn.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue