Skip to content

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 / EnforceLimit compose with RequirePermission), 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

  1. Tiers never grant permissions. Tiers select entitlements and limits; RBAC decides who can do what. A tier flips a flag (automations enabled), an org admin grants a role the automations.manage permission. The two are orthogonal and stay orthogonal.
  2. 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.
  3. 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.
  4. 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.
  5. Per-subscription overrides. Sales motion can grant exceptions on top of the snapshot — recorded as audited rows, not direct edits to the snapshot.
  6. No hardcoded tier strings. Tiers are referenced by tier_id (UUID) or code (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.
  7. 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 the entitlements / limit_definitions catalogs with the org-side billing engine. Patients have no role at the org — portal access comes from the existence of a patients row, 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:

ConceptQuestionWhere definedWhere enforcedDoc
PermissionIs the verb allowed for this caller?permissions (catalog) + roles/role_permissionsRequirePermission middleware + RLSrbac-permissions.md
TierEntitlementIs this entitlement included in the org's SKU?entitlements (catalog) + tier_entitlementsorganization_subscription_entitlementsRequireTierEntitlement middlewarethis doc
OrgEntitlementIs this regulated boundary open for this org?organization_entitlements (typed columns)RequireOrgEntitlement / current_app_has_org_entitlementorg-settings.md + this doc
LimitIs the meter under cap for this resource?limit_definitions (catalog) + tier_limitsorganization_subscription_limitsEnforceLimit middleware + usage_eventsthis 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).

ColumnTypeNotes
idUUID PK
codeTEXT NOT NULL UNIQUEe.g. free, pro, dedicated, addon_telerehab, pack_video_minutes_1000. Stable, used by migrations and external billing system.
nameTEXT NOT NULLDisplay name.
descriptionTEXT
kindTEXT NOT NULL`base
billing_cycleTEXT NULL`monthly
base_priceDECIMAL(10,2) NULLInformational; canonical price comes from the external billing system once wired.
currencyTEXT NOT NULL DEFAULT 'RON'(P22)
is_publicBOOLEAN NOT NULL DEFAULT FALSETRUE ⇒ appears in self-service signup. FALSE ⇒ sales-only, custom tiers.
versionINT NOT NULL DEFAULT 1Bumped on any entitlement/limit edit.
publishedBOOLEAN NOT NULL DEFAULT FALSEOnly published versions can be subscribed to.
published_atTIMESTAMPTZ NULL
deprecated_atTIMESTAMPTZ NULLWhen set, prevents new signups; existing subscribers continue.
translationsJSONB NOT NULL DEFAULT '{}'(P21) — for name/description localization.
created_at, updated_atTIMESTAMPTZ

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.

ColumnTypeNotes
idUUID PK
tier_idUUID FK
versionINT NOT NULLMatches tiers.version at the moment of publish.
published_atTIMESTAMPTZ NOT NULL
entitlements_snapshotJSONB NOT NULLArray of entitlement codes enabled at this version.
limits_snapshotJSONB NOT NULLArray of {code, cap_value, behavior}.
metadata_snapshotJSONB NOT NULLFrozen {name, description, base_price, currency, billing_cycle}.
changed_by_principal_idUUID FK NULL → principals(id)Superadmin (human) who published this version. Human-only constraint enforced by platform_memberships, not by a CHECK here.
created_atTIMESTAMPTZ
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.

ColumnTypeNotes
codeTEXT PKe.g. custom_domain, automations, webhooks, bulk_export, api_access, treatment_plans, video_consultations, pose_estimation.
nameTEXT NOT NULLDisplay name for billing UI.
descriptionTEXT
regulatedBOOLEAN NOT NULL DEFAULT FALSETRUE ⇒ entitlement must be projected onto organization_entitlements when active. See Entitlement projection.
entitlement_columnTEXT NULLName 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_atTIMESTAMPTZ

RLS. SELECT for everyone. Writes superadmin-only via AdminPool.

