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
+5 -1
View File
@@ -243,10 +243,14 @@ Add all six jobs (replace `YOUR_SECRET` and `yourdomain.jqc.app`):
0 3 * * * curl -s -X POST https://yourdomain.jqc.app/notifications/cleanup-tokens \ 0 3 * * * curl -s -X POST https://yourdomain.jqc.app/notifications/cleanup-tokens \
-d "token=YOUR_DIGEST_SECRET" >> /var/www/jqc/logs/cron.log 2>&1 -d "token=YOUR_DIGEST_SECRET" >> /var/www/jqc/logs/cron.log 2>&1
# Trial-ending reminder emails — 9 AM daily ← NEW # Trial-ending reminder emails — 9 AM daily
0 9 * * * curl -s -X POST https://yourdomain.jqc.app/notifications/trial-reminders \ 0 9 * * * curl -s -X POST https://yourdomain.jqc.app/notifications/trial-reminders \
-d "token=YOUR_DIGEST_SECRET" >> /var/www/jqc/logs/cron.log 2>&1 -d "token=YOUR_DIGEST_SECRET" >> /var/www/jqc/logs/cron.log 2>&1
# Payment dunning reminders (day 3 / 7 / 14 escalation) — 10 AM daily ← NEW
0 10 * * * curl -s -X POST https://yourdomain.jqc.app/notifications/dunning-reminders \
-d "token=YOUR_DIGEST_SECRET" >> /var/www/jqc/logs/cron.log 2>&1
# Per-tenant DB backup — 2 AM daily # Per-tenant DB backup — 2 AM daily
0 2 * * * set -a; . /etc/jqc/control.env; set +a; \ 0 2 * * * set -a; . /etc/jqc/control.env; set +a; \
python -m control.backup --tenant all \ python -m control.backup --tenant all \
+22
View File
@@ -15,23 +15,45 @@ from app import mail
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SUBJECTS = { _SUBJECTS = {
'welcome': 'Welcome to JQC — your workspace is ready',
'payment_failed': 'Action Required: Payment failed for your JQC subscription', 'payment_failed': 'Action Required: Payment failed for your JQC subscription',
'payment_reminder': 'Reminder: Payment still needed for your JQC subscription',
'payment_final': 'Final Notice: Your JQC account will be cancelled soon',
'trial_ending': 'Your JQC free trial ends in 3 days', 'trial_ending': 'Your JQC free trial ends in 3 days',
'subscription_cancelled': 'Your JQC subscription has been cancelled', 'subscription_cancelled': 'Your JQC subscription has been cancelled',
} }
_HTML_TEMPLATES = { _HTML_TEMPLATES = {
'welcome': 'billing/email/welcome.html',
'payment_failed': 'billing/email/payment_failed.html', 'payment_failed': 'billing/email/payment_failed.html',
'payment_reminder': 'billing/email/payment_reminder.html',
'payment_final': 'billing/email/payment_final.html',
'trial_ending': 'billing/email/trial_ending.html', 'trial_ending': 'billing/email/trial_ending.html',
'subscription_cancelled': 'billing/email/subscription_cancelled.html', 'subscription_cancelled': 'billing/email/subscription_cancelled.html',
} }
_PLAIN_BODIES = { _PLAIN_BODIES = {
'welcome': (
"Welcome to Janitorial QC!\n\n"
"Your workspace is ready at:\n {login_url}\n\n"
"Trial: {trial_days} days (expires {trial_ends_at})\n\n— The JQC Team"
),
'payment_failed': ( 'payment_failed': (
"We were unable to process your most recent payment for your JQC subscription.\n\n" "We were unable to process your most recent payment for your JQC subscription.\n\n"
"Please update your payment method to avoid interruption to your service:\n" "Please update your payment method to avoid interruption to your service:\n"
" {portal_url}\n\n— The JQC Team" " {portal_url}\n\n— The JQC Team"
), ),
'payment_reminder': (
"Friendly reminder: your JQC subscription payment is still outstanding.\n\n"
"Update your payment method to restore full access:\n"
" {portal_url}\n\n— The JQC Team"
),
'payment_final': (
"This is a final notice: your JQC subscription will be cancelled within 24 hours\n"
"if payment is not received.\n\n"
"Update your payment method now to keep your workspace:\n"
" {portal_url}\n\n— The JQC Team"
),
'trial_ending': ( 'trial_ending': (
"Your JQC free trial will end on {trial_ends_at}.\n\n" "Your JQC free trial will end on {trial_ends_at}.\n\n"
"Subscribe now to keep your workspace active:\n" "Subscribe now to keep your workspace active:\n"
+6
View File
@@ -210,6 +210,9 @@ def _on_payment_succeeded(obj):
if t.subscription_status == 'past_due': if t.subscription_status == 'past_due':
t.subscription_status = 'active' t.subscription_status = 'active'
t.past_due_since = None
t.dunning_stage = 0
t.dunning_sent_at = None
if period_end: if period_end:
t.current_period_end = _ts_to_dt(period_end) t.current_period_end = _ts_to_dt(period_end)
@@ -236,6 +239,9 @@ def _on_payment_failed(obj):
return return
t.subscription_status = 'past_due' t.subscription_status = 'past_due'
if t.past_due_since is None:
from control.time_utils import now_eastern
t.past_due_since = now_eastern()
billing_email = t.billing_email billing_email = t.billing_email
s.add(TenantAudit( s.add(TenantAudit(
+2
View File
@@ -44,6 +44,8 @@ def login():
else: else:
# Generic message — don't reveal whether the username exists # Generic message — don't reveal whether the username exists
flash('Invalid credentials. Please try again.', 'danger') flash('Invalid credentials. Please try again.', 'danger')
logger.warning('LOGIN_FAILED | ip=%s username=%s',
request.remote_addr, form.username.data)
return render_template('auth/login.html', form=form) return render_template('auth/login.html', form=form)
+137
View File
@@ -433,3 +433,140 @@ def trial_reminders():
logger.info('TRIAL REMINDERS COMPLETE | sent=%s skipped=%s errors=%s', logger.info('TRIAL REMINDERS COMPLETE | sent=%s skipped=%s errors=%s',
sent, skipped, errors) sent, skipped, errors)
return jsonify({'ok': True, 'sent': sent, 'skipped': skipped, 'errors': 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})
+24
View File
@@ -25,6 +25,23 @@ from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationE
bp = Blueprint('signup', __name__, url_prefix='/signup') bp = Blueprint('signup', __name__, url_prefix='/signup')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _send_welcome_email(to_email, login_url, tenant_name, plan_code, trial_ends_at):
"""Fire a welcome email in a background thread — never blocks the request."""
try:
from app.billing.emails import send_billing_email
ends_str = (trial_ends_at.strftime('%B %d, %Y')
if hasattr(trial_ends_at, 'strftime') else str(trial_ends_at or ''))
send_billing_email(to_email, 'welcome', {
'tenant_name': tenant_name,
'login_url': login_url,
'trial_days': 14,
'trial_ends_at': ends_str,
'plan_name': plan_code.title(),
})
except Exception as exc:
logger.error('SIGNUP | welcome_email_failed | to=%s err=%s', to_email, exc)
_SLUG_RE = re.compile(r'^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$') _SLUG_RE = re.compile(r'^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$')
@@ -119,6 +136,13 @@ def index():
login_url = f'https://{slug}.{base_domain}/auth/login' login_url = f'https://{slug}.{base_domain}/auth/login'
logger.info('SIGNUP | provisioned | slug=%s plan=%s tenant_id=%s', logger.info('SIGNUP | provisioned | slug=%s plan=%s tenant_id=%s',
slug, plan_code, info['tenant_id']) slug, plan_code, info['tenant_id'])
_send_welcome_email(
to_email = email,
login_url = login_url,
tenant_name = company,
plan_code = plan_code,
trial_ends_at = info.get('trial_ends_at'),
)
return render_template('signup/success.html', return render_template('signup/success.html',
login_url=login_url, login_url=login_url,
slug=slug, slug=slug,
@@ -0,0 +1,43 @@
{% extends "billing/email/base.html" %}
{% set subject = "Final Notice: Your JQC account will be cancelled soon" %}
{% block body %}
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#b91c1c;">Final Notice — Action Required</h2>
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">
Your JQC subscription payment has been outstanding for 14 days.
Your account will be <strong>cancelled within 24 hours</strong> if payment is not received.
</p>
<table width="100%" cellpadding="12" cellspacing="0" style="background:#fef2f2;border-radius:6px;margin-bottom:24px;border:1px solid #fca5a5;">
<tr>
<td>
<p style="margin:0;font-size:.9rem;color:#7f1d1d;line-height:1.6;">
<strong>&#10007; Account cancellation is imminent.</strong><br>
Update your payment method now to prevent losing access to all your inspection data,
reports, and workspace.
</p>
</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr>
<td align="center">
<a href="{{ portal_url }}"
style="display:inline-block;background:#dc2626;color:#fff;text-decoration:none;
padding:14px 32px;border-radius:6px;font-size:1rem;font-weight:700;
letter-spacing:.02em;">
Pay Now — Keep My Account
</a>
</td>
</tr>
</table>
<p style="margin:0 0 12px;font-size:.85rem;color:#374151;line-height:1.6;">
Once cancelled, your workspace data is retained for 30 days before permanent deletion.
You can reactivate during this window by contacting support.
</p>
<p style="margin:0;font-size:.85rem;color:#9ca3af;">
Questions? Contact us at <a href="mailto:support@jqc.app" style="color:#1a56db;">support@jqc.app</a>.
</p>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "billing/email/base.html" %}
{% set subject = "Reminder: Payment still needed for your JQC subscription" %}
{% block body %}
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#111827;">Payment reminder</h2>
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">
Your JQC subscription payment is still outstanding. Please update your payment
method to restore full access to your workspace.
</p>
<table width="100%" cellpadding="12" cellspacing="0" style="background:#fefce8;border-radius:6px;margin-bottom:24px;border:1px solid #fde68a;">
<tr>
<td>
<p style="margin:0;font-size:.9rem;color:#92400e;line-height:1.6;">
<strong>&#9888; Your workspace access is limited</strong> until payment is received.
Update your payment method to remove this restriction.
</p>
</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr>
<td align="center">
<a href="{{ portal_url }}"
style="display:inline-block;background:#1a56db;color:#fff;text-decoration:none;
padding:12px 28px;border-radius:6px;font-size:.95rem;font-weight:600;">
Update Payment Method
</a>
</td>
</tr>
</table>
<p style="margin:0;font-size:.85rem;color:#9ca3af;">
If you have any questions, reply to this email or contact
<a href="mailto:support@jqc.app" style="color:#1a56db;">support@jqc.app</a>.
</p>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "billing/email/base.html" %}
{% set subject = "Welcome to JQC — your workspace is ready" %}
{% block body %}
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#111827;">Welcome to Janitorial QC!</h2>
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">Your workspace <strong>{{ tenant_name }}</strong> is ready. You're on a free trial — no credit card needed until {{ trial_ends_at }}.</p>
<table width="100%" cellpadding="12" cellspacing="0" style="background:#f0fdf4;border-radius:6px;margin-bottom:24px;">
<tr>
<td>
<p style="margin:0;font-size:.9rem;color:#15803d;line-height:1.6;">
<strong>&#10003; Workspace:</strong> <a href="{{ login_url }}" style="color:#15803d;">{{ login_url }}</a><br>
<strong>&#10003; Free trial:</strong> {{ trial_days }} days (expires {{ trial_ends_at }})<br>
<strong>&#10003; Plan:</strong> {{ plan_name }}
</p>
</td>
</tr>
</table>
<p style="margin:0 0 16px;font-size:.9rem;color:#374151;line-height:1.6;">
Here's what you can do right now:
</p>
<ul style="margin:0 0 24px;padding-left:1.25rem;font-size:.9rem;color:#374151;line-height:1.8;">
<li>Add your facilities and areas</li>
<li>Create inspection templates</li>
<li>Invite your team members</li>
<li>Run your first inspection</li>
</ul>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr>
<td align="center">
<a href="{{ login_url }}"
style="display:inline-block;background:#1a56db;color:#fff;text-decoration:none;
padding:12px 28px;border-radius:6px;font-size:.95rem;font-weight:600;">
Go to My Workspace
</a>
</td>
</tr>
</table>
<p style="margin:0;font-size:.85rem;color:#9ca3af;">
Your login URL: <a href="{{ login_url }}" style="color:#1a56db;word-break:break-all;">{{ login_url }}</a><br>
Need help? Reply to this email or visit <a href="mailto:support@jqc.app" style="color:#1a56db;">support@jqc.app</a>.
</p>
{% endblock %}
+2
View File
@@ -59,6 +59,8 @@ def _is_exempt(path):
return True # public self-service signup has no tenant context return True # public self-service signup has no tenant context
if path.startswith('/notifications/trial-reminders'): if path.startswith('/notifications/trial-reminders'):
return True # cross-tenant cron — iterates all tenants from control DB return True # cross-tenant cron — iterates all tenants from control DB
if path.startswith('/notifications/dunning-reminders'):
return True # cross-tenant cron — iterates past_due tenants from control DB
for prefix in current_app.config.get('MULTI_TENANT_EXEMPT_PATHS', []): for prefix in current_app.config.get('MULTI_TENANT_EXEMPT_PATHS', []):
if prefix and path.startswith(prefix): if prefix and path.startswith(prefix):
return True return True
@@ -0,0 +1,54 @@
"""control0004_dunning_tracking
Adds three columns to `tenants` for tracking dunning (payment-failure
reminder) emails:
- past_due_since DATETIME NULL when the tenant first entered past_due state
- dunning_stage TINYINT DEFAULT 0 0=none, 1=day3, 2=day7, 3=day14
- dunning_sent_at DATETIME NULL timestamp of the last dunning email sent
All columns are guarded by INFORMATION_SCHEMA existence checks so this
migration is safe to re-run.
Revision ID: control0004_dunning_tracking
Revises: control0003_trial_reminder_sent
Create Date: 2026-06-29
"""
from alembic import op
import sqlalchemy as sa
revision = 'control0004_dunning_tracking'
down_revision = 'control0003_trial_reminder_sent'
branch_labels = None
depends_on = None
def _column_exists(table, column):
result = op.get_bind().execute(sa.text(
"SELECT COUNT(*) FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
" AND TABLE_NAME = :tbl "
" AND COLUMN_NAME = :col"
), {'tbl': table, 'col': column})
return result.scalar() > 0
def upgrade():
if not _column_exists('tenants', 'past_due_since'):
op.add_column('tenants', sa.Column('past_due_since',
sa.DateTime(), nullable=True))
if not _column_exists('tenants', 'dunning_stage'):
op.add_column('tenants', sa.Column('dunning_stage',
sa.SmallInteger(), nullable=False,
server_default='0'))
if not _column_exists('tenants', 'dunning_sent_at'):
op.add_column('tenants', sa.Column('dunning_sent_at',
sa.DateTime(), nullable=True))
def downgrade():
for col in ('dunning_sent_at', 'dunning_stage', 'past_due_since'):
if _column_exists('tenants', col):
op.drop_column('tenants', col)
+5
View File
@@ -113,6 +113,11 @@ class Tenant(ControlBase):
current_period_end = Column(DateTime, nullable=True) current_period_end = Column(DateTime, nullable=True)
billing_email = Column(String(255), nullable=True) billing_email = Column(String(255), nullable=True)
# ── Dunning (payment failure reminders) ────────────────────────────────
past_due_since = Column(DateTime, nullable=True) # set when payment first fails
dunning_stage = Column(Integer, nullable=False, default=0) # 0=none 1=day3 2=day7 3=day14
dunning_sent_at = Column(DateTime, nullable=True) # last dunning email timestamp
plan = relationship('Plan', back_populates='tenants') plan = relationship('Plan', back_populates='tenants')
domains = relationship('TenantDomain', back_populates='tenant', domains = relationship('TenantDomain', back_populates='tenant',
cascade='all, delete-orphan') cascade='all, delete-orphan')
+17
View File
@@ -0,0 +1,17 @@
# /etc/fail2ban/filter.d/jqc-login.conf
#
# Detects repeated login failures from the JQC application log.
# The app logs: WARNING LOGIN_FAILED | ip=<IP> username=<user>
#
# Deploy:
# sudo cp deploy/fail2ban/filter.d/jqc-login.conf /etc/fail2ban/filter.d/
# sudo cp deploy/fail2ban/jail.d/jqc.conf /etc/fail2ban/jail.d/
# sudo systemctl restart fail2ban
# sudo fail2ban-client status jqc-login # verify
[INCLUDES]
before = common.conf
[Definition]
failregex = ^.*WARNING LOGIN_FAILED \| ip=<HOST> .*$
ignoreregex =
+15
View File
@@ -0,0 +1,15 @@
# /etc/fail2ban/jail.d/jqc.conf
#
# Jail for JQC login brute-force protection.
# Bans IPs that fail login 5 times within 5 minutes for 1 hour.
#
# Adjust logpath to match your actual log file location.
[jqc-login]
enabled = true
filter = jqc-login
logpath = /var/www/jqc/logs/jqc.log
maxretry = 5
findtime = 300
bantime = 3600
action = iptables-multiport[name=jqc, port="80,443", protocol=tcp]