Subscription Gating Guide¶
Last Updated: 2026-05-25 — this is the implementation guide (backend middleware code, frontend wiring, service checklist). For the authoritative policy statement and how this fits into the full three-layer authorization model, see trinity-authorization-pattern.md, which is the more actively maintained of the two.
Principles¶
- Subscription NEVER blocks login. Users must always be able to authenticate regardless of subscription status.
- Read operations always pass through. GET/HEAD/OPTIONS are never gated by subscription — users can view their data even with expired subscriptions.
- Mutations require active subscription. POST/PUT/PATCH/DELETE are blocked with 403 when subscription is inactive.
- Superuser and platform owner always bypass. Both
claims.IsSuperuser()andclaims.IsPlatformOwnerskip all subscription checks. - Frontend shows upgrade UI, not login redirects. Subscription 403s trigger banners/toasts/modals — never redirect to SSO or login page.
Backend Pattern (Go Services)¶
Mutations-Only Middleware¶
All Go services use this inline middleware in their router (same pattern as ordering-backend):
api.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read-only methods always pass through
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
claims, ok := authclient.ClaimsFromContext(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}
if claims.IsSuperuser() || claims.IsPlatformOwner || claims.IsSubscriptionActive() {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"Your subscription is not active. Please renew to continue.","code":"subscription_inactive","upgrade":true}`))
})
})
Error Response Format¶
All subscription enforcement returns:
{
"error": "Your subscription is not active. Please renew to continue.",
"code": "subscription_inactive",
"upgrade": true
}
The upgrade: true field is the key discriminator frontends use to distinguish subscription 403s from auth/permission 403s.
Shared Auth-Client Convenience Function¶
shared/auth-client/middleware.go provides RequireActiveSubscriptionForMutations() for services that update to the latest shared-auth-client release. Services using a published version (e.g. v0.5.0) should use the inline pattern above.
Services Reference¶
| Service | Enforcement | Notes |
|---|---|---|
| ordering-backend | Mutations only (inline) | Reference implementation |
| pos-api | Mutations only (inline) | |
| treasury-api | Mutations only (inline) | |
| projects-api | Mutations only (inline) | |
| inventory-api | Mutations only (per-route) | |
| logistics-api | Mutations only (inline) | |
| auth-api | No enforcement | Core service (token authority) |
| subscriptions-api | No enforcement | Core service (licensing authority) |
| notifications-api | No enforcement | Uses plan-based email rate limiting instead |
Frontend Pattern (Next.js/React)¶
Architecture¶
Login → SSO callback → Token exchange → /me sync → Dashboard
↓
useSubscription() hook
(lazy load from subscriptions-api)
↓
SubscriptionBanner (top of layout)
SubscriptionGate (wraps gated features)
Files Per Frontend¶
Each frontend implements these files:
| File | Purpose |
|---|---|
src/lib/auth/subscription.ts |
fetchSubscriptionInfo() — fetches from subscriptions-api, returns null on error (fail-open) |
src/hooks/use-subscription.ts |
useSubscription() — React hook with isActive, hasFeature(), isPastDue, isExpired, etc. |
src/components/subscription/subscription-gate.tsx |
<SubscriptionGate feature="..."> — wraps content, shows upgrade prompt when gated |
src/components/subscription/subscription-banner.tsx |
<SubscriptionBanner /> — persistent top banner for trial expiry, past due, expired |
Auth Store Requirements¶
Each frontend's Zustand auth store must include:
subscriptionInfo: Record<string, unknown> | null | undefined;
setSubscriptionInfo: (info: Record<string, unknown> | null) => void;
Do NOT persist subscriptionInfo in Zustand's partialize — fetch fresh each session.
API Client: 403 Discrimination + 5xx Server Errors¶
Each frontend must implement a central error handler (src/lib/api/error-handler.ts) and wire callbacks in the API client:
// src/lib/api/error-handler.ts
export type SubscriptionErrorCode =
| 'subscription_inactive' | 'subscription_expired'
| 'feature_not_available' | 'usage_limit_exceeded'
| 'device_limit_reached' | 'plan_upgrade_required';
export function isSubscriptionError(data: any): boolean {
if (data?.upgrade === true) return true; // legacy shape
return SUBSCRIPTION_CODES.has(data?.code);
}
// Axios-based API client (src/lib/api/client.ts):
private onSubscription403Callback: ((data: any) => void) | null = null;
private onServerErrorCallback: ((status: number, message: string) => void) | null = null;
public setOnSubscription403(callback: ((data: any) => void) | null) {
this.onSubscription403Callback = callback;
}
public setOnServerError(callback: ((status: number, message: string) => void) | null) {
this.onServerErrorCallback = callback;
}
private handleError = (error: any) => {
// 403 — subscription gating
if (error.response?.status === 403) {
const data = error.response?.data;
if (isSubscriptionError(data) && this.onSubscription403Callback) {
this.onSubscription403Callback(data);
}
}
// 5xx — server error toast
if (error.response?.status >= 500 && this.onServerErrorCallback) {
const data = error.response?.data;
const message = data?.message ?? data?.error ?? 'A server error occurred. Please try again.';
this.onServerErrorCallback(error.response.status, message);
}
return Promise.reject(error);
};
Wire both callbacks in the auth provider using sonner toast:
// src/providers/auth-provider.tsx
useEffect(() => {
apiClient.setOnSubscription403((data) => {
toast.error('Subscription limit reached', {
description: subscriptionErrorMessage(data),
duration: 8000,
action: { label: 'Upgrade plan', onClick: () => router.push(`/${orgSlug}/settings/billing`) },
});
});
return () => apiClient.setOnSubscription403(null);
}, [orgSlug, router]);
useEffect(() => {
apiClient.setOnServerError((_status, message) => {
toast.error('Server error', { description: message, duration: 6000 });
});
return () => apiClient.setOnServerError(null);
}, []);
Auth Provider: Skip Redirect for Subscription 403¶
Auth providers that handle 403 from /me must check the error body before redirecting:
// WRONG: redirects to unauthorized on ANY 403
if (isError && statusCode === 403) {
router.replace('/unauthorized');
}
// CORRECT: skip redirect for subscription 403
if (isError && statusCode === 403) {
const data = (error as any)?.response?.data;
if (isSubscriptionError(data)) return; // show toast instead
router.replace('/unauthorized');
}
Sensitive Action Confirmation¶
All delete, deactivate, force-close, revoke, and other destructive actions must use a confirm dialog — never window.confirm().
// Each frontend must have (or copy from pos-ui):
// src/components/ui/confirm-dialog.tsx
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
const [open, setOpen] = useState(false);
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title="Delete item?"
description="This cannot be undone."
confirmLabel="Delete"
variant="danger" // 'danger' | 'warning' | 'info'
onConfirm={handleDelete}
/>
useSubscription() Hook¶
The hook:
1. Waits for status === "authenticated" before fetching
2. Skips fetch for platform owners (auto-grants enterprise)
3. Fetches from NEXT_PUBLIC_SUBSCRIPTIONS_API_URL/api/v1/subscription with Bearer token + tenant headers
4. Returns null on error (fail-open — never blocks UI)
5. Exposes: isActive, isPastDue, isExpired, needsSubscription, hasFeature(code), getLimit(key)
JWT claims: The JWT embeds subscription data as
sub_plan,sub_status,sub_features,sub_limits,sub_expires. The hook reads from the live subscriptions-api response (not the JWT), so it reflects the latest plan state even before token refresh.
SubscriptionBanner¶
- Placed at top of org-scoped layout, before main content
- Shows nothing for active/trial subscriptions (unless trial ends in < 3 days)
- Shows warning for trial ending soon, past due payment
- Shows error for expired subscriptions
- Shows info for free tier (no subscription)
- Dismissible per session
- Links to subscriptions-ui for upgrade/billing actions
SubscriptionGate¶
- Wraps content that requires a specific feature or plan
- Shows children optimistically during loading
- Shows upgrade prompt (lock icon + upgrade button) when feature is unavailable
- Links to subscriptions-ui subscribe page
Adding Subscription Gating to a New Service¶
Backend¶
- Add the mutations-only middleware to your router (see pattern above)
- Ensure
claims.IsSuperuser()andclaims.IsPlatformOwnerbypass - Response must include
"code":"subscription_inactive"and"upgrade":true
Frontend¶
- Add
subscriptionInfo+setSubscriptionInfoto your Zustand auth store - Copy
subscription.ts,use-subscription.ts,subscription-gate.tsx,subscription-banner.tsxfrom ordering-frontend - Adapt auth store import path
- Add
<SubscriptionBanner />to your org-scoped layout - Add
setOnSubscription403to your API client - Fix auth-provider 403 handling to skip redirect for subscription 403
- Wrap gated actions with
<SubscriptionGate feature="...">or checkuseSubscription().isActive