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' %} +
+ Your JQC subscription is now active. You have full access to all features + included in your plan. +
+ + View Plan & Usage + ++ Your JQC subscription has been cancelled or has lapsed. Please update your + billing information to restore access to your workspace. +
+ +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.
" "