Deliver a displaced subscription again in the act that ends its holder

Seventh section of #269's change, and #270. Until now a subscription a
grant displaced delivered again only at its next reconcile, so the act
that ended the grant restored the organization type's default in
between. Now a revocation, an expiry, the subscription reconcile's
ended branch and its end of a surplus provision call
entitlements.DeliverDisplacedSubscriptionsTx when they ended at least
one provision: after the resumption of marked grants and before the
default floor, under the pool row the act holds and without any
subscription's advisory lock. It reads the recorded current items of the
organization's active, trialing, past_due, unpaid and paused
subscriptions, keeps each product with an entitlement set that has no
live provision of the subscription, applies the displacement rule to
them and the reconcile's choice of tiers to the subscription's products
the rule does not leave alone, and confers each, sourced by the
subscription: first the subscriptions the ending plan superseded, then
the rest, each in subscription id order. Each subscription's delivery
runs under its own savepoint, with a past-due, unpaid or paused
subscription's suspension inside it; a conferral the database refuses
rolls back that subscription alone, is logged, and the act goes on. The
Tier changes table labels the four reasons "Subscription delivers again
after a revocation", "... after a grant expired", "... after a
subscription ended" and "... after a subscription dropped its tier".

docs/stripe.md, docs/plan-management.md,
docs/models/plan-ladders-transitions.md, docs/database-locks.md and the
demo seed's README say the same.

Tests: TestDeliverDisplaced_* in entitlements (revocation, with a
trialing subscription and a set-less item; expiry; past_due, unpaid and
paused coming back suspended; a resumable grant first; a refused
conferral rolled back alone, and the whole subscription rolled back
when one of its conferrals is refused; a replay delivers nothing; id
order; the subscription the ending holder displaced first; a held
higher tier not downgraded; a canceled subscription not delivered;
the pool row awaited without the subscription's lock) and in
fulfillment (the holder's end and a dropped item). Seven trace goldens
gain the one read. TestHumanizeTransitionReason gains the four rows.

Tests changed outside the tasks' first list, now listed in 7.5: three
tests of #212 in displaced_subscription_test.go no longer reached the
state they test, because the revocation now delivers. They are rebuilt
on states the console reaches (a refused delivery then the product
published again, and a tier removal), and
TestReconcile_ARevocationDeliversTheSubscriptionAndItsReconcileWritesNothing
is added. twoOnOneLadder gives the subscription that drops its tier a
second item, since Stripe keeps a subscription's last item, which also
changes section 4's TestSurplusEndRequestsTheOtherSubscriptions.

Review: round 1, two majors (a savepoint per conferral where the spec
wants one per subscription, so a half-delivered subscription kept its
billing paused; a test helper that faked a state the console never
writes) and six minors, all fixed; round 2, no major. The change's
text: the spec and design D7 now give the per-subscription savepoint
and run the choice of tiers over the products the rule does not leave
alone, and task 7.2 lists the fourth reason.

Breaks: 27 of 27 caught (24 in code, 3 in migration 00034 on reset
test databases). Full suite on reset databases: 4831 pass, 14 skip.

make screens, after the demo snapshot's rebuild: Carlos Member's Tier
changes read the revocation and expiry labels where they read "Default
restored after ...", his Active grants tab is empty, the grants list
loses the two restored defaults (11 to 9), and the overview's recent
activity loses their two rows. With Carlos's tab empty, no capture
showed the Manage grant form, so the screens manifest gains
operator-organization-detail-grant, Bob Builder's organization, whose
default delivers. Hand captures of the four Tier changes labels sit
beside the change's screens.
This commit is contained in:
2026-10-28 11:30:05 -05:00
parent c30e725932
commit dacb8b57a9
27 changed files with 1362 additions and 62 deletions
+28 -8
View File
@@ -120,11 +120,17 @@ handlers, `ExpireGrantActivity` and the demo seed.
- **Revoke** (`RevokeGrantTx`): the rendezvous, the organization's default
pool row (`LockPools`), the lineage head row, then
`core.end_conferral` (its pool locks are already held), resumption (the pool
row again), the floor restoration (`core.confer`) and materialization.
row again), the delivery step when the revocation ended a provision (under
the pool row the act holds, `core.confer` for each subscription it
delivers, no subscription advisory lock), the floor restoration
(`core.confer`) and materialization.
- **Expire** (`ExpireGrantTx`): the rendezvous, then the pool row through
`LockPoolOfGrant`, then the grant update. It does not take the lineage head
row. A grant that names no organization has no pool, so the lock,
resumption and materialization are skipped.
row. After the grant update and `core.end_conferral` it runs resumption and,
when the expiry ended a provision, the delivery step, both under the pool
row, then the floor and materialization. A grant that names no organization
has no pool, so the lock, resumption, delivery and materialization are
skipped.
- **Extend a waiting grant** (`ExtendWaitingGrantTx`): the rendezvous, the
pool row (`LockPoolOfGrant`), the lineage head row.
- **Manage** (`ManageGrantTx`): the rendezvous, the pool row (`LockPools`), the
@@ -140,6 +146,18 @@ handlers, `ExpireGrantActivity` and the demo seed.
(the reconcile's active branch: before it). An outbox request is an insert
in the act's transaction and takes no lock. `ConferGrantTx` requests
nothing.
- **Deliver displaced subscriptions** (`DeliverDisplacedSubscriptionsTx`):
called after resumption and before the floor in Revoke, Expire and the
reconcile's ended branch, and after a surplus end in its active branch,
only when the act ended a provision. It runs under the pool row the caller
holds and takes no other lock: it reads the
organization's recorded subscription products and each subscription's live
provisions, shapes and displacements, and calls `core.confer` (and
`core.sync_source_status('suspended')` for a past-due, unpaid or paused
subscription). It takes no subscription advisory lock, because every
position write is under the pool row and the subscription lock protects the
subscription's own rows, which the step only reads. No path takes a pool
row and then a subscription's advisory lock.
- **Resume replaced grants** (`ResumeReplacedGrantsTx`): called inside the
transactions above and in the subscription reconcile. It takes the pool row
first and reads the resumable grants only after the lock is held, so an
@@ -169,14 +187,16 @@ advisory lock, then the rest by branch.
subscription advisory lock and supersedes those rows; a released schedule
that held none opens no second transaction. It takes no rendezvous and no
pool row: it writes only scheduled-change rows. When a surplus provision
ended, the reconcile requests (inserts, no lock) follow `convergeConferrals`,
ended, the delivery step (under the pool row the transaction holds) and
then the reconcile requests (inserts, no lock) follow `convergeConferrals`,
before the materialization.
- **Ended:** `core.end_conferral` by subscription (the pool row of each
provision it ends), then, only when something ended, resumption (the pool
row), then the pool row again (`LockPools`, which is the first take when
nothing ended), then the floor restoration (`ReapplyDefaultsIfVacant`,
which confers through `core.confer`), then materialization, then the
reconcile requests (inserts, no lock) when something ended.
row) and the delivery step (under that row), then the pool row again
(`LockPools`, which is the first take when nothing ended), then the floor
restoration (`ReapplyDefaultsIfVacant`, which confers through
`core.confer`), then materialization, then the reconcile requests (inserts,
no lock) when something ended.
- **Suspended:** `reconcileSuspended` takes the pool row (`LockPools`) before
`core.sync_source_status('suspended')`, then materializes when it suspended
a provision. For a `past_due` or `unpaid` subscription it then makes the
+1 -1
View File
@@ -77,7 +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 delivers nothing there, and its next reconcile after no such plan holds the ladder confers it again. While every conferring product the subscription pays for is left alone and every plan holding those ladders is a grant, the subscription's collection is paused at Stripe. 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, the organization page and the operator's subscriptions list 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 delivers nothing there. When the plan that holds the ladder ends and the act ended at least one of its provisions (a grant's revocation or expiry, the holding subscription's end, or a live subscription dropping the item that held the ladder), that act confers each such subscription's recorded products in its own transaction, after resumption (invariant 18) and before default restoration, with the reason `subscription-delivery:revocation:`, `subscription-delivery:expiration:`, `subscription-delivery:subscription:` or `subscription-delivery:dropped:` followed by the ending plan's id; a subscription the ending plan superseded goes first, then the rest in subscription id order, a past-due, unpaid or paused subscription comes back suspended, and a refused conferral is logged and leaves that subscription undelivered. In any other case the subscription's next reconcile after no such plan holds the ladder confers it again. While every conferring product the subscription pays for is left alone and every plan holding those ladders is a grant, the subscription's collection is paused at Stripe. 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, the organization page and the operator's subscriptions list read it.
## Dimensions
+1 -1
View File
@@ -161,7 +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 delivers nothing there while the grant lasts, and the console pauses its collection at Stripe, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge whose title reads "Billing paused while a grant holds {ladder}." once Stripe confirms the pause ("grants hold" when several grants hold the ladders) and "Billing continues while a grant holds {ladder}." until it does; the subscription delivers again at its next reconcile after the grant ends
- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription delivers nothing there while the grant lasts, and the console pauses its collection at Stripe, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge whose title reads "Billing paused while a grant holds {ladder}." once Stripe confirms the pause ("grants hold" when several grants hold the ladders) and "Billing continues while a grant holds {ladder}." until it does; the subscription delivers again in the act that ends the grant
While Temporal is not connected, which on a deployment without
`temporal-host` is always, a grant with **Valid until** is refused before
+1 -1
View File
@@ -211,7 +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: once the cancellation takes effect, Stripe stops retrying that subscription's open invoice and leaves the customer's other subscriptions collecting, so the member pays it through the invoice's **Pay invoice**. A paused subscription shows its state and is refused every act; only a subscription made outside the console reaches `paused`, which follows a trial that ends without a payment method, because the console's Checkout creates no trial.
- **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, and the subscription delivers nothing there. The console pauses the subscription's collection at Stripe (`pause_collection` with `behavior=void`, and the marker `member_console_collection=paused_for_grant` in the subscription's metadata) within seconds, through the outbox, and sets no `resumes_at`. Each renewal Stripe makes while the grant holds is a draft for about an hour and is then voided, so nothing is charged. When the grant ends, the console clears the pause and the marker, and the tier is delivered again; no money moves at either edge. While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge. Once Stripe confirms the pause, which the subscription row records, its title reads "Billing paused while a grant holds {ladder}." ("grants hold" when several grants hold the ladders); until it does, and while the pause has failed, it reads "Billing continues while a grant holds {ladder}." 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. What this accepts until the exact money rule (#280): the renewal date decides which way a grant that is not whole periods falls, because a period's renewal is voided or charged whole; a pause landing after a renewal's finalization lets that renewal be charged, and a clear landing before the finalization of a renewal made while paused lets it be charged; a pause or clear that lands late moves a whole period one way or the other, and a void cannot be undone; the first charge after a grant can come without Stripe's upcoming-renewal reminder. A subscription item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds. An invoice open before the grant stays due: Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription.
- **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, and the subscription delivers nothing there. The console pauses the subscription's collection at Stripe (`pause_collection` with `behavior=void`, and the marker `member_console_collection=paused_for_grant` in the subscription's metadata) within seconds, through the outbox, and sets no `resumes_at`. Each renewal Stripe makes while the grant holds is a draft for about an hour and is then voided, so nothing is charged. When the grant ends, the tier is delivered again in the act that ends the grant, and the console clears the pause and the marker within seconds; no money moves at either edge. While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge. Once Stripe confirms the pause, which the subscription row records, its title reads "Billing paused while a grant holds {ladder}." ("grants hold" when several grants hold the ladders); until it does, and while the pause has failed, it reads "Billing continues while a grant holds {ladder}." When the ladder is left to the default or to nothing in another way, such as a tier removal, the subscription's next reconcile delivers its tier again. A new purchase over a grant still replaces the grant. What this accepts until the exact money rule (#280): the renewal date decides which way a grant that is not whole periods falls, because a period's renewal is voided or charged whole; a pause landing after a renewal's finalization lets that renewal be charged, and a clear landing before the finalization of a renewal made while paused lets it be charged; a pause or clear that lands late moves a whole period one way or the other, and a void cannot be undone; the first charge after a grant can come without Stripe's upcoming-renewal reminder. A subscription item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds. An invoice open before the grant stays due: Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription.
- **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. Paying there makes the new card the customer's default for invoices but leaves the subscription's own default on the card that failed, which Stripe charges first, so the next renewal charges the failed card again. Stripe's page still takes payment for an invoice Stripe has marked uncollectible, which the console shows as open until the invoice sweep reads it again.
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`. The console also updates a subscription with `POST /v1/subscriptions/{id}` to set `pause_collection` while a grant holds its ladder and to clear it when the grant no longer does, which is the Subscriptions write the plan moves already need. Stripe names the missing permission in the error a key without it receives.
+19 -5
View File
@@ -233,6 +233,7 @@ type RevokeGrantResult struct {
// RevokeGrantTx records the revocation decree on the head of the named
// grant's lineage (the named grant unless an extension replaced it), ends its
// conferral, resumes the marked grants it held when that ended something,
// delivers the subscriptions it displaced (`DeliverDisplacedSubscriptionsTx`),
// restores the org-type default if the pool was left vacant, and
// re-materializes, then writes a reconcile request for each live subscription
// of the organization (`RequestPoolSubscriptionReconciles`), within the
@@ -302,6 +303,13 @@ func RevokeGrantTx(ctx context.Context, tx *sql.Tx, in RevokeGrantInput) (*Revok
ResumptionReasonRevocation+headID); err != nil {
return nil, fmt.Errorf("resume the grants %s held: %w", headID, err)
}
// A subscription this grant displaced delivers again here, in the
// same transaction and before the floor, so no default is conferred
// over it (grant-pauses-subscription-billing D7).
if _, err := DeliverDisplacedSubscriptionsTx(ctx, tx, pool.PoolID, "",
SubscriptionDeliveryRevocation+headID, ended); err != nil {
return nil, fmt.Errorf("deliver the subscriptions %s displaced: %w", headID, err)
}
}
// Restore the org-type baseline when the revoke left the default ladder
@@ -334,11 +342,13 @@ type ExpireGrantResult struct {
// ExpireGrantTx decrees the grant expired (status active -> expired), ends
// every position conferred by this grant source, resumes the marked grants
// it held when that ended something, restores the org-type default if the
// pool was left vacant, and re-materializes, then writes a reconcile request for each live subscription
// of the organization (`RequestPoolSubscriptionReconciles`), within the caller's
// transaction. It reads the grant and locks its pool before it writes the
// grant row, the order revocation and resumption use (grant-resumption D4).
// it held when that ended something, delivers the subscriptions it displaced
// (`DeliverDisplacedSubscriptionsTx`), restores the org-type default if the
// pool was left vacant, and re-materializes, then writes a reconcile request
// for each live subscription of the organization
// (`RequestPoolSubscriptionReconciles`), within the caller's transaction. It
// reads the grant and locks its pool before it writes the grant row, the
// order revocation and resumption use (grant-resumption D4).
// A grant that is no longer active raises ErrGrantNotActive and writes
// nothing; a grant that names no organization has no pool to lock or settle,
// so the lock, resumption and materialization are skipped for it.
@@ -390,6 +400,10 @@ func ExpireGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (*ExpireGran
ResumptionReasonExpiration+grantID); err != nil {
return nil, fmt.Errorf("resume the grants %s held: %w", grantID, err)
}
if _, err := DeliverDisplacedSubscriptionsTx(ctx, tx, poolID, "",
SubscriptionDeliveryExpiration+grantID, ended); err != nil {
return nil, fmt.Errorf("deliver the subscriptions %s displaced: %w", grantID, err)
}
}
if _, err := ReapplyDefaultsIfVacant(ctx, tx, poolID, Actor{
ActorType: "system",
@@ -208,6 +208,66 @@ func (q *Queries) ListRecentTransitions(ctx context.Context, limit int32) ([]Lis
return items, nil
}
const listRecordedSubscriptionProductsOfPool = `-- name: ListRecordedSubscriptionProductsOfPool :many
SELECT s.subscription_id::uuid AS subscription_id,
s.status::text AS status,
si.product_id::uuid AS product_id,
SUM(si.quantity)::int AS quantity
FROM core.resource_pools rp
JOIN core.accounts a ON a.org_id = rp.org_id
JOIN core.subscriptions s ON s.billing_account_id = a.billing_account_id
JOIN core.subscription_items si ON si.subscription_id = s.subscription_id
JOIN core.products p ON p.product_id = si.product_id
WHERE rp.pool_id = $1::uuid
AND s.status IN ('active', 'trialing', 'past_due', 'unpaid', 'paused')
AND si.ended_at IS NULL
AND p.entitlement_set_id IS NOT NULL
GROUP BY s.subscription_id, s.status, si.product_id
ORDER BY s.subscription_id, si.product_id
`
type ListRecordedSubscriptionProductsOfPoolRow struct {
SubscriptionID string `json:"subscription_id"`
Status string `json:"status"`
ProductID string `json:"product_id"`
Quantity int32 `json:"quantity"`
}
// The recorded products of the pool's organization's subscriptions whose
// Stripe status is active, trialing, past_due, unpaid or paused: per
// subscription and product the summed quantity of its current items, for
// products with an entitlement set, by subscription id then product id. The
// ending acts deliver these products again
// (grant-pauses-subscription-billing D7); a subscription already canceled
// delivers nothing. The caller holds the pool row.
func (q *Queries) ListRecordedSubscriptionProductsOfPool(ctx context.Context, poolID string) ([]ListRecordedSubscriptionProductsOfPoolRow, error) {
rows, err := q.db.QueryContext(ctx, listRecordedSubscriptionProductsOfPool, poolID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecordedSubscriptionProductsOfPoolRow{}
for rows.Next() {
var i ListRecordedSubscriptionProductsOfPoolRow
if err := rows.Scan(
&i.SubscriptionID,
&i.Status,
&i.ProductID,
&i.Quantity,
); 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 listSubscriptionDisplacements = `-- name: ListSubscriptionDisplacements :many
SELECT d.plan_ladder_id::uuid AS plan_ladder_id,
pl.name AS ladder_name,
@@ -336,6 +396,64 @@ func (q *Queries) ListSubscriptionDisplacementsBySubscriptionIDs(ctx context.Con
return items, nil
}
const listSubscriptionsSupersededByProvisions = `-- name: ListSubscriptionsSupersededByProvisions :many
SELECT DISTINCT lt.subscription_id::uuid AS subscription_id
FROM (
SELECT DISTINCT ON (p.subscription_id, l.plan_ladder_id)
p.subscription_id, l.plan_ladder_id, l.provision_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 = $1::uuid
AND p.subscription_id = ANY($2::uuid[])
ORDER BY p.subscription_id, l.plan_ladder_id, l.activated_at DESC, l.provision_ladder_id DESC
) lt
WHERE lt.status = 'ended'
AND EXISTS (
SELECT 1
FROM core.supersession_pairs sp
WHERE sp.pool_id = $1::uuid
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 sp.successor_provision_id = ANY($3::uuid[]))
ORDER BY 1
`
type ListSubscriptionsSupersededByProvisionsParams struct {
PoolID string `json:"pool_id"`
SubscriptionIds []string `json:"subscription_ids"`
ProvisionIds []string `json:"provision_ids"`
}
// Of the listed subscriptions, those whose latest episode on a ladder of the
// pool ended in a supersession pair whose successor is one of the listed
// provisions (grant-pauses-subscription-billing D7). The latest episode is
// taken as core.subscription_displacements takes it (00034) and the pair is
// matched as core.supersession_pairs pairs it (00033): the pool, the ladder,
// the ended provision and effective_at = ended_at.
func (q *Queries) ListSubscriptionsSupersededByProvisions(ctx context.Context, arg ListSubscriptionsSupersededByProvisionsParams) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listSubscriptionsSupersededByProvisions, arg.PoolID, pq.Array(arg.SubscriptionIds), pq.Array(arg.ProvisionIds))
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var subscription_id string
if err := rows.Scan(&subscription_id); err != nil {
return nil, err
}
items = append(items, subscription_id)
}
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
+15
View File
@@ -555,6 +555,14 @@ type Querier interface {
// order the feed merges its sources in, so a page cut here is the page the
// feed shows.
ListRecentTransitions(ctx context.Context, limit int32) ([]ListRecentTransitionsRow, error)
// The recorded products of the pool's organization's subscriptions whose
// Stripe status is active, trialing, past_due, unpaid or paused: per
// subscription and product the summed quantity of its current items, for
// products with an entitlement set, by subscription id then product id. The
// ending acts deliver these products again
// (grant-pauses-subscription-billing D7); a subscription already canceled
// delivers nothing. The caller holds the pool row.
ListRecordedSubscriptionProductsOfPool(ctx context.Context, poolID string) ([]ListRecordedSubscriptionProductsOfPoolRow, error)
// Display metadata for entitlement rendering: each key's human-friendly
// label plus its owning integration's display name. LEFT JOIN because
// provider NULL = platform-owned/pooled (see the resource_keys DDL
@@ -591,6 +599,13 @@ type Querier interface {
// per row. The aliases repeat the function's columns because sqlc cannot read
// a function's RETURNS TABLE. The status filter stays in Go.
ListSubscriptionDisplacementsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]ListSubscriptionDisplacementsBySubscriptionIDsRow, error)
// Of the listed subscriptions, those whose latest episode on a ladder of the
// pool ended in a supersession pair whose successor is one of the listed
// provisions (grant-pauses-subscription-billing D7). The latest episode is
// taken as core.subscription_displacements takes it (00034) and the pair is
// matched as core.supersession_pairs pairs it (00033): the pool, the ladder,
// the ended provision and effective_at = ended_at.
ListSubscriptionsSupersededByProvisions(ctx context.Context, arg ListSubscriptionsSupersededByProvisionsParams) ([]string, 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
@@ -143,3 +143,55 @@ JOIN core.subscriptions s ON s.billing_account_id = a.billing_account_id
WHERE rp.pool_id = sqlc.arg(pool_id)::uuid
AND s.status IN ('active', 'trialing', 'past_due', 'unpaid')
ORDER BY s.subscription_id;
-- name: ListRecordedSubscriptionProductsOfPool :many
-- The recorded products of the pool's organization's subscriptions whose
-- Stripe status is active, trialing, past_due, unpaid or paused: per
-- subscription and product the summed quantity of its current items, for
-- products with an entitlement set, by subscription id then product id. The
-- ending acts deliver these products again
-- (grant-pauses-subscription-billing D7); a subscription already canceled
-- delivers nothing. The caller holds the pool row.
SELECT s.subscription_id::uuid AS subscription_id,
s.status::text AS status,
si.product_id::uuid AS product_id,
SUM(si.quantity)::int AS quantity
FROM core.resource_pools rp
JOIN core.accounts a ON a.org_id = rp.org_id
JOIN core.subscriptions s ON s.billing_account_id = a.billing_account_id
JOIN core.subscription_items si ON si.subscription_id = s.subscription_id
JOIN core.products p ON p.product_id = si.product_id
WHERE rp.pool_id = sqlc.arg(pool_id)::uuid
AND s.status IN ('active', 'trialing', 'past_due', 'unpaid', 'paused')
AND si.ended_at IS NULL
AND p.entitlement_set_id IS NOT NULL
GROUP BY s.subscription_id, s.status, si.product_id
ORDER BY s.subscription_id, si.product_id;
-- name: ListSubscriptionsSupersededByProvisions :many
-- Of the listed subscriptions, those whose latest episode on a ladder of the
-- pool ended in a supersession pair whose successor is one of the listed
-- provisions (grant-pauses-subscription-billing D7). The latest episode is
-- taken as core.subscription_displacements takes it (00034) and the pair is
-- matched as core.supersession_pairs pairs it (00033): the pool, the ladder,
-- the ended provision and effective_at = ended_at.
SELECT DISTINCT lt.subscription_id::uuid AS subscription_id
FROM (
SELECT DISTINCT ON (p.subscription_id, l.plan_ladder_id)
p.subscription_id, l.plan_ladder_id, l.provision_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 = sqlc.arg(pool_id)::uuid
AND p.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[])
ORDER BY p.subscription_id, l.plan_ladder_id, l.activated_at DESC, l.provision_ladder_id DESC
) lt
WHERE lt.status = 'ended'
AND EXISTS (
SELECT 1
FROM core.supersession_pairs sp
WHERE sp.pool_id = sqlc.arg(pool_id)::uuid
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 sp.successor_provision_id = ANY(sqlc.arg(provision_ids)::uuid[]))
ORDER BY 1;
@@ -0,0 +1,291 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"slices"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
)
// Subscription delivery (openspec change grant-pauses-subscription-billing,
// design D7). A subscription that bills while a grant holds its ladder is
// recorded but holds no provision. When the act that ends the holder (a
// revocation, an expiry, the holding subscription's end, or its dropping the
// item) ended a provision, it calls DeliverDisplacedSubscriptionsTx after
// ResumeReplacedGrantsTx and before ReapplyDefaultsIfVacant (in the
// reconcile's active branch, after the surplus end), so the floor
// finds the ladder held and no default is conferred over a subscription that
// is paid up. An act that ended nothing delivers nothing, as it resumes
// nothing, so a replay is inert.
//
// The step confers only. Ending and re-bounding stay with the reconcile the
// act's request runs seconds later. It takes no subscription advisory lock:
// every position write is under the pool row, which the caller holds, and the
// subscription lock protects the subscription's own rows, which the step
// only reads (docs/database-locks.md).
// The transition reasons a delivery records, one per ending act, each
// followed by the id of the plan whose provision ended. The Tier changes
// list reads them as "Subscription delivers again after a revocation",
// "... after a grant expired", "... after a subscription ended" and "...
// after a subscription dropped its tier".
const (
SubscriptionDeliveryRevocation = "subscription-delivery:revocation:"
SubscriptionDeliveryExpiration = "subscription-delivery:expiration:"
SubscriptionDeliverySubscription = "subscription-delivery:subscription:"
SubscriptionDeliveryDropped = "subscription-delivery:dropped:"
)
// deliverySavepoint isolates one subscription's delivery, so a refusal rolls
// back that subscription alone.
const deliverySavepoint = "subscription_delivery"
// DeliveredProduct is one product a delivery conferred, from which
// subscription, and the provision it wrote.
type DeliveredProduct struct {
SubscriptionID string
ProductID string
ProvisionID string
}
// recordedSubscription is one subscription's recorded products in a delivery:
// its status and its products in product id order with the summed quantity of
// each.
type recordedSubscription struct {
id string
status string
products []string
quantity map[string]int32
}
// DeliverDisplacedSubscriptionsTx confers, within the caller's transaction
// and under the pool row the caller holds, each product of the organization's
// subscriptions that the subscription records and no provision of it holds,
// unless another plan holds its ladder or a higher tier of the subscription
// takes it. exceptSubscriptionID (empty: none) is the subscription whose
// reconcile is calling: the one that ended, or the one that dropped the item.
// endedProvisionIDs are the provisions the act ended: the subscriptions they
// superseded are delivered first, then the rest, each group in subscription
// id order, so that a subscription the ending plan displaced is not left
// behind a subscription it had itself displaced.
//
// A subscription whose status is past_due, unpaid or paused comes back
// suspended. A refused conferral rolls back the whole subscription's delivery
// to its savepoint, is logged, and the ending proceeds; a lock wait, a
// deadlock and a cancelled context are not refusals: they fail the act, which
// its caller retries whole. The caller materializes afterwards.
func DeliverDisplacedSubscriptionsTx(ctx context.Context, tx *sql.Tx, poolID, exceptSubscriptionID, reason string, endedProvisionIDs []string) ([]DeliveredProduct, error) {
q := New(tx)
rows, err := q.ListRecordedSubscriptionProductsOfPool(ctx, poolID)
if err != nil {
return nil, fmt.Errorf("deliver subscriptions: read the recorded products of pool %s: %w", poolID, err)
}
subs := recordedSubscriptions(rows, exceptSubscriptionID)
if len(subs) == 0 {
return nil, nil
}
subs, err = supersededFirst(ctx, q, poolID, subs, endedProvisionIDs)
if err != nil {
return nil, err
}
delivered := []DeliveredProduct{}
for _, s := range subs {
got, err := deliverSubscription(ctx, tx, q, poolID, s, reason)
if err != nil {
return nil, fmt.Errorf("deliver subscription %s: %w", s.id, err)
}
delivered = append(delivered, got...)
}
return delivered, nil
}
// recordedSubscriptions groups the query's rows (ordered by subscription id
// then product id) by subscription, leaving out the excepted one.
func recordedSubscriptions(rows []ListRecordedSubscriptionProductsOfPoolRow, except string) []recordedSubscription {
var subs []recordedSubscription
for _, r := range rows {
if r.SubscriptionID == except {
continue
}
if n := len(subs); n == 0 || subs[n-1].id != r.SubscriptionID {
subs = append(subs, recordedSubscription{id: r.SubscriptionID, status: r.Status, quantity: map[string]int32{}})
}
s := &subs[len(subs)-1]
s.products = append(s.products, r.ProductID)
s.quantity[r.ProductID] = r.Quantity
}
return subs
}
// supersededFirst puts the subscriptions the ended provisions superseded
// before the rest, each group keeping its subscription id order.
func supersededFirst(ctx context.Context, q *Queries, poolID string, subs []recordedSubscription, endedProvisionIDs []string) ([]recordedSubscription, error) {
ids := make([]string, 0, len(subs))
for _, s := range subs {
ids = append(ids, s.id)
}
first, err := q.ListSubscriptionsSupersededByProvisions(ctx, ListSubscriptionsSupersededByProvisionsParams{
PoolID: poolID,
SubscriptionIds: ids,
ProvisionIds: endedProvisionIDs,
})
if err != nil {
return nil, fmt.Errorf("deliver subscriptions: read the subscriptions the ended provisions superseded: %w", err)
}
isFirst := make(map[string]bool, len(first))
for _, id := range first {
isFirst[id] = true
}
ordered := make([]recordedSubscription, 0, len(subs))
for _, s := range subs {
if isFirst[s.id] {
ordered = append(ordered, s)
}
}
for _, s := range subs {
if !isFirst[s.id] {
ordered = append(ordered, s)
}
}
return ordered, nil
}
// deliverSubscription confers the products of one subscription that it
// records and holds no provision of.
func deliverSubscription(ctx context.Context, tx *sql.Tx, q *Queries, poolID string, s recordedSubscription, reason string) ([]DeliveredProduct, error) {
subID := uuid.MustParse(s.id)
live, err := q.GetLivePoolProvisionsBySubscriptionID(ctx, uuid.NullUUID{UUID: subID, Valid: true})
if err != nil {
return nil, fmt.Errorf("list the live provisions: %w", err)
}
held := make(map[string]bool, len(live))
for _, p := range live {
held[p.ProductID] = true
}
var missing []string
for _, productID := range s.products {
if !held[productID] {
missing = append(missing, productID)
}
}
if len(missing) == 0 {
return nil, nil
}
kept, err := deliverableProducts(ctx, q, poolID, s, missing)
if err != nil {
return nil, err
}
toConfer := false
for _, productID := range kept {
if !held[productID] {
toConfer = true
}
}
if !toConfer {
return nil, nil
}
return deliverInSavepoint(ctx, tx, q, poolID, s, kept, held, reason)
}
// deliveredSuspended reports whether a subscription of this status holds its
// tier suspended, as the reconcile's suspended branch leaves it.
func deliveredSuspended(status string) bool {
return slices.Contains([]string{"past_due", "unpaid", "paused"}, status)
}
// deliverableProducts is what ChooseTiers keeps among the subscription's
// recorded products that DisplacedProducts does not leave alone, the held
// ones included (the caller skips those), in product id order.
func deliverableProducts(ctx context.Context, q *Queries, poolID string, s recordedSubscription, missing []string) ([]string, error) {
shapes, err := q.ListConferralShapes(ctx, s.products)
if err != nil {
return nil, fmt.Errorf("list the conferral shapes: %w", err)
}
rungs := RungsByProduct(shapes)
rows, err := q.ListSubscriptionDisplacements(ctx, ListSubscriptionDisplacementsParams{
SubscriptionID: s.id,
PoolID: poolID,
})
if err != nil {
return nil, fmt.Errorf("list the displacements: %w", err)
}
left := DisplacedProducts(missing, rungs, DisplacementsFromRows(rows))
remaining := make([]string, 0, len(s.products))
for _, productID := range s.products {
if _, out := left[productID]; !out {
remaining = append(remaining, productID)
}
}
kept, _ := ChooseTiers(remaining, rungs)
return kept, nil
}
// deliverInSavepoint confers the kept products the subscription does not hold
// and, when it delivered something and its status is past_due, unpaid or
// paused, suspends them, all under one savepoint. A refused conferral rolls
// back the whole subscription's delivery to the savepoint, is logged, and
// the step moves on with nothing delivered for this subscription: delivering
// part of it would leave the requested reconcile failing on the refused
// product with the billing pause never cleared. A lock wait, a deadlock, a
// cancelled context and a failing suspension fail the act.
func deliverInSavepoint(ctx context.Context, tx *sql.Tx, q *Queries, poolID string, s recordedSubscription, kept []string, held map[string]bool, reason string) ([]DeliveredProduct, error) {
subID := uuid.MustParse(s.id)
if _, err := tx.ExecContext(ctx, "SAVEPOINT "+deliverySavepoint); err != nil {
return nil, err
}
var delivered []DeliveredProduct
for _, productID := range kept {
if held[productID] {
continue
}
provisionID, outcome, confErr := q.Confer(ctx, ConferParams{
PoolID: poolID,
ProductID: productID,
SubscriptionID: uuid.NullUUID{UUID: subID, Valid: true},
Quantity: s.quantity[productID],
ActorType: "system",
Reason: sql.NullString{String: reason, Valid: reason != ""},
})
if confErr != nil {
if db.IsLockContention(confErr) || ctx.Err() != nil {
return nil, fmt.Errorf("confer product %s: %w", productID, confErr)
}
if _, rbErr := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+deliverySavepoint); rbErr != nil {
return nil, errors.Join(confErr, rbErr)
}
slog.WarnContext(ctx, "subscription delivery refused; the ending proceeds without it",
slog.String("subscription_id", s.id),
slog.String("product_id", productID),
slog.String("pool_id", poolID),
slog.Any("error", confErr))
return nil, nil
}
// A subscription source returns noop only when it already holds a
// live provision of the product (core.confer step 4a), which the
// missing filter excludes.
if outcome == "created" {
delivered = append(delivered, DeliveredProduct{SubscriptionID: s.id, ProductID: productID, ProvisionID: provisionID})
}
}
if len(delivered) > 0 && deliveredSuspended(s.status) {
if _, err := q.SyncSourceStatus(ctx, SyncSourceStatusParams{
Status: "suspended",
SubscriptionID: uuid.NullUUID{UUID: subID, Valid: true},
}); err != nil {
return nil, fmt.Errorf("suspend the delivered tier: %w", err)
}
}
if _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+deliverySavepoint); err != nil {
return nil, err
}
return delivered, nil
}
@@ -0,0 +1,575 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// The act that ends a holder delivers the subscription it displaced
// (openspec change grant-pauses-subscription-billing, design D7). The world
// is grant_resumption_test.go's plusProWorld: one ladder Wiki with Free, Plus,
// Pro and Max at ranks 0 to 3. A subscription S that pays for Plus is
// displaced by a grant of Pro; the subscription's recorded items name Plus
// and no provision of it is live. The fulfillment package drives the same
// step through the real reconcile.
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"testing"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
// record writes a current item of the subscription for the product, the row
// the reconcile's item mirror keeps.
func (w *resumeWorld) record(sub, productID string, quantity int) {
w.t.Helper()
var priceID string
if err := w.tx.QueryRowContext(w.ctx,
`INSERT INTO core.prices (product_id, currency, unit_amount, recurring_interval)
VALUES ($1, 'usd', 1000, 'month') RETURNING price_id`, productID).Scan(&priceID); err != nil {
w.t.Fatalf("create a price of %s: %v", productID, err)
}
if _, err := w.tx.ExecContext(w.ctx,
`INSERT INTO core.subscription_items (subscription_id, product_id, price_id, quantity)
VALUES ($1, $2, $3, $4)`, sub, productID, priceID, quantity); err != nil {
w.t.Fatalf("record %s on subscription %s: %v", productID, sub, err)
}
}
// setlessProduct creates a published product with no entitlement set, which
// core.confer refuses.
func (w *resumeWorld) setlessProduct() string {
w.t.Helper()
var id string
if err := w.tx.QueryRowContext(w.ctx,
`INSERT INTO core.products (name, is_active, is_public, lifecycle_status)
VALUES ($1, TRUE, TRUE, 'published') RETURNING product_id`,
"Setless "+uuid.NewString()[:8]).Scan(&id); err != nil {
w.t.Fatalf("create a product with no entitlement set: %v", err)
}
return id
}
// setStatus writes the subscription's Stripe status.
func (w *resumeWorld) setStatus(sub, status string) {
w.t.Helper()
if _, err := w.tx.ExecContext(w.ctx, `UPDATE core.subscriptions SET status = $2 WHERE subscription_id = $1`, sub, status); err != nil {
w.t.Fatalf("set subscription %s to %s: %v", sub, status, err)
}
}
// liveProducts is the products of the subscription's non-ended provisions.
func (w *resumeWorld) liveProducts(sub string) []string {
w.t.Helper()
rows, err := w.tx.QueryContext(w.ctx,
`SELECT product_id::text FROM core.pool_provisions WHERE subscription_id = $1 AND status <> 'ended' ORDER BY product_id`, sub)
if err != nil {
w.t.Fatalf("read the live provisions of %s: %v", sub, err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
w.t.Fatalf("scan: %v", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
w.t.Fatalf("read the live provisions of %s: %v", sub, err)
}
return out
}
// positionStatus is the status of the subscription's live position on a
// ladder, "" when it has none.
func (w *resumeWorld) positionStatus(sub string) string {
w.t.Helper()
var status string
err := w.tx.QueryRowContext(w.ctx, `
SELECT l.status FROM core.pool_provision_ladders l
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
WHERE p.subscription_id = $1 AND l.status <> 'ended'`, sub).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return ""
}
if err != nil {
w.t.Fatalf("read the position of %s: %v", sub, err)
}
return status
}
// holderSubscription is the subscription that holds the ladder, "" when a
// grant or nothing does.
func (w *resumeWorld) holderSubscription(ladderID string) string {
w.t.Helper()
var sub sql.NullString
err := w.tx.QueryRowContext(w.ctx, `
SELECT p.subscription_id::text FROM core.pool_provision_ladders l
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
WHERE l.pool_id = $1 AND l.plan_ladder_id = $2 AND l.status IN ('active', 'suspended')`,
w.org.pool.PoolID, ladderID).Scan(&sub)
if errors.Is(err, sql.ErrNoRows) {
return ""
}
if err != nil {
w.t.Fatalf("read the holder of %s: %v", ladderID, err)
}
return sub.String
}
// deliveryReasons is the reasons of the subscription's initiating transitions
// that a delivery wrote.
func (w *resumeWorld) deliveryReasons(sub string) []string {
w.t.Helper()
rows, err := w.tx.QueryContext(w.ctx, `
SELECT t.reason FROM core.pool_provision_transitions t
JOIN core.pool_provisions p ON p.provision_id = t.provision_id
WHERE p.subscription_id = $1 AND t.reason LIKE 'subscription-delivery:%'
ORDER BY t.transition_id`, sub)
if err != nil {
w.t.Fatalf("read the delivery reasons of %s: %v", sub, err)
}
defer rows.Close()
var out []string
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
w.t.Fatalf("scan: %v", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
w.t.Fatalf("read the delivery reasons of %s: %v", sub, err)
}
return out
}
// displacedByGrant is the common shape: S pays for Plus and holds it, then a
// grant of the product supersedes S. The grant delivers; S records Plus and
// holds nothing.
func (p plusProWorld) displacedByGrant(grantProduct string) (entitlements.Grant, string) {
sub := p.subscription()
p.conferSub(sub, p.plus)
p.record(sub, p.plus, 1)
g := p.grant(grantProduct, grantOpts{})
p.confer(g)
return g, sub
}
func TestDeliverDisplaced_ARevocationDeliversTheSubscription(t *testing.T) {
p := newPlusProWorld(t)
g, sub := p.displacedByGrant(p.pro)
p.record(sub, p.plus, 2) // a second item of the product: the quantities add
p.record(sub, p.setlessProduct(), 1)
p.setStatus(sub, "trialing")
if got := p.liveProducts(sub); len(got) != 0 {
t.Fatalf("precondition: S holds %v while the grant displaces it", got)
}
p.revoke(g)
if got := p.holdsLadder(p.wiki); got != p.plus {
t.Errorf("after the revocation, S holds %v on the ladder, want Plus", p.productName(got))
}
if got := p.holderSubscription(p.wiki); got != sub {
t.Errorf("the ladder's holder is subscription %q, want S (%s)", got, sub)
}
if n := p.defaultGrants(); n != 0 {
t.Errorf("the floor conferred the default over a subscription it should have delivered (%d default decrees)", n)
}
if got := p.positionStatus(sub); got != "active" {
t.Errorf("the trialing S's position status = %q, want active", got)
}
want := []string{entitlements.SubscriptionDeliveryRevocation + g.GrantID}
if got := p.deliveryReasons(sub); !slices.Equal(got, want) {
t.Errorf("delivery reasons = %v, want %v", got, want)
}
if got := p.count(`SELECT coalesce(sum(quantity), 0) FROM core.pool_provisions WHERE subscription_id = $1 AND status <> 'ended'`, sub); got != 3 {
t.Errorf("S's delivered quantity = %d, want 3, the sum of its items", got)
}
}
func TestDeliverDisplaced_AnExpiryDeliversTheSubscription(t *testing.T) {
p := newPlusProWorld(t)
g, sub := p.displacedByGrant(p.pro)
p.expire(g)
if got := p.holdsLadder(p.wiki); got != p.plus || p.holderSubscription(p.wiki) != sub {
t.Errorf("after the expiry, S holds %v, want Plus", p.productName(got))
}
want := []string{entitlements.SubscriptionDeliveryExpiration + g.GrantID}
if got := p.deliveryReasons(sub); !slices.Equal(got, want) {
t.Errorf("delivery reasons = %v, want %v", got, want)
}
}
func TestDeliverDisplaced_APastDueSubscriptionComesBackSuspended(t *testing.T) {
for _, status := range []string{"past_due", "unpaid", "paused"} {
t.Run(status, func(t *testing.T) { wantComesBackSuspended(t, status) })
}
}
func wantComesBackSuspended(t *testing.T, status string) {
p := newPlusProWorld(t)
g, sub := p.displacedByGrant(p.pro)
p.setStatus(sub, status)
p.revoke(g)
if got := p.positionStatus(sub); got != "suspended" {
t.Errorf("%s: S's position status = %q, want suspended", status, got)
}
}
// A canceled subscription still records its items and holds nothing; the
// act delivers it nothing.
func TestDeliverDisplaced_ACanceledSubscriptionIsNotDelivered(t *testing.T) {
p := newPlusProWorld(t)
g, sub := p.displacedByGrant(p.pro)
p.setStatus(sub, "canceled")
p.revoke(g)
if got := p.liveProducts(sub); len(got) != 0 {
t.Errorf("a canceled subscription was delivered: %v", got)
}
}
// S pays for Plus and Pro and holds Pro, as the reconcile leaves a
// subscription paying for two tiers of one ladder. An act that ends a grant
// on another ladder runs the step, which chooses among all S's recorded
// products and so never confers the lower tier over S's own higher one.
func TestDeliverDisplaced_AHeldHigherTierIsNotDowngraded(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.conferSub(sub, p.pro)
p.record(sub, p.plus, 1)
p.record(sub, p.pro, 1)
forumFree, forumPlus := p.product("Forum Free"), p.product("Forum Plus")
p.ladder("Forum", forumFree, forumPlus)
g := p.grant(forumPlus, grantOpts{})
p.confer(g)
p.revoke(g)
if got := p.holdsLadder(p.wiki); got != p.pro {
t.Errorf("the delivery conferred S's lower tier over its own higher one: S holds %v", p.productName(got))
}
}
// S records Plus on Wiki and Forum Plus on Forum and is displaced on both by
// one grant of Pro, a tier of both ladders. Forum Plus is draft, so its
// conferral is refused, and the refusal rolls back the whole of S's delivery;
// S′ records a product on a third ladder that nothing holds, and is
// delivered.
func TestDeliverDisplaced_ARefusedConferralRollsBackTheWholeSubscription(t *testing.T) {
p := newPlusProWorld(t)
forumFree, forumPlus := p.product("Forum Free"), p.product("Forum Plus")
p.ladder("Forum", forumFree, forumPlus, p.pro)
thirdFree, thirdPlus := p.product("Third Free"), p.product("Third Plus")
p.ladder("Third", thirdFree, thirdPlus)
sub, other := p.subscription(), p.subscription()
p.conferSub(sub, p.plus)
p.record(sub, p.plus, 1)
p.conferSub(sub, forumPlus)
p.record(sub, forumPlus, 1)
p.record(other, thirdPlus, 1)
g := p.grant(p.pro, grantOpts{})
p.confer(g)
if _, err := p.tx.ExecContext(p.ctx, `UPDATE core.products SET lifecycle_status = 'draft' WHERE product_id = $1`, forumPlus); err != nil {
t.Fatalf("draft Forum Plus: %v", err)
}
p.revoke(g)
if got := p.liveProducts(sub); len(got) != 0 {
t.Errorf("S holds %v after one of its conferrals was refused, want nothing", got)
}
if got := p.liveProducts(other); !slices.Equal(got, []string{thirdPlus}) {
t.Errorf("S′ holds %v, want its product: a refusal rolled back another subscription", got)
}
}
// G0, a marked grant G replaced, resumes before S is delivered: S is then
// displaced by G0 and stays left alone.
func TestDeliverDisplaced_AResumableGrantGoesFirst(t *testing.T) {
p := newPlusProWorld(t)
sub := p.subscription()
p.conferSub(sub, p.plus)
p.record(sub, p.plus, 1)
g0 := p.grant(p.plus, grantOpts{marked: true})
p.confer(g0)
g := p.grant(p.pro, grantOpts{})
p.confer(g)
p.revoke(g)
if got := p.liveProducts(sub); len(got) != 0 {
t.Errorf("with G0 resumable, S was delivered: %v", got)
}
if !p.delivers(g0.GrantID) {
t.Errorf("G0 does not deliver after the revocation, want it resumed")
}
}
func TestDeliverDisplaced_ARefusedConferralIsRolledBackAlone(t *testing.T) {
p := newPlusProWorld(t)
g, sub := p.displacedByGrant(p.pro)
if _, err := p.tx.ExecContext(p.ctx, `UPDATE core.products SET lifecycle_status = 'draft' WHERE product_id = $1`, p.plus); err != nil {
t.Fatalf("draft Plus: %v", err)
}
var logs strings.Builder
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
_, err := entitlements.RevokeGrantTx(p.ctx, p.tx, entitlements.RevokeGrantInput{
GrantID: g.GrantID, OrgID: p.org.org.OrgID, RevokedByPersonID: p.org.person.PersonID,
})
if err != nil {
t.Fatalf("the refused delivery failed the revocation: %v", err)
}
if got := p.liveProducts(sub); len(got) != 0 {
t.Errorf("S holds %v after a refused delivery, want nothing", got)
}
if n := p.defaultGrants(); n != 1 {
t.Errorf("default decrees = %d, want 1: the floor runs after a refused delivery", n)
}
wantInLine(t, logs.String(), "subscription delivery refused; the ending proceeds without it",
"subscription_id="+sub, "product_id="+p.plus, "pool_id="+p.org.pool.PoolID, "error=")
}
func TestDeliverDisplaced_AnActThatEndedNothingDeliversNothing(t *testing.T) {
p := newPlusProWorld(t)
g, sub := p.displacedByGrant(p.pro)
p.endProvision(p.liveProvision(g.GrantID))
p.revoke(g)
if got := p.liveProducts(sub); len(got) != 0 {
t.Errorf("a replayed revocation delivered %v", got)
}
}
// Two subscriptions displaced on two ladders by one bundle grant are
// delivered in subscription id order, whichever ladder each pays for.
func TestDeliverDisplaced_SubscriptionsInIdOrder(t *testing.T) {
for _, flip := range []bool{false, true} {
t.Run(fmt.Sprintf("flip=%v", flip), func(t *testing.T) {
p := newPlusProWorld(t)
forumFree, forumPlus := p.product("Forum Free"), p.product("Forum Plus")
p.ladder("Forum", forumFree, forumPlus, p.pro)
first, second := p.subscription(), p.subscription()
onWiki, onForum := first, second
if flip {
onWiki, onForum = second, first
}
p.conferSub(onWiki, p.plus)
p.record(onWiki, p.plus, 1)
p.conferSub(onForum, forumPlus)
p.record(onForum, forumPlus, 1)
g := p.grant(p.pro, grantOpts{})
p.confer(g)
p.revoke(g)
got := p.deliveredSubscriptions()
if len(got) != 2 {
t.Fatalf("delivered %v, want both subscriptions", got)
}
if !slices.IsSorted(got) {
t.Errorf("delivered %v, want subscription id order", got)
}
})
}
}
// deliveredSubscriptions is the subscriptions of the delivery transitions in
// the order the transitions were written.
func (w *resumeWorld) deliveredSubscriptions() []string {
w.t.Helper()
rows, err := w.tx.QueryContext(w.ctx, `
SELECT p.subscription_id::text FROM core.pool_provision_transitions t
JOIN core.pool_provisions p ON p.provision_id = t.provision_id
WHERE t.pool_id = $1 AND t.reason LIKE 'subscription-delivery:%'
ORDER BY t.transition_id`, w.org.pool.PoolID)
if err != nil {
w.t.Fatalf("read the delivered subscriptions: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
w.t.Fatalf("scan: %v", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
w.t.Fatalf("read the delivered subscriptions: %v", err)
}
return out
}
// S1, the lower id, is displaced by S2, and S2 is displaced by G. When G ends,
// S2 delivers first and S1 finds S2 holding the ladder; subscription id order
// alone would give the ladder to S1.
func TestDeliverDisplaced_TheSubscriptionTheEndingHolderDisplacedGoesFirst(t *testing.T) {
p := newPlusProWorld(t)
s1, s2 := p.subscription(), p.subscription()
if s1 >= s2 {
t.Fatalf("precondition: subscription ids %s, %s do not rise", s1, s2)
}
p.conferSub(s1, p.plus)
p.record(s1, p.plus, 1)
p.conferSub(s2, p.pro)
p.record(s2, p.pro, 1)
g := p.grant(p.maxTier, grantOpts{})
p.confer(g)
p.revoke(g)
got := p.holdsLadder(p.wiki)
if got != p.pro || p.holderSubscription(p.wiki) != s2 {
t.Errorf("after G ended, the ladder holds %v, want S2's product", p.productName(got))
}
if got := p.liveProducts(s1); len(got) != 0 {
t.Errorf("S1 holds %v, want it left alone behind S2", got)
}
}
// An act that waits on the pool row finishes while another transaction holds
// the subscription's advisory lock: the step reads the subscription and
// writes positions under the pool row alone.
func TestDeliverDisplaced_WaitsForThePoolRow(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
fx := newDeliveryFixture(t, ctx, database)
poolHolder, err := entitlements.BeginMaterializing(ctx, database)
if err != nil {
t.Fatal(err)
}
defer poolHolder.Rollback()
if _, err := entitlements.New(poolHolder).LockPools(ctx, fx.org.pool.PoolID); err != nil {
t.Fatalf("lock the pool: %v", err)
}
var poolHolderPID int
if err := poolHolder.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&poolHolderPID); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
tx, err := entitlements.BeginMaterializing(ctx, database)
if err != nil {
done <- err
return
}
defer tx.Rollback()
if _, err := entitlements.RevokeGrantTx(ctx, tx, entitlements.RevokeGrantInput{
GrantID: fx.g.GrantID, OrgID: fx.org.org.OrgID, RevokedByPersonID: fx.org.person.PersonID,
}); err != nil {
done <- err
return
}
done <- tx.Commit()
}()
dbtest.AwaitLockWait(t, database, dbtest.Backend{BlockedBy: poolHolderPID})
// S's reconcile waiting on the pool row holds its advisory lock, keyed by
// the Stripe id, and S's row, which its upsert updated before it took the
// pool row; this transaction holds both and leaves the pool row free.
subLock, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer subLock.Rollback()
if _, err := subLock.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, fx.stripeID); err != nil {
t.Fatalf("take S's advisory lock: %v", err)
}
if _, err := subLock.ExecContext(ctx, `UPDATE core.subscriptions SET status = status WHERE subscription_id = $1`, fx.sub); err != nil {
t.Fatalf("lock S's row: %v", err)
}
if err := poolHolder.Commit(); err != nil {
t.Fatalf("release the pool row: %v", err)
}
if err := <-done; err != nil {
t.Fatalf("the delivery took S's advisory lock: %v", err)
}
if n := fx.liveProvisions(t, ctx, database); n != 1 {
t.Errorf("live provisions of S = %d after the revocation, want 1", n)
}
}
// deliveryFixture is one committed pool: S pays for Plus and is displaced by
// a grant of Pro, and S has a Stripe mapping.
type deliveryFixture struct {
org testOrg
g entitlements.Grant
sub string
stripeID string
}
func newDeliveryFixture(t *testing.T, ctx context.Context, database *sql.DB) deliveryFixture {
t.Helper()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
w := newResumeWorldIn(t, ctx, tx)
plus, pro := w.product("Delivery Plus"), w.product("Delivery Pro")
w.ladder("Delivery Wiki", plus, pro)
fx := deliveryFixture{org: w.org, sub: w.subscription(), stripeID: "sub_" + uuid.NewString()}
w.conferSub(fx.sub, plus)
w.record(fx.sub, plus, 1)
if _, err := tx.ExecContext(ctx,
`INSERT INTO stripe.subscription_mappings (subscription_id, stripe_subscription_id, sync_status, livemode)
VALUES ($1, $2, 'synced', false)`, fx.sub, fx.stripeID); err != nil {
t.Fatalf("map the subscription: %v", err)
}
fx.g = w.grant(pro, grantOpts{})
w.confer(fx.g)
if err := tx.Commit(); err != nil {
t.Fatalf("commit the fixture: %v", err)
}
return fx
}
func (fx deliveryFixture) liveProvisions(t *testing.T, ctx context.Context, database *sql.DB) int {
t.Helper()
var n int
if err := database.QueryRowContext(ctx,
`SELECT count(*) FROM core.pool_provisions WHERE subscription_id = $1 AND status <> 'ended'`, fx.sub).Scan(&n); err != nil {
t.Fatalf("count the live provisions of S: %v", err)
}
return n
}
// wantInLine fails the test unless one line of logs holds every part.
func wantInLine(t *testing.T, logs string, parts ...string) {
t.Helper()
for _, line := range strings.Split(logs, "\n") {
found := true
for _, p := range parts {
found = found && strings.Contains(line, p)
}
if found {
return
}
}
t.Errorf("no log line holds %q; logs:\n%s", parts, logs)
}
@@ -25,6 +25,7 @@ import (
"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"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// subscribed is a subscription on the fixture's tier, reconciled once, with
@@ -181,6 +182,11 @@ func TestReconcile_AMarkedGrantOverTheSubscriptionIsNotReplaced(t *testing.T) {
}
}
// fixtureTier is the fixture's tier as the test kit names a product.
func fixtureTier(fx fixture) world.Product {
return world.Product{ID: fx.productID, Name: "fixture tier"}
}
// 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 {
@@ -190,8 +196,50 @@ func withDefaultOnTheLadder(t *testing.T, database *sql.DB, fx fixture) string {
return free
}
// T4: once the grant is revoked and the default holds the ladder, the
// subscription delivers again.
// The act that ends a grant delivers the subscription it displaced, and the
// reconcile the act requested then finds the conferred provision live and its
// product desired, and writes nothing (openspec change
// grant-pauses-subscription-billing, design D7, Locks). S pays for the second
// tier, then for the fixture's tier under a new Stripe item id, as a
// schedule's phase change does; a grant of a third tier displaces it, and the
// grant's revocation delivers the fixture's tier, not the ended second one.
func TestReconcile_ARevocationDeliversTheSubscriptionAndItsReconcileWritesNothing(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
free := withDefaultOnTheLadder(t, database, fx)
_, _, stripePrice2 := secondTier(t, database, fx)
mock := &stripetest.MockBackend{Sub: fx.activeSub(item("si_old_"+fx.sfx, stripePrice2))}
t.Cleanup(stripetest.Install(mock))
reconcile(t, database, fx.stripeSubID, "test:created")
mock.Sub = fx.activeSub(fx.planItem())
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
top, _, _ := addTier(t, database, fx, "top", 6000)
g := issueGrant(t, database, fx, top, false)
if !grantDelivers(t, database, g.GrantID) {
t.Fatalf("precondition: the grant does not deliver after it was issued over the subscription")
}
revokeGrant(t, database, fx, g)
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("after the revocation, the subscription's tier holds %d positions, want 1", n)
}
if n := activeAttachCount(t, database, fx.ladderID, free); n != 0 {
t.Errorf("the floor conferred the default over the subscription the revocation delivered (%d positions)", n)
}
before, mark := poolRows(t, database, fx), len(mock.Calls)
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if n := poolRows(t, database, fx); n != before {
t.Errorf("the reconcile after the revocation wrote %d rows, want none", n-before)
}
if calls := callsSince(mock, mark); len(calls) != 1 || !strings.HasPrefix(calls[0], "stripe GET /v1/subscriptions/") {
t.Errorf("the reconcile after the revocation made Stripe calls beyond the subscription's read: %v", calls)
}
}
// T4: after a refused delivery left the floor's default on the ladder (design
// F11) and the product is published again, the subscription's reconcile
// delivers over the default.
func TestReconcile_TheSubscriptionDeliversAgainOverTheDefault(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
@@ -201,10 +249,13 @@ func TestReconcile_TheSubscriptionDeliversAgainOverTheDefault(t *testing.T) {
reconcile(t, database, fx.stripeSubID, "test:created")
prod2, _, _ := secondTier(t, database, fx)
g := issueGrant(t, database, fx, prod2, false)
w := world.New(t, database)
w.Draft(fixtureTier(fx))
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)
t.Fatalf("the default holds %d positions after the refused delivery, want 1", n)
}
w.Lifecycle(fixtureTier(fx), "published")
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -212,9 +263,8 @@ func TestReconcile_TheSubscriptionDeliversAgainOverTheDefault(t *testing.T) {
}
}
// 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.
// T5: once the grant's tier is removed from the ladder, nothing holds it, and
// the reconcile the removal asked for delivers the subscription again.
func TestReconcile_TheSubscriptionDeliversAgainOnAVacantLadder(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
@@ -222,15 +272,20 @@ func TestReconcile_TheSubscriptionDeliversAgainOnAVacantLadder(t *testing.T) {
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 _, err := entitlements.RemoveTier(context.Background(), database, entitlements.RemoveTierInput{
LadderID: fx.ladderID, ProductID: prod2, OperatorPersonID: fixturePersonID(t, database, fx.orgID),
}); err != nil {
t.Fatalf("remove the grant's tier: %v", err)
}
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))
}
if !grantDelivers(t, database, g.GrantID) {
t.Fatalf("precondition: the grant stopped delivering when its tier was removed")
}
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
@@ -238,8 +293,9 @@ func TestReconcile_TheSubscriptionDeliversAgainOnAVacantLadder(t *testing.T) {
}
}
// T6: a grant issued over the default after the first grant ended keeps the
// ladder against the subscription.
// T6: a grant issued over the default, after a refused delivery left the
// default on the ladder (design F11), keeps the ladder against the
// subscription.
func TestReconcile_AGrantIssuedOverTheDefaultKeepsTheLadder(t *testing.T) {
database := dbtest.Open(t)
fx := seedFixture(t, database)
@@ -248,10 +304,14 @@ func TestReconcile_AGrantIssuedOverTheDefaultKeepsTheLadder(t *testing.T) {
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))
first := issueGrant(t, database, fx, prod2, false)
w := world.New(t, database)
w.Draft(fixtureTier(fx))
revokeGrant(t, database, fx, first)
if n := activeAttachCount(t, database, fx.ladderID, free); n != 1 {
t.Fatalf("precondition: the default holds %d positions on the ladder, want 1", n)
}
w.Lifecycle(fixtureTier(fx), "published")
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")
@@ -539,13 +599,16 @@ func TestReconcile_TwoTiersOfOneLadderConferTheHigher(t *testing.T) {
// twoOnOneLadder is the fixture's subscription S1 with a second subscription
// S2 on the second tier of the same ladder reconciled over it, so S2 holds the
// ladder S1 pays for. It returns S1's and S2's core ids.
// ladder S1 pays for. S2 also pays for a product on a ladder of its own, as
// the second item of s2.Items.Data. It returns S1's and S2's core ids.
func twoOnOneLadder(t *testing.T) (database *sql.DB, mock *stripetest.MockBackend, s2 *stripe.Subscription, core1, core2 string) {
t.Helper()
database, fx, mock := subscribed(t)
_, _, stripePrice2 := secondTier(t, database, fx)
_, _, keptPrice := addLadder(t, database, fx, "kept")
core1 = coreSubID(t, database, fx.stripeSubID)
s2 = secondSubscription(fx, stripePrice2)
s2.Items.Data = append(s2.Items.Data, item("si_kept_"+fx.sfx, keptPrice))
mock.Sub = s2
reconcile(t, database, s2.ID, "stripe:customer.subscription.updated")
return database, mock, s2, core1, coreSubID(t, database, s2.ID)
@@ -574,7 +637,7 @@ func TestSurplusEndRequestsTheOtherSubscriptions(t *testing.T) {
before1, before2 := reconcileEntries(t, database, core1), reconcileEntries(t, database, core2)
dropped := *s2
dropped.Items = &stripe.SubscriptionItemList{}
dropped.Items = &stripe.SubscriptionItemList{Data: s2.Items.Data[1:]}
mock.Sub = &dropped
reconcile(t, database, s2.ID, "stripe:customer.subscription.updated")
+20 -4
View File
@@ -272,8 +272,9 @@ func resolveAccountPool(
// reconcileEnded is the terminal branch (1.10): it ends every live provision
// for this subscription via end_conferral (idempotent — nothing live is a quiet success), resumes
// the grants it replaced, restores the floor, materializes, closes the
// scheduled changes, marks the mapping deleted and commits. Only when it ended
// the grants it replaced, delivers the subscriptions waiting on the ladders it
// held (`DeliverDisplacedSubscriptionsTx`), restores the floor, materializes,
// closes the scheduled changes, marks the mapping deleted and commits. Only when it ended
// a provision, it then writes a reconcile request for each live subscription of
// the organization but this one (`RequestPoolSubscriptionReconciles`). It takes the
// pool row before the floor restoration, so the vacancy read and the
@@ -305,6 +306,12 @@ func reconcileEnded(
entitlements.ResumptionReasonSubscription+subscriptionID); err != nil {
return fmt.Errorf("reconcile: resume the grants subscription %s held: %w", subscriptionID, err)
}
// A subscription waiting on a ladder this one held delivers
// before the floor, in this transaction (grant-pauses-subscription-billing D7).
if _, err := entitlements.DeliverDisplacedSubscriptionsTx(ctx, tx, poolID, subscriptionID,
entitlements.SubscriptionDeliverySubscription+subscriptionID, ended); err != nil {
return fmt.Errorf("reconcile: deliver the subscriptions waiting on subscription %s: %w", subscriptionID, err)
}
}
// The pool row is taken here for the case where nothing ended: then
// end_conferral and resumption took no row, and the vacancy read and the
@@ -595,8 +602,10 @@ func recordCollectionMirror(ctx context.Context, billingQ *billing.Queries, subs
// committed while the reconcile waited is visible (docs/database-locks.md,
// rule 2).
//
// Only when it ended a provision, it then writes a reconcile request for each
// live subscription of the organization but this one
// Only when it ended a provision, it then delivers a subscription that waits
// on the ladder the dropped item held
// (`DeliverDisplacedSubscriptionsTx`), then writes a reconcile request for
// each live subscription of the organization but this one
// (`RequestPoolSubscriptionReconciles`).
func reconcileItems(
ctx context.Context,
@@ -653,6 +662,13 @@ func reconcileItems(
// for those to be reconciled before the materialization, in the order that
// design's Locks section states (grant-pauses-subscription-billing D4).
if len(out.ended) > 0 {
// The subscription is still live, so only a Dashboard edit reaches
// here; a subscription waiting on the ladder it held delivers in
// this transaction (grant-pauses-subscription-billing D7).
if _, err := entitlements.DeliverDisplacedSubscriptionsTx(ctx, tx, poolID, subscriptionID,
entitlements.SubscriptionDeliveryDropped+subscriptionID, out.ended); err != nil {
return conferralOutcome{}, fmt.Errorf("reconcile: deliver the subscriptions waiting on the dropped tier: %w", err)
}
if err := entitlements.RequestPoolSubscriptionReconciles(ctx, tx, poolID, subscriptionID); err != nil {
return conferralOutcome{}, fmt.Errorf("reconcile: request the other subscriptions' reconciles: %w", err)
}
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fulfillment_test
// The reconcile's two ways of ending a holder deliver the subscription that
// waits on its ladder in the same transaction (openspec change
// grant-pauses-subscription-billing, design D7, tasks 7.5): the holding
// subscription's end, and a live subscription dropping the item that held the
// ladder. The fixture is displaced_subscription_test.go's.
import (
"database/sql"
"testing"
stripe "github.com/stripe/stripe-go/v81"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// deliveryReason is the reason of the delivery transition the subscription's
// provision carries, "" when it has none.
func deliveryReason(t *testing.T, database *sql.DB, coreSub string) string {
t.Helper()
return scalarStr(t, database, `
SELECT COALESCE(max(t.reason), '') FROM core.pool_provision_transitions t
JOIN core.pool_provisions p ON p.provision_id = t.provision_id
WHERE p.subscription_id = $1 AND t.reason LIKE 'subscription-delivery:%'`, coreSub)
}
// S1 waits on a ladder S2 holds; S2's end delivers S1 in the reconcile that
// ends S2, before any reconcile of S1.
func TestDeliverDisplaced_TheEndOfTheHoldingSubscription(t *testing.T) {
database, fx, mock := subscribed(t)
prod2, _, stripePrice2 := secondTier(t, database, fx)
first := mock.Sub
second := secondSubscription(fx, stripePrice2)
mock.Sub = second
reconcile(t, database, second.ID, "stripe:customer.subscription.created")
mock.Sub = first
reconcile(t, database, first.ID, "stripe:customer.subscription.updated")
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 1 {
t.Fatalf("precondition: the later subscription's tier holds %d positions, want 1", n)
}
mock.Sub = withStatus(second, stripe.SubscriptionStatusCanceled)
reconcile(t, database, second.ID, "stripe:subscription.deleted")
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("after S2's end, S1 holds %v, want its product", n)
}
want := entitlements.SubscriptionDeliverySubscription + coreSubID(t, database, second.ID)
if got := deliveryReason(t, database, coreSubID(t, database, first.ID)); got != want {
t.Errorf("S1's delivery reason = %q, want %q", got, want)
}
}
// S1 holds a ladder and stays active with a second item on a ladder of its
// own; S2 waits on it. A Dashboard edit drops S1's item for that tier, and
// S1's reconcile delivers S2.
func TestDeliverDisplaced_ADroppedItemDeliversTheWaitingSubscription(t *testing.T) {
database, fx, mock := subscribed(t)
_, _, stripePrice2 := secondTier(t, database, fx)
_, _, keptPrice := addLadder(t, database, fx, "kept")
s2 := mock.Sub
s1 := secondSubscription(fx, stripePrice2)
kept := item("si_kept_"+fx.sfx, keptPrice)
s1.Items.Data = append(s1.Items.Data, kept)
mock.Sub = s1
reconcile(t, database, s1.ID, "stripe:customer.subscription.created")
mock.Sub = s2
reconcile(t, database, s2.ID, "stripe:customer.subscription.updated")
if n := liveProvisions(t, database, fx); n != 0 {
t.Fatalf("precondition: the waiting subscription holds %d live provisions, want 0", n)
}
dropped := secondSubscription(fx, stripePrice2)
dropped.Items.Data = []*stripe.SubscriptionItem{kept}
mock.Sub = dropped
reconcile(t, database, s1.ID, "stripe:customer.subscription.updated")
want := entitlements.SubscriptionDeliveryDropped + coreSubID(t, database, s1.ID)
if got := deliveryReason(t, database, coreSubID(t, database, s2.ID)); got != want {
t.Errorf("after S1 dropped the item, S2's delivery reason = %q, want the dropped prefix (%q)", got, want)
}
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 1 {
t.Errorf("S2 holds %d positions of its product, want 1", n)
}
}
@@ -28,6 +28,7 @@ ListConferralShapes
ListSubscriptionDisplacements
GetLivePoolProvisionsBySubscriptionID
SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)
ListRecordedSubscriptionProductsOfPool
ListLiveSubscriptionsOfPool
<materialize>
ListActiveScheduledChangesBySubscription
@@ -21,6 +21,7 @@ ListResumableGrantsHeldBy
SAVEPOINT grant_resumption
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RELEASE SAVEPOINT grant_resumption
ListRecordedSubscriptionProductsOfPool
LockResourcePool
SELECT EXISTS (
<materialize>
@@ -18,6 +18,7 @@ CreateSubscriptionChange
SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)
LockResourcePool
ListResumableGrantsHeldBy
ListRecordedSubscriptionProductsOfPool
LockResourcePool
SELECT EXISTS (
GetResourcePoolByID
@@ -21,6 +21,7 @@ UpdateSubscriptionCollection
SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)
LockResourcePool
ListResumableGrantsHeldBy
ListRecordedSubscriptionProductsOfPool
LockResourcePool
SELECT EXISTS (
GetResourcePoolByID
@@ -22,6 +22,7 @@ CreateSubscriptionChange
SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)
LockResourcePool
ListResumableGrantsHeldBy
ListRecordedSubscriptionProductsOfPool
LockResourcePool
SELECT EXISTS (
GetResourcePoolByID
@@ -23,6 +23,7 @@ CreateSubscriptionChange
SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)
LockResourcePool
ListResumableGrantsHeldBy
ListRecordedSubscriptionProductsOfPool
LockResourcePool
SELECT EXISTS (
GetResourcePoolByID
@@ -23,6 +23,7 @@ CreateSubscriptionChange
SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)
LockResourcePool
ListResumableGrantsHeldBy
ListRecordedSubscriptionProductsOfPool
LockResourcePool
SELECT EXISTS (
GetResourcePoolByID
@@ -1127,6 +1127,11 @@ func TestHumanizeTransitionReason(t *testing.T) {
{"grant-resumption:subscription:s-1", "Grant resumed after a subscription ended"},
{"grant-resumption:revocation:g-1", "Grant resumed after a revocation"},
{"grant-resumption:expiration:g-1", "Grant resumed after a grant expired"},
// grant-pauses-subscription-billing D7: the four delivery reasons.
{"subscription-delivery:revocation:g-1", "Subscription delivers again after a revocation"},
{"subscription-delivery:expiration:g-1", "Subscription delivers again after a grant expired"},
{"subscription-delivery:subscription:s-1", "Subscription delivers again after a subscription ended"},
{"subscription-delivery:dropped:s-1", "Subscription delivers again after a subscription dropped its tier"},
}
for _, c := range cases {
t.Run(c.raw, func(t *testing.T) {
@@ -1149,6 +1154,10 @@ func TestHumanizeTransitionReason(t *testing.T) {
{"operator_extension", "initiate", "operator"},
{"operator_extension", "transfer", "system"},
{"grant-resumption:subscription:s-1", "initiate", "operator"},
{"subscription-delivery:revocation:g-1", "initiate", "operator"},
{"subscription-delivery:expiration:g-1", "initiate", "operator"},
{"subscription-delivery:subscription:s-1", "initiate", "operator"},
{"subscription-delivery:dropped:s-1", "initiate", "operator"},
} {
if label, known := humanizeTransitionReason(c.raw, c.typ, c.actor); known || label != c.raw {
t.Errorf("humanizeTransitionReason(%q, %q, %q) = %q, %v; want raw", c.raw, c.typ, c.actor, label, known)
+17 -8
View File
@@ -15,12 +15,13 @@ import (
// a label. known is false for a reason the map does not recognize; callers
// then fall back to the raw string (D3: "a reason the map does not know
// renders raw"). Issue grant copies the operator's note into the reason, so
// the two reasons an act writes for itself count only from the writer that
// writes them: a resumption's from "system", an extension's from an
// "operator" transition of the type its conferral records (transfer, or the
// incumbent's end beside it); the same text from anyone else renders raw.
// the reasons an act writes for itself count only from the writer that
// writes them: a resumption's and a delivery's from "system", an extension's
// from an "operator" transition of the type its conferral records (transfer,
// or the incumbent's end beside it); the same text from anyone else renders
// raw.
func humanizeTransitionReason(raw, transitionType, actorType string) (label string, known bool) {
resumption := actorType == "system"
bySystem := actorType == "system"
extension := actorType == "operator" && (transitionType == "transfer" || transitionType == "end")
switch {
case raw == "operator_revocation":
@@ -35,12 +36,20 @@ func humanizeTransitionReason(raw, transitionType, actorType string) (label stri
return "Default on organization creation", true
case raw == "demoseed: floor personal pool at org-type default":
return "Seeded default", true
case resumption && strings.HasPrefix(raw, entitlements.ResumptionReasonSubscription):
case bySystem && strings.HasPrefix(raw, entitlements.ResumptionReasonSubscription):
return "Grant resumed after a subscription ended", true
case resumption && strings.HasPrefix(raw, entitlements.ResumptionReasonRevocation):
case bySystem && strings.HasPrefix(raw, entitlements.ResumptionReasonRevocation):
return "Grant resumed after a revocation", true
case resumption && strings.HasPrefix(raw, entitlements.ResumptionReasonExpiration):
case bySystem && strings.HasPrefix(raw, entitlements.ResumptionReasonExpiration):
return "Grant resumed after a grant expired", true
case bySystem && strings.HasPrefix(raw, entitlements.SubscriptionDeliveryRevocation):
return "Subscription delivers again after a revocation", true
case bySystem && strings.HasPrefix(raw, entitlements.SubscriptionDeliveryExpiration):
return "Subscription delivers again after a grant expired", true
case bySystem && strings.HasPrefix(raw, entitlements.SubscriptionDeliverySubscription):
return "Subscription delivers again after a subscription ended", true
case bySystem && strings.HasPrefix(raw, entitlements.SubscriptionDeliveryDropped):
return "Subscription delivers again after a subscription dropped its tier", true
case strings.HasPrefix(raw, "grant-expiration:") && strings.HasSuffix(raw, " default restoration"):
return "Default restored after a grant expired", true
case strings.HasPrefix(raw, "grant-expiration:"):
@@ -171,7 +171,7 @@ Shape:
- It reads, under the pool row the caller holds, the recorded products of the organization's subscriptions whose status is `active`, `trialing`, `past_due`, `unpaid` or `paused` (`ListRecordedSubscriptionProductsOfPool`: subscription id, status, product id and the summed quantity of its current items, `core.subscription_items.ended_at IS NULL`, for products with an entitlement set, ordered by subscription id then product id), skipping the ending subscription in `reconcileEnded`.
- Short-circuit: when the read returns no row other than the excepted subscription, the step makes no further read, including the ordering read `ListSubscriptionsSupersededByProvisions`; a subscription with no missing product makes no shape or displacement read. The step reads per live subscription, displaced or not, so every trace of an act that ended a provision gains the one read.
- The order: first the subscriptions the ending plan superseded, then the rest, each group in subscription id order. A subscription the ending plan superseded is one whose latest episode on a ladder of the pool ended in a `core.supersession_pairs` row whose `successor_provision_id` is one of `endedProvisionIDs`; one new read, `ListSubscriptionsSupersededByProvisions(pool_id, provision_ids, subscription_ids)`, finds them among the listed subscriptions, with the latest episode taken as `core.subscription_displacements` takes it (`00034_subscription_displacements.sql:41-49`) and the pairs matched as the view pairs them (`00033_supersession_pairs.sql:27-43`). The holder of a displacement cannot order them: `core.subscription_displacements` names only a live position as holder (`h.status IN ('active', 'suspended')`, `00034:69-73`), so after `core.end_conferral` no displacement names an ended provision, and before it, the holder is whatever holds the ladder now, not the plan that superseded the subscription. Take S1 displaced by S2 and S2 displaced by a grant G: when G ends, S2 delivers first and S1 then finds S2 holding the ladder, where subscription id order alone would give the ladder to S1 and leave S2, the subscription G displaced, billing undelivered. The limit: the read matches the pair's successor against `endedProvisionIDs` alone, so it misses S2 whenever the provision that displaced S2 is not one the act ended, either because the ending grant extends it (an extension, through Manage, supersedes its predecessor's provision with its own) or because a later grant, not an extension of it, replaced it. S2's pair then names the earlier provision, and S2 falls into the id-ordered rest. When S2 has the higher id, S1 then takes the ladder and S2 stays left alone, displaced by S1, billing without delivering (F62). Following the grant's lineage would cover the first shape only. Two subscriptions on one ladder is a shape only the Dashboard makes (F16), and a subscription displaced by another subscription keeps billing in such shapes as decided with the maintainer (D2), so the step accepts the limit.
- Per subscription, in that order: its live provisions (`GetLivePoolProvisionsBySubscriptionID`); the missing products; the shapes (one `ListConferralShapes` for every product); its displacements (`ListSubscriptionDisplacements`); `DisplacedProducts` over the missing ones; `ChooseTiers` over the rest; then, under a savepoint (`subscription_delivery`), `core.confer` sourced by the subscription for each kept missing product in product id order, actor `system`; and, when the status is `past_due`, `unpaid` or `paused`, `core.sync_source_status('suspended')` for the subscription, so its tier comes back suspended. A refused conferral rolls back to the savepoint, is logged (L4), and the ending proceeds; a lock wait, a deadlock or a cancelled context fails the act, as in resumption.
- Per subscription, in that order: its live provisions (`GetLivePoolProvisionsBySubscriptionID`); the missing products; the shapes (one `ListConferralShapes` for every product); its displacements (`ListSubscriptionDisplacements`); `DisplacedProducts` over the missing ones; `ChooseTiers` over every recorded product of the subscription that `DisplacedProducts` does not leave alone, the held ones included, as the reconcile's `convergeConferrals` runs it, so a subscription paying for two tiers of one ladder never has its lower tier conferred over its own higher one; then, under one savepoint for the subscription (`subscription_delivery`), `core.confer` sourced by the subscription for each kept missing product in product id order, actor `system`, and, when something was conferred and the status is `past_due`, `unpaid` or `paused`, `core.sync_source_status('suspended')` for the subscription, so its tier comes back suspended. A refused conferral rolls back the subscription's savepoint, so the subscription delivers nothing and stays paused while the floor's default delivers (F11), is logged (L4), and the ending proceeds to the next subscription; a lock wait, a deadlock or a cancelled context fails the act, as in resumption.
- It confers only; ending and re-bounding stay with the reconcile, which the act's request (D4) runs seconds later.
- A grant resumed just before it holds its rungs, so `DisplacedProducts` leaves the subscription's product alone there; the floor then sees the ladder held.
@@ -94,7 +94,7 @@ Every reconcile SHALL record the subscription's `pause_collection` behavior, its
### Requirement: A subscription delivers again in the act that ends the plan holding its ladder
A revocation, an expiry, the subscription reconcile's ended branch and its end of a surplus provision SHALL, when they ended at least one provision of the ending plan, confer in the same transaction each live subscription's recorded products that the displacement rule no longer leaves alone, after the resumption of marked grants and before the default floor, under the pool row the act holds and without any subscription's advisory lock. The step, `entitlements.DeliverDisplacedSubscriptionsTx`, SHALL read the recorded current items of the organization's subscriptions whose status is `active`, `trialing`, `past_due`, `unpaid` or `paused` (the ending subscription left out), keep each product with an entitlement set that has no live provision of the subscription, apply the displacement rule and the reconcile's choice of tiers to them, and confer each kept product sourced by the subscription, each subscription under its own savepoint, the subscriptions whose latest episode on a ladder of the pool was superseded by a provision the act ended first, and then the rest, each group in ascending subscription id order and each subscription's products in ascending `product_id` order. A subscription whose status is `past_due`, `unpaid` or `paused` SHALL be suspended through `core.sync_source_status` in the same savepoint, so its tier comes back suspended. A conferral the database refuses SHALL roll back to its savepoint, SHALL be logged, and SHALL NOT fail the act; a lock wait, a deadlock or a cancelled context SHALL fail it. The step SHALL only confer: ending and re-bounding stay with the subscription's own reconcile, which the act's outbox request runs. An act that ended nothing SHALL deliver nothing.
A revocation, an expiry, the subscription reconcile's ended branch and its end of a surplus provision SHALL, when they ended at least one provision of the ending plan, confer in the same transaction each live subscription's recorded products that the displacement rule no longer leaves alone, after the resumption of marked grants and before the default floor, under the pool row the act holds and without any subscription's advisory lock. The step, `entitlements.DeliverDisplacedSubscriptionsTx`, SHALL read the recorded current items of the organization's subscriptions whose status is `active`, `trialing`, `past_due`, `unpaid` or `paused` (the ending subscription left out), keep each product with an entitlement set that has no live provision of the subscription, apply the displacement rule to them, and the reconcile's choice of tiers to the subscription's recorded products the rule does not leave alone, and confer each kept product the subscription holds no provision of, sourced by the subscription, each subscription under its own savepoint, the subscriptions whose latest episode on a ladder of the pool was superseded by a provision the act ended first, and then the rest, each group in ascending subscription id order and each subscription's products in ascending `product_id` order. A subscription whose status is `past_due`, `unpaid` or `paused` SHALL be suspended through `core.sync_source_status` in the same savepoint, so its tier comes back suspended. A conferral the database refuses SHALL roll back the subscription's savepoint, so that subscription delivers nothing, SHALL be logged, and SHALL NOT fail the act; a lock wait, a deadlock or a cancelled context SHALL fail it. The step SHALL only confer: ending and re-bounding stay with the subscription's own reconcile, which the act's outbox request runs. An act that ended nothing SHALL deliver nothing.
#### Scenario: A revocation delivers the subscription in its own transaction
@@ -130,7 +130,7 @@ A revocation, an expiry, the subscription reconcile's ended branch and its end o
#### Scenario: A refused conferral leaves the ending in place
- **WHEN** the grant that displaced S is revoked and S's product was drafted after S bought it
- **THEN** the revocation commits, S's conferral is rolled back alone and logged, and the floor runs
- **THEN** the revocation commits, S's delivery is rolled back alone and logged, and the floor runs
#### Scenario: An ending that ended nothing delivers nothing
@@ -157,15 +157,15 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
## 7. The act that ends a holder delivers the subscription again (#270)
- [ ] 7.1 Query (design D7): `ListRecordedSubscriptionProductsOfPool` (billing or entitlements; subscription id, status, product id, summed quantity of current items for products with an entitlement set, for the pool's organization's subscriptions whose status is `active`, `trialing`, `past_due`, `unpaid` or `paused`, ordered by subscription id, product id); `ListSubscriptionsSupersededByProvisions(pool_id, provision_ids, subscription_ids)` (`internal/entitlements/queries/pool_provision_transitions.sql`; the listed subscriptions whose latest episode on a ladder of the pool, by `pool_provision_ladders` activation time and then id as `00034_subscription_displacements.sql:41-49` takes it, ended in a `core.supersession_pairs` row whose `successor_provision_id` is one of `provision_ids`, matched on the pool, the ladder, the ended provision and `effective_at = ended_at` as `00033_supersession_pairs.sql:27-43` pairs them; distinct subscription ids, ordered). `make sqlc-generate`.
- [ ] 7.2 `internal/entitlements/subscription_delivery.go` (new): `DeliveredProduct{SubscriptionID, ProductID, ProvisionID string}` and `DeliverDisplacedSubscriptionsTx(ctx, tx, poolID, exceptSubscriptionID, reason string, endedProvisionIDs []string) ([]DeliveredProduct, error)`, as design D7 states, the subscriptions the ending plan superseded first (one `ListSubscriptionsSupersededByProvisions` read over the listed subscriptions, with `endedProvisionIDs`) and then the rest, each group in subscription id order, with `deliverInSavepoint` following `resumeInSavepoint` (`grant_resumption.go:146-171`), savepoint `subscription_delivery`, and the conferral at the summed quantity, as `conferOrBound` confers (`reconcile.go:938-972`). L4 for a refused conferral. When the read returns no row other than the excepted subscription, the step makes no further read, including the ordering read `ListSubscriptionsSupersededByProvisions`, and a subscription with no missing product makes no shape or displacement read (design D7). Reason constants `SubscriptionDeliveryRevocation = "subscription-delivery:revocation:"`, `SubscriptionDeliveryExpiration = "subscription-delivery:expiration:"`, `SubscriptionDeliverySubscription = "subscription-delivery:subscription:"`.
- [ ] 7.3 `RevokeGrantTx` and `ExpireGrantTx` call it after `ResumeReplacedGrantsTx` and before `ReapplyDefaultsIfVacant`, only when the act ended a provision, with the reason, the grant id and the ids of the provisions it ended; `reconcileEnded` calls it when it ended one, with the ending subscription excepted, the provisions `core.end_conferral` ended and the reason `subscription-delivery:subscription:<subscription id>`; the active branch calls it after `endSurplusConferrals` when that ended one, with the subscription excepted, the provisions `endSurplusConferrals` ended, which it returns (3.3), and the reason `subscription-delivery:dropped:<subscription id>` (the subscription is still live; only a Dashboard edit reaches it). `docs/database-locks.md`, "Grant acts" and "Subscription reconcile", gain the step as the design's Locks section states.
- [ ] 7.4 `internal/server/operator_tier_changes.go`, `humanizeTransitionReason`: the four prefixes, for actor `system`, read T1, T2, T3 and T4 (`subscription-delivery:dropped:` reads "Subscription delivers again after a subscription dropped its tier").
- [ ] 7.5 Tests (`internal/entitlements/subscription_delivery_db_test.go`):
- `TestDeliverDisplaced_ARevocationDeliversTheSubscription`: the revocation's transaction confers Plus sourced by S, and no default is conferred. Failure texts: `after the revocation, S holds %v on the ladder, want Plus`; `the floor conferred the default over a subscription it should have delivered`.
- [x] 7.1 Query (design D7): `ListRecordedSubscriptionProductsOfPool` (billing or entitlements; subscription id, status, product id, summed quantity of current items for products with an entitlement set, for the pool's organization's subscriptions whose status is `active`, `trialing`, `past_due`, `unpaid` or `paused`, ordered by subscription id, product id); `ListSubscriptionsSupersededByProvisions(pool_id, provision_ids, subscription_ids)` (`internal/entitlements/queries/pool_provision_transitions.sql`; the listed subscriptions whose latest episode on a ladder of the pool, by `pool_provision_ladders` activation time and then id as `00034_subscription_displacements.sql:41-49` takes it, ended in a `core.supersession_pairs` row whose `successor_provision_id` is one of `provision_ids`, matched on the pool, the ladder, the ended provision and `effective_at = ended_at` as `00033_supersession_pairs.sql:27-43` pairs them; distinct subscription ids, ordered). `make sqlc-generate`.
- [x] 7.2 `internal/entitlements/subscription_delivery.go` (new): `DeliveredProduct{SubscriptionID, ProductID, ProvisionID string}` and `DeliverDisplacedSubscriptionsTx(ctx, tx, poolID, exceptSubscriptionID, reason string, endedProvisionIDs []string) ([]DeliveredProduct, error)`, as design D7 states, the subscriptions the ending plan superseded first (one `ListSubscriptionsSupersededByProvisions` read over the listed subscriptions, with `endedProvisionIDs`) and then the rest, each group in subscription id order, with `deliverInSavepoint` taking one savepoint `subscription_delivery` per subscription, around all its conferrals and, when something was conferred and the status is `past_due`, `unpaid` or `paused`, its suspension, and handling errors as `resumeInSavepoint` does (`grant_resumption.go:146-171`), and each conferral at the summed quantity, as `conferOrBound` confers (`reconcile.go:938-972`). L4 for a refused conferral, which rolls back the whole subscription's savepoint. When the read returns no row other than the excepted subscription, the step makes no further read, including the ordering read `ListSubscriptionsSupersededByProvisions`, and a subscription with no missing product makes no shape or displacement read (design D7). Reason constants `SubscriptionDeliveryRevocation = "subscription-delivery:revocation:"`, `SubscriptionDeliveryExpiration = "subscription-delivery:expiration:"`, `SubscriptionDeliverySubscription = "subscription-delivery:subscription:"`, `SubscriptionDeliveryDropped = "subscription-delivery:dropped:"`.
- [x] 7.3 `RevokeGrantTx` and `ExpireGrantTx` call it after `ResumeReplacedGrantsTx` and before `ReapplyDefaultsIfVacant`, only when the act ended a provision, with the reason, the grant id and the ids of the provisions it ended; `reconcileEnded` calls it when it ended one, with the ending subscription excepted, the provisions `core.end_conferral` ended and the reason `subscription-delivery:subscription:<subscription id>`; the active branch calls it after `endSurplusConferrals` when that ended one, with the subscription excepted, the provisions `endSurplusConferrals` ended, which it returns (3.3), and the reason `subscription-delivery:dropped:<subscription id>` (the subscription is still live; only a Dashboard edit reaches it). `docs/database-locks.md`, "Grant acts" and "Subscription reconcile", gain the step as the design's Locks section states.
- [x] 7.4 `internal/server/operator_tier_changes.go`, `humanizeTransitionReason`: the four prefixes, for actor `system`, read T1, T2, T3 and T4 (`subscription-delivery:dropped:` reads "Subscription delivers again after a subscription dropped its tier").
- [x] 7.5 Tests (`internal/entitlements/subscription_delivery_db_test.go`):
- `TestDeliverDisplaced_ARevocationDeliversTheSubscription`: S is `trialing` and also records an item whose product has no entitlement set and is on no ladder; the revocation's transaction confers Plus sourced by S, active, and no default is conferred. Failure texts: `after the revocation, S holds %v on the ladder, want Plus`; `the floor conferred the default over a subscription it should have delivered`; `the trialing S's position status = %q, want active`.
- `TestDeliverDisplaced_AnExpiryDeliversTheSubscription`. Failure text: `after the expiry, S holds %v, want Plus`.
- `TestDeliverDisplaced_TheEndOfTheHoldingSubscription` (`internal/fulfillment`): S2's ended reconcile confers S1's product. Failure text: `after S2's end, S1 holds %v, want its product`.
- `TestDeliverDisplaced_APastDueSubscriptionComesBackSuspended`. Failure text: `S's position status = %q, want suspended`.
- `TestDeliverDisplaced_APastDueSubscriptionComesBackSuspended`, rows `past_due`, `unpaid` and `paused`, run through one named function. Failure text: `%s: S's position status = %q, want suspended`.
- `TestDeliverDisplaced_AResumableGrantGoesFirst`. Failure text: `with G0 resumable, S was delivered: %v`.
- `TestDeliverDisplaced_ARefusedConferralIsRolledBackAlone`: S's product drafted; the revocation commits, S holds nothing, the floor ran, L4 logged with its four attributes. Failure text: `the refused delivery failed the revocation: %v`.
- `TestDeliverDisplaced_AnActThatEndedNothingDeliversNothing`. Failure text: `a replayed revocation delivered %v`.
@@ -173,10 +173,19 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
- `TestDeliverDisplaced_TheSubscriptionTheEndingHolderDisplacedGoesFirst`: S1, the lower id, is displaced by S2, and S2 is displaced by grant G; G is revoked. The ladder holds S2's product and S1 is left alone. Failure text: `after G ended, the ladder holds %v, want S2's product`.
- `TestDeliverDisplaced_WaitsForThePoolRow`: a transaction holds the pool row, and the revocation waits (`dbtest.AwaitLockWait`); a second transaction holds S's subscription advisory lock, with `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, the key the reconcile takes as its trace shows it, and leaves the pool row free; once the first transaction releases the row, the revocation must commit. A revocation that took S's lock would wait on it, and its transaction's `lock_timeout` (set by `BeginMaterializing`, `internal/entitlements/tx.go:81-89`) would fail it. Failure text: `the delivery took S's advisory lock`.
- `TestDeliverDisplaced_ADroppedItemDeliversTheWaitingSubscription` (`internal/fulfillment`): S1 holds a ladder and stays active, S2 waits on it, S1's item for that tier is dropped and its reconcile ends the surplus provision; S2's conferral carries the reason `subscription-delivery:dropped:<S1>`. Failure text: `after S1 dropped the item, S2's delivery reason = %q, want the dropped prefix`.
- `TestHumanizeTransitionReason` rows for the four prefixes. Failure text unchanged.
- [ ] 7.6 Trace goldens, rewritten with `-update` and read: the three reconcile traces `reconcile_trace/canceled_to_default.golden`, `canceled_resumes_grant.golden` and `addon_removed.golden`, and the four sweep traces `sweep_trace/already_ended.golden`, `already_canceled.golden`, `due_cancellation.golden` and `lost_claim.golden`, whose acts all ended a provision, each gain the one statement `ListRecordedSubscriptionProductsOfPool` after the resumption's statements (after `ListResumableGrantsHeldBy` in `canceled_to_default` and the four sweep traces, after `RELEASE SAVEPOINT grant_resumption` in `canceled_resumes_grant`), or, in `addon_removed`, the active branch's surplus end, which resumes nothing, directly after `core.end_conferral` and before section 4's `ListLiveSubscriptionsOfPool`: the step reads per live subscription, displaced or not, and its short-circuit (7.2) makes no further read when the fixture holds no other subscription. No server golden changes: no fixture under `internal/server/testdata/` revokes or expires a grant in an organization with a subscription, so no T1 to T4 row appears. Any case whose rows change is a stop.
- [ ] 7.7 Docs: `docs/stripe.md`'s grant bullet says the tier is delivered in the act that ends the grant; `docs/models/plan-ladders-transitions.md` invariant 20 says the same; `docs/plan-management.md:164` ("the subscription delivers again at its next reconcile after the grant ends") is rewritten, because it is false after this section. `test/seed/member-console-demo/README.md:73-74` is rewritten, because the ledger demo's revocation and expiry now deliver Carlos's canceling subscription (7.9): line 73 (the **Grants** row) says that Carlos Member's organization carries only the default grant its pool was floored with, since no default is restored on it; line 74 (Carlos Member's Tier changes) reads "Started (Subscription delivers again after a grant expired)" and "Started (Subscription delivers again after a revocation)" where it reads "Started (Default restored after a grant expired)" and "Started (Default restored after a revocation)". The README then agrees with the change's `member-console-demo-seed` delta ("The seed plays a ledger demo on Carlos's organization"), which states the same Tier changes and an Active tab that shows its empty state.
- [ ] 7.8 Breaks:
- `TestDeliverDisplaced_ARefusedConferralRollsBackTheWholeSubscription`: S records Plus on Wiki and Forum Plus on Forum, conferred and then displaced on both ladders by one grant of Pro; S′ records a product on a third ladder nothing holds; Forum Plus is drafted and the grant revoked. S delivers nothing, and S′ is delivered (design D7, F11). Failure texts: `S holds %v after one of its conferrals was refused, want nothing`; `S′ holds %v, want its product: a refusal rolled back another subscription`.
- `TestDeliverDisplaced_AHeldHigherTierIsNotDowngraded`: S records Plus and Pro of one ladder and holds Pro, as the reconcile leaves a subscription paying for two tiers; a grant on another ladder of the pool is revoked. Failure text: `the delivery conferred S's lower tier over its own higher one: S holds %v`.
- `TestDeliverDisplaced_ACanceledSubscriptionIsNotDelivered`: a canceled subscription with a recorded item of a missing product stays undelivered. Failure text: `a canceled subscription was delivered: %v`.
- `TestHumanizeTransitionReason` rows for the four prefixes, and the raw-render subtest a row per prefix. Failure text unchanged.
- `internal/fulfillment/displaced_subscription_test.go`, where a revocation now delivers the subscription itself, so three tests of #212 no longer reach the state they test; each is rebuilt on a state the console reaches, and one test is added:
- `TestReconcile_ARevocationDeliversTheSubscriptionAndItsReconcileWritesNothing` (new): S's second-tier item replaced by the fixture's tier through a new Stripe item id, a grant of a third tier issued over S and revoked; the revocation delivers, the floor confers nothing, and the reconcile the revocation asked for writes no row and makes no Stripe call beyond the subscription's read. Failure texts: `precondition: the grant does not deliver after it was issued over the subscription`; `after the revocation, the subscription's tier holds %d positions, want 1`; `the floor conferred the default over the subscription the revocation delivered (%d positions)`; `the reconcile after the revocation wrote %d rows, want none`; `the reconcile after the revocation made Stripe calls beyond the subscription's read: %v`.
- `TestReconcile_TheSubscriptionDeliversAgainOverTheDefault`, on F11's recovery: the subscription's product drafted with the kit's `Draft`, so the revocation's delivery is refused and the floor confers the default; the product published again with `Lifecycle`; the reconcile delivers over the default. New precondition text: `the default holds %d positions after the refused delivery, want 1`; final text unchanged.
- `TestReconcile_TheSubscriptionDeliversAgainOnAVacantLadder`, on `entitlements.RemoveTier` of the grant's tier, which leaves the delivery to the reconcile it requests (design D7). New precondition text: `precondition: the grant stopped delivering when its tier was removed`; the others and the final text unchanged.
- `TestReconcile_AGrantIssuedOverTheDefaultKeepsTheLadder`, on F11's recovery as above, then a later grant issued over the default. Texts unchanged.
- `twoOnOneLadder` gives S2 a second item on a ladder of its own, so a dropped tier item leaves S2 a live subscription with an item, as Stripe requires, and section 4's `TestSurplusEndRequestsTheOtherSubscriptions`, which uses it, drops only the tier's item; `TestDeliverDisplaced_ADroppedItemDeliversTheWaitingSubscription` builds its own pair the same way.
- [x] 7.6 Trace goldens, rewritten with `-update` and read: the three reconcile traces `reconcile_trace/canceled_to_default.golden`, `canceled_resumes_grant.golden` and `addon_removed.golden`, and the four sweep traces `sweep_trace/already_ended.golden`, `already_canceled.golden`, `due_cancellation.golden` and `lost_claim.golden`, whose acts all ended a provision, each gain the one statement `ListRecordedSubscriptionProductsOfPool` after the resumption's statements (after `ListResumableGrantsHeldBy` in `canceled_to_default` and the four sweep traces, after `RELEASE SAVEPOINT grant_resumption` in `canceled_resumes_grant`), or, in `addon_removed`, the active branch's surplus end, which resumes nothing, directly after `core.end_conferral` and before section 4's `ListLiveSubscriptionsOfPool`: the step reads per live subscription, displaced or not, and its short-circuit (7.2) makes no further read when the fixture holds no other subscription. No server golden changes: no fixture under `internal/server/testdata/` revokes or expires a grant in an organization with a subscription, so no T1 to T4 row appears. Any case whose rows change is a stop.
- [x] 7.7 Docs: `docs/stripe.md`'s grant bullet says the tier is delivered in the act that ends the grant; `docs/models/plan-ladders-transitions.md` invariant 20 says the same; `docs/plan-management.md:164` ("the subscription delivers again at its next reconcile after the grant ends") is rewritten, because it is false after this section. `test/seed/member-console-demo/README.md:73-74` is rewritten, because the ledger demo's revocation and expiry now deliver Carlos's canceling subscription (7.9): line 73 (the **Grants** row) says that Carlos Member's organization carries only the default grant its pool was floored with, since no default is restored on it; line 74 (Carlos Member's Tier changes) reads "Started (Subscription delivers again after a grant expired)" and "Started (Subscription delivers again after a revocation)" where it reads "Started (Default restored after a grant expired)" and "Started (Default restored after a revocation)". The README then agrees with the change's `member-console-demo-seed` delta ("The seed plays a ledger demo on Carlos's organization"), which states the same Tier changes and an Active tab that shows its empty state.
- [x] 7.8 Breaks:
- the step after the floor (the call moved below `ReapplyDefaultsIfVacant` in `RevokeGrantTx`): caught by `TestDeliverDisplaced_ARevocationDeliversTheSubscription`, `want_new` `the floor conferred the default over a subscription`;
- the step before the resumption: caught by `TestDeliverDisplaced_AResumableGrantGoesFirst`, `want_new` `with G0 resumable, S was delivered`;
- no savepoint (the conferral's error returned): caught by `TestDeliverDisplaced_ARefusedConferralIsRolledBackAlone`, `want_new` `the refused delivery failed the revocation`;
@@ -189,8 +198,16 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
- the label of T1 changed: caught by `TestHumanizeTransitionReason`, `want_new` `humanizeTransitionReason("subscription-delivery:revocation:`;
- the active branch writing the ended prefix `subscription-delivery:subscription:` instead of `subscription-delivery:dropped:`: caught by `TestDeliverDisplaced_ADroppedItemDeliversTheWaitingSubscription`, `want_new` `after S1 dropped the item, S2's delivery reason =`;
- the T4 label mapped to T3's string: caught by `TestHumanizeTransitionReason`, `want_new` `humanizeTransitionReason("subscription-delivery:dropped:`;
- an advisory lock on S taken before the conferral (`pg_advisory_xact_lock(hashtext(<S>)::bigint)` added to the delivery step ahead of `core.confer`): caught by `TestDeliverDisplaced_WaitsForThePoolRow`, `want_new` `the delivery took S's advisory lock`.
- [ ] 7.9 `cd test && ./reset-app-db.sh --rebuild`, then `make screens`. The rebuild is needed because the demo snapshot's fingerprint covers the migrations, `internal/demoseed/*.go` and the clock pin (`test/reset-app-db.sh:120`), not `internal/entitlements`, and this section changes none of the three, so a plain reset would restore the snapshot section 4 built. The demo changes: Carlos's canceling subscription records `demo-plan`, a tier of the Demo Ladder, which the revoked grant and the expired grant are on, and holds no provision, so the revocation's delivery step confers `demo-plan` sourced by the subscription instead of restoring the default, and the expiry confers it again. Carlos Member's Tier changes then read T1 and T2 where they read "Default restored after a revocation" and "Default restored after a grant expired", the two restored default grants are not issued, and the subscription holds the ladder. The captures that change, each read and named in the commit: `operator-organization-detail` (it prefers Carlos Member, `test/e2e/screens/manifest.go:60-61`), in each state the utility captures, at both widths; `operator-grants`, at both widths, which loses the two restored default grants; and `operator-overview`'s recent activity, at both widths. Any other change is a stop. The demo seed's goldens under `internal/demoseed/testdata/` do not change: the canceling and grant steps' statement goldens run no ledger act, and `seed/twice.txt` records the steps' counts and the rows a second seed changes, which stay the same. Hand captures at 1280 desktop width and 390 mobile width (emulated, `innerWidth` checked) of the Tier changes page with a row for each of T1 to T3, on the test stack with the kit's states loaded, are attached to the commit's review.
- an advisory lock on S taken before the conferral (`pg_advisory_xact_lock(hashtext(<S>)::bigint)` added to the delivery step ahead of `core.confer`): caught by `TestDeliverDisplaced_WaitsForThePoolRow`, `want_new` `the delivery took S's advisory lock`;
- a savepoint per conferral instead of per subscription: caught by `TestDeliverDisplaced_ARefusedConferralRollsBackTheWholeSubscription`, `want_new` `after one of its conferrals was refused`;
- `ChooseTiers` run over the missing products alone: caught by `TestDeliverDisplaced_AHeldHigherTierIsNotDowngraded`, `want_new` `the delivery conferred S's lower tier over its own higher one`;
- `unpaid`, and `paused`, removed from the suspended statuses: caught by `TestDeliverDisplaced_APastDueSubscriptionComesBackSuspended`, `want_new` `unpaid: S's position status = "active"` and `paused: S's position status = "active"`;
- `trialing`, `unpaid` and `paused` each removed from the read's statuses, and `canceled` added: caught by `TestDeliverDisplaced_ARevocationDeliversTheSubscription` (`after the revocation, S holds`), `TestDeliverDisplaced_APastDueSubscriptionComesBackSuspended` (`unpaid: S's position status = ""`, `paused: S's position status = ""`) and `TestDeliverDisplaced_ACanceledSubscriptionIsNotDelivered` (`a canceled subscription was delivered`);
- the read's entitlement-set filter removed: caught by `TestDeliverDisplaced_ARevocationDeliversTheSubscription`, `want_new` `after the revocation, S holds`;
- the read's ended-item filter removed: caught by `TestReconcile_ARevocationDeliversTheSubscriptionAndItsReconcileWritesNothing`, `want_new` `the reconcile after the revocation wrote`;
- the revocation's delivery call deleted: caught by the same, `want_new` `after the revocation, the subscription's tier holds 0`;
- the breaks #212 listed for the three rebuilt tests, which edit `core.subscription_displacements` in migration 00034 and so run as command records after a reset of the test databases: `AND NOT h.default_sourced` removed (`the subscription did not deliver again over the default`), an `ended` position counted as a holder (`the subscription did not deliver again on the vacant ladder`), and the holder required to be the displacement's own successor (`the grant issued over the default stopped delivering`).
- [x] 7.9 `cd test && ./reset-app-db.sh --rebuild`, then `make screens`. The rebuild is needed because the demo snapshot's fingerprint covers the migrations, `internal/demoseed/*.go` and the clock pin (`test/reset-app-db.sh:120`), not `internal/entitlements`, and this section changes none of the three, so a plain reset would restore the snapshot section 4 built. The demo changes: Carlos's canceling subscription records `demo-plan`, a tier of the Demo Ladder, which the revoked grant and the expired grant are on, and holds no provision, so the revocation's delivery step confers `demo-plan` sourced by the subscription instead of restoring the default, and the expiry confers it again. Carlos Member's Tier changes then read T1 and T2 where they read "Default restored after a revocation" and "Default restored after a grant expired", the two restored default grants are not issued, and the subscription holds the ladder. The captures that change, each read and named in the commit: `operator-organization-detail` (it prefers Carlos Member, `test/e2e/screens/manifest.go:60-61`), in each state the utility captures, at both widths; `operator-grants`, at both widths, which loses the two restored default grants; and `operator-overview`'s recent activity, at both widths. Any other change is a stop. Carlos Member's Active grants tab is then empty, so the organization screen no longer shows the grants ledger, which is the Manage grant form, and `TestEveryRegisteredFormWasCaptured` fails; `test/e2e/screens/manifest.go` therefore gains the screen `operator-organization-detail-grant`, the same route preferring Bob Builder's organization, whose floor default delivers, and the comment on `operator.enrollment.grant.manage` names it. Its captures are new in each state at both widths. The demo seed's goldens under `internal/demoseed/testdata/` do not change: the canceling and grant steps' statement goldens run no ledger act, and `seed/twice.txt` records the steps' counts and the rows a second seed changes, which stay the same. Hand captures at 1280 desktop width and 390 mobile width (emulated, `innerWidth` checked) of the Tier changes page with a row for each of T1 to T4, on the test stack with the kit's states loaded, are attached to the commit's review.
## 8. A subscription that pays at or above the grant takes the ladder
+7 -2
View File
@@ -59,6 +59,11 @@ var Manifest = []Screen{
{Name: "operator-organizations", Route: "/operator/organizations", Path: "/operator/organizations", Surface: "operator", Role: "operator"},
{Name: "operator-organization-detail", Route: "/operator/organizations/{orgID}", Surface: "operator", Role: "operator",
Instance: &Instance{ListPath: "/operator/organizations", LinkPrefix: "/operator/organizations/", Prefer: "Carlos Member"}},
// Carlos Member's subscription holds his ladder, so his Active grants
// tab is empty; this organization's floor default delivers, so its page
// shows the grants ledger, which is the Manage grant form.
{Name: "operator-organization-detail-grant", Route: "/operator/organizations/{orgID}", Surface: "operator", Role: "operator",
Instance: &Instance{ListPath: "/operator/organizations", LinkPrefix: "/operator/organizations/", Prefer: "Bob Builder"}},
{Name: "operator-grants", Route: "/operator/grants", Path: "/operator/grants", Surface: "operator", Role: "operator"},
{Name: "operator-billing-accounts", Route: "/operator/billing/accounts", Path: "/operator/billing/accounts", Surface: "operator", Role: "operator"},
{Name: "operator-billing-subscriptions", Route: "/operator/billing/subscriptions", Path: "/operator/billing/subscriptions", Surface: "operator", Role: "operator"},
@@ -198,8 +203,8 @@ var WalkthroughVerifiedForms = map[string]string{
"member.plan.move": "no walkthrough: the demo database never seeds a member onto a paid plan tier, so no rod step can reach the \"Switch\" control that opens this preview",
// operator.enrollment.grant.manage is not here: the grants ledger is the
// form (grant-resumption D15), so the organization detail screen shows
// it whenever the organization holds a grant. Its editing row, which the
// form (grant-resumption D15), so the operator-organization-detail-grant
// screen shows it, its organization holding a delivering grant. Its editing row, which the
// capture cannot open, is driven by
// test/e2e/operator-walkthroughs/grant_manage_test.go.
}
+2 -2
View File
@@ -70,8 +70,8 @@ attach the grant.
- **Products**: 6 rows starting with `demo-`.
- **Plan Ladders**: 1 row "Demo Ladder" with 3 tiers.
- **Entitlement Sets**: 2 rows, `demo-baseline` and `demo-addons`, each with 2 rules.
- **Grants**: 6 rows the seeder owns after alice has logged in once and seed-demo is re-run: the baseline on her personal org and the five of the ledger demo on Carlos Member's. Each personal org also carries the org-type default grants its pool was floored and restored with.
- **Carlos Member's organization**: Tier changes reads, newest first, Started (Default restored after a grant expired), Ended (Grant expired), Upgraded, Started (Default restored after a revocation), Ended (Revoked by an operator), Downgraded, Extended, Upgraded, Upgraded, Started (Seeded default).
- **Grants**: 6 rows the seeder owns after alice has logged in once and seed-demo is re-run: the baseline on her personal org and the five of the ledger demo on Carlos Member's. Each personal org also carries the one org-type default grant its pool was floored with; the ledger demo restores none on Carlos Member's, because its revocation and expiry deliver his canceling subscription.
- **Carlos Member's organization**: Tier changes reads, newest first, Started (Subscription delivers again after a grant expired), Ended (Grant expired), Upgraded, Started (Subscription delivers again after a revocation), Ended (Revoked by an operator), Downgraded, Extended, Upgraded, Upgraded, Started (Seeded default).
Or via `psql`: