Jun 28 - Implement payment functions (Stripe)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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'))
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 ›</a>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -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 & Usage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 isn’t 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'))
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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 #}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ pytz
|
|||||||
pyJWT
|
pyJWT
|
||||||
openpyxl
|
openpyxl
|
||||||
groq
|
groq
|
||||||
|
stripe
|
||||||
|
|||||||
Reference in New Issue
Block a user