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. +

+ + + + + +
+ + Update Payment Method + +
+ +

+ 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. +

+ + + + + +
+ + Reactivate Subscription + +
+ +

+ 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. +

+ + + + + +
+ + Subscribe Now + +
+ +

+ 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 History + + Manage in Stripe › + +
+
+ + + + + + + + + + + + {% for inv in invoices %} + + + + + + + + {% endfor %} + +
Invoice #DateAmountStatus
{{ 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 %} +
+
+
+{% 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) ── #}
Billing
-
- 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 #} +
+ + + +
+ + days +
-
- {% 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'])