intake-reminder: weekly fallback so capped paid orders aren't abandoned

Two of our three real paid customers (Mark Adams / mark@adamslumber.com and
Paul Wilson / synthetic@pipeline.com) never completed intake. They each hit the
old hard cap of 10 daily reminders (last sent Jun 12 / Jun 13) and the worker
then went permanently silent -- the last two daily runs reminded 0 orders even
though both still owe us intake on paid work. (The third, mitchell allen /
mitchell@allenscrapmetal.com, did complete intake; his orders are dispatched.)

Replace the dead-stop cap with a two-phase cadence:
  - daily for the first DAILY_PHASE (10) nudges -- the initial burst,
  - then weekly (WEEKLY_INTERVAL_DAYS) up to an absolute MAX_REMINDERS (60),
so a paid order with missing intake keeps getting a gentle nudge instead of
being dropped. Tunable via INTAKE_REMINDER_DAILY_PHASE /
INTAKE_REMINDER_WEEKLY_INTERVAL_DAYS / INTAKE_REMINDER_MAX. Clearing
intake_reminder_last_at re-arms an order immediately (documented in the
module docstring).
This commit is contained in:
justin 2026-06-15 22:13:27 -05:00
parent ba6f171c9d
commit ef3b7a96f0

View file

@ -3,15 +3,28 @@
After a customer pays for a compliance service we email them an intake form
link so we can collect the information needed to prepare the filing. Some
customers never complete intake, which stalls fulfillment. This worker runs
once a day (noon ET) and nudges any PAID order whose intake is still
incomplete, up to a per-order cap.
customers never complete intake, which stalls fulfillment of an order we've
already been paid for. This worker runs once a day (noon ET) and nudges any
PAID order whose intake is still incomplete.
Cadence (two phases, so we never permanently abandon a paid order):
1. DAILY phase the first DAILY_PHASE reminders go out at most once per
calendar day. This is the initial burst right after payment.
2. WEEKLY phase after the daily burst the customer keeps getting a gentle
nudge, but only once every WEEKLY_INTERVAL_DAYS, until either intake is
completed or we hit the absolute MAX_REMINDERS ceiling (~a year of
weekly nudges) at which point ops takes over manually.
Previously the worker stopped dead at a 10-reminder cap, so a customer who
ignored the first burst was silently dropped forever and their paid order
just stalled. The weekly fallback keeps fulfillment moving.
Eligibility (a row is reminded when ALL hold):
- payment_status = 'paid'
- intake_data_validated IS NOT TRUE (intake not yet completed)
- intake_reminder_count < MAX_REMINDERS (default 10)
- intake_reminder_last_at IS NULL OR < today (at most one reminder/day)
- intake_data_validated IS NOT TRUE (intake not yet completed)
- intake_reminder_count < MAX_REMINDERS (absolute ceiling)
- paid_at is set and older than MIN_AGE_HOURS
- due for its phase: daily (< today) or weekly (< now - WEEKLY_INTERVAL_DAYS)
- customer_email is a real, deliverable address (placeholders skipped)
The intake link mirrors the one the post-payment email uses:
@ -19,7 +32,10 @@ The intake link mirrors the one the post-payment email uses:
Tracking columns (migration 087):
intake_reminder_count incremented on every send (cap MAX_REMINDERS)
intake_reminder_last_at set to now() on every send (one-per-day gate)
intake_reminder_last_at set to now() on every send (cadence gate)
To re-engage a previously-capped order, clear intake_reminder_last_at (set
NULL); the next run picks it up regardless of phase.
Cron: daily at noon America/New_York. systemd OnCalendar runs in UTC, so the
timer fires at 16:00 (EST) / 17:00 (EDT); see the worker-crons role. The
@ -55,9 +71,18 @@ SMTP_USER = os.getenv("SMTP_USER", "noreply@performancewest.net")
SMTP_PASS = os.getenv("SMTP_PASS", "")
SMTP_FROM = os.getenv("SMTP_FROM", "Performance West <noreply@performancewest.net>")
# Per-order reminder cap. After this many nudges we stop emailing this order and
# let ops follow up manually.
MAX_REMINDERS = int(os.getenv("INTAKE_REMINDER_MAX", "10"))
# Absolute per-order ceiling. With the weekly fallback below this is ~a year of
# nudges before we give up and leave it to ops. Generous on purpose: a paid
# order with no intake is money we owe work on, so we keep gently reaching out.
MAX_REMINDERS = int(os.getenv("INTAKE_REMINDER_MAX", "60"))
# How many of the first reminders go out on the daily cadence (the initial
# burst right after payment). After this many, we fall back to WEEKLY so the
# customer keeps hearing from us without being nagged every single day.
DAILY_PHASE = int(os.getenv("INTAKE_REMINDER_DAILY_PHASE", "10"))
# Weekly-phase spacing (days) once the daily burst is exhausted.
WEEKLY_INTERVAL_DAYS = int(os.getenv("INTAKE_REMINDER_WEEKLY_INTERVAL_DAYS", "7"))
# Only remind once payment has settled long enough for the post-payment intake
# email to have been delivered first (avoids double-emailing within the hour).
@ -107,24 +132,33 @@ def _build_html(customer_name: str, services: list[dict], nth: int) -> str:
f'text-decoration:underline;">{s["name"]}</a></li>'
for s in services
)
# Tone escalates a little with repeated nudges but stays friendly.
# Tone escalates across the cadence but always stays friendly. `nth` is the
# reminder number about to be sent (1-indexed): the daily burst is 1..DAILY_PHASE,
# the weekly fallback is DAILY_PHASE+1..MAX_REMINDERS.
if nth <= 1:
lede = (
"Thanks for your order! To start preparing your filing we just need "
"you to complete a short intake form for each service below."
)
elif nth < MAX_REMINDERS:
lede = (
"We're still waiting on your intake details before we can prepare "
"your filing. It only takes a couple of minutes — please complete "
"the form(s) below."
)
else:
elif nth >= MAX_REMINDERS:
lede = (
"This is our final automated reminder. Your filing is on hold until "
"we receive your intake details. Please complete the form(s) below, "
"or reply to this email and we'll be glad to help."
)
elif nth > DAILY_PHASE:
lede = (
"We're still holding your paid order open and ready to file — we just "
"need your intake details to proceed. It only takes a couple of "
"minutes. Please complete the form(s) below, or reply to this email "
"and we'll be glad to help."
)
else:
lede = (
"We're still waiting on your intake details before we can prepare "
"your filing. It only takes a couple of minutes — please complete "
"the form(s) below."
)
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
@ -164,8 +198,13 @@ def run() -> int:
skipped_placeholder = set()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
# All paid + intake-incomplete orders still under the reminder cap,
# not already reminded today, aged past MIN_AGE_HOURS.
# All paid + intake-incomplete orders still under the absolute cap,
# aged past MIN_AGE_HOURS, that are DUE for their cadence phase:
# - daily phase (count < DAILY_PHASE): due if not reminded today.
# - weekly phase (count >= DAILY_PHASE): due if last reminder was
# more than WEEKLY_INTERVAL_DAYS ago.
# A NULL last-reminded is always due (covers brand-new orders AND
# any order an operator manually re-armed by clearing the column).
cur.execute(
"""
SELECT order_number, service_slug, service_name,
@ -174,16 +213,29 @@ def run() -> int:
FROM compliance_orders
WHERE payment_status = 'paid'
AND COALESCE(intake_data_validated, FALSE) = FALSE
AND intake_reminder_count < %s
AND intake_reminder_count < %(max_reminders)s
AND paid_at IS NOT NULL
AND paid_at < now() - (%s || ' hours')::interval
AND paid_at < now() - (%(min_age_hours)s || ' hours')::interval
AND (
intake_reminder_last_at IS NULL
OR intake_reminder_last_at < date_trunc('day', now())
OR (
intake_reminder_count < %(daily_phase)s
AND intake_reminder_last_at < date_trunc('day', now())
)
OR (
intake_reminder_count >= %(daily_phase)s
AND intake_reminder_last_at
< now() - (%(weekly_days)s || ' days')::interval
)
)
ORDER BY customer_email, order_number
""",
(MAX_REMINDERS, str(MIN_AGE_HOURS)),
{
"max_reminders": MAX_REMINDERS,
"min_age_hours": str(MIN_AGE_HOURS),
"daily_phase": DAILY_PHASE,
"weekly_days": str(WEEKLY_INTERVAL_DAYS),
},
)
rows = cur.fetchall()