limit_definitions

Catalog of every metered or capped resource.

ColumnTypeNotes
idUUID PK
codeTEXT NOT NULL UNIQUEe.g. max_patients, max_specialists, max_storage_bytes, max_active_treatment_plans, video_minutes_per_month.
nameTEXT NOT NULL
descriptionTEXT
unitTEXT NOT NULL`count
default_behaviorTEXT NOT NULL`hard_block
period_kindTEXT NOT NULL`lifetime
created_at, updated_atTIMESTAMPTZ

RLS. SELECT for everyone. Writes superadmin-only.

tier_entitlements

Which entitlements a tier unlocks.

ColumnTypeNotes
tier_idUUID FK
entitlement_codeTEXT NOT NULL FK → entitlements(code)Reference by code (not id) for migration ergonomics.
enabledBOOLEAN NOT NULL DEFAULT TRUEAllows a tier version to explicitly disable an entitlement without removing the row (audit trail across versions).
created_atTIMESTAMPTZ
PK(tier_id, entitlement_code)

tier_limits

What caps and meter behaviors a tier sets.

ColumnTypeNotes
tier_idUUID FK
limit_codeTEXT NOT NULL FK → limit_definitions(code)
cap_valueBIGINT NULLNumeric cap in the unit defined by limit_definitions.unit. NULL ⇒ unlimited (e.g. dedicated-mode tiers).
behavior_overrideTEXT NULLOverride limit_definitions.default_behavior if this tier should treat the resource differently. NULL ⇒ inherit.
created_atTIMESTAMPTZ
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_telerehab unlocks the regulated treatment_plans entitlement without changing the base tier.
  • usage_pack — a one-time purchase that increases the cap of one or more metered limits. Example: pack_video_minutes_1000 adds 1000 to the video_minutes_per_month cap 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.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
tier_idUUID FKPointer to the catalog tier.
tier_versionINT NOT NULLThe tier_versions.version snapshotted at subscribe time.
statusTEXT NOT NULL`trialing
started_atTIMESTAMPTZ NOT NULL
current_period_starts_atTIMESTAMPTZ NULLNULL for usage_pack (no period).
current_period_ends_atTIMESTAMPTZ NULL
cancel_atTIMESTAMPTZ NULLScheduled cancel — at period end if scheduled, immediate if NULL.
canceled_atTIMESTAMPTZ NULL
payment_providerTEXT NOT NULL DEFAULT 'manual'`manual
external_subscription_idTEXT NULLe.g. Stripe subscription ID. NULL until billing is wired.
created_at, updated_atTIMESTAMPTZ
Index(organization_id, status)Hot path: "active subscriptions for org X" on every gated request.

RLS.

  • SELECT: callers with subscriptions.view_org (granted to admin template).
  • INSERT/UPDATE/DELETE: callers with subscriptions.manage (granted to admin template). 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.

ColumnTypeNotes
subscription_idUUID FK
entitlement_codeTEXT NOT NULL
enabledBOOLEAN NOT NULL
created_atTIMESTAMPTZ
PK(subscription_id, entitlement_code)

RLS. Inherits from organization_subscriptions access.

organization_subscription_limits (snapshot)

Frozen at subscribe time.

ColumnTypeNotes
subscription_idUUID FK
limit_codeTEXT NOT NULL
cap_valueBIGINT NULLNULL = unlimited.
behaviorTEXT NOT NULLResolved from tier_limits.behavior_override ?? limit_definitions.default_behavior.
created_atTIMESTAMPTZ
PK(subscription_id, limit_code)

organization_subscription_overrides

Sales-granted exceptions on top of the snapshot. Audited.

ColumnTypeNotes
idUUID PK
subscription_idUUID FK
override_kindTEXT NOT NULL`entitlement
entitlement_codeTEXT NULLRequired when override_kind = 'entitlement'.
entitlement_enabledBOOLEAN NULLRequired when override_kind = 'entitlement'.
limit_codeTEXT NULLRequired when override_kind = 'limit'.
cap_valueBIGINT NULLNULL = unlimited (override).
behavior_overrideTEXT NULL
granted_by_principal_idUUID FK → principals(id)Superadmin (human) who granted. Human-only constraint enforced by platform_memberships, not by a CHECK here.
reasonTEXT NOT NULLAudit trail.
effective_fromTIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_atTIMESTAMPTZ NULLNULL = until subscription ends.
revoked_atTIMESTAMPTZ NULL
revoked_by_principal_idUUID FK NULL → principals(id)
created_atTIMESTAMPTZ
CHECKchk_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_block quotas (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 future soft_meter quota whose cap is a snapshot rather than a usage-pack addition.
  • For usage_pack purchases (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_subscriptions for orgId.
  • After any insert/update/delete on organization_subscription_overrides for any subscription of orgId.
  • 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) selects code, 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 on organization_entitlements. Called from every subscription mutation path (Create, UpdateStatus, GrantOverride, RevokeOverride).
  • Read (read side): Subject.HasOrgEntitlement(code) (services/api/internal/core/principal/subject.go) — code is an entitlements.code value; the lookup map is hydrated from organization_entitlements when OrganizationContext runs, 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.

ColumnTypeNotes
idUUID PK
organization_idUUID FK(P1)
codeTEXT NOT NULLOrg-defined, e.g. basic, premium.
nameTEXT NOT NULL
descriptionTEXT
is_activeBOOLEAN DEFAULT TRUE
is_defaultBOOLEAN DEFAULT FALSEExactly 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.
versionINT NOT NULL DEFAULT 1
publishedBOOLEAN NOT NULL DEFAULT FALSE
published_atTIMESTAMPTZ NULL
sort_orderINT DEFAULT 0
external_price_hintDECIMAL(10,2) NULLInformational only. Source of truth for pricing is the clinic's own billing system.
currencyTEXT DEFAULT 'RON'
created_at, updated_atTIMESTAMPTZ
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 to admin template).

