Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

Care-service tariff and access states without payments

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-10-31 · Related: ADR-0004 (payments providers), ADR-0028 (privilege matrix), ADR-0031 (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

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:

FromTriggerToWrites
trialnow >= trial_ends_atread_onlyread_only_until = trial_ends_at + 90 days
read_onlynow >= read_only_untilclosedclosed_at = now
trial/read_onlyplatform admin sets a paid tariffactiveclears read_only_until
activenow >= cancel_effective_atexport_onlyexport_until = cancel_effective_at + 90 days
export_onlynow >= export_untilclosedclosed_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

  • 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

  • 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.
Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.