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 %} +
+ 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. |
+
| + + 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 %} ++ 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 %} +Your workspace {{ tenant_name }} is ready. You're on a free trial — no credit card needed until {{ trial_ends_at }}.
+ +|
+
+ ✓ Workspace: {{ login_url }} |
+
+ 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.
+