From f292c8fb7b9742ba8496e3142e3765b9f5b1bf68 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Sun, 28 Jun 2026 12:01:25 -0400 Subject: [PATCH] Jun 28 - Implement payment functions (Stripe) --- app/__init__.py | 30 ++ app/billing/__init__.py | 15 + app/billing/emails.py | 105 +++++++ app/billing/routes.py | 207 ++++++++++++++ app/billing/stripe_client.py | 87 ++++++ app/billing/webhooks.py | 263 ++++++++++++++++++ app/routes/tenant_settings.py | 32 ++- app/templates/base.html | 2 + app/templates/billing/_billing_banner.html | 12 + app/templates/billing/success.html | 22 ++ app/templates/billing/suspended.html | 36 +++ app/templates/tenant_settings/plan.html | 32 +++ app/tenancy/context.py | 5 + app/tenancy/middleware.py | 75 ++++- app/tenancy/resolver.py | 3 + config.py | 11 + .../versions/control0002_billing.py | 105 +++++++ control/models.py | 14 +- .../panel/templates/panel/tenant_detail.html | 66 ++++- control/panel/tenants.py | 7 + requirements.txt | 1 + 21 files changed, 1112 insertions(+), 18 deletions(-) create mode 100644 app/billing/__init__.py create mode 100644 app/billing/emails.py create mode 100644 app/billing/routes.py create mode 100644 app/billing/stripe_client.py create mode 100644 app/billing/webhooks.py create mode 100644 app/templates/billing/_billing_banner.html create mode 100644 app/templates/billing/success.html create mode 100644 app/templates/billing/suspended.html create mode 100644 control/migrations/versions/control0002_billing.py diff --git a/app/__init__.py b/app/__init__.py index 648f859..caf41ad 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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. diff --git a/app/billing/__init__.py b/app/billing/__init__.py new file mode 100644 index 0000000..bea6539 --- /dev/null +++ b/app/billing/__init__.py @@ -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 diff --git a/app/billing/emails.py b/app/billing/emails.py new file mode 100644 index 0000000..000bcf5 --- /dev/null +++ b/app/billing/emails.py @@ -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() diff --git a/app/billing/routes.py b/app/billing/routes.py new file mode 100644 index 0000000..3c8971d --- /dev/null +++ b/app/billing/routes.py @@ -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')) diff --git a/app/billing/stripe_client.py b/app/billing/stripe_client.py new file mode 100644 index 0000000..5ed7612 --- /dev/null +++ b/app/billing/stripe_client.py @@ -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) diff --git a/app/billing/webhooks.py b/app/billing/webhooks.py new file mode 100644 index 0000000..7b88450 --- /dev/null +++ b/app/billing/webhooks.py @@ -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) diff --git a/app/routes/tenant_settings.py b/app/routes/tenant_settings.py index 374ff25..a5a0703 100644 --- a/app/routes/tenant_settings.py +++ b/app/routes/tenant_settings.py @@ -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 ──────────────────────────────────────────────────────────── diff --git a/app/templates/base.html b/app/templates/base.html index 6746641..9d99195 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -338,6 +338,8 @@ {% endif %} {% endwith %} + {% include 'billing/_billing_banner.html' %} + {% block content %}{% endblock %} diff --git a/app/templates/billing/_billing_banner.html b/app/templates/billing/_billing_banner.html new file mode 100644 index 0000000..c380900 --- /dev/null +++ b/app/templates/billing/_billing_banner.html @@ -0,0 +1,12 @@ +{% if billing_warning == 'past_due' %} + +{% endif %} diff --git a/app/templates/billing/success.html b/app/templates/billing/success.html new file mode 100644 index 0000000..da31626 --- /dev/null +++ b/app/templates/billing/success.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Subscription Activated — JQC{% endblock %} + +{% block content %} +
+
+
+
+ +
+

Subscription Activated!

+

+ Your JQC subscription is now active. You have full access to all features + included in your plan. +

+ + View Plan & Usage + +
+
+
+{% endblock %} diff --git a/app/templates/billing/suspended.html b/app/templates/billing/suspended.html new file mode 100644 index 0000000..7a2cc2f --- /dev/null +++ b/app/templates/billing/suspended.html @@ -0,0 +1,36 @@ + + + + + + Subscription Suspended — JQC + + + + + +
+
+
+ +
+

Subscription Suspended

+

+ Your JQC subscription has been cancelled or has lapsed. Please update your + billing information to restore access to your workspace. +

