Jun 29 - Update: Fail2ban, welcome email, dunning sequence, escalation
This commit is contained in:
+5
-1
@@ -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 \
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -432,4 +432,141 @@ 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})
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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})
|
return jsonify({'ok': True, 'sent': sent, 'skipped': skipped, 'errors': errors})
|
||||||
@@ -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>✗ 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>⚠ 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 %}
|
||||||
@@ -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>✓ Workspace:</strong> <a href="{{ login_url }}" style="color:#15803d;">{{ login_url }}</a><br>
|
||||||
|
<strong>✓ Free trial:</strong> {{ trial_days }} days (expires {{ trial_ends_at }})<br>
|
||||||
|
<strong>✓ 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 %}
|
||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user