diff --git a/app/billing/emails.py b/app/billing/emails.py
index 000bcf5..dbbe754 100644
--- a/app/billing/emails.py
+++ b/app/billing/emails.py
@@ -2,98 +2,89 @@
app/billing/emails.py
---------------------
Dunning / billing lifecycle emails sent via Flask-Mail in a background thread.
-
-Pattern mirrors app/utils/notifications.py: captures the app object before
-spawning the thread so the mail context is valid inside the worker.
-
-Public API:
- send_billing_email(to_addr, event_type, context_dict)
- event_type in ('payment_failed', 'trial_ending', 'subscription_cancelled')
+Sends multipart HTML + plain-text messages using Jinja2 templates in
+app/templates/billing/email/.
"""
import logging
import threading
-from flask import current_app
+from flask import current_app, render_template
from flask_mail import Message
from app import mail
logger = logging.getLogger(__name__)
-# ── Email templates ────────────────────────────────────────────────────────────
-
_SUBJECTS = {
- 'payment_failed': 'Action Required: Payment failed for your JQC subscription',
- 'trial_ending': 'Your JQC free trial ends in 3 days',
- 'subscription_cancelled': 'Your JQC subscription has been cancelled',
+ 'payment_failed': 'Action Required: Payment failed for your JQC subscription',
+ 'trial_ending': 'Your JQC free trial ends in 3 days',
+ 'subscription_cancelled': 'Your JQC subscription has been cancelled',
}
-_BODIES = {
- 'payment_failed': """\
-Hi,
+_HTML_TEMPLATES = {
+ 'payment_failed': 'billing/email/payment_failed.html',
+ 'trial_ending': 'billing/email/trial_ending.html',
+ 'subscription_cancelled': 'billing/email/subscription_cancelled.html',
+}
-We were unable to process your most recent payment for your JQC subscription.
-
-Please update your payment method to avoid interruption to your service:
- {portal_url}
-
-If you have any questions, please contact support.
-
-— The JQC Team
-""",
-
- 'trial_ending': """\
-Hi,
-
-Your JQC free trial will end on {trial_ends_at}. After that date, you will
-need an active subscription to continue using the service.
-
-Subscribe now to keep your workspace active:
- {subscribe_url}
-
-— The JQC Team
-""",
-
- 'subscription_cancelled': """\
-Hi,
-
-Your JQC subscription has been cancelled and your workspace access has
-been suspended. You can reactivate your subscription at any time:
- {portal_url}
-
-— The JQC Team
-""",
+_PLAIN_BODIES = {
+ '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"
+ ),
+ 'trial_ending': (
+ "Your JQC free trial will end on {trial_ends_at}.\n\n"
+ "Subscribe now to keep your workspace active:\n"
+ " {subscribe_url}\n\n— The JQC Team"
+ ),
+ 'subscription_cancelled': (
+ "Your JQC subscription has been cancelled and your workspace access has been suspended.\n\n"
+ "You can reactivate at any time:\n"
+ " {portal_url}\n\n— The JQC Team"
+ ),
}
def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
- """Send a billing lifecycle email in a background thread.
+ """Send a billing lifecycle HTML+plain email in a background thread.
Args:
- to_addr: Recipient email address (tenant admin or billing contact).
- event_type: Key in _SUBJECTS / _BODIES.
- context_dict: Variables substituted into the body template.
+ to_addr: Recipient email address.
+ event_type: One of 'payment_failed', 'trial_ending', 'subscription_cancelled'.
+ context_dict: Variables substituted into both the HTML template and plain body.
"""
if not to_addr:
logger.warning('BILLING EMAIL | skipped | event=%s | reason=no_recipient', event_type)
return
subject = _SUBJECTS.get(event_type, 'JQC Billing Notification')
- body_template = _BODIES.get(event_type, '')
+ plain_tmpl = _PLAIN_BODIES.get(event_type, '')
+ html_tmpl_path = _HTML_TEMPLATES.get(event_type)
+
try:
- body = body_template.format(**context_dict)
+ plain_body = plain_tmpl.format(**context_dict)
except KeyError as exc:
- logger.error('BILLING EMAIL | template_error | event=%s | missing_key=%s', event_type, exc)
- body = body_template # send with unfilled placeholders rather than crashing
+ logger.error('BILLING EMAIL | plain_template_error | event=%s | key=%s', event_type, exc)
+ plain_body = plain_tmpl
app = current_app._get_current_object()
def _send():
try:
with app.app_context():
+ html_body = None
+ if html_tmpl_path:
+ try:
+ html_body = render_template(html_tmpl_path, **context_dict)
+ except Exception as exc:
+ logger.error('BILLING EMAIL | html_render_failed | tmpl=%s | err=%s',
+ html_tmpl_path, exc)
+
msg = Message(
subject=subject,
recipients=[to_addr],
- body=body,
+ body=plain_body,
+ html=html_body,
)
mail.send(msg)
logger.info('BILLING EMAIL | sent | event=%s | to=%s', event_type, to_addr)
@@ -101,5 +92,4 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
logger.error('BILLING EMAIL | send_failed | event=%s | to=%s | err=%s',
event_type, to_addr, exc)
- thread = threading.Thread(target=_send, daemon=True)
- thread.start()
+ threading.Thread(target=_send, daemon=True).start()
diff --git a/app/routes/tenant_settings.py b/app/routes/tenant_settings.py
index a5a0703..6aba584 100644
--- a/app/routes/tenant_settings.py
+++ b/app/routes/tenant_settings.py
@@ -231,12 +231,12 @@ def plan():
subscription_status = None
trial_ends_at = None
has_stripe_customer = False
+ invoices = []
if _mt_enabled():
tenant_ctx = getattr(g, 'tenant', None)
if tenant_ctx is not None:
subscription_status = tenant_ctx.subscription_status
trial_ends_at = tenant_ctx.trial_ends_at
- # Check whether stripe_customer_id is set (for "Manage Billing" link).
if billing_enabled:
try:
from control.base import control_session
@@ -244,8 +244,32 @@ def plan():
with control_session() as s:
t = s.get(ControlTenant, tenant_ctx.id)
has_stripe_customer = bool(t and t.stripe_customer_id)
+ stripe_customer_id = t.stripe_customer_id if t else None
except Exception:
- pass
+ stripe_customer_id = None
+
+ # Fetch last 10 invoices from Stripe.
+ if stripe_customer_id:
+ try:
+ import stripe as _stripe
+ _stripe.api_key = current_app.config.get('STRIPE_SECRET_KEY', '')
+ raw = _stripe.Invoice.list(customer=stripe_customer_id, limit=10)
+ for inv in raw.auto_paging_iter():
+ import datetime as _dt
+ invoices.append({
+ 'id': inv.id,
+ 'number': inv.number or inv.id,
+ 'date': _dt.datetime.fromtimestamp(inv.created).strftime('%b %d, %Y'),
+ 'amount': '${:,.2f}'.format(inv.amount_paid / 100),
+ 'currency': (inv.currency or 'usd').upper(),
+ 'status': inv.status,
+ 'pdf_url': inv.invoice_pdf,
+ 'hosted_url': inv.hosted_invoice_url,
+ })
+ if len(invoices) >= 10:
+ break
+ except Exception as exc:
+ logger.error('tenant_settings.plan: stripe invoice fetch failed: %s', exc)
return render_template(
'tenant_settings/plan.html',
@@ -255,6 +279,7 @@ def plan():
subscription_status=subscription_status,
trial_ends_at=trial_ends_at,
has_stripe_customer=has_stripe_customer,
+ invoices=invoices,
)
diff --git a/app/templates/billing/email/base.html b/app/templates/billing/email/base.html
new file mode 100644
index 0000000..8827399
--- /dev/null
+++ b/app/templates/billing/email/base.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+{{ subject }}
+
+
+
+
+
+
+
+ |
+
+ ✓ Janitorial QC
+
+ |
+
+
+
+ |
+ {% block body %}{% endblock %}
+ |
+
+
+
+ |
+
+ You received this email because you are the billing contact for your
+ JQC workspace. If you have questions, reply to this email or contact
+ support@jqc.app.
+
+ |
+
+
+ |
+
+
+
diff --git a/app/templates/billing/email/payment_failed.html b/app/templates/billing/email/payment_failed.html
new file mode 100644
index 0000000..78d2914
--- /dev/null
+++ b/app/templates/billing/email/payment_failed.html
@@ -0,0 +1,39 @@
+{% extends "billing/email/base.html" %}
+{% set subject = "Action Required: Payment failed for your JQC subscription" %}
+{% block body %}
+Payment failed
+We were unable to process your most recent payment.
+
+
+
+ |
+
+ ⚠ Your subscription may be suspended if the payment is not resolved.
+ Please update your payment method as soon as possible.
+
+ |
+
+
+
+
+ This can happen when a card expires, has insufficient funds, or is declined by
+ your bank. Updating your payment method takes less than a minute.
+
+
+
+
+
+ Or copy this link into your browser:
+ {{ portal_url }}
+
+{% endblock %}
diff --git a/app/templates/billing/email/subscription_cancelled.html b/app/templates/billing/email/subscription_cancelled.html
new file mode 100644
index 0000000..988109c
--- /dev/null
+++ b/app/templates/billing/email/subscription_cancelled.html
@@ -0,0 +1,39 @@
+{% extends "billing/email/base.html" %}
+{% set subject = "Your JQC subscription has been cancelled" %}
+{% block body %}
+Subscription cancelled
+Your JQC subscription has ended and your workspace has been suspended.
+
+
+
+ |
+
+ ✗ Workspace access suspended
+ Your data is safe and will be retained for 30 days. Reactivate at any time to restore access immediately.
+
+ |
+
+
+
+
+ If this was a mistake or you'd like to reactivate your subscription,
+ click the button below to manage your billing.
+
+
+
+
+
+ Or copy this link into your browser:
+ {{ portal_url }}
+
+{% endblock %}
diff --git a/app/templates/billing/email/trial_ending.html b/app/templates/billing/email/trial_ending.html
new file mode 100644
index 0000000..e1e4cf0
--- /dev/null
+++ b/app/templates/billing/email/trial_ending.html
@@ -0,0 +1,40 @@
+{% extends "billing/email/base.html" %}
+{% set subject = "Your JQC free trial ends in 3 days" %}
+{% block body %}
+Your free trial is ending soon
+Subscribe before {{ trial_ends_at }} to keep your workspace active.
+
+
+
+ |
+
+ ⌛ Trial expiry: {{ trial_ends_at }}
+ After this date your workspace will be locked until you subscribe.
+ All your data is preserved.
+
+ |
+
+
+
+
+ Choose the plan that fits your team — you can upgrade or downgrade at any time,
+ and your first bill is prorated to today.
+
+
+
+
+
+ Or copy this link into your browser:
+ {{ subscribe_url }}
+
+{% endblock %}
diff --git a/app/templates/tenant_settings/plan.html b/app/templates/tenant_settings/plan.html
index 8206bf2..1c3822e 100644
--- a/app/templates/tenant_settings/plan.html
+++ b/app/templates/tenant_settings/plan.html
@@ -121,6 +121,67 @@
+{% if billing_enabled and invoices %}
+
+
+
+
+
+
+ | Invoice # |
+ Date |
+ Amount |
+ Status |
+ |
+
+
+
+ {% for inv in invoices %}
+
+ | {{ inv.number }} |
+ {{ inv.date }} |
+ {{ inv.amount }} |
+
+ {% if inv.status == 'paid' %}
+ Paid
+ {% elif inv.status == 'open' %}
+ Open
+ {% elif inv.status == 'void' %}
+ Void
+ {% elif inv.status == 'uncollectible' %}
+ Uncollectible
+ {% else %}
+ {{ inv.status }}
+ {% endif %}
+ |
+
+ {% if inv.pdf_url %}
+
+ PDF
+
+ {% endif %}
+ {% if inv.hosted_url %}
+
+ View
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+{% endif %}
+
{% else %}
Plan information is only available when multi-tenancy is enabled.
diff --git a/control/backup.py b/control/backup.py
new file mode 100644
index 0000000..21c0bf5
--- /dev/null
+++ b/control/backup.py
@@ -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
/_.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()
diff --git a/control/panel/__init__.py b/control/panel/__init__.py
index 451e630..48480c6 100644
--- a/control/panel/__init__.py
+++ b/control/panel/__init__.py
@@ -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
diff --git a/control/panel/health.py b/control/panel/health.py
new file mode 100644
index 0000000..4753deb
--- /dev/null
+++ b/control/panel/health.py
@@ -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(),
+ )
diff --git a/control/panel/templates/panel/base.html b/control/panel/templates/panel/base.html
index 3e85bd9..f7e3f5d 100644
--- a/control/panel/templates/panel/base.html
+++ b/control/panel/templates/panel/base.html
@@ -80,6 +80,10 @@
class="{{ 'active' if request.endpoint == 'tenants.provision_tenant' else '' }}">
New Tenant
+
+ Health
+
- {# ── Billing (MT-8, read-only) ── #}
+ {# ── Billing (MT-8) ── #}
-
-
Subscription
+
+ {# Read-only info #}
+
+
Status
{% if tenant.subscription_status == 'active' %}
active
@@ -309,44 +311,80 @@
{% if tenant.trial_ends_at %}
-
-
Trial ends
-
{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}
-
+
Trial ends
+
{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}
{% endif %}
{% if tenant.current_period_end %}
-
-
Period ends
-
{{ tenant.current_period_end.strftime('%Y-%m-%d') }}
-
+
Period ends
+
{{ tenant.current_period_end.strftime('%Y-%m-%d') }}
{% endif %}
{% if tenant.billing_email %}
-
-
Billing email
-
{{ tenant.billing_email }}
-
+
Billing email
+
{{ tenant.billing_email }}
{% endif %}
{% if tenant.stripe_customer_id %}
-
-
Stripe customer
-
- {{ tenant.stripe_customer_id[:20] }}…
-
-
+
Stripe customer
+
+ {{ tenant.stripe_customer_id }}
{% endif %}
{% if tenant.stripe_subscription_id %}
-
-
Stripe subscription
-
- {{ tenant.stripe_subscription_id[:20] }}…
+
Stripe subscription
+
+ {{ tenant.stripe_subscription_id }}
+ {% endif %}
+
+
+
+ {# Set trial #}
+
- {% endif %}
- {% if not tenant.subscription_status %}
-
No billing configured.
+
+
+ {# Override status #}
+
+
+ {# Apply coupon — only if Stripe customer exists #}
+ {% if tenant.stripe_customer_id %}
+
{% endif %}
+
diff --git a/control/panel/tenants.py b/control/panel/tenants.py
index 0b483a0..9a79c27 100644
--- a/control/panel/tenants.py
+++ b/control/panel/tenants.py
@@ -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('/
/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('//migrate', methods=['POST'])