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:
parent
ba6f171c9d
commit
ef3b7a96f0
1 changed files with 76 additions and 24 deletions
|
|
@ -3,15 +3,28 @@
|
||||||
|
|
||||||
After a customer pays for a compliance service we email them an intake form
|
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
|
link so we can collect the information needed to prepare the filing. Some
|
||||||
customers never complete intake, which stalls fulfillment. This worker runs
|
customers never complete intake, which stalls fulfillment of an order we've
|
||||||
once a day (noon ET) and nudges any PAID order whose intake is still
|
already been paid for. This worker runs once a day (noon ET) and nudges any
|
||||||
incomplete, up to a per-order cap.
|
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):
|
Eligibility (a row is reminded when ALL hold):
|
||||||
- payment_status = 'paid'
|
- payment_status = 'paid'
|
||||||
- intake_data_validated IS NOT TRUE (intake not yet completed)
|
- intake_data_validated IS NOT TRUE (intake not yet completed)
|
||||||
- intake_reminder_count < MAX_REMINDERS (default 10)
|
- intake_reminder_count < MAX_REMINDERS (absolute ceiling)
|
||||||
- intake_reminder_last_at IS NULL OR < today (at most one reminder/day)
|
- 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)
|
- customer_email is a real, deliverable address (placeholders skipped)
|
||||||
|
|
||||||
The intake link mirrors the one the post-payment email uses:
|
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):
|
Tracking columns (migration 087):
|
||||||
intake_reminder_count incremented on every send (cap MAX_REMINDERS)
|
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
|
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
|
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_PASS = os.getenv("SMTP_PASS", "")
|
||||||
SMTP_FROM = os.getenv("SMTP_FROM", "Performance West <noreply@performancewest.net>")
|
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
|
# Absolute per-order ceiling. With the weekly fallback below this is ~a year of
|
||||||
# let ops follow up manually.
|
# nudges before we give up and leave it to ops. Generous on purpose: a paid
|
||||||
MAX_REMINDERS = int(os.getenv("INTAKE_REMINDER_MAX", "10"))
|
# 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
|
# 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).
|
# 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>'
|
f'text-decoration:underline;">{s["name"]}</a></li>'
|
||||||
for s in services
|
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:
|
if nth <= 1:
|
||||||
lede = (
|
lede = (
|
||||||
"Thanks for your order! To start preparing your filing we just need "
|
"Thanks for your order! To start preparing your filing we just need "
|
||||||
"you to complete a short intake form for each service below."
|
"you to complete a short intake form for each service below."
|
||||||
)
|
)
|
||||||
elif nth < MAX_REMINDERS:
|
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:
|
|
||||||
lede = (
|
lede = (
|
||||||
"This is our final automated reminder. Your filing is on hold until "
|
"This is our final automated reminder. Your filing is on hold until "
|
||||||
"we receive your intake details. Please complete the form(s) below, "
|
"we receive your intake details. Please complete the form(s) below, "
|
||||||
"or reply to this email and we'll be glad to help."
|
"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>
|
return f"""<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
<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()
|
skipped_placeholder = set()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
# All paid + intake-incomplete orders still under the reminder cap,
|
# All paid + intake-incomplete orders still under the absolute cap,
|
||||||
# not already reminded today, aged past MIN_AGE_HOURS.
|
# 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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT order_number, service_slug, service_name,
|
SELECT order_number, service_slug, service_name,
|
||||||
|
|
@ -174,16 +213,29 @@ def run() -> int:
|
||||||
FROM compliance_orders
|
FROM compliance_orders
|
||||||
WHERE payment_status = 'paid'
|
WHERE payment_status = 'paid'
|
||||||
AND COALESCE(intake_data_validated, FALSE) = FALSE
|
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 IS NOT NULL
|
||||||
AND paid_at < now() - (%s || ' hours')::interval
|
AND paid_at < now() - (%(min_age_hours)s || ' hours')::interval
|
||||||
AND (
|
AND (
|
||||||
intake_reminder_last_at IS NULL
|
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
|
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()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue