Jun 29 - Update: Fail2ban, welcome email, dunning sequence, escalation

This commit is contained in:
2026-06-29 13:47:57 -04:00
parent c133b5d419
commit b00bbba2da
14 changed files with 414 additions and 1 deletions
+137
View File
@@ -432,4 +432,141 @@ def trial_reminders():
logger.info('TRIAL REMINDERS COMPLETE | sent=%s skipped=%s errors=%s',
sent, skipped, errors)
return jsonify({'ok': True, 'sent': sent, 'skipped': skipped, 'errors': errors})
# ── Dunning reminder (called by cron) ────────────────────────────────────────
@bp.route('/dunning-reminders', methods=['POST'])
@csrf.exempt
def dunning_reminders():
"""Send escalating payment-failure reminder emails to past_due tenants.
Stage logic (based on time since first payment failure):
Stage 0 → 1 (day 3): Friendly reminder
Stage 1 → 2 (day 7): Urgent reminder
Stage 2 → 3 (day 14): Final notice (account cancellation imminent)
Cross-tenant: queries the control DB and iterates every qualifying tenant.
Exempt from tenant middleware — callable without a tenant Host header.
Protected by DIGEST_SECRET.
Recommended cron schedule — once per day at 10:00 is sufficient:
0 10 * * * curl -s -X POST https://yourdomain.com/notifications/dunning-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('DUNNING 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
except ImportError as exc:
logger.error('DUNNING REMINDERS | control import failed: %s', exc)
return jsonify({'ok': False, 'error': str(exc)}), 500
now = now_eastern()
sent = 0
skipped = 0
errors = 0
# Stage thresholds (days since past_due_since)
_STAGES = [
(3, 1, 'payment_reminder'),
(7, 2, 'payment_reminder'),
(14, 3, 'payment_final'),
]
with control_session() as s:
past_due_tenants = (
s.query(ControlTenant)
.filter(
ControlTenant.subscription_status == 'past_due',
ControlTenant.past_due_since.isnot(None),
)
.all()
)
for t in past_due_tenants:
elapsed_days = (now - t.past_due_since).days
# Determine the target stage based on elapsed time
target_stage = 0
target_event = None
for days_threshold, stage_num, event_type in _STAGES:
if elapsed_days >= days_threshold:
target_stage = stage_num
target_event = event_type
if target_stage == 0 or t.dunning_stage >= target_stage:
skipped += 1
continue
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('DUNNING REMINDERS | admin email lookup failed | '
'tenant=%s err=%s', t.slug, exc)
if not email:
logger.warning('DUNNING REMINDERS | no email | tenant=%s', t.slug)
skipped += 1
continue
# Build billing portal URL from tenant's primary verified domain.
primary = next(
(d.domain for d in t.domains if d.is_primary and d.verified),
None,
)
portal_url = (
f'https://{primary}/billing/portal'
if primary
else current_app.config.get('APP_BASE_URL', '') + '/billing/portal'
)
try:
send_billing_email(email, target_event, {
'portal_url': portal_url,
'tenant_name': t.name,
'days_overdue': elapsed_days,
})
t.dunning_stage = target_stage
t.dunning_sent_at = now
sent += 1
logger.info('DUNNING REMINDERS | sent | tenant=%s stage=%s to=%s',
t.slug, target_stage, email)
except Exception as exc:
logger.error('DUNNING REMINDERS | send failed | tenant=%s err=%s',
t.slug, exc)
errors += 1
logger.info('DUNNING REMINDERS COMPLETE | sent=%s skipped=%s errors=%s',
sent, skipped, errors)
return jsonify({'ok': True, 'sent': sent, 'skipped': skipped, 'errors': errors})