From ef3b7a96f09bcb4e571a9ffe81108266f442ffdb Mon Sep 17 00:00:00 2001 From: justin Date: Mon, 15 Jun 2026 22:13:27 -0500 Subject: [PATCH] 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). --- scripts/workers/intake_reminder.py | 100 ++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/scripts/workers/intake_reminder.py b/scripts/workers/intake_reminder.py index 4c184c2..19995b5 100644 --- a/scripts/workers/intake_reminder.py +++ b/scripts/workers/intake_reminder.py @@ -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 ") -# 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"]}' 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""" @@ -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()