# Care-service tariff and access states without payments

The repository's architectural decision, rationale and consequences.

# Care-service tariff and access states without payments

**Status:** accepted · **Date:** 2026-10-31 · **Related:** [ADR-0004](0004-payments-providers.md) (payments providers), [ADR-0028](0028-immutable-clinical-records-and-privilege-matrix.md) (privilege matrix), [ADR-0031](0031-one-clinical-authorization-boundary.md) (clinical boundary)

**Enforcement:** `packages/api/src/features/org-settings/org-settings.rules.test.ts` (pure transitions and the policy schema), `packages/api/src/features/org-settings/org-settings.procedures.test.ts` (RN-01, RN-02, RN-03, RN-11, RN-06, RN-12, US-02, ROLES).

Payments stay disabled in v1 (coordinator decision, plan §1.5.1): a platform admin sets the tariff, and the product's trial / read-only / cancellation windows are modelled as **access states** on the `care_service` row of each Organization. The template keeps its User-scoped Entitlements for the admin dashboard; those are not used for care services, and the two systems do not meet.

## Decision [#decision]

**The access state is the tariff behaviour.** A `care_service` row carries `tariff` (`trial` by default), `access_state` and timed terminators (`trial_ends_at`, `read_only_until`, `cancel_effective_at`, `export_until`, `closed_at`). The clinical boundary (ADR-0031, ticket 17) reads them through its pure `evaluateAccess` and refuses writes in `read_only`, everything but exports in `export_only`, and everything in `closed`. Payments-adjacent fields (`stripeCustomerId`, Stripe webhook) are **not** written for care services.

**Trial → read_only → closed, active → export_only → closed.** The transitions (plan §1.5.1) are:

| From                | Trigger                           | To            | Writes                                         |
| ------------------- | --------------------------------- | ------------- | ---------------------------------------------- |
| `trial`             | `now >= trial_ends_at`            | `read_only`   | `read_only_until = trial_ends_at + 90 days`    |
| `read_only`         | `now >= read_only_until`          | `closed`      | `closed_at = now`                              |
| `trial`/`read_only` | platform admin sets a paid tariff | `active`      | clears `read_only_until`                       |
| `active`            | `now >= cancel_effective_at`      | `export_only` | `export_until = cancel_effective_at + 90 days` |
| `export_only`       | `now >= export_until`             | `closed`      | `closed_at = now`                              |

The transitions live as a pure function in `org-settings.rules.ts` (`nextAccessTransition`) and run once a day in the `accessStates` job (plan §1.9) under the system reason `wundklar.org-provision`. The job is idempotent: a row already in the target state is left untouched. A manual admin cutover ran out of band stays convergent because the next tick reads the current columns, not a timer. The job takes the tick's `jobs.tick` handle and clock from `platform/jobs/run.ts`, and every transition (plus provisioning and admin activation) appends a row to the append-only `care_service_access_event` history at its effective time; the clinical gate proves the 48-hour grace from it (ADR-0031). A new Organization also gets its data and intimate keys in the provisioning hook (`@wundklare/auth/org-provision`, bound to `ensureOrgKeys` by the Worker entry).

**Over-limit tracking (RN-03).** The job also recomputes active-wound usage per Organization (RN-02: wounds with `status='open'` AND an assessment in the last 30 days). The first scan over the tariff's limit sets `over_limit_since = now`; the clinical sync gate blocks `wound.create` after `over_limit_since + 14 days` with `PLAN_LIMIT_EXCEEDED`. Documentation on existing wounds continues throughout. A rescan below the limit clears `over_limit_since`.

**Tariff limits are config.** The plan §5 [TBD] values (Team 30, Facility 100, Trial and Network unlimited) live in `org-settings.rules.ts` as the `TARIFF_LIMITS` constant; a `care_service` row may override with its own `active_wound_limit`. Keeping them in the LEAF module next to the schema means the daily job, the usage read and the sync gate never drift.

**Provisioning in the auth hook.** `afterCreateOrganization` writes the `care_service` row inline under the system reason `wundklar.org-provision`: tariff `trial`, `trial_ends_at = now + 30 days`, `access_state = trial`, retention 10 years, `kind = ambulatory`. The write is `onConflictDoNothing` on the primary key so a replay or a hand-seeded org is a no-op. Ticket 18's `ensureOrgKeys` adds the ODK/OIK rows under the same system reason once the keys package lands.

**Role gating on procedures.** `orgSettings.adminSetTariff` runs on `adminProcedure` (platform admin only; the DB-authoritative role check in `requireAdmin`). `orgSettings.setPolicy` requires `owner` or `admin`. `orgSettings.acceptAvv` and `orgSettings.cancel` require `owner`. The service functions call `enqueueAudit` for `avv.accepted`, `policy.updated` and `access.changed`; the daily job writes `access.changed` for every scheduled transition.

**Policy schema.** `policySchema` in `org-settings.rules.ts` is a strict zod object with every field defaulted, so `policySchema.parse({})` returns the plan §1.5 defaults. `measureAid` is **not** a field (RN-11), so an input that carries it is refused at validation. `fullFrameRoles` is refined so only `wound_expert`, `owner` or `admin` may appear (RN-06).

## Consequences [#consequences]

- The template's `Entitlement` / `stripeCustomerId` paths stay as they are for the admin's own account; care services never touch them. Billing procedures remain in place for the User-scoped SaaS billing use case the template ships.
- A platform admin console calls `orgSettings.adminSetTariff` to flip an Organization to `team` or `facility` after an offline onboarding; later, when payments return, a new ADR will decide whether the state becomes derived from a Stripe subscription or stays authoritative here.
- Nothing in the system issues a hard shutdown for a `closed` Organization. The data stays encrypted and readable only via the retention job's export engine; a resumption is a product decision.
- The 48-hour write grace in `evaluateAccess` (ADR-0031) still applies: an offline write committed before a transition is accepted up to 48 hours after the cutover, so a nurse never loses finished work when her Organization moves to `read_only` overnight.

## Alternatives considered [#alternatives-considered]

- **Reuse `Entitlement` for care services.** Rejected: Entitlements are per-User (the template's SaaS billing model) and have no notion of a trial, a read-only window or cancellation effective dates. Overloading them would mean a second parallel schema with the same vocabulary but different semantics.
- **One state column instead of five timestamps.** Rejected: the gate's grace window (ADR-0031) needs the exact moment the Organization entered its current restricted state, and the daily job needs each timed terminator in a single read. A single column would need a side table or an audit trail scan.
- **Make the clinical gate call Stripe.** Rejected and reaffirmed: a payment event is an eventual input to the admin's tariff decision at best; the clinical path cannot block on an external HTTP call. The daily job is the convergence point.

