Appearance
Subscription Plans Case Matrix
One row per behavioral case for every use case named in 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 and IDENTITY_CASE_MATRIX.md. A case with no proving test answers — and is listed under 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 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; 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); theGET /plansread 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.
organizationRuntimeCannotProvisionOrganizationsstill provesplatform.provision_organizationstays barred. ThePOST /organizationsroute is served by the Organization module and its HTTP proof lives under O1 inORGANIZATION_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, nottrialing → ended) is proven bySP18-01(the scheduled-job row of SP5). - SP6 Change plan — integration (immutability triggers, conditional-
UPDATEconcurrency) + a dedicated two-connection concurrency test for the race case, the same patterntest/integration/sign-out.integration-spec.tsalready uses for Identity. - SP7 Request cancellation of a subscription — integration.
- SP8 Ask whether a capability is granted — unit (contract composition) + integration (the underlying
effective_capabilitycall, already exercised informally byassert_operation_allowed's own tests once they exist). - SP9 Ask a plan limit — unit + integration;
identity.assert_membership_seat_availableis 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 foundationSessionGuardinsrc/foundation/auth/andPlatformStaffGuardinsrc/modules/platform/presentation/guards/. Remaining gap: the organization-owner-impersonation and direct-INSERT-by-ninaku_runtimerows have no dedicated test. - SP14 List plans available in a country before signup — shipped and proven by
test/e2e/plans-catalog.e2e-spec.tsandlist-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) anddatabase/tests/concurrency/subscription-plans-concurrency.ts, plussrc/modules/platform/application/plan-administration/andPlatformAdminPlansController. 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), plusAssignPlanUseCase/ChangeSubscriptionPlanUseCase/ExtendSubscriptionTrialUseCaseandPlatformAdminSubscriptionsController. 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 byadministration-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) anddatabase/tests/concurrency/subscription-plans-concurrency.ts›subscriptionLifecycleSweepLockRaceanddatabase/tests/concurrency/subscription-grace-expiry-concurrency.ts›paymentVersusGraceExpiryRace/graceExpiryVersusPaymentRace/graceExpirySweepOutcomes, confirming the "noALTER TABLEneeded" 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) anddatabase/tests/concurrency/subscription-grace-expiry-concurrency.ts›paymentVersusGraceExpiryRace/graceExpiryVersusPaymentRace, plusRecordSubscriptionPaymentUseCaseand thepaymentsroute onPlatformAdminSubscriptionsController. The optional external reference that stops the same real-world payment renewing twice is proven bydatabase/tests/contracts/subscription-payment-reference-contracts.ts(RP-01..RP-09),database/tests/concurrency/subscription-payment-reference-concurrency.tsandtest/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; theIdempotent-Replayedheader is not surfaced on the four204routes. - 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.