Jun 28 - Implement payment functions (Stripe)
This commit is contained in:
@@ -161,6 +161,30 @@ def create_app(config_name='default'):
|
||||
pass
|
||||
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)
|
||||
|
||||
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 devices # Admin device management
|
||||
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(dashboard.bp)
|
||||
@@ -191,6 +216,11 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(broadcast.bp)
|
||||
app.register_blueprint(devices.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) ───────────────────
|
||||
# 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:
|
||||
logger.error('tenant_settings.plan: quota count failed: %s', exc)
|
||||
|
||||
return render_template('tenant_settings/plan.html',
|
||||
plan_info=plan_info, quota_usage=quota_usage)
|
||||
# MT-8: billing fields are already on g.tenant — no extra DB query needed.
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -338,6 +338,8 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% include 'billing/_billing_banner.html' %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</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>
|
||||
</table>
|
||||
<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"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-arrow-up-circle me-1"></i> Request Plan Upgrade
|
||||
</a>
|
||||
{% endif %}
|
||||
</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 datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -36,3 +37,7 @@ class TenantContext:
|
||||
allow_scheduled_reports: bool = True
|
||||
allow_branding: 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
|
||||
|
||||
+61
-14
@@ -3,25 +3,37 @@ app/tenancy/middleware.py
|
||||
-------------------------
|
||||
Wires tenant resolution into the Flask request lifecycle.
|
||||
|
||||
`init_tenancy(app)` registers a single app-level before_request handler that:
|
||||
* always clears g.tenant / g.tenant_engine (so downstream code can rely on them)
|
||||
* does NOTHING further when MULTI_TENANT_ENABLED is False → today's behaviour
|
||||
* bypasses static + configured exempt paths (health checks)
|
||||
* MT-4: checks session['impersonating_tenant_id'] and short-circuits Host
|
||||
resolution when a superadmin is impersonating a tenant
|
||||
* otherwise resolves the Host header to a tenant and selects its engine
|
||||
* returns a 404 page for an unknown / unverified / suspended host
|
||||
`init_tenancy(app)` registers two app-level before_request handlers:
|
||||
|
||||
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
|
||||
* bypasses static + configured exempt paths (health checks)
|
||||
* MT-4: checks session['impersonating_tenant_id'] and short-circuits Host
|
||||
resolution when a superadmin is impersonating a tenant
|
||||
* otherwise resolves the Host header to a tenant and selects its engine
|
||||
* 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,
|
||||
so each request rebinds via RoutingSession.get_bind against the fresh
|
||||
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.engine_cache import get_tenant_engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNKNOWN_TENANT_PAGE = (
|
||||
"<!doctype html><html lang='en'><head><meta charset='utf-8'>"
|
||||
"<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}"
|
||||
"</style></head><body><div class='card'>"
|
||||
"<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>"
|
||||
"</div></body></html>"
|
||||
)
|
||||
@@ -55,6 +67,7 @@ def init_tenancy(app):
|
||||
# Default state — referenced safely by downstream code regardless of mode.
|
||||
g.tenant = 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):
|
||||
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_branding=plan.allow_branding 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_engine = get_tenant_engine(ctx)
|
||||
return # skip normal Host resolution
|
||||
except Exception:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
'TENANCY | impersonation_failed | tenant_id=%s', imp_id
|
||||
)
|
||||
logger.warning('TENANCY | impersonation_failed | tenant_id=%s', imp_id)
|
||||
# Tenant not found, suspended, or engine error — clear stale session
|
||||
# keys so the next request doesn't retry a permanently failing lookup.
|
||||
session.pop('impersonating_tenant_id', None)
|
||||
@@ -113,3 +126,37 @@ def init_tenancy(app):
|
||||
|
||||
g.tenant = 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_branding=plan.allow_branding 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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user