Leave a ladder alone while another plan holds what it took

#212: when a grant or another source took a ladder from a paying
subscription, the subscription's next reconcile conferred its tier again
and ended the plan that held the ladder. Now the reconcile reads which
ladders the subscription was displaced on and leaves those alone while
the displacer still holds them.

Migration 00034 adds core.subscription_displacements(subscription, pool):
for each ladder, the subscription's latest episode; it is a displacement
when that episode ended in a supersession pair (core.supersession_pairs)
whose successor is another source, and it is returned while an active or
suspended, non-default position of another source holds the ladder. The
function returns the holder's provision and kind (grant, subscription,
purchase). A default-sourced position never holds against a subscription.

fulfillment.DisplacedProducts is the one rule over those rows: it reads
no database row, takes the desired products' rungs (ListConferralShapes,
fulfillment.RungsByProduct) and the displacements, and returns the
products to leave alone whole, any of whose rungs is on a displaced and
held ladder. The item pass reads both under the pool row, confers the
rest in ascending product id, and logs each left-out product once at
Info ("reconcile: product not conferred; another plan holds its
ladder", with product, ladder, holder provision and kind). Add-ons and
other ladders are conferred; live products only update their quantity.
When the holder ends, the subscription's next reconcile confers its tier
again.

Goldens: 29 trace goldens change. Four flip on purpose to the left-alone
answer (grant_over_subscription, grant_over_suspended_recovery,
second_subscription_over_first, downgrade_lands_while_displaced): the
Info line, no core.confer, no materialization, the holder's provision
still active. The other 25 gain only the two reads. The trace kit names
provisions by product and source. TestReconcile_SupersededWhileSuspended
NotResurrected now wants no live provision after the recovery.

Tests: nine TestSubscriptionDisplacements_* on the function (a grant
over the subscription, an extension still holds, the default does not
hold, a successor with no from side, an episode the subscription ended
itself, the subscription's own switch, the latest episode decides, a
second subscription, one ladder of a bundle); twelve on the reconcile (a
grant over the subscription and one of its own tier keep the ladder, a
marked grant is not replaced, the subscription delivers again over the
default and on a vacant ladder, a grant issued over the default and an
extended grant keep the ladder, a subscription bought over a grant
supersedes it, a later subscription keeps the ladder, a grant issued
while the reconcile waits is seen (also under -race), a bundle displaced
on one ladder is left alone, an item back on a ladder it left supersedes
the grant); a downgrade that lands while displaced is applied and not
conferred (sweep); TestDisplacedProducts.

Docs: plan-ladders-transitions.md invariant 20 and the function's row;
payments-billing.md, stripe.md, plan-management.md and database-locks.md
say a subscription waits while another plan holds what it took.

Breaks (scripts/mutation-check, 27 of 27 CAUGHT):
- convergeConferrals confers the left-out products (seven records: T1,
  T1b, T2, T3, T6b, T12, trace grant_over_subscription)
- DisplacedProducts matches the product, not the ladder (T8, unit)
- DisplacedProducts reads the first rung only (T12, unit)
- reconcileItems without its LockPools call (T11)
- the log line removed
- the item pass walks the ids in reverse (section 2's break restated
  against this section's loop, which now ranges over the ordered ids)
- holder: a default holds against the subscription (T4, function)
- an ended position counts as a holder
- displacer's chain: the holder must be the displacement's successor
  (T6, function)
- occupancy alone
- grant successors only (T9, function)
- the pair condition removed (function, T13)
- every episode read, not the latest
- the earliest episode read
- the pairs view without from_rank
The convergeConferrals record is written as
`if _, out := left[productID]; false && out {`, since `if false {`
leaves `left` unused and does not build.

Two mutants no test can see: productsLeftAlone passing the missing
products rather than every product (a live product's rungs are its
latest episodes, so it can never sit on a displaced ladder), and
DisplacedProducts keeping only the first row per ladder (the exclusion
constraint allows one live position per pool and ladder, and the
function returns one latest episode per ladder).

The suite on reset databases passes, 4650 tests, 15 skipped; after the
review's wording-only fixes, ./internal/fulfillment and
./internal/entitlements pass. make lint passes; openspec validate
--strict passes.
This commit is contained in:
2026-10-24 15:22:10 -04:00
parent 8181d5461c
commit 76ff9d693a
48 changed files with 1227 additions and 71 deletions
+6 -3
View File
@@ -143,9 +143,12 @@ advisory lock, then the rest by branch.
- **Active:** `reconcileItems` takes the pool row (`LockPools`) after the
subscription lock and before `core.sync_source_status('active')`. It then
mirrors the items, reads the live provisions and confers a missing product
(`core.confer`), changes a quantity (`core.update_conferral_bounds`) and
ends a surplus provision (`core.end_conferral`); each of those functions
mirrors the items, reads the live provisions, then the shapes of the
desired products and the subscription's displacements, all under the pool
row, and confers a missing product unless another plan took one of its
ladders from the subscription and still holds it (`core.confer`), changes a
quantity (`core.update_conferral_bounds`) and ends a surplus provision
(`core.end_conferral`); each of those functions
takes the row the transaction already holds. Materialization follows.
`convergeScheduledChanges` then writes the scheduled-change ledger. A spent
Stripe subscription schedule it finds is released after the commit
+1 -1
View File
@@ -37,7 +37,7 @@ Database objects sit in the `core` schema (defined in `internal/db/migrations/00
| Invoice reconcile | `ReconcileInvoice`: reads the invoice, its lines and the charges of its payment intent back from the Stripe API at the SDK's version, takes a per-invoice lock, and converges the invoice (created and numbered by the first run that finds it issued, then only moved forward), its lines (inserted once), and one payment per settled charge | `internal/integrations/stripe/workflows/invoice_reconcile.go` |
| Invoice sweep | The hourly Temporal schedule (`stripe-invoice-sweep`), also triggered at every boot, that reads back invoice mappings never read back, open invoices last read more than a day ago, and invoices named by dead-lettered invoice events, then fills payment methods and subscription links mapped since; it never renumbers, deletes, or reconciles a subscription. The Stripe provider page states when it last finished | `internal/integrations/stripe/workflows/invoice_sweep.go` |
| Outbox drainer | The recurring worker that executes outbox rows (create Stripe product / price / customer), retrying with backoff until a cap parks the row as `dead_letter` for operator-driven retry | `internal/integrations/stripe/workflows/outbox.go` |
| Reconciler | `ReconcileSubscription`: refetches the subscription from the Stripe API and converges console records and provisions to match | `internal/fulfillment/reconcile.go` |
| Reconciler | `ReconcileSubscription`: refetches the subscription from the Stripe API and converges console records and provisions to match. A desired product whose ladder another plan took from the subscription and holds is left alone and logged, while the subscription keeps billing; the reconcile writes in product id order under the pool row | `internal/fulfillment/reconcile.go` |
| Plan-change mechanics | Member-initiated switch and cancel, a preview of the price difference before switching, the commitment gating, and the scheduled-change bookkeeping | `internal/fulfillment/plan_change.go` |
| Scheduled-change sweeper | The hourly backstop that fires due scheduled changes Stripe itself cannot fire | `internal/fulfillment/sweep.go`; `internal/workflows/billing/` |
| Checkout | Runs the guard checks and starts the Stripe Checkout session; also creates the Stripe customer synchronously if the organization has none yet | `internal/server/billing.go` |
+4 -2
View File
@@ -34,6 +34,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| `core.product_conferral_shapes` | The view conferral reads for a product's ladder positions and availability facts (introduced in the Product Catalog card) | `00004_doc41_schema_and_data.sql` |
| `core.supersession_pairs` | The one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason` | `00033_supersession_pairs.sql` |
| `core.grant_replacements` | A view of which source replaced a grant: it reads its pairs from `core.supersession_pairs` and keeps those whose ended provision is a grant's and whose successor is the grant or subscription that took the rung, leaving out extensions and a grant's own re-conferral | `00023_grant_resumption.sql`; `00033_supersession_pairs.sql` |
| `core.subscription_displacements` | The ladders of a pool on which a subscription is displaced and another plan holds the ladder (invariant 20): per ladder, the product the subscription lost, the instant, and the holder's provision and source. Displaced means the subscription's latest episode on the ladder ended in a supersession (a pair in `core.supersession_pairs`) whose successor belongs to another subscription or to no subscription; held means an active or suspended position of a non-default source other than the subscription sits on the ladder. The subscription reconcile reads it; it reads no `reason` | `00034_subscription_displacements.sql` |
| `core.transition_change_kind` | The kind of change one transition records, which the Tier changes Change badge and the recent-activity line name: `resumption` for a resumption's start, `extension` for an extension's transfer or a legacy `extend` row, otherwise the row's type. A machine reason counts only from the writer that writes it, because Issue grant copies the operator's note into the reason | `00025_superseded_transitions.sql` |
| `core.superseded_ends` | A view of the `end` rows a typed row of the same act states: an `end` and a typed row with a from side in the same pool, at the same `effective_at`, by the same actor, with the same reason, on any ladder, since supersession ends every rung of the incumbent. The Tier changes list leaves out every one; the recent-activity feed leaves out only those an extension's transfer states (`by_extension`). A grant that a plan act creates already points at its act in `core.grants.act_id` (the [Acts card](acts.md)), but a transition row has no such column, so the view still matches the rows by pool, instant, actor and reason. When the writers of transitions record their act (#179), the view reads that pointer and only its body changes. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
| `core.extension_handoffs` | A view of the transitions that record an extension, each with the grant it handed the position to. The recent-activity feed leaves those grants' rows out: the transfer, which reads "Extended", is the extension's one row. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
@@ -43,7 +44,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| `core.org_types.default_plan_ladder_id` | A column on the organization-types table (every organization has a type; today all are the personal type): it points at the ladder whose rank-0 tier is that type's baseline plan — the only place a default is configured | `00001_init.sql` |
| Conferral boundary | The one Go file that calls the five functions: typed wrappers, error mapping, and the grant path (write the grant decree, confer it, then materialize) | `internal/entitlements/conferral.go` |
| Default restoration | Resolves the org type's default ladder to its rank-0 product and confers it — plus the floor guard that restores only when the pool has no live occupancy on any ladder | `internal/entitlements/reapply_defaults.go` |
| Stripe-driven convergence | Translates a subscription's remote state into confer / end / suspend-resume calls (see the Payments and Billing card) | `internal/fulfillment/reconcile.go` |
| Stripe-driven convergence | Translates a subscription's remote state into confer / end / suspend-resume calls, and leaves a product alone while another plan holds a ladder it took from the subscription (invariant 20; see the Payments and Billing card) | `internal/fulfillment/reconcile.go` |
| Member plan moves | Switch, cancel, and the commitment gating layered above conferral | `internal/fulfillment/plan_change.go` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch and fulfillment's plan changes read it. The query returns every live position with the position's and the subscription's status, active rows first, and "held" is what each reader makes of it. The Products page, Checkout and the switch hold a suspended position as the current plan, while it delivers nothing | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member move offer | `plans.Offer` decides which move an organization is offered toward a price on a ladder, `plans.Across` combines a product's ladders, `plans.OffLadder` decides a product on none; the Products page draws from them and Checkout, the switch and its preview refuse anything else | `internal/plans/offer.go` |
@@ -63,7 +64,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
4. **[db]** One live provision per source: per grant, per purchase, and per product within a subscription (a subscription can deliver several products, so uniqueness is per product inside it). A retried conferral cannot double-deliver, and a source that ended can deliver again later. ("Purchase" is the schema's third source arm, reserved for one-time purchases; nothing creates purchase-sourced provisions yet.)
5. **[db-code]** Callers never choose a transition type. `core.confer` derives it from ranks: no previous rank means initiate, a higher rank is an upgrade, a lower one a downgrade, and the same rank is a transfer — which can only happen when a different source takes over the position, because the same source re-conferring is a no-op (invariant 13).
6. **[db]** Every new transition row names the occupancy episode it belongs to, and operator-attributed rows must carry the acting person's id.
7. **[db-code]** Supersession is whole-bundle and atomic: displacement triggers when the new conferral's product shares at least one ladder with an incumbent position, and then *every* rung that incumbent held ends — including rungs on ladders the new product is not on — the incumbent's provision ends, and the replacement with all its rungs is created, inside one `core.confer` call. Supersession ends the incumbent's delivery, not its source: a superseded grant stays a standing decree, and invariant 18 says when it delivers again.
7. **[db-code]** Supersession is whole-bundle and atomic: displacement triggers when the new conferral's product shares at least one ladder with an incumbent position, and then *every* rung that incumbent held ends — including rungs on ladders the new product is not on — the incumbent's provision ends, and the replacement with all its rungs is created, inside one `core.confer` call. Supersession ends the incumbent's delivery, not its source: a superseded grant stays a standing decree, and invariant 18 says when it delivers again and invariant 20 says when a superseded subscription does.
8. **[db-code]** `core.confer` only activates in the present: a future activation time is rejected (future intent belongs in scheduled changes), and a backdated one may not be earlier than the activation time of the incumbent it supersedes.
9. **[db-code]** An ended episode stays ended. The status-sync trigger propagates a provision's suspension or recovery to its episode rows, but never to rows already ended — so recovering from suspension cannot resurrect a superseded rung. A resumption (invariant 18) is a new `core.confer` call from the grant's own row, writing a fresh provision and fresh episodes; no ended row is reopened.
10. **[app]** Cancelling to the default is an end plus a separate restoration, never a downgrade: ending never re-mints the baseline itself; each ending entry point (webhook, operator revoke, grant expiry, org-type migration, tier removal) separately calls the guarded restoration. The three that end a plan's delivery (reconcile, through which a subscription ends, operator revocation and grant expiry) first resume the marked grants that plan held, when they ended at least one of its provisions (invariant 18). A restored default and a resumed grant both record "initiate".
@@ -76,6 +77,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
17. **[app]** Every plan act's transition reason comes from one function, `PlanActReason` (`internal/entitlements/plan_act_runner.go`), which builds it from the act's detail row so the wording lives in one place. The three tier acts that write transitions (add, removal, reorder) name the ladder: `tier added to ladder <name>`, `tier removal (<product> from <ladder>)` and `plan-ladder tier reorder (<ladder>)`. The default change names the org type's key: `org-type default change (<org type>)`. `DeleteTier` records its act (cause `tier_removed`) but writes no transition, because no one holds the tier, so it builds no reason; a Retry moves no position and has no reason. A move that a drain settles later builds the reason from the original act's detail row, so it reads as the act's own, and a migrated or grandfathered position appends what was done to it (`MigratedReason`, `GrandfatheredReason`). The reason is stored on the transition rows, so it is a write-time snapshot: it records the name as it read then, and a later rename does not rewrite it. An act that cannot read the ladder's row refuses at the read of names and records nothing.
18. **[app]** When a subscription's end, a grant's revocation or a grant's expiry ends at least one provision of that plan, every grant `core.resumable_grants(now())` names with that plan as holder re-confers from its own row, with the reason `grant-resumption:subscription:`, `grant-resumption:revocation:` or `grant-resumption:expiration:` followed by the ending plan's id. The chain rule: a marked grant waits on the nearest plan above it in its chain of replacements that has not ended, and resumes when that plan ends; a plan between them that ends first (it expires, is revoked, or is unmarked while it waits) is passed over. A grant and its extensions are one plan, named by the lineage's head. `core.grant_waits` is the one statement of the walk and the conditions (marked, active, valid at the instant, delivering nothing, its product on at least one ladder whose rungs no source but the holder holds, and first in resumption order among grants sharing a ladder), and the operator's delivery states and the member's held rung read it through the two functions. No other ending path resumes a grant: not a suspension, not a subscription dropping an item, not an extension, not an org-type migration and not a tier removal.
19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up, through Checkout. Checkout never supersedes a position on another ladder of the product unless that ladder offers Checkout too: Checkout of a product refuses unless every ladder it is a tier of offers it. Two exceptions are open (#88), because the decision reads a position only on the ladders it is asked about. The plan switch: a switch to a product that is a tier of several ladders can supersede a position on another of them, a grant's included, because deciding it needs the subscription that holds each ladder. And a rung on a ladder the product is not a tier of: supersession is whole-bundle (invariant 7), so Checkout that supersedes a grant's position on a ladder of the product also ends that grant's rungs on ladders the product is not a tier of, and no default is restored there while the pool holds the product (invariant 11). `plans.Offer` and `plans.Across` state the rule; the Products page, Checkout and the switch read it.
20. **[db-code]** and **[app]** A subscription's reconcile does not take back a ladder another plan took from it. While a position of a non-default source other than the subscription holds a ladder on which the subscription's latest episode ended in a supersession by another source, the reconcile confers none of the subscription's products whose shape names that ladder; the subscription keeps billing and delivers nothing there, and its next reconcile after no such plan holds the ladder confers it again. A default-sourced position never holds a ladder against a subscription (invariant 11). `core.subscription_displacements` is the one statement of which ladders those are; the reconcile reads it.
## Dimensions
+1
View File
@@ -161,6 +161,7 @@ An operator can issue a grant to move a pool to a specific tier:
The system:
- Creates a grant with the chosen reason and the resumption mark, then calls the conferral boundary (`internal/entitlements/conferral.go`) through the operator handler in `internal/server/operator_enrollment.go`: it writes the grant decree, confers the position onto the organization's default pool, then materializes the organization's effective entitlements
- Registers a **Temporal workflow** to expire the grant at `valid_until` when one is set
- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription keeps billing and delivers nothing there while the grant lasts, and delivers again at its next reconcile after the grant ends
While Temporal is not connected, which on a deployment without
`temporal-host` is always, a grant with **Valid until** is refused before
+1
View File
@@ -211,6 +211,7 @@ A member moves between the paid tiers of a plan ladder on the Products page, and
- **Cancellation.** Every paid plan offers **Cancel plan** on its card. Access continues until the end of the paid period (`cancel_at_period_end`), after which the organization moves to its type's default plan, when one is configured, or leaves the ladder.
- **Taking a scheduled move back.** While a downgrade or a cancellation is scheduled, the plan's card offers **Keep plan**, which releases the schedule or clears `cancel_at_period_end` at no charge. While a downgrade is scheduled, **Cancel plan** follows it and replaces the downgrade; while a cancellation is scheduled, **Keep plan** is the only control.
- **A plan whose payment is due.** A subscription Stripe holds as `past_due` or `unpaid` keeps its card on the Products page, shown as the current plan with its state, while its entitlements are suspended. No switch is offered in either direction and a posted one is refused before any Stripe call, because a switch would invoice while the failed invoice is still open. **Keep plan** and **Cancel plan** at the period end work as on an active plan. A posted immediate cancellation is refused, because Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices, and a **Keep plan** over a downgrade that Stripe has already applied is refused, because there is nothing left to take back. The unpaid invoice stays due after a cancellation. A paused subscription shows its state and is refused every act.
- **A grant over a paid plan.** An operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked. The subscription keeps billing and delivers nothing there, and the console changes nothing at Stripe. Once only the organization type's default holds the ladder, or nothing does, the subscription's next reconcile delivers its tier again. A new purchase over a grant still replaces the grant.
- **Pay invoice.** The card of a plan whose payment is due, and the detail of an open invoice, link to Stripe's hosted invoice page. The console reads the page's URL from Stripe when the member opens the link and redirects there; it never stores or logs the URL, which is a bearer link, and it needs no key permission beyond the reads of subscriptions and invoices that the reconciles already make.
A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. Stripe names the missing permission in the error a key without it receives.
@@ -0,0 +1,83 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- +goose StatementBegin
-- ---------------------------------------------------------------------------
-- The ladders a subscription is displaced on (openspec change
-- displaced-subscription-waits). For the subscription and pool named, the
-- function returns one row per ladder on which the subscription is displaced
-- and another plan holds the ladder: the product the subscription lost
-- there, the instant it lost it, and the holder's provision and source.
--
-- Two rules decide a row. The subscription is displaced on a ladder when its
-- latest episode there (by activated_at, then the episode's id) is ended and
-- core.supersession_pairs pairs that episode's end row with a successor whose
-- provision belongs to another subscription or to no subscription; an
-- episode the subscription ended itself, or a tier removal ended, has no
-- such pair. Another plan holds the ladder when an active or suspended
-- position of a provision other than the subscription's sits on it.
--
-- A default-sourced position never holds a ladder against a subscription: the
-- default is the floor, never a deliberate holder (invariant 11 of
-- docs/models/plan-ladders-transitions.md), so a subscription delivers again
-- over it.
--
-- This is the one statement the subscription reconcile and the organization
-- page read. It reads no pool_provision_transitions.reason, which the reader
-- role withholds, and reads transitions only through the pairs view.
-- ---------------------------------------------------------------------------
CREATE FUNCTION core.subscription_displacements(p_subscription_id UUID, p_pool_id UUID)
RETURNS TABLE (
plan_ladder_id UUID,
displaced_product_id UUID,
displaced_at TIMESTAMPTZ,
holder_provision_id UUID,
holder_source TEXT
)
LANGUAGE sql
STABLE
AS $$
WITH latest AS (
SELECT DISTINCT ON (l.plan_ladder_id)
l.plan_ladder_id, l.provision_id, l.product_id, l.status, l.ended_at
FROM core.pool_provision_ladders l
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
WHERE l.pool_id = p_pool_id
AND p.subscription_id = p_subscription_id
ORDER BY l.plan_ladder_id, l.activated_at DESC, l.provision_ladder_id DESC
),
displaced AS (
SELECT lt.plan_ladder_id, lt.product_id, lt.ended_at
FROM latest lt
WHERE lt.status = 'ended'
AND EXISTS (
SELECT 1
FROM core.supersession_pairs sp
JOIN core.pool_provisions succ ON succ.provision_id = sp.successor_provision_id
WHERE sp.pool_id = p_pool_id
AND sp.plan_ladder_id = lt.plan_ladder_id
AND sp.ended_provision_id = lt.provision_id
AND sp.effective_at = lt.ended_at
AND succ.subscription_id IS DISTINCT FROM p_subscription_id)
)
SELECT d.plan_ladder_id, d.product_id, d.ended_at, h.provision_id,
CASE WHEN hp.grant_id IS NOT NULL THEN 'grant'
WHEN hp.subscription_id IS NOT NULL THEN 'subscription'
ELSE 'purchase' END
FROM displaced d
JOIN core.pool_provision_ladders h
ON h.pool_id = p_pool_id
AND h.plan_ladder_id = d.plan_ladder_id
AND h.status IN ('active', 'suspended')
AND NOT h.default_sourced
JOIN core.pool_provisions hp ON hp.provision_id = h.provision_id
WHERE hp.subscription_id IS DISTINCT FROM p_subscription_id
ORDER BY d.plan_ladder_id, h.provision_id;
$$;
-- +goose StatementEnd
GRANT EXECUTE ON FUNCTION core.subscription_displacements(UUID, UUID) TO core_reader, core_writer, core_owner;
-- +goose Down
DROP FUNCTION core.subscription_displacements(UUID, UUID);
@@ -343,6 +343,48 @@ func (q *Queries) GetPlanPoolBucket(ctx context.Context, arg GetPlanPoolBucketPa
return bucket, err
}
const listConferralShapes = `-- name: ListConferralShapes :many
SELECT s.product_id, s.plan_ladder_id, s.rank
FROM core.product_conferral_shapes s
WHERE s.product_id = ANY($1::uuid[])
AND s.plan_ladder_id IS NOT NULL
ORDER BY s.product_id, s.plan_ladder_id
`
type ListConferralShapesRow struct {
ProductID string `json:"product_id"`
PlanLadderID uuid.NullUUID `json:"plan_ladder_id"`
Rank sql.NullInt32 `json:"rank"`
}
// The ladders and ranks of the products' current shapes
// (core.product_conferral_shapes), one row per rung; a product on no ladder
// has none. The reconcile reads it under the pool row and the shared rendezvous it
// holds; the rendezvous keeps the ranks from changing under it
// (docs/database-locks.md, the rendezvous).
func (q *Queries) ListConferralShapes(ctx context.Context, productIds []string) ([]ListConferralShapesRow, error) {
rows, err := q.db.QueryContext(ctx, listConferralShapes, pq.Array(productIds))
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListConferralShapesRow{}
for rows.Next() {
var i ListConferralShapesRow
if err := rows.Scan(&i.ProductID, &i.PlanLadderID, &i.Rank); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLadderPositionsByPool = `-- name: ListLadderPositionsByPool :many
SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at,
l.status AS position_status,
@@ -100,6 +100,68 @@ func (q *Queries) ListRecentTransitions(ctx context.Context, limit int32) ([]Lis
return items, nil
}
const listSubscriptionDisplacements = `-- name: ListSubscriptionDisplacements :many
SELECT d.plan_ladder_id::uuid AS plan_ladder_id,
pl.name AS ladder_name,
d.displaced_product_id::uuid AS displaced_product_id,
d.displaced_at::timestamptz AS displaced_at,
d.holder_provision_id::uuid AS holder_provision_id,
d.holder_source::text AS holder_source
FROM core.subscription_displacements($1::uuid, $2::uuid)
AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)
JOIN core.plan_ladders pl ON pl.plan_ladder_id = d.plan_ladder_id::uuid
ORDER BY pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid
`
type ListSubscriptionDisplacementsParams struct {
SubscriptionID string `json:"subscription_id"`
PoolID string `json:"pool_id"`
}
type ListSubscriptionDisplacementsRow struct {
PlanLadderID string `json:"plan_ladder_id"`
LadderName string `json:"ladder_name"`
DisplacedProductID string `json:"displaced_product_id"`
DisplacedAt time.Time `json:"displaced_at"`
HolderProvisionID string `json:"holder_provision_id"`
HolderSource string `json:"holder_source"`
}
// The ladders of the pool on which the subscription is displaced and another
// plan holds the ladder (displaced-subscription-waits D1, D2), with the
// ladder's name. core.subscription_displacements is the rule's one
// statement; the aliases repeat its columns because sqlc cannot read a
// function's RETURNS TABLE. The reconcile calls it under the pool row.
func (q *Queries) ListSubscriptionDisplacements(ctx context.Context, arg ListSubscriptionDisplacementsParams) ([]ListSubscriptionDisplacementsRow, error) {
rows, err := q.db.QueryContext(ctx, listSubscriptionDisplacements, arg.SubscriptionID, arg.PoolID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSubscriptionDisplacementsRow{}
for rows.Next() {
var i ListSubscriptionDisplacementsRow
if err := rows.Scan(
&i.PlanLadderID,
&i.LadderName,
&i.DisplacedProductID,
&i.DisplacedAt,
&i.HolderProvisionID,
&i.HolderSource,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTransitionsByPool = `-- name: ListTransitionsByPool :many
SELECT
+12
View File
@@ -252,6 +252,12 @@ type Querier interface {
// pending pool. A change whose remaining rows are all failed owes the drain
// nothing until an operator's Retry requeues them.
ListChangesWithUnsettledObligations(ctx context.Context, limit int32) ([]ListChangesWithUnsettledObligationsRow, error)
// The ladders and ranks of the products' current shapes
// (core.product_conferral_shapes), one row per rung; a product on no ladder
// has none. The reconcile reads it under the pool row and the shared rendezvous it
// holds; the rendezvous keeps the ranks from changing under it
// (docs/database-locks.md, the rendezvous).
ListConferralShapes(ctx context.Context, productIds []string) ([]ListConferralShapesRow, error)
ListContributionsByEntitlementID(ctx context.Context, entitlementID string) ([]NumericEntitlementContribution, error)
// Returns only grants currently delivering entitlements to a pool in the
// given org — i.e. grants with at least one provision in status='active'.
@@ -554,6 +560,12 @@ type Querier interface {
// definition leaves no two of them sharing a ladder, so their conferrals
// never block each other. The caller holds the pool lock.
ListResumableGrantsHeldBy(ctx context.Context, arg ListResumableGrantsHeldByParams) ([]ListResumableGrantsHeldByRow, error)
// The ladders of the pool on which the subscription is displaced and another
// plan holds the ladder (displaced-subscription-waits D1, D2), with the
// ladder's name. core.subscription_displacements is the rule's one
// statement; the aliases repeat its columns because sqlc cannot read a
// function's RETURNS TABLE. The reconcile calls it under the pool row.
ListSubscriptionDisplacements(ctx context.Context, arg ListSubscriptionDisplacementsParams) ([]ListSubscriptionDisplacementsRow, error)
// The pools among pool_ids (the pools the tier add has locked) that the add
// cannot align the product's provisions in, ascending by pool_id: a pool that
// already holds a live position on the ladder (held_product_name is the
@@ -278,3 +278,15 @@ LEFT JOIN (
WHERE rp.pool_id = ANY(sqlc.arg(pool_ids)::uuid[])
AND (rung.pool_id IS NOT NULL OR twice.pool_id IS NOT NULL)
ORDER BY rp.pool_id;
-- name: ListConferralShapes :many
-- The ladders and ranks of the products' current shapes
-- (core.product_conferral_shapes), one row per rung; a product on no ladder
-- has none. The reconcile reads it under the pool row and the shared rendezvous it
-- holds; the rendezvous keeps the ranks from changing under it
-- (docs/database-locks.md, the rendezvous).
SELECT s.product_id, s.plan_ladder_id, s.rank
FROM core.product_conferral_shapes s
WHERE s.product_id = ANY(sqlc.arg(product_ids)::uuid[])
AND s.plan_ladder_id IS NOT NULL
ORDER BY s.product_id, s.plan_ladder_id;
@@ -66,3 +66,20 @@ WHERE NOT EXISTS (
WHERE s.transition_id = t.transition_id AND s.by_extension)
ORDER BY t.effective_at DESC, t.transition_id DESC
LIMIT $1;
-- name: ListSubscriptionDisplacements :many
-- The ladders of the pool on which the subscription is displaced and another
-- plan holds the ladder (displaced-subscription-waits D1, D2), with the
-- ladder's name. core.subscription_displacements is the rule's one
-- statement; the aliases repeat its columns because sqlc cannot read a
-- function's RETURNS TABLE. The reconcile calls it under the pool row.
SELECT d.plan_ladder_id::uuid AS plan_ladder_id,
pl.name AS ladder_name,
d.displaced_product_id::uuid AS displaced_product_id,
d.displaced_at::timestamptz AS displaced_at,
d.holder_provision_id::uuid AS holder_provision_id,
d.holder_source::text AS holder_source
FROM core.subscription_displacements(sqlc.arg(subscription_id)::uuid, sqlc.arg(pool_id)::uuid)
AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)
JOIN core.plan_ladders pl ON pl.plan_ladder_id = d.plan_ladder_id::uuid
ORDER BY pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid;
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// core.subscription_displacements (openspec change displaced-subscription-waits,
// task 4.5): the ladders on which another plan took a subscription's tier and
// holds the ladder. Each test runs in the world's rolled-back transaction,
// reads through ListSubscriptionDisplacements and prints the rows it got.
import (
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// displacements reads the subscription's displacements in the world's pool.
func (w *resumeWorld) displacements(sub string) []entitlements.ListSubscriptionDisplacementsRow {
w.t.Helper()
rows, err := w.q.ListSubscriptionDisplacements(w.ctx, entitlements.ListSubscriptionDisplacementsParams{
SubscriptionID: sub,
PoolID: w.org.pool.PoolID,
})
if err != nil {
w.t.Fatalf("read the displacements of subscription %s: %v", sub, err)
}
return rows
}
// extend extends the grant whose provision is provisionID, returning the
// extension's provision.
func (w *resumeWorld) extend(provisionID string) string {
w.t.Helper()
ext, err := entitlements.ExtendGrantTx(w.ctx, w.tx, entitlements.ExtendGrantInput{
OrgID: w.org.org.OrgID, PoolID: w.org.pool.PoolID, ProvisionID: provisionID,
GrantedByPersonID: w.org.person.PersonID, ActorID: personActor(w.org),
})
if err != nil {
w.t.Fatalf("extend provision %s: %v", provisionID, err)
}
return ext.ProvisionID
}
func TestSubscriptionDisplacements_AGrantOverTheSubscription(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.conferSub(sub, p.plus)
grantProvision := p.confer(p.grant(p.pro, grantOpts{}))
got := p.displacements(sub)
if len(got) != 1 || got[0].PlanLadderID != p.wiki || !strings.Contains(got[0].LadderName, "Wiki ") ||
got[0].DisplacedProductID != p.plus || got[0].HolderProvisionID != grantProvision || got[0].HolderSource != "grant" {
t.Errorf("displacements = %+v, want one on Wiki naming the grant's provision", got)
}
}
func TestSubscriptionDisplacements_AnExtensionStillHolds(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.conferSub(sub, p.plus)
grantProvision := p.confer(p.grant(p.pro, grantOpts{}))
extension := p.extend(grantProvision)
got := p.displacements(sub)
if len(got) != 1 || got[0].HolderProvisionID != extension {
t.Errorf("displacements = %+v, want the extension's provision as the holder", got)
}
}
func TestSubscriptionDisplacements_TheDefaultDoesNotHold(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.conferSub(sub, p.plus)
g := p.grant(p.pro, grantOpts{})
p.confer(g)
p.revoke(g)
if got := p.holdsLadder(p.wiki); got != p.free {
t.Fatalf("precondition: Wiki holds %q after the revocation, want Free from the floor", got)
}
if got := p.displacements(sub); len(got) != 0 {
t.Errorf("displacements = %+v, want none while only the default holds Wiki", got)
}
}
func TestSubscriptionDisplacements_ASuccessorWithNoFromSideIsNotADisplacement(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.conferSub(sub, p.plus)
p.removeTier(p.wiki, p.plus)
p.confer(p.grant(p.pro, grantOpts{}))
if got := p.holdsLadder(p.wiki); got != p.pro {
t.Fatalf("precondition: Wiki holds %q, want the grant's Pro", got)
}
if got := p.displacements(sub); len(got) != 0 {
t.Errorf("displacements = %+v, want none: the successor's row has no from side", got)
}
}
func TestSubscriptionDisplacements_AnEpisodeTheSubscriptionEndedIsNotADisplacement(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.endProvision(p.conferSub(sub, p.plus))
p.confer(p.grant(p.pro, grantOpts{}))
if got := p.displacements(sub); len(got) != 0 {
t.Errorf("displacements = %+v, want none: the subscription ended its own episode", got)
}
}
func TestSubscriptionDisplacements_TheSubscriptionsOwnSwitch(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.conferSub(sub, p.plus)
p.conferSub(sub, p.pro)
if got := p.displacements(sub); len(got) != 0 {
t.Fatalf("displacements = %+v, want none after the subscription's own switch", got)
}
p.confer(p.grant(p.maxTier, grantOpts{}))
got := p.displacements(sub)
if len(got) != 1 || got[0].DisplacedProductID != p.pro {
t.Errorf("displacements = %+v, want one with Pro displaced", got)
}
}
func TestSubscriptionDisplacements_TheLatestEpisodeDecides(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.conferSub(sub, p.plus)
first := p.confer(p.grant(p.pro, grantOpts{}))
p.endProvision(first)
p.conferSub(sub, p.plus)
if got := p.displacements(sub); len(got) != 0 {
t.Fatalf("displacements = %+v, want none once the subscription holds Wiki again", got)
}
second := p.confer(p.grant(p.maxTier, grantOpts{}))
got := p.displacements(sub)
if len(got) != 1 || got[0].HolderProvisionID != second {
t.Errorf("displacements = %+v, want the second grant's provision as the holder", got)
}
}
func TestSubscriptionDisplacements_ASecondSubscription(t *testing.T) {
p := newPlusProWorld(t)
first, second := p.subscription(), p.subscription()
p.conferSub(first, p.plus)
secondProvision := p.conferSub(second, p.pro)
got := p.displacements(first)
if len(got) != 1 || got[0].HolderProvisionID != secondProvision || got[0].HolderSource != "subscription" {
t.Errorf("displacements of the first subscription = %+v, want one naming the second's provision", got)
}
if got := p.displacements(second); len(got) != 0 {
t.Errorf("displacements of the second subscription = %+v, want none", got)
}
}
func TestSubscriptionDisplacements_OneLadderOfABundle(t *testing.T) {
p := newPlusProWorld(t)
docsFree, siteFree, bundle, higher := p.product("Docs Free"), p.product("Site Free"), p.product("Bundle"), p.product("Site Higher")
p.ladder("Docs", docsFree, bundle)
site := p.ladder("Site", siteFree, bundle, higher)
sub := p.subscription()
p.conferSub(sub, bundle)
p.confer(p.grant(higher, grantOpts{}))
got := p.displacements(sub)
if len(got) != 1 || got[0].PlanLadderID != site {
t.Errorf("displacements = %+v, want one on Site and none on Docs", got)
}
}
@@ -0,0 +1,471 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fulfillment_test
// The reconcile leaves a ladder alone while another plan holds it that took it
// from the subscription (openspec change displaced-subscription-waits, design
// D2, tasks 4.6). The fixture is seedFixture's: its product is the
// subscription's tier, secondTier and addTier put the tiers a grant takes
// above it, and every Stripe answer is the mock's.
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"github.com/google/uuid"
stripe "github.com/stripe/stripe-go/v81"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
// subscribed is a subscription on the fixture's tier, reconciled once, with
// the mock installed.
func subscribed(t *testing.T) (*sql.DB, fixture, *stripetest.MockBackend) {
t.Helper()
database := dbtest.Open(t)
fx := seedFixture(t, database)
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
reconcile(t, database, fx.stripeSubID, "test:created")
return database, fx, mock
}
// grantOver is a subscription on the fixture's tier and an operator's grant of
// the second tier over it, which must deliver.
func grantOver(t *testing.T, marked bool) (*sql.DB, fixture, *stripetest.MockBackend, entitlements.Grant) {
t.Helper()
database, fx, mock := subscribed(t)
prod2, _, _ := secondTier(t, database, fx)
g := issueGrant(t, database, fx, prod2, marked)
if !grantDelivers(t, database, g.GrantID) {
t.Fatalf("precondition: the grant does not deliver after it was issued over the subscription")
}
return database, fx, mock, g
}
// revokeGrant revokes the grant in a materializing transaction, as the
// operator's act does, and runs the floor with it.
func revokeGrant(t *testing.T, database *sql.DB, fx fixture, g entitlements.Grant) {
t.Helper()
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database)
if err != nil {
t.Fatalf("begin the revocation: %v", err)
}
if _, err := entitlements.RevokeGrantTx(ctx, tx, entitlements.RevokeGrantInput{
GrantID: g.GrantID, OrgID: fx.orgID, RevokedByPersonID: fixturePersonID(t, database, fx.orgID),
}); err != nil {
_ = tx.Rollback()
t.Fatalf("revoke the grant: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit the revocation: %v", err)
}
}
// extendGrant extends the grant in a materializing transaction and returns the
// extension.
func extendGrant(t *testing.T, database *sql.DB, fx fixture, g entitlements.Grant) entitlements.Grant {
t.Helper()
ctx := context.Background()
person := fixturePersonID(t, database, fx.orgID)
provision := scalarStr(t, database, `SELECT provision_id FROM core.pool_provisions WHERE grant_id = $1 AND status <> 'ended'`, g.GrantID)
tx, err := entitlements.BeginMaterializing(ctx, database)
if err != nil {
t.Fatalf("begin the extension: %v", err)
}
res, err := entitlements.ExtendGrantTx(ctx, tx, entitlements.ExtendGrantInput{
OrgID: fx.orgID, PoolID: fx.poolID, ProvisionID: provision, GrantedByPersonID: person,
ActorID: uuid.NullUUID{UUID: uuid.MustParse(person), Valid: true},
})
if err != nil {
_ = tx.Rollback()
t.Fatalf("extend the grant: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit the extension: %v", err)
}
return res.Grant
}
// liveProvisions counts the subscription's provisions not ended.
func liveProvisions(t *testing.T, database *sql.DB, fx fixture) int {
t.Helper()
return scalarInt(t, database, `SELECT count(*) FROM core.pool_provisions WHERE subscription_id = $1 AND status <> 'ended'`,
coreSubID(t, database, fx.stripeSubID))
}
// reconcileLeavesTheGrant reconciles the fixture's subscription and checks that
// the grant still delivers, the subscription conferred nothing, the pool
// gained no row and Stripe saw only the subscription's read.
func reconcileLeavesTheGrant(t *testing.T, database *sql.DB, fx fixture, mock *stripetest.MockBackend, g entitlements.Grant, grantMsg string) {
t.Helper()
before, mark := poolRows(t, database, fx), len(mock.Calls)
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if !grantDelivers(t, database, g.GrantID) {
t.Error(grantMsg)
}
if n := liveProvisions(t, database, fx); n != 0 {
t.Errorf("the subscription holds %d live provisions, want 0", n)
}
if n := poolRows(t, database, fx); n != before {
t.Errorf("the reconcile wrote %d rows", n-before)
}
if calls := callsSince(mock, mark); len(calls) != 1 || !strings.HasPrefix(calls[0], "stripe GET /v1/subscriptions/") {
t.Errorf("the reconcile made Stripe calls beyond the subscription's read: %v", calls)
}
}
// T1: an unmarked grant over the subscription keeps the ladder through a
// reconcile with the items unchanged and one with the quantity changed.
func TestReconcile_AGrantOverTheSubscriptionKeepsTheLadder(t *testing.T) {
database, fx, mock, g := grantOver(t, false)
reconcileLeavesTheGrant(t, database, fx, mock, g, "the grant stopped delivering after the subscription's reconcile")
three := fx.planItem()
three.Quantity = 3
mock.Sub = fx.activeSub(three)
reconcileLeavesTheGrant(t, database, fx, mock, g, "the grant stopped delivering after the subscription's reconcile")
}
// T1b: a grant of the subscription's own tier takes the ladder as a transfer,
// and keeps it.
func TestReconcile_AGrantOfTheSubscriptionsOwnTierKeepsTheLadder(t *testing.T) {
database, fx, mock := subscribed(t)
g := issueGrant(t, database, fx, fx.productID, false)
if !grantDelivers(t, database, g.GrantID) {
t.Fatalf("precondition: the grant of the subscription's own tier does not deliver")
}
reconcileLeavesTheGrant(t, database, fx, mock, g, "the grant of the subscription's own tier stopped delivering")
}
// T3: a marked grant over the subscription delivers, and the subscription does
// not replace it.
func TestReconcile_AMarkedGrantOverTheSubscriptionIsNotReplaced(t *testing.T) {
database, fx, _, g := grantOver(t, true)
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if !grantDelivers(t, database, g.GrantID) {
t.Errorf("the marked grant stopped delivering")
}
if n := scalarInt(t, database, `SELECT count(*) FROM core.grant_replacements WHERE grant_id = $1 AND replacing_subscription_id = $2`,
g.GrantID, coreSubID(t, database, fx.stripeSubID)); n != 0 {
t.Errorf("core.grant_replacements pairs the marked grant with the subscription")
}
}
// withDefaultOnTheLadder makes a free rank-0 tier the default of the fixture's
// ladder.
func withDefaultOnTheLadder(t *testing.T, database *sql.DB, fx fixture) string {
t.Helper()
free := addRankZeroDefault(t, database, fx)
setPersonalDefault(t, database, fx.ladderID)
return free
}
// T4: once the grant is revoked and the default holds the ladder, the
// subscription delivers again.
func TestReconcile_TheSubscriptionDeliversAgainOverTheDefault(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
free := withDefaultOnTheLadder(t, database, fx)
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
reconcile(t, database, fx.stripeSubID, "test:created")
prod2, _, _ := secondTier(t, database, fx)
g := issueGrant(t, database, fx, prod2, false)
revokeGrant(t, database, fx, g)
if n := activeAttachCount(t, database, fx.ladderID, free); n != 1 {
t.Fatalf("precondition: the default holds %d positions on the ladder, want 1", n)
}
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("the subscription's tier holds %d positions, want 1: the subscription did not deliver again over the default", n)
}
}
// T5: once the grant is revoked and nothing holds the ladder, because the floor
// found the pool occupied by a grant on another ladder, the subscription
// delivers again.
func TestReconcile_TheSubscriptionDeliversAgainOnAVacantLadder(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
free := withDefaultOnTheLadder(t, database, fx)
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
reconcile(t, database, fx.stripeSubID, "test:created")
otherProduct, _, _ := addLadder(t, database, fx, "other")
issueGrant(t, database, fx, otherProduct, false)
prod2, _, _ := secondTier(t, database, fx)
g := issueGrant(t, database, fx, prod2, false)
revokeGrant(t, database, fx, g)
if n := scalarInt(t, database, `SELECT count(*) FROM core.pool_provision_ladders WHERE pool_id = $1 AND plan_ladder_id = $2 AND status IN ('active', 'suspended')`,
fx.poolID, fx.ladderID); n != 0 {
t.Fatalf("precondition: the ladder holds %d positions, want it vacant (the default holds %d)", n, activeAttachCount(t, database, fx.ladderID, free))
}
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("the subscription's tier holds %d positions, want 1: the subscription did not deliver again on the vacant ladder", n)
}
}
// T6: a grant issued over the default after the first grant ended keeps the
// ladder against the subscription.
func TestReconcile_AGrantIssuedOverTheDefaultKeepsTheLadder(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
free := withDefaultOnTheLadder(t, database, fx)
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
t.Cleanup(stripetest.Install(mock))
reconcile(t, database, fx.stripeSubID, "test:created")
prod2, _, _ := secondTier(t, database, fx)
revokeGrant(t, database, fx, issueGrant(t, database, fx, prod2, false))
if n := activeAttachCount(t, database, fx.ladderID, free); n != 1 {
t.Fatalf("precondition: the default holds %d positions on the ladder, want 1", n)
}
later := issueGrant(t, database, fx, prod2, false)
if !grantDelivers(t, database, later.GrantID) {
t.Fatalf("precondition: the grant issued over the default does not deliver")
}
reconcileLeavesTheGrant(t, database, fx, mock, later, "the grant issued over the default stopped delivering")
}
// T6b: an extended grant keeps the ladder.
func TestReconcile_AnExtendedGrantKeepsTheLadder(t *testing.T) {
database, fx, mock, g := grantOver(t, false)
extension := extendGrant(t, database, fx, g)
if !grantDelivers(t, database, extension.GrantID) {
t.Fatalf("precondition: the extension does not deliver")
}
reconcileLeavesTheGrant(t, database, fx, mock, extension, "the extended grant stopped delivering")
}
// T7: a subscription bought over a grant supersedes it, and its next reconcile
// writes nothing.
func TestReconcile_ASubscriptionBoughtOverAGrantSupersedesIt(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
proProduct, _, stripePro := addTier(t, database, fx, "pro", 4000)
issueGrant(t, database, fx, fx.productID, true)
mock := &stripetest.MockBackend{Sub: fx.activeSub(item("si_pro_"+fx.sfx, stripePro))}
t.Cleanup(stripetest.Install(mock))
reconcile(t, database, fx.stripeSubID, "test:created")
if n := activeAttachCount(t, database, fx.ladderID, proProduct); n != 1 {
t.Errorf("the subscription bought over the grant holds %d positions, want 1", n)
}
before := poolRows(t, database, fx)
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, proProduct); n != 1 {
t.Errorf("the subscription bought over the grant holds %d positions, want 1", n)
}
if n := poolRows(t, database, fx); n != before {
t.Errorf("the second reconcile wrote %d rows", n-before)
}
}
// T8: a period-end downgrade that lands while a grant holds the ladder is
// applied, its schedule released, and its tier not conferred.
func TestSweep_ADowngradeThatLandsWhileDisplacedIsAppliedAndNotConferred(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
top, _, _ := addTier(t, database, fx, "top", 6000)
g := issueGrant(t, database, fx, top, false)
core := coreSubID(t, database, fx.stripeSubID)
if _, err := database.ExecContext(ctx, `UPDATE core.subscription_scheduled_changes
SET effective_at = NOW() - interval '1 minute'
WHERE subscription_id=$1 AND status='scheduled'`, core); err != nil {
t.Fatalf("make the row due: %v", err)
}
landed := fx.activeSub(fx.planItem())
landed.Schedule = mock.Sub.Schedule
mock.Sub = landed
if _, err := fulfillment.SweepDueScheduledChanges(ctx, database, discardLogger(), stripetest.KeyMode()); err != nil {
t.Fatalf("sweep: %v", err)
}
if n := rowCount(t, database, core, "plan_switch", "period_end", "applied"); n != 1 {
t.Errorf("applied downgrades = %d, want 1", n)
}
if !mock.Sent("POST", "/v1/subscription_schedules/"+mockSchedule+"/release") {
t.Error("the spent schedule was not released")
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
t.Errorf("the landed tier holds %d positions, want 0: the downgrade was conferred over the grant", n)
}
if !grantDelivers(t, database, g.GrantID) {
t.Error("the grant stopped delivering after the downgrade landed")
}
}
// secondSubscription is a second Stripe subscription of the fixture's customer
// on the product priced at stripePrice, with a Stripe item id of its own.
func secondSubscription(fx fixture, stripePrice string) *stripe.Subscription {
s := fx.activeSub(item("si_b_"+fx.sfx, stripePrice))
s.ID = "sub_b_" + fx.sfx
return s
}
// T9: the later of two subscriptions on one ladder keeps it through every
// reconcile of either, and the earlier delivers again once the later ends.
func TestReconcile_ALaterSubscriptionKeepsTheLadder(t *testing.T) {
database, fx, mock := subscribed(t)
prod2, _, stripePrice2 := secondTier(t, database, fx)
first := mock.Sub
second := secondSubscription(fx, stripePrice2)
step := func(s *stripe.Subscription) {
t.Helper()
mock.Sub = s
reconcile(t, database, s.ID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 1 {
t.Errorf("the later subscription's tier holds %d positions, want 1", n)
}
}
step(second)
step(first)
before := poolRows(t, database, fx)
step(second)
step(first)
if n := poolRows(t, database, fx); n != before {
t.Errorf("the reconciles after the second subscription wrote %d rows", n-before)
}
mock.Sub = withStatus(second, stripe.SubscriptionStatusCanceled)
reconcile(t, database, second.ID, "stripe:subscription.deleted")
mock.Sub = first
reconcile(t, database, first.ID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("the first subscription's tier holds %d positions, want 1 after the second ended", n)
}
}
// T11: a grant that commits while the reconcile waits for the pool is seen by
// the reconcile's reads.
func TestReconcile_AGrantIssuedWhileTheReconcileWaitsIsSeen(t *testing.T) {
database, fx, mock := subscribed(t)
ctx := context.Background()
prod2, _, stripePrice2 := secondTier(t, database, fx)
top, _, _ := addTier(t, database, fx, "top", 6000)
person := fixturePersonID(t, database, fx.orgID)
side, sidePID := beginMaterializingSide(t, database)
res, err := entitlements.ConferGrantTx(ctx, side, entitlements.ConferGrantInput{
ProductID: top,
OrgID: fx.orgID,
GrantedByPersonID: person,
GrantReason: "complimentary",
Quantity: 1,
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(person), Valid: true},
TransitionReason: "test: issue grant",
})
if err != nil {
t.Fatalf("grant act: %v", err)
}
moved := fx.planItem()
moved.Price = &stripe.Price{ID: stripePrice2}
mock.Sub = fx.activeSub(moved)
r := startReconcile(ctx, database, fx)
r.awaitBlockedBy(t, database, sidePID)
if err := side.Commit(); err != nil {
t.Fatalf("commit the grant act: %v", err)
}
if err := r.wait(); err != nil {
t.Fatalf("reconcile: %v", err)
}
if !grantDelivers(t, database, res.Grant.GrantID) {
t.Errorf("the grant issued while the reconcile waited stopped delivering")
}
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 0 {
t.Errorf("the changed item's tier holds %d positions, want 0", n)
}
}
// bundleOnTwoLadders is a product on the fixture's ladder and on a second
// ladder created after it, and a product above it on the second ladder only.
func bundleOnTwoLadders(t *testing.T, database *sql.DB, fx fixture) (bundle, stripeBundle, higher, secondLadder string) {
t.Helper()
ctx := context.Background()
bq := billing.New(database)
bundle, stripeBundle = addPriced(t, database, fx, "bundle")
higher, _ = addPriced(t, database, fx, "higher")
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: fx.ladderID, ProductID: bundle}); err != nil {
t.Fatalf("put the bundle on the fixture's ladder: %v", err)
}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{Name: "GR second " + fx.sfx, IsActive: true})
if err != nil {
t.Fatalf("second ladder: %v", err)
}
for _, p := range []string{bundle, higher} {
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: ladder.PlanLadderID, ProductID: p}); err != nil {
t.Fatalf("tier %s on the second ladder: %v", p, err)
}
}
return bundle, stripeBundle, higher, ladder.PlanLadderID
}
// T12: a bundle displaced on one of its ladders is left alone whole.
func TestReconcile_ABundleDisplacedOnOneLadderIsLeftAlone(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
bundle, stripeBundle, higher, secondLadder := bundleOnTwoLadders(t, database, fx)
mock := &stripetest.MockBackend{Sub: fx.activeSub(item("si_bundle_"+fx.sfx, stripeBundle))}
t.Cleanup(stripetest.Install(mock))
reconcile(t, database, fx.stripeSubID, "test:created")
g := issueGrant(t, database, fx, higher, false)
if !grantDelivers(t, database, g.GrantID) {
t.Fatalf("precondition: the grant on the second ladder does not deliver")
}
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, bundle) + activeAttachCount(t, database, secondLadder, bundle); n != 0 {
t.Errorf("the bundle holds %d positions, want 0", n)
}
if !grantDelivers(t, database, g.GrantID) {
t.Errorf("the grant on the second ladder stopped delivering")
}
if n := scalarInt(t, database, `SELECT count(*) FROM core.pool_provision_ladders WHERE pool_id = $1 AND plan_ladder_id = $2 AND status = 'active'`,
fx.poolID, fx.ladderID); n != 0 {
t.Errorf("the fixture's ladder holds %d positions, want 0", n)
}
}
// T13: an item that left a ladder by the subscription's own edit and came back
// supersedes the grant that took the vacant ladder meanwhile.
func TestReconcile_AnItemBackOnALadderItLeftSupersedesTheGrant(t *testing.T) {
database, fx, mock := subscribed(t)
_, stripeAddon := addPriced(t, database, fx, "addon")
prod2, _, _ := secondTier(t, database, fx)
mock.Sub = fx.activeSub(item("si_addon_"+fx.sfx, stripeAddon))
reconcile(t, database, fx.stripeSubID, "test:addon")
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
t.Fatalf("precondition: the tier holds %d positions after its item was replaced, want 0", n)
}
g := issueGrant(t, database, fx, prod2, false)
if !grantDelivers(t, database, g.GrantID) {
t.Fatalf("precondition: the grant does not deliver on the vacant ladder")
}
mock.Sub = fx.activeSub(fx.planItem())
reconcile(t, database, fx.stripeSubID, "test:back")
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("the subscription's tier holds %d positions, want 1", n)
}
}
+149 -6
View File
@@ -412,8 +412,10 @@ func upsertSubscription(
// reconcileItems converges the subscription's core item rows, then reconciles
// pool entitlements as a diff (design D4): desired = subscription items grouped
// by product (quantities summed); live = the subscription's non-ended
// provisions. Missing product -> confer; surplus product -> end_conferral;
// quantity-only delta -> update_conferral_bounds. A product tiering into any
// provisions. Missing product -> confer, unless another plan took its ladder
// from the subscription and holds it (core.subscription_displacements);
// surplus product -> end_conferral; quantity-only delta ->
// update_conferral_bounds. A product tiering into any
// number of ladders is a single confer call — no ladder-count branching.
//
// The pool row is taken after the subscription's lock and before the resume,
@@ -465,7 +467,7 @@ func reconcileItems(
}
}
conferred, err := convergeConferrals(ctx, entQ, items.desired, subUUID, poolID, actor)
conferred, err := convergeConferrals(ctx, logger, entQ, items.desired, subUUID, poolID, actor)
if err != nil {
return err
}
@@ -663,12 +665,99 @@ func sortedProducts(desired map[string]int32) []string {
return slices.Sorted(maps.Keys(desired))
}
// Rung is one ladder a product is a tier of, with the tier's rank.
type Rung struct {
LadderID string
Rank int32
}
// RungsByProduct groups the conferral shapes' rows by product, each product's
// rungs in the rows' order (ladder id order, as ListConferralShapes returns
// them). It reads no row.
func RungsByProduct(rows []entitlements.ListConferralShapesRow) map[string][]Rung {
rungs := make(map[string][]Rung)
for _, r := range rows {
if !r.PlanLadderID.Valid {
continue
}
rungs[r.ProductID] = append(rungs[r.ProductID], Rung{
LadderID: r.PlanLadderID.UUID.String(),
Rank: r.Rank.Int32,
})
}
return rungs
}
// conferralRungs reads the ladders and ranks of the products' current shapes,
// once, and groups them by product.
func conferralRungs(ctx context.Context, entQ *entitlements.Queries, products []string) (map[string][]Rung, error) {
rows, err := entQ.ListConferralShapes(ctx, products)
if err != nil {
return nil, fmt.Errorf("reconcile: list conferral shapes: %w", err)
}
return RungsByProduct(rows), nil
}
// Displacement is a ladder on which a subscription is displaced and another
// plan holds the ladder (core.subscription_displacements): the displaced
// product, and the holder's provision and source.
type Displacement struct {
LadderID string
LadderName string
DisplacedProductID string
HolderProvisionID string
HolderSource string
}
// displacementsFromRows turns the displacement query's rows into
// Displacements, in the rows' order.
func displacementsFromRows(rows []entitlements.ListSubscriptionDisplacementsRow) []Displacement {
out := make([]Displacement, 0, len(rows))
for _, r := range rows {
out = append(out, Displacement{
LadderID: r.PlanLadderID,
LadderName: r.LadderName,
DisplacedProductID: r.DisplacedProductID,
HolderProvisionID: r.HolderProvisionID,
HolderSource: r.HolderSource,
})
}
return out
}
// DisplacedProducts maps each missing product the reconcile leaves alone to
// the displacement that decides it: the first of its rungs, in rung order,
// whose ladder the subscription is displaced on. The check is per ladder, so
// another product of a displaced ladder is left alone too. It indexes the
// displacements by ladder, keeping the first row of each, and reads no row:
// the reconcile enforces the rule.
func DisplacedProducts(missing []string, rungs map[string][]Rung, displacements []Displacement) map[string]Displacement {
byLadder := make(map[string]Displacement, len(displacements))
for _, d := range displacements {
if _, seen := byLadder[d.LadderID]; !seen {
byLadder[d.LadderID] = d
}
}
left := make(map[string]Displacement)
for _, p := range missing {
for _, r := range rungs[p] {
if d, ok := byLadder[r.LadderID]; ok {
left[p] = d
break
}
}
}
return left
}
// convergeConferrals brings the subscription's live provisions to desired:
// a missing product is conferred, a quantity-only delta updates the bounds,
// and a provision no longer desired is ended. It reports whether it wrote
// anything.
// and a provision no longer desired is ended. A missing product whose ladder
// another plan took from the subscription and holds is left alone
// (core.subscription_displacements). It reports whether it wrote anything.
func convergeConferrals(
ctx context.Context,
logger *slog.Logger,
entQ *entitlements.Queries,
desired map[string]int32,
subUUID uuid.UUID,
@@ -685,9 +774,18 @@ func convergeConferrals(
liveByProduct[p.ProductID] = p
}
order := sortedProducts(desired)
left, err := productsLeftAlone(ctx, logger, entQ, order, liveByProduct, subUUID, poolID)
if err != nil {
return false, err
}
// Missing product -> confer; quantity-only delta -> update_conferral_bounds.
changed := false
for _, productID := range sortedProducts(desired) {
for _, productID := range order {
if _, out := left[productID]; out {
continue
}
wrote, err := conferOrBound(ctx, entQ, liveByProduct, productID, desired[productID], subUUID, poolID, actor)
if err != nil {
return false, err
@@ -699,6 +797,51 @@ func convergeConferrals(
return changed || ended, err
}
// productsLeftAlone reads the shapes of the desired products and the
// subscription's displacements, takes as missing the products with no live
// provision, and returns those DisplacedProducts leaves alone, logging each in
// order once. Both reads run under the pool row the caller holds.
func productsLeftAlone(
ctx context.Context,
logger *slog.Logger,
entQ *entitlements.Queries,
order []string,
liveByProduct map[string]entitlements.PoolProvision,
subUUID uuid.UUID,
poolID string,
) (map[string]Displacement, error) {
rungs, err := conferralRungs(ctx, entQ, order)
if err != nil {
return nil, err
}
rows, err := entQ.ListSubscriptionDisplacements(ctx, entitlements.ListSubscriptionDisplacementsParams{
SubscriptionID: subUUID.String(),
PoolID: poolID,
})
if err != nil {
return nil, fmt.Errorf("reconcile: list subscription displacements: %w", err)
}
var missing []string
for _, productID := range order {
if _, ok := liveByProduct[productID]; !ok {
missing = append(missing, productID)
}
}
left := DisplacedProducts(missing, rungs, displacementsFromRows(rows))
for _, productID := range order {
d, out := left[productID]
if !out {
continue
}
logger.Info("reconcile: product not conferred; another plan holds its ladder",
slog.String("subscription_id", subUUID.String()),
slog.String("product_id", productID),
slog.String("plan_ladder_id", d.LadderID),
slog.String("holder_provision_id", d.HolderProvisionID))
}
return left, nil
}
// conferOrBound confers productID from the subscription when it has no live
// provision, or updates the live provision's bounds when only its quantity
// differs. It reports whether it wrote anything.
@@ -217,8 +217,7 @@ func TestReconcile_SuspendedIncumbentSuperseded(t *testing.T) {
// 6.4e Superseded-while-suspended does NOT resurrect: after the grant
// supersession of 6.4d, the recovery of the subscription to active leaves the
// ended provision ended. The test drives the recovery through the reconcile,
// so the item pass runs; its last assertion pins #212 (the pass confers the
// subscription's tier over the grant) until the rule of displacement flips it.
// so the item pass runs, and the grant keeps the ladder it took.
func TestReconcile_SupersededWhileSuspendedNotResurrected(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
@@ -259,15 +258,15 @@ func TestReconcile_SupersededWhileSuspendedNotResurrected(t *testing.T) {
t.Fatalf("reconcile recovered: %v", err)
}
if n := scalarInt(t, database,
`SELECT count(*) FROM core.pool_provisions WHERE subscription_id = $1 AND status <> 'ended'`, core); n != 1 {
t.Errorf("the subscription holds %d live provisions after the recovery, want 1", n)
`SELECT count(*) FROM core.pool_provisions WHERE subscription_id = $1 AND status <> 'ended'`, core); n != 0 {
t.Errorf("the subscription holds %d live provisions after the recovery, want 0", n)
}
if s := scalarStr(t, database,
`SELECT status FROM core.pool_provisions WHERE provision_id = $1`, incumbent); s != "ended" {
t.Errorf("incumbent status = %q, want ended (no resurrection)", s)
}
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 0 {
t.Errorf("grant tier attach after the recovery = %d, want 0", n)
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 1 {
t.Errorf("grant tier attach after the recovery = %d, want 1", n)
}
}
@@ -4,6 +4,7 @@
package fulfillment
import (
"maps"
"slices"
"testing"
@@ -42,3 +43,66 @@ func TestSortedProducts(t *testing.T) {
}
}
}
// displacedProductsRow is one case of DisplacedProducts: the missing products,
// their rungs, the subscription's displacements and the products left out.
type displacedProductsRow struct {
name string
missing []string
rungs map[string][]Rung
displacements []Displacement
want map[string]Displacement
}
// checkDisplacedProducts runs one row.
func checkDisplacedProducts(t *testing.T, row displacedProductsRow) {
t.Helper()
if got := DisplacedProducts(row.missing, row.rungs, row.displacements); !maps.Equal(got, row.want) {
t.Errorf("DisplacedProducts(%s) = %v, want %v", row.name, got, row.want)
}
}
// TestDisplacedProducts pins which missing products the reconcile leaves
// alone: those with a rung on a ladder the subscription is displaced on.
func TestDisplacedProducts(t *testing.T) {
onL1 := Displacement{LadderID: "L1", DisplacedProductID: "A", HolderProvisionID: "H"}
for _, row := range []displacedProductsRow{
{
name: "a missing product on a displaced ladder is left out",
missing: []string{"A"},
rungs: map[string][]Rung{"A": {{LadderID: "L1", Rank: 1}}},
displacements: []Displacement{onL1},
want: map[string]Displacement{"A": onL1},
},
{
name: "a missing product on another ladder is kept",
missing: []string{"A"},
rungs: map[string][]Rung{"A": {{LadderID: "L2", Rank: 1}}},
displacements: []Displacement{onL1},
want: map[string]Displacement{},
},
{
name: "a lower tier of the displaced ladder is left out",
missing: []string{"B"},
rungs: map[string][]Rung{"B": {{LadderID: "L1", Rank: 0}}},
displacements: []Displacement{onL1},
want: map[string]Displacement{"B": onL1},
},
{
name: "a bundle whose second rung is displaced is left out",
missing: []string{"C"},
rungs: map[string][]Rung{"C": {{LadderID: "L0", Rank: 1}, {LadderID: "L1", Rank: 1}}},
displacements: []Displacement{onL1},
want: map[string]Displacement{"C": onL1},
},
{
name: "an add-on is kept",
missing: []string{"D"},
rungs: map[string][]Rung{},
displacements: []Displacement{onL1},
want: map[string]Displacement{},
},
} {
checkDisplacedProducts(t, row)
}
}
@@ -244,8 +244,9 @@ var reconcileCases = []traceCase{
e.start()
return e.reconcile("stripe:customer.subscription.created:evt_1")
}},
// #212: a reconcile confers the subscription's tier again over the
// operator's grant that replaced it. The fix flips this golden.
// #212: a reconcile leaves the ladder to the operator's grant that replaced
// the subscription's tier: it confers nothing, and logs the product it
// left alone.
{"grant_over_subscription", func(e *traceEnv) string {
_, plus, _ := onPlan(e, true)
e.w.Issue(e.org, plus.product, world.IssueOpts{})
@@ -23,6 +23,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
<materialize>
ListActiveScheduledChangesBySubscription
@@ -28,6 +28,8 @@ CreateSubscriptionItem
UpsertSubscriptionItemMapping
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -23,6 +23,8 @@ GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
EndSubscriptionItem
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)
<materialize>
@@ -24,6 +24,8 @@ CreateSubscriptionItem
UpsertSubscriptionItemMapping
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -22,13 +22,14 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
<materialize>
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
commit
== logs
INFO reconcile: product not conferred; another plan holds its ladder subscription_id=<subscription> product_id=<product:Plan> plan_ladder_id=<ladder:Ladder> holder_provision_id=<provision:Plus grant>
== rows
-- subscriptions
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription>", "sync_status" : "synced", "livemode" : false}
@@ -38,19 +39,15 @@ commit
{"stripe_id" : "si_a", "product" : "<product:Plan>", "price" : "<price:Plan>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
-- scheduled_changes
-- provisions
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "grant", "quantity" : 1, "status" : "ended", "ended" : true}
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "grant", "quantity" : 1, "status" : "active", "ended" : false}
-- ladder_positions
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "ended"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "active"}
-- transitions
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
{"type" : "end", "actor" : "operator", "reason" : "test fixture", "from_rank" : 0, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
{"type" : "upgrade", "actor" : "operator", "reason" : "test fixture", "from_rank" : 0, "to_rank" : 1, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
{"type" : "end", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_2", "from_rank" : 1, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
{"type" : "downgrade", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_2", "from_rank" : 1, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
-- numeric_entitlements
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 1}
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 3}
-- boolean_entitlements
@@ -23,13 +23,14 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
<materialize>
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
commit
== logs
INFO reconcile: product not conferred; another plan holds its ladder subscription_id=<subscription> product_id=<product:Plan> plan_ladder_id=<ladder:Ladder> holder_provision_id=<provision:Plus grant>
== rows
-- subscriptions
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription>", "sync_status" : "synced", "livemode" : false}
@@ -41,19 +42,15 @@ commit
{"stripe_id" : "si_a", "product" : "<product:Plan>", "price" : "<price:Plan>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
-- scheduled_changes
-- provisions
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "grant", "quantity" : 1, "status" : "ended", "ended" : true}
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "grant", "quantity" : 1, "status" : "active", "ended" : false}
-- ladder_positions
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "ended"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "active"}
-- transitions
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
{"type" : "end", "actor" : "operator", "reason" : "test fixture", "from_rank" : 0, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
{"type" : "upgrade", "actor" : "operator", "reason" : "test fixture", "from_rank" : 0, "to_rank" : 1, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
{"type" : "end", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
{"type" : "downgrade", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
-- numeric_entitlements
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 1}
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 3}
-- boolean_entitlements
@@ -24,6 +24,8 @@ UpsertSubscriptionItemMapping
GetSubscriptionItemsBySubscriptionID
EndSubscriptionItem
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -20,6 +20,8 @@ GetPriceMappingByStripeID
GetPrice
GetProductByID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -23,6 +23,8 @@ GetSubscriptionItemByID
UpdateSubscriptionItemPriceProduct
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -30,6 +30,8 @@ CreateSubscriptionItem
UpsertSubscriptionItemMapping
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -23,6 +23,8 @@ GetSubscriptionItemByID
UpdateSubscriptionItemPriceProduct
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT core.update_conferral_bounds($1, $2, $3, $4, $5, $6, $7)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -24,6 +24,8 @@ CreateSubscriptionItem
UpsertSubscriptionItemMapping
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -22,6 +22,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
@@ -22,13 +22,14 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
<materialize>
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
commit
== logs
INFO reconcile: product not conferred; another plan holds its ladder subscription_id=<subscription> product_id=<product:Plan> plan_ladder_id=<ladder:Ladder> holder_provision_id=<provision:Plus subscription>
== rows
-- subscriptions
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription-b>", "sync_status" : "synced", "livemode" : false}
@@ -41,19 +42,15 @@ commit
{"stripe_id" : "si_b", "product" : "<product:Plus>", "price" : "<price:Plus>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
-- scheduled_changes
-- provisions
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
-- ladder_positions
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "ended"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "active"}
-- transitions
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
{"type" : "end", "actor" : "webhook", "reason" : "test:second", "from_rank" : 0, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
{"type" : "upgrade", "actor" : "webhook", "reason" : "test:second", "from_rank" : 0, "to_rank" : 1, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
{"type" : "end", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
{"type" : "downgrade", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
-- numeric_entitlements
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 1}
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 3}
-- boolean_entitlements
@@ -36,6 +36,8 @@ CreateSubscriptionItem
UpsertSubscriptionItemMapping
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
@@ -27,6 +27,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -22,6 +22,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
@@ -23,6 +23,8 @@ GetSubscriptionItemByID
UpdateSubscriptionItemPriceProduct
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -23,6 +23,8 @@ GetSubscriptionItemByID
UpdateSubscriptionItemPriceProduct
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -23,9 +23,9 @@ GetSubscriptionItemByID
UpdateSubscriptionItemPriceProduct
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
<materialize>
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
MarkScheduledChangeApplied
@@ -33,6 +33,7 @@ commit
stripe POST /v1/subscription_schedules/sub_sched_mock/release
probe: the subscription lock is free
== logs
INFO reconcile: product not conferred; another plan holds its ladder subscription_id=<subscription> product_id=<product:Plan> plan_ladder_id=<ladder:Ladder> holder_provision_id=<provision:Top grant>
== rows
-- subscriptions
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription>", "sync_status" : "synced", "livemode" : false}
@@ -43,19 +44,15 @@ probe: the subscription lock is free
-- scheduled_changes
{"type" : "plan_switch", "trigger" : "period_end", "target" : "<price:Plan>", "status" : "applied"}
-- provisions
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
{"product" : "<product:Top>", "set" : "<set:Top>", "source" : "grant", "quantity" : 1, "status" : "ended", "ended" : true}
{"product" : "<product:Top>", "set" : "<set:Top>", "source" : "grant", "quantity" : 1, "status" : "active", "ended" : false}
-- ladder_positions
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Top>", "status" : "ended"}
{"ladder" : "<ladder:Ladder>", "product" : "<product:Top>", "status" : "active"}
-- transitions
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 1, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
{"type" : "end", "actor" : "operator", "reason" : "test fixture", "from_rank" : 1, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
{"type" : "upgrade", "actor" : "operator", "reason" : "test fixture", "from_rank" : 1, "to_rank" : 2, "ladder" : "<ladder:Ladder>", "product" : "<product:Top>"}
{"type" : "end", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_2", "from_rank" : 2, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Top>"}
{"type" : "downgrade", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_2", "from_rank" : 2, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
-- numeric_entitlements
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 1}
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 5}
-- boolean_entitlements
@@ -22,6 +22,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
@@ -23,6 +23,8 @@ GetSubscriptionItemByID
UpdateSubscriptionItemPriceProduct
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -23,6 +23,8 @@ GetSubscriptionItemByID
UpdateSubscriptionItemPriceProduct
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -22,6 +22,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
@@ -22,6 +22,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
@@ -27,6 +27,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
@@ -28,6 +28,8 @@ GetSubscriptionItemByID
UpdateSubscriptionItemPriceProduct
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
@@ -27,6 +27,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
ListActiveScheduledChangesBySubscription
GetPriceMappingByStripeID
@@ -38,6 +38,8 @@ GetSubscriptionItemMappingByStripeID
GetSubscriptionItemByID
GetSubscriptionItemsBySubscriptionID
GetLivePoolProvisionsBySubscriptionID
ListConferralShapes
ListSubscriptionDisplacements
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
GetLivePoolProvisionsBySubscriptionID
<materialize>
+33 -3
View File
@@ -70,6 +70,8 @@ type traceEnv struct {
logger *slog.Logger
norm *golden.Normalizer
labels map[string]string // product id -> the label its tier was created with
orgType string
org world.Org
account world.Account
@@ -82,7 +84,7 @@ type traceEnv struct {
// every case shares, with its billing account mapped to Stripe.
func newTraceEnv(t *testing.T, scratch bool) *traceEnv {
t.Helper()
e := &traceEnv{t: t, ctx: context.Background(), refuse: map[string]error{}, probes: map[string]func() string{}, norm: golden.NewNormalizer()}
e := &traceEnv{t: t, ctx: context.Background(), refuse: map[string]error{}, probes: map[string]func() string{}, labels: map[string]string{}, norm: golden.NewNormalizer()}
if scratch {
dsn := dbtest.Scratch(t)
plain := dbtest.Connect(t, dsn)
@@ -137,6 +139,7 @@ func (e *traceEnv) tier(label string, limit int64, cents int32) tier {
})
pr := e.w.Price(p, cents)
e.w.MapPrice(pr)
e.labels[p.ID] = label
e.norm.Name("product", label, p.ID)
e.norm.Name("set", label, p.SetID)
e.norm.Name("price", label, pr.ID)
@@ -300,13 +303,21 @@ func foldMaterialize(in []string) []string {
}
// nameWritten names the rows the act wrote whose ids a log line or an error
// prints: the core subscription, its items and its scheduled changes.
// prints: the core subscriptions, the fixture pool's provisions, the
// subscription's items and its scheduled changes.
func (e *traceEnv) nameWritten() {
q := dbtest.On(e.t, e.w.DB)
for _, id := range q.Strings(`SELECT s.subscription_id::text FROM core.subscriptions s
for _, row := range q.Strings(`SELECT s.subscription_id::text || ' ' || coalesce(m.stripe_subscription_id, '')
FROM core.subscriptions s LEFT JOIN stripe.subscription_mappings m USING (subscription_id)
WHERE s.billing_account_id = $1`, e.account.ID) {
id, stripeID, _ := strings.Cut(row, " ")
if strings.HasPrefix(stripeID, "sub_trace_b_") {
e.norm.Add(id, "<subscription-b>")
continue
}
e.norm.Add(id, "<subscription>")
}
e.nameProvisions(q)
for _, row := range q.Strings(`SELECT i.subscription_item_id::text || ' ' || coalesce(m.stripe_subscription_item_id, '?')
FROM core.subscription_items i JOIN core.subscriptions s USING (subscription_id)
LEFT JOIN stripe.subscription_item_mappings m USING (subscription_item_id)
@@ -322,6 +333,25 @@ func (e *traceEnv) nameWritten() {
}
}
// nameProvisions names each provision of the fixture's pool
// <provision:{product} {source}>, with " 2", " 3" and on after the first of
// one product and source in activation order, so that a log line naming a
// holder prints a name.
func (e *traceEnv) nameProvisions(q *dbtest.Q) {
seen := map[string]int{}
for _, row := range q.Strings(`SELECT p.provision_id::text || ' ' || p.product_id::text || ' ' ||
CASE WHEN p.subscription_id IS NOT NULL THEN 'subscription' WHEN p.grant_id IS NOT NULL THEN 'grant' ELSE 'other' END
FROM core.pool_provisions p WHERE p.pool_id = $1 ORDER BY p.activated_at, p.provision_id`, e.org.PoolID) {
parts := strings.Fields(row)
name := e.labels[parts[1]] + " " + parts[2]
seen[name]++
if seen[name] > 1 {
name = fmt.Sprintf("%s %d", name, seen[name])
}
e.norm.Name("provision", name, parts[0])
}
}
// rowQueries read the fixture's rows, one JSON text per row. Each section is
// sorted after naming, except the ledgers, which list in write order.
var rowQueries = []struct {
@@ -55,11 +55,11 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
## 4. The reconcile leaves a ladder alone while another plan holds it
- [ ] 4.1 `internal/db/migrations/00034_subscription_displacements.sql` (design D1, D2): a header comment saying what the function returns (the ladders of the pool on which the subscription is displaced and another plan holds the ladder, with the displaced product, the instant and the holder), its two rules, that a default-sourced position never holds a ladder against a subscription, that it is the one statement the reconcile and the organization page read, and that it reads no `reason`; the function as the design writes it; `GRANT EXECUTE ON FUNCTION core.subscription_displacements(UUID, UUID) TO core_reader, core_writer, core_owner;`. Down: `DROP FUNCTION core.subscription_displacements(UUID, UUID);`.
- [ ] 4.2 Queries, then `make sqlc-generate`:
- [x] 4.1 `internal/db/migrations/00034_subscription_displacements.sql` (design D1, D2): a header comment saying what the function returns (the ladders of the pool on which the subscription is displaced and another plan holds the ladder, with the displaced product, the instant and the holder), its two rules, that a default-sourced position never holds a ladder against a subscription, that it is the one statement the reconcile and the organization page read, and that it reads no `reason`; the function as the design writes it; `GRANT EXECUTE ON FUNCTION core.subscription_displacements(UUID, UUID) TO core_reader, core_writer, core_owner;`. Down: `DROP FUNCTION core.subscription_displacements(UUID, UUID);`.
- [x] 4.2 Queries, then `make sqlc-generate`:
- `ListSubscriptionDisplacements :many` in `internal/entitlements/queries/pool_provision_transitions.sql`: `d.plan_ladder_id::uuid AS plan_ladder_id`, `pl.name AS ladder_name`, `d.displaced_product_id::uuid AS displaced_product_id`, `d.displaced_at::timestamptz AS displaced_at`, `d.holder_provision_id::uuid AS holder_provision_id`, `d.holder_source::text AS holder_source`, from `core.subscription_displacements(sqlc.arg(subscription_id)::uuid, sqlc.arg(pool_id)::uuid) AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)` joined to `core.plan_ladders pl` on the ladder, ordered `pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid`. Its comment says that the function is the rule's one statement, that the aliases repeat the function's columns because sqlc cannot read a function's `RETURNS TABLE` (as `ListResumableGrantsHeldBy` does), and that the reconcile calls it under the pool row.
- `ListConferralShapes :many` in `internal/entitlements/queries/pool_provision_ladders.sql`: `s.product_id`, `s.plan_ladder_id`, `s.rank` from `core.product_conferral_shapes s` where `s.product_id = ANY(sqlc.arg(product_ids)::uuid[])` and `s.plan_ladder_id IS NOT NULL`, ordered `s.product_id, s.plan_ladder_id`. Its comment says that the rendezvous the reconcile holds keeps the ranks from changing under it (`docs/database-locks.md`, the rendezvous).
- [ ] 4.3 `internal/fulfillment/reconcile.go` (design D2, D13):
- [x] 4.3 `internal/fulfillment/reconcile.go` (design D2, D13):
- `Rung` (a ladder id, `LadderID`, and a rank, `Rank`), `RungsByProduct(rows []entitlements.ListConferralShapesRow) map[string][]Rung`, reading no row, and `conferralRungs(ctx, entQ, products []string) (map[string][]Rung, error)`, reading `ListConferralShapes` once and calling `RungsByProduct`, each product's rungs in ladder id order. `Rung`, `RungsByProduct` and `DisplacedProducts` are exported because the organization page calls them too (section 6, design D4): a rule that a page shows and the reconcile enforces is one function that reads no row (`docs/go-conventions.md`).
- `Displacement` (the ladder id `LadderID`, the ladder name, `DisplacedProductID`, the holder's provision id and its source), built from the rows of either displacement query, and `DisplacedProducts(missing []string, rungs map[string][]Rung, displacements []Displacement) map[string]Displacement`, reading no row: it indexes the displacements by ladder, keeping the first row of each, and maps each missing product to the displacement of the first of its rungs, in its rung order, whose ladder is indexed:
@@ -75,8 +75,8 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
```
- `convergeConferrals` gains the logger and, after its live read, computes `order := sortedProducts(desired)`, reads `conferralRungs(order)` and `ListSubscriptionDisplacements` (subscription, pool), takes as missing the products of `order` with no live provision, computes `left := DisplacedProducts(missing, rungs, displacements)`, logs each left-out product in `order` once at Info (Copy, log row L1: `reconcile: product not conferred; another plan holds its ladder`, attributes `subscription_id`, `product_id`, `plan_ladder_id`, `holder_provision_id`), and calls `conferOrBound` for every other product of `order`, its loop skipping a left-out one with `if _, out := left[productID]; out { continue }`. `endSurplusConferrals` is unchanged: a left-out product stays desired. The doc comments of `reconcileItems` and `convergeConferrals` state the rule in one sentence and point at `core.subscription_displacements`. The functions stay under the complexity limit by splitting, not by a lint exception.
- [ ] 4.4 `internal/fulfillment/trace_db_test.go`: `nameWritten` names each provision of the fixture's pool, `<provision:{product} {source}>`, with ` 2`, ` 3` and on after the first of one product and source in activation order, so that L1's `holder_provision_id` prints a name and `check`'s unnamed-id guard passes. It also names the core subscription the second Stripe subscription of `second_subscription_over_first` makes `<subscription-b>`, where it names every other core subscription of the account `<subscription>` (`trace_db_test.go:315-320`), so that the flipped golden's L1 line says which subscription was left alone.
- [ ] 4.5 Tests, `internal/entitlements/subscription_displacements_test.go` (package `entitlements_test`, over `newPlusProWorld` or a world with the ladders a test names, in the world's transaction, reading through `ListSubscriptionDisplacements`); each failure prints the rows it got:
- [x] 4.4 `internal/fulfillment/trace_db_test.go`: `nameWritten` names each provision of the fixture's pool, `<provision:{product} {source}>`, with ` 2`, ` 3` and on after the first of one product and source in activation order, so that L1's `holder_provision_id` prints a name and `check`'s unnamed-id guard passes. It also names the core subscription the second Stripe subscription of `second_subscription_over_first` makes `<subscription-b>`, where it names every other core subscription of the account `<subscription>` (`trace_db_test.go:315-320`), so that the flipped golden's L1 line says which subscription was left alone.
- [x] 4.5 Tests, `internal/entitlements/subscription_displacements_test.go` (package `entitlements_test`, over `newPlusProWorld` or a world with the ladders a test names, in the world's transaction, reading through `ListSubscriptionDisplacements`); each failure prints the rows it got:
- `TestSubscriptionDisplacements_AGrantOverTheSubscription`: a subscription on Plus, a grant of Pro over it: one row, on Wiki, with the ladder's name, displaced product Plus, the grant's provision as holder and source `grant`. A failure reads `displacements = %+v, want one on Wiki naming the grant's provision`.
- `TestSubscriptionDisplacements_AnExtensionStillHolds`: the same, then the grant extended (`ExtendGrantTx`): one row naming the extension's provision. A failure reads `displacements = %+v, want the extension's provision as the holder`.
- `TestSubscriptionDisplacements_TheDefaultDoesNotHold`: the grant revoked, so the floor confers Free: no row. A failure reads `displacements = %+v, want none while only the default holds Wiki`.
@@ -87,7 +87,7 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
- `TestSubscriptionDisplacements_ASecondSubscription`: subscription S1 on Plus, subscription S2 conferring Pro over it: one row for S1 naming S2's provision with source `subscription`, and none for S2. Failures read `displacements of the first subscription = %+v, want one naming the second's provision` and `displacements of the second subscription = %+v, want none`.
- `TestSubscriptionDisplacements_OneLadderOfABundle`: a product that is a tier of ladders Docs and Site, conferred from a subscription, and a grant of a product that is a higher tier of Site only: one row, on Site, and none on Docs. A failure reads `displacements = %+v, want one on Site and none on Docs`.
- No test claims the pool scoping: the function filters by the pool three times (the episodes, the pairs, the holders), and a subscription's provisions all sit in its organization's default pool, so no single filter's removal changes a row (design, Tests).
- [ ] 4.6 Tests, a new `internal/fulfillment/displaced_subscription_test.go` (package `fulfillment_test`, over `seedFixture`'s ladder with `secondTier` and `addTier` where named, the grants issued through `issueGrant`, revocations and extensions through a `BeginMaterializing` transaction as `grant_resumption_test.go` revokes, every Stripe answer from the mock):
- [x] 4.6 Tests, a new `internal/fulfillment/displaced_subscription_test.go` (package `fulfillment_test`, over `seedFixture`'s ladder with `secondTier` and `addTier` where named, the grants issued through `issueGrant`, revocations and extensions through a `BeginMaterializing` transaction as `grant_resumption_test.go` revokes, every Stripe answer from the mock):
- T1 `TestReconcile_AGrantOverTheSubscriptionKeepsTheLadder`: the subscription on the fixture's tier, an unmarked grant of the second tier over it; `poolRows` and the mock's calls recorded; a reconcile with the items unchanged, then one with the item's quantity at 3. After each: `the grant stopped delivering after the subscription's reconcile`, `the subscription holds %d live provisions, want 0`, `the reconcile wrote %d rows` (from `poolRows`), and no Stripe call beyond the subscription's read.
- T1b `TestReconcile_AGrantOfTheSubscriptionsOwnTierKeepsTheLadder`: a grant of the fixture's own tier (a `transfer`), then a reconcile: `the grant of the subscription's own tier stopped delivering`.
- T2: `TestReconcile_SupersededWhileSuspendedNotResurrected` (item 1.4) flips on purpose: `grant tier attach after the recovery = %d, want 1`, and `the subscription holds %d live provisions after the recovery, want 0`; its doc comment drops the pin.
@@ -103,18 +103,18 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
- T12 `TestReconcile_ABundleDisplacedOnOneLadderIsLeftAlone`: a product B that is a tier of the fixture's ladder and of a second ladder created after it, and a product G that is a tier of the second ladder only, ranked above B; the subscription on B, a grant of G over it (which ends B's rungs on both ladders), a reconcile: `the bundle holds %d positions, want 0`, `the grant on the second ladder stopped delivering`, and `the fixture's ladder holds %d positions, want 0`.
- T13 `TestReconcile_AnItemBackOnALadderItLeftSupersedesTheGrant`: the subscription on the fixture's tier; its item moved to an add-on (`addPriced`) and reconciled, which ends the tier as a surplus; a grant of the second tier on the vacant ladder; the item moved back to the tier and reconciled: `the subscription's tier holds %d positions, want 1`. The subscription ended its own episode, so it is not displaced.
- `TestDisplacedProducts` (`internal/fulfillment/reconcile_order_test.go`, package `fulfillment`, no database), rows through `displacedProductsRow`: `a missing product on a displaced ladder is left out`; `a missing product on another ladder is kept`; `a lower tier of the displaced ladder is left out` (the displacement names another product, as F16's does); `a bundle whose second rung is displaced is left out`; `an add-on is kept`. A failure reads `DisplacedProducts(%s) = %v, want %v`, with the row's name.
- [ ] 4.7 Goldens:
- [x] 4.7 Goldens:
- flipped on purpose: `reconcile_trace/grant_over_subscription`, `grant_over_suspended_recovery`, `second_subscription_over_first` and `scheduled_change_trace/downgrade_lands_while_displaced`: no `core.confer` in the item pass, the grant's or the second subscription's provision stays `active`, no `end` or typed row by `webhook`, and L1 in the logs with the holder's provision named; `second_subscription_over_first` also reads the second core subscription as `<subscription-b>` (item 4.4). `downgrade_lands_while_displaced` keeps its applied row and its release; `grant_over_subscription`'s case comment ("The fix flips this golden") is rewritten to say what the case now pins.
- every golden of the three directories whose trace holds the item pass's `GetLivePoolProvisionsBySubscriptionID` gains `ListConferralShapes` and `ListSubscriptionDisplacements` directly after the first `GetLivePoolProvisionsBySubscriptionID` of the trace (every item-pass golden holds it twice: the convergence read and `endSurplusConferrals`' re-read), and no other line; `two_tiers_one_ladder` changes by those two lines only, since its choice is section 5's.
- [ ] 4.8 Docs (`CONTRIBUTING.md`, "Docs move with the code"):
- [x] 4.8 Docs (`CONTRIBUTING.md`, "Docs move with the code"):
- `docs/models/plan-ladders-transitions.md`: invariant 20, **[db-code]** and **[app]**: a subscription's reconcile does not take back a ladder another plan took from it; while a position of a non-default source other than the subscription holds a ladder on which the subscription's latest episode ended in a supersession by another source, the reconcile confers none of the subscription's products whose shape names that ladder, the subscription keeps billing and delivers nothing there, and its next reconcile after no such plan holds the ladder confers it again; `core.subscription_displacements` is the one statement. A "Where this lives" row for `core.subscription_displacements` (`00034_subscription_displacements.sql`), and the "Stripe-driven convergence" row names the rule. Invariant 7's "invariant 18 says when it delivers again" gains "and invariant 20 says when a superseded subscription does".
- `docs/models/payments-billing.md`: the Reconciler row says that a desired product whose ladder another plan took from the subscription and holds is left alone, logged, while the subscription keeps billing, and that the reconcile writes in product id order under the pool row.
- `docs/stripe.md`, "Plan changes": a bullet **A grant over a paid plan.**: an operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked; the subscription keeps billing and delivers nothing there, and the console changes nothing at Stripe; once only the organization type's default holds the ladder, or nothing does, the subscription's next reconcile delivers its tier again; a new purchase over a grant still replaces the grant.
- `docs/plan-management.md`, "Issue Grant", the system's list: a bullet that a grant superseding a tier a subscription pays for keeps the ladder while it lasts, the subscription billing and delivering nothing there, and that the subscription delivers again at its next reconcile after the grant ends.
- `docs/database-locks.md`, "Subscription reconcile", Active: the live read is followed by the shapes read and the displacement read, under the pool row.
- [ ] 4.9 `go test -race -p 1 -count=1 -run '^TestReconcile_AGrantIssuedWhileTheReconcileWaitsIsSeen$' ./internal/fulfillment` passes after `test/reset-test-db.sh`; the commit message records it.
- [ ] 4.10 Breaks:
- `convergeConferrals` conferring the left-out products (`if _, out := left[productID]; out {` replaced by `if false {` in its loop), one edit and several records: caught by T1, `want_new` `the grant stopped delivering after the subscription's reconcile`; by T1b, `want_new` `the grant of the subscription's own tier stopped delivering`; by T2, `want_new` `grant tier attach after the recovery = 0, want 1`; by T3, `want_new` `core.grant_replacements pairs the marked grant with the subscription`; by T6b, `want_new` `the extended grant stopped delivering`; by T12, `want_new` `the grant on the second ladder stopped delivering`; and by `TestReconcileTrace/grant_over_subscription`, `want_new` `testdata/reconcile_trace/grant_over_subscription.golden differs from what the code produced`;
- [x] 4.9 `go test -race -p 1 -count=1 -run '^TestReconcile_AGrantIssuedWhileTheReconcileWaitsIsSeen$' ./internal/fulfillment` passes after `test/reset-test-db.sh`; the commit message records it.
- [x] 4.10 Breaks:
- `convergeConferrals` conferring the left-out products (`if _, out := left[productID]; out {` replaced by `if _, out := left[productID]; false && out {` in its loop), one edit and several records: caught by T1, `want_new` `the grant stopped delivering after the subscription's reconcile`; by T1b, `want_new` `the grant of the subscription's own tier stopped delivering`; by T2, `want_new` `grant tier attach after the recovery = 0, want 1`; by T3, `want_new` `core.grant_replacements pairs the marked grant with the subscription`; by T6b, `want_new` `the extended grant stopped delivering`; by T12, `want_new` `the grant on the second ladder stopped delivering`; and by `TestReconcileTrace/grant_over_subscription`, `want_new` `testdata/reconcile_trace/grant_over_subscription.golden differs from what the code produced`;
- `DisplacedProducts` matching the product instead of the ladder (`if d, ok := byLadder[r.LadderID]; ok {` replaced by `if d, ok := byLadder[r.LadderID]; ok && d.DisplacedProductID == p {`): caught by T8, `want_new` `the downgrade was conferred over the grant`, and by `TestDisplacedProducts`, `want_new` `DisplacedProducts(a lower tier of the displaced ladder is left out) =`;
- `DisplacedProducts` reading the first rung only (`for _, r := range rungs[p] {` replaced by `for _, r := range rungs[p][:min(1, len(rungs[p]))] {`): caught by T12, `want_new` `the grant on the second ladder stopped delivering`, and by `TestDisplacedProducts`, `want_new` `DisplacedProducts(a bundle whose second rung is displaced is left out) =`;
- `reconcileItems` without its `LockPools` call (item 1.1 of `reconcile-takes-pool-lock`): caught by T11, `want_new` `the grant issued while the reconcile waited stopped delivering`;
@@ -162,7 +162,7 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
- `TestSubscriptionMayBeDisplaced`, one row per status `subscriptionStatusFacets` lists (`internal/server/operator_billing.go`): true for `active`, `trialing`, `past_due`, `unpaid` and `paused`, false for `incomplete`, `incomplete_expired` and `canceled`. A failure reads `subscriptionMayBeDisplaced(%q) = %v, want %v`.
- `TestOrgEnrollmentGolden` (`internal/server/operator_enrollment_golden_db_test.go`): the state `grant-over-subscription`, an organization of the plain type subscribed to the fixture's existing Small2 on ladder Box (`w.Subscribe`, period end noon on Jan 15, 2099) and issued a grant of Small2 over it (`f.grant("Small2", w.Issue(o, small2, world.IssueOpts{}))`), which takes the subscription's position as a `transfer`. It adds no product, because every state's Issue grant catalog lists all the fixture's products and a new one would change every golden. Its goldens are written with `-update`: `.html` reads the Subscriptions row with the line `Delivers nothing on Box, which a grant holds` and the Pools panel with the grant's delivery; `.json` carries the same `DisplacementNote`. Every other `org_enrollment` JSON golden gains `"DisplacementNote": ""` and `"DisplacementError": ""` in its `BillingSummary` and no other change; no other HTML golden changes. The commit message names the files.
- The read-count row of the organization page (`internal/server/operator_read_count_db_test.go`, `TestOperatorPagesReadAFixedNumberOfTimes`) gains a subscription a grant displaced in its `grow` (a second subscription on its own ladder and a grant of its tier over it), so the page's statement count is pinned at no statement per displaced subscription; its `perRow` stays 0.
- [ ] 6.4 Docs: `docs/plan-management.md`, "Issue Grant" (item 4.8's bullet): the organization page's Billing card then reads "Delivers nothing on {ladder}, which a grant holds" under the subscription; `docs/stripe.md`'s bullet **A grant over a paid plan.** gains the same sentence.
- [ ] 6.4 Docs: `docs/plan-management.md`, "Issue Grant" (item 4.8's bullet): the organization page's Billing card then reads "Delivers nothing on {ladder}, which a grant holds" under the subscription; `docs/stripe.md`'s bullet **A grant over a paid plan.** gains the same sentence; `docs/models/plan-ladders-transitions.md` (the `core.subscription_displacements` row and invariant 20) and the doc comments of `RungsByProduct` and `DisplacedProducts` in `internal/fulfillment/reconcile.go` gain back the clause that the organization page reads the rule too.
- [ ] 6.5 `make screens`: no changed screen is expected, because the demo seed holds no subscription a grant displaced; a changed screen is a stop to report, never a baseline this change accepts.
- [ ] 6.6 Breaks:
- `DisplacementNote` never set (its assignment in `loadOrgBillingSummary` removed): caught by `TestOrgEnrollmentGolden`, `want_new` `testdata/org_enrollment/grant-over-subscription.html differs from what the code produced`;