From 7022e469428428428299f71d2085d226bad22698 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 29 Jun 2026 10:04:45 -0400 Subject: [PATCH] Jun 29 - Add trial reminder sent function --- GOING_LIVE.md | 313 ++++++++++++++++++ app/routes/notifications.py | 129 +++++++- app/tenancy/middleware.py | 2 + .../control0002_trial_reminder_sent.py | 40 +++ control/models.py | 7 +- 5 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 GOING_LIVE.md create mode 100644 control/migrations/versions/control0002_trial_reminder_sent.py diff --git a/GOING_LIVE.md b/GOING_LIVE.md new file mode 100644 index 0000000..0f77559 --- /dev/null +++ b/GOING_LIVE.md @@ -0,0 +1,313 @@ +# Going Live — JQC Billing & Email Checklist + +Steps to complete before accepting real paying customers. +Follow sections in order: Stripe first, then email, then cron, then verify. + +--- + +## 1. Switch Stripe from Test → Live Mode + +### 1.1 Get your live API keys + +1. Log in to [dashboard.stripe.com](https://dashboard.stripe.com) +2. Click the toggle in the top-left from **Test mode** → **Live mode** +3. Go to **Developers → API keys** +4. Copy: + - **Secret key** — starts with `sk_live_…` + - **Publishable key** — starts with `pk_live_…` + +### 1.2 Create live Products and Price IDs + +> You must do this in **live mode** — test-mode prices are not valid for real payments. + +1. Go to **Products → + Add product** +2. Create three products: **Starter**, **Pro**, **Enterprise** +3. For each, add a **recurring price** (monthly) +4. Copy each **Price ID** — starts with `price_live_…` (NOT `prod_…`) + +### 1.3 Update `/etc/jqc/app.env` + +```bash +sudo nano /etc/jqc/app.env +``` + +Replace the test values: + +```env +STRIPE_SECRET_KEY=sk_live_XXXXXXXXXXXXXXXXXXXXXXXX +STRIPE_PUBLISHABLE_KEY=pk_live_XXXXXXXXXXXXXXXXXXXXXXXX +STRIPE_PRICE_STARTER=price_live_XXXXXXXXXXXXXXXXXXXXXXXX +STRIPE_PRICE_PRO=price_live_XXXXXXXXXXXXXXXXXXXXXXXX +STRIPE_PRICE_ENTERPRISE=price_live_XXXXXXXXXXXXXXXXXXXXXXXX +``` + +### 1.4 Seed the live Price IDs into the control DB + +```bash +set -a; . /etc/jqc/app.env; . /etc/jqc/control.env; set +a +python -m control.cli seed +python -m control.cli list-plans # verify all three show price_live_... IDs +``` + +### 1.5 Register the live Stripe webhook + +1. In Stripe Dashboard (live mode) → **Developers → Webhooks → + Add endpoint** +2. **Endpoint URL:** `https://yourdomain.jqc.app/billing/webhook` + *(use your tenant-zero domain or the primary custom domain)* +3. **Events to listen to** (select these exactly): + - `invoice.payment_failed` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `checkout.session.completed` +4. Click **Add endpoint** +5. Copy the **Signing secret** — starts with `whsec_…` + +### 1.6 Update webhook secret + +```bash +sudo nano /etc/jqc/app.env +``` + +```env +STRIPE_WEBHOOK_SECRET=whsec_XXXXXXXXXXXXXXXXXXXXXXXX +``` + +### 1.7 Restart the app + +```bash +sudo systemctl daemon-reload +sudo systemctl restart jqc +``` + +### 1.8 Verify webhook delivery + +1. In Stripe Dashboard → Webhooks → click your endpoint +2. Send a **test event**: `invoice.payment_failed` +3. Check your app logs: `tail -f /var/www/jqc/logs/jqc.log | grep BILLING` +4. You should see: `BILLING WEBHOOK | event=invoice.payment_failed` + +--- + +## 2. Email Deliverability (SPF, DKIM, DMARC) + +Without these DNS records, billing emails (trial ending, payment failed, etc.) +will land in spam or be rejected outright. + +### 2.1 SPF Record + +SPF tells receiving servers which servers are allowed to send email for your domain. + +Add a **TXT record** on your sending domain (e.g. `jqc.app` or your custom domain): + +| Name | Type | Value | +|---|---|---| +| `@` | TXT | `v=spf1 include:YOUR-SMTP-PROVIDER.com ~all` | + +Replace `YOUR-SMTP-PROVIDER.com` with your actual SMTP provider's include directive: + +| Provider | Include value | +|---|---| +| SendGrid | `include:sendgrid.net` | +| Mailgun | `include:mailgun.org` | +| Amazon SES | `include:amazonses.com` | +| Google Workspace | `include:_spf.google.com` | +| Your own server | `ip4:YOUR.SERVER.IP.ADDRESS` | + +**Example for your own server:** +``` +v=spf1 ip4:123.45.67.89 ~all +``` + +**Example for SendGrid:** +``` +v=spf1 include:sendgrid.net ~all +``` + +> ⚠️ Only one SPF record per domain is allowed. If one already exists, append to it. + +### 2.2 DKIM Record + +DKIM cryptographically signs outgoing emails so recipients can verify they're authentic. + +**If using SendGrid / Mailgun / Amazon SES:** +- Follow their dashboard instructions to generate a DKIM key pair +- They will give you TXT records to add (usually at `mail._domainkey.yourdomain.com`) +- Add those records exactly as provided + +**If sending from your own server (Postfix + OpenDKIM):** + +```bash +# Install OpenDKIM +sudo apt install opendkim opendkim-tools + +# Generate key pair +sudo mkdir -p /etc/opendkim/keys/yourdomain.com +sudo opendkim-genkey -b 2048 -d yourdomain.com -D /etc/opendkim/keys/yourdomain.com -s mail -v + +# View the TXT record to add to DNS +sudo cat /etc/opendkim/keys/yourdomain.com/mail.txt +``` + +Add the output as a TXT record at `mail._domainkey.yourdomain.com`. + +### 2.3 DMARC Record + +DMARC tells receiving servers what to do with emails that fail SPF/DKIM checks, +and where to send failure reports. + +Add a **TXT record**: + +| Name | Type | Value | +|---|---|---| +| `_dmarc` | TXT | `v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc@yourdomain.com` | + +Start with `p=none` (monitoring only) for the first 2 weeks, then move to `p=quarantine`: + +``` +# Week 1-2: monitor only +v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com + +# After confirming SPF and DKIM pass in reports: +v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc@yourdomain.com +``` + +### 2.4 Test email deliverability + +Once DNS records propagate (up to 48 hours), test with: + +1. **[mail-tester.com](https://mail-tester.com)** — free, shows your score out of 10 and exactly what's failing +2. **[MXToolbox](https://mxtoolbox.com/emailhealth)** — checks SPF, DKIM, DMARC, blacklists + +Target score: **9/10 or higher** before sending billing emails to customers. + +### 2.5 Verify from the app + +```bash +# Send a test billing email from Flask shell +cd /var/www/jqc +source venv/bin/activate +flask shell + +from app.billing.emails import send_billing_email +send_billing_email( + 'your@email.com', + 'trial_ending', + { + 'trial_ends_at': 'July 15, 2026', + 'subscribe_url': 'https://demo.jqc.app/billing/subscribe', + 'days_left': 3, + 'tenant_name': 'Test Tenant', + } +) +exit() +``` + +Check that it arrives in the inbox (not spam) and renders correctly. + +--- + +## 3. Cron Jobs — Final Setup + +After going live, make sure all cron jobs including the new trial reminder are configured. + +Edit the crontab for the `jqc` user: + +```bash +sudo crontab -u jqc -e +``` + +Add all six jobs (replace `YOUR_SECRET` and `yourdomain.jqc.app`): + +```cron +# SLA breach alerts — every 30 minutes +*/30 * * * * curl -s -X POST https://yourdomain.jqc.app/notifications/check-sla \ + -d "token=YOUR_DIGEST_SECRET" >> /var/www/jqc/logs/cron.log 2>&1 + +# Daily email digest — 7 AM +0 7 * * * curl -s -X POST https://yourdomain.jqc.app/notifications/send-digest \ + -d "token=YOUR_DIGEST_SECRET&frequency=daily" >> /var/www/jqc/logs/cron.log 2>&1 + +# Weekly email digest — Monday 7 AM +0 7 * * 1 curl -s -X POST https://yourdomain.jqc.app/notifications/send-digest \ + -d "token=YOUR_DIGEST_SECRET&frequency=weekly" >> /var/www/jqc/logs/cron.log 2>&1 + +# Facility score trend alerts — 8 AM daily +0 8 * * * curl -s -X POST https://yourdomain.jqc.app/notifications/check-score-trends \ + -d "token=YOUR_DIGEST_SECRET" >> /var/www/jqc/logs/cron.log 2>&1 + +# Scheduled reports — 8 AM daily +0 8 * * * curl -s -X POST https://yourdomain.jqc.app/scheduled-reports/run \ + -d "secret=YOUR_DIGEST_SECRET" >> /var/www/jqc/logs/cron.log 2>&1 + +# Expired API token cleanup — 3 AM daily +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 +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 + +# Per-tenant DB backup — 2 AM daily +0 2 * * * set -a; . /etc/jqc/control.env; set +a; \ + python -m control.backup --tenant all \ + --output-dir /var/backups/jqc >> /var/www/jqc/logs/cron.log 2>&1 +``` + +> **Note on `yourdomain.jqc.app`:** Use your tenant-zero subdomain (e.g. `lts.jqc.app`) or +> your custom domain (e.g. `jqc.ltservicesinc.com`). The trial-reminders endpoint is exempt +> from tenant middleware so any valid host works for it, but the others require a valid tenant host. + +--- + +## 4. Final Pre-Launch Verification + +Run through this checklist before announcing to customers: + +### Stripe + +- [ ] `python -m control.cli list-plans` shows `price_live_…` IDs (not `price_test_…`) +- [ ] Stripe Dashboard → Webhooks shows endpoint is **enabled** in **live mode** +- [ ] Made a real test payment through the subscribe flow using a live card +- [ ] Webhook delivery log shows `200 OK` responses +- [ ] `billing_email` is set on tenant-zero (check: `python -m control.cli list-plans`) + +### Email + +- [ ] mail-tester.com score ≥ 9/10 +- [ ] SPF TXT record published and passes at mxtoolbox.com +- [ ] DKIM TXT record published and passes +- [ ] DMARC TXT record published (start with `p=none`) +- [ ] Test billing email arrives in Gmail inbox, not spam +- [ ] Test billing email HTML renders correctly (blue header, button, footer) + +### Trial reminder cron + +- [ ] Test manually: `curl -X POST https://yourdomain.jqc.app/notifications/trial-reminders -d "token=YOUR_SECRET"` +- [ ] Response: `{"ok": true, "sent": N, "skipped": N, "errors": 0}` +- [ ] Cron entry is in `crontab -u jqc -l` + +### Backup + +- [ ] `python -m control.backup --tenant all --output-dir /var/backups/jqc` runs without errors +- [ ] `/var/backups/jqc/` contains `.sql.gz` files for each tenant +- [ ] Backup cron entry is in the crontab + +--- + +## 5. Post-Launch Monitoring + +Watch these in the first week after going live: + +```bash +# App errors +tail -f /var/www/jqc/logs/jqc.log | grep -E 'ERROR|WARNING|BILLING' + +# Cron job results +tail -f /var/www/jqc/logs/cron.log + +# Stripe events (Stripe Dashboard → Developers → Events) +# Look for: checkout.session.completed, customer.subscription.updated + +# Trial reminders sent +grep 'TRIAL REMINDERS' /var/www/jqc/logs/jqc.log +``` diff --git a/app/routes/notifications.py b/app/routes/notifications.py index c8a9c0a..34ab6a8 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -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}) \ No newline at end of file + 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}) \ No newline at end of file diff --git a/app/tenancy/middleware.py b/app/tenancy/middleware.py index 637368e..daf3410 100644 --- a/app/tenancy/middleware.py +++ b/app/tenancy/middleware.py @@ -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 diff --git a/control/migrations/versions/control0002_trial_reminder_sent.py b/control/migrations/versions/control0002_trial_reminder_sent.py new file mode 100644 index 0000000..1a0c548 --- /dev/null +++ b/control/migrations/versions/control0002_trial_reminder_sent.py @@ -0,0 +1,40 @@ +"""control0002 — add trial_reminder_sent_at to tenants + +Adds a nullable DATETIME column that the trial-reminders cron endpoint writes +to after dispatching a warning email. Prevents duplicate sends within 22 hours. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'control0002_trial_reminder_sent' +down_revision = 'control0001_init' +branch_labels = None +depends_on = None + + +def _col_exists(bind, table, column): + result = bind.execute(sa.text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() " + "AND table_name = :t AND column_name = :c" + ), {'t': table, 'c': column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if not _col_exists(bind, 'tenants', 'trial_reminder_sent_at'): + op.execute(sa.text( + "ALTER TABLE tenants " + "ADD COLUMN trial_reminder_sent_at DATETIME NULL " + "AFTER trial_ends_at" + )) + + +def downgrade(): + bind = op.get_bind() + if _col_exists(bind, 'tenants', 'trial_reminder_sent_at'): + op.execute(sa.text( + "ALTER TABLE tenants DROP COLUMN trial_reminder_sent_at" + )) diff --git a/control/models.py b/control/models.py index 6890529..ea50eed 100644 --- a/control/models.py +++ b/control/models.py @@ -108,9 +108,10 @@ class Tenant(ControlBase): Enum('trial', 'active', 'past_due', 'cancelled', name='subscription_status'), nullable=True, ) - trial_ends_at = Column(DateTime, nullable=True) - current_period_end = Column(DateTime, nullable=True) - billing_email = Column(String(255), nullable=True) + trial_ends_at = Column(DateTime, nullable=True) + trial_reminder_sent_at = Column(DateTime, nullable=True) # set after trial warning email + current_period_end = Column(DateTime, nullable=True) + billing_email = Column(String(255), nullable=True) plan = relationship('Plan', back_populates='tenants') domains = relationship('TenantDomain', back_populates='tenant',