Files
member-console/docs/plan-management.md
Christian Galo 751bae7768 Use plan ladder for org defaults
Add default_plan_ladder_id with a forward data migration and update
the runtime to resolve the ladder's rank-0 tier at use-time. Regenerate
sqlc, update auto-provisioning, ReapplyDefaultsForPool, operator UI and
tests; add GetTierByLadderRank and pool/provision query helpers. Add a
CSP-safe confirm-action modal and wire operator actions to it. Close
plan-sole-writer safety gaps and serialize IssueGrant with a FOR UPDATE
pool lock to prevent ladder races.
2026-04-27 01:57:17 -05:00

8.2 KiB

audience
audience
operators

Plan Management

This document describes how operators configure plan ladders, manage automatic provisioning, perform manual transitions, and audit enrollment history.

Developers: see plan-architecture.md for where plan code lives and the sole-writer rule.

Overview

A plan ladder is a ordered set of plan products. Each organization is enrolled on at most one tier per ladder at a time. Transitions between tiers — upgrades, downgrades, initiations, and ends — are recorded in the transition history for audit.

When to use a plan vs. a regular product

Plans are an opt-in modeling choice for products that follow tier semantics: an organization should be on exactly one tier of a ladder, and moving between tiers means leaving the previous one. If that's not what you want, use a regular product instead.

Use a plan when:

  • The product belongs to a graded set (e.g., Free → Standard → Pro) and an organization should hold exactly one at a time.
  • You need an audit trail of transitions between tiers (who moved the org, when, why).
  • Stripe subscription state (created/upgraded/downgraded/cancelled) should drive the org's tier directly.

Use a regular product (addon, usage, one-time) when:

  • You want to grant the same thing multiple times (multiple gift cycles, stacked credits, parallel benefits).
  • The product is independent of any tier — it adds to an org's entitlements rather than replacing them.
  • You want simple grant/revoke semantics without ladder transitions or supersession.

If you find yourself wanting to grant the same plan twice to the same org, consider whether you need an extend (same tier, new grant) or whether the product should have been modeled as an addon. Plans enforce one-tier-per-ladder by design; addons and usage products do not. The Extend Current Tier action exists precisely for the legitimate case of issuing another grant on the same tier.

Developers: this distinction is enforced at the catalog layer (membership in plan_ladder_tiers is opt-in per product). See plan-architecture.md for the runtime consequence.

Ladder Configuration

Creating a Ladder

  1. Open the Operator panel and select the Plan Ladders tab.
  2. Enter a unique slug (kebab-case identifier), a display name, and an optional description.
  3. Click Create Ladder.

Attaching Tiers

  1. From the ladder list, click Tiers on the ladder you want to configure.
  2. Select a published plan product (products with product_type = NULL) and specify its rank.
  3. Click Add Tier.

Tiers within a ladder are ordered by rank. Lower ranks are "lower" tiers; higher ranks are "higher" tiers. The typical pattern is:

  • Rank 0 — Public / Free tier
  • Rank 1 — Standard tier
  • Rank 2 — Pro tier

Reordering Tiers

Tiers can be reordered by changing their rank values. The UI enforces unique ranks within a ladder.

Removing Tiers

A tier can be removed only when no active pool attachments exist for that tier. The UI shows the active attachment count per tier.

Deleting a Ladder

A ladder can be deleted only when it has no active attachments across all its tiers. Deleting a ladder also removes all tier memberships.

Auto-Provisioning Policy

Per-Organization-Type Default Plan

Each organization type can have a default plan configured by pointing at a billing.plan_ladders row. The default tier is always the rank-0 tier of that ladder, resolved at use-time. When a new organization of that type is created, the rank-0 product of the configured ladder is automatically granted to the organization's default pool.

The dropdown in the Operator panel sources from plan ladders only — every option renders as LadderName — rank-0 product name so the resolved default is visible inline. Products that are not the rank-0 tier of any ladder cannot be set as defaults; the Plan Ladders tab is the single source of truth for which products are eligible.

Applies to new organizations only. Changing the default plan for an org type does not affect existing organizations.

Backfilling Existing Organizations

To apply the current default plan to existing organizations:

  1. Open the Operator panel and select the Org Types tab.
  2. Find the org type with a configured default plan.
  3. Click Backfill existing orgs.
  4. Confirm the action.

The system iterates all organizations of that type, locks each pool row, and invokes ReapplyDefaultsForPool. The result reports:

  • N updated — pools that received a new default grant
  • M skipped — pools that were already at the target tier (idempotent)
  • K failed — pools whose transition errored (per-org reasons listed below the summary)

Backfill is rejected if the org type has no default plan configured.

Manual Grant Workflows

Issue Grant

An operator can issue a grant to move a pool to a specific tier:

  1. Open the Operator panel and select the Enrollment tab (or click Enrollment from the Organizations list).
  2. Select the target organization.
  3. In the Issue Grant card, choose a plan product, optionally set a valid until timestamp, and provide a reason.
  4. Click Issue Grant.

The system:

  • Creates a grant with grant_reason = 'manual' (or 'trial' if valid_until is set)
  • Invokes Transition to attach the pool to the target tier
  • Registers a Temporal workflow to expire the grant at valid_until only for trials

A grant with no valid_until is permanent. A grant with valid_until is a time-bounded trial; when it expires, the pool automatically transitions back to the organization's default tier (or detaches if no default is configured).

Grant Revocation

An operator can revoke an active grant:

  1. On the enrollment page, locate the grant in the Active Grants table.
  2. Click Revoke.
  3. Confirm the action.

The system revokes the grant, ends the associated provision, and invokes Transition(End) to return the pool to its default tier.

Extend Current Tier

An operator can issue a new grant on the pool's current tier without changing position:

  1. On the enrollment page, locate the Extend current tier card (visible only when the pool has an active plan attachment).
  2. Provide a reason (e.g., "extending trial", "comp cycle").
  3. Optionally set Expires at — when present, the new grant has a valid_until and a Temporal expiration workflow is scheduled. When empty, the extension is open-ended (comp/gift case).
  4. Click Extend tier.

The system:

  • Creates a new grant chained to the prior grant via extends_grant_id
  • Invokes Transition with Extend=true to create a new provision + ladder attachment at the same tier
  • Records a transition_type='extend' audit row with from_rank == to_rank
  • Schedules GrantExpirationWorkflow keyed off the new grant's ID when Expires at is set, so trial extensions expire on their own clock and don't ride the original trial's expiry

Use this to extend a trial that is about to expire (set a new Expires at), comp an additional cycle during an outage, or gift a second period without disturbing the existing audit trail.

Enrollment Audit

Reading Transition History

The enrollment page displays a chronological list of all pool_provision_transitions rows for each pool. Each row shows:

  • Typeinitiate, upgrade, downgrade, end, or extend
  • From / To rank — the ladder position delta (extend shows equal ranks)
  • Actorsystem, webhook (Stripe), or the operator's display name
  • Reason — free-form text or system-generated attribution
  • Effective at — when the change took effect

Canonicalization vs. subscription_changes

pool_provision_transitions is the canonical source for plan-position history: what tier a pool held, when, and under whose authority. billing.subscription_changes records commercial mutations of the subscription (status changes, amount changes, etc.). A Stripe-driven upgrade is recorded in both tables because they answer different questions.

Corrections

Transition history is read-only. Errors or clarifications should be expressed by performing a new transition with an explanatory reason, not by editing historical rows.