Jun 28 - Implement payment functions (Stripe)

This commit is contained in:
2026-06-28 12:01:25 -04:00
parent 61ede27093
commit f292c8fb7b
21 changed files with 1112 additions and 18 deletions
+61 -14
View File
@@ -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 isnt linked to an active JQC workspace.</p>"
"<p>This address isn't linked to an active JQC workspace.</p>"
"<p>Check the URL, or contact your administrator.</p>"
"</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'))