---
url: /reference/SUBSCRIPTION_PLANS_CASE_MATRIX.md
---
# Subscription Plans Case Matrix

One row per behavioral case for every use case named in
[`SUBSCRIPTION_PLANS_USE_CASES.md`](SUBSCRIPTION_PLANS_USE_CASES.md). Each
case names the test that would prove it, quoted verbatim as a spec file
path, then the separator "›", then the exact test title, exactly like
[`ORGANIZATION_CASE_MATRIX.md`](ORGANIZATION_CASE_MATRIX.md) and
[`IDENTITY_CASE_MATRIX.md`](IDENTITY_CASE_MATRIX.md). A case with no
proving test answers `—` and is listed under [Gaps](#gaps), together with
the lowest layer that could prove it honestly.

**Group 0 (SP2, SP3, and SP5's entitlement window) shipped in baseline
revision 28** and is proven against real PostgreSQL by
`database/tests/prepare/prepare-database-contracts.ts`. NestJS application
code now exists for SP13 (staff actor), SP14 (public catalog), SP15
(plan/version/offering/market administration), SP16 (assign/change a
plan, extend a trial), SP17 (suspend/reactivate an organization), SP18
(the renewal lifecycle sweep job), SP19 (record a payment) and SP20
(idempotency and audit for staff commands), under `src/modules/platform/`.
The row counts in [Totals](#totals) below were recounted across SP1-SP20
and `npm run lint:case-matrix` now fails when they drift from the tables.

***

## Totals

| | |
|---|---:|
| Use cases | 20 (SP1 to SP20) |
| Per-case scenario rows | 210 |
| Per-case scenario rows proven | 143 (Group 0: SP2 seed shape, SP3 caller-scoped provisioning, SP5 trial-entitlement window; SP4 hardening rows; the shipped SP13 to SP20 rows over the database contracts and the e2e catalog) |
| Cross-tenant and concurrency invariants | 10 (1 proven) |
| Gaps | 76 |

***

## Plan catalog as data

### SP1 — Define and publish a plan

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: a draft plan version with attached capabilities/limits is published | `plan_versions.status = 'active'`, `published_at`/`effective_from` set | — |
| A second version of the same plan attempts `active` while the first is still active | Refused — `publish_plan_version_as_staff` raises `23514` named `plan_versions_another_version_active`; through the API `422 platform.plan_version_command_refused` with `That plan already has an active plan version.`, the second version stays `draft` and the Idempotency-Key stays free; `plan_versions_one_active_per_plan_uq` stays the backstop | `database/tests/concurrency/subscription-plans-unique-races-concurrency.ts` › `sequentialMistakesAreRefusals` (`NINAKU_SEQUENTIAL_PLAN_MISTAKES_REFUSED_OK`); `test/e2e/platform-admin-plan-unique-refusals.e2e-spec.ts` › `refuses publishing a second version while another is active with 422 and changes nothing` |
| Editing capabilities/limits of a version already `active` | Refused by `require_draft_plan_version` | — |
| Attaching a deprecated capability or limit definition to a draft version | Refused by `validate_plan_version_capability`/`validate_plan_version_limit` | — |
| Publishing an offering whose plan or plan version is not `active` | Refused by `validate_plan_offering` | — |
| Publishing an offering for a market that is not `beta`/`active`/`paused` | Refused by `validate_plan_offering` | — |
| A plan version retires while its offerings stay `active` | Not refused by any trigger today — a documented gap, not a passing case | — |

### SP2 — Seed the first real Plan and its Market Offerings

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: the seed migration produces active Colombia/Ecuador Markets, one active `pionero` plan version, and one active, priced Offering per Market with `trial_days = 30` | `platform.provision_organization_for_caller`-shaped calls can reference a real `plan_offering_id` per country | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeProvisionsItselfPerCountry` (CO and EC) |
| `trial_days` outside `0`–`365` | Refused by `plan_offerings_trial_days_chk` | — |
| Active market missing default locale/currency | Refused by `markets_operational_defaults_chk` | — |
| A second Offering for the same Plan/Market is added without retiring the first | Not refused — an ops-discipline gap, not a schema guarantee | — |
| An organization attempts a second trial after its first subscription ends | Not refused by any schema object — bounded only by the cost of creating a second organization | — |

***

## Subscription lifecycle at provisioning

### SP3 — Start a subscription when provisioning an organization

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: `platform.provision_organization_for_caller` inserts a `trialing` subscription with `trial_ends_at = starts_at + trial_days` | `201 Created`, subscription row present, `ended_at IS NULL` | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeProvisionsItselfPerCountry` |
| Happy path, zero-trial offering | Subscription inserted `active` with a computed `current_period_end` | — |
| `ninaku_runtime` executes `platform.provision_organization` directly | Refused — `permission denied for function provision_organization` (already proven today; must keep passing unchanged) | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeCannotProvisionOrganizations` |
| `ninaku_runtime` executes `platform.provision_organization_for_caller` for itself | `200`/`201` — this is the new, intentionally reachable path | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeProvisionsItselfPerCountry` |
| Chosen plan offering belongs to another country's Market | Refused — `"Plan Offering does not belong to the requested country Market"` | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeRefusesAnOfferingFromAnotherCountry` |
| Chosen plan offering is not `active` | Refused before any insert | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeRefusesAnInactiveOffering` |
| Offering's availability window excludes `starts_at` | Refused before any insert | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeRefusesAnInactiveOffering` |
| Unknown country/currency/locale | Refused before any insert | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeRefusesAnUnknownCountry`/`...Currency`/`...Locale` |
| Country with no active Market | Refused before any insert | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeRefusesACountryWithNoMarket` |
| Owner identity is blocked, with no partial garbage left behind | Refused; no organization, membership, subscription or provisioning-request row survives | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeCannotProvisionForABlockedIdentity` |
| Caller cannot name another Identity or Role as owner | The function has no such parameter (structural) and the resulting organization's sole member is the caller (behavioral) | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeCannotNameAnotherOwner` |
| Two provisioning attempts with the same idempotency key and payload | Same `organization_id`, no duplicate subscription | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeProvisionsItselfPerCountry` |
| Same idempotency key, different payload | Refused — fingerprint mismatch | `database/tests/prepare/prepare-database-contracts.ts` › `organizationRuntimeProvisionsItselfPerCountry` |

***

## Reading a subscription

### SP4 — Read my organization's subscription and plan

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: member reads their organization's current subscription, plan name, version, and entitlement summary | `200 OK` | `test/e2e/my-subscription.e2e-spec.ts` › `answers the organization current plan, price, period and entitlements`; `src/modules/platform/infrastructure/subscription-reading/platform-my-subscription.repository.integration-spec.ts` › `returns the current plan, price, period and entitlements of the tenant organization`; `test/e2e/my-subscription.e2e-spec.ts` › `lets a member without any role read the subscription` |
| Caller's `X-Organization-Id` names a foreign organization | Never reaches this query — refused by the tenant-context guard (Organization O3) | `test/e2e/my-subscription.e2e-spec.ts` › `refuses every reason to lack a tenant context with the same body`; `src/modules/platform/infrastructure/subscription-reading/platform-my-subscription.repository.integration-spec.ts` › `never shows another organization's subscription` |
| Ended/cancelled subscription | Returned as historical fact, not an error | `src/modules/platform/infrastructure/subscription-reading/platform-my-subscription.repository.integration-spec.ts` › `returns an ended subscription as historical fact when nothing newer exists`; `src/modules/platform/infrastructure/subscription-reading/platform-my-subscription.repository.integration-spec.ts` › `shows the current row, not the one a plan change ended` |
| `ninaku_runtime` attempts to join through to `platform.plans`/`plan_versions` today | Allowed — `SELECT` shipped in Group 0 (revision 28); the read model is `GET /subscription` | `src/modules/platform/infrastructure/subscription-reading/platform-my-subscription.repository.integration-spec.ts` › `returns the current plan, price, period and entitlements of the tenant organization` |
| ETag stability: two identical reads | Same body, same strong ETag (quoted SHA-256 of the body) | `test/e2e/my-subscription-etag.e2e-spec.ts › gives two identical reads the same body and the same ETag` |
| ETag changes when a limit override changes what the organization is held to | The old `If-None-Match` answers `200` with the new limit; the new tag answers `304` | `test/e2e/my-subscription-etag.e2e-spec.ts › changes the ETag when a limit override changes what the organization is held to, so the old tag no longer answers 304` |
| `If-None-Match` variants: `*`, a list holding the tag, a weak validator, an unquoted copy, another tag, a list of other tags, an empty value | `304` with no body and `Cache-Control: private, no-cache` for the first three; `200` with the body for the rest (Express freshness and the shared helper agree) | `test/e2e/my-subscription-etag.e2e-spec.ts › treats %s as If-None-Match and answers %i` |
| One organization's tag presented for another organization of the same caller | Never `304`; a different tag and a different plan | `test/e2e/my-subscription-etag.e2e-spec.ts › never lets one organization's tag answer 304 for another organization of the same caller` |
| A caller outside the organization presents the exact current tag or `*` | `403 · tenant.context_rejected`, never `304`, no `ETag` header, no plan data | `test/e2e/my-subscription-etag.e2e-spec.ts › answers a caller outside the organization 403 even when it presents the exact current tag or a wildcard` |
| A member of one organization names another organization's id | The same `403 · tenant.context_rejected` body as an unknown id, without the organization's id | `test/e2e/my-subscription-isolation.e2e-spec.ts › answers a member of one organization naming another's id exactly as it answers an unknown id` |
| Membership ended or suspended after the token was issued; organization suspended | `403 · tenant.context_rejected`, no data | `test/e2e/my-subscription-isolation.e2e-spec.ts › refuses a member whose membership became %s after the token was issued`; `test/e2e/my-subscription-isolation.e2e-spec.ts › refuses even the owner once the organization is suspended` |
| Only an ended subscription exists | `200` as history: `ended`, `endedAt` set, no capabilities, the last limit ceiling kept | `test/e2e/my-subscription-isolation.e2e-spec.ts › answers an ended subscription as history: ended, no entitlements, the last ceiling kept` |
| Trial whose `trial_ends_at` has passed | Capabilities are empty, the limits keep the ceiling they had | `test/e2e/my-subscription-isolation.e2e-spec.ts › drops the capabilities of a lapsed trial but keeps the limits it was held to` |
| Price values `0.1`, `19.99`, `0.000001`, `9999999999999.999999`, `59000` | A JSON string with six decimals (`0.100000`, `19.990000`, `0.000001`, `9999999999999.999999`, `59000.000000`), never a number | `test/e2e/my-subscription-isolation.e2e-spec.ts › answers the price %s as the exact decimal string %s and never as a number` |
| Reading never writes | No subscription, organization, membership or role assignment row is touched, and the read transaction never gets a transaction id | `test/e2e/my-subscription-isolation.e2e-spec.ts › reads without writing: no subscription, organization, membership or role assignment row is touched`; `src/modules/platform/infrastructure/subscription-reading/platform-my-subscription.repository.integration-spec.ts › reads inside a transaction that never wrote anything` |
| Twenty parallel reads while staff change the plan | Every answer is one complete snapshot, the old plan in full or the new plan in full, never a mix | `test/e2e/my-subscription-coherence.e2e-spec.ts › answers every one of 20 parallel reads as one complete snapshot, the old plan in full or the new plan in full` |
| Override windows: an override that expired, one that starts tomorrow, and an unlimited override | Expired and future overrides are ignored (the plan ceiling shows); an unlimited override shows no value and `isUnlimited: true` | `src/modules/platform/infrastructure/subscription-reading/platform-my-subscription.repository.integration-spec.ts › ignores a limit override that is %s and keeps the plan ceiling`; `src/modules/platform/infrastructure/subscription-reading/platform-my-subscription.repository.integration-spec.ts › answers an unlimited override as no value and unlimited` |

***

## Trial and lapse

### SP5 — Trial expiry: what the product does when it ends

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: `trial_ends_at` passes with no plan change | `platform.subscription_is_entitling` returns `true` before, `false` from that instant, with no application code involved | `database/tests/prepare/prepare-database-contracts.ts` › `organizationTrialEntitlementFollowsTrialEndsAt` |
| A capability-gated write attempted after trial expiry | Refused by `assert_operation_allowed` (`effective_capability` resolves `disabled`) | — |
| A permission-only operation (no capability check) attempted after trial expiry | Still succeeds — unaffected by entitlement lapse | — |
| Trial subscriber changes plan before `trial_ends_at` | No lapse ever observed; old row ends as part of the plan-change transition (SP6) | — |
| A `trialing` subscription attempts to hold a `grace_until` value | Refused by `subscriptions_grace_chk` — structurally impossible for a trial | — |
| Scheduled job detects a lapsed `trialing` row at `trial_ends_at` | Transitions to `past_due` (not `ended`), backfilling `current_period_start`/`current_period_end`/`grace_until` so the existing grace window applies — full rows under SP18 below | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP18-01 a lapsed trial moves to past_due with a 3-day grace backfilled onto its period` |

***

## Changing plan

### SP6 — Change plan (upgrade or downgrade)

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: caller with `organization.account.manage` moves to a new active offering | Old subscription `ended_at` set, new subscription inserted, exactly one current row | — |
| Concurrent plan-change commands on the same organization | Exactly one wins; the loser's conditional `UPDATE` affects zero rows and is refused, not retried into a duplicate | — |
| Downgrade below current usage of a limited resource (e.g., active memberships) | Downgrade succeeds; existing usage is grandfathered; the *next* admission against the new, lower limit fails at the owning domain's own check | — |
| Target offering's plan version is `retired` | Refused by `validate_subscription_creation`'s active-offering check | — |
| Target offering itself is `retired`/`paused`/`draft` | Refused by the same check | — |
| Caller lacks `organization.account.manage` | Refused by the permission check | — |
| Change to the exact same offering | Recommended no-op `200`, not a wasted end-and-reopen write | — |
| `ninaku_runtime` attempts a raw `INSERT`/`UPDATE` on `platform.subscriptions` today | Refused — no grant exists beyond `SELECT` | — |

### SP7 — Request cancellation of a subscription

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: immediate cancellation | Subscription `ended_at` set, `status = 'ended'`, no replacement row | — |
| Happy path: cancel at period end | `cancel_requested_at` set now, `cancel_at_period_end = true`; actual end deferred to `current_period_end` | — |
| `cancel_at_period_end = true` without `cancel_requested_at` set | Refused by `subscriptions_cancel_at_period_end_chk` | — |
| Cancelling an already-cancelled (pending) request | Idempotent, not an error | — |
| Immediate cancellation of a `trialing` subscription | Organization left with no current subscription; every capability-gated operation afterward fails closed the same way SP5 describes | — |
| Caller lacks `organization.account.manage` | Refused by the permission check | — |

***

## Entitlement contract — how other modules ask

### SP8 — Ask whether a capability is granted (`effective_capability`)

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: capability included in the current entitling subscription's plan version | `'enabled'` | — |
| Organization itself is not `active` | `'disabled'`, checked before the capability lookup | — |
| Unknown capability code | `'disabled'`, not an error | — |
| Market-scoped capability with no market support row, or a `paused`/`retired` one | `'disabled'`, independent of plan inclusion | — |
| Explicit `deny` override present | `'disabled'`, overriding an otherwise-included capability | — |
| Explicit `grant` override present, no entitling subscription | `'enabled'` — a grant does not require a subscription | — |
| No entitling subscription, no override, capability included in plan | `'disabled'` | — |

### SP9 — Ask a plan limit (`effective_limit`)

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: no override, current subscription's plan defines the limit | Returns `{ limitValue, isUnlimited: false, limitSource: 'plan' }` | — |
| Valid, currently-effective override present | Returns the override's value, `limitSource: 'override'`, regardless of the plan | — |
| Limit code never defined | No row returned; caller decides how to treat "undefined" | — |
| Subscription ended, no override | Still returns the most recently ended subscription's plan limit, `limitSource: 'plan'` | — |
| `identity.assert_membership_seat_available` calling this for `identity.active_memberships` | Refuses the (N+1)th active membership once `count(*) > limit_value` | — |

### SP10 — Authorize an operation combining permission and capability (`assert_operation_allowed`)

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: permission and capability both granted | Succeeds | — |
| Permission present, capability absent | Refused, distinctly from a plain permission refusal | — |
| Capability present, permission absent | Refused | — |
| Target outlet inactive or has an inactive ancestor | Refused | — |
| Vertical named but not active for the resolved business unit | Refused | — |
| No active tenant context | Refused, `42501` | — |

***

## Versioning and grandfathering

### SP11 — Plan version changes after an organization has subscribed

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: `V1` retires while an organization still subscribes through an offering pointing at `V1` | Existing subscriber's entitlement is unaffected; `effective_capability`/`effective_limit` still resolve through the unchanged `plan_offering_id` | — |
| An offering's `plan_version_id` is attempted to change after leaving `draft` | Refused by `protect_plan_offering_terms` | — |
| A new offering under `V2` is created for the same plan/market | Coexists with the old offering under `V1`; no organization is moved without an explicit SP6 command | — |

***

## Out of scope

### SP12 — Billing and payment collection

| Case | Expected answer | Proving test |
|---|---|---|
| Any attempt to charge a card, call a payment processor, or reconcile a payment result through `platform.*` | No code path exists — explicitly deferred to `payments`/`integrations` | — |
| A paid (`amount > 0`) offering is created without a billing integration | Not refused by any trigger — `plan_offerings_amount_chk` only requires `amount >= 0`; nothing ties the offering to a payment capability | — |

***

## Platform administration and the super admin actor

### SP13 — Platform super admin as an actor

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: an identity with an active `platform.staff_members` row calls a staff-gated function | Succeeds past the staff check | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP13-01 an active staff grant is recognized` |
| Identity has no `platform.staff_members` row | Refused, `42501`, before any table is touched | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP13-03 an ordinary identity with no staff row at all is refused` |
| Staff row exists but `status = 'revoked'` | Refused — checked at call time, not at session-issue time | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP13-02 a revoked staff grant is refused, even naming its own identity` |
| An identity attempts to hold two simultaneously active staff grants | Refused — `staff_members_one_active_per_identity_uq` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP13-04 an identity cannot hold two simultaneously active staff grants` |
| An organization owner's `role_assignments` row is presented as if it were staff | Refused — the two tables share no column, no trigger, no function | — |
| `ninaku_runtime` attempts `INSERT` on `platform.staff_members` directly | Refused — no such grant is proposed; the first row is a migration insert by `ninaku_migrator` | — |

### SP14 — List plans available in a country before signup

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: an unauthenticated caller lists a country's plans | `200`, active plans/offerings for that market only | `test/e2e/plans-catalog.e2e-spec.ts` › `needs no session and shows the price for the chosen country`; `src/modules/platform/application/plan-catalog/list-public-plans.use-case.integration-spec.ts` › `shows the price seeded for the offering in the chosen country` |
| Unknown or unsupported country code | Empty list, not an error | `src/modules/platform/application/plan-catalog/list-public-plans.use-case.integration-spec.ts` › `returns an empty catalog for a country with no active Market` |
| Market exists but is `planned`/`paused`/`retired` | Empty list | — |
| A plan version is `active` with zero `active` offerings anywhere | Plan omitted from the response | — |
| A `draft`/`paused`/`retired` offering, plan, or version | Never returned, at any join | — |

### SP15 — Create and manage plans, versions, prices, and markets

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: staff creates a plan, drafts a version, publishes it | `plans`/`plan_versions` rows `active` as expected | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-06 staff creates a plan`, `SP15-07 staff drafts a plan version`, `SP15-09 publishing succeeds once every active Limit Definition is declared` |
| A draft version missing an active Limit Definition attempts to publish | Refused — every active Limit Definition must be declared first | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-08 a draft version missing an active Limit Definition cannot publish` |
| Two staff calls race to publish two versions of the same plan `active` | Exactly one wins; the loser blocks on the Plan row lock and is then refused with `Plan already has an active Plan Version`, never a unique violation; one version `active`, the other still `draft` | `database/tests/concurrency/subscription-plans-unique-races-concurrency.ts` › `concurrentPublishesOfOnePlan` (`NINAKU_CONCURRENT_PUBLISHES_ONE_ACTIVE_OK`) |
| Two staff calls race to draft a version of the same plan | Both succeed with distinct numbers (`1,2`): the second blocks on the Plan row lock before it computes `MAX(version_number)+1` | `database/tests/concurrency/subscription-plans-unique-races-concurrency.ts` › `concurrentDraftsForOnePlan` (`NINAKU_CONCURRENT_DRAFTS_DISTINCT_NUMBERS_OK`) |
| Staff creates a plan whose code already exists, in any letter case | `422 platform.plan_version_command_refused` with `A plan with that code already exists.`; no second plan is stored | `test/e2e/platform-admin-plan-unique-refusals.e2e-spec.ts` › `refuses a duplicate plan code with 422 and creates no second plan` |
| Staff retires a version with `active`/`paused` offerings | Version retires **and** every one of its offerings retires in the same transaction — closes SP1's documented cascade gap | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-10 staff retires a plan version`, `SP15-11 retiring a plan version cascades to retire its offerings atomically` |
| Staff retires a version with zero offerings | The cascading `UPDATE` affects zero rows; not an error | — |
| Staff attempts to change an existing active offering's `amount` directly | Refused — `protect_plan_offering_terms` | — |
| Staff adds a new offering for the same plan version/market instead | Succeeds — `INSERT`, never `UPDATE` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-12 staff drafts an offering` |
| Staff creates an offering for a market, a currency or a target organization that does not exist | `422 platform.plan_offering_command_refused` whose detail names only the missing reference (`That market does not exist.`, `That currency does not exist.`, `The target organization does not exist.`); no offering is stored and no constraint or SQL text is exposed. The repository maps each foreign key by its constraint name (`plan_offerings_market_fk`, `plan_offerings_currency_fk`, `plan_offerings_target_organization_fk`), never by the SQLSTATE class; the market and the plan version are named by `validate_plan_offering` itself (`CONSTRAINT` added in migration `0042`) | `test/e2e/platform-admin-offering-references.e2e-spec.ts › refuses an unknown $reference with 422 and a safe reason, and stores no offering`; `src/modules/platform/infrastructure/plan-administration/platform-plan-administration.repository.spec.ts › refuses a missing reference named $constraint with its own reason and reveals nothing else`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-12b an unknown Market names its foreign key`, `SP15-12c an unknown currency names its foreign key`, `SP15-12d an unknown target organization names its foreign key` |
| Staff creates an offering under a plan version that does not exist | `404 platform.plan_offering_not_found`; no offering is stored. Publishing and withdrawing an offering keep answering `404` for any foreign key violation | `test/e2e/platform-admin-offering-references.e2e-spec.ts › answers an unknown plan version with 404 and stores no offering`; `src/modules/platform/infrastructure/plan-administration/platform-plan-administration.repository.spec.ts › keeps an unknown plan version a not found`; `src/modules/platform/infrastructure/plan-administration/platform-plan-administration.repository.spec.ts › keeps any foreign key violation a not found, whatever the constraint`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-12a an unknown Plan Version names its foreign key` |
| Staff publishes a draft offering | `draft → active`, sellable from `available_from` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-13 staff publishes the offering` |
| Staff withdraws (pauses) an `active` offering | `active → paused`, allowed by `protect_plan_offering_terms` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-14 staff pauses (not retires) a withdrawn offering by default` |
| Staff withdraws (retires) a `paused` offering | `paused → retired`, allowed | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-15 staff retires the offering for good` |
| Staff opens a market with `status = 'active'` and no default locale/currency | Refused — `markets_operational_defaults_chk` | — |
| Staff opens a market for a country that already has one, or two staff race to open the same new market concurrently | Idempotent — `open_market_as_staff` is a single atomic `INSERT ... ON CONFLICT DO UPDATE`; both callers converge on the same row, never a `markets_country_uq` violation | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-17 staff re-opens an already-open market idempotently, updating it in place`; `database/tests/concurrency/subscription-plans-concurrency.ts` › `marketOpeningRace` (`NINAKU_MARKET_OPENING_CONCURRENCY_OK`) |
| Staff publishes an offering whose plan version retired between the draft and publish calls, including the exact race where the retire commits while the publish is waiting on the same row | Refused — `publish_plan_offering_as_staff` locks the Plan Version `FOR SHARE`, blocking on a concurrent retire and re-reading its committed status; `validate_plan_offering`'s active-plan/version check is the same-transaction backstop | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-18 an offering cannot publish once its Plan Version is retired`; `database/tests/concurrency/subscription-plans-concurrency.ts` › `publishOfferingVersusRetireVersionRace` (`NINAKU_PUBLISH_OFFERING_VERSUS_RETIRE_VERSION_CONCURRENCY_OK`) |
| An offering `amount` at or beyond the `numeric(19,6)` boundary | Accepted up to 13 integer digits and 6 decimals; refused with `422` beyond that, never a raw database overflow error | `src/modules/platform/presentation/dtos/create-plan-offering-request.schema.spec.ts` |
| A non-staff caller invokes any of the eleven functions above | Refused, `42501` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP15-05 a non-staff identity cannot create a plan`, `SP15-16 a non-staff identity cannot open a market` |

### SP16 — Assign a plan, extend a trial, or change an organization's plan (staff override)

Shipped. `platform.assign_plan_as_staff`, `platform.change_subscription_plan_as_staff` and `platform.extend_subscription_trial_as_staff` share `platform.assert_offering_assignable_to_organization`, which closes the country-mismatch gap this row used to flag as an open question: the target Offering's Market must match `platform.organization_country_code(organization_id)` (the country of the Organization's most recent Subscription — `organization.organizations` itself carries no country column), a private Offering with a `target_organization_id` must name this exact Organization, and a trial Offering (`trial_days > 0`) is refused outright on the staff path (a trial is a self-service acquisition benefit; there is no owner identity here to attribute `identity_trial_grants` to).

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: staff assigns a plan to an organization with no current Subscription | `INSERT`, guarded by `validate_subscription_creation` exactly as SP3's insert is | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-01 staff assigns a plan to an organization with no current Subscription` |
| Happy path: staff changes an organization's plan to a new active Offering | Old Subscription ends, new one is inserted, exactly one current row | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-07 staff changes an organization plan, ending the old Subscription and starting a new one` |
| Happy path: staff extends a `trialing` Subscription's `trial_ends_at` forward | Updated; `subscriptions_trial_chk` holds | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-08 staff extends a trialing Subscription forward` |
| Staff attempts to extend a trial that is not currently `trialing`, or attempts to move `trial_ends_at` backward/sideways | Refused as a no-op — the function's own `WHERE status='trialing' AND p_new_trial_ends_at > trial_ends_at` matches zero rows | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-09 extending a trial backward or sideways is refused as a no-op` |
| Staff assigns or changes to a trial Offering (`trial_days > 0`) | Refused — a trial is a self-service acquisition benefit, never staff-assigned | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-02 staff cannot assign a trial Offering` |
| Staff assigns or changes to an Offering from a different country's Market than the organization's own | Refused — `platform.organization_country_code` mismatch | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-03 staff cannot assign an Offering from a different country than the organization own` |
| Staff assigns or changes to a private Offering targeted to a different Organization | Refused | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-04 staff cannot assign a private Offering targeted to a different organization` |
| Staff assigns a private Offering to its own declared target | Succeeds | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-04b a targeted private Offering assigns cleanly to its own target` |
| Staff assigns a plan to an organization that already has a current Subscription | Refused | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-05 assigning a plan is refused for an organization that already has a current Subscription` |
| Staff assigns or changes to a draft (unpublished) Offering | Refused — `validate_subscription_creation` requires an active Offering | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-06 assigning a draft (unpublished) Offering is refused` |
| Two staff members race to change the same organization's plan | Exactly one wins — same conditional-`UPDATE` signal as SP6 | — |
| Two staff calls with different Idempotency-Keys race to assign a plan to one organization that has no current Subscription | Exactly one creates the Subscription; the loser blocks on the Organization row lock and is refused with `Organization already has a current Subscription` (`422 platform.subscription_command_refused` through the API), never a unique violation | `database/tests/concurrency/subscription-plans-unique-races-concurrency.ts` › `concurrentAssignmentsForOneOrganization` (`NINAKU_CONCURRENT_ASSIGNMENTS_ONE_CURRENT_OK`); `test/e2e/platform-admin-plan-unique-refusals.e2e-spec.ts` › `refuses a second assignment to an organization that already has a current subscription` |
| Staff assigns a plan to an `archived` organization | Refused — `validate_subscription_creation`'s organization-status check | — |
| A non-staff caller invokes any of the three functions above | Refused, `42501` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP16-10a`/`SP16-10b`/`SP16-10c` |

### SP17 — Suspend and reactivate an organization

Archival, the one-way third transition, follows this same staff-command shape but is catalogued and proven under O6 in [`ORGANIZATION_CASE_MATRIX.md`](ORGANIZATION_CASE_MATRIX.md); an archived organization can no longer be reactivated by the staff command below, by a payment, or by the lifecycle sweep.

Shipped. `platform.suspend_organization_as_staff` closes the mid-provisioning gap this row used to flag as an open question: it refuses when the Organization has no `platform.subscriptions` row at all (every real Organization gets one atomically with `provision_organization`/`provision_organization_for_caller`, so a missing one means provisioning never finished). `platform.reactivate_organization_as_staff` adds no new trigger — D21's existing deferred check (`identity.assert_administration_preserved`, `0400_cross_domain_contracts.sql`) already fires on `organization.organizations` regardless of which caller ran the `UPDATE`, so the staff path inherits the same last-administrator guarantee O6 already proves for the member-authored path.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: staff suspends an `active` organization | `status = 'suspended'` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-01 staff suspends an active organization` |
| Happy path: staff reactivates a `suspended` organization that still has an active administrator | `status = 'active'` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-03 staff reactivates a suspended organization that still has its administrator` |
| Staff reactivates a suspended organization that lost its last administrator while suspended (D21 does not apply to a non-`active` organization, so the last role assignment can be removed while suspended) | Refused at commit — D21, `23514 · identity_last_administrator_required` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-04 reactivating a suspended organization that lost its last administrator is refused` |
| Staff suspends an organization mid-provisioning (no completed Subscription yet) | Refused — `platform.subscriptions` has no row for it yet | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-05 suspending an organization mid-provisioning is refused` |
| Staff suspends an already-`suspended` organization | Zero-row conditional `UPDATE`; refused as a no-op, not silently re-applied | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-02 suspending an already-suspended organization is refused as a no-op` |
| Staff suspends an `archived` organization | Zero-row conditional `UPDATE`; archived stays archived | — |
| Staff reactivates a `suspended` organization whose current Subscription is `suspended` | Refused (`23514`, `organization_reactivation_subscription_suspended`); the organization stays `suspended` with its source unchanged; a payment is the way to restore it | `database/tests/contracts/organization-suspension-source-contracts.ts` › `OSS-03 staff reactivation is refused while the subscription is suspended`; `database/tests/contracts/organization-suspension-source-contracts.ts` › `OSS-03b the refused reactivation leaves the organization suspended by the subscription` |
| The same refusal over HTTP | `422 · platform.organization_command_refused` with a safe body naming no constraint, one `denied` audit record with reason code `subscription_suspended`, the organization still `suspended`; a payment then restores both the Subscription and the organization | `test/e2e/platform-admin-organization-suspension-source.e2e-spec.ts` › `refuses to reactivate an organization whose subscription is suspended, audits the denial and lets a payment restore it`; `src/modules/platform/infrastructure/organization-administration/platform-organization-administration.repository.spec.ts` › `refuses a suspended subscription as a command refusal that names its reason` |
| Staff suspends, reactivates or archives an organization | Suspension records `suspension_source = 'staff'`; reactivation of a staff-suspended organization (Subscription `active`, or `past_due` inside its grace) and archival clear it | `database/tests/contracts/organization-suspension-source-contracts.ts` › `OSS-04 staff suspension records the staff source and reactivation of an active subscription clears it`; `database/tests/contracts/organization-suspension-source-contracts.ts` › `OSS-05 a subscription that still entitles, past_due inside its grace, does not block a staff reactivation`; `database/tests/contracts/organization-suspension-source-contracts.ts` › `OSS-06 archival clears the suspension source` |
| A suspend, reactivate or archive command meets a transaction holding the Subscription row | The command waits for the Subscription row before it touches the organization (the common lock order of the payment and the sweep) and then applies | `database/tests/concurrency/subscription-staff-commands-concurrency.ts` › `everyStaffCommandWaitsForTheSubscriptionRow` |
| Staff reactivation races a payment, in both orders | Reactivation after a payment on a `suspended` Subscription is refused as no longer suspended; reactivation and payment on a staff-suspended organization both apply in either order; always `active`/`active` with one payment and no deadlock | `database/tests/concurrency/subscription-staff-commands-concurrency.ts` › `reactivationWaitsForAPaymentOnASuspendedSubscription`; `database/tests/concurrency/subscription-staff-commands-concurrency.ts` › `reactivationAndPaymentOnAStaffSuspendedOrganization` |
| Staff suspension races a payment, and races the grace-expiry sweep, in both orders | A payment renews the Subscription and leaves the organization `suspended` with source `staff`; against the sweep the final state is `suspended`/`suspended` with source `subscription` when the sweep goes first (the suspension is refused as no longer active) and `staff` when the suspension goes first | `database/tests/concurrency/subscription-staff-commands-concurrency.ts` › `staffSuspensionVersusPayment`; `database/tests/concurrency/subscription-staff-commands-concurrency.ts` › `staffSuspensionVersusTheGraceExpirySweep` |
| A non-staff caller invokes suspend/reactivate | Refused, `42501` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP17-06a`/`SP17-06b` |
| Staff suspends an organization | `204`; the Audit Record `platform.organization.suspended` is sealed with the staff member's `reasonText` | `test/e2e/platform-admin-organization-lifecycle.e2e-spec.ts` › `suspends an organization and records who did it` |
| Staff reactivates a suspended organization | `204`; the Audit Record `platform.organization.reactivated` is sealed | `test/e2e/platform-admin-organization-lifecycle.e2e-spec.ts` › `reactivates a suspended organization and records who did it` |
| Staff suspends without a reason, or with an empty or blank one | `400`; nothing changes, because the action type requires a reason | `test/e2e/platform-admin-organization-lifecycle.e2e-spec.ts` › `refuses to suspend with a $label reason and leaves nothing behind` |
| A suspended organization's own member attempts to establish tenant context | Refused today — `identity.has_active_tenant_context()` requires `organization.status = 'active'` | — |

### SP20 — Idempotency-Key and audit for staff commands

Every staff write built so far (`create_plan`, `create_draft_plan_version`, `publish_plan_version`, `retire_plan_version`, `create_plan_offering`, `publish_plan_offering`, `withdraw_plan_offering`, `open_market`, `assign_plan`, `change_subscription_plan`, `extend_subscription_trial`, `suspend_organization`, `reactivate_organization`, `record_subscription_payment`) requires an `Idempotency-Key` header and records a platform Audit Record on the same transaction it executes in. SP16-SP19's five commands share this exact same `staff-command-idempotency.ts` helper and `PlatformAuditContract.record` call, proven the same way as the original eight.

| Case | Expected answer | Proving test |
|---|---|---|
| A brand new `Idempotency-Key` reserves the command | `reserved = true`; the command executes and its result is stored | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP20-01 a brand new idempotency key is reserved for its caller` |
| The same key and the same request body are retried | `200`/`201`/`204` with `Idempotent-Replayed: true` and the original result, never a second execution | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP20-02 the same key and fingerprint replays the completed result instead of executing again` |
| The same key is retried with a different request body | `409 idempotency.key_reused`, the original request's fingerprint is what is compared, not the new one | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP20-03 the same key with a different fingerprint still reports the ORIGINAL fingerprint, not the new one` |
| The same key and request body are reused for a different command (for example a suspension key sent to the archival route, whose payload fingerprint is identical) | `409 idempotency.key_reused`; the second command does not execute, no stored result is replayed and no archive Audit Record is written. Defended twice: `platform.reserve_staff_command_idempotency` answers a foreign command with no stored fingerprint and no result, and `claimStaffCommand` binds the command name into the fingerprint it reserves and compares | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP20-04 the same key and fingerprint for a DIFFERENT command is a mismatch and never returns the stored result`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP20-05 a foreign command reusing the key leaves the original command able to replay its result`; `src/modules/platform/application/staff/staff-command-idempotency.spec.ts` › `refuses the same key and payload fingerprint for a different command with $description and does not run it`; `test/e2e/platform-admin-organization-archival-refusals.e2e-spec.ts` › `refuses an archival that reuses the key of a suspension with the same reason and neither archives nor audits` |
| Two callers race to claim the exact same brand-new key concurrently | Exactly one executes; the loser blocks on the winner's row lock and then replays its committed result — never a double execution, never a raw `23505` | `database/tests/concurrency/subscription-plans-concurrency.ts` › `staffCommandIdempotencyRace` (`NINAKU_STAFF_COMMAND_IDEMPOTENCY_CONCURRENCY_OK`) |
| A staff command is refused by the domain | Commands that opt in with `@AuditsRefusal` leave an Audit Record with the same action code, `outcome = denied` and a `reason_code`, written in a second transaction because the refusal rolls the first back. If that record cannot be written, the caller still gets the refusal and the gap is logged at error level (a refused attempt changed nothing; a success keeps failing closed because its record shares its transaction). Today only the security message reissue opts in | `src/modules/platform/presentation/audit/platform-refusal-audit.interceptor.spec.ts`; against the real database, `test/e2e/platform-admin-reissue-refusal-audit.e2e-spec.ts › records one denied attempt per refused request, so two refusals of the same message leave two records`; `test/e2e/platform-admin-reissue-refusal-audit.e2e-spec.ts › still answers the same refusal body whether or not the attempt was recorded`; `test/e2e/platform-admin-reissue-refusal-audit.e2e-spec.ts › writes no denied record when a non-staff caller is refused before the handler, and answers as it does for a message that does not exist`; `test/e2e/platform-admin-reissue-refusal-audit.e2e-spec.ts › writes no denied record when a staff member has no fresh elevation`; `test/e2e/platform-admin-reissue-refusal-audit.e2e-spec.ts › writes no denied record for a message that does not exist` |
| A staff command executes for the first time | A platform Audit Record is written in the same transaction, actor = the staff identity, target = the created/changed Plan, Plan Version, Offering or Market | `src/modules/platform/application/plan-administration/*.use-case.ts` (each calls `PlatformAuditContract.record` only on `IDEMPOTENT_OUTCOME.EXECUTED`) — proven end-to-end, one sealed record with the staff actor and the changed target per command (a market is upserted by country, so its record is matched by the staff actor), by `test/e2e/platform-admin-catalog-audit.e2e-spec.ts` (plan, version, offering and market commands), `test/e2e/platform-admin-subscription-audit.e2e-spec.ts` (assign, change, extend trial, record payment) and `test/e2e/platform-admin-organization-lifecycle.e2e-spec.ts` (suspend, reactivate) |
| A retried (replayed) staff command | No second Audit Record is written | Same use-case guard (`if outcome === EXECUTED`) — unit-level only, not re-proven with a dedicated integration test in this iteration (disclosed gap) |
| Void (`204`) routes (`publish`/`retire` version, `publish`/`withdraw` offering) on replay | `204` without a body and with `Idempotent-Replayed: true`; the first execution is `204` without the header and exactly one platform Audit Record exists after the replay. The routes return `HttpIdempotentResponse` with no data and the envelope interceptor answers it as a `204` | `test/e2e/platform-admin-catalog-replay-header.e2e-spec.ts › marks the replay of every 204 command of the plan catalog lifecycle and does nothing twice`; `src/foundation/http/envelope/http-envelope.interceptor.spec.ts › answers 204 with no envelope and sets Idempotent-Replayed when a replayed HttpIdempotentResponse carries no data` |

### Step-up (elevation) on money-changing commands

Shipped for the three commands that move or redirect money: `assign_plan`, `change_subscription_plan` and `record_subscription_payment`. Reuses 100% of Identity's existing elevation mechanism (`POST /step-ups`, `POST /step-ups/confirmations`, the stateless JWT `ElevationTokenPort`) rather than building a second one: `IdentitySessionContract` (Identity's own public cross-module contract, now reached through the foundation `SessionAuthenticator` port) gained one new method, `verifyElevation(elevationToken, caller)`, implemented by the same `AuthenticateCallerUseCase` that answers `SessionAuthenticator.authenticate()`. A new `PlatformElevationGuard` mirrors Identity's own `ElevationGuard` exactly, reading `X-Elevation-Token` and the caller the foundation `SessionGuard` already placed on the request, and is applied at the method level (never class level) on `assignments`, `plan-changes` and `payments`, never on `trial-extensions` (extending a trial moves no money). It is also applied, still at the method level, on the five plan administration commands that change what is sold, at what price and where: publishing a plan version, retiring a plan version, publishing an offering, withdrawing an offering and opening or updating a market. The routes that only create drafts (`POST /platform-admin/plans`, `.../plans/{planId}/versions` and `.../plan-versions/{versionId}/offerings`) stay open to a staff session without an elevation, because a draft is not sellable. Clients must now run a step-up before those five actions. The routes share one documentation decorator, `ApiPlatformElevation`, which declares the required `X-Elevation-Token` header and the `403` answer in the OpenAPI document. A staff identity signs in and steps up through the exact same Identity routes any tenant member uses — no new elevation route, no new token format, no duplicated verification logic — because an elevation token is bound only to `identityId`/`sessionId`, never to which guard authenticated the request.

| Case | Expected answer | Proving test |
|---|---|---|
| A staff caller with a live session but no elevation token calls a money-changing command | Refused, `403 platform.elevation_required` | `src/modules/platform/presentation/guards/platform-elevation.guard.spec.ts`; end-to-end: `test/e2e/platform-admin-elevation.e2e-spec.ts` › `refuses assigning a plan with no elevation token, then succeeds once elevated` |
| A staff caller completes step-up and presents a matching elevation token | Command executes | Same e2e test, second half of the same case |
| An elevation token minted for a different session (or a different identity) than the one calling now | Refused, `403 platform.elevation_required` | `authenticate-caller.use-case.spec.ts` (contract-level); `platform-elevation.guard.spec.ts` (guard-level); `test/e2e/platform-admin-elevation.e2e-spec.ts` › `refuses an elevation token minted for a different session than the one calling now` (end-to-end) |
| `PlatformElevationGuard` runs before the foundation `SessionGuard` populated a caller | Refused, `401 identity.session_required`, never an elevation-specific message | `platform-elevation.guard.spec.ts` |
| `extend_subscription_trial`, `suspend_organization`, `reactivate_organization` | No elevation required — none of these three moves money | Deliberate scope decision, not a gap |
| Staff price administration: publishing or retiring a plan version, publishing or withdrawing an offering, opening or updating a market | Elevation required, same guard as the money commands. Owner-delegated decision, reversible: a stolen staff access token could otherwise change what is sold, at what price and where | `test/e2e/platform-admin-catalog-elevation.e2e-spec.ts` (the rows below) |
| A staff caller with a session but no elevation token, with a token nobody minted, or with a token minted for another session calls one of those five commands | `403 platform.elevation_required`; the plan version, offering or market is unchanged, no idempotency record is spent and no Audit Record is written | `test/e2e/platform-admin-catalog-elevation.e2e-spec.ts` › `answers 403 platform.elevation_required and changes nothing when no elevation token is sent`, `answers the same 403 and changes nothing for an elevation token nobody minted`, `answers 403 and changes nothing for a token minted for another session` (each runs once per route: publish version, retire version, publish offering, withdraw offering, open market) |
| A staff caller with a live elevation withdraws an offering, retires a plan version with its offerings, or opens a market | Works as before: `204` / `204` / `200` | `test/e2e/platform-admin-catalog-elevation.e2e-spec.ts` › `withdraws an offering with a live elevation`, `retires a plan version and its offerings with a live elevation`, `opens a market with a live elevation`; publishing a plan version and an offering with an elevation: `test/e2e/platform-admin-catalog-audit.e2e-spec.ts` › `audits every command of the plan catalog lifecycle with its actor and target` |
| A staff caller without an elevation creates a plan, drafts a version or drafts an offering | Still works: `201` for each, a draft is not sellable | `test/e2e/platform-admin-catalog-elevation.e2e-spec.ts` › `keeps the draft routes open to a staff session without an elevation` |

***

## Renewal lifecycle and payments

### SP18 — Renewal lifecycle: trial to active, grace, automatic block, automatic reactivation

Shipped. `platform.apply_trial_lapse`, `apply_period_lapse` and `apply_grace_expiry` compose into `platform.apply_subscription_lifecycle_transitions` — the one function a periodic job calls. `apply_grace_expiry` also suspends the underlying `organization.organizations` row (mirroring SP17's staff suspension automatically, no new trigger) — in the same statement as the Subscription `UPDATE`, and only the `active` Organizations of the Subscriptions that statement returned (migration `0043`), so "blocked" means the same thing here as it does for a staff-suspended organization: no tenant context at all, not a read-only mode. `SubscriptionLifecycleSweepJob` (`@nestjs/schedule`, `src/modules/platform/presentation/jobs/`) runs it on a configurable interval (`SUBSCRIPTION_LIFECYCLE_SWEEP_INTERVAL_MS`, default 300000ms) and takes a Postgres advisory transaction lock (`pg_try_advisory_xact_lock`) first, skipping the tick entirely if another replica already holds it — an efficiency measure, not the correctness backstop: each function's own conditional `UPDATE ... WHERE <preconditions>` is already idempotent by construction (a row that already transitioned stops matching), so even without the lock two replicas could never double-apply, only waste work contending on the same rows.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path, Case A: an `active` period lapses with no renewal | Job sets `past_due`, `grace_until = current_period_end + 3 days`; `subscription_is_entitling` stays `true` through grace, `false` after | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP18-02 a lapsed paid period moves to past_due with a 3-day grace` |
| Happy path, Case B: a `trialing` subscription lapses with no successor | Job sets `past_due`, backfilling `current_period_start`/`current_period_end`/`grace_until` in the same `UPDATE`; the same 3-day grace window now applies to a trial-derived lapse, with **no `ALTER TABLE`** | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP18-01 a lapsed trial moves to past_due with a 3-day grace backfilled onto its period` |
| Day 3 of grace: `past_due`, grace not yet expired | Still `subscription_is_entitling = true`, organization still `active` — "still works" | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP18-03 day 3 of grace still works: past_due, still entitling, organization still active` |
| Day 4: grace expired with no payment recorded | `past_due → suspended`; `subscription_is_entitling` has no branch for `suspended` so entitlement is `false`; the organization is suspended too — "blocks" | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP18-04 day 4 blocks: suspended, no longer entitling, organization suspended too` |
| The sweep suspends an organization at day 4 while a staff suspension is already in force | The sweep records `suspension_source = 'subscription'` on the organization it suspends and never overwrites a staff suspension | `database/tests/contracts/organization-suspension-source-contracts.ts` › `OSS-07 the sweep records the subscription source and never overwrites a staff suspension` |
| Re-running the composed sweep once every lapse already settled | No-op — `0` rows for every transition | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP18-05 re-running the composed sweep on an already-settled state is a no-op` |
| Two replicas' job ticks race the same sweep window | The second replica's `pg_try_advisory_xact_lock` call returns `false` immediately and it skips its tick entirely; releasing the lock makes it available again | `database/tests/concurrency/subscription-plans-concurrency.ts` › `subscriptionLifecycleSweepLockRace` (`NINAKU_SUBSCRIPTION_LIFECYCLE_SWEEP_LOCK_CONCURRENCY_OK`) |
| The job runs late — `current_period_end` has passed but `status` is still `active` | `subscription_is_entitling`'s `active` branch (`p_at < current_period_end`) already evaluates `false`; no over-granting while `status` lags | — |
| A subscriber changes plan (SP16) between two job runs | The job's `WHERE` clause no longer matches; no lapse is misapplied to a row that moved on | — |
| A payment is recorded exactly at the `grace_until` boundary, racing `apply_grace_expiry()` | The payment's own `SELECT ... FOR UPDATE` holds the row for the whole transaction; the racing sweep blocks on its `UPDATE` of the Subscription, re-evaluates its `WHERE` clause against the now-`active` row and matches zero, and the Organization is suspended only for the Subscriptions that same statement suspended, so it matches zero too — the final state is Subscription `active` and Organization `active` | `database/tests/concurrency/subscription-grace-expiry-concurrency.ts` › `paymentVersusGraceExpiryRace` (`NINAKU_PAYMENT_VERSUS_GRACE_EXPIRY_CONCURRENCY_OK`) |
| The sweep holds the Subscription lock first and a payment for the same Subscription waits for it | After both commit the Subscription is `active`, the Organization the sweep suspended is `active` again and exactly one payment row exists | `database/tests/concurrency/subscription-grace-expiry-concurrency.ts` › `graceExpiryVersusPaymentRace` (`NINAKU_GRACE_EXPIRY_VERSUS_PAYMENT_CONCURRENCY_OK`) |
| One sweep over an expired `past_due` Subscription, one still inside its grace, one whose Organization is archived and one already `suspended` whose Organization is `active` | Only the expired one is suspended together with its Organization; the one inside its grace stays `past_due` with an `active` Organization; the archived Organization is never touched (the Subscription is still suspended); the already suspended Subscription does not suspend its `active` Organization again | `database/tests/concurrency/subscription-grace-expiry-concurrency.ts` › `graceExpirySweepOutcomes` (`NINAKU_GRACE_EXPIRY_SWEEP_OUTCOMES_OK`) |
| A subscription has no current row at all (SP7's immediate cancellation) | No job function's `WHERE` clause matches a row that does not exist; nothing to lapse | — |

### SP19 — Record a payment

Shipped. `platform.subscription_payments` is insert-only — `ninaku_runtime` holds no direct grant on it at all, only `EXECUTE` on `platform.record_subscription_payment_as_staff`, the same isolation `platform.staff_members` already uses. The function locks the target Subscription with `SELECT ... FOR UPDATE` before reading its `current_period_end`, compares the amount and currency with those of the offering the Subscription is on (`numeric` equality: a manual staff payment is a full-period payment) and refuses a mismatch before anything is written, then inserts the payment row and renews the period from that captured value and clears `grace_until`: an early payment on a live period keeps `current_period_start` and adds one interval at the end, a payment inside grace starts the new period at the old end, and a payment after a long lapse anchors the period at the payment so the customer always ends entitled (migration `0049`); the function refuses with `subscription_payment_not_entitling` a payment that would leave the Subscription without access. The function reactivates the Organization only when it is `suspended` with `suspension_source = 'subscription'` (the value `apply_grace_expiry` writes); a suspension applied by `platform.suspend_organization_as_staff` (SP17) records the `staff` source and is never undone by a payment, whatever the Subscription's own status was (migration `0045`). A payment may carry an optional `external_reference` (the id read on the bank transfer or the processor receipt); the same reference is never recorded twice, so the same real-world payment cannot renew a Subscription twice whatever `Idempotency-Key` it arrives under (migration `0047`). The reference is global, one namespace across every payment, because the model has no notion of a payment source; two different sources that happen to issue the same reference would collide, a known simplification.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: staff records a payment for a `past_due` subscription | `subscription_payments` row inserted; subscription reactivates to `active` with a recomputed period anchored to the old `current_period_end`, never to `now()` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-01 a payment on a past_due Subscription reactivates it, anchoring the new period on its own current_period_end, never now()` |
| Happy path: staff records a payment for a `suspended` subscription | Subscription and the Organization `apply_grace_expiry` suspended both reactivate to `active` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-02 a payment on a suspended Subscription also reactivates the Organization apply_grace_expiry had suspended` |
| Happy path: staff records an ordinary early renewal of an `active` subscription, before it lapses | Accepted; the period start is kept and one interval is added at the end, no requirement to wait for a lapse | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-03 an ordinary early renewal of an active Subscription is accepted, keeping its current_period_start and adding one interval to its current_period_end` |
| A payment is recorded for an organization the subscription lapse suspended | The Subscription renews and the organization reactivates with its source cleared | `database/tests/contracts/organization-suspension-source-contracts.ts` › `OSS-01 a payment reactivates an organization the subscription lapse suspended and clears the source` |
| A payment is recorded for an organization suspended by staff whose Subscription later lapsed to `suspended` | The Subscription renews to `active`; the organization stays `suspended` with source `staff` until staff reactivate it, over HTTP too | `database/tests/contracts/organization-suspension-source-contracts.ts` › `OSS-02 a payment renews the subscription of a staff-suspended organization but never undoes the staff suspension`; `test/e2e/platform-admin-organization-suspension-source.e2e-spec.ts` › `keeps an organization suspended by staff suspended after a payment and lets staff reactivate it afterwards` |
| A payment is recorded for an Organization suspended by staff policy (SP17), whose Subscription itself stayed `active` | The payment renews the Subscription but never touches `organization.organizations.status` — a policy suspension is never silently undone by a payment | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-04 a payment never lifts a staff policy suspension that never suspended the Subscription itself` |
| An `active` subscription in a live period is paid early | Stays entitled right after the payment and at the old period end; `current_period_start` unchanged and `current_period_end` moved by exactly one interval | `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-01 an early payment on an active Subscription adds one interval at the end and keeps the period start`; `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-01b the Subscription stays entitled right after the early payment and at the old period end`; `test/e2e/platform-admin-subscription-payment-renewal.e2e-spec.ts` › `keeps an active subscription entitled when it is paid early: the period start stays and one interval is added at the end` |
| Two early payments in a row | Two intervals added, no gap, entitled throughout | `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-02 two early payments in a row add two intervals and never open a gap`; `test/e2e/platform-admin-subscription-payment-renewal.e2e-spec.ts` › `adds two intervals and opens no gap when the same subscription is paid early twice` |
| A `past_due` subscription pays inside its grace | Unchanged: the new period starts at the old end and the subscription is entitled | `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-03 a past_due payment inside grace still starts the new period at the old end and is entitled`; `test/e2e/platform-admin-subscription-payment-renewal.e2e-spec.ts` › `still starts the new period at the old end when a past_due subscription pays inside its grace` |
| A subscription suspended long ago (old end plus one interval still in the past) is paid once | The period is anchored at the payment (start about now, end one interval later), the subscription is entitled and the reactivated organization sits over an entitled subscription | `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-04 a payment after a long lapse anchors the new period at the payment and gives a full interval`; `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-04b the reactivated Organization sits over an entitled Subscription`; `test/e2e/platform-admin-subscription-payment-renewal.e2e-spec.ts` › `anchors the period at the payment after a long lapse, so the reactivated organization has access` |
| A payment for an organization suspended by staff whose subscription is live | The subscription is renewed and entitled; the organization stays suspended with source `staff` | `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-05 a payment renews a staff-suspended Organization Subscription and leaves the Organization suspended`; `test/e2e/platform-admin-subscription-payment-renewal.e2e-spec.ts` › `renews the subscription of a staff-suspended organization and leaves the organization suspended` |
| A wrong amount on an early payment | Refused, both period bounds untouched | `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-06 a wrong amount on an early payment is refused`; `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-06b the refusal leaves both period bounds untouched` |
| A payment that would leave the subscription without access (a scheduled subscription whose period has not started) | Refused, `23514`, `subscription_payment_not_entitling`; no payment row, no period moved | `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-07 a payment that would leave the Subscription without access is refused`; `database/tests/contracts/subscription-payment-renewal-contracts.ts` › `EP-07b the refusal records no payment and moves no period bound` |
| Staff records a payment for an organization with no `active`/`past_due`/`suspended` subscription at all (e.g. only an `ended` one) | Refused, `23514` — nothing to resolve | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-05 an Organization with no active, past-due or suspended Subscription has no lapse to resolve` |
| Staff records a zero amount | Refused as a mismatch with the offering price (`23514`, `subscription_payment_amount_mismatch`), because zero never equals a price; nothing is written | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-06 a zero amount is refused as a mismatch with the offering price, never silently accepted`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `${caseName.slice(0, 7)} the refusal writes nothing: no payment row, no renewal, no reactivation` |
| A non-staff caller invokes `record_subscription_payment_as_staff` | Refused, `42501` | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-07 a non-staff identity cannot record a payment` |
| Staff records an amount equal to the offering's price, in the offering's currency | Accepted, whatever scale the amount is written in: the comparison is `numeric`, never text or float | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-08 a payment equal to the offering price in the offering currency is accepted whatever scale it is written in`; `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `accepts a payment equal to the offering price whatever scale the amount is written in` |
| Staff records an amount below, or above, the price of the offering the Subscription is on | Refused (`23514`, `subscription_payment_amount_mismatch`) before anything is written: no payment row, no renewal, no reactivation | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-09 an amount below the offering price is refused`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-10 an amount above the offering price is refused`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `${caseName.slice(0, 7)} the refusal writes nothing: no payment row, no renewal, no reactivation` |
| Staff records the right amount in a currency other than the offering's | Refused (`23514`, `subscription_payment_currency_mismatch`), nothing written; a wrong amount in a wrong currency is refused as a currency mismatch first | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-11 the right amount in another currency is refused`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-12 a wrong amount in a wrong currency is refused as a currency mismatch first` |
| Staff records a payment that is not the offering price for a `suspended` Subscription whose Organization is suspended | Refused; the Subscription and the Organization stay `suspended`. A payment equal to the price reactivates both | `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-13 a wrong amount never renews a suspended Subscription nor reactivates its suspended Organization`; `database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP19-14 after the refused payment the Subscription and the Organization are still suspended`; `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `never reactivates a suspended organization with a payment that is not the offering price`; `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `reactivates a suspended organization with a payment equal to the offering price` |
| The same refusals over HTTP | `422 · platform.organization_command_refused` with a safe body that names no price, no constraint and no SQL, and one `denied` platform audit record (action `platform.subscription.payment_recorded`, actor = the staff member, target = the organization) whose reason code is `payment_amount_mismatch` or `payment_currency_mismatch`; the subscription state is unchanged | `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `refuses $what, writes nothing and audits the denied attempt`; `src/modules/platform/infrastructure/subscription-payment/platform-subscription-payment.repository.spec.ts` › `refuses $constraint as a command refusal that names its reason and reveals nothing else`; `src/modules/platform/presentation/audit/organization-command-refusal-reason.spec.ts` › `names the %s refusal so its denied attempt can be audited` |
| A refused payment and the `Idempotency-Key` | The refusal does not spend the key: the same key then pays the right price and records one payment; the use case lets the refusal reach the caller without a success audit record or a stored idempotent result | `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `does not spend the Idempotency-Key on a refused payment, so the same key can then pay the right price`; `src/modules/platform/application/subscription-payment/record-subscription-payment.use-case.spec.ts` › `lets a %s refusal reach the caller, audits no success and completes no idempotent result` |
| A repeated payment request, and a key reused with a different payment | The same key and payment answers the same way with one payment row; a different payment on the same key is `409 · idempotency.key_reused` and the first payment stands | `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `answers a repeated request with the same key and payment the same way and records one payment`; `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `refuses to reuse a key with a different payment and keeps the first one`; `src/modules/platform/application/subscription-payment/record-subscription-payment.use-case.spec.ts` › `replays the same key and payment without recording or auditing a second time`; `src/modules/platform/application/subscription-payment/record-subscription-payment.use-case.spec.ts` › `refuses the same key reused with a different amount and keeps the first payment` |
| The same real-world payment is registered a second time with the same external reference, under another `Idempotency-Key` | Refused before anything is written, with a unique violation naming `subscription_payments_external_reference_ci_uq`: one payment row and one renewal, not two | `database/tests/contracts/subscription-payment-reference-contracts.ts` › `RP-02 the same reference registered a second time is refused`; `database/tests/contracts/subscription-payment-reference-contracts.ts` › `${caseName.slice(0, 5)}b the refusal changes nothing: no payment row, no renewal` |
| The reference differs only in case or surrounding whitespace, or another organization already recorded it | Still the same reference: the comparison is `lower(btrim(...))` and global | `database/tests/contracts/subscription-payment-reference-contracts.ts` › `RP-03 a case and whitespace variant of the reference collides`; `database/tests/contracts/subscription-payment-reference-contracts.ts` › `RP-04 the reference is global: another organization cannot reuse it`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `treats a reference that differs only in case and surrounding whitespace as the same one`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `refuses a reference another organization already recorded, because the reference is global` |
| Distinct references, a blank reference, and the shape of the guarantee | Distinct references on one organization are all recorded; a blank reference is refused by `subscription_payments_external_reference_chk`; the unique index is partial on `lower(btrim(external_reference))` where it is not null, and the four-argument function signature no longer exists | `database/tests/contracts/subscription-payment-reference-contracts.ts` › `RP-06 distinct references on one organization are all recorded`; `database/tests/contracts/subscription-payment-reference-contracts.ts` › `RP-07 the unique index is on the trimmed lowercase reference and ignores missing references`; `database/tests/contracts/subscription-payment-reference-contracts.ts` › `RP-08 a blank reference is refused by the check`; `database/tests/contracts/subscription-payment-reference-contracts.ts` › `RP-09 the four-argument signature no longer exists` |
| A payment row names a Subscription that belongs to another Organization, written directly by an administrator | Refused by `subscription_payments_subscription_fk`, which is the composite foreign key `(subscription_id, organization_id)` onto `platform.subscriptions (id, organization_id)`; no row is written, a payment whose Subscription belongs to its own Organization is accepted, the plain `subscription_payments_organization_fk` is kept, and the staff function still records a payment end to end | `database/tests/contracts/payment-and-session-schema-integrity-contracts.ts` › `SI-01 a payment whose subscription belongs to another organization is refused by the composite foreign key`; `database/tests/contracts/payment-and-session-schema-integrity-contracts.ts` › `SI-01b the refused cross-organization payment wrote no row`; `database/tests/contracts/payment-and-session-schema-integrity-contracts.ts` › `SI-02 a payment whose subscription belongs to its own organization is accepted`; `database/tests/contracts/payment-and-session-schema-integrity-contracts.ts` › `SI-03 the staff recording function still records a payment end to end`; `database/tests/contracts/payment-and-session-schema-integrity-contracts.ts` › `SI-04 the subscription foreign key is composite on the subscription and its organization`; `database/tests/contracts/payment-and-session-schema-integrity-contracts.ts` › `SI-05 the plain organization foreign key is kept` |
| Two payments with the same reference at the same time | Exactly one is recorded. On one Subscription the second waits on the Subscription lock and then gets the clean refusal from the function's own check, never a unique violation; on two Subscriptions it waits on the unique index entry and is refused on that constraint, and nothing is renewed for it | `database/tests/concurrency/subscription-payment-reference-concurrency.ts` › `sameReferenceOnOneSubscription`; `database/tests/concurrency/subscription-payment-reference-concurrency.ts` › `sameReferenceOnTwoSubscriptions` |
| The same refusal over HTTP | `422 · platform.organization_command_refused` with a safe body that names no constraint and no SQL, one `denied` platform audit record with the reason `payment_already_recorded`, the subscription state unchanged, and the `Idempotency-Key` still free for another payment; a payment that commits while the request is in flight is refused the same way (the repository maps the unique violation by constraint name, and no other unique violation) rather than answering `500` | `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `refuses the same reference registered again under another Idempotency-Key and changes nothing`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `does not spend the Idempotency-Key on a refused duplicate, so the same key can then record another payment`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `answers the clean refusal, not a 500, when a payment with the same reference commits while the request is in flight`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `lets exactly one of two simultaneous payments with one reference win, the other a clean refusal and never a 500`; `src/modules/platform/infrastructure/subscription-payment/platform-subscription-payment.repository.spec.ts` › `refuses a unique violation of the external reference index as an already recorded payment, revealing nothing else`; `src/modules/platform/infrastructure/subscription-payment/platform-subscription-payment.repository.spec.ts` › `does not map $what, which stays the original failure`; `src/modules/platform/application/subscription-payment/record-subscription-payment.use-case.spec.ts` › `lets a %s refusal reach the caller, audits no success and completes no idempotent result`; `src/modules/platform/presentation/audit/organization-command-refusal-reason.spec.ts` › `names the %s refusal so its denied attempt can be audited` |
| The reference on the request, in the stored payment and in the success audit record | Optional, trimmed, at most 200 characters, never blank, never NUL; stored as written after trimming; the success audit record carries it as the context attribute `external_reference`; a key reused with another reference is `409 · idempotency.key_reused` | `src/modules/platform/presentation/dtos/record-subscription-payment-request.schema.spec.ts` › `is optional and reads as null when the request leaves it out`; `src/modules/platform/presentation/dtos/record-subscription-payment-request.schema.spec.ts` › `trims the reference the staff member typed`; `src/modules/platform/presentation/dtos/record-subscription-payment-request.schema.spec.ts` › `accepts a reference of exactly the maximum length and refuses one more`; `src/modules/platform/presentation/dtos/record-subscription-payment-request.schema.spec.ts` › `refuses $what`; `test/unit/free-text-request-fields.spec.ts` › `$name: refuses a NUL character`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `records a payment with a reference, stores it trimmed and audits it`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `answers a blank reference with a validation failure and records nothing`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `refuses to reuse a key with another reference and keeps the first payment`; `src/modules/platform/application/subscription-payment/record-subscription-payment.use-case.spec.ts` › `hands the external reference to the repository and audits it as a context attribute`; `src/modules/platform/application/subscription-payment/record-subscription-payment.use-case.spec.ts` › `refuses the same key reused with a different reference and keeps the first payment`; `src/modules/platform/infrastructure/subscription-payment/platform-subscription-payment.repository.spec.ts` › `hands the external reference to the function as its fifth argument, and null when there is none` |
| An organization with no subscription to renew, a caller who is not active staff, a staff member with no live elevation | Unchanged: `422 · platform.subscription_payment_refused` (no mismatch audit record), `403 · platform.staff_required`, `403 · platform.elevation_required`; nothing written | `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `answers 422 platform.subscription_payment_refused when the organization has no subscription to renew, and audits no mismatch refusal`; `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `answers 403 to a caller who is not active staff and records nothing`; `test/e2e/platform-admin-subscription-payment.e2e-spec.ts` › `answers 403 to a staff member with no live elevation and records nothing` |
| Two staff members record two payments for the same lapsed period, with no external reference | Both rows persist; the second reactivation `UPDATE` is a no-op, not an error: a payment without a reference is never deduplicated | `database/tests/contracts/subscription-payment-reference-contracts.ts` › `RP-05 payments without a reference are never deduplicated`; `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts` › `records payments without a reference as before, however many there are` |
| A payment is recorded racing SP18's `apply_grace_expiry()` job on the same row | The payment's `SELECT ... FOR UPDATE` wins the race; see the Renewal lifecycle concurrency row above | `database/tests/concurrency/subscription-grace-expiry-concurrency.ts` › `paymentVersusGraceExpiryRace`; `database/tests/concurrency/subscription-grace-expiry-concurrency.ts` › `graceExpiryVersusPaymentRace` |

***

## Cross-tenant and concurrency invariants

| Invariant | Expected answer | Proving test |
|---|---|---|
| Organization A reads Organization B's subscription | `0` rows — `subscriptions_tenant_select_policy` scopes `SELECT` to `organization_id = current_organization_id()` (SP4) | — |
| Direct cross-tenant `INSERT`/`UPDATE` on `platform.subscriptions` as `ninaku_runtime` | Refused today at the grant level (only `SELECT` exists); once Group 3 ships, refused by the new function's own `identity.current_organization_id()` scoping (SP6, SP7) | — |
| Concurrent plan-change commands for the same organization | Exactly one current subscription survives; the conditional `UPDATE`'s zero-row result is the losing side's signal (SP6) | — |
| Two organizations sharing the same `plan_offering_id` | Each keeps an independent `subscriptions` row; `subscriptions_one_current_per_organization_uq` is scoped per organization, not per offering | — |
| Retrying organization provisioning with the same idempotency key | Returns the same `organization_id` and the same subscription, never a duplicate (SP3, shared with Organization O1) | `test/e2e/organizations-provisioning-concurrency.e2e-spec.ts › answers the same organization to every concurrent replay of one key and creates exactly one` |
| A capability/limit override effective-dated window overlapping an existing one for the same organization+capability (or +limit) | Refused by the `EXCLUDE USING gist` constraint on `organization_capability_overrides`/`organization_limit_overrides` | — |
| `effective_limit`/`effective_capability` called with an organization that has no current or historical subscription at all | `effective_capability` returns `'disabled'`; `effective_limit` returns no row from the subscription branch (an override, if any, still applies) | — |
| Two platform-admin functions (SP15-SP19) race to change the same target row (a version, an offering, a subscription, an organization's status) | Exactly one wins; every staff function repeats the conditional-`UPDATE` pattern SP6 established, never a bare unconditional `UPDATE` | — |
| A platform-admin function is called with an `organization_id`/`plan_id`/`plan_version_id`/`offering_id` naming nothing that exists | Refused by the function's own `FOUND` check, uniformly across SP15-SP19, before any table is touched | — |
| An identity holds both a tenant `role_assignments` row and an active `platform.staff_members` row at the same time | The two authorities never compose — a staff function ignores `role_assignments` entirely, an org-scoped function ignores `staff_members` entirely; two unrelated grants on the same person, not a privilege-escalation path | — |

***

## Gaps

NestJS application code now exists for SP13-SP20 (see below); every route
in the Derived API's platform-admin group has a controller, use case,
port and repository. Group 0's DB-layer seed and `provision_organization_for_caller` shipped in
revision 28 and are proven by
`database/tests/prepare/prepare-database-contracts.ts`. Grouped by family,
each remaining line names the lowest layer that could prove it honestly
once the corresponding code exists, or the specific rows still missing a
test where the family has already shipped:

* **SP1 Define and publish a plan** — integration (the version/offering lifecycle triggers are proven against real PostgreSQL, not mockable); the seed-shape half of it is proven by Group 0.
* **SP2 Seed the first real Plan and its Market Offerings** — proven by Group 0 (`organizationRuntimeProvisionsItselfPerCountry`); the `GET /plans` read model is proven under SP14 (`test/e2e/plans-catalog.e2e-spec.ts`).
* **SP3 Start a subscription when provisioning an organization** — proven by Group 0: caller-scoped happy path per country, idempotent retry, replay mismatch, offering-country mismatch, inactive offering, blocked identity with no partial garbage, and the no-owner-parameter guard. `organizationRuntimeCannotProvisionOrganizations` still proves `platform.provision_organization` stays barred. The `POST /organizations` route is served by the Organization module and its HTTP proof lives under O1 in `ORGANIZATION_CASE_MATRIX.md`; the zero-trial row of this table still has no dedicated test.
* **SP4 Read my organization's subscription and plan** — integration (RLS-dependent read) + e2e (response contract).
* **SP5 Trial expiry** — the half-open window is proven by Group 0 (`organizationTrialEntitlementFollowsTrialEndsAt`); the revised main path (`trialing → past_due`, not `trialing → ended`) is proven by `SP18-01` (the scheduled-job row of SP5).
* **SP6 Change plan** — integration (immutability triggers, conditional-`UPDATE` concurrency) + a dedicated two-connection concurrency test for the race case, the same pattern `test/integration/sign-out.integration-spec.ts` already uses for Identity.
* **SP7 Request cancellation of a subscription** — integration.
* **SP8 Ask whether a capability is granted** — unit (contract composition) + integration (the underlying `effective_capability` call, already exercised informally by `assert_operation_allowed`'s own tests once they exist).
* **SP9 Ask a plan limit** — unit + integration; `identity.assert_membership_seat_available` is the existing example to extend, not a new pattern to invent.
* **SP10 Authorize an operation** — unit (contract composition) + integration; shared proving surface with Organization O34.
* **SP11 Plan version changes after an organization has subscribed** — integration (prove the grandfathering property directly, not by absence of a bug report).
* **SP12 Billing and payment collection** — not schedulable; no owner, no module, explicitly deferred.
* **SP13 Platform super admin as an actor** — shipped and proven by `database/tests/contracts/subscription-plans-administration-contracts.ts` (`SP13-01`..`SP13-04`), plus the foundation `SessionGuard` in `src/foundation/auth/` and `PlatformStaffGuard` in `src/modules/platform/presentation/guards/`. Remaining gap: the organization-owner-impersonation and direct-`INSERT`-by-`ninaku_runtime` rows have no dedicated test.
* **SP14 List plans available in a country before signup** — shipped and proven by `test/e2e/plans-catalog.e2e-spec.ts` and `list-public-plans.use-case.integration-spec.ts`; the only case in this revision needing zero new grants.
* **SP15 Create and manage plans, versions, prices, and markets** — shipped and proven by `database/tests/contracts/subscription-plans-administration-contracts.ts` (`SP15-05`..`SP15-18`) and `database/tests/concurrency/subscription-plans-concurrency.ts`, plus `src/modules/platform/application/plan-administration/` and `PlatformAdminPlansController`. Remaining gaps: the two-versions-race-to-publish row, the missing-locale/currency-on-open row, and the offering-`amount`-immutability row have no dedicated test.
* **SP16 Assign a plan, extend a trial, or change an organization's plan** — shipped and proven by `subscription-plans-administration-contracts.ts` (`SP16-01`..`SP16-10c`), plus `AssignPlanUseCase`/`ChangeSubscriptionPlanUseCase`/`ExtendSubscriptionTrialUseCase` and `PlatformAdminSubscriptionsController`. Remaining gaps: the concurrent-plan-change race and the archived-organization row have no dedicated test.
* **SP17 Suspend and reactivate an organization** — shipped and proven by `subscription-plans-administration-contracts.ts` (`SP17-01`..`SP17-06b`), reusing D21's existing triggers (already proven for the member-authored path by `administration-contracts.ts`) now also exercised from a staff-authored caller. Remaining gap: the archived-organization row has no dedicated test.
* **SP18 Renewal lifecycle** — shipped and proven by `subscription-plans-administration-contracts.ts` (`SP18-01`..`SP18-05`) and `database/tests/concurrency/subscription-plans-concurrency.ts` › `subscriptionLifecycleSweepLockRace` and `database/tests/concurrency/subscription-grace-expiry-concurrency.ts` › `paymentVersusGraceExpiryRace`/`graceExpiryVersusPaymentRace`/`graceExpirySweepOutcomes`, confirming the "no `ALTER TABLE` needed" schema claim directly. Remaining gaps: the late-job-run row, the plan-change-mid-sweep row, and the subscription-does-not-exist row have no dedicated test.
* **SP19 Record a payment** — shipped and proven by `subscription-plans-administration-contracts.ts` (`SP19-01`..`SP19-14`) and `database/tests/concurrency/subscription-grace-expiry-concurrency.ts` › `paymentVersusGraceExpiryRace`/`graceExpiryVersusPaymentRace`, plus `RecordSubscriptionPaymentUseCase` and the `payments` route on `PlatformAdminSubscriptionsController`. The optional external reference that stops the same real-world payment renewing twice is proven by `database/tests/contracts/subscription-payment-reference-contracts.ts` (`RP-01`..`RP-09`), `database/tests/concurrency/subscription-payment-reference-concurrency.ts` and `test/e2e/platform-admin-subscription-payment-reference.e2e-spec.ts`. Remaining gap: the reference is global and optional, so a payment recorded without one is still not deduplicated, and two payment sources that issue the same reference would collide.
* **SP20 Idempotency-Key and audit for staff commands** — shipped and proven for all 14 commands that exist today (`database/tests/contracts/subscription-plans-administration-contracts.ts` › `SP20-01`..`SP20-03`, `database/tests/concurrency/subscription-plans-concurrency.ts` › `staffCommandIdempotencyRace`). Remaining gaps: no dedicated integration test that a replayed HTTP call skips the second Audit Record; the `Idempotent-Replayed` header is not surfaced on the four `204` routes.
* **Cross-tenant and concurrency invariants (all 10 rows)** — integration, with the concurrent-plan-change and GiST-exclusion rows still gaps; the platform-admin-race row is now partly covered (offering-publish-versus-version-retire and market-open-versus-market-open both have a dedicated two-connection test), but a subscription-assignment or organization-status race (SP16/SP17, which now exist) has no dedicated two-connection test.
