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

158 lines
8.2 KiB
Markdown

---
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`](./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:
- **Type** — `initiate`, `upgrade`, `downgrade`, `end`, or `extend`
- **From / To rank** — the ladder position delta (`extend` shows equal ranks)
- **Actor** — `system`, `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.