+ +
+
+ + diff --git a/app/templates/tenant_settings/plan.html b/app/templates/tenant_settings/plan.html index d0111e0..96b4c2b 100644 --- a/app/templates/tenant_settings/plan.html +++ b/app/templates/tenant_settings/plan.html @@ -44,10 +44,42 @@
+ {% if billing_enabled %} + {# MT-8: live subscription status badge #} + {% if subscription_status == 'trial' %} + + + Free trial{% if trial_ends_at %} — expires {{ trial_ends_at.strftime('%b %d, %Y') }}{% endif %} + + {% elif subscription_status == 'active' %} + + Active subscription + + {% elif subscription_status == 'past_due' %} + + Payment past due + + {% elif subscription_status == 'cancelled' %} + + Subscription cancelled + + {% endif %} + {# Action button #} + {% if subscription_status in ('active', 'past_due') or has_stripe_customer %} + + Manage Billing + + {% else %} + + Subscribe Now + + {% endif %} + {% else %} Request Plan Upgrade + {% endif %}
diff --git a/app/tenancy/context.py b/app/tenancy/context.py index b155b8c..a772aeb 100644 --- a/app/tenancy/context.py +++ b/app/tenancy/context.py @@ -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 diff --git a/app/tenancy/middleware.py b/app/tenancy/middleware.py index 30e0441..5900925 100644 --- a/app/tenancy/middleware.py +++ b/app/tenancy/middleware.py @@ -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 = ( "" "" @@ -34,7 +46,7 @@ _UNKNOWN_TENANT_PAGE = ( "h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:.25rem 0;color:#6b7280}" "
" "

Workspace not found

" - "

This address isn’t linked to an active JQC workspace.

" + "

This address isn't linked to an active JQC workspace.

" "

Check the URL, or contact your administrator.

" "
" ) @@ -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')) diff --git a/app/tenancy/resolver.py b/app/tenancy/resolver.py index 62b16ed..b281b4f 100644 --- a/app/tenancy/resolver.py +++ b/app/tenancy/resolver.py @@ -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, ) diff --git a/config.py b/config.py index 35aac8e..a69ff9f 100644 --- a/config.py +++ b/config.py @@ -75,6 +75,17 @@ class Config: # ── Google Maps (used for GPS map on inspection view) ──────────────────── 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_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') diff --git a/control/migrations/versions/control0002_billing.py b/control/migrations/versions/control0002_billing.py new file mode 100644 index 0000000..0852d9e --- /dev/null +++ b/control/migrations/versions/control0002_billing.py @@ -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")) diff --git a/control/models.py b/control/models.py index 2166664..6890529 100644 --- a/control/models.py +++ b/control/models.py @@ -41,9 +41,10 @@ class Plan(ControlBase): allow_scheduled_reports = 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) billing_period = Column(String(16), nullable=True) + stripe_price_id = Column(String(100), nullable=True) created_at = Column(DateTime, nullable=False, default=now_eastern) @@ -100,6 +101,17 @@ class Tenant(ControlBase): suspended_at = Column(DateTime, 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') domains = relationship('TenantDomain', back_populates='tenant', cascade='all, delete-orphan') diff --git a/control/panel/templates/panel/tenant_detail.html b/control/panel/templates/panel/tenant_detail.html index 1ea9cb2..f6ba6a8 100644 --- a/control/panel/templates/panel/tenant_detail.html +++ b/control/panel/templates/panel/tenant_detail.html @@ -268,7 +268,7 @@ {# ── Quick info ── #} -
+
Primary URL
@@ -286,6 +286,70 @@
+ {# ── Billing (MT-8, read-only) ── #} +
+
+ Billing +
+
+
+ Subscription +
+ {% if tenant.subscription_status == 'active' %} + active + {% elif tenant.subscription_status == 'trial' %} + trial + {% elif tenant.subscription_status == 'past_due' %} + past_due + {% elif tenant.subscription_status == 'cancelled' %} + cancelled + {% else %} + + {% endif %} +
+
+ {% if tenant.trial_ends_at %} +
+ Trial ends +
{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}
+
+ {% endif %} + {% if tenant.current_period_end %} +
+ Period ends +
{{ tenant.current_period_end.strftime('%Y-%m-%d') }}
+
+ {% endif %} + {% if tenant.billing_email %} +
+ Billing email +
{{ tenant.billing_email }}
+
+ {% endif %} + {% if tenant.stripe_customer_id %} +
+ Stripe customer +
+ {{ tenant.stripe_customer_id[:20] }}… +
+
+ {% endif %} + {% if tenant.stripe_subscription_id %} +
+ Stripe subscription +
+ {{ tenant.stripe_subscription_id[:20] }}… +
+
+ {% endif %} + {% if not tenant.subscription_status %} + No billing configured. + {% endif %} +
+
+ {# /col-lg-4 #} {# /row #} diff --git a/control/panel/tenants.py b/control/panel/tenants.py index 93d72fa..0b483a0 100644 --- a/control/panel/tenants.py +++ b/control/panel/tenants.py @@ -119,6 +119,13 @@ def tenant_detail(tenant_id): 'db_host': t.db_host, 'db_port': t.db_port, 'alembic_head': t.alembic_head, 'created_at': t.created_at, '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] domain_rows = [ diff --git a/requirements.txt b/requirements.txt index 1a06143..bbe0cd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,4 @@ pytz pyJWT openpyxl groq +stripe