Jun 28 - Update and polish UI/UX

This commit is contained in:
2026-06-28 19:00:34 -04:00
parent 54fa5b8c87
commit a42fc31fc5
14 changed files with 943 additions and 91 deletions
+202
View File
@@ -0,0 +1,202 @@
"""
control/backup.py
-----------------
Per-tenant MySQL database backup tool.
Parses each tenant's db_uri and runs mysqldump via subprocess, writing a
gzip-compressed SQL dump to <output_dir>/<slug>_<YYYYMMDD_HHMMSS>.sql.gz.
Usage:
python -m control.backup --tenant all --output-dir /backups
python -m control.backup --tenant acme --output-dir /backups
python -m control.backup --list
Requirements:
mysqldump binary on PATH.
Environment: source /etc/jqc/control.env first (needs CONTROL_DATABASE_URL).
"""
import argparse
import gzip
import logging
import os
import shutil
import subprocess
import sys
from datetime import datetime
from urllib.parse import urlparse
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
logger = logging.getLogger(__name__)
def _parse_db_uri(uri: str) -> dict:
"""Return host, port, user, password, database from a SQLAlchemy DB URI."""
p = urlparse(uri)
return {
'host': p.hostname or '127.0.0.1',
'port': str(p.port or 3306),
'user': p.username or '',
'password': p.password or '',
'database': p.path.lstrip('/'),
}
def backup_tenant(slug: str, db_uri: str, output_dir: str) -> str:
"""Run mysqldump for one tenant and save as a .sql.gz file.
Returns the full path to the written file.
Raises RuntimeError on failure.
"""
if not shutil.which('mysqldump'):
raise RuntimeError('mysqldump not found on PATH')
db = _parse_db_uri(db_uri)
if not db['database']:
raise RuntimeError(f'Could not determine database name from URI: {db_uri}')
os.makedirs(output_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'{slug}_{timestamp}.sql.gz'
filepath = os.path.join(output_dir, filename)
cmd = [
'mysqldump',
f'--host={db["host"]}',
f'--port={db["port"]}',
f'--user={db["user"]}',
f'--password={db["password"]}',
'--single-transaction',
'--routines',
'--triggers',
'--set-gtid-purged=OFF',
db['database'],
]
logger.info('Backing up tenant=%s db=%s%s', slug, db['database'], filepath)
try:
result = subprocess.run(
cmd,
capture_output=True,
timeout=600,
)
except subprocess.TimeoutExpired:
raise RuntimeError(f'mysqldump timed out for tenant {slug}')
if result.returncode != 0:
stderr = result.stderr.decode(errors='replace').strip()
# mysqldump prints warnings to stderr even on success; only fail on non-zero exit.
raise RuntimeError(
f'mysqldump exited {result.returncode} for tenant {slug}:\n{stderr}'
)
with gzip.open(filepath, 'wb') as gz:
gz.write(result.stdout)
size_kb = os.path.getsize(filepath) // 1024
logger.info('%s written (%d KB)', filepath, size_kb)
return filepath
def _select_tenants(slug_or_all: str):
"""Return list of (slug, db_uri) tuples from the control DB."""
from control.base import control_session
from control.models import Tenant
with control_session() as s:
if slug_or_all == 'all':
tenants = s.query(Tenant).filter(
Tenant.status != 'deleted'
).order_by(Tenant.id).all()
else:
tenants = s.query(Tenant).filter_by(slug=slug_or_all).all()
if not tenants:
raise ValueError(f'Tenant "{slug_or_all}" not found in control DB.')
return [(t.slug, t.db_uri) for t in tenants]
def _cmd_backup(args):
try:
tenants = _select_tenants(args.tenant)
except ValueError as exc:
logger.error('%s', exc)
sys.exit(1)
output_dir = os.path.expanduser(args.output_dir)
ok = 0
failed = 0
for slug, db_uri in tenants:
try:
path = backup_tenant(slug, db_uri, output_dir)
ok += 1
except Exception as exc:
logger.error('FAILED tenant=%s: %s', slug, exc)
failed += 1
print(f'\nBackup complete: {ok} succeeded, {failed} failed.')
print(f'Files written to: {output_dir}')
if failed:
sys.exit(1)
def _cmd_list(args):
"""List tenants in the control DB."""
from control.base import control_session
from control.models import Tenant
with control_session() as s:
tenants = s.query(Tenant).order_by(Tenant.id).all()
print(f'{"ID":<5} {"Slug":<20} {"Status":<12} {"DB Name":<30}')
print('' * 70)
for t in tenants:
db_name = _parse_db_uri(t.db_uri).get('database', '?')
print(f'{t.id:<5} {t.slug:<20} {t.status:<12} {db_name:<30}')
def main():
parser = argparse.ArgumentParser(
description='JQC per-tenant MySQL backup tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
sub = parser.add_subparsers(dest='command')
p_backup = sub.add_parser('backup', help='Run mysqldump for one or all tenants')
p_backup.add_argument('--tenant', required=True,
help='Tenant slug or "all"')
p_backup.add_argument('--output-dir', default='/var/backups/jqc',
help='Directory for dump files (default: /var/backups/jqc)')
p_backup.set_defaults(func=_cmd_backup)
p_list = sub.add_parser('list', help='List tenant slugs and DB names')
p_list.set_defaults(func=_cmd_list)
# Allow calling without a subcommand when --tenant is given (convenience).
parser.add_argument('--tenant', help='Tenant slug or "all" (shorthand — no subcommand needed)')
parser.add_argument('--output-dir', default='/var/backups/jqc',
help='Output directory for dump files')
parser.add_argument('--list', action='store_true',
help='List tenants (shorthand)')
args = parser.parse_args()
if args.command:
args.func(args)
elif getattr(args, 'list', False):
_cmd_list(args)
elif getattr(args, 'tenant', None):
_cmd_backup(args)
else:
parser.print_help()
sys.exit(0)
if __name__ == '__main__':
main()
+2
View File
@@ -28,6 +28,7 @@ from flask_wtf.csrf import CSRFProtect
from .auth import bp as auth_bp
from .tenants import bp as tenants_bp
from .health import bp as health_bp
csrf = CSRFProtect()
@@ -83,6 +84,7 @@ def create_panel_app():
# ── Blueprints ────────────────────────────────────────────────────────
app.register_blueprint(auth_bp) # /login, /logout
app.register_blueprint(tenants_bp) # /tenants/…
app.register_blueprint(health_bp) # /health/
# ── Root redirect ─────────────────────────────────────────────────────
from flask import redirect, url_for
+109
View File
@@ -0,0 +1,109 @@
"""
control/panel/health.py
-----------------------
Superadmin health / monitoring dashboard (MT-8+).
GET /health — reads control plane data only (no per-tenant DB queries)
so it stays fast regardless of tenant count.
"""
import logging
import os
from flask import Blueprint, render_template, session
from control.base import control_session
from control.models import Plan, Tenant
from control.tenant_migrate import chain_head
from control.time_utils import now_eastern
from .decorators import superadmin_required
logger = logging.getLogger(__name__)
bp = Blueprint('health', __name__, url_prefix='/health')
_BASE_DOMAIN = lambda: os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')
@bp.route('/')
@superadmin_required
def dashboard():
try:
head = chain_head()
except Exception:
head = None
with control_session() as s:
plans = {p.id: p.name for p in s.query(Plan).all()}
tenants = s.query(Tenant).order_by(Tenant.id).all()
now = now_eastern()
rows = []
for t in tenants:
trial_days_left = None
trial_expired = False
if t.trial_ends_at:
delta = (t.trial_ends_at - now).days
trial_days_left = max(0, delta)
trial_expired = now >= t.trial_ends_at
schema_ok = (t.alembic_head == head) if head else None
rows.append({
'id': t.id,
'slug': t.slug,
'name': t.name,
'status': t.status,
'plan_name': plans.get(t.plan_id, '?'),
'subscription_status': t.subscription_status,
'trial_ends_at': t.trial_ends_at,
'trial_days_left': trial_days_left,
'trial_expired': trial_expired,
'current_period_end': t.current_period_end,
'has_stripe': bool(t.stripe_customer_id),
'alembic_head': t.alembic_head,
'schema_ok': schema_ok,
'created_at': t.created_at,
'suspended_at': t.suspended_at,
})
# ── Aggregate stats ──────────────────────────────────────────────────────
total = len(rows)
active = sum(1 for r in rows if r['status'] == 'active')
suspended = sum(1 for r in rows if r['status'] == 'suspended')
schema_behind = sum(1 for r in rows if r['schema_ok'] is False)
sub_counts = {}
for r in rows:
key = r['subscription_status'] or 'none'
sub_counts[key] = sub_counts.get(key, 0) + 1
trial_expiring_soon = sum(
1 for r in rows
if r['subscription_status'] == 'trial'
and r['trial_days_left'] is not None
and r['trial_days_left'] <= 3
and not r['trial_expired']
)
trial_expired_count = sum(
1 for r in rows
if r['subscription_status'] == 'trial' and r['trial_expired']
)
stats = {
'total': total,
'active': active,
'suspended': suspended,
'schema_behind': schema_behind,
'sub_counts': sub_counts,
'trial_expiring_soon': trial_expiring_soon,
'trial_expired': trial_expired_count,
}
return render_template(
'panel/health.html',
rows=rows,
stats=stats,
chain_head=head,
sa_username=session.get('sa_username'),
base_domain=_BASE_DOMAIN(),
)
+4
View File
@@ -80,6 +80,10 @@
class="{{ 'active' if request.endpoint == 'tenants.provision_tenant' else '' }}">
<i class="bi bi-plus-circle"></i> New Tenant
</a>
<a href="{{ url_for('health.dashboard') }}"
class="{{ 'active' if request.endpoint == 'health.dashboard' else '' }}">
<i class="bi bi-heart-pulse"></i> Health
</a>
</nav>
<div class="sa-footer">
{% if sa_username %}
+197
View File
@@ -0,0 +1,197 @@
{% extends "panel/base.html" %}
{% block title %}Health — JQC Control{% endblock %}
{% block page_title %}System Health{% endblock %}
{% block extra_css %}
<style>
.sub-badge-trial { background:#dbeafe;color:#1d4ed8; }
.sub-badge-active { background:#dcfce7;color:#15803d; }
.sub-badge-past_due { background:#fef3c7;color:#92400e; }
.sub-badge-cancelled { background:#fee2e2;color:#991b1b; }
.sub-badge-none { background:#f1f5f9;color:#64748b; }
.sub-badge { display:inline-block;padding:.18rem .55rem;border-radius:12px;font-size:.7rem;font-weight:600; }
.stat-card { background:#fff;border-radius:8px;padding:1rem 1.25rem;box-shadow:0 1px 3px rgba(0,0,0,.06); }
.stat-num { font-size:1.8rem;font-weight:700;line-height:1; }
</style>
{% endblock %}
{% block content %}
{# ── Summary stat cards ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="text-muted" style="font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;">Total Tenants</div>
<div class="stat-num mt-1">{{ stats.total }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="text-muted" style="font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;">Active</div>
<div class="stat-num mt-1 text-success">{{ stats.active }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="text-muted" style="font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;">Suspended</div>
<div class="stat-num mt-1 {{ 'text-warning' if stats.suspended else '' }}">{{ stats.suspended }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="text-muted" style="font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;">Schema Behind</div>
<div class="stat-num mt-1 {{ 'text-danger' if stats.schema_behind else '' }}">{{ stats.schema_behind }}</div>
</div>
</div>
</div>
{# ── Subscription breakdown + trial alerts ── #}
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;">Subscription Breakdown</span>
</div>
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-2">
{% for key, count in stats.sub_counts.items() %}
<div class="d-flex align-items-center gap-1">
<span class="sub-badge sub-badge-{{ key }}">{{ key }}</span>
<span class="fw-semibold">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;">Trial Alerts</span>
</div>
<div class="card-body py-2 d-flex flex-column gap-2" style="font-size:.85rem;">
{% if stats.trial_expired %}
<div class="d-flex align-items-center gap-2 text-danger">
<i class="bi bi-x-circle-fill"></i>
<span><strong>{{ stats.trial_expired }}</strong> trial{{ 's' if stats.trial_expired != 1 }} expired — workspace locked</span>
</div>
{% endif %}
{% if stats.trial_expiring_soon %}
<div class="d-flex align-items-center gap-2 text-warning">
<i class="bi bi-hourglass-split"></i>
<span><strong>{{ stats.trial_expiring_soon }}</strong> trial{{ 's' if stats.trial_expiring_soon != 1 }} expiring within 3 days</span>
</div>
{% endif %}
{% if not stats.trial_expired and not stats.trial_expiring_soon %}
<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>All trials healthy.</span>
{% endif %}
</div>
</div>
</div>
</div>
{# ── Tenant table ── #}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-table me-1"></i>All Tenants</span>
<span class="text-muted" style="font-size:.78rem;">
Schema HEAD: <code>{{ chain_head or '?' }}</code>
</span>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle" style="font-size:.82rem;">
<thead class="table-light">
<tr>
<th>#</th>
<th>Slug / Name</th>
<th>Status</th>
<th>Plan</th>
<th>Subscription</th>
<th>Trial / Period</th>
<th>Stripe</th>
<th>Schema</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="{{ 'table-warning' if r.status == 'suspended' else
('table-danger' if r.trial_expired and r.subscription_status == 'trial' else '') }}">
<td class="text-muted">{{ r.id }}</td>
<td>
<div class="fw-semibold">{{ r.slug }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ r.name }}</div>
</td>
<td>
<span class="status-badge status-{{ r.status }}">{{ r.status }}</span>
</td>
<td>{{ r.plan_name }}</td>
<td>
<span class="sub-badge sub-badge-{{ r.subscription_status or 'none' }}">
{{ r.subscription_status or '—' }}
</span>
{% if r.subscription_status == 'past_due' %}
<i class="bi bi-exclamation-triangle-fill text-warning ms-1"></i>
{% endif %}
</td>
<td>
{% if r.subscription_status == 'trial' and r.trial_ends_at %}
{% if r.trial_expired %}
<span class="text-danger fw-semibold">
<i class="bi bi-x-circle me-1"></i>Expired
</span>
{% elif r.trial_days_left <= 3 %}
<span class="text-warning fw-semibold">
{{ r.trial_days_left }}d left
</span>
{% else %}
<span class="text-muted">{{ r.trial_ends_at.strftime('%Y-%m-%d') }}</span>
{% endif %}
{% elif r.current_period_end %}
<span class="text-muted">{{ r.current_period_end.strftime('%Y-%m-%d') }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if r.has_stripe %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="font-monospace" style="font-size:.72rem;">
{% if r.schema_ok is none %}
<span class="text-muted">?</span>
{% elif r.schema_ok %}
<span class="rev-ok"><i class="bi bi-check-circle-fill"></i></span>
{% else %}
<span class="rev-behind">
<i class="bi bi-exclamation-circle-fill me-1"></i>{{ (r.alembic_head or '?')[:10] }}…
</span>
{% endif %}
</td>
<td style="color:#64748b;">
{{ r.created_at.strftime('%Y-%m-%d') if r.created_at else '—' }}
</td>
<td>
<a href="{{ url_for('tenants.tenant_detail', tenant_id=r.id) }}"
class="btn btn-outline-secondary btn-sm" style="font-size:.72rem;padding:.2rem .5rem;">
Detail
</a>
</td>
</tr>
{% else %}
<tr><td colspan="10" class="text-center text-muted py-4">No tenants.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="text-muted mt-2" style="font-size:.75rem; text-align:right;">
Refreshes on page load only. Schema status read from control DB cache (run Detail → Upgrade to live-check).
</div>
{% endblock %}
@@ -286,14 +286,16 @@
</div>
</div>
{# ── Billing (MT-8, read-only) ── #}
{# ── Billing (MT-8) ── #}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-credit-card me-1"></i>Billing</span>
</div>
<div class="card-body py-2" style="font-size:.83rem;">
<div class="mb-1">
<span class="text-muted">Subscription</span>
{# Read-only info #}
<div class="mb-2">
<span class="text-muted">Status</span>
<div>
{% if tenant.subscription_status == 'active' %}
<span class="badge bg-success">active</span>
@@ -309,44 +311,80 @@
</div>
</div>
{% if tenant.trial_ends_at %}
<div class="mb-1">
<span class="text-muted">Trial ends</span>
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div>
</div>
<div class="mb-1"><span class="text-muted">Trial ends</span>
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div></div>
{% endif %}
{% if tenant.current_period_end %}
<div class="mb-1">
<span class="text-muted">Period ends</span>
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div>
</div>
<div class="mb-1"><span class="text-muted">Period ends</span>
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div></div>
{% endif %}
{% if tenant.billing_email %}
<div class="mb-1">
<span class="text-muted">Billing email</span>
<div>{{ tenant.billing_email }}</div>
</div>
<div class="mb-1"><span class="text-muted">Billing email</span>
<div>{{ tenant.billing_email }}</div></div>
{% endif %}
{% if tenant.stripe_customer_id %}
<div class="mb-1">
<span class="text-muted">Stripe customer</span>
<div class="font-monospace" style="font-size:.75rem;"
title="{{ tenant.stripe_customer_id }}">
{{ tenant.stripe_customer_id[:20] }}…
</div>
</div>
<div class="mb-1"><span class="text-muted">Stripe customer</span>
<div class="font-monospace" style="font-size:.72rem;" title="{{ tenant.stripe_customer_id }}">
{{ tenant.stripe_customer_id }}</div></div>
{% endif %}
{% if tenant.stripe_subscription_id %}
<div class="mb-1">
<span class="text-muted">Stripe subscription</span>
<div class="font-monospace" style="font-size:.75rem;"
title="{{ tenant.stripe_subscription_id }}">
{{ tenant.stripe_subscription_id[:20] }}…
<div class="mb-2"><span class="text-muted">Stripe subscription</span>
<div class="font-monospace" style="font-size:.72rem;" title="{{ tenant.stripe_subscription_id }}">
{{ tenant.stripe_subscription_id }}</div></div>
{% endif %}
<hr class="my-2">
{# Set trial #}
<form method="POST"
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
class="mb-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="set_trial">
<label class="form-label mb-1" style="font-size:.8rem;">Extend / Set Trial</label>
<div class="input-group input-group-sm">
<input type="number" name="trial_days" value="14" min="1" max="365"
class="form-control" style="max-width:70px;">
<span class="input-group-text">days</span>
<button class="btn btn-outline-info btn-sm">Set Trial</button>
</div>
</div>
{% endif %}
{% if not tenant.subscription_status %}
<span class="text-muted">No billing configured.</span>
</form>
{# Override status #}
<form method="POST"
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
class="mb-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="set_status">
<label class="form-label mb-1" style="font-size:.8rem;">Override Status</label>
<div class="input-group input-group-sm">
<select name="subscription_status" class="form-select">
{% for st in ('trial','active','past_due','cancelled') %}
<option value="{{ st }}" {{ 'selected' if tenant.subscription_status == st }}>
{{ st }}
</option>
{% endfor %}
</select>
<button class="btn btn-outline-warning btn-sm">Set</button>
</div>
</form>
{# Apply coupon — only if Stripe customer exists #}
{% if tenant.stripe_customer_id %}
<form method="POST"
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
onsubmit="return confirm('Apply coupon to Stripe customer?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="apply_coupon">
<label class="form-label mb-1" style="font-size:.8rem;">Apply Stripe Coupon</label>
<div class="input-group input-group-sm">
<input type="text" name="coupon_id" class="form-control"
placeholder="COUPON_ID" required>
<button class="btn btn-outline-success btn-sm">Apply</button>
</div>
</form>
{% endif %}
</div>
</div>
+66
View File
@@ -376,6 +376,72 @@ def provision_tenant():
return redirect(url_for('tenants.tenant_detail', tenant_id=info['tenant_id']))
# ── billing controls (superadmin override) ────────────────────────────────────
@bp.route('/<int:tenant_id>/billing', methods=['POST'])
@superadmin_required
def update_billing(tenant_id):
"""Superadmin billing controls: extend trial, override status, apply coupon."""
action = (request.form.get('action') or '').strip()
with control_session() as s:
t = s.get(Tenant, tenant_id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenants.list_tenants'))
if action == 'set_trial':
from datetime import timedelta
try:
days = int(request.form.get('trial_days', 14))
except (ValueError, TypeError):
flash('Invalid trial days.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
new_ends = now_eastern() + timedelta(days=days)
t.subscription_status = 'trial'
t.trial_ends_at = new_ends
_audit('BILLING_TRIAL', tenant_id=tenant_id,
details=f'days={days} ends_at={new_ends.date()}')
flash(f'Trial set to {days} days (expires {new_ends.strftime("%b %d, %Y")}).', 'success')
elif action == 'set_status':
status = (request.form.get('subscription_status') or '').strip()
allowed = ('trial', 'active', 'past_due', 'cancelled')
if status not in allowed:
flash(f'Invalid status. Choose from: {", ".join(allowed)}.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
old = t.subscription_status
t.subscription_status = status
_audit('BILLING_STATUS', tenant_id=tenant_id,
details=f'status {old}{status}')
flash(f'Subscription status updated to "{status}".', 'success')
elif action == 'apply_coupon':
coupon_id = (request.form.get('coupon_id') or '').strip()
if not coupon_id:
flash('Coupon ID is required.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
if not t.stripe_customer_id:
flash('Tenant has no Stripe customer — cannot apply coupon.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
try:
import os
import stripe as _stripe
_stripe.api_key = os.environ.get('STRIPE_SECRET_KEY', '')
_stripe.Customer.modify(t.stripe_customer_id, coupon=coupon_id)
_audit('BILLING_COUPON', tenant_id=tenant_id,
details=f'coupon={coupon_id} customer={t.stripe_customer_id}')
flash(f'Coupon "{coupon_id}" applied to Stripe customer.', 'success')
except Exception as exc:
logger.error('PANEL | apply_coupon | tenant=%s err=%s', tenant_id, exc)
flash(f'Stripe error: {exc}', 'danger')
else:
flash('Unknown billing action.', 'danger')
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
# ── run migration ─────────────────────────────────────────────────────────────
@bp.route('/<int:tenant_id>/migrate', methods=['POST'])