Jun 28 - Implement payment functions (Stripe)

This commit is contained in:
2026-06-28 12:01:25 -04:00
parent 61ede27093
commit f292c8fb7b
21 changed files with 1112 additions and 18 deletions
+30
View File
@@ -161,6 +161,30 @@ def create_app(config_name='default'):
pass pass
return {'tenant_branding': None} return {'tenant_branding': None}
@app.context_processor
def inject_billing_context():
"""MT-8: push billing state into every template."""
try:
from flask import g
billing_warning = getattr(g, 'billing_warning', None)
tenant = getattr(g, 'tenant', None)
subscription_status = tenant.subscription_status if tenant else None
trial_ends_at = tenant.trial_ends_at if tenant else None
return {
'billing_enabled': app.config.get('BILLING_ENABLED', False),
'billing_warning': billing_warning,
'subscription_status': subscription_status,
'trial_ends_at': trial_ends_at,
}
except Exception:
pass
return {
'billing_enabled': False,
'billing_warning': None,
'subscription_status': None,
'trial_ends_at': None,
}
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
from app.routes import auth, dashboard, inspections, templates, reports, facilities from app.routes import auth, dashboard, inspections, templates, reports, facilities
@@ -174,6 +198,7 @@ def create_app(config_name='default'):
from app.routes import broadcast # Admin broadcast messages from app.routes import broadcast # Admin broadcast messages
from app.routes import devices # Admin device management from app.routes import devices # Admin device management
from app.routes import tenant_settings # MT-7 — tenant self-service from app.routes import tenant_settings # MT-7 — tenant self-service
from app.billing import bp as billing_bp # MT-8 — Stripe billing
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp) app.register_blueprint(dashboard.bp)
@@ -191,6 +216,11 @@ def create_app(config_name='default'):
app.register_blueprint(broadcast.bp) app.register_blueprint(broadcast.bp)
app.register_blueprint(devices.bp) app.register_blueprint(devices.bp)
app.register_blueprint(tenant_settings.bp) app.register_blueprint(tenant_settings.bp)
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
csrf.exempt(billing_bp)
app.register_blueprint(billing_bp)
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
+15
View File
@@ -0,0 +1,15 @@
"""
app/billing/__init__.py
-----------------------
MT-8 Stripe billing package. Blueprint registered at /billing/.
All routes are CSRF-exempt (billing.bp is exempted individually in
app/__init__.py). The webhook route specifically requires raw POST body
access — CSRF tokens are never sent by Stripe.
"""
from flask import Blueprint
bp = Blueprint('billing', __name__, url_prefix='/billing')
from app.billing import routes # noqa: F401, E402 — registers routes on bp
+105
View File
@@ -0,0 +1,105 @@
"""
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')
"""
import logging
import threading
from flask import current_app
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',
}
_BODIES = {
'payment_failed': """\
Hi,
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
""",
}
def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
"""Send a billing lifecycle 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.
"""
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, '')
try:
body = body_template.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
app = current_app._get_current_object()
def _send():
try:
with app.app_context():
msg = Message(
subject=subject,
recipients=[to_addr],
body=body,
)
mail.send(msg)
logger.info('BILLING EMAIL | sent | event=%s | to=%s', event_type, to_addr)
except Exception as exc:
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()
+207
View File
@@ -0,0 +1,207 @@
"""
app/billing/routes.py
---------------------
MT-8 Stripe billing routes.
Routes:
GET /billing/subscribe → create Stripe Checkout session → redirect
GET /billing/portal → create Stripe Customer Portal session → redirect
POST /billing/webhook → verify Stripe signature → handle event
GET /billing/suspended → suspension landing page (no auth required)
GET /billing/success → post-checkout success landing
GET /billing/cancel → post-checkout cancel (redirects to plan page)
All routes are CSRF-exempt (billing.bp is exempted in app/__init__.py).
The webhook route is the primary reason — Stripe cannot send CSRF tokens.
"""
import logging
from flask import (
current_app, redirect, url_for, render_template,
request, flash, g, abort,
)
from flask_login import login_required, current_user
from app.billing import bp
from app.utils.decorators import admin_required
logger = logging.getLogger(__name__)
def _billing_enabled():
return current_app.config.get('BILLING_ENABLED', False)
def _mt_enabled():
return current_app.config.get('MULTI_TENANT_ENABLED', False)
# ── Subscribe: initiate Stripe Checkout ───────────────────────────────────────
@bp.route('/subscribe')
@login_required
@admin_required
def subscribe():
if not _billing_enabled():
flash('Billing is not enabled on this deployment.', 'info')
return redirect(url_for('tenant_settings.plan'))
if not _mt_enabled():
flash('Billing is only available in multi-tenant mode.', 'info')
return redirect(url_for('tenant_settings.plan'))
tenant = getattr(g, 'tenant', None)
if tenant is None:
abort(400)
from control.base import control_session
from control.models import Tenant as ControlTenant
from app.billing.stripe_client import create_stripe_customer, create_checkout_session
with control_session() as s:
t = s.get(ControlTenant, tenant.id)
if t is None:
flash('Tenant not found.', 'danger')
return redirect(url_for('tenant_settings.plan'))
plan = t.plan
if plan is None or not plan.stripe_price_id:
flash(
'Your current plan does not have a Stripe price configured. '
'Please contact support to set up billing.',
'warning',
)
return redirect(url_for('tenant_settings.plan'))
# Get or create Stripe customer. Write back immediately within the
# control_session so the customer_id is persisted before the redirect.
if not t.stripe_customer_id:
try:
customer = create_stripe_customer(
email=current_user.email,
name=current_user.display_name,
tenant_id=tenant.id,
slug=tenant.slug,
)
t.stripe_customer_id = customer['id']
t.billing_email = current_user.email
except Exception as exc:
logger.error('BILLING | create_customer_failed | tenant=%s err=%s', tenant.slug, exc)
flash('Could not connect to Stripe. Please try again or contact support.', 'danger')
return redirect(url_for('tenant_settings.plan'))
stripe_customer_id = t.stripe_customer_id
price_id = plan.stripe_price_id
# Create Checkout Session AFTER the control_session commits customer_id.
base_url = (current_app.config.get('APP_BASE_URL') or request.host_url).rstrip('/')
try:
session = create_checkout_session(
customer_id=stripe_customer_id,
price_id=price_id,
tenant_id=tenant.id,
success_url=f'{base_url}/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
cancel_url=f'{base_url}/billing/cancel',
)
except Exception as exc:
logger.error('BILLING | create_checkout_failed | tenant=%s err=%s', tenant.slug, exc)
flash('Could not create a Stripe Checkout session. Please try again.', 'danger')
return redirect(url_for('tenant_settings.plan'))
return redirect(session['url'], 303)
# ── Portal: Stripe Customer Portal for managing subscriptions ─────────────────
@bp.route('/portal')
@login_required
@admin_required
def portal():
if not _billing_enabled():
flash('Billing is not enabled on this deployment.', 'info')
return redirect(url_for('tenant_settings.plan'))
tenant = getattr(g, 'tenant', None)
if tenant is None:
abort(400)
from control.base import control_session
from control.models import Tenant as ControlTenant
from app.billing.stripe_client import create_portal_session
with control_session() as s:
t = s.get(ControlTenant, tenant.id)
if t is None or not t.stripe_customer_id:
flash('No active subscription found. Please subscribe first.', 'warning')
return redirect(url_for('tenant_settings.plan'))
stripe_customer_id = t.stripe_customer_id
base_url = (current_app.config.get('APP_BASE_URL') or request.host_url).rstrip('/')
return_url = f'{base_url}/settings/plan'
try:
session = create_portal_session(
customer_id=stripe_customer_id,
return_url=return_url,
)
except Exception as exc:
logger.error('BILLING | create_portal_failed | tenant=%s err=%s', tenant.slug, exc)
flash('Could not open the billing portal. Please try again.', 'danger')
return redirect(url_for('tenant_settings.plan'))
return redirect(session['url'], 303)
# ── Webhook: receives all Stripe events ───────────────────────────────────────
@bp.route('/webhook', methods=['POST'])
def webhook():
"""Stripe webhook endpoint. Verifies signature then dispatches to webhooks.py."""
webhook_secret = current_app.config.get('STRIPE_WEBHOOK_SECRET')
if not webhook_secret:
logger.error('BILLING WEBHOOK | STRIPE_WEBHOOK_SECRET not configured')
return {'error': 'Webhook secret not configured'}, 500
payload = request.get_data()
sig_header = request.headers.get('Stripe-Signature', '')
try:
import stripe
stripe.api_key = current_app.config.get('STRIPE_SECRET_KEY', '')
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
except ValueError:
logger.warning('BILLING WEBHOOK | invalid_payload')
return {'error': 'Invalid payload'}, 400
except stripe.error.SignatureVerificationError:
logger.warning('BILLING WEBHOOK | invalid_signature')
return {'error': 'Invalid signature'}, 400
from app.billing.webhooks import handle_event
handle_event(event)
return {'ok': True}, 200
# ── Suspended: shown when subscription is cancelled ───────────────────────────
@bp.route('/suspended')
def suspended():
"""Billing-suspended landing page. No authentication required."""
return render_template('billing/suspended.html')
# ── Post-checkout success / cancel ────────────────────────────────────────────
@bp.route('/success')
@login_required
def success():
flash('Your subscription is now active. Welcome aboard!', 'success')
return render_template('billing/success.html')
@bp.route('/cancel')
@login_required
def cancel():
flash('Checkout was cancelled. Your subscription has not changed.', 'info')
return redirect(url_for('tenant_settings.plan'))
+87
View File
@@ -0,0 +1,87 @@
"""
app/billing/stripe_client.py
-----------------------------
Thin wrappers around the Stripe SDK. No Flask imports — usable from routes,
webhooks, and background tasks alike.
All functions raise RuntimeError with a clear message if STRIPE_SECRET_KEY is
absent, rather than producing cryptic Stripe SDK authentication errors.
"""
import os
import logging
logger = logging.getLogger(__name__)
def _get_stripe():
"""Import and initialise Stripe with the secret key. Raises if key is missing."""
import stripe as _stripe
key = os.environ.get('STRIPE_SECRET_KEY')
if not key:
raise RuntimeError(
'STRIPE_SECRET_KEY is not set. '
'Add it to your environment before enabling BILLING_ENABLED=true.'
)
_stripe.api_key = key
return _stripe
def create_stripe_customer(*, email: str, name: str, tenant_id: int, slug: str) -> dict:
"""Create a Stripe Customer and return the customer dict.
Stores tenant_id and slug in Stripe metadata for webhook reverse-lookup.
"""
stripe = _get_stripe()
customer = stripe.Customer.create(
email=email,
name=name,
metadata={'tenant_id': str(tenant_id), 'slug': slug},
)
logger.info('BILLING | stripe_customer_created | tenant=%s customer=%s', slug, customer['id'])
return customer
def create_checkout_session(
*,
customer_id: str,
price_id: str,
tenant_id: int,
success_url: str,
cancel_url: str,
) -> dict:
"""Create a Stripe Checkout Session for a subscription and return the session dict.
`success_url` should contain `{CHECKOUT_SESSION_ID}` which Stripe replaces
with the actual session ID on redirect (pass as `{{CHECKOUT_SESSION_ID}}`
inside an f-string to produce the literal braces).
"""
stripe = _get_stripe()
session = stripe.checkout.Session.create(
customer=customer_id,
mode='subscription',
line_items=[{'price': price_id, 'quantity': 1}],
success_url=success_url,
cancel_url=cancel_url,
metadata={'tenant_id': str(tenant_id)},
allow_promotion_codes=True,
)
logger.info('BILLING | checkout_session_created | tenant_id=%s session=%s', tenant_id, session['id'])
return session
def create_portal_session(*, customer_id: str, return_url: str) -> dict:
"""Create a Stripe Billing Portal Session and return the session dict."""
stripe = _get_stripe()
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url=return_url,
)
logger.info('BILLING | portal_session_created | customer=%s', customer_id)
return session
def retrieve_subscription(subscription_id: str) -> dict:
"""Retrieve a Stripe Subscription object."""
stripe = _get_stripe()
return stripe.Subscription.retrieve(subscription_id)
+263
View File
@@ -0,0 +1,263 @@
"""
app/billing/webhooks.py
------------------------
Stripe webhook event dispatcher. Receives a verified stripe.Event object
(signature already checked by the route) and mutates the control DB accordingly.
Each event handler is self-contained and catches its own exceptions so that
one malformed event cannot abort processing of subsequent events in the same
delivery batch.
Public API:
handle_event(event) -> None
"""
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
# Maps Stripe subscription status strings to our internal subscription_status values.
_STRIPE_STATUS_MAP = {
'trialing': 'trial',
'active': 'active',
'past_due': 'past_due',
'canceled': 'cancelled', # Stripe uses single 'l'
'cancelled': 'cancelled',
'incomplete': 'past_due',
'incomplete_expired': 'cancelled',
'unpaid': 'past_due',
'paused': 'past_due',
}
def _ts_to_dt(ts) -> datetime | None:
"""Convert a Unix timestamp (int or None) to a naive UTC datetime."""
if ts is None:
return None
return datetime.fromtimestamp(int(ts), tz=timezone.utc).replace(tzinfo=None)
def _lookup_tenant_by_customer(stripe_customer_id: str):
"""Return the Tenant ORM object (within an open session) or None."""
from control.models import Tenant
return None # placeholder — caller must query within a control_session
def _get_base_url() -> str:
from flask import current_app
return (current_app.config.get('APP_BASE_URL') or '').rstrip('/')
def handle_event(event) -> None:
"""Dispatch a verified Stripe event to the appropriate handler."""
event_type = event['type']
data_obj = event['data']['object']
handlers = {
'checkout.session.completed': _on_checkout_completed,
'customer.subscription.updated': _on_subscription_updated,
'customer.subscription.deleted': _on_subscription_deleted,
'invoice.payment_succeeded': _on_payment_succeeded,
'invoice.payment_failed': _on_payment_failed,
'customer.subscription.trial_will_end': _on_trial_will_end,
}
handler = handlers.get(event_type)
if handler is None:
logger.debug('BILLING WEBHOOK | unhandled | type=%s', event_type)
return
try:
handler(data_obj)
except Exception as exc:
logger.error('BILLING WEBHOOK | handler_error | type=%s | err=%s', event_type, exc, exc_info=True)
# ── Individual event handlers ──────────────────────────────────────────────────
def _on_checkout_completed(obj):
"""checkout.session.completed — customer completed Stripe Checkout."""
customer_id = obj.get('customer')
subscription_id = obj.get('subscription')
if not customer_id or not subscription_id:
logger.warning('BILLING | checkout_completed_missing_fields | obj=%s', obj.get('id'))
return
from control.base import control_session
from control.models import Tenant, TenantAudit
from control.time_utils import now_eastern
with control_session() as s:
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
if t is None:
logger.warning('BILLING | checkout_completed_no_tenant | customer=%s', customer_id)
return
t.stripe_subscription_id = subscription_id
t.subscription_status = 'active'
t.trial_ends_at = None # trial period ended at checkout
s.add(TenantAudit(
action='billing_checkout_completed',
tenant_id=t.id,
details=f'subscription_id={subscription_id}',
))
logger.info('BILLING | checkout_completed | tenant_id=%s sub=%s', t.id, subscription_id)
def _on_subscription_updated(obj):
"""customer.subscription.updated — sync status from Stripe."""
customer_id = obj.get('customer')
subscription_id = obj.get('id')
stripe_status = obj.get('status', '')
period_end_ts = obj.get('current_period_end')
trial_end_ts = obj.get('trial_end')
our_status = _STRIPE_STATUS_MAP.get(stripe_status)
if our_status is None:
logger.warning('BILLING | subscription_updated_unknown_status | status=%s', stripe_status)
return
from control.base import control_session
from control.models import Tenant, TenantAudit
with control_session() as s:
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
if t is None:
logger.warning('BILLING | subscription_updated_no_tenant | customer=%s', customer_id)
return
old_status = t.subscription_status
t.subscription_status = our_status
t.stripe_subscription_id = subscription_id
t.current_period_end = _ts_to_dt(period_end_ts)
if trial_end_ts:
t.trial_ends_at = _ts_to_dt(trial_end_ts)
s.add(TenantAudit(
action='billing_subscription_updated',
tenant_id=t.id,
details=f'stripe_status={stripe_status} our_status={our_status} prev={old_status}',
))
logger.info('BILLING | subscription_updated | tenant_id=%s %s%s', t.id, old_status, our_status)
def _on_subscription_deleted(obj):
"""customer.subscription.deleted — subscription was cancelled."""
customer_id = obj.get('customer')
from control.base import control_session
from control.models import Tenant, TenantAudit
from control.time_utils import now_eastern
from app.billing.emails import send_billing_email
with control_session() as s:
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
if t is None:
logger.warning('BILLING | subscription_deleted_no_tenant | customer=%s', customer_id)
return
t.subscription_status = 'cancelled'
billing_email = t.billing_email
s.add(TenantAudit(
action='billing_subscription_cancelled',
tenant_id=t.id,
details=f'customer={customer_id}',
))
logger.info('BILLING | subscription_deleted | tenant_id=%s', t.id)
if billing_email:
base_url = _get_base_url()
send_billing_email(billing_email, 'subscription_cancelled', {
'portal_url': f'{base_url}/billing/portal',
})
def _on_payment_succeeded(obj):
"""invoice.payment_succeeded — payment went through, clear past_due."""
customer_id = obj.get('customer')
period_end = obj.get('lines', {}).get('data', [{}])[0].get('period', {}).get('end')
from control.base import control_session
from control.models import Tenant, TenantAudit
with control_session() as s:
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
if t is None:
return
if t.subscription_status == 'past_due':
t.subscription_status = 'active'
if period_end:
t.current_period_end = _ts_to_dt(period_end)
s.add(TenantAudit(
action='billing_payment_recovered',
tenant_id=t.id,
details=f'customer={customer_id}',
))
logger.info('BILLING | payment_succeeded_recovered | tenant_id=%s', t.id)
def _on_payment_failed(obj):
"""invoice.payment_failed — mark past_due and send dunning email."""
customer_id = obj.get('customer')
from control.base import control_session
from control.models import Tenant, TenantAudit
from app.billing.emails import send_billing_email
with control_session() as s:
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
if t is None:
logger.warning('BILLING | payment_failed_no_tenant | customer=%s', customer_id)
return
t.subscription_status = 'past_due'
billing_email = t.billing_email
s.add(TenantAudit(
action='billing_payment_failed',
tenant_id=t.id,
details=f'customer={customer_id}',
))
logger.info('BILLING | payment_failed | tenant_id=%s', t.id)
if billing_email:
base_url = _get_base_url()
send_billing_email(billing_email, 'payment_failed', {
'portal_url': f'{base_url}/billing/portal',
})
def _on_trial_will_end(obj):
"""customer.subscription.trial_will_end — sent 3 days before trial ends."""
customer_id = obj.get('customer')
trial_end_ts = obj.get('trial_end')
from control.base import control_session
from control.models import Tenant
from app.billing.emails import send_billing_email
with control_session() as s:
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
if t is None:
return
billing_email = t.billing_email
if billing_email:
trial_dt = _ts_to_dt(trial_end_ts)
trial_str = trial_dt.strftime('%B %d, %Y') if trial_dt else 'soon'
base_url = _get_base_url()
send_billing_email(billing_email, 'trial_ending', {
'trial_ends_at': trial_str,
'subscribe_url': f'{base_url}/billing/subscribe',
})
logger.info('BILLING | trial_will_end_email_sent | customer=%s', customer_id)
+30 -2
View File
@@ -226,8 +226,36 @@ def plan():
except Exception as exc: except Exception as exc:
logger.error('tenant_settings.plan: quota count failed: %s', exc) logger.error('tenant_settings.plan: quota count failed: %s', exc)
return render_template('tenant_settings/plan.html', # MT-8: billing fields are already on g.tenant — no extra DB query needed.
plan_info=plan_info, quota_usage=quota_usage) billing_enabled = current_app.config.get('BILLING_ENABLED', False)
subscription_status = None
trial_ends_at = None
has_stripe_customer = False
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
from control.models import Tenant as ControlTenant
with control_session() as s:
t = s.get(ControlTenant, tenant_ctx.id)
has_stripe_customer = bool(t and t.stripe_customer_id)
except Exception:
pass
return render_template(
'tenant_settings/plan.html',
plan_info=plan_info,
quota_usage=quota_usage,
billing_enabled=billing_enabled,
subscription_status=subscription_status,
trial_ends_at=trial_ends_at,
has_stripe_customer=has_stripe_customer,
)
# ── custom domains ──────────────────────────────────────────────────────────── # ── custom domains ────────────────────────────────────────────────────────────
+2
View File
@@ -338,6 +338,8 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% include 'billing/_billing_banner.html' %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
@@ -0,0 +1,12 @@
{% if billing_warning == 'past_due' %}
<div class="alert alert-warning alert-dismissible fade show d-flex align-items-center gap-2 mb-3" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 fs-5"></i>
<div>
<strong>Payment past due.</strong>
Your most recent payment could not be processed. Please update your billing information
to avoid service interruption.
<a href="{{ url_for('billing.portal') }}" class="alert-link ms-1">Update Payment Method &rsaquo;</a>
</div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Subscription Activated — JQC{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-6 text-center">
<div class="card border-0 shadow-sm p-5">
<div class="mb-4">
<i class="bi bi-check-circle-fill text-success" style="font-size: 3rem;"></i>
</div>
<h2 class="h4 mb-2">Subscription Activated!</h2>
<p class="text-muted mb-4">
Your JQC subscription is now active. You have full access to all features
included in your plan.
</p>
<a href="{{ url_for('tenant_settings.plan') }}" class="btn btn-primary">
<i class="bi bi-patch-check me-1"></i> View Plan &amp; Usage
</a>
</div>
</div>
</div>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Subscription Suspended — JQC</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #f6f7f9; display: flex; min-height: 100vh; align-items: center; justify-content: center; }
.card { max-width: 480px; border-radius: 12px; }
</style>
</head>
<body>
<div class="container py-5">
<div class="card border-0 shadow-sm mx-auto text-center p-5">
<div class="mb-4">
<i class="bi bi-pause-circle text-warning" style="font-size: 3rem;"></i>
</div>
<h1 class="h4 mb-2">Subscription Suspended</h1>
<p class="text-muted mb-4">
Your JQC subscription has been cancelled or has lapsed. Please update your
billing information to restore access to your workspace.
</p>
<div class="d-grid gap-2">
<a href="{{ url_for('billing.portal') }}" class="btn btn-primary">
<i class="bi bi-credit-card me-1"></i> Manage Billing
</a>
<a href="mailto:support@jqc.app" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-envelope me-1"></i> Contact Support
</a>
</div>
</div>
</div>
</body>
</html>
+32
View File
@@ -44,10 +44,42 @@
</tbody> </tbody>
</table> </table>
<div class="mt-3"> <div class="mt-3">
{% if billing_enabled %}
{# MT-8: live subscription status badge #}
{% if subscription_status == 'trial' %}
<span class="badge bg-info text-dark mb-2 d-block" style="font-size:.8rem;">
<i class="bi bi-hourglass-split me-1"></i>
Free trial{% if trial_ends_at %} — expires {{ trial_ends_at.strftime('%b %d, %Y') }}{% endif %}
</span>
{% elif subscription_status == 'active' %}
<span class="badge bg-success mb-2 d-block" style="font-size:.8rem;">
<i class="bi bi-check-circle me-1"></i> Active subscription
</span>
{% elif subscription_status == 'past_due' %}
<span class="badge bg-warning text-dark mb-2 d-block" style="font-size:.8rem;">
<i class="bi bi-exclamation-triangle me-1"></i> Payment past due
</span>
{% elif subscription_status == 'cancelled' %}
<span class="badge bg-danger mb-2 d-block" style="font-size:.8rem;">
<i class="bi bi-x-circle me-1"></i> Subscription cancelled
</span>
{% endif %}
{# Action button #}
{% if subscription_status in ('active', 'past_due') or has_stripe_customer %}
<a href="{{ url_for('billing.portal') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-credit-card me-1"></i> Manage Billing
</a>
{% else %}
<a href="{{ url_for('billing.subscribe') }}" class="btn btn-sm btn-primary">
<i class="bi bi-lightning-charge me-1"></i> Subscribe Now
</a>
{% endif %}
{% else %}
<a href="mailto:support@jqc.app?subject=Plan upgrade request" <a href="mailto:support@jqc.app?subject=Plan upgrade request"
class="btn btn-sm btn-outline-primary"> class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-up-circle me-1"></i> Request Plan Upgrade <i class="bi bi-arrow-up-circle me-1"></i> Request Plan Upgrade
</a> </a>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
+5
View File
@@ -11,6 +11,7 @@ gates.py can read them from g.tenant without a second control-DB round-trip.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Optional from typing import Optional
@@ -36,3 +37,7 @@ class TenantContext:
allow_scheduled_reports: bool = True allow_scheduled_reports: bool = True
allow_branding: bool = True allow_branding: bool = True
allow_custom_domain: bool = True allow_custom_domain: bool = True
# MT-8: billing state (None = billing not configured / legacy tenant)
subscription_status: Optional[str] = None # trial|active|past_due|cancelled
trial_ends_at: Optional[datetime] = None
+55 -8
View File
@@ -3,8 +3,10 @@ app/tenancy/middleware.py
------------------------- -------------------------
Wires tenant resolution into the Flask request lifecycle. Wires tenant resolution into the Flask request lifecycle.
`init_tenancy(app)` registers a single app-level before_request handler that: `init_tenancy(app)` registers two app-level before_request handlers:
* always clears g.tenant / g.tenant_engine (so downstream code can rely on them)
1. `_resolve_tenant` Host tenant resolution (MT-1 / MT-4):
* always clears g.tenant / g.tenant_engine
* does NOTHING further when MULTI_TENANT_ENABLED is False today's behaviour * does NOTHING further when MULTI_TENANT_ENABLED is False today's behaviour
* bypasses static + configured exempt paths (health checks) * bypasses static + configured exempt paths (health checks)
* MT-4: checks session['impersonating_tenant_id'] and short-circuits Host * MT-4: checks session['impersonating_tenant_id'] and short-circuits Host
@@ -12,16 +14,26 @@ Wires tenant resolution into the Flask request lifecycle.
* otherwise resolves the Host header to a tenant and selects its engine * otherwise resolves the Host header to a tenant and selects its engine
* returns a 404 page for an unknown / unverified / suspended host * returns a 404 page for an unknown / unverified / suspended host
2. `_billing_gate` Stripe subscription enforcement (MT-8):
* inert when BILLING_ENABLED=False or MULTI_TENANT_ENABLED=False
* sets g.billing_warning for past_due tenants (banner shown in base.html)
* redirects cancelled/suspended-by-billing tenants to /billing/suspended
* always exempts /billing/* paths so tenants can manage their subscription
Flask-SQLAlchemy already removes the scoped session on app-context teardown, Flask-SQLAlchemy already removes the scoped session on app-context teardown,
so each request rebinds via RoutingSession.get_bind against the fresh so each request rebinds via RoutingSession.get_bind against the fresh
g.tenant_engine no teardown handler is needed here. g.tenant_engine no teardown handler is needed here.
""" """
from flask import g, request, current_app, Response, session import logging
from flask import g, request, current_app, Response, session, redirect, url_for
from app.tenancy.resolver import resolve_tenant from app.tenancy.resolver import resolve_tenant
from app.tenancy.engine_cache import get_tenant_engine from app.tenancy.engine_cache import get_tenant_engine
logger = logging.getLogger(__name__)
_UNKNOWN_TENANT_PAGE = ( _UNKNOWN_TENANT_PAGE = (
"<!doctype html><html lang='en'><head><meta charset='utf-8'>" "<!doctype html><html lang='en'><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>" "<meta name='viewport' content='width=device-width, initial-scale=1'>"
@@ -34,7 +46,7 @@ _UNKNOWN_TENANT_PAGE = (
"h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:.25rem 0;color:#6b7280}" "h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:.25rem 0;color:#6b7280}"
"</style></head><body><div class='card'>" "</style></head><body><div class='card'>"
"<h1>Workspace not found</h1>" "<h1>Workspace not found</h1>"
"<p>This address isnt linked to an active JQC workspace.</p>" "<p>This address isn't linked to an active JQC workspace.</p>"
"<p>Check the URL, or contact your administrator.</p>" "<p>Check the URL, or contact your administrator.</p>"
"</div></body></html>" "</div></body></html>"
) )
@@ -55,6 +67,7 @@ def init_tenancy(app):
# Default state — referenced safely by downstream code regardless of mode. # Default state — referenced safely by downstream code regardless of mode.
g.tenant = None g.tenant = None
g.tenant_engine = None g.tenant_engine = None
g.billing_warning = None # MT-8: set to 'past_due' by _billing_gate when needed
if not current_app.config.get('MULTI_TENANT_ENABLED', False): if not current_app.config.get('MULTI_TENANT_ENABLED', False):
return # inert: default database serves everything (single-tenant) return # inert: default database serves everything (single-tenant)
@@ -91,15 +104,15 @@ def init_tenancy(app):
allow_scheduled_reports=plan.allow_scheduled_reports if plan else True, allow_scheduled_reports=plan.allow_scheduled_reports if plan else True,
allow_branding=plan.allow_branding if plan else True, allow_branding=plan.allow_branding if plan else True,
allow_custom_domain=plan.allow_custom_domain if plan else True, allow_custom_domain=plan.allow_custom_domain if plan else True,
# MT-8: billing state
subscription_status=t.subscription_status,
trial_ends_at=t.trial_ends_at,
) )
g.tenant = ctx g.tenant = ctx
g.tenant_engine = get_tenant_engine(ctx) g.tenant_engine = get_tenant_engine(ctx)
return # skip normal Host resolution return # skip normal Host resolution
except Exception: except Exception:
import logging as _logging logger.warning('TENANCY | impersonation_failed | tenant_id=%s', imp_id)
_logging.getLogger(__name__).warning(
'TENANCY | impersonation_failed | tenant_id=%s', imp_id
)
# Tenant not found, suspended, or engine error — clear stale session # Tenant not found, suspended, or engine error — clear stale session
# keys so the next request doesn't retry a permanently failing lookup. # keys so the next request doesn't retry a permanently failing lookup.
session.pop('impersonating_tenant_id', None) session.pop('impersonating_tenant_id', None)
@@ -113,3 +126,37 @@ def init_tenancy(app):
g.tenant = tenant g.tenant = tenant
g.tenant_engine = get_tenant_engine(tenant) g.tenant_engine = get_tenant_engine(tenant)
@app.before_request
def _billing_gate():
"""MT-8: Enforce subscription status. Inert when BILLING_ENABLED=False."""
if not current_app.config.get('BILLING_ENABLED', False):
return
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
return
tenant = getattr(g, 'tenant', None)
if tenant is None:
return # resolver already handled this (404 or exempt path)
# Billing routes must always be reachable so tenants can manage their
# subscription even when blocked, and the webhook can receive events.
if (request.path.startswith('/billing/')
or request.path.startswith('/static/')
or _is_exempt(request.path)):
return
status = tenant.subscription_status
if status is None or status in ('trial', 'active'):
# Fully authorised — no action needed.
return
if status == 'past_due':
# Allow access but signal the template to show the payment warning banner.
g.billing_warning = 'past_due'
return
# status == 'cancelled' (or any unrecognised future value)
# Block access and redirect to the subscription management page.
return redirect(url_for('billing.suspended'))
+3
View File
@@ -64,4 +64,7 @@ def resolve_tenant(host):
allow_scheduled_reports=plan.allow_scheduled_reports if plan else True, allow_scheduled_reports=plan.allow_scheduled_reports if plan else True,
allow_branding=plan.allow_branding if plan else True, allow_branding=plan.allow_branding if plan else True,
allow_custom_domain=plan.allow_custom_domain if plan else True, allow_custom_domain=plan.allow_custom_domain if plan else True,
# MT-8: billing state
subscription_status=tenant.subscription_status,
trial_ends_at=tenant.trial_ends_at,
) )
+11
View File
@@ -75,6 +75,17 @@ class Config:
# ── Google Maps (used for GPS map on inspection view) ──────────────────── # ── Google Maps (used for GPS map on inspection view) ────────────────────
GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '') GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '')
# ── Stripe / Billing (MT-8) ──────────────────────────────────────────────
# BILLING_ENABLED=false by default — inert until explicitly flipped.
# Flip to true only after STRIPE_* keys are set and plans have stripe_price_id.
BILLING_ENABLED = os.environ.get('BILLING_ENABLED', 'false').strip().lower() in ('1', 'true', 'yes', 'on')
STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY')
STRIPE_PUBLISHABLE_KEY = os.environ.get('STRIPE_PUBLISHABLE_KEY')
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')
STRIPE_PRICE_STARTER = os.environ.get('STRIPE_PRICE_STARTER')
STRIPE_PRICE_PRO = os.environ.get('STRIPE_PRICE_PRO')
STRIPE_PRICE_ENTERPRISE = os.environ.get('STRIPE_PRICE_ENTERPRISE')
MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_SERVER = os.environ.get('MAIL_SERVER')
MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
@@ -0,0 +1,105 @@
"""control0002 — Stripe billing columns (MT-8)
Adds billing state columns to the control-plane `tenants` table and
`stripe_price_id` to the `plans` table.
All column additions use INFORMATION_SCHEMA existence checks so the
migration is safe to re-run (CLAUDE.md Rule 14). No ENUM change to the
existing `tenants.status` column `subscription_status` is a separate
nullable column tracking billing lifecycle independently of operational state.
"""
import sqlalchemy as sa
from alembic import op
revision = 'control0002_billing'
down_revision = 'control0001_init'
branch_labels = None
depends_on = None
def _column_exists(bind, table: str, column: str) -> bool:
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() AND table_name = :t AND column_name = :c"
), {'t': table, 'c': column})
return result.scalar() > 0
def _index_exists(bind, table: str, index_name: str) -> bool:
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i"
), {'t': table, 'i': index_name})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
# ── plans: stripe_price_id ─────────────────────────────────────────────
if not _column_exists(bind, 'plans', 'stripe_price_id'):
op.execute(sa.text(
"ALTER TABLE plans ADD COLUMN stripe_price_id VARCHAR(100) NULL"
))
# ── tenants: stripe_customer_id ────────────────────────────────────────
if not _column_exists(bind, 'tenants', 'stripe_customer_id'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN stripe_customer_id VARCHAR(64) NULL"
))
if not _index_exists(bind, 'tenants', 'ix_tenants_stripe_customer'):
op.execute(sa.text(
"ALTER TABLE tenants ADD INDEX ix_tenants_stripe_customer (stripe_customer_id)"
))
# ── tenants: stripe_subscription_id ───────────────────────────────────
if not _column_exists(bind, 'tenants', 'stripe_subscription_id'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN stripe_subscription_id VARCHAR(64) NULL"
))
if not _index_exists(bind, 'tenants', 'ix_tenants_stripe_sub'):
op.execute(sa.text(
"ALTER TABLE tenants ADD INDEX ix_tenants_stripe_sub (stripe_subscription_id)"
))
# ── tenants: subscription_status ──────────────────────────────────────
if not _column_exists(bind, 'tenants', 'subscription_status'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN subscription_status "
"ENUM('trial','active','past_due','cancelled') NULL"
))
if not _index_exists(bind, 'tenants', 'ix_tenants_sub_status'):
op.execute(sa.text(
"ALTER TABLE tenants ADD INDEX ix_tenants_sub_status (subscription_status)"
))
# ── tenants: trial_ends_at ─────────────────────────────────────────────
if not _column_exists(bind, 'tenants', 'trial_ends_at'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN trial_ends_at DATETIME NULL"
))
# ── tenants: current_period_end ────────────────────────────────────────
if not _column_exists(bind, 'tenants', 'current_period_end'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN current_period_end DATETIME NULL"
))
# ── tenants: billing_email ─────────────────────────────────────────────
if not _column_exists(bind, 'tenants', 'billing_email'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN billing_email VARCHAR(255) NULL"
))
def downgrade():
bind = op.get_bind()
for col in ('billing_email', 'current_period_end', 'trial_ends_at',
'subscription_status', 'stripe_subscription_id', 'stripe_customer_id'):
if _column_exists(bind, 'tenants', col):
op.execute(sa.text(f"ALTER TABLE tenants DROP COLUMN {col}"))
if _column_exists(bind, 'plans', 'stripe_price_id'):
op.execute(sa.text("ALTER TABLE plans DROP COLUMN stripe_price_id"))
+13 -1
View File
@@ -41,9 +41,10 @@ class Plan(ControlBase):
allow_scheduled_reports = Column(Boolean, nullable=False, default=False) allow_scheduled_reports = Column(Boolean, nullable=False, default=False)
allow_branding = Column(Boolean, nullable=False, default=False) allow_branding = Column(Boolean, nullable=False, default=False)
# Billing (future — MT-8) # Billing (MT-8)
price_cents = Column(Integer, nullable=True) price_cents = Column(Integer, nullable=True)
billing_period = Column(String(16), nullable=True) billing_period = Column(String(16), nullable=True)
stripe_price_id = Column(String(100), nullable=True)
created_at = Column(DateTime, nullable=False, default=now_eastern) created_at = Column(DateTime, nullable=False, default=now_eastern)
@@ -100,6 +101,17 @@ class Tenant(ControlBase):
suspended_at = Column(DateTime, nullable=True) suspended_at = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
# ── Billing (MT-8) ─────────────────────────────────────────────────────
stripe_customer_id = Column(String(64), nullable=True, index=True)
stripe_subscription_id = Column(String(64), nullable=True, index=True)
subscription_status = Column(
Enum('trial', 'active', 'past_due', 'cancelled', name='subscription_status'),
nullable=True,
)
trial_ends_at = Column(DateTime, nullable=True)
current_period_end = Column(DateTime, nullable=True)
billing_email = Column(String(255), nullable=True)
plan = relationship('Plan', back_populates='tenants') plan = relationship('Plan', back_populates='tenants')
domains = relationship('TenantDomain', back_populates='tenant', domains = relationship('TenantDomain', back_populates='tenant',
cascade='all, delete-orphan') cascade='all, delete-orphan')
@@ -268,7 +268,7 @@
</div> </div>
{# ── Quick info ── #} {# ── Quick info ── #}
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm mb-3">
<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-link-45deg me-1"></i>Primary URL</span> <span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-link-45deg me-1"></i>Primary URL</span>
</div> </div>
@@ -286,6 +286,70 @@
</div> </div>
</div> </div>
{# ── Billing (MT-8, read-only) ── #}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-credit-card me-1"></i>Billing</span>
</div>
<div class="card-body py-2" style="font-size:.83rem;">
<div class="mb-1">
<span class="text-muted">Subscription</span>
<div>
{% if tenant.subscription_status == 'active' %}
<span class="badge bg-success">active</span>
{% elif tenant.subscription_status == 'trial' %}
<span class="badge bg-info text-dark">trial</span>
{% elif tenant.subscription_status == 'past_due' %}
<span class="badge bg-warning text-dark">past_due</span>
{% elif tenant.subscription_status == 'cancelled' %}
<span class="badge bg-danger">cancelled</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</div>
</div>
{% if tenant.trial_ends_at %}
<div class="mb-1">
<span class="text-muted">Trial ends</span>
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div>
</div>
{% endif %}
{% if tenant.current_period_end %}
<div class="mb-1">
<span class="text-muted">Period ends</span>
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div>
</div>
{% endif %}
{% if tenant.billing_email %}
<div class="mb-1">
<span class="text-muted">Billing email</span>
<div>{{ tenant.billing_email }}</div>
</div>
{% endif %}
{% if tenant.stripe_customer_id %}
<div class="mb-1">
<span class="text-muted">Stripe customer</span>
<div class="font-monospace" style="font-size:.75rem;"
title="{{ tenant.stripe_customer_id }}">
{{ tenant.stripe_customer_id[:20] }}…
</div>
</div>
{% endif %}
{% if tenant.stripe_subscription_id %}
<div class="mb-1">
<span class="text-muted">Stripe subscription</span>
<div class="font-monospace" style="font-size:.75rem;"
title="{{ tenant.stripe_subscription_id }}">
{{ tenant.stripe_subscription_id[:20] }}…
</div>
</div>
{% endif %}
{% if not tenant.subscription_status %}
<span class="text-muted">No billing configured.</span>
{% endif %}
</div>
</div>
</div>{# /col-lg-4 #} </div>{# /col-lg-4 #}
</div>{# /row #} </div>{# /row #}
+7
View File
@@ -119,6 +119,13 @@ def tenant_detail(tenant_id):
'db_host': t.db_host, 'db_port': t.db_port, 'db_host': t.db_host, 'db_port': t.db_port,
'alembic_head': t.alembic_head, 'created_at': t.created_at, 'alembic_head': t.alembic_head, 'created_at': t.created_at,
'suspended_at': t.suspended_at, 'notes': t.notes, 'suspended_at': t.suspended_at, 'notes': t.notes,
# MT-8: billing fields (read-only in panel)
'subscription_status': t.subscription_status,
'trial_ends_at': t.trial_ends_at,
'current_period_end': t.current_period_end,
'stripe_customer_id': t.stripe_customer_id,
'stripe_subscription_id': t.stripe_subscription_id,
'billing_email': t.billing_email,
} }
plan_rows = [{'id': p.id, 'code': p.code, 'name': p.name} for p in plans] plan_rows = [{'id': p.id, 'code': p.code, 'name': p.name} for p in plans]
domain_rows = [ domain_rows = [
+1
View File
@@ -22,3 +22,4 @@ pytz
pyJWT pyJWT
openpyxl openpyxl
groq groq
stripe