diff --git a/app/templates/_quota_warning.html b/app/templates/_quota_warning.html
new file mode 100644
index 0000000..7a939ff
--- /dev/null
+++ b/app/templates/_quota_warning.html
@@ -0,0 +1,18 @@
+{# _quota_warning.html
+ Include at the top of any template whose route uses @quota_soft_check.
+ Renders nothing when g.quota_warning is absent or limit not exceeded.
+#}
+{% if g.quota_warning and g.quota_warning.exceeded %}
+
+
+
+ Usage limit reached.
+ You've used {{ g.quota_warning.current }} of {{ g.quota_warning.limit }}
+ {{ g.quota_warning.axis }} allowed on your current plan this month.
+ Your submission was saved, but consider
+ upgrading your plan
+ for higher limits.
+
+
+
+{% endif %}
diff --git a/app/tenancy/__init__.py b/app/tenancy/__init__.py
index 9f7a143..b782888 100644
--- a/app/tenancy/__init__.py
+++ b/app/tenancy/__init__.py
@@ -1,18 +1,24 @@
"""
-app/tenancy/
-============
-Host-based tenant resolution and per-tenant database routing (MT-1).
+app/tenancy/__init__.py
+-----------------------
+Public exports for the tenancy package.
-Public surface:
- RoutingSession — tenant-aware session class (installed on `db`)
- init_tenancy — registers the before_request resolver hook
- TenantContext — detached descriptor carried on g.tenant
-
-Inert unless config MULTI_TENANT_ENABLED is True.
+MT-1: RoutingSession, init_tenancy, TenantContext
+MT-5: feature_required, quota_soft_check, check_quota, check_feature
"""
from app.tenancy.routing import RoutingSession
from app.tenancy.middleware import init_tenancy
from app.tenancy.context import TenantContext
+from app.tenancy.gates import feature_required, quota_soft_check
+from app.tenancy.quota import check_quota, check_feature
-__all__ = ['RoutingSession', 'init_tenancy', 'TenantContext']
+__all__ = [
+ 'RoutingSession',
+ 'init_tenancy',
+ 'TenantContext',
+ 'feature_required',
+ 'quota_soft_check',
+ 'check_quota',
+ 'check_feature',
+]
diff --git a/app/tenancy/context.py b/app/tenancy/context.py
index 90cbc5a..b155b8c 100644
--- a/app/tenancy/context.py
+++ b/app/tenancy/context.py
@@ -5,9 +5,13 @@ Lightweight, detached descriptor for the resolved tenant. Populated by the
resolver while a control-plane session is open, then carried on `g.tenant`
for the lifetime of the request. Holds no live ORM object — safe to use after
the control session closes.
+
+MT-5: plan feature flags and quota limits are included so quota.py and
+gates.py can read them from g.tenant without a second control-DB round-trip.
"""
from dataclasses import dataclass
+from typing import Optional
@dataclass(frozen=True)
@@ -17,3 +21,18 @@ class TenantContext:
name: str
plan_id: int
db_uri: str
+
+ # MT-5: plan fields (None = unlimited / not loaded)
+ plan_code: Optional[str] = None
+
+ # Quota limits (None = unlimited)
+ max_users: Optional[int] = None
+ max_facilities: Optional[int] = None
+ max_inspections_month: Optional[int] = None
+ max_issues_month: Optional[int] = None
+
+ # Feature gates
+ allow_mobile_api: bool = True
+ allow_scheduled_reports: bool = True
+ allow_branding: bool = True
+ allow_custom_domain: bool = True
diff --git a/app/tenancy/gates.py b/app/tenancy/gates.py
new file mode 100644
index 0000000..329a6b0
--- /dev/null
+++ b/app/tenancy/gates.py
@@ -0,0 +1,119 @@
+"""
+app/tenancy/gates.py
+---------------------
+MT-5 enforcement decorators.
+
+@feature_required('mobile_api')
+ Blocks the route (403 web / JSON) when the tenant's plan doesn't include
+ the named feature. Inert when MULTI_TENANT_ENABLED=False.
+
+@quota_soft_check('inspections')
+ Soft quota enforcement per MULTI_TENANT_PLAN.md §6:
+ - Never blocks the request.
+ - When over limit, sets g.quota_warning = QuotaStatus so the route/
+ template can show an upgrade prompt.
+ - Logs the event for future billing metrics.
+
+Usage
+-----
+Web route:
+ @bp.route('/new', methods=['GET','POST'])
+ @login_required
+ @feature_required('scheduled_reports') # hard block if not on plan
+ def create(): ...
+
+ @bp.route('//submit', methods=['POST'])
+ @login_required
+ @quota_soft_check('inspections') # soft warn, never reject
+ def submit(id): ...
+
+API route:
+ @bp.route('', methods=['POST'])
+ @jwt_required
+ @feature_required('mobile_api')
+ @quota_soft_check('inspections')
+ def create_inspection(): ...
+
+Template usage (quota warning banner):
+ {% if g.quota_warning and g.quota_warning.exceeded %}
+
+ You have reached your {{ g.quota_warning.axis }} limit
+ ({{ g.quota_warning.current }}/{{ g.quota_warning.limit }}).
+ Upgrade your plan for more.
+
+ {% endif %}
+"""
+
+import logging
+from functools import wraps
+
+from flask import g, request, jsonify
+
+from app.tenancy.quota import check_feature, check_quota
+
+logger = logging.getLogger(__name__)
+
+
+def _is_api_request():
+ """True when the request is to an /api/ path or expects JSON."""
+ return (request.path.startswith('/api/')
+ or request.accept_mimetypes.best == 'application/json')
+
+
+def feature_required(feature_key: str):
+ """
+ Hard-block decorator. Returns 403 if the tenant plan doesn't include
+ feature_key. Transparent when MT is disabled.
+ """
+ def decorator(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ if not check_feature(feature_key):
+ tenant = getattr(g, 'tenant', None)
+ slug = tenant.slug if tenant else '?'
+ logger.warning(
+ 'GATE | feature_blocked | feature=%s tenant=%s path=%s',
+ feature_key, slug, request.path,
+ )
+ if _is_api_request():
+ return jsonify({
+ 'ok': False,
+ 'error': f'Feature "{feature_key}" is not available on your current plan.',
+ 'upgrade_required': True,
+ }), 403
+ # Web: flash + redirect back
+ from flask import flash, redirect, url_for
+ flash(
+ f'This feature is not available on your current plan. '
+ f'Please upgrade to access it.',
+ 'warning',
+ )
+ return redirect(url_for('dashboard.index'))
+ return f(*args, **kwargs)
+ return decorated
+ return decorator
+
+
+def quota_soft_check(axis: str):
+ """
+ Soft quota decorator — never blocks the request.
+
+ Sets g.quota_warning = QuotaStatus when the current count >= plan limit.
+ The route or template reads g.quota_warning to show an upgrade prompt.
+ """
+ def decorator(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ g.quota_warning = None
+ status = check_quota(axis)
+ if status is not None and status.exceeded:
+ g.quota_warning = status
+ tenant = getattr(g, 'tenant', None)
+ slug = tenant.slug if tenant else '?'
+ logger.info(
+ 'QUOTA | soft_exceeded | axis=%s tenant=%s current=%s limit=%s path=%s',
+ axis, slug, status.current, status.limit, request.path,
+ )
+ return f(*args, **kwargs)
+ return decorated
+ return decorator
diff --git a/app/tenancy/quota.py b/app/tenancy/quota.py
new file mode 100644
index 0000000..aa8640a
--- /dev/null
+++ b/app/tenancy/quota.py
@@ -0,0 +1,156 @@
+"""
+app/tenancy/quota.py
+---------------------
+Live quota helpers for MT-5.
+
+Counts current-month rows directly in the tenant DB — no counter table, always
+accurate. Plan limits are read from g.tenant (loaded once by the resolver) so
+no second control-DB round-trip is needed.
+
+Quota axes:
+ inspections → Inspection.inspection_date in current month, status='completed'
+ issues → Issue.created_at in current month
+ users → User.active == True (total, not monthly)
+ facilities → Facility.active == True (total, not monthly)
+
+Returns None when MULTI_TENANT_ENABLED is False or g.tenant is absent —
+all quota checks pass, single-tenant behaviour unchanged.
+"""
+
+import logging
+from datetime import datetime
+
+from flask import g, current_app
+
+logger = logging.getLogger(__name__)
+
+
+def _mt_enabled():
+ return current_app.config.get('MULTI_TENANT_ENABLED', False)
+
+
+def _month_window():
+ now = datetime.now()
+ start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ if now.month == 12:
+ end = now.replace(year=now.year + 1, month=1, day=1,
+ hour=0, minute=0, second=0, microsecond=0)
+ else:
+ end = now.replace(month=now.month + 1, day=1,
+ hour=0, minute=0, second=0, microsecond=0)
+ return start, end
+
+
+def count_inspections_this_month():
+ from app.models.inspection import Inspection
+ start, end = _month_window()
+ return (Inspection.query
+ .filter(Inspection.status == 'completed',
+ Inspection.inspection_date >= start,
+ Inspection.inspection_date < end)
+ .count())
+
+
+def count_issues_this_month():
+ from app.models.issue import Issue
+ start, end = _month_window()
+ return (Issue.query
+ .filter(Issue.created_at >= start,
+ Issue.created_at < end)
+ .count())
+
+
+def count_active_users():
+ from app.models.user import User
+ return User.query.filter_by(active=True).count()
+
+
+def count_active_facilities():
+ from app.models.facility import Facility
+ return Facility.query.filter_by(active=True).count()
+
+
+# ── Public interface ──────────────────────────────────────────────────────────
+
+class QuotaStatus:
+ __slots__ = ('axis', 'limit', 'current', 'exceeded')
+
+ def __init__(self, axis, limit, current):
+ self.axis = axis
+ self.limit = limit
+ self.current = current
+ self.exceeded = (limit is not None and current >= limit)
+
+ def __repr__(self):
+ return (f'')
+
+
+def check_quota(axis: str):
+ """
+ Check one quota axis. Returns QuotaStatus or None (MT disabled / no tenant).
+ Reads plan limits from g.tenant — no extra DB query.
+ """
+ if not _mt_enabled():
+ return None
+ tenant = getattr(g, 'tenant', None)
+ if tenant is None:
+ return None
+
+ _limit_attrs = {
+ 'inspections': 'max_inspections_month',
+ 'issues': 'max_issues_month',
+ 'users': 'max_users',
+ 'facilities': 'max_facilities',
+ }
+ _counter_fns = {
+ 'inspections': count_inspections_this_month,
+ 'issues': count_issues_this_month,
+ 'users': count_active_users,
+ 'facilities': count_active_facilities,
+ }
+ if axis not in _limit_attrs:
+ logger.warning('quota.check_quota: unknown axis %r', axis)
+ return None
+
+ limit = getattr(tenant, _limit_attrs[axis], None)
+ if limit is None:
+ # Unlimited — still return a status so caller can display current usage
+ try:
+ current = _counter_fns[axis]()
+ except Exception as exc:
+ logger.error('quota.check_quota count failed axis=%s err=%s', axis, exc)
+ return None
+ return QuotaStatus(axis=axis, limit=None, current=current)
+
+ try:
+ current = _counter_fns[axis]()
+ except Exception as exc:
+ logger.error('quota.check_quota count failed axis=%s err=%s', axis, exc)
+ return None
+
+ return QuotaStatus(axis=axis, limit=limit, current=current)
+
+
+def check_feature(feature_key: str) -> bool:
+ """
+ Return True if the current tenant's plan allows feature_key.
+ Reads from g.tenant — no extra DB query.
+ Returns True when MT is disabled.
+ """
+ if not _mt_enabled():
+ return True
+ tenant = getattr(g, 'tenant', None)
+ if tenant is None:
+ return True
+
+ _gate_attrs = {
+ 'mobile_api': 'allow_mobile_api',
+ 'scheduled_reports': 'allow_scheduled_reports',
+ 'branding': 'allow_branding',
+ 'custom_domain': 'allow_custom_domain',
+ }
+ attr = _gate_attrs.get(feature_key)
+ if attr is None:
+ return True # unknown feature → fail open
+ return getattr(tenant, attr, True)
diff --git a/app/tenancy/resolver.py b/app/tenancy/resolver.py
index f4e846b..62b16ed 100644
--- a/app/tenancy/resolver.py
+++ b/app/tenancy/resolver.py
@@ -11,6 +11,10 @@ Resolution rules:
* exact match on tenant_domains.domain (host, lowercased, port stripped)
* tenant must be status='active'
* custom domains must be verified; subdomains we issue are trusted
+
+MT-5: plan fields are loaded in the same session and stored on TenantContext
+so quota.py / gates.py can read them without a second control-DB query.
+
Returns a detached TenantContext or None.
"""
@@ -40,6 +44,8 @@ def resolve_tenant(host):
if tenant is None or tenant.status != 'active':
return None
+ plan = tenant.plan # relationship already loaded via joined session
+
# Materialise everything needed while the session is still open
# (db_uri decrypts the stored credential).
return TenantContext(
@@ -48,4 +54,14 @@ def resolve_tenant(host):
name=tenant.name,
plan_id=tenant.plan_id,
db_uri=tenant.db_uri,
+ # MT-5: plan fields
+ plan_code=plan.code if plan else None,
+ max_users=plan.max_users if plan else None,
+ max_facilities=plan.max_facilities if plan else None,
+ max_inspections_month=plan.max_inspections_month if plan else None,
+ max_issues_month=plan.max_issues_month if plan else None,
+ allow_mobile_api=plan.allow_mobile_api if plan else True,
+ allow_scheduled_reports=plan.allow_scheduled_reports if plan else True,
+ allow_branding=plan.allow_branding if plan else True,
+ allow_custom_domain=plan.allow_custom_domain if plan else True,
)