Tiers, Subscriptions & Patient Tiers
The platform's commercial model: tiers the platform sells to clinics (org-side), and tiers the clinic offers patients (per-org). Companion to org-settings.md (where the current-tier pointer lives), middleware-composition.md (how
RequireTierEntitlement/EnforceLimitcompose withRequirePermission), and data-model.md (entity catalog).Why this exists. Tiers gate entitlements and quotas; they do not grant permissions. RBAC is the authorization primitive; tiers pick which entitlements are available and how much capacity an org has. The two systems compose without overlapping. This doc fixes the schema, the resolution rules, and the regulatory boundary so future features inherit a consistent model instead of inventing one.
Core principles
- Tiers never grant permissions. Tiers select entitlements and limits; RBAC decides who can do what. A tier flips a flag (
automationsenabled), an org admin grants a role theautomations.managepermission. The two are orthogonal and stay orthogonal. - Tiers never gate clinical code directly. Regulated code reads only from
organization_entitlements. The tier engine projects regulated entitlements onto that surface; the regulated code is decoupled from the tier engine. See Entitlement projection. - Snapshot on subscribe. Entitlements and limits are frozen onto the subscription at signup (P14b / P18). Tier edits affect new signups only; existing subscribers are grandfathered until they renew or actively switch.
- Multi-subscription stacking. An org can hold one base tier plus N add-ons plus M usage packs simultaneously. Effective entitlements and limits are resolved by combining all active subscriptions.
- Per-subscription overrides. Sales motion can grant exceptions on top of the snapshot — recorded as audited rows, not direct edits to the snapshot.
- No hardcoded tier strings. Tiers are referenced by
tier_id(UUID) orcode(TEXT) only as catalog lookups. The only hardcoded tier-shaped string in the codebase is in the platform-level catalog migration that seeds the initial tiers. Application code never compares against tier codes. - Patient tiers are per-org and never flip RBAC. Each clinic defines its own tier catalog. Tier perks ride entirely on parallel snapshot tables (
patient_tier_entitlements,patient_tier_limits,patient_subscription_entitlements,patient_subscription_limits) that share theentitlements/limit_definitionscatalogs with the org-side billing engine. Patients have no role at the org — portal access comes from the existence of apatientsrow, never from a permission grant. See decisions.md → Why patients are not memberships, and patient tiers are not roles.
Concept map
Four orthogonal concerns at the request gate:
| Concept | Question | Where defined | Where enforced | Doc |
|---|---|---|---|---|
| Permission | Is the verb allowed for this caller? | permissions (catalog) + roles/role_permissions | RequirePermission middleware + RLS | rbac-permissions.md |
| TierEntitlement | Is this entitlement included in the org's SKU? | entitlements (catalog) + tier_entitlements → organization_subscription_entitlements | RequireTierEntitlement middleware | this doc |
| OrgEntitlement | Is this regulated boundary open for this org? | organization_entitlements (typed columns) | RequireOrgEntitlement / current_app_has_org_entitlement | org-settings.md + this doc |
| Limit | Is the meter under cap for this resource? | limit_definitions (catalog) + tier_limits → organization_subscription_limits | EnforceLimit middleware + usage_events | this doc |
A request that reaches a clinical write must clear all four. See middleware-composition.md for ordering and error codes.
Catalog tables (platform-wide, no organization_id)
Catalog tables are managed via migrations, not at runtime. They are global; superadmin-only writes via AdminPool.
tiers
The platform's product catalog. Versioned for snapshot (P18).
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
code | TEXT NOT NULL UNIQUE | e.g. free, pro, dedicated, addon_telerehab, pack_video_minutes_1000. Stable, used by migrations and external billing system. |
name | TEXT NOT NULL | Display name. |
description | TEXT | |
kind | TEXT NOT NULL | `base |
billing_cycle | TEXT NULL | `monthly |
base_price | DECIMAL(10,2) NULL | Informational; canonical price comes from the external billing system once wired. |
currency | TEXT NOT NULL DEFAULT 'RON' | (P22) |
is_public | BOOLEAN NOT NULL DEFAULT FALSE | TRUE ⇒ appears in self-service signup. FALSE ⇒ sales-only, custom tiers. |
version | INT NOT NULL DEFAULT 1 | Bumped on any entitlement/limit edit. |
published | BOOLEAN NOT NULL DEFAULT FALSE | Only published versions can be subscribed to. |
published_at | TIMESTAMPTZ NULL | |
deprecated_at | TIMESTAMPTZ NULL | When set, prevents new signups; existing subscribers continue. |
translations | JSONB NOT NULL DEFAULT '{}' | (P21) — for name/description localization. |
created_at, updated_at | TIMESTAMPTZ |
RLS. SELECT for everyone (read-only catalog). UPDATE/INSERT/DELETE: superadmin via AdminPool only.
tier_versions
Append-only history of tier + entitlements + limits. Snapshotted onto organization_subscriptions at subscribe time.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
tier_id | UUID FK | |
version | INT NOT NULL | Matches tiers.version at the moment of publish. |
published_at | TIMESTAMPTZ NOT NULL | |
entitlements_snapshot | JSONB NOT NULL | Array of entitlement codes enabled at this version. |
limits_snapshot | JSONB NOT NULL | Array of {code, cap_value, behavior}. |
metadata_snapshot | JSONB NOT NULL | Frozen {name, description, base_price, currency, billing_cycle}. |
changed_by_principal_id | UUID FK NULL → principals(id) | Superadmin (human) who published this version. Human-only constraint enforced by platform_memberships, not by a CHECK here. |
created_at | TIMESTAMPTZ | |
| Unique | (tier_id, version) |
Snapshot semantics follow P14a (append-only): once a version row is inserted, it is immutable. Editing the catalog row produces a new version row. (P14b — immutable-after-state-transition — applies to forms / signed PDFs, not to tier versions.)
entitlements
Catalog of every tier-gated entitlement. Mirrors the permissions catalog in shape — every entitlement code is documented in this table, seeded by the migration that introduces it. Natural-key PK.
| Column | Type | Notes |
|---|---|---|
code | TEXT PK | e.g. custom_domain, automations, webhooks, bulk_export, api_access, treatment_plans, video_consultations, pose_estimation. |
name | TEXT NOT NULL | Display name for billing UI. |
description | TEXT | |
regulated | BOOLEAN NOT NULL DEFAULT FALSE | TRUE ⇒ entitlement must be projected onto organization_entitlements when active. See Entitlement projection. |
entitlement_column | TEXT NULL | Name of the typed column on organization_entitlements this entitlement projects to; required when regulated = TRUE, must be NULL otherwise (enforced by chk_entitlements_entitlement_column_consistency CHECK). |
created_at, updated_at | TIMESTAMPTZ |
RLS. SELECT for everyone. Writes superadmin-only via AdminPool.
limit_definitions
Catalog of every metered or capped resource.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
code | TEXT NOT NULL UNIQUE | e.g. max_patients, max_specialists, max_storage_bytes, max_active_treatment_plans, video_minutes_per_month. |
name | TEXT NOT NULL | |
description | TEXT | |
unit | TEXT NOT NULL | `count |
default_behavior | TEXT NOT NULL | `hard_block |
period_kind | TEXT NOT NULL | `lifetime |
created_at, updated_at | TIMESTAMPTZ |
RLS. SELECT for everyone. Writes superadmin-only.
tier_entitlements
Which entitlements a tier unlocks.
| Column | Type | Notes |
|---|---|---|
tier_id | UUID FK | |
entitlement_code | TEXT NOT NULL FK → entitlements(code) | Reference by code (not id) for migration ergonomics. |
enabled | BOOLEAN NOT NULL DEFAULT TRUE | Allows a tier version to explicitly disable an entitlement without removing the row (audit trail across versions). |
created_at | TIMESTAMPTZ | |
| PK | (tier_id, entitlement_code) |
tier_limits
What caps and meter behaviors a tier sets.
| Column | Type | Notes |
|---|---|---|
tier_id | UUID FK | |
limit_code | TEXT NOT NULL FK → limit_definitions(code) | |
cap_value | BIGINT NULL | Numeric cap in the unit defined by limit_definitions.unit. NULL ⇒ unlimited (e.g. dedicated-mode tiers). |
behavior_override | TEXT NULL | Override limit_definitions.default_behavior if this tier should treat the resource differently. NULL ⇒ inherit. |
created_at | TIMESTAMPTZ | |
| PK | (tier_id, limit_code) |
Tier kinds
tiers.kind is one of:
base— the org's primary subscription. Exactly one base subscription is active per org at a time (service-layer invariant — checked on every subscription insert; not a DB constraint because past inactive base subscriptions are valid history).addon— adds entitlements/limits on top of a base. An org can hold N concurrent add-ons. Example:addon_telerehabunlocks the regulatedtreatment_plansentitlement without changing the base tier.usage_pack— a one-time purchase that increases the cap of one or moremeteredlimits. Example:pack_video_minutes_1000adds 1000 to thevideo_minutes_per_monthcap for the current period. Usage packs are not subscriptions in the renewal sense — they expire when consumed or at period end.
The kind drives resolution rules — entitlements and quota caps are MAX across active subscriptions; usage-pack caps are SUM.
Subscription tables (per-org)
organization_subscriptions
The N:M between an org and the tiers it holds. One row per active tier attachment.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK | |
tier_id | UUID FK | Pointer to the catalog tier. |
tier_version | INT NOT NULL | The tier_versions.version snapshotted at subscribe time. |
status | TEXT NOT NULL | `trialing |
started_at | TIMESTAMPTZ NOT NULL | |
current_period_starts_at | TIMESTAMPTZ NULL | NULL for usage_pack (no period). |
current_period_ends_at | TIMESTAMPTZ NULL | |
cancel_at | TIMESTAMPTZ NULL | Scheduled cancel — at period end if scheduled, immediate if NULL. |
canceled_at | TIMESTAMPTZ NULL | |
payment_provider | TEXT NOT NULL DEFAULT 'manual' | `manual |
external_subscription_id | TEXT NULL | e.g. Stripe subscription ID. NULL until billing is wired. |
created_at, updated_at | TIMESTAMPTZ | |
| Index | (organization_id, status) | Hot path: "active subscriptions for org X" on every gated request. |
RLS.
- SELECT: callers with
subscriptions.view_org(granted toadmintemplate). - INSERT/UPDATE/DELETE: callers with
subscriptions.manage(granted toadmintemplate). The tier engine writes via the AppPool with the org admin's session — billing webhooks acquire a service-account context. Superadmin via AdminPool can write any org.
State machine.
┌────────────┐ start_trial ┌──────────┐
│ trialing │──────────────▶│ active │
└────┬───────┘ └────┬─────┘
│ │
│ trial_expired │ payment_failed
▼ ▼
┌──────────┐ ┌──────────┐
│ expired │ │ past_due │
└──────────┘ └────┬─────┘
│ payment_recovered → active
│ dunning_exceeded → canceled
▼
┌──────────┐
│ canceled │
└──────────┘paused is a manual admin pause (trip-out without canceling). Transitions are validated in the service layer per P33.
organization_subscription_entitlements (snapshot)
Frozen at subscribe time. Tier edits never modify these rows.
| Column | Type | Notes |
|---|---|---|
subscription_id | UUID FK | |
entitlement_code | TEXT NOT NULL | |
enabled | BOOLEAN NOT NULL | |
created_at | TIMESTAMPTZ | |
| PK | (subscription_id, entitlement_code) |
RLS. Inherits from organization_subscriptions access.
organization_subscription_limits (snapshot)
Frozen at subscribe time.
| Column | Type | Notes |
|---|---|---|
subscription_id | UUID FK | |
limit_code | TEXT NOT NULL | |
cap_value | BIGINT NULL | NULL = unlimited. |
behavior | TEXT NOT NULL | Resolved from tier_limits.behavior_override ?? limit_definitions.default_behavior. |
created_at | TIMESTAMPTZ | |
| PK | (subscription_id, limit_code) |
organization_subscription_overrides
Sales-granted exceptions on top of the snapshot. Audited.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
subscription_id | UUID FK | |
override_kind | TEXT NOT NULL | `entitlement |
entitlement_code | TEXT NULL | Required when override_kind = 'entitlement'. |
entitlement_enabled | BOOLEAN NULL | Required when override_kind = 'entitlement'. |
limit_code | TEXT NULL | Required when override_kind = 'limit'. |
cap_value | BIGINT NULL | NULL = unlimited (override). |
behavior_override | TEXT NULL | |
granted_by_principal_id | UUID FK → principals(id) | Superadmin (human) who granted. Human-only constraint enforced by platform_memberships, not by a CHECK here. |
reason | TEXT NOT NULL | Audit trail. |
effective_from | TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
expires_at | TIMESTAMPTZ NULL | NULL = until subscription ends. |
revoked_at | TIMESTAMPTZ NULL | |
revoked_by_principal_id | UUID FK NULL → principals(id) | |
created_at | TIMESTAMPTZ | |
| CHECK | chk_sub_override_shape — when override_kind = 'entitlement', entitlement_code + entitlement_enabled are NOT NULL and the limit-side columns are NULL; when override_kind = 'limit', limit_code is NOT NULL and the entitlement-side columns are NULL. |
RLS. SELECT for subscriptions.view_org. INSERT/UPDATE: superadmin only. The clinic admin cannot grant their own overrides.
Resolution rules
When a request reaches RequireTierEntitlement or EnforceLimit, the resolver runs against the org's active subscriptions.
Tier-entitlement resolution
For each active subscription S of org O (status IN ('trialing','active','paused')):
For each organization_subscription_entitlements row where enabled = TRUE:
entitlement is unlocked
For each organization_subscription_overrides row where override_kind='entitlement' and active and not revoked and not expired:
if entitlement_enabled = TRUE: entitlement is unlocked
if entitlement_enabled = FALSE: entitlement is locked (override > snapshot)Tier-entitlement unlock semantics: any active subscription that includes the entitlement unlocks it. A false override locks regardless of subscriptions. The cached resolution is invalidated when organization_subscription_entitlements or organization_subscription_overrides change for the org.
Limit resolution
Behavior-aware:
- For
hard_blockquotas (max_patients,max_specialists,max_storage_bytes— the Layer 1 seed set, all lifetime caps):cap_value = MAX(snapshot caps across active subscriptions, with overrides applied).- The org gets the largest cap any active subscription provides. Add-ons that bundle a higher cap raise the ceiling; downgrading a subscription lowers it (potentially making the org over-cap; see Over-cap state).
- Same
MAX(...)rule applies to any futuresoft_meterquota whose cap is a snapshot rather than a usage-pack addition.
- For
usage_packpurchases (video_minutes_per_month):cap_value = base_subscription_cap + SUM(usage_pack additions for current period).- Usage packs add to the meter; they are not snapshots that replace.
behavior resolution: if any active subscription says hard_block, the limit is hard. Otherwise soft_meter. Overrides take priority.
Org-entitlement resolution
Org-entitlements are read from organization_entitlements directly. The tier engine writes that table; the resolver here is not in the regulated read path. See Entitlement projection.
Entitlement projection
The regulatory boundary. The tier engine writes organization_entitlements; clinical/regulated code reads only from organization_entitlements. Clinical code never reads organization_subscription_entitlements or tier_entitlements directly. This is the load-bearing rule of middleware-composition.md.
Trigger
SubscriptionService.RecomputeOrgEntitlements(orgId) runs:
- After any insert/update/delete on
organization_subscriptionsfororgId. - After any insert/update/delete on
organization_subscription_overridesfor any subscription oforgId. - On startup for any org with stale projection state (operational safety net).
Logic
entitlements_to_set = {}
For each regulated entitlement F (where entitlements.regulated = TRUE):
if tier_entitlement_resolution(orgId, F.code) == enabled:
entitlements_to_set[F.entitlement_column] = TRUE
else:
entitlements_to_set[F.entitlement_column] = FALSE
UPDATE organization_entitlements SET ... WHERE organization_id = orgId
INSERT INTO audit_log (action='UPDATE', entity_type='organization_entitlements', changes=diff, action_context='org_entitlement_change', ...)The mapping entitlement.code → entitlement_column → organization_entitlements.<col> is data-driven via the entitlements.entitlement_column column. The CHECK constraint chk_entitlements_entitlement_column_consistency (regulated ⇔ entitlement_column NOT NULL) means a regulated entitlement cannot be added to the catalog without specifying its target entitlement column — fail-loud at migration time, no chance of a regulated entitlement silently projecting nowhere. When entitlement_column is NULL, the entitlement is non-regulated and never projects.
Concrete entrypoints:
- Projection (write side):
subscriptions.Service.RecomputeOrgEntitlements(orgID)(services/api/internal/core/domain/subscriptions/service.go) selectscode, entitlement_column FROM entitlements WHERE regulated = TRUE, resolves each against the org's active subscriptions/overrides, and writes the resulting boolean into the named column onorganization_entitlements. Called from every subscription mutation path (Create, UpdateStatus, GrantOverride, RevokeOverride). - Read (read side):
Subject.HasOrgEntitlement(code)(services/api/internal/core/principal/subject.go) —codeis anentitlements.codevalue; the lookup map is hydrated fromorganization_entitlementswhenOrganizationContextruns, so handlers and middleware never touch the column-name layer directly.
Why this shape
If clinical code read organization_subscription_entitlements directly, the tier engine, the subscription model, and RequireTierEntitlement middleware would all sit inside the SaMD verification scope (IEC 62304). Every tier-pricing change, every billing-state machine edit, would trigger regulatory re-validation. Decoupling via organization_entitlements confines the regulated read surface to a small, stable, typed table. The tier engine is non-clinical infrastructure that writes to that surface — its V&V scope is no broader than any other internal config writer.
Manual override
Superadmin can write organization_entitlements directly via Console UI without going through the tier engine. Use cases: incident kill-switch (an entitlement is regulated-disabled platform-wide; per-org disable for an audit), pre-tier-engine state (Layer 1 ships before the tier engine; entitlements are entirely manual until the engine wires up), per-org regulatory exception (e.g. a US clinic can't have pose_estimation_enabled without HIPAA review).
The tier engine's projection is idempotent and convergent — if a superadmin manually disables an entitlement that the tier engine would re-enable, the next projection re-enables it. To keep a manual disable persistent, either remove the regulated entitlement from the org's tier/overrides, or implement a "manual lock" flag on organization_entitlements (deferred — see Open questions).
Patient tiers
Tiers are per-clinic offerings. Each clinic defines its own catalog. Tiers never grant roles. Subscribing to a tier never mutates organization_memberships — patients have no role at the org post-1.26. Tier perks ride on parallel patient_tier_entitlements / patient_tier_limits tables that share the entitlements / limit_definitions catalogs with the org-side billing engine; the snapshot-on-subscribe pattern (P37) projects them onto patient_subscription_entitlements / patient_subscription_limits at subscribe time.
patient_tiers
Per-org catalog of tiers the clinic offers. Mirror of tiers — versioned (P18), publishable, with parallel patient_tier_entitlements and patient_tier_limits junction tables.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK | (P1) |
code | TEXT NOT NULL | Org-defined, e.g. basic, premium. |
name | TEXT NOT NULL | |
description | TEXT | |
is_active | BOOLEAN DEFAULT TRUE | |
is_default | BOOLEAN DEFAULT FALSE | Exactly one tier per org should have is_default = TRUE; that's the tier auto-assigned to new sign-ups. Partial unique index WHERE is_default = TRUE enforces this at the DB level. |
version | INT NOT NULL DEFAULT 1 | |
published | BOOLEAN NOT NULL DEFAULT FALSE | |
published_at | TIMESTAMPTZ NULL | |
sort_order | INT DEFAULT 0 | |
external_price_hint | DECIMAL(10,2) NULL | Informational only. Source of truth for pricing is the clinic's own billing system. |
currency | TEXT DEFAULT 'RON' | |
created_at, updated_at | TIMESTAMPTZ | |
| Unique | (organization_id, code) |
Companion tables (1.21 / 1.26). patient_tier_versions (append-only history, mirror of tier_versions), patient_tier_entitlements(tier_id, entitlement_code, enabled), patient_tier_limits(tier_id, limit_code, cap_value, behavior_override) — see data-model.md Area 16 for the full shape.
RLS.
- SELECT: members of the org.
- INSERT/UPDATE/DELETE: callers with
patient_tiers.manage(granted toadmintemplate).
patient_subscriptions
Per-org per-patient subscription to a tier.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK | |
patient_id | UUID FK | References patients.id (per-org), not patient_profiles — subscriptions are per-clinic. |
tier_id | UUID FK | |
status | TEXT NOT NULL | `trialing |
started_at | TIMESTAMPTZ NOT NULL | When the subscription first began (does not change across renewals). |
current_period_starts_at | TIMESTAMPTZ NULL | Start of the current billing/grant period. Advances on each renewal. NULL for tiers without a recurring period (one-shot tiers). |
current_period_ends_at | TIMESTAMPTZ NULL | End of the current period. Drives the tier-inclusions rollover hook. |
expires_at | TIMESTAMPTZ NULL | Hard expiry of the whole subscription regardless of renewal. NULL = renews indefinitely until canceled. |
canceled_at | TIMESTAMPTZ NULL | |
payment_provider | TEXT NOT NULL DEFAULT 'external' | `external |
external_subscription_id | TEXT NULL | Set when payment_provider != 'external'. |
created_at, updated_at | TIMESTAMPTZ | |
| Index | (patient_id, status) | One active subscription per patient is the service-layer invariant. |
RLS.
- SELECT: members of the org with
patient_subscriptions.view_org. Patients see their own viacurrent_human_patient_profile_ids()join throughpatients.patient_profile_id. - INSERT/UPDATE/DELETE: callers with
patient_subscriptions.manage(granted toadmintemplate; clinic's admin or customer_support is who flips tiers in the out-of-band model).
Snapshot tables. patient_subscription_entitlements and patient_subscription_limits are populated at subscribe time from patient_tier_entitlements and patient_tier_limits (the active version's snapshot). Tier edits never modify subscriber rows. patient_subscription_overrides carries clinic-granted exceptions (gated by patient_subscriptions.manage, not platform admin — clinics, not the platform, grant patient-side overrides).
patient_tier_inclusions — counted entitlements per tier
A tier can do more than grant role-based access — it can also bundle counted entitlements that auto-replenish per period. "Annual Premium includes 4 free consultations and 4 specialist-assigned treatment plans" is a tier inclusion.
Design choice: delegate to service_plans, do not invent a parallel counter. service_plans + patient_service_plans (in Service Catalog, Area 3) already track session counts, expiry, status, and consumption-on-booking. A tier inclusion is a row that says "when a patient subscribes to this tier, auto-create one or more patient_service_plans rows from the named template; revoke them when the tier ends." Existing booking-against-plan logic doesn't change — it sees a patient_service_plan and consumes from it regardless of how it came into existence.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK | (P1) |
tier_id | UUID FK → patient_tiers(id) | |
service_plan_id | UUID FK → service_plans(id) | The template to clone into patient_service_plans when a tier subscription becomes active. |
grant_period | TEXT NOT NULL | `per_subscription_period |
grant_quantity | INT NOT NULL DEFAULT 1 | Usually 1 (one cloned patient_service_plan per inclusion). >1 if a tier intentionally grants multiple separate enrollments of the same template. |
carry_over_unused | BOOLEAN NOT NULL DEFAULT FALSE | When a per-period grant rolls into a new period, do unused sessions carry forward (TRUE) or drop (FALSE). Default FALSE ("use it or lose it") — clearest accounting; per-row override allowed for products that explicitly market rollover. |
prorate_on_upgrade | BOOLEAN NOT NULL DEFAULT FALSE | When a patient upgrades to this tier mid-period, is the inclusion granted in full (FALSE) or pro-rated to the remaining period (TRUE). Default FALSE (full grant) — common sales-lever practice; per-row override allowed for inclusions where proration matters more than UX. |
created_at, updated_at | TIMESTAMPTZ | |
| Index | (tier_id) | Hot path for resolving "what does this tier include?" on every subscription state change. |
RLS. SELECT for members of the org. INSERT/UPDATE/DELETE: callers with patient_tiers.manage — same permission gating the tier itself, since inclusions are part of the tier definition.
Tier → entitlement provisioning
The companion hook to role binding. Runs on the same patient_subscriptions state changes; projects patient_tier_inclusions onto the patient's patient_service_plans.
on patient_subscription INSERT or status transition to 'active' | 'trialing':
for each patient_tier_inclusions row of sub.tier_id:
for q in 1..grant_quantity:
INSERT patient_service_plans
(cloned from inclusion.service_plan_id template,
source_tier_subscription_id = sub.id,
access window aligned to sub.current_period_*)
on patient_subscription status transition to 'canceled' | 'expired' | 'past_due':
UPDATE patient_service_plans
SET status = 'expired'
WHERE source_tier_subscription_id = sub.id AND status = 'active'
-- soft expire only (CLAUDE.md: never hard-delete patient records;
-- consumed sessions remain visible as historical record)
on patient_subscription period rollover (current_period_starts_at advances):
for each patient_tier_inclusions row of sub.tier_id where grant_period = 'per_subscription_period':
if carry_over_unused = FALSE:
expire previous period's patient_service_plans (status := 'expired')
clone fresh patient_service_plans (as on subscription start)
on patient_subscription tier_id change (tier upgrade/downgrade mid-period):
expire patient_service_plans where source_tier_subscription_id = sub.id
re-run provisioning for the new tier:
for inclusions where prorate_on_upgrade = FALSE: full grant
for inclusions where prorate_on_upgrade = TRUE: scaled by remaining period fractionThis hook runs atomically with the subscription state change and audit-logs via the standard P10 path. There is no parallel role-binding hook — patients have no role at the org, so subscription state changes never touch RBAC.
Layer 3 dependency: patient_service_plans.source_tier_subscription_id. When patient_service_plans is fleshed out at Layer 3, it must include a nullable FK column source_tier_subscription_id UUID NULL REFERENCES patient_subscriptions(id). NULL = patient purchased directly (the existing flow described in Service Catalog); set = auto-granted by a tier subscription. The booking-against-plan logic doesn't differentiate; the column exists for revocation and audit traceability. To be added to data-model.md Area 3 in the Pass 2 propagation.
Counter semantics for non-session entitlements. Today service_plans tracks sessions_total (consumed by appointment bookings). For an inclusion like "4 specialist-assigned treatment plans included," the counter measures treatment-plan assignments, not appointment sessions. This requires a small extension to service_plans at Layer 3 — either a parallel counter column (treatment_plan_assignments_total INT NULL) or a more general entitlement-type abstraction. Not a Layer 1 problem; flagged here so the data-model reconciliation pass picks it up. Until that extension lands, tier inclusions can be modeled for any entitlement that maps to existing service_plans.sessions_total semantics (consultations, therapy sessions, evaluations, etc.).
Example. A clinic on Pro + Pachet Telereabilitare defines an Annual Premium tier with two inclusions:
inclusion 1:service_plan= "4 consultations / 12 months" (session-based,sessions_total=4,validity_days=365),grant_period = per_subscription_period,grant_quantity = 1,carry_over_unused = FALSE.inclusion 2:service_plan= "4 specialist treatment-plan assignments / 12 months" (uses the future Layer 3 counter extension), same config.
When Maria subscribes to Annual Premium:
- 2
patient_service_plansrows auto-created, each withsource_tier_subscription_id = Maria's tier sub. - Maria books a free consultation → existing booking flow finds the active
patient_service_plansand decrements itssessions_completed. - 12 months later, Maria's tier renews (
current_period_starts_atadvances) → service hook expires the old period's rows, regrants fresh 4+4 for the new period. - Maria upgrades to a hypothetical higher tier mid-period → old inclusions expire, new tier's inclusions are full-granted (default
prorate_on_upgrade = FALSE).
Money flow today vs. future
Today (Layer 1+, clinic-owned billing): payment_provider = 'external'. The clinic charges patients out-of-band. The clinic's admin or customer_support staff updates patient_subscriptions via Console/Clinic UI when payment is received. Or — for clinics with their own billing system — a webhook into POST /v1/orgs/{orgId}/patients/{patientId}/subscription flips state programmatically. We never see the money.
Future (platform-mediated, Stripe Connect): A new billing domain wires up Stripe Connect. patient_subscriptions.payment_provider flips to 'stripe_connect', external_subscription_id holds the Stripe sub ID, status changes are driven by Stripe webhooks. The schema reservations made now (payment_provider, external_subscription_id) ensure no migration of patient_subscriptions itself when this lands — the additive surface is the new billing tables (invoices, payment methods, payouts, tax records). That decision is its own design moment, not part of this doc.
Portal sign-up integration (post-1.26)
Portal onboarding goes through the portalonboarding domain — POST /v1/portal/onboard. The endpoint is mounted outside OrganizationContext (a fresh human with no patients row would otherwise 403 the gate) and provisions the full chain in one AdminPool transaction:
on portal sign-up for org O:
INSERT INTO patient_profiles (human_id, ...)
INSERT INTO patients (organization_id, patient_profile_id, ...)
-- Resolve default tier directly; no organization_settings.default_signup_role_id indirection.
SELECT id, version FROM patient_tiers
WHERE organization_id = O AND is_default = TRUE AND is_active = TRUE
-- tier_version snapshots patient_tiers.version at subscribe time (data-model.md Area 16).
INSERT INTO patient_subscriptions
(organization_id, patient_id, tier_id, tier_version, status='active', ...)
-- Snapshot from the active version of the tier.
INSERT INTO patient_subscription_entitlements SELECT ... FROM patient_tier_entitlements WHERE tier_id = ...
INSERT INTO patient_subscription_limits SELECT ... FROM patient_tier_limits WHERE tier_id = ...Idempotency: re-onboarding the same human at the same org returns the existing chain (200, no audit). The non-idempotent path emits three audit rows (one per row created).
No organization_memberships row is ever created for patients. Portal session resolution in OrganizationContext reads Subject.PatientOrgIDs (loaded alongside Memberships at sign-in) and grants org scope without a staff membership row, setting IsPatientSession = true. Staff role + permissions loading is skipped on the patient path; org-entitlements are still loaded for portal entitlement gating.
Usage metering (deferred)
For soft_meter and metered limits (e.g. video_minutes_per_month), we need an append-only event stream and a materialized counter for fast reads.
usage_events (deferred)
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK | |
subscription_id | UUID FK NULL | Resolved at write time; NULL if pre-resolution. |
limit_code | TEXT NOT NULL | |
delta | BIGINT NOT NULL | Usually 1 for counts, N for bytes/minutes. |
entity_type, entity_id | TEXT, UUID NULL | What caused the meter tick (appointment, document, etc.). |
actor_principal_id | UUID FK NULL | FK to principals(id). NULL when the meter tick is system-driven (no actor — e.g., scheduled job, retention sweep). |
request_id | UUID NULL | Correlation. |
created_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() |
Append-only (P14a). RLS: SELECT for usage.view_org. INSERT only via the system-level metering hook.
usage_counters (deferred)
Materialized current-period counters for fast EnforceLimit reads.
| Column | Type | Notes |
|---|---|---|
subscription_id | UUID FK | |
limit_code | TEXT NOT NULL | |
period_starts_at | TIMESTAMPTZ NOT NULL | |
period_ends_at | TIMESTAMPTZ NULL | NULL for lifetime period_kind. |
current_value | BIGINT NOT NULL DEFAULT 0 | |
updated_at | TIMESTAMPTZ | |
| PK | (subscription_id, limit_code, period_starts_at) |
Maintained by a background reducer over usage_events. For hard_block limits, EnforceLimit reads current_value synchronously and rejects 402 if current_value + delta > cap_value. For soft_meter, EnforceLimit always allows and emits the event.
Layer 1 status: these tables are documented here for forward design but not shipped at Layer 1. They land when the first soft-metered limit ships (likely with video consultations or storage at later layers). The catalog tables (limit_definitions) are shipped at Layer 1 so that future caps slot in cleanly.
Over-cap state
When an org downgrades and falls below an existing usage level (e.g. tier downgrade lowers max_patients from 1000 to 100, but the org has 600 active patients):
hard_blockbehavior: existing rows are not retroactively deleted. The cap blocks futureINSERTs. The org is "over-cap" until they upgrade or delete patients. Surface this state in admin UI as a warning.soft_meterbehavior: same — existing rows persist, future events still meter.
The platform never deletes user data because of a billing change. CLAUDE.md ("soft delete only — never hard-delete patient records") forbids it anyway. This is a product/UX problem, not a schema problem.
Layer 1 reservation
What this design lands at Layer 1 (the foundation), and what is deferred to later layers because the schema FKs to entities introduced there. Cross-reference: implementation-plan.md sequences the work; this section catalogs the dependencies.
Lands at Layer 1 (foundation) — owned by 1.19, 1.20, 1.21:
- Tables:
tiers,tier_versions,entitlements,limit_definitions,tier_entitlements,tier_limits,organization_subscriptions,organization_subscription_entitlements,organization_subscription_limits,organization_subscription_overrides,patient_tiers. - Org-side companion tables (1.19):
organization_settings,organization_billing,organization_entitlements. See org-settings.md. - Catalog seed migration: an initial set of tiers (
free,pro,dedicated), an initial set of entitlements (the regulated four matchingorganization_entitlementscolumns plus a few non-regulated ones), an initial set of limit definitions (max_patients,max_specialists,max_storage_bytes). - Forward-compat columns:
payment_providerandexternal_subscription_idonorganization_subscriptions. - Permissions seeded:
subscriptions.view_org,subscriptions.manage,patient_tiers.manage(plus theorganizations.update_settings,organizations.manage_billingpermissions covered in org-settings.md). Granted toadmintemplate. - RLS policies on every Layer 1 table per the rules above.
current_app_has_org_entitlement(entitlement_code TEXT)SQL function (also covered in org-settings.md).- Initial
organization_subscriptionsrow created when an org is provisioned: a singlefreebase subscription, statusactive, with snapshots of entitlements and limits. This makes the resolver always return a defined answer (no "no subscription" edge case). - Initial default
patient_tiersrow created when an org is provisioned. Post-1.26 the tier carries norole_id— patients are not memberships, and the org-creation trigger no longer touchesorganization_settings.default_signup_role_id(column dropped in 1.26).
Lands at Layer 2 — alongside patients (owned by Layer 2.5):
patient_subscriptions.patient_id FKs to patients(id) introduced in Layer 2.2, so the table cannot physically exist before Layer 2.
- Tables:
patient_subscriptions+ snapshot tablespatient_subscription_entitlements,patient_subscription_limits,patient_subscription_overrides(mirrors of the org-side billing engine, withpayment_providerandexternal_subscription_idforward-compat columns on the parent). - Permissions seeded:
patient_subscriptions.view_org,patient_subscriptions.manage. Granted toadminandcustomer_supporttemplates. - RLS policies per the rules above.
- Portal sign-up integration:
POST /v1/portal/onboardprovisionspatient_profiles+patients+patient_subscriptions(with snapshots) in one AdminPool transaction. No tier→role binding — patients have no role at the org.
Lands at Layer 3 — alongside service_plans (owned by Layer 3.2):
patient_tier_inclusions.service_plan_id FKs to service_plans(id) introduced in Layer 3.2, so the table cannot physically exist before Layer 3.
- Table:
patient_tier_inclusions. - RLS gated by
patient_tiers.manage(already seeded at Layer 1). - Tier-inclusion provisioning service — the provision / revoke / rollover hook described in Tier → entitlement provisioning.
patient_service_plans.source_tier_subscription_idFK column — to be added to data-model.md Area 3 in the Pass 2 propagation.service_planscounter extension for non-session entitlements (e.g.treatment_plan_assignments_total) — designed whenservice_plansis fleshed out for first-class implementation.
Deferred (independent of People / Service Catalog timing):
RequireTierEntitlement,EnforceLimit,RequireOrgEntitlementmiddleware — designed in middleware-composition.md; shipped when the first entitlement/limit-gated endpoint lands. Layer 1 ships them as callable infrastructure with stub resolution (1.22).usage_eventsandusage_counterstables — ship with the first soft-metered limit.- Entitlement projection service (
SubscriptionService.RecomputeOrgEntitlements) — can ship at Layer 1 as a no-op (no regulated entitlements in any tier yet) and be activated when the first regulated entitlement appears in a tier. Until then,organization_entitlementsis superadmin-managed only. - Console UI for managing subscriptions, overrides, entitlements, and patient-tier catalog — Layer 1 (1.13). Console UI for
patient_subscriptionslifecycle — Layer 2.5+. - External billing wire-up (Stripe / Chargebee for the platform side) — its own decision moment, not Layer 1.
- Stripe Connect for patient subscriptions — its own decision moment.
Stub semantics by phase.
- Layer 1. Schema structurally complete on the org side: every org has a subscription, every subscription has snapshots, every org has a default tier. No request is gated on entitlements/limits yet because no
RequireTierEntitlementcalls exist. The schema is the contract; the gating is incremental. - Layer 2.5.
patient_subscriptionsbecomes live and is provisioned byPOST /v1/portal/onboardalongside the patient row. No tier→role binding (patients have no role). Patient-tier inclusions still don't fire (table not yet created). - Layer 3.2.
patient_tier_inclusionslands; the provisioning hook becomes live, projecting tier subscriptions ontopatient_service_plans. Counter extensions onservice_plansallow non-session entitlements (treatment-plan assignments) to be metered.
Open questions
| Question | Decided in |
|---|---|
Manual entitlement lock — should organization_entitlements carry a manually_locked_<entitlement> flag so superadmin disables aren't overwritten by the tier engine projection? | Before the tier engine projection ships |
Tier migration on version bump — when a customer is on pro v3 and we publish pro v4, do we offer one-click upgrade, force-upgrade at renewal, or hold them on v3 indefinitely? | Before the first tier version bump in production |
Trial mechanics — is trialing per-subscription, per-org-lifetime ("one trial per org"), or per-tier ("one trial per tier code")? Affects schema (where trial_used_at lives). | Before self-service signup ships |
| Add-on cancellation atomicity — if a base tier is canceled, do active add-ons cancel too, or persist as orphans? | Before sales motion uses add-ons |
| Currency consolidation — can an org hold subscriptions in mixed currencies (RON base + EUR add-on)? Probably no, but call it out. | Before second-currency support lands |
organization_subscription_overrides audit — should overrides have their own audit_log.action_context = 'override' or fold into the standard mutation log? | First override grant in production |