From b00bbba2da8d89931cb38a10a9f45ea688eec7e1 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 29 Jun 2026 13:47:57 -0400 Subject: [PATCH] Jun 29 - Update: Fail2ban, welcome email, dunning sequence, escalation --- GOING_LIVE.md | 6 +- app/billing/emails.py | 22 +++ app/billing/webhooks.py | 6 + app/routes/auth.py | 2 + app/routes/notifications.py | 137 ++++++++++++++++++ app/routes/signup.py | 24 +++ .../billing/email/payment_final.html | 43 ++++++ .../billing/email/payment_reminder.html | 37 +++++ app/templates/billing/email/welcome.html | 45 ++++++ app/tenancy/middleware.py | 2 + .../versions/control0004_dunning_tracking.py | 54 +++++++ control/models.py | 5 + deploy/fail2ban/filter.d/jqc-login.conf | 17 +++ deploy/fail2ban/jail.d/jqc.conf | 15 ++ 14 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 app/templates/billing/email/payment_final.html create mode 100644 app/templates/billing/email/payment_reminder.html create mode 100644 app/templates/billing/email/welcome.html create mode 100644 control/migrations/versions/control0004_dunning_tracking.py create mode 100644 deploy/fail2ban/filter.d/jqc-login.conf create mode 100644 deploy/fail2ban/jail.d/jqc.conf diff --git a/GOING_LIVE.md b/GOING_LIVE.md index 0f77559..b8eaeea 100644 --- a/GOING_LIVE.md +++ b/GOING_LIVE.md @@ -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 \ -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 \ -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 0 2 * * * set -a; . /etc/jqc/control.env; set +a; \ python -m control.backup --tenant all \ diff --git a/app/billing/emails.py b/app/billing/emails.py index dbbe754..82a0b46 100644 --- a/app/billing/emails.py +++ b/app/billing/emails.py @@ -15,23 +15,45 @@ from app import mail logger = logging.getLogger(__name__) _SUBJECTS = { + 'welcome': 'Welcome to JQC — your workspace is ready', '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', 'subscription_cancelled': 'Your JQC subscription has been cancelled', } _HTML_TEMPLATES = { + 'welcome': 'billing/email/welcome.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', 'subscription_cancelled': 'billing/email/subscription_cancelled.html', } _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': ( "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" " {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': ( "Your JQC free trial will end on {trial_ends_at}.\n\n" "Subscribe now to keep your workspace active:\n" diff --git a/app/billing/webhooks.py b/app/billing/webhooks.py index 77053f9..87db24b 100644 --- a/app/billing/webhooks.py +++ b/app/billing/webhooks.py @@ -210,6 +210,9 @@ def _on_payment_succeeded(obj): if t.subscription_status == 'past_due': t.subscription_status = 'active' + t.past_due_since = None + t.dunning_stage = 0 + t.dunning_sent_at = None if period_end: t.current_period_end = _ts_to_dt(period_end) @@ -236,6 +239,9 @@ def _on_payment_failed(obj): return 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 s.add(TenantAudit( diff --git a/app/routes/auth.py b/app/routes/auth.py index d83f461..6d6872d 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -44,6 +44,8 @@ def login(): else: # Generic message — don't reveal whether the username exists 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) diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 34ab6a8..efd504b 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -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}) \ No newline at end of file diff --git a/app/routes/signup.py b/app/routes/signup.py index 1cb2f37..fd951b8 100644 --- a/app/routes/signup.py +++ b/app/routes/signup.py @@ -25,6 +25,23 @@ from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationE bp = Blueprint('signup', __name__, url_prefix='/signup') 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])?$') @@ -119,6 +136,13 @@ def index(): login_url = f'https://{slug}.{base_domain}/auth/login' logger.info('SIGNUP | provisioned | slug=%s plan=%s tenant_id=%s', 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', login_url=login_url, slug=slug, diff --git a/app/templates/billing/email/payment_final.html b/app/templates/billing/email/payment_final.html new file mode 100644 index 0000000..6b4c796 --- /dev/null +++ b/app/templates/billing/email/payment_final.html @@ -0,0 +1,43 @@ +{% extends "billing/email/base.html" %} +{% set subject = "Final Notice: Your JQC account will be cancelled soon" %} +{% block body %} +

Final Notice — Action Required

+

+ Your JQC subscription payment has been outstanding for 14 days. + Your account will be cancelled within 24 hours if payment is not received. +

+ + + + + +
+

+ ✗ Account cancellation is imminent.
+ Update your payment method now to prevent losing access to all your inspection data, + reports, and workspace. +

+
+ + + + + +
+ + Pay Now — Keep My Account + +
+ +

+ Once cancelled, your workspace data is retained for 30 days before permanent deletion. + You can reactivate during this window by contacting support. +

+ +

+ Questions? Contact us at support@jqc.app. +

+{% endblock %} diff --git a/app/templates/billing/email/payment_reminder.html b/app/templates/billing/email/payment_reminder.html new file mode 100644 index 0000000..7eef11a --- /dev/null +++ b/app/templates/billing/email/payment_reminder.html @@ -0,0 +1,37 @@ +{% extends "billing/email/base.html" %} +{% set subject = "Reminder: Payment still needed for your JQC subscription" %} +{% block body %} +

Payment reminder

+

+ Your JQC subscription payment is still outstanding. Please update your payment + method to restore full access to your workspace. +

+ + + + + +
+

+ ⚠ Your workspace access is limited until payment is received. + Update your payment method to remove this restriction. +

+
+ + + + + +
+ + Update Payment Method + +
+ +

+ If you have any questions, reply to this email or contact + support@jqc.app. +

+{% endblock %} diff --git a/app/templates/billing/email/welcome.html b/app/templates/billing/email/welcome.html new file mode 100644 index 0000000..fbbdf99 --- /dev/null +++ b/app/templates/billing/email/welcome.html @@ -0,0 +1,45 @@ +{% extends "billing/email/base.html" %} +{% set subject = "Welcome to JQC — your workspace is ready" %} +{% block body %} +

Welcome to Janitorial QC!

+

Your workspace {{ tenant_name }} is ready. You're on a free trial — no credit card needed until {{ trial_ends_at }}.

+ + + + + +
+

+ ✓ Workspace: {{ login_url }}
+ ✓ Free trial: {{ trial_days }} days (expires {{ trial_ends_at }})
+ ✓ Plan: {{ plan_name }} +

+
+ +

+ Here's what you can do right now: +

+ + + + + + +
+ + Go to My Workspace + +
+ +

+ Your login URL: {{ login_url }}
+ Need help? Reply to this email or visit support@jqc.app. +

+{% endblock %} diff --git a/app/tenancy/middleware.py b/app/tenancy/middleware.py index daf3410..2cd526e 100644 --- a/app/tenancy/middleware.py +++ b/app/tenancy/middleware.py @@ -59,6 +59,8 @@ def _is_exempt(path): 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 + 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', []): if prefix and path.startswith(prefix): return True diff --git a/control/migrations/versions/control0004_dunning_tracking.py b/control/migrations/versions/control0004_dunning_tracking.py new file mode 100644 index 0000000..af1b7c9 --- /dev/null +++ b/control/migrations/versions/control0004_dunning_tracking.py @@ -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) diff --git a/control/models.py b/control/models.py index ea50eed..3965d47 100644 --- a/control/models.py +++ b/control/models.py @@ -113,6 +113,11 @@ class Tenant(ControlBase): current_period_end = Column(DateTime, 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') domains = relationship('TenantDomain', back_populates='tenant', cascade='all, delete-orphan') diff --git a/deploy/fail2ban/filter.d/jqc-login.conf b/deploy/fail2ban/filter.d/jqc-login.conf new file mode 100644 index 0000000..7971685 --- /dev/null +++ b/deploy/fail2ban/filter.d/jqc-login.conf @@ -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= username= +# +# 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= .*$ +ignoreregex = diff --git a/deploy/fail2ban/jail.d/jqc.conf b/deploy/fail2ban/jail.d/jqc.conf new file mode 100644 index 0000000..69d6c15 --- /dev/null +++ b/deploy/fail2ban/jail.d/jqc.conf @@ -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]