patient_subscriptions

Per-org per-patient subscription to a tier.

ColumnTypeNotes
idUUID PK
organization_idUUID FK
patient_idUUID FKReferences patients.id (per-org), not patient_profiles — subscriptions are per-clinic.
tier_idUUID FK
statusTEXT NOT NULL`trialing
started_atTIMESTAMPTZ NOT NULLWhen the subscription first began (does not change across renewals).
current_period_starts_atTIMESTAMPTZ NULLStart of the current billing/grant period. Advances on each renewal. NULL for tiers without a recurring period (one-shot tiers).
current_period_ends_atTIMESTAMPTZ NULLEnd of the current period. Drives the tier-inclusions rollover hook.
expires_atTIMESTAMPTZ NULLHard expiry of the whole subscription regardless of renewal. NULL = renews indefinitely until canceled.
canceled_atTIMESTAMPTZ NULL
payment_providerTEXT NOT NULL DEFAULT 'external'`external
external_subscription_idTEXT NULLSet when payment_provider != 'external'.
created_at, updated_atTIMESTAMPTZ
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 via current_human_patient_profile_ids() join through patients.patient_profile_id.
  • INSERT/UPDATE/DELETE: callers with patient_subscriptions.manage (granted to admin template; 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.

ColumnTypeNotes
idUUID PK
organization_idUUID FK(P1)
tier_idUUID FK → patient_tiers(id)
service_plan_idUUID FK → service_plans(id)The template to clone into patient_service_plans when a tier subscription becomes active.
grant_periodTEXT NOT NULL`per_subscription_period
grant_quantityINT NOT NULL DEFAULT 1Usually 1 (one cloned patient_service_plan per inclusion). >1 if a tier intentionally grants multiple separate enrollments of the same template.
carry_over_unusedBOOLEAN NOT NULL DEFAULT FALSEWhen 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_upgradeBOOLEAN NOT NULL DEFAULT FALSEWhen 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_atTIMESTAMPTZ
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 fraction

This 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_plans rows auto-created, each with source_tier_subscription_id = Maria's tier sub.
  • Maria books a free consultation → existing booking flow finds the active patient_service_plans and decrements its sessions_completed.
  • 12 months later, Maria's tier renews (current_period_starts_at advances) → 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)

ColumnTypeNotes
idUUID PK
organization_idUUID FK
subscription_idUUID FK NULLResolved at write time; NULL if pre-resolution.
limit_codeTEXT NOT NULL
deltaBIGINT NOT NULLUsually 1 for counts, N for bytes/minutes.
entity_type, entity_idTEXT, UUID NULLWhat caused the meter tick (appointment, document, etc.).
actor_principal_idUUID FK NULLFK to principals(id). NULL when the meter tick is system-driven (no actor — e.g., scheduled job, retention sweep).
request_idUUID NULLCorrelation.
created_atTIMESTAMPTZ 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.

ColumnTypeNotes
subscription_idUUID FK
limit_codeTEXT NOT NULL
period_starts_atTIMESTAMPTZ NOT NULL
period_ends_atTIMESTAMPTZ NULLNULL for lifetime period_kind.
current_valueBIGINT NOT NULL DEFAULT 0
updated_atTIMESTAMPTZ
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_block behavior: existing rows are not retroactively deleted. The cap blocks future INSERTs. The org is "over-cap" until they upgrade or delete patients. Surface this state in admin UI as a warning.
  • soft_meter behavior: 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 matching organization_entitlements columns plus a few non-regulated ones), an initial set of limit definitions (max_patients, max_specialists, max_storage_bytes).
  • Forward-compat columns: payment_provider and external_subscription_id on organization_subscriptions.
  • Permissions seeded: subscriptions.view_org, subscriptions.manage, patient_tiers.manage (plus the organizations.update_settings, organizations.manage_billing permissions covered in org-settings.md). Granted to admin template.
  • 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_subscriptions row created when an org is provisioned: a single free base subscription, status active, with snapshots of entitlements and limits. This makes the resolver always return a defined answer (no "no subscription" edge case).
  • Initial default patient_tiers row created when an org is provisioned. Post-1.26 the tier carries no role_id — patients are not memberships, and the org-creation trigger no longer touches organization_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 tables patient_subscription_entitlements, patient_subscription_limits, patient_subscription_overrides (mirrors of the org-side billing engine, with payment_provider and external_subscription_id forward-compat columns on the parent).
  • Permissions seeded: patient_subscriptions.view_org, patient_subscriptions.manage. Granted to admin and customer_support templates.
  • RLS policies per the rules above.
  • Portal sign-up integration: POST /v1/portal/onboard provisions patient_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_id FK column — to be added to data-model.md Area 3 in the Pass 2 propagation.
  • service_plans counter extension for non-session entitlements (e.g. treatment_plan_assignments_total) — designed when service_plans is fleshed out for first-class implementation.

Deferred (independent of People / Service Catalog timing):

  • RequireTierEntitlement, EnforceLimit, RequireOrgEntitlement middleware — 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_events and usage_counters tables — 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_entitlements is superadmin-managed only.
  • Console UI for managing subscriptions, overrides, entitlements, and patient-tier catalog — Layer 1 (1.13). Console UI for patient_subscriptions lifecycle — 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 RequireTierEntitlement calls exist. The schema is the contract; the gating is incremental.
  • Layer 2.5. patient_subscriptions becomes live and is provisioned by POST /v1/portal/onboard alongside 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_inclusions lands; the provisioning hook becomes live, projecting tier subscriptions onto patient_service_plans. Counter extensions on service_plans allow non-session entitlements (treatment-plan assignments) to be metered.

Open questions

QuestionDecided 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