Jun 29 - Add trial reminder sent function

This commit is contained in:
2026-06-29 10:04:45 -04:00
parent 42790c499c
commit 7022e46942
5 changed files with 487 additions and 4 deletions
+128 -1
View File
@@ -305,4 +305,131 @@ def check_score_trends():
sent = send_score_alerts(**kwargs)
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent)
return jsonify({'ok': True, 'alerts_sent': sent})
return jsonify({'ok': True, 'alerts_sent': sent})
# ── Trial-ending reminder (called by cron) ────────────────────────────────────
@bp.route('/trial-reminders', methods=['POST'])
@csrf.exempt
def trial_reminders():
"""Send trial-ending warning emails for tenants whose trial expires within 3 days.
Cross-tenant: queries the control DB and iterates every qualifying tenant.
Exempt from tenant middleware — must be callable without a tenant Host header.
Protected by DIGEST_SECRET. Sets trial_reminder_sent_at on the Tenant row
so re-runs within 22 hours are skipped (handles cron timing jitter).
Recommended cron schedule — once per day at 09:00 is sufficient:
0 9 * * * curl -s -X POST https://yourdomain.com/notifications/trial-reminders \\
-d "token=YOUR_DIGEST_SECRET"
"""
token = request.form.get('token') or request.args.get('token')
expected = current_app.config.get('DIGEST_SECRET')
if not expected or token != expected:
logger.warning('TRIAL REMINDERS REJECTED | bad or missing token')
abort(403)
if not current_app.config.get('BILLING_ENABLED', False):
return jsonify({'ok': True, 'skipped': 'billing_disabled', 'sent': 0})
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
return jsonify({'ok': True, 'skipped': 'multi_tenant_disabled', 'sent': 0})
from datetime import timedelta
from app.utils.time_utils import now_eastern
from app.billing.emails import send_billing_email
try:
from control.base import control_session
from control.models import Tenant as ControlTenant, TenantDomain
except ImportError as exc:
logger.error('TRIAL REMINDERS | control import failed: %s', exc)
return jsonify({'ok': False, 'error': str(exc)}), 500
now = now_eastern()
window_end = now + timedelta(days=3)
resend_gap = timedelta(hours=22)
sent = 0
skipped = 0
errors = 0
with control_session() as s:
candidates = (
s.query(ControlTenant)
.filter(
ControlTenant.subscription_status == 'trial',
ControlTenant.trial_ends_at.isnot(None),
ControlTenant.trial_ends_at > now, # not yet expired
ControlTenant.trial_ends_at <= window_end, # expires within 3 days
)
.all()
)
for t in candidates:
# Skip if we already sent a reminder recently.
if t.trial_reminder_sent_at and (now - t.trial_reminder_sent_at) < resend_gap:
skipped += 1
continue
# Resolve recipient email: billing_email first, fall back to tenant DB admin.
email = t.billing_email
if not email:
try:
from sqlalchemy import create_engine, text as sa_text
eng = create_engine(t.db_uri, pool_pre_ping=True,
connect_args={'connect_timeout': 5})
with eng.connect() as conn:
row = conn.execute(sa_text(
"SELECT email FROM users "
"WHERE role='admin' AND active=1 "
"ORDER BY id LIMIT 1"
)).fetchone()
if row:
email = row[0]
eng.dispose()
except Exception as exc:
logger.error('TRIAL REMINDERS | admin email lookup failed | '
'tenant=%s err=%s', t.slug, exc)
if not email:
logger.warning('TRIAL REMINDERS | no email | tenant=%s', t.slug)
skipped += 1
continue
# Build subscribe URL from tenant's primary verified domain.
primary = next(
(d.domain for d in t.domains if d.is_primary and d.verified),
None,
)
subscribe_url = (
f'https://{primary}/billing/subscribe'
if primary
else current_app.config.get('APP_BASE_URL', '') + '/billing/subscribe'
)
days_left = (t.trial_ends_at - now).days
expiry_str = t.trial_ends_at.strftime('%B %d, %Y')
try:
send_billing_email(email, 'trial_ending', {
'trial_ends_at': expiry_str,
'subscribe_url': subscribe_url,
'days_left': days_left,
'tenant_name': t.name,
})
t.trial_reminder_sent_at = now
sent += 1
logger.info('TRIAL REMINDERS | sent | tenant=%s to=%s days_left=%s',
t.slug, email, days_left)
except Exception as exc:
logger.error('TRIAL REMINDERS | send failed | tenant=%s err=%s',
t.slug, exc)
errors += 1
logger.info('TRIAL REMINDERS COMPLETE | sent=%s skipped=%s errors=%s',
sent, skipped, errors)
return jsonify({'ok': True, 'sent': sent, 'skipped': skipped, 'errors': errors})
+2
View File
@@ -57,6 +57,8 @@ def _is_exempt(path):
return True
if path.startswith('/signup'):
return True # public self-service signup has no tenant context
if path.startswith('/notifications/trial-reminders'):
return True # cross-tenant cron — iterates all tenants from control DB
for prefix in current_app.config.get('MULTI_TENANT_EXEMPT_PATHS', []):
if prefix and path.startswith(prefix):
return True