Jun 28 - Update and polish UI/UX
This commit is contained in:
+45
-55
@@ -2,98 +2,89 @@
|
|||||||
app/billing/emails.py
|
app/billing/emails.py
|
||||||
---------------------
|
---------------------
|
||||||
Dunning / billing lifecycle emails sent via Flask-Mail in a background thread.
|
Dunning / billing lifecycle emails sent via Flask-Mail in a background thread.
|
||||||
|
Sends multipart HTML + plain-text messages using Jinja2 templates in
|
||||||
Pattern mirrors app/utils/notifications.py: captures the app object before
|
app/templates/billing/email/.
|
||||||
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')
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from flask import current_app
|
from flask import current_app, render_template
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from app import mail
|
from app import mail
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Email templates ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_SUBJECTS = {
|
_SUBJECTS = {
|
||||||
'payment_failed': 'Action Required: Payment failed for your JQC subscription',
|
'payment_failed': 'Action Required: Payment failed for your JQC subscription',
|
||||||
'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',
|
||||||
}
|
}
|
||||||
|
|
||||||
_BODIES = {
|
_HTML_TEMPLATES = {
|
||||||
'payment_failed': """\
|
'payment_failed': 'billing/email/payment_failed.html',
|
||||||
Hi,
|
'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.
|
_PLAIN_BODIES = {
|
||||||
|
'payment_failed': (
|
||||||
Please update your payment method to avoid interruption to your service:
|
"We were unable to process your most recent payment for your JQC subscription.\n\n"
|
||||||
{portal_url}
|
"Please update your payment method to avoid interruption to your service:\n"
|
||||||
|
" {portal_url}\n\n— The JQC Team"
|
||||||
If you have any questions, please contact support.
|
),
|
||||||
|
'trial_ending': (
|
||||||
— The JQC Team
|
"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"
|
||||||
'trial_ending': """\
|
),
|
||||||
Hi,
|
'subscription_cancelled': (
|
||||||
|
"Your JQC subscription has been cancelled and your workspace access has been suspended.\n\n"
|
||||||
Your JQC free trial will end on {trial_ends_at}. After that date, you will
|
"You can reactivate at any time:\n"
|
||||||
need an active subscription to continue using the service.
|
" {portal_url}\n\n— The JQC Team"
|
||||||
|
),
|
||||||
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
|
|
||||||
""",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
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:
|
Args:
|
||||||
to_addr: Recipient email address (tenant admin or billing contact).
|
to_addr: Recipient email address.
|
||||||
event_type: Key in _SUBJECTS / _BODIES.
|
event_type: One of 'payment_failed', 'trial_ending', 'subscription_cancelled'.
|
||||||
context_dict: Variables substituted into the body template.
|
context_dict: Variables substituted into both the HTML template and plain body.
|
||||||
"""
|
"""
|
||||||
if not to_addr:
|
if not to_addr:
|
||||||
logger.warning('BILLING EMAIL | skipped | event=%s | reason=no_recipient', event_type)
|
logger.warning('BILLING EMAIL | skipped | event=%s | reason=no_recipient', event_type)
|
||||||
return
|
return
|
||||||
|
|
||||||
subject = _SUBJECTS.get(event_type, 'JQC Billing Notification')
|
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:
|
try:
|
||||||
body = body_template.format(**context_dict)
|
plain_body = plain_tmpl.format(**context_dict)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
logger.error('BILLING EMAIL | template_error | event=%s | missing_key=%s', event_type, exc)
|
logger.error('BILLING EMAIL | plain_template_error | event=%s | key=%s', event_type, exc)
|
||||||
body = body_template # send with unfilled placeholders rather than crashing
|
plain_body = plain_tmpl
|
||||||
|
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
def _send():
|
def _send():
|
||||||
try:
|
try:
|
||||||
with app.app_context():
|
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(
|
msg = Message(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
recipients=[to_addr],
|
recipients=[to_addr],
|
||||||
body=body,
|
body=plain_body,
|
||||||
|
html=html_body,
|
||||||
)
|
)
|
||||||
mail.send(msg)
|
mail.send(msg)
|
||||||
logger.info('BILLING EMAIL | sent | event=%s | to=%s', event_type, to_addr)
|
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',
|
logger.error('BILLING EMAIL | send_failed | event=%s | to=%s | err=%s',
|
||||||
event_type, to_addr, exc)
|
event_type, to_addr, exc)
|
||||||
|
|
||||||
thread = threading.Thread(target=_send, daemon=True)
|
threading.Thread(target=_send, daemon=True).start()
|
||||||
thread.start()
|
|
||||||
|
|||||||
@@ -231,12 +231,12 @@ def plan():
|
|||||||
subscription_status = None
|
subscription_status = None
|
||||||
trial_ends_at = None
|
trial_ends_at = None
|
||||||
has_stripe_customer = False
|
has_stripe_customer = False
|
||||||
|
invoices = []
|
||||||
if _mt_enabled():
|
if _mt_enabled():
|
||||||
tenant_ctx = getattr(g, 'tenant', None)
|
tenant_ctx = getattr(g, 'tenant', None)
|
||||||
if tenant_ctx is not None:
|
if tenant_ctx is not None:
|
||||||
subscription_status = tenant_ctx.subscription_status
|
subscription_status = tenant_ctx.subscription_status
|
||||||
trial_ends_at = tenant_ctx.trial_ends_at
|
trial_ends_at = tenant_ctx.trial_ends_at
|
||||||
# Check whether stripe_customer_id is set (for "Manage Billing" link).
|
|
||||||
if billing_enabled:
|
if billing_enabled:
|
||||||
try:
|
try:
|
||||||
from control.base import control_session
|
from control.base import control_session
|
||||||
@@ -244,8 +244,32 @@ def plan():
|
|||||||
with control_session() as s:
|
with control_session() as s:
|
||||||
t = s.get(ControlTenant, tenant_ctx.id)
|
t = s.get(ControlTenant, tenant_ctx.id)
|
||||||
has_stripe_customer = bool(t and t.stripe_customer_id)
|
has_stripe_customer = bool(t and t.stripe_customer_id)
|
||||||
|
stripe_customer_id = t.stripe_customer_id if t else None
|
||||||
except Exception:
|
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(
|
return render_template(
|
||||||
'tenant_settings/plan.html',
|
'tenant_settings/plan.html',
|
||||||
@@ -255,6 +279,7 @@ def plan():
|
|||||||
subscription_status=subscription_status,
|
subscription_status=subscription_status,
|
||||||
trial_ends_at=trial_ends_at,
|
trial_ends_at=trial_ends_at,
|
||||||
has_stripe_customer=has_stripe_customer,
|
has_stripe_customer=has_stripe_customer,
|
||||||
|
invoices=invoices,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{ subject }}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f6f7f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:40px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);overflow:hidden;max-width:100%;">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#1a56db;padding:24px 32px;">
|
||||||
|
<span style="color:#fff;font-size:1.1rem;font-weight:700;letter-spacing:-.01em;">
|
||||||
|
✓ Janitorial QC
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px;">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#f6f7f9;padding:20px 32px;border-top:1px solid #e5e7eb;">
|
||||||
|
<p style="margin:0;font-size:.8rem;color:#9ca3af;line-height:1.5;">
|
||||||
|
You received this email because you are the billing contact for your
|
||||||
|
JQC workspace. If you have questions, reply to this email or contact
|
||||||
|
<a href="mailto:support@jqc.app" style="color:#1a56db;text-decoration:none;">support@jqc.app</a>.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "billing/email/base.html" %}
|
||||||
|
{% set subject = "Action Required: Payment failed for your JQC subscription" %}
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#111827;">Payment failed</h2>
|
||||||
|
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">We were unable to process your most recent payment.</p>
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="12" cellspacing="0" style="background:#fef3c7;border-radius:6px;margin-bottom:24px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p style="margin:0;font-size:.9rem;color:#92400e;line-height:1.5;">
|
||||||
|
<strong>⚠ Your subscription may be suspended</strong> if the payment is not resolved.
|
||||||
|
Please update your payment method as soon as possible.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;font-size:.9rem;color:#374151;line-height:1.6;">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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;">
|
||||||
|
Or copy this link into your browser:<br>
|
||||||
|
<a href="{{ portal_url }}" style="color:#1a56db;word-break:break-all;">{{ portal_url }}</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "billing/email/base.html" %}
|
||||||
|
{% set subject = "Your JQC subscription has been cancelled" %}
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#111827;">Subscription cancelled</h2>
|
||||||
|
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">Your JQC subscription has ended and your workspace has been suspended.</p>
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="12" cellspacing="0" style="background:#fef2f2;border-radius:6px;margin-bottom:24px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p style="margin:0;font-size:.9rem;color:#991b1b;line-height:1.5;">
|
||||||
|
<strong>✗ Workspace access suspended</strong><br>
|
||||||
|
Your data is safe and will be retained for 30 days. Reactivate at any time to restore access immediately.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;font-size:.9rem;color:#374151;line-height:1.6;">
|
||||||
|
If this was a mistake or you'd like to reactivate your subscription,
|
||||||
|
click the button below to manage your billing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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;">
|
||||||
|
Reactivate Subscription
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0;font-size:.85rem;color:#9ca3af;">
|
||||||
|
Or copy this link into your browser:<br>
|
||||||
|
<a href="{{ portal_url }}" style="color:#1a56db;word-break:break-all;">{{ portal_url }}</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "billing/email/base.html" %}
|
||||||
|
{% set subject = "Your JQC free trial ends in 3 days" %}
|
||||||
|
{% block body %}
|
||||||
|
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#111827;">Your free trial is ending soon</h2>
|
||||||
|
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">Subscribe before <strong>{{ trial_ends_at }}</strong> to keep your workspace active.</p>
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="12" cellspacing="0" style="background:#eff6ff;border-radius:6px;margin-bottom:24px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p style="margin:0;font-size:.9rem;color:#1e40af;line-height:1.5;">
|
||||||
|
<strong>⌛ Trial expiry: {{ trial_ends_at }}</strong><br>
|
||||||
|
After this date your workspace will be locked until you subscribe.
|
||||||
|
All your data is preserved.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;font-size:.9rem;color:#374151;line-height:1.6;">
|
||||||
|
Choose the plan that fits your team — you can upgrade or downgrade at any time,
|
||||||
|
and your first bill is prorated to today.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="{{ subscribe_url }}"
|
||||||
|
style="display:inline-block;background:#1a56db;color:#fff;text-decoration:none;
|
||||||
|
padding:12px 28px;border-radius:6px;font-size:.95rem;font-weight:600;">
|
||||||
|
Subscribe Now
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0;font-size:.85rem;color:#9ca3af;">
|
||||||
|
Or copy this link into your browser:<br>
|
||||||
|
<a href="{{ subscribe_url }}" style="color:#1a56db;word-break:break-all;">{{ subscribe_url }}</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -121,6 +121,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if billing_enabled and invoices %}
|
||||||
|
<div class="card border-0 shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;">Invoice History</span>
|
||||||
|
<a href="{{ url_for('billing.portal') }}" class="btn btn-xs btn-outline-secondary"
|
||||||
|
style="font-size:.75rem;padding:2px 10px;">
|
||||||
|
Manage in Stripe ›
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Invoice #</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for inv in invoices %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ inv.number }}</td>
|
||||||
|
<td>{{ inv.date }}</td>
|
||||||
|
<td>{{ inv.amount }}</td>
|
||||||
|
<td>
|
||||||
|
{% if inv.status == 'paid' %}
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle">Paid</span>
|
||||||
|
{% elif inv.status == 'open' %}
|
||||||
|
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">Open</span>
|
||||||
|
{% elif inv.status == 'void' %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Void</span>
|
||||||
|
{% elif inv.status == 'uncollectible' %}
|
||||||
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">Uncollectible</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-muted border">{{ inv.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if inv.pdf_url %}
|
||||||
|
<a href="{{ inv.pdf_url }}" target="_blank" class="btn btn-xs btn-outline-secondary me-1"
|
||||||
|
style="font-size:.75rem;padding:2px 8px;">
|
||||||
|
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if inv.hosted_url %}
|
||||||
|
<a href="{{ inv.hosted_url }}" target="_blank" class="btn btn-xs btn-outline-primary"
|
||||||
|
style="font-size:.75rem;padding:2px 8px;">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
Plan information is only available when multi-tenancy is enabled.
|
Plan information is only available when multi-tenancy is enabled.
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -28,6 +28,7 @@ from flask_wtf.csrf import CSRFProtect
|
|||||||
|
|
||||||
from .auth import bp as auth_bp
|
from .auth import bp as auth_bp
|
||||||
from .tenants import bp as tenants_bp
|
from .tenants import bp as tenants_bp
|
||||||
|
from .health import bp as health_bp
|
||||||
|
|
||||||
csrf = CSRFProtect()
|
csrf = CSRFProtect()
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ def create_panel_app():
|
|||||||
# ── Blueprints ────────────────────────────────────────────────────────
|
# ── Blueprints ────────────────────────────────────────────────────────
|
||||||
app.register_blueprint(auth_bp) # /login, /logout
|
app.register_blueprint(auth_bp) # /login, /logout
|
||||||
app.register_blueprint(tenants_bp) # /tenants/…
|
app.register_blueprint(tenants_bp) # /tenants/…
|
||||||
|
app.register_blueprint(health_bp) # /health/
|
||||||
|
|
||||||
# ── Root redirect ─────────────────────────────────────────────────────
|
# ── Root redirect ─────────────────────────────────────────────────────
|
||||||
from flask import redirect, url_for
|
from flask import redirect, url_for
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -80,6 +80,10 @@
|
|||||||
class="{{ 'active' if request.endpoint == 'tenants.provision_tenant' else '' }}">
|
class="{{ 'active' if request.endpoint == 'tenants.provision_tenant' else '' }}">
|
||||||
<i class="bi bi-plus-circle"></i> New Tenant
|
<i class="bi bi-plus-circle"></i> New Tenant
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
<div class="sa-footer">
|
<div class="sa-footer">
|
||||||
{% if sa_username %}
|
{% if sa_username %}
|
||||||
|
|||||||
@@ -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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Billing (MT-8, read-only) ── #}
|
{# ── Billing (MT-8) ── #}
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-header bg-white py-2">
|
<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>
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-credit-card me-1"></i>Billing</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body py-2" style="font-size:.83rem;">
|
<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>
|
<div>
|
||||||
{% if tenant.subscription_status == 'active' %}
|
{% if tenant.subscription_status == 'active' %}
|
||||||
<span class="badge bg-success">active</span>
|
<span class="badge bg-success">active</span>
|
||||||
@@ -309,44 +311,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if tenant.trial_ends_at %}
|
{% if tenant.trial_ends_at %}
|
||||||
<div class="mb-1">
|
<div class="mb-1"><span class="text-muted">Trial ends</span>
|
||||||
<span class="text-muted">Trial ends</span>
|
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div></div>
|
||||||
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if tenant.current_period_end %}
|
{% if tenant.current_period_end %}
|
||||||
<div class="mb-1">
|
<div class="mb-1"><span class="text-muted">Period ends</span>
|
||||||
<span class="text-muted">Period ends</span>
|
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div></div>
|
||||||
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if tenant.billing_email %}
|
{% if tenant.billing_email %}
|
||||||
<div class="mb-1">
|
<div class="mb-1"><span class="text-muted">Billing email</span>
|
||||||
<span class="text-muted">Billing email</span>
|
<div>{{ tenant.billing_email }}</div></div>
|
||||||
<div>{{ tenant.billing_email }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if tenant.stripe_customer_id %}
|
{% if tenant.stripe_customer_id %}
|
||||||
<div class="mb-1">
|
<div class="mb-1"><span class="text-muted">Stripe customer</span>
|
||||||
<span class="text-muted">Stripe customer</span>
|
<div class="font-monospace" style="font-size:.72rem;" title="{{ tenant.stripe_customer_id }}">
|
||||||
<div class="font-monospace" style="font-size:.75rem;"
|
{{ tenant.stripe_customer_id }}</div></div>
|
||||||
title="{{ tenant.stripe_customer_id }}">
|
|
||||||
{{ tenant.stripe_customer_id[:20] }}…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if tenant.stripe_subscription_id %}
|
{% if tenant.stripe_subscription_id %}
|
||||||
<div class="mb-1">
|
<div class="mb-2"><span class="text-muted">Stripe subscription</span>
|
||||||
<span class="text-muted">Stripe subscription</span>
|
<div class="font-monospace" style="font-size:.72rem;" title="{{ tenant.stripe_subscription_id }}">
|
||||||
<div class="font-monospace" style="font-size:.75rem;"
|
{{ tenant.stripe_subscription_id }}</div></div>
|
||||||
title="{{ tenant.stripe_subscription_id }}">
|
|
||||||
{{ tenant.stripe_subscription_id[:20] }}…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not tenant.subscription_status %}
|
|
||||||
<span class="text-muted">No billing configured.</span>
|
<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>
|
||||||
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -376,6 +376,72 @@ def provision_tenant():
|
|||||||
return redirect(url_for('tenants.tenant_detail', tenant_id=info['tenant_id']))
|
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 ─────────────────────────────────────────────────────────────
|
# ── run migration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/<int:tenant_id>/migrate', methods=['POST'])
|
@bp.route('/<int:tenant_id>/migrate', methods=['POST'])
|
||||||
|
|||||||
Reference in New Issue
Block a user