Jun 29 - Add trial reminder sent function

This commit is contained in:
2026-06-29 10:04:45 -04:00
parent 42790c499c
commit 7022e46942
5 changed files with 487 additions and 4 deletions
+313
View File
@@ -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
```
+127
View File
@@ -306,3 +306,130 @@ def check_score_trends():
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent)
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})
+2
View File
@@ -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
@@ -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"
))
+1
View File
@@ -109,6 +109,7 @@ class Tenant(ControlBase):
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)