Record how the reconcile takes a plan back from a grant today
#212: a grant that holds a ladder displaces the subscription paying for
it, and the subscription's next reconcile confers its tier again and
ends the grant. These goldens record that answer before the change that
fixes it, so its sections flip them on purpose:
- reconcile_trace/grant_over_suspended_recovery: a grant issued while the
subscription is past due; the recovery's item pass confers the plan
and ends the grant (#212 itself).
- reconcile_trace/second_subscription_over_first: a second subscription
of the same customer on the same ladder ends the first's plan (the
two-subscription form).
- reconcile_trace/two_tiers_one_ladder: one subscription paying for two
tiers of one ladder confers whichever its live read leaves (the
two-tier form).
- scheduled_change_trace/downgrade_lands_while_displaced: a scheduled
downgrade landing while a grant holds the ladder.
TestReconcile_SupersededWhileSuspendedNotResurrected now recovers
through ReconcileSubscription and records that the subscription holds
one live provision afterwards; section 4 flips it to none. The trace
kit gains a second subscription (<stripe-subscription-b>) and fails a
test whose setup reconcile fails, so a later -update cannot write a
broken setup in as the answer.
The OpenSpec change displaced-subscription-waits is added with this
commit; it depends on reconcile-takes-pool-lock (16cc1de6).
Breaks (scripts/mutation-check, 6 of 6 CAUGHT):
- live read includes ended: grant_over_suspended_recovery
- live read includes ended: second_subscription_over_first
- live read includes ended: two_tiers_one_ladder
- live read includes ended: recovery test
- live read includes ended: recovery test live count
- applyLandedDowngrades applies nothing
The suite on reset databases passes, 4620 tests, 15 skipped; after the
review's test-only fixes, ./internal/fulfillment passes twice in a row.
make lint passes; openspec validate --strict passes.
This commit is contained in:
@@ -215,9 +215,10 @@ func TestReconcile_SuspendedIncumbentSuperseded(t *testing.T) {
|
||||
}
|
||||
|
||||
// 6.4e Superseded-while-suspended does NOT resurrect: after the grant
|
||||
// supersession of 6.4d, a resume (sync_source_status 'active') for the
|
||||
// subscription source finds nothing non-ended and returns the empty set — the
|
||||
// ended provision stays ended and the grant's position stands.
|
||||
// supersession of 6.4d, the recovery of the subscription to active leaves the
|
||||
// ended provision ended. The test drives the recovery through the reconcile,
|
||||
// so the item pass runs; its last assertion pins #212 (the pass confers the
|
||||
// subscription's tier over the grant) until the rule of displacement flips it.
|
||||
func TestReconcile_SupersededWhileSuspendedNotResurrected(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
fx := seedFixture(t, database)
|
||||
@@ -252,24 +253,21 @@ func TestReconcile_SupersededWhileSuspendedNotResurrected(t *testing.T) {
|
||||
t.Fatalf("confer grant over suspended incumbent: %v", err)
|
||||
}
|
||||
|
||||
// Resume the source directly at the wrapper level: never resurrects.
|
||||
entQ := entitlements.New(database)
|
||||
resumed, err := entQ.SyncSourceStatus(ctx, entitlements.SyncSourceStatusParams{
|
||||
Status: "active",
|
||||
SubscriptionID: uuid.NullUUID{UUID: uuid.MustParse(core), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sync_source_status resume: %v", err)
|
||||
// The recovery through the reconcile: never resurrects the ended provision.
|
||||
mock.Sub = fx.activeSub(fx.planItem())
|
||||
if err := fulfillment.ReconcileSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:recovered"); err != nil {
|
||||
t.Fatalf("reconcile recovered: %v", err)
|
||||
}
|
||||
if len(resumed) != 0 {
|
||||
t.Errorf("resume touched %v, want empty set (ended stays ended)", resumed)
|
||||
if n := scalarInt(t, database,
|
||||
`SELECT count(*) FROM core.pool_provisions WHERE subscription_id = $1 AND status <> 'ended'`, core); n != 1 {
|
||||
t.Errorf("the subscription holds %d live provisions after the recovery, want 1", n)
|
||||
}
|
||||
if s := scalarStr(t, database,
|
||||
`SELECT status FROM core.pool_provisions WHERE provision_id = $1`, incumbent); s != "ended" {
|
||||
t.Errorf("incumbent status = %q, want ended (no resurrection)", s)
|
||||
}
|
||||
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 1 {
|
||||
t.Errorf("grant tier attach = %d, want 1 (grant position stands)", n)
|
||||
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 0 {
|
||||
t.Errorf("grant tier attach after the recovery = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,42 @@ var reconcileCases = []traceCase{
|
||||
e.start()
|
||||
return e.reconcile("stripe:customer.subscription.updated:evt_2")
|
||||
}},
|
||||
// #212: three ways a reconcile meets another tier on a ladder the
|
||||
// subscription pays for: a grant issued while it was past due, then its
|
||||
// recovery; a second subscription of the customer on the same ladder; and
|
||||
// two tiers of one ladder on the subscription itself.
|
||||
{"grant_over_suspended_recovery", func(e *traceEnv) string {
|
||||
_, plus, _ := onPlan(e, true)
|
||||
e.mock.Sub.Status = stripe.SubscriptionStatusPastDue
|
||||
if res := e.reconcile("test:past_due"); res != "nil" {
|
||||
e.t.Fatalf("past_due reconcile: %s", res)
|
||||
}
|
||||
e.w.Issue(e.org, plus.product, world.IssueOpts{})
|
||||
e.mock.Sub.Status = stripe.SubscriptionStatusActive
|
||||
e.start()
|
||||
return e.reconcile("stripe:customer.subscription.updated:evt_3")
|
||||
}},
|
||||
{"second_subscription_over_first", func(e *traceEnv) string {
|
||||
_, plus, _ := onPlan(e, true)
|
||||
first := e.mock.Sub
|
||||
second := e.subB(stripe.SubscriptionStatusActive, e.item("si_b", plus, 1))
|
||||
e.mock.Sub = second
|
||||
if res := e.reconcileID(second.ID, "test:second"); res != "nil" {
|
||||
e.t.Fatalf("second reconcile: %s", res)
|
||||
}
|
||||
e.mock.Sub = first
|
||||
e.start()
|
||||
return e.reconcile("stripe:customer.subscription.updated:evt_3")
|
||||
}},
|
||||
{"two_tiers_one_ladder", func(e *traceEnv) string {
|
||||
plan, plus, _ := onPlan(e, true)
|
||||
e.mock.Sub = e.sub(stripe.SubscriptionStatusActive, e.item("si_a", plan, 1), e.item("si_b", plus, 1))
|
||||
if res := e.reconcile("test:two-items"); res != "nil" {
|
||||
e.t.Fatalf("two-items reconcile: %s", res)
|
||||
}
|
||||
e.start()
|
||||
return e.reconcile("stripe:customer.subscription.updated:evt_3")
|
||||
}},
|
||||
// #212: a reconcile confers the subscription's tier again over the
|
||||
// operator's grant that replaced it. The fix flips this golden.
|
||||
{"grant_over_subscription", func(e *traceEnv) string {
|
||||
|
||||
@@ -79,6 +79,13 @@ var ruleCases = []traceCase{
|
||||
e.start()
|
||||
return e.reconcile("stripe:customer.subscription.updated:evt_2")
|
||||
}},
|
||||
{"downgrade_lands_while_displaced", func(e *traceEnv) string {
|
||||
plan, top := downgraded(e)
|
||||
e.w.Issue(e.org, top.product, world.IssueOpts{})
|
||||
e.onTierWith(plan)
|
||||
e.start()
|
||||
return e.reconcile("stripe:customer.subscription.updated:evt_2")
|
||||
}},
|
||||
{"schedule_released_outside", func(e *traceEnv) string {
|
||||
downgraded(e)
|
||||
e.mock.Sub.Schedule = nil
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
== result
|
||||
nil
|
||||
== trace
|
||||
GetSubscriptionMappingByStripeID
|
||||
stripe GET /v1/subscriptions/<stripe-subscription> expand[0]=items.data.price&expand[1]=schedule
|
||||
begin
|
||||
SET LOCAL lock_timeout = '5s'
|
||||
SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))
|
||||
SELECT pg_advisory_xact_lock(hashtext($1)::bigint)
|
||||
GetCustomerMappingByStripeCustomerID
|
||||
GetBillingAccountByID
|
||||
GetDefaultPoolByOrgID
|
||||
GetSubscriptionMappingByStripeID
|
||||
GetSubscriptionByID
|
||||
UpdateSubscriptionStatus
|
||||
CreateSubscriptionChange
|
||||
LockResourcePool
|
||||
SELECT core.sync_source_status($1, $2, $3, $4, $5)
|
||||
GetPriceMappingByStripeID
|
||||
GetPrice
|
||||
GetProductByID
|
||||
GetSubscriptionItemMappingByStripeID
|
||||
GetSubscriptionItemByID
|
||||
GetSubscriptionItemsBySubscriptionID
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
<materialize>
|
||||
ListActiveScheduledChangesBySubscription
|
||||
GetPriceMappingByStripeID
|
||||
commit
|
||||
== logs
|
||||
== rows
|
||||
-- subscriptions
|
||||
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription>", "sync_status" : "synced", "livemode" : false}
|
||||
-- subscription_changes
|
||||
{"from" : null, "to" : "active", "reason" : "test:setup"}
|
||||
{"from" : "active", "to" : "past_due", "reason" : "test:past_due"}
|
||||
{"from" : "past_due", "to" : "active", "reason" : "stripe:customer.subscription.updated:evt_3"}
|
||||
-- subscription_items
|
||||
{"stripe_id" : "si_a", "product" : "<product:Plan>", "price" : "<price:Plan>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
|
||||
-- scheduled_changes
|
||||
-- provisions
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "grant", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
-- ladder_positions
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "ended"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
|
||||
-- transitions
|
||||
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
{"type" : "end", "actor" : "operator", "reason" : "test fixture", "from_rank" : 0, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
{"type" : "upgrade", "actor" : "operator", "reason" : "test fixture", "from_rank" : 0, "to_rank" : 1, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
|
||||
{"type" : "end", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
|
||||
{"type" : "downgrade", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
-- numeric_entitlements
|
||||
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 1}
|
||||
-- boolean_entitlements
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
== result
|
||||
nil
|
||||
== trace
|
||||
GetSubscriptionMappingByStripeID
|
||||
stripe GET /v1/subscriptions/<stripe-subscription> expand[0]=items.data.price&expand[1]=schedule
|
||||
begin
|
||||
SET LOCAL lock_timeout = '5s'
|
||||
SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))
|
||||
SELECT pg_advisory_xact_lock(hashtext($1)::bigint)
|
||||
GetCustomerMappingByStripeCustomerID
|
||||
GetBillingAccountByID
|
||||
GetDefaultPoolByOrgID
|
||||
GetSubscriptionMappingByStripeID
|
||||
GetSubscriptionByID
|
||||
UpdateSubscriptionStatus
|
||||
LockResourcePool
|
||||
SELECT core.sync_source_status($1, $2, $3, $4, $5)
|
||||
GetPriceMappingByStripeID
|
||||
GetPrice
|
||||
GetProductByID
|
||||
GetSubscriptionItemMappingByStripeID
|
||||
GetSubscriptionItemByID
|
||||
GetSubscriptionItemsBySubscriptionID
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
<materialize>
|
||||
ListActiveScheduledChangesBySubscription
|
||||
GetPriceMappingByStripeID
|
||||
commit
|
||||
== logs
|
||||
== rows
|
||||
-- subscriptions
|
||||
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription-b>", "sync_status" : "synced", "livemode" : false}
|
||||
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription>", "sync_status" : "synced", "livemode" : false}
|
||||
-- subscription_changes
|
||||
{"from" : null, "to" : "active", "reason" : "test:setup"}
|
||||
{"from" : null, "to" : "active", "reason" : "test:second"}
|
||||
-- subscription_items
|
||||
{"stripe_id" : "si_a", "product" : "<product:Plan>", "price" : "<price:Plan>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
|
||||
{"stripe_id" : "si_b", "product" : "<product:Plus>", "price" : "<price:Plus>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
|
||||
-- scheduled_changes
|
||||
-- provisions
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
-- ladder_positions
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "ended"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
|
||||
-- transitions
|
||||
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
{"type" : "end", "actor" : "webhook", "reason" : "test:second", "from_rank" : 0, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
{"type" : "upgrade", "actor" : "webhook", "reason" : "test:second", "from_rank" : 0, "to_rank" : 1, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
|
||||
{"type" : "end", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
|
||||
{"type" : "downgrade", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
-- numeric_entitlements
|
||||
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 1}
|
||||
-- boolean_entitlements
|
||||
@@ -0,0 +1,63 @@
|
||||
== result
|
||||
nil
|
||||
== trace
|
||||
GetSubscriptionMappingByStripeID
|
||||
stripe GET /v1/subscriptions/<stripe-subscription> expand[0]=items.data.price&expand[1]=schedule
|
||||
begin
|
||||
SET LOCAL lock_timeout = '5s'
|
||||
SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))
|
||||
SELECT pg_advisory_xact_lock(hashtext($1)::bigint)
|
||||
GetCustomerMappingByStripeCustomerID
|
||||
GetBillingAccountByID
|
||||
GetDefaultPoolByOrgID
|
||||
GetSubscriptionMappingByStripeID
|
||||
GetSubscriptionByID
|
||||
UpdateSubscriptionStatus
|
||||
LockResourcePool
|
||||
SELECT core.sync_source_status($1, $2, $3, $4, $5)
|
||||
GetPriceMappingByStripeID
|
||||
GetPrice
|
||||
GetProductByID
|
||||
GetSubscriptionItemMappingByStripeID
|
||||
GetSubscriptionItemByID
|
||||
GetPriceMappingByStripeID
|
||||
GetPrice
|
||||
GetProductByID
|
||||
GetSubscriptionItemMappingByStripeID
|
||||
GetSubscriptionItemByID
|
||||
GetSubscriptionItemsBySubscriptionID
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
<materialize>
|
||||
ListActiveScheduledChangesBySubscription
|
||||
GetPriceMappingByStripeID
|
||||
GetPriceMappingByStripeID
|
||||
commit
|
||||
== logs
|
||||
== rows
|
||||
-- subscriptions
|
||||
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription>", "sync_status" : "synced", "livemode" : false}
|
||||
-- subscription_changes
|
||||
{"from" : null, "to" : "active", "reason" : "test:setup"}
|
||||
-- subscription_items
|
||||
{"stripe_id" : "si_a", "product" : "<product:Plan>", "price" : "<price:Plan>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
|
||||
{"stripe_id" : "si_b", "product" : "<product:Plus>", "price" : "<price:Plus>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
|
||||
-- scheduled_changes
|
||||
-- provisions
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
-- ladder_positions
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "ended"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
|
||||
-- transitions
|
||||
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
{"type" : "end", "actor" : "webhook", "reason" : "test:two-items", "from_rank" : 0, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
{"type" : "upgrade", "actor" : "webhook", "reason" : "test:two-items", "from_rank" : 0, "to_rank" : 1, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
|
||||
{"type" : "end", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
|
||||
{"type" : "downgrade", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_3", "from_rank" : 1, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
-- numeric_entitlements
|
||||
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 1}
|
||||
-- boolean_entitlements
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
== result
|
||||
nil
|
||||
== trace
|
||||
GetSubscriptionMappingByStripeID
|
||||
stripe GET /v1/subscriptions/<stripe-subscription> expand[0]=items.data.price&expand[1]=schedule
|
||||
begin
|
||||
SET LOCAL lock_timeout = '5s'
|
||||
SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))
|
||||
SELECT pg_advisory_xact_lock(hashtext($1)::bigint)
|
||||
GetCustomerMappingByStripeCustomerID
|
||||
GetBillingAccountByID
|
||||
GetDefaultPoolByOrgID
|
||||
GetSubscriptionMappingByStripeID
|
||||
GetSubscriptionByID
|
||||
UpdateSubscriptionStatus
|
||||
LockResourcePool
|
||||
SELECT core.sync_source_status($1, $2, $3, $4, $5)
|
||||
GetPriceMappingByStripeID
|
||||
GetPrice
|
||||
GetProductByID
|
||||
GetSubscriptionItemMappingByStripeID
|
||||
GetSubscriptionItemByID
|
||||
UpdateSubscriptionItemPriceProduct
|
||||
GetSubscriptionItemsBySubscriptionID
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
<materialize>
|
||||
ListActiveScheduledChangesBySubscription
|
||||
GetPriceMappingByStripeID
|
||||
MarkScheduledChangeApplied
|
||||
commit
|
||||
stripe POST /v1/subscription_schedules/sub_sched_mock/release
|
||||
probe: the subscription lock is free
|
||||
== logs
|
||||
== rows
|
||||
-- subscriptions
|
||||
{"status" : "active", "cancel_at_period_end" : false, "period" : "4102444800-4105123200", "canceled" : false, "ended" : false, "stripe_id" : "<stripe-subscription>", "sync_status" : "synced", "livemode" : false}
|
||||
-- subscription_changes
|
||||
{"from" : null, "to" : "active", "reason" : "test:setup"}
|
||||
-- subscription_items
|
||||
{"stripe_id" : "si_a", "product" : "<product:Plan>", "price" : "<price:Plan>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
|
||||
-- scheduled_changes
|
||||
{"type" : "plan_switch", "trigger" : "period_end", "target" : "<price:Plan>", "status" : "applied"}
|
||||
-- provisions
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
|
||||
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
{"product" : "<product:Top>", "set" : "<set:Top>", "source" : "grant", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
-- ladder_positions
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Top>", "status" : "ended"}
|
||||
-- transitions
|
||||
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 1, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
|
||||
{"type" : "end", "actor" : "operator", "reason" : "test fixture", "from_rank" : 1, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Plus>"}
|
||||
{"type" : "upgrade", "actor" : "operator", "reason" : "test fixture", "from_rank" : 1, "to_rank" : 2, "ladder" : "<ladder:Ladder>", "product" : "<product:Top>"}
|
||||
{"type" : "end", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_2", "from_rank" : 2, "to_rank" : null, "ladder" : "<ladder:Ladder>", "product" : "<product:Top>"}
|
||||
{"type" : "downgrade", "actor" : "webhook", "reason" : "stripe:customer.subscription.updated:evt_2", "from_rank" : 2, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
-- numeric_entitlements
|
||||
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 1}
|
||||
-- boolean_entitlements
|
||||
@@ -158,8 +158,22 @@ func (e *traceEnv) ladder(tiers ...tier) world.Ladder {
|
||||
// sub is the subscription Stripe answers with: status, the canned period,
|
||||
// the fixture's customer and items.
|
||||
func (e *traceEnv) sub(status stripe.SubscriptionStatus, items ...*stripe.SubscriptionItem) *stripe.Subscription {
|
||||
return e.subOf(e.subID, status, items...)
|
||||
}
|
||||
|
||||
// subB is a second Stripe subscription of the fixture's customer, named
|
||||
// <stripe-subscription-b> in a trace.
|
||||
func (e *traceEnv) subB(status stripe.SubscriptionStatus, items ...*stripe.SubscriptionItem) *stripe.Subscription {
|
||||
id := "sub_trace_b_" + e.w.Sfx
|
||||
e.norm.Add(id, "<stripe-subscription-b>")
|
||||
return e.subOf(id, status, items...)
|
||||
}
|
||||
|
||||
// subOf is the subscription whose Stripe id is id, as Stripe answers with it:
|
||||
// status, the canned period, the fixture's customer and items.
|
||||
func (e *traceEnv) subOf(id string, status stripe.SubscriptionStatus, items ...*stripe.SubscriptionItem) *stripe.Subscription {
|
||||
return &stripe.Subscription{
|
||||
ID: e.subID, Status: status, Livemode: false,
|
||||
ID: id, Status: status, Livemode: false,
|
||||
Customer: &stripe.Customer{ID: "cus_" + e.account.ID},
|
||||
CurrentPeriodStart: tracePeriodStart, CurrentPeriodEnd: tracePeriodEnd,
|
||||
Items: &stripe.SubscriptionItemList{Data: items},
|
||||
@@ -234,7 +248,12 @@ func (e *traceEnv) start() {
|
||||
|
||||
// reconcile runs the reconcile of the fixture's subscription with reason.
|
||||
func (e *traceEnv) reconcile(reason string) string {
|
||||
return result(fulfillment.ReconcileSubscription(e.ctx, e.db, e.logger, stripetest.KeyMode(), e.subID, reason))
|
||||
return e.reconcileID(e.subID, reason)
|
||||
}
|
||||
|
||||
// reconcileID runs the reconcile of the Stripe subscription id with reason.
|
||||
func (e *traceEnv) reconcileID(id, reason string) string {
|
||||
return result(fulfillment.ReconcileSubscription(e.ctx, e.db, e.logger, stripetest.KeyMode(), id, reason))
|
||||
}
|
||||
|
||||
// sweep runs the scheduled-change sweep on the code's handle and prints the
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-24
|
||||
@@ -0,0 +1,358 @@
|
||||
## Context
|
||||
|
||||
The subscription reconcile, `ReconcileSubscription` (`internal/fulfillment/reconcile.go:47-137`), reads the subscription from Stripe before it opens a transaction (`:65`), opens a `Materializing` transaction whose first statement takes the shared rendezvous (`:70`; `internal/entitlements/tx.go:44-71`), takes the subscription's advisory lock (`:78-81`), resolves the billing account and the organization's default pool (`:87`), upserts the core subscription row (`:94`), and branches on `classifyStatus` (`:791-802`): `active` and `trialing` run `reconcileItems` and the scheduled-change ledger (`:110-117`); `past_due`, `unpaid` and `paused` run `reconcileSuspended` (`:119-122`); `canceled` and `incomplete_expired` run `reconcileEnded` (`:107-108`); `incomplete` records only.
|
||||
|
||||
`reconcileItems` (`:412-467`) resumes the subscription's provisions through `core.sync_source_status('active')` (`:433-439`), which resumes only non-ended provisions (`internal/db/migrations/00005_doc41_conferral_functions.sql:400-437`, "never resurrects"). `mirrorItems` (`:482-525`) builds `desired`, a map from product id to summed quantity (`:522`). `convergeConferrals` (`:651-681`) reads the subscription's live provisions once (`:660`, `GetLivePoolProvisionsBySubscriptionID`, `subscription_id = $1 AND status <> 'ended'`), ranges over the map `desired` (`:671`), and for each product with no live provision of this subscription calls `core.confer` sourced by the subscription (`conferOrBound`, `:711-721`). `endSurplusConferrals` (`:727-757`) re-reads the live provisions and ends each product no longer desired. The reconcile takes no pool row lock before `core.confer` takes it inside until `reconcile-takes-pool-lock` (#193) lands; that change comes first and takes the row in both branches after the subscription's lock and before `core.sync_source_status`, and this design describes the reconcile as it leaves it.
|
||||
|
||||
`core.confer` (latest definition `internal/db/migrations/00023_grant_resumption.sql:375-671`) supersedes every `active` or `suspended` episode on a ladder of the new product's shape, whatever its source (step 5, `:556-625`): each rung of the superseded provision gets an `end` row, uncontested rungs included (`:604-617`), the provision is set `ended` at the conferral's instant (`:620-622`), and the new provision's typed row on each contested ladder carries the incumbent's rank as `from_rank` (`:640-666`). The `end` row and the typed row share the pool, the ladder, `effective_at`, `actor_type` and `actor_id`. Step 4a (`:495-514`) is a `noop` for the same subscription and product only; a different product of the same subscription falls through to supersession. Step 4b's decree-duplication `noop` (`:547-553`) applies only when the new conferral is a grant, so a subscription conferring the product a grant holds supersedes it as a `transfer`. A superseded grant's row is never written: it keeps `status = 'active'`.
|
||||
|
||||
`core.grant_replacements` (`00023:44-68`) already pairs exactly those two rows, for an ended grant provision, and `core.grant_waits` and resumption read it; its tests are `internal/entitlements/grant_waits_test.go:69-150`. `core.superseded_ends` (`00025_superseded_transitions.sql:75-90`) pairs on pool, instant, actor and reason with no ladder, and reads `reason`, which the reader role withholds (`scripts/db-reader-role.sql:95`). `openspec/specs/grant-resumption/spec.md:38` forbids adding a column to the position tables or changing the conferral functions' supersession to record the relation. `pool_provision_ladders.default_sourced` (`00026_outgoing_default_rule.sql:23-26`) marks a position whose grant is a default grant.
|
||||
|
||||
The issue form warns that a grant would supersede a subscription's tier and says "the subscription keeps billing until it is changed in the billing view" (`internal/server/operator_enrollment_forms.go:132`); every `/operator/billing/*` route is a read (`internal/server/testdata/routes.golden:61-73`). The plan-enrollment requirement for that warning forbids changing the subscription as a side effect of the grant (`openspec/specs/plan-enrollment-administration/spec.md:364`). The suspension requirement asks the condition to be surfaced "via the operator-facing audit trail and an operator warning" (`openspec/specs/stripe-subscription-management/spec.md:355`, `:374`).
|
||||
|
||||
On the organization page the subscription is read once: the Billing card's Subscriptions row, built by `summarizeSubscriptions` (`internal/server/operator_enrollment.go:400-431`) from every subscription of the account (`loadOrgBillingSummary`, `:1567-1606`). For one subscription the row is that subscription's state badge and its period clause; for several it is a count, the worst state and a note counting those in it. The card names no product (`operator_enrollment.go:335-339`); the per-subscription list it links to, `/operator/billing/subscriptions` (`internal/server/operator_billing.go:428-590`), lists subscriptions across organizations and reads no pool.
|
||||
|
||||
What the operator and the member see today before the next reconcile, the state this change keeps: the Pools panel lists the grant's delivery only; the Billing card reads the subscription as active with its renewal; the grants ledger reads the grant live; Tier changes reads the operator's upgrade or downgrade. The member's Products page shows the grant's tier as current with no price line and no Cancel plan, and invoices keep arriving. After the next reconcile, today: the subscription's tier is back, the grant reads inactive (unmarked) or on hold waiting on the subscription (marked).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- A grant that took a subscription's ladder keeps it until it ends, through every reconcile path, recovery from past due included.
|
||||
- The subscription keeps billing, delivers nothing there while another plan holds the ladder, and delivers again without anyone's act once no other plan does.
|
||||
- A subscription that pays for two tiers of one ladder, and two subscriptions on one ladder, stop alternating.
|
||||
- The operator sees, on the organization page, which ladder a subscription delivers nothing on and what kind of plan holds it.
|
||||
|
||||
**Non-Goals:**
|
||||
- Any change at Stripe (D3).
|
||||
- A member-facing fact, and the issue form's sentence (D4, out of scope).
|
||||
- Delivering again in the act that ends the holder (D10, out of scope).
|
||||
- The ended branch's lock order (#180).
|
||||
- The pool row lock itself, which `reconcile-takes-pool-lock` (#193) takes and this change depends on (D9).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. How the reconcile knows another plan took the ladder
|
||||
|
||||
Options: the transition pair `core.confer` writes (an `end` row and a typed row with a from side, same pool, ladder, instant and actor), which `core.grant_replacements` already reads for grants; a column `superseded_by_provision_id` written by `core.confer`; the end row's actor; `core.superseded_ends`.
|
||||
|
||||
Pick: the transition pair, stated once in a view, and one SQL function over it.
|
||||
|
||||
- `core.supersession_pairs (pool_id, plan_ladder_id, effective_at, ended_provision_id, successor_provision_id)`, migration `00033`: the join `core.grant_replacements` makes today, without its grant-specific filters:
|
||||
|
||||
```sql
|
||||
CREATE VIEW core.supersession_pairs AS
|
||||
SELECT
|
||||
e.pool_id,
|
||||
e.plan_ladder_id,
|
||||
e.effective_at,
|
||||
e.provision_id AS ended_provision_id,
|
||||
n.provision_id AS successor_provision_id
|
||||
FROM core.pool_provision_transitions e
|
||||
JOIN core.pool_provision_transitions n
|
||||
ON n.pool_id = e.pool_id
|
||||
AND n.plan_ladder_id = e.plan_ladder_id
|
||||
AND n.effective_at = e.effective_at
|
||||
AND n.actor_type = e.actor_type
|
||||
AND n.actor_id IS NOT DISTINCT FROM e.actor_id
|
||||
WHERE e.transition_type = 'end'
|
||||
AND n.transition_type IN ('initiate', 'upgrade', 'downgrade', 'transfer')
|
||||
AND n.from_rank IS NOT NULL;
|
||||
```
|
||||
|
||||
`core.grant_replacements` is re-expressed over it with the same columns and rows, by `CREATE OR REPLACE VIEW`:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW core.grant_replacements AS
|
||||
SELECT
|
||||
ep.grant_id,
|
||||
np.grant_id AS replacing_grant_id,
|
||||
np.subscription_id AS replacing_subscription_id,
|
||||
sp.pool_id,
|
||||
max(sp.effective_at) AS replaced_at
|
||||
FROM core.supersession_pairs sp
|
||||
JOIN core.pool_provisions ep ON ep.provision_id = sp.ended_provision_id
|
||||
JOIN core.pool_provisions np ON np.provision_id = sp.successor_provision_id
|
||||
LEFT JOIN core.grants ng ON ng.grant_id = np.grant_id
|
||||
WHERE ep.grant_id IS NOT NULL
|
||||
AND (np.grant_id IS NOT NULL OR np.subscription_id IS NOT NULL)
|
||||
AND np.grant_id IS DISTINCT FROM ep.grant_id
|
||||
AND ng.extends_grant_id IS DISTINCT FROM ep.grant_id
|
||||
GROUP BY ep.grant_id, np.grant_id, np.subscription_id, sp.pool_id;
|
||||
```
|
||||
|
||||
A row whose provision is NULL (a transition written before provisions were attributed, `00004`) has no provision row and drops out at the joins, as it drops out of today's view. The view names its columns and reads no `reason`, so the reader role reads it (`docs/database-management.md`, "The `console_reader` role").
|
||||
|
||||
- `core.subscription_displacements(p_subscription_id UUID, p_pool_id UUID)`, migration `00034`, invoker rights, `STABLE`:
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION core.subscription_displacements(p_subscription_id UUID, p_pool_id UUID)
|
||||
RETURNS TABLE (
|
||||
plan_ladder_id UUID,
|
||||
displaced_product_id UUID,
|
||||
displaced_at TIMESTAMPTZ,
|
||||
holder_provision_id UUID,
|
||||
holder_source TEXT
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (l.plan_ladder_id)
|
||||
l.plan_ladder_id, l.provision_id, l.product_id, l.status, l.ended_at
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
WHERE l.pool_id = p_pool_id
|
||||
AND p.subscription_id = p_subscription_id
|
||||
ORDER BY l.plan_ladder_id, l.activated_at DESC, l.provision_ladder_id DESC
|
||||
),
|
||||
displaced AS (
|
||||
SELECT lt.plan_ladder_id, lt.product_id, lt.ended_at
|
||||
FROM latest lt
|
||||
WHERE lt.status = 'ended'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM core.supersession_pairs sp
|
||||
JOIN core.pool_provisions succ ON succ.provision_id = sp.successor_provision_id
|
||||
WHERE sp.pool_id = p_pool_id
|
||||
AND sp.plan_ladder_id = lt.plan_ladder_id
|
||||
AND sp.ended_provision_id = lt.provision_id
|
||||
AND sp.effective_at = lt.ended_at
|
||||
AND succ.subscription_id IS DISTINCT FROM p_subscription_id)
|
||||
)
|
||||
SELECT d.plan_ladder_id, d.product_id, d.ended_at, h.provision_id,
|
||||
CASE WHEN hp.grant_id IS NOT NULL THEN 'grant'
|
||||
WHEN hp.subscription_id IS NOT NULL THEN 'subscription'
|
||||
ELSE 'purchase' END
|
||||
FROM displaced d
|
||||
JOIN core.pool_provision_ladders h
|
||||
ON h.pool_id = p_pool_id
|
||||
AND h.plan_ladder_id = d.plan_ladder_id
|
||||
AND h.status IN ('active', 'suspended')
|
||||
AND NOT h.default_sourced
|
||||
JOIN core.pool_provisions hp ON hp.provision_id = h.provision_id
|
||||
WHERE hp.subscription_id IS DISTINCT FROM p_subscription_id
|
||||
ORDER BY d.plan_ladder_id, h.provision_id;
|
||||
$$;
|
||||
```
|
||||
|
||||
Its rules: the subscription's latest episode on each ladder of the pool, by `activated_at` and then the episode's id; only when that episode is `ended`; its `end` row found through the pair by provision, ladder and `effective_at = ended_at`; a successor whose provision is not this subscription's (D12); and a holder (D2). The episode is matched by provision, ladder and instant, the key the view pairs on, rather than by the transition's `provision_ladder_id`; every transition row that names a provision carries its episode id since `00004`'s backfill, and the constraint `chk_pool_provision_transitions_occupancy_named` refuses a new one without it, so the two matches agree on every row the schema holds. It grants `EXECUTE` to `core_reader`, `core_writer` and `core_owner`, as `00023:359-360` grants `core.grant_waits`; `console_runtime` reaches it through `core_writer` (`00029`).
|
||||
|
||||
Reason: the record exists for every supersession ever written, so history needs no backfill; the grant-resumption spec forbids the column; the end row's actor cannot tell a second subscription (both rows are `webhook`) from the subscription's own tier change, and `core.superseded_ends` has no ladder or source and reads `reason`. A rule that SQL and Go both need lives in one of them (`docs/go-conventions.md`, "Where a rule and an act live"): the reconcile, the organization page and a later D10 act read the function, and none restates it. When #179 makes transitions point at their act, only the pairs view's body changes, as `00025:15-17` plans for `core.superseded_ends`.
|
||||
|
||||
Adversarial check: a false pair needs a transaction that ends the subscription's episode on a ladder and writes another source's typed row on that ladder with a from side, by the same actor at the same instant, without a supersession. The ended branch's resumption and floor write `system` rows with `from_rank` NULL; a tier removal's floor finds the subscription's episode already ended, so its row is an `initiate`; the plan-act moves touch default-held positions only (`internal/entitlements/plan_act_move.go:130-217`). A false negative needs the two rows of one supersession to differ in instant or actor, which `core.confer` cannot write.
|
||||
|
||||
### D2. When the reconcile leaves a ladder alone
|
||||
|
||||
Options: displacement alone; occupancy alone (never confer over a non-default holder); the displacer's chain (leave alone while the plan that took the ladder, or what replaced it, holds); displacement and a holder.
|
||||
|
||||
Pick: displacement and a holder. The reconcile leaves a missing desired product alone when any ladder of its current shape is one the function returns, that is, one where the subscription is displaced and an `active` or `suspended` position of a non-default source other than this subscription sits.
|
||||
|
||||
- The check runs per ladder, not per product, so a period-end downgrade that lands while displaced (another product of the same ladder) is held back too (F16).
|
||||
- A desired product with a live provision is not checked: its episodes are live, so it is not displaced, and its bounds update as today.
|
||||
- Two reads run once per item pass, after the live read and under the pool row: `ListConferralShapes` (the desired products' ladders and ranks, from `core.product_conferral_shapes`) and `ListSubscriptionDisplacements` (the function, with each ladder's name). They run whatever is missing, so every active reconcile's trace has the same statements.
|
||||
- The rule is a Go function that reads no row, `DisplacedProducts(missing []string, rungs map[string][]Rung, displacements []Displacement) map[string]Displacement`, in package `fulfillment`, so a unit test covers it and the organization page calls it too (D4): a rule that a page shows and the reconcile enforces is one function that reads no row (`docs/go-conventions.md`, "Where a rule and an act live").
|
||||
|
||||
Reason: the subscription pays, so it delivers whenever no other deliberate plan holds its ladder, and a default is the floor, never a deliberate holder (`docs/models/plan-ladders-transitions.md`, invariant 11). Displacement alone would leave a subscription that bills delivering nothing forever once the grant ends (F11, F12), where today's next reconcile at least brings it back. Occupancy alone would break a purchase over a grant, which the offer sells (rule 13, `internal/plans/offer.go:181`) and invariant 19 relies on (F17, F18). The displacer's chain ends a grant an operator issues later over the default (F13), the same defect for that grant. Accepted edge: a legacy grant from a default change, re-decreed over the default while the subscription was displaced, holds the ladder against it (F25); it is reachable only through the gap D10 would close, and it lasts until an operator revokes the legacy grant, which has no end date.
|
||||
|
||||
### D3. Billing
|
||||
|
||||
The subscription keeps billing; the reconcile changes nothing at Stripe. #212 says so, the issue form says so, `plan-enrollment-administration` forbids changing the subscription as a side effect of the grant, and Doc 41 §11.2 leaves the coupling to the commercial layer.
|
||||
|
||||
### D4. What the operator sees
|
||||
|
||||
Options: (a) nothing beyond the issuance warning; (b) an operator fact on the organization page where the subscription is read, read from D1's function; (c) (b) and a member fact; (d) the issue form's sentence reworded.
|
||||
|
||||
Pick: (b), on the Billing card's Subscriptions row, as one muted line under the row's value line (Copy table, rows O1 to O3). (c) and (d) are out of scope, waiting on a product decision: (c) needs an answer on whether a member can cancel a displaced subscription in the console, which today no plan move can (`plan_change.go:770`), and (d) touches the advisory requirement `suspended-plans-stay-held` modifies.
|
||||
|
||||
Where: the organization page reads subscriptions in one place, the Billing card's Subscriptions row, which is where an operator reads a displaced subscription today, as active with its renewal. The row is built from each of the account's subscriptions, and for one subscription, the case Checkout makes, it is that subscription's own line (its state and its period). The subscriptions list's row is read per subscription too, but it lives on `/operator/billing/subscriptions`, a list across organizations that reads no pool and is not the organization page; a fact there needs the function once per listed row. So the line goes on the card, beside the Pools panel that shows the delivery holding the ladder.
|
||||
|
||||
What it says: the ladders the subscription delivers nothing on, by name, and the kind of plan that holds them (a grant, another subscription, another plan), never a product, because the card names none (`plan-enrollment-administration`, "The composite's billing card summarizes the money"). For a product of several ladders the line names only the ladder the function picked, the held one, though the whole product delivers nothing (F21); that is enough for now. With several subscriptions on the account, the line counts the displaced ones, as the row's note counts those in the worst state. The badge and the period clause already say the subscription bills; the line adds that it delivers nothing and why, which the operator cannot read anywhere else on the page: a cost the member is paying is a fact under the copy rules (`AGENTS.md`, "UI copy").
|
||||
|
||||
How: the card states the rule the way the reconcile does, not the function alone. The function reads episodes, pairs and holders, never the subscription's current items, so after a Stripe-side edit that moves the subscription's item to a product on another ladder, or removes it, the function still returns the old ladder while the reconcile has stopped leaving anything alone. The card therefore calls the reconcile's pure function, `fulfillment.DisplacedProducts` (D2), over the subscription's current items and their rungs, and names the displaced ladders of the products it leaves out; a displacement whose ladder no current item names produces nothing. The reconcile passes the products with no live provision as the missing set; the card passes all the current products, which gives the same answer, because a product with a live provision has live episodes and is on no displaced ladder (D2).
|
||||
|
||||
`loadOrgBillingSummary` runs three statements for the account, none per subscription (`docs/go-conventions.md`, a read page derives what it shows from data it loaded; `TestOperatorPagesReadAFixedNumberOfTimes` pins every operator page at no statement per row): `ListAccountSubscriptionDisplacements`, which applies the function to every subscription of the account with the organization's default pool (`FROM core.subscriptions s CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, ...)`), `ListCurrentSubscriptionItemsByBillingAccountID` in `internal/billing/queries/subscription_items.sql`, which selects `si.subscription_id` and `si.product_id` from `core.subscription_items si JOIN core.subscriptions s USING (subscription_id)` where `s.billing_account_id = sqlc.arg(billing_account_id)::uuid AND si.ended_at IS NULL`, ordered `si.subscription_id, si.product_id` (the rows `mirrorItems` writes), and `ListConferralShapes` for those products. The default pool comes from the pools `loadOrgPools` already read, not from a second read of the row. A failed pools read sets `data.Error`, and the page then renders that error alone (`operator_enrollment.html:14-18`), so the card is not drawn. The ladder names come from the displacement query's join to `core.plan_ladders`, one statement. The status filter stays in Go (`subscriptionMayBeDisplaced`) over the subscriptions the card already read, which are `active`, `trialing`, `past_due`, `unpaid` or `paused`. A pure function `displacementNote(subscriptions int, displaced []displacedSubscription) string` builds the line, and `BillingSummaryViewModel` carries it as `DisplacementNote`.
|
||||
|
||||
A failed read is never an answer (`docs/go-conventions.md`): a failed items, shapes or displacement read logs (Copy, log row L3) and sets `BillingSummaryViewModel.DisplacementError`, which the template renders in the line's place (Copy, row O3), as the page already shows `data.MembersError` for the members section. The card's other silent reads are #207's defects, not a pattern this change extends.
|
||||
|
||||
### D5. A subscription that pays for two tiers of one ladder
|
||||
|
||||
Options: the higher-ranked tier; the newer Stripe item; the first item in Stripe's order; nothing on the ladder, logged.
|
||||
|
||||
Pick: the higher-ranked tier, decided after D2 removes the products it leaves alone. Two desired products conflict when their current shapes share a ladder. A pure function, `chooseTiers(products []string, rungs map[string][]rung) (kept []string, outranked map[string]outrank)`, walks the remaining products by the highest rank each holds on any of its ladders, descending, then by `product_id` ascending, and keeps a product unless a kept product already holds one of its ladders; a product on no ladder is always kept. It returns the kept products in the order it was given (ascending id, D6), and for each product left out the product that outranked it and the shared ladder. Each left-out product is logged as a warning (Copy, log row L2), is not conferred, and is not ended: it stays desired, so `endSurplusConferrals` keeps it, and the kept product's conferral supersedes its provision if it had one.
|
||||
|
||||
Reason: the member pays for both, the higher delivers the most of what they pay for, and the rule needs no Stripe timestamp. Stripe's item order can change on an edit, which would flip the holder, and the newer item is a guess at an operator's intent. Removing the left-alone products first lets a lower tier take a ladder a displaced one cannot (a bundle displaced on one ladder); on a single ladder both are displaced alike.
|
||||
|
||||
Because the walk compares a product's highest rank across ladders, a product ranked 3 on L1 and 1 on L2 beats a tier ranked 2 on L2, which is the higher tier on L2: the member then holds the lower rung on the shared ladder. The rule is deterministic and `TestChooseTiers` pins it with a row.
|
||||
|
||||
Rollout: a subscription already alternating switches once more, to the higher tier, at its first reconcile after the deploy, and then stays.
|
||||
|
||||
### D6. A fixed order
|
||||
|
||||
`convergeConferrals` walks `sortedProducts(desired)`, the desired product ids in ascending order (`slices.Sorted(maps.Keys(desired))`; UUIDv7 ids, so the products' creation order), never the map. D5's walk decides which products are kept; the write order is the id order, so an edit that reorders Stripe's items changes nothing. `docs/go-conventions.md` ("Output order") requires a stated order for a loop that writes several rows; the same lines name `core.confer`'s own loops (#218), which are not this change. The trace harness's `sortConferralRuns`, which sorted runs of conferral statements because "their order is Go's map order", is deleted with its premise; no committed golden holds a run of two conferral statements, so no golden changes.
|
||||
|
||||
### D7. Recovery from past due
|
||||
|
||||
No branch of its own. Recovery reaches `reconcileItems`, whose resume finds nothing for a displaced subscription, and whose item pass applies D2 (F4). The tests drive the recovery through `ReconcileSubscription`, not `SyncSourceStatus`. #148's design D14 ("the recovery converges both") stays true.
|
||||
|
||||
### D8. The grant marked to resume
|
||||
|
||||
Nothing new. With the re-conferral gone, a marked grant over a subscription is never paired as replaced by it, so `core.grant_waits` and resumption are untouched. A marked grant that the subscription had itself replaced earlier keeps waiting on the subscription and, while the subscription is displaced, reads as its spec says (the top of its chain delivers nothing). The scenario "Two conferrals on one ladder in one transaction yield one row" (`grant-resumption/spec.md:52-55`) describes "one reconcile of subscription S confers two products of ladder L", which D5 makes unreachable through the reconcile; its test confers directly (`grant_waits_test.go:99-111`), so the delta rewords the WHEN as one transaction conferring from S, and the view keeps the behaviour.
|
||||
|
||||
### D9. The pool row
|
||||
|
||||
This change takes no lock of its own. `reconcile-takes-pool-lock` (#193) takes the pool row in both branches of the reconcile, after the subscription's advisory lock and before `core.sync_source_status`, and lands first. The rule that a path takes the row first is stated there, in `plan-transitions`, "IssueGrant serializes concurrent operators on the same pool", and this change restates nothing.
|
||||
|
||||
What this change relies on: the displacement read and the live read run under that row, so a grant that committed while the reconcile waited is visible to them (F23). The two reads this change adds take no lock and set no order.
|
||||
|
||||
### D10. When a displaced subscription delivers again
|
||||
|
||||
In this change: at its next reconcile once no other plan holds the ladder (F11, F12), as today. The more correct moment is the act that ends the holder, before the floor; that is a second resumption mechanism with its own conditions and tests, so it is a candidate issue (proposal, Out of scope). It must take no subscription lock (Locks and transactions).
|
||||
|
||||
### D11. A multi-ladder product displaced on one ladder
|
||||
|
||||
Leave the whole product alone; the vacated rung stays vacant until the holder ends (F21). `core.confer` cannot confer part of a bundle, and invariant 7 makes the whole-bundle end the model. The issue form's warning already says the grant supersedes the subscription's tier.
|
||||
|
||||
### D12. Two subscriptions on one ladder
|
||||
|
||||
The later conferral keeps the ladder (F19). D1 counts another subscription as another source, so the alternation stops with no special case, and the earlier subscription bills and delivers nothing until the later one ends.
|
||||
|
||||
### D13. The log lines
|
||||
|
||||
The reconcile logs each product D2 leaves alone at `Info`, once per reconcile, with the core subscription id, the product, the ladder and the holder's provision (Copy, log row L1): it is a state the operator chose and it repeats at every renewal. D5's line is `Warn` (L2): the member pays for a tier they do not get.
|
||||
|
||||
### D14. Rows the defect already wrote
|
||||
|
||||
No compatibility code: the rows the defect wrote are fixed by hand after a read-only count, rather than by code every deployment would carry for one deployment's history. Before the landing, whoever lands it runs one read-only query on each deployment, as the reader role, which cannot read `reason` and needs it nowhere (tasks, item 7.3): the grants that took a subscription's ladder and that the same subscription's reconcile took back. Each hit is fixed by hand afterwards, on the maintainer's go. After this change such a subscription is not displaced (its latest episode is live), so nothing converges them on its own; an unmarked hit is a grant recorded active that delivers nothing, and a marked hit waits on the subscription.
|
||||
|
||||
## Failure table
|
||||
|
||||
"Holder" is an `active` or `suspended` position on the ladder of a provision that is neither the subscription's nor default-sourced. "Displaced on L": the subscription's latest episode on L ended in a supersession whose successor belongs to another source.
|
||||
|
||||
| # | State when the subscription's reconcile runs | Today | After | Rule |
|
||||
|---|---|---|---|---|
|
||||
| F1 | Grant over an active subscription; renewal or edit reconcile | Re-confers over the grant; the grant's provision ends; webhook `end` and typed rows; entitlements move back | Nothing written; the grant keeps the ladder; one Info line; the subscription keeps billing | D1, D2 |
|
||||
| F2 | Same, with a quantity change at Stripe | As F1 | As F1: nothing is live, so no bounds change | D2 |
|
||||
| F3 | Grant over a suspended subscription; still `past_due` | Nothing live to suspend; no item pass | Unchanged | #148 D14 |
|
||||
| F4 | Same; recovery to `active` | The resume finds nothing; the item pass confers over the grant | The grant keeps the ladder; the subscription delivers nothing and bills | D2, D7 |
|
||||
| F5 | Marked grant over the subscription; any active reconcile | Re-confers; the grant is paired as replaced by the subscription, waits on hold, resumes only at the subscription's end | The grant keeps delivering; no replacement pair is written | D2, D8 |
|
||||
| F6 | Unmarked grant over the subscription; any active reconcile | Re-confers; the grant reads inactive with `status = 'active'` until its expiry and never delivers again | The grant keeps delivering | D2 |
|
||||
| F7 | Grant of the product the subscription pays for | Re-confers as a `transfer` | The grant keeps the ladder | D2 (the pair reads `transfer`) |
|
||||
| F8 | The subscription carries two tiers of one ladder | First reconcile: map order picks the holder; every later one switches the tier, writes `end` and typed rows, moves entitlements | The higher tier is conferred and kept; the other is logged at Warn; later reconciles write nothing | D5, D6 |
|
||||
| F9 | Two desired products whose shapes share ladders with conflicting ranks | As F8 | One stated order decides, logged | D5 |
|
||||
| F10 | The default holds the ladder; the subscription confers (a purchase, a comeback) | Supersedes the default | Unchanged | A default never holds against a subscription (D2) |
|
||||
| F11 | Grant over the subscription, then the grant is revoked or expires and the pool is left vacant | The floor confers the default; the next reconcile supersedes it | Unchanged; ideally in the ending act | D2; D10 |
|
||||
| F12 | Same, the pool holds another ladder so no floor runs | The ladder stays vacant until the next reconcile, which confers | Unchanged; ideally in the ending act | D2; D10 |
|
||||
| F13 | Grant G over the subscription, G revoked, the default, then an operator issues G3 over the default | The next reconcile ends G3 | G3 keeps the ladder | D2 (any non-default holder) |
|
||||
| F14 | G over the subscription, then G is extended (G') | The next reconcile ends G' | G' keeps the ladder | D2 |
|
||||
| F15 | The subscription's own tier change on its ladder, not displaced | Supersedes its own provision | Unchanged | The successor is the same subscription (D1) |
|
||||
| F16 | A period-end downgrade lands while displaced (the subscription event, or the sweeper's `reconcilePeriodEndDowngrade`) | Confers the lower tier over the grant | Not conferred; the row is applied by rule 1, which reads prices (`reconcile.go:876-893`); the spent schedule is released after commit | D2 (per ladder) |
|
||||
| F17 | The subscription left ladder L by its own edit, a grant later took the vacant L, then the item moves back to L | Confers over the grant | Unchanged: the latest episode on L ended without another source's successor, so this is a purchase onto a grant-held ladder | D1; T13 |
|
||||
| F18 | A new subscription (Checkout, offer rule 13) over a grant | Supersedes the grant; a marked grant waits on it | Unchanged | Never displaced (D1) |
|
||||
| F19 | A second subscription superseded the first on one ladder | The two alternate at every reconcile of either | The later keeps the ladder; the earlier bills and delivers nothing | D1, D12 |
|
||||
| F20 | The subscription is cancelled while displaced | Ends nothing, resumes nothing, the floor sees the grant | Unchanged; a marked grant it had replaced never resumes (`grant-resumption/spec.md:323-326`) | `reconcileEnded` resumes only when it ended something |
|
||||
| F21 | A product of ladders L1 and L2; a grant on one of them only | The whole-bundle end vacated the other; the next reconcile re-confers the product over the grant | The product is left alone; the other ladder stays vacant until the grant ends | D11 |
|
||||
| F22 | A tier removal or alignment ended the subscription's rung | Not a supersession; the next reconcile confers per the product's new shape | Unchanged | The removal's floor row has no `from_rank`, so no pair (D1) |
|
||||
| F23 | An operator issues a grant while the reconcile is between its live read and `core.confer` | `core.confer` waits on the pool row, then supersedes the grant that just committed | The reconcile reads after it holds the pool row and sees the grant | D9 |
|
||||
| F24 | A displaced subscription goes `past_due` | Nothing live to suspend; no Payment due card, since the ladder's position is the grant's | Unchanged | #148 |
|
||||
| F25 | A legacy grant, re-decreed over the default by a default change, holds L before the displaced subscription's next reconcile | Superseded | Holds the ladder against the subscription, as any non-default grant, indefinitely: the grandfather move writes a legacy grant with no end date, so the subscription bills and delivers nothing until an operator revokes it, and the Billing card's line says a grant holds the ladder | D2; reachable through the F11 gap only |
|
||||
| F26 | A status change (suspension or recovery) while an operator's grant act on the same pool is uncommitted | No pool lock: the last commit writes entitlements that miss one change (#193) | Closed by `reconcile-takes-pool-lock`, not by this change | D9 |
|
||||
| F27 | A subscription never displaced, canceled, or in its ordinary reconciles | Unchanged | Unchanged, plus the two reads | |
|
||||
| F28 | The organization page of an account whose subscription is displaced | The Billing card reads Active and "renews {date}", nothing else | The same, and the line "Delivers nothing on {ladder}, which a grant holds" | D4 |
|
||||
| F29 | The displaced subscription's item is moved at Stripe to a product on another ladder, or removed, and the organization page is read before the next reconcile | The Billing card has no line | No line: the card applies the reconcile's choice over the current items, so it never says "Delivers nothing" for a ladder the subscription no longer pays for | D4 |
|
||||
|
||||
## Locks and transactions
|
||||
|
||||
`ReconcileSubscription`'s transaction after this change and `reconcile-takes-pool-lock`, active branch, in order:
|
||||
|
||||
1. The shared rendezvous, the first statement (`BeginMaterializing`).
|
||||
2. The subscription advisory lock (`hashtext(<Stripe subscription id>)`).
|
||||
3. The `core.subscriptions` row, written by `upsertSubscription`.
|
||||
4. The pool row, `LockPools(poolID)` (`internal/entitlements/tx.go:114-135`), through the `LockResourcePool` query; taken by `reconcile-takes-pool-lock`.
|
||||
5. `core.sync_source_status('active')`, now under the pool row.
|
||||
6. The subscription item rows (`mirrorItems`, `endReplacedItems`).
|
||||
7. The live provisions read, then **new:** `ListConferralShapes` and `ListSubscriptionDisplacements`, all after step 4, so a grant committed while the reconcile waited is visible.
|
||||
8. `core.confer` and `core.update_conferral_bounds` in ascending product id order, then `core.end_conferral` for the surplus, each taking the pool row the transaction holds.
|
||||
9. Materialization.
|
||||
10. The scheduled-change rows; commit; the Stripe schedule release after commit, and `supersedeReleasedDowngrades` in its own transaction with the subscription lock only.
|
||||
|
||||
Suspended branch and ended branch: as `reconcile-takes-pool-lock` leaves them.
|
||||
|
||||
Why the order is safe is that change's (its design D2, `docs/database-locks.md`); the two reads this change adds take no lock. The D10 follow-up must lock by pool only: a revocation that held the pool and then took a subscription's lock would wait in a cycle with a reconcile that holds the subscription's lock and waits for the pool.
|
||||
|
||||
This change's edit to `docs/database-locks.md` is one sentence in "Subscription reconcile", Active: the live read is followed by the shapes read and the displacement read, under the pool row.
|
||||
|
||||
## Copy
|
||||
|
||||
Every sentence a person reads that this change adds. The UI copy rules apply (`AGENTS.md`, "UI copy"; `openspec/specs/ui-vocabulary/spec.md`, "Copy earns its place"): no filler, no em dash, facts only, operator vocabulary (`plan ladder`, `plan` for any delivering source, as "No plan is delivering into this pool" already uses it). Angle brackets mark a value filled at run time. Every entry waits for the maintainer's approval.
|
||||
|
||||
### Operator
|
||||
|
||||
| # | Where | Text |
|
||||
|---|---|---|
|
||||
| O1 | Billing card, Subscriptions row, one muted line under the row's value line, when the account holds one subscription and it is displaced (D4) | `Delivers nothing on <ladders>, which <holder>`, for example `Delivers nothing on Hosting, which a grant holds` |
|
||||
| O2 | The same line when the account holds several subscriptions, `<k>` of them displaced | `<k> delivers nothing on <ladders>, which <holder>` for one, `<k> deliver nothing on <ladders>, which <holder>` for more, for example `1 delivers nothing on Hosting, which another subscription holds` |
|
||||
| O3 | The same line's place, when a read for it failed | `Failed to load deliveries.` |
|
||||
|
||||
The O1 and O2 lines carry no terminal period, as the page's other muted values carry none ("No subscriptions", "No invoices issued"); O3 is an error message and keeps the period, in the form of the members section's `Failed to load members.`
|
||||
|
||||
`<ladders>` is the distinct displaced ladders' display names (distinct by ladder id) sorted by `strings.Compare` in `displacementNote`, whatever order the query returns them in, joined `A`, `A and B`, `A, B and C`. `<holder>` is `a grant holds`, `another subscription holds` or `another plan holds` when one provision holds every ladder the line names, by that provision's source (`another plan` for a purchase, which no console path writes), and `other plans hold` when several provisions hold them.
|
||||
|
||||
Reused unchanged: the card's state badges, its count and period clause, and the issue form's warning (`issueGrantSupersedeWarning`).
|
||||
|
||||
### Log lines
|
||||
|
||||
| # | Where, level | Text |
|
||||
|---|---|---|
|
||||
| L1 | `convergeConferrals`, a product D2 leaves alone (Info, D13) | `reconcile: product not conferred; another plan holds its ladder`, attributes `subscription_id`, `product_id`, `plan_ladder_id`, `holder_provision_id` |
|
||||
| L2 | `convergeConferrals`, a product D5 leaves out (Warn, D13) | `reconcile: product not conferred; a higher tier of the subscription holds its ladder`, attributes `subscription_id`, `product_id`, `plan_ladder_id`, `outranked_by_product_id` |
|
||||
| L3 | `loadOrgBillingSummary`, a failed items, shapes or displacement read (Warn) | `org enrollment: subscription displacement read failed`, attributes `org_id`, `billing_account_id`, `error` |
|
||||
|
||||
## Tests
|
||||
|
||||
Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deliberate breaks"); the full lists, with the texts each failing test prints, are in the tasks. A break of a migration's SQL is a command record that resets the test databases with the broken file before it runs the test, since the shared database runs the function as migrated.
|
||||
|
||||
- **Characterization first** (section 1, `docs/testing.md`, "Characterize before a refactor"): trace cases with today's answers, `grant_over_suspended_recovery`, `second_subscription_over_first` and `two_tiers_one_ladder` (seeded with one item and then two, so today's run is deterministic: the stale live read makes the first conferral a `noop`) in `reconcile_trace`, and `downgrade_lands_while_displaced` in `scheduled_change_trace`; `TestReconcile_SupersededWhileSuspendedNotResurrected` rewritten to recover through `ReconcileSubscription`, wanting today's answer. Each flips in the section that fixes it. A marked-grant trace case is not added: the trace's rows read no grant, so it would equal `grant_over_subscription`; `TestReconcile_AMarkedGrantOverTheSubscriptionIsNotReplaced` reads `core.grant_replacements` instead.
|
||||
- **The order** (section 2): `TestSortedProducts`, a unit test over five ids in maps built forward, in reverse and in one fixed shuffle, each called fifty times and compared with the sorted slice, because Go's map order is random and one call can come out sorted; the trace case `three_ladders_first_sight`, whose transition rows show the order but catch the map break only on most runs.
|
||||
- **The pairs view** (section 3): the existing `TestGrantReplacements_*`, `TestResumableGrants_*` and `TestGrantWaits_*` pass unedited, which is the proof that `core.grant_replacements` keeps its rows; three tests of the view itself, the restoration test driven by an expiry, the one path where `from_rank` alone decides; `TestConsoleReaderColumns` (the reader role reads every column) and `TestRuntimeRolePrivileges` (`console_runtime` reads the view).
|
||||
- **The rule** (section 4): nine entitlements tests of the function (`TestSubscriptionDisplacements_*`), from an operator grant to the latest episode after a comeback; fulfillment tests through the Stripe mock for F1, F2, F4 to F7, F11, F13, F14, F16, F18, F19, F21 and F23 (the tasks' T1 to T9 and T11 to T13); `TestDisplacedProducts`; the trace goldens flip.
|
||||
- **The choice** (section 5): `TestChooseTiers` over hand-built shapes, each row in two input orders; `TestReconcile_TwoTiersOfOneLadderConferTheHigher` through the Stripe mock with the items in each order.
|
||||
- **The page** (section 6): `TestDisplacementNote` compares every Copy row's whole sentence; `TestDisplacedOnPage` pins that a displacement on a ladder no current item names reads no line; `TestSubscriptionMayBeDisplaced`; the organization page's read-count row gains a displaced subscription; `TestOrgEnrollmentGolden` gains the state `grant-over-subscription`.
|
||||
|
||||
Breaks no test sees, with the reason:
|
||||
- The function's `succ.subscription_id IS DISTINCT FROM p_subscription_id`: the subscription's own successor on a ladder is always its latest episode there (it activates when the ended one ends, and an episode activated in the same transaction sorts after by its id), so the clause never decides a row the latest-episode rule leaves.
|
||||
- The function's final `hp.subscription_id IS DISTINCT FROM p_subscription_id`: a live position of the subscription on the ladder would be its latest episode, which the `displaced` rows exclude.
|
||||
- The function's `GRANT EXECUTE`: `PUBLIC` keeps `EXECUTE` on an invoker-rights function, so removing the grant changes nothing a role can do; the grant follows `00023` so that a later revoke from `PUBLIC` leaves the three core roles able to call it.
|
||||
- Any one of the function's three pool filters (the episodes, the pairs, the holders): a subscription's provisions all sit in its organization's default pool, and the other two filters scope the result, so no single removal changes a row; no test claims the scoping.
|
||||
- The comeback case (no row once the subscription holds the ladder again): the latest-episode rule and the holder's exclusion of the subscription each decide it alone.
|
||||
- The match of an old transition row without an episode id: the constraint refuses such a row, so no test can write one.
|
||||
- A holder made optional (the holder join turned into an outer join) is not used as the break of "displacement alone": a row without a holder fails the query's scan before any assertion runs, so the break counts an ended position as a holder instead.
|
||||
|
||||
## What is unverified
|
||||
|
||||
- **The tie between episodes activated at one instant.** Two conferrals of one subscription in one transaction (legacy rows of the two-tier case) give two episodes the same `activated_at`; the latest is the one with the greater id, which relies on PostgreSQL's `uuidv7()` rising within one backend. Either episode leads to the same answer here, since the successor of the earlier is the same subscription.
|
||||
- **The cost of the two reads.** Every active reconcile runs the shapes read and the function; the function's pair lookup uses `idx_pool_provision_transitions_pairing (pool_id, plan_ladder_id, effective_at)`. Not measured.
|
||||
- **The landing count's reach.** The query reads direct pairs only: a grant extended between taking the ladder and being taken back is not counted, because the take-back ended the extension's provision, not the first one's.
|
||||
- **Stripe.** Nothing new is called, so nothing at Stripe is unverified.
|
||||
|
||||
## Migration plan
|
||||
|
||||
- **Landing order.** 00033 and 00034 assume this change lands before `tiers-need-an-entitlement-set` (00035), and after `reconcile-takes-pool-lock`. A database that has applied 00035 refuses an unapplied lower version (goose runs without allow-missing), so whichever change lands second takes the next free numbers at landing, and its tasks' file names change with them.
|
||||
- `internal/db/migrations/00033_supersession_pairs.sql` (the next number after `00032_lock_plan_act_obligation.sql`): the view, `core.grant_replacements` replaced over it, `GRANT SELECT ON core.supersession_pairs TO core_reader, core_writer, core_owner`. Down: `core.grant_replacements` replaced with `00023`'s body, then `DROP VIEW core.supersession_pairs`.
|
||||
- `internal/db/migrations/00034_subscription_displacements.sql`: the function and its `GRANT EXECUTE`. Down: `DROP FUNCTION core.subscription_displacements(UUID, UUID)`.
|
||||
- Neither writes a row or changes a column. `internal/migrate/testdata/console-reader-columns.txt` gains the view's five columns, marked `read`.
|
||||
- The queries are regenerated with `make sqlc-generate` (`docs/database-management.md`, "sqlc Code Generation").
|
||||
- After a deploy applies them, the deployment re-runs `scripts/db-reader-role.sql`, or the reader role cannot read the new view (`docs/database-management.md`, "Running the script").
|
||||
|
||||
## Rollback
|
||||
|
||||
The previous binary runs against the migrated schema: it reads neither new object, and `core.grant_replacements` keeps its columns and rows. `migrate down` twice restores `00023`'s view. No data needs restoring. Rolling back brings the take-back back, for every displaced subscription at its next reconcile.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **A paying member gets nothing on the ladder while the grant holds it.** That is what the operator chose and the issue form says; the organization page now says it too. The member's own view is out of scope (D4).
|
||||
- **A displaced subscription delivers again only at its next reconcile** after the holder ends, often its renewal, and the floor's default fills the gap (F11, F12; D10).
|
||||
- **A member whose subscription a grant displaced cannot cancel it in the console while the grant holds the ladder**, now for the grant's whole life rather than one period; only an operator acting in Stripe's Dashboard can.
|
||||
- **A legacy grant from a default change can hold a ladder against a subscription** (F25), reachable only through the F11 gap, and indefinitely once it does: the grandfather move writes a grant with no end date, so the subscription bills and delivers nothing until an operator revokes it; the Billing card's line says a grant holds the ladder.
|
||||
- **Two extra reads per active reconcile**, under the pool row, which other acts on the pool now wait behind for slightly longer.
|
||||
- **The organization page runs three more statements** for the account, none per subscription.
|
||||
- **Every active-branch trace golden gains two lines** (the two reads in section 4); the sections name the directories, and no other golden may change.
|
||||
@@ -0,0 +1,70 @@
|
||||
## Why
|
||||
|
||||
An operator can issue a grant to an organization whose tier on a ladder a subscription pays for. The grant's conferral ends the subscription's provision and takes the ladder, and the issue form says the subscription keeps billing. The subscription's next reconcile undoes it. `reconcileItems` (`internal/fulfillment/reconcile.go:412-467`) compares the products on the Stripe subscription with the subscription's live provisions; the grant ended the provision, so the product reads as missing, and `conferOrBound` (`reconcile.go:711-721`) confers it again. That conferral ends the grant's provision and puts the subscription's tier back. Nothing asks whether another plan took the ladder from the subscription.
|
||||
|
||||
Every reconcile of an active subscription reaches this: the subscription events at each renewal and after any edit, `checkout.session.completed`, the checkout return, the sweeper's period-end and term-boundary reconciles, a member's move on another ladder of the same subscription, and the invoice reconcile when an invoice line names an unmapped item. A subscription that was past due when the grant was issued does the same when it recovers: `core.sync_source_status` resumes nothing, and the item pass that follows confers the tier again. The committed trace golden `internal/fulfillment/testdata/reconcile_trace/grant_over_subscription.golden` records the take-back: after one subscription event, the grant's provision reads ended and the webhook has written an `end` of the grant's tier and a downgrade to the subscription's.
|
||||
|
||||
What the take-back leaves: a grant not marked to resume stays recorded as active and never delivers again, even after the subscription ends; a grant marked to resume is recorded as replaced by the subscription and comes back only when the subscription is cancelled.
|
||||
|
||||
The same root cause has two more forms. A subscription that carries two items for two tiers of one ladder (an edit at Stripe, which the console never makes) confers both on its first reconcile, and the later conferral ends the earlier one; which tier wins depends on Go's map order (`reconcile.go:671`). Every later reconcile confers the losing tier again, so the organization's tier alternates, each switch writes tier-change rows, and the entitlements move. Two subscriptions of one organization on one ladder alternate the same way at every reconcile of either.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **A reconcile leaves a ladder alone while another plan that took it from the subscription holds it.** The subscription is displaced on a ladder when its latest episode there ended in a supersession whose successor is another source's provision, and another plan holds the ladder while a non-default `active` or `suspended` position sits on it. One SQL function, `core.subscription_displacements(subscription, pool)`, states both rules. The item pass reads it under the pool lock and does not confer a missing product any ladder of whose shape is such a ladder; it logs the product instead. The whole product is left alone, because a conferral cannot place part of a product's shape.
|
||||
- **The subscription keeps billing and delivers again on its own.** Reconcile changes nothing at Stripe. A default-sourced position never holds a ladder against a subscription, so once the grant ends and only the default (or nothing) is left, the subscription's next reconcile confers its tier again. A new purchase over a grant still supersedes the grant: that subscription was never displaced.
|
||||
- **Recovery from past due goes through the same rule.** No branch of its own: the resume finds nothing and the item pass leaves the ladder alone.
|
||||
- **A subscription that pays for two tiers of one ladder confers the higher.** The rule is one Go function that reads no row: conflicting products are walked by their highest rank, then product id, and a product is kept unless a kept one holds one of its ladders. The product left out is logged as a warning, since the member pays for it.
|
||||
- **The item pass writes in ascending product id order**, never in Stripe's item order or Go's map order.
|
||||
- **One statement of a supersession pair.** A view, `core.supersession_pairs`, pairs an `end` row with the typed row of the conferral that caused it, which `core.grant_replacements` already does for grants alone. `core.grant_replacements` is re-expressed over it with its rows unchanged, and the displacement function reads it.
|
||||
- **The organization page says so.** The Billing card's Subscriptions row carries one muted line when a subscription of the account delivers nothing while another plan holds its ladder, read through the reconcile's own choice over the subscription's current items, for example "Delivers nothing on Hosting, which a grant holds" (design, Copy table). A failed read shows the section's error in the line's place, never an absent line.
|
||||
|
||||
## Order of landing
|
||||
|
||||
**Depends on** the change that closes #193, which takes the pool row in both branches of the reconcile; this change's displacement read relies on it (design, F23).
|
||||
|
||||
`reconcile-takes-pool-lock` lands and archives first. The two changes modify no requirement in common: that change modifies `plan-transitions`, "IssueGrant serializes concurrent operators on the same pool", where the pool-lock rule is stated once, and this change's requirements point at it by name and restate nothing. Were a requirement modified by both, this change's delta would copy it as that change leaves it, so that the two archive in order without conflict.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Each is a candidate issue or a note for the maintainer, filed only on his go.
|
||||
|
||||
- **What the member sees of a displaced subscription.** The Products page shows the grant's tier with no price line, while invoices keep arriving; a fact there needs a decision on whether a member can cancel such a subscription in the console, which today no plan move can do, because every move resolves the subscription through the ladder's position (`resolveLadderSubscription`, `internal/fulfillment/plan_change.go:770`). The comment on `resolveCurrentPlanBilling` (`internal/server/member_plans.go`) that a grant-held tier "genuinely" has no billing is false while a displaced subscription bills. Waits on a product decision; #159 covers a grant above the paid tier, not at or below it.
|
||||
- **The issue form's sentence.** `issueGrantSupersedeWarning` (`internal/server/operator_enrollment_forms.go:132`) sends the operator to "the billing view", whose routes are all reads (`internal/server/testdata/routes.golden:61-73`). Rewording it to name what changes a subscription waits on the same product decision, and touches the requirement `suspended-plans-stay-held` modifies.
|
||||
- **A displaced subscription delivers again only at its next reconcile.** Between the grant's end and that reconcile, the floor's default or nothing fills the ladder. The more correct moment is the act that ends the holder (revocation, expiry, a subscription's end, an extension's lineage), before the floor, the way `ResumeReplacedGrantsTx` resumes a marked grant: confer the displaced, still-billing subscription from its mirrored items, and suspend it when it is past due. It is a second resumption mechanism with its own conditions and tests, so it is a candidate issue of its own. It must lock by pool only: an ending act that held the pool and then took a subscription's lock would wait in a cycle with a reconcile that holds the subscription's lock and waits for the pool. It would also let a marked grant that a displaced subscription had replaced resume when that subscription ends, which `grant-resumption` today says it does not.
|
||||
- **A bundle's other rungs stay vacant.** A grant on one ladder of a multi-ladder product ends the subscription's rungs on every ladder (whole-bundle supersession); after this change the other rungs stay vacant until the grant ends. Candidate issue, beside #88's Checkout analogue.
|
||||
- **Checkout above the grant while the first subscription bills.** After a grant displaces a subscription, the Products page offers Checkout above the grant, so a second concurrent subscription is sellable, which `docs/models/payments-billing.md` invariant 12 says cannot happen. Candidate issue, or a note on #159.
|
||||
- **The sweeper's term-boundary switch reads the first item.** `firePlanSwitch` and `scheduledChangeMappings` (`internal/fulfillment/sweep.go`) act on the subscription's first current item, the wrong one on a two-item subscription. Latent, since a term-boundary switch needs a commitment no console path writes. A note on #233.
|
||||
- **The operator's subscriptions list** (`/operator/billing/subscriptions`) shows no displacement; it lists subscriptions across organizations and reads no pool. Note only.
|
||||
- **The plan-ending paths' lock order** (#180) and **the member's plan change racing a grant** (#202) are unchanged. After this change the reconcile at the end of #202's race no longer ends the grant, but the member's change still reaches Stripe for a subscription that no longer holds the ladder, which stays #202's.
|
||||
- **Rows the defect already wrote.** No compatibility code: the landing reads a count of grants a subscription took back on each deployment, as the reader role, and each hit is fixed by hand.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `stripe-subscription-management`: modified "Subscription reconcile computes a genuine diff, grouped by product" (displacement, the higher of two tiers, the fixed order), "Suspension and resume propagate via sync_source_status" (the recovery's item pass, the Billing card in place of an unnamed warning) and "Scheduled moves converge with Stripe" (a downgrade that lands while a grant holds the ladder).
|
||||
- `grant-resumption`: modified "The source that replaced a grant is identified from the transition audit" (the pair stated once in `core.supersession_pairs`; the two-conferrals scenario reworded, because the reconcile no longer confers two tiers of one ladder).
|
||||
- `plan-enrollment-administration`: added "The billing card says when a subscription delivers nothing while another plan holds its ladder".
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code:** `internal/fulfillment/reconcile.go` (the fixed order, the displacement skip, the choice of tiers, two log lines, the pure rule exported for the page), `internal/entitlements/queries` (`ListSubscriptionDisplacements`, `ListAccountSubscriptionDisplacements`, `ListConferralShapes`), `internal/billing/queries` (`ListCurrentSubscriptionItemsByBillingAccountID`), `internal/server/operator_enrollment.go` and `operator_enrollment.html` (the Billing card line), the trace harness `internal/fulfillment/trace_db_test.go`.
|
||||
- **Schema:** two core migrations, `00033_supersession_pairs.sql` (a view, and `core.grant_replacements` re-expressed over it) and `00034_subscription_displacements.sql` (a function). No column, no data, no grant change beyond the new objects' own.
|
||||
- **Stripe calls:** none added, none removed.
|
||||
- **Routes:** none.
|
||||
- **Behaviour a deployment sees:** an operator's grant over a paid tier keeps the ladder until it ends, while the subscription keeps billing; the organization page says which ladder the subscription delivers nothing on. A subscription with two tiers of one ladder stops alternating, after one last switch to the higher tier at its first reconcile after the deploy.
|
||||
- **Deploy:** re-run `scripts/db-reader-role.sql` after the migrations apply, so the reader role reads the new view (`docs/database-management.md`, "Running the script").
|
||||
- **Tests:** fulfillment tests through the Stripe mock for each state the design's failure table names, an entitlements test of the function, unit tests of the order and the choice, the trace goldens, the organization page golden and its read-count row, and one test of a reconcile waiting for the pool row.
|
||||
- **Docs:** `docs/database-locks.md`, `docs/models/plan-ladders-transitions.md`, `docs/models/payments-billing.md`, `docs/plan-management.md`, `docs/stripe.md`.
|
||||
|
||||
## Related issues
|
||||
|
||||
- **#212** is this change.
|
||||
- **#193** is closed by `reconcile-takes-pool-lock`, which this change depends on.
|
||||
- **#148** (`suspended-plans-stay-held`, in flight) edits the organization page this change's last section edits; that section waits for #148's organization-page commit. The two changes modify different requirements and archive in any order.
|
||||
- **#218** asks `core.confer` for a stated order of its own loops; this change orders the reconcile's calls, not the function's.
|
||||
- **#179** would make transitions point at their act; then only `core.supersession_pairs`' body changes.
|
||||
@@ -0,0 +1,58 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: The source that replaced a grant is identified from the transition audit
|
||||
|
||||
The system SHALL identify that a source replaced a grant from the pair of `core.pool_provision_transitions` rows `core.confer` writes for one supersession: an `end` row of a provision whose `grant_id` is the grant, and a row of type `initiate`, `upgrade`, `downgrade` or `transfer` with `from_rank IS NOT NULL` of a provision whose `subscription_id` or `grant_id` names the replacing source, in the same pool, on the same ladder, at the same `effective_at`, with equal `actor_type`, and with `actor_id` equal under `IS NOT DISTINCT FROM`. `core.confer` writes this pair the same way whichever sources supersede and are superseded. A pair whose successor grant's `extends_grant_id` names the ended grant (an extension), or whose successor provision belongs to the ended provision's own grant, SHALL NOT count as a replacement.
|
||||
|
||||
The pair SHALL be stated once, in the view `core.supersession_pairs (pool_id, plan_ladder_id, effective_at, ended_provision_id, successor_provision_id)`: one row per `end` row and typed row so paired, whatever sources the two provisions belong to, reading no `reason`. `core.grant_replacements` and the subscription reconcile's displacement read (`stripe-subscription-management`, "Subscription reconcile computes a genuine diff, grouped by product") SHALL both read the pair through it, and neither SHALL restate the pairing.
|
||||
|
||||
A view, `core.grant_replacements (grant_id, replacing_grant_id, replacing_subscription_id, pool_id, replaced_at)`, SHALL expose one row per grant, replacing source and pool so paired, with exactly one of `replacing_grant_id` and `replacing_subscription_id` set and `replaced_at` the latest instant of its pairs, because a transaction that confers twice on one ladder pairs the grant's `end` row with each typed row. Every reader of the relation SHALL read it through that view. The view SHALL NOT read `pool_provision_transitions.reason`, which the reader role withholds. The system SHALL NOT add a column to the position tables or change the conferral functions' supersession to record the relation.
|
||||
|
||||
A subscription that later supersedes its own provision (a switch to another tier on the same ladder) SHALL leave the grant linked to that subscription through the first pair.
|
||||
|
||||
#### Scenario: A buy-up links the grant to the subscription
|
||||
|
||||
- **WHEN** an organization holds Plus by grant G and reconcile confers Pro from subscription S on the same ladder
|
||||
- **THEN** `core.grant_replacements` has one row for G with `replacing_subscription_id` S, at the instant of the conferral
|
||||
|
||||
#### Scenario: A grant issued over a grant links the two grants
|
||||
|
||||
- **WHEN** an organization holds Plus by grant G and an operator issues an evaluation grant E for Pro on the same ladder
|
||||
- **THEN** `core.grant_replacements` has one row for G with `replacing_grant_id` E, because `core.confer` wrote the `end` row and the `upgrade` row with the operator as actor at one instant
|
||||
|
||||
#### Scenario: Two conferrals on one ladder in one transaction yield one row
|
||||
|
||||
- **WHEN** one transaction confers two products of ladder L from subscription S, the first over grant G
|
||||
- **THEN** `core.grant_replacements` has exactly one row for G and S
|
||||
|
||||
#### Scenario: A revocation followed by the default restoration is not a replacement
|
||||
|
||||
- **WHEN** an operator revokes a grant and the default restoration confers the rank-0 tier in the same transaction
|
||||
- **THEN** `core.grant_replacements` has no row for that pair, because the restoration's row has `from_rank` NULL and is written by the system, not the operator
|
||||
|
||||
#### Scenario: An expiry followed by the default restoration is not a replacement
|
||||
|
||||
- **WHEN** a grant's `valid_until` has passed and its expiry and the default restoration that confers the rank-0 tier run in one transaction, by the same actor
|
||||
- **THEN** `core.grant_replacements` has no row for that pair, because the restoration's row has `from_rank` NULL
|
||||
|
||||
#### Scenario: An extension is not a replacement
|
||||
|
||||
- **WHEN** an operator extends a live grant G, whose extension E supersedes G's provision as a `transfer`
|
||||
- **THEN** `core.grant_replacements` has no row for G and E
|
||||
|
||||
#### Scenario: The link survives the subscription's own switch
|
||||
|
||||
- **WHEN** subscription S replaced grant G on Pro and later moved to Max on the same ladder
|
||||
- **THEN** `core.grant_replacements` still links G to S
|
||||
|
||||
#### Scenario: The pair is stated once for every source
|
||||
|
||||
- **WHEN** an operator's grant G supersedes subscription S's position on ladder L
|
||||
- **THEN** `core.supersession_pairs` has one row on L naming S's provision as the ended provision and G's provision as the successor, at the instant of the conferral
|
||||
- **AND** `core.grant_replacements` has no row for that pair, because the ended provision is not a grant's
|
||||
|
||||
#### Scenario: A whole-bundle end pairs only the contested ladder
|
||||
|
||||
- **WHEN** a provision holding rungs on ladders L1 and L2 is superseded by a conferral of a product that is a tier of L1 only
|
||||
- **THEN** `core.supersession_pairs` has a row on L1 and none on L2, where no typed row of the successor was written
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: The billing card says when a subscription delivers nothing while another plan holds its ladder
|
||||
|
||||
On the organization composite, the billing card's Subscriptions row SHALL carry one muted line under its value line when at least one subscription of the billing account whose status is `active`, `trialing`, `past_due`, `unpaid` or `paused` is displaced on a ladder that another plan holds, as `core.subscription_displacements` states it for the subscription and the organization's default pool, for a product among the subscription's current items, through the same choice the reconcile makes (`stripe-subscription-management`, "Subscription reconcile computes a genuine diff, grouped by product"). The card SHALL NOT restate the rule, and a displacement on a ladder that no current item of the subscription names SHALL produce no line. The line SHALL name the ladders, by their display names, and the kind of plan that holds them, and SHALL name no product, as the rest of the card names none:
|
||||
|
||||
- for an account with one subscription: "Delivers nothing on {ladders}, which {holder}";
|
||||
- for an account with several subscriptions, of which k are displaced: "{k} delivers nothing on {ladders}, which {holder}" when k is 1, and "{k} deliver nothing on {ladders}, which {holder}" otherwise.
|
||||
|
||||
{ladders} SHALL be the distinct displaced ladders' names sorted by `strings.Compare`, joined as "A", "A and B", or "A, B and C". {holder} SHALL be "a grant holds", "another subscription holds" or "another plan holds" when one provision holds every ladder the line names, by that provision's source (a grant, a subscription other than the displaced one, or any other source), and "other plans hold" when several provisions hold them. A subscription that is canceled or `incomplete`, a subscription no other plan displaced, and a ladder held only by the organization type's default SHALL produce no line. For a product of several ladders the line SHALL name the displaced ladder the function picked, not the product's other ladders. A failed read of the displacements, the subscription's items or their ladders SHALL be logged and SHALL show the section's error in the line's place, never an absent line. The card SHALL change nothing at Stripe and SHALL offer no control for the condition.
|
||||
|
||||
#### Scenario: A grant over the only subscription
|
||||
|
||||
- **WHEN** an operator views the organization page of an organization whose one subscription, on ladder Hosting, was superseded by an operator's grant that still delivers
|
||||
- **THEN** the Subscriptions row reads its state badge, "1 subscription" as a link and its period clause, and under them the muted line "Delivers nothing on Hosting, which a grant holds"
|
||||
|
||||
#### Scenario: Another subscription holds the ladder
|
||||
|
||||
- **WHEN** the account holds two subscriptions on ladder Hosting, and the later one superseded the earlier one's position
|
||||
- **THEN** the line reads "1 delivers nothing on Hosting, which another subscription holds"
|
||||
|
||||
#### Scenario: The default does not count
|
||||
|
||||
- **WHEN** the grant that superseded the organization's subscription was revoked and the floor conferred the organization type's default on the ladder
|
||||
- **THEN** the Subscriptions row carries no such line
|
||||
|
||||
#### Scenario: A subscription that delivers has no line
|
||||
|
||||
- **WHEN** every subscription of the account delivers on its ladders
|
||||
- **THEN** the Subscriptions row carries no such line
|
||||
|
||||
#### Scenario: A canceled subscription has no line
|
||||
|
||||
- **WHEN** the account's only subscription, superseded by an operator's grant, has been canceled
|
||||
- **THEN** the Subscriptions row carries no such line
|
||||
|
||||
#### Scenario: A subscription whose item left the ladder has no line
|
||||
|
||||
- **WHEN** an operator's grant superseded the organization's subscription on ladder Hosting, and the subscription's only current item now names a product of another ladder
|
||||
- **THEN** the Subscriptions row carries no such line
|
||||
|
||||
#### Scenario: A failed read shows the section's error
|
||||
|
||||
- **WHEN** the read of the displacements fails
|
||||
- **THEN** the Subscriptions row shows "Failed to load deliveries." in the line's place, and not an absent line
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Subscription reconcile computes a genuine diff, grouped by product
|
||||
|
||||
Subscription reconcile SHALL compute the desired item state from the subscription's current items grouped by product — an item's product resolved via its price, with two items sharing one product collapsed into a single desired entry at the summed quantity — and SHALL compare that desired state against the subscription's live pool provisions, matched on `product_id` (the live-uniqueness grain is `(subscription_id, product_id)`, backed by a partial unique index scoped to non-ended provisions). Reconcile SHALL NOT tear down and recreate the subscription's provisions wholesale on each run; it SHALL act only on the computed delta between desired and live state. A product present in the desired state but absent from the live provisions (missing) SHALL be conferred via `entitlements.confer`, sourced by the subscription, at the item's quantity and period window, unless another plan took one of its ladders from the subscription and still holds it.
|
||||
|
||||
The subscription is **displaced** on a ladder when its latest episode there (its latest `pool_provision_ladders` row on that ladder in the pool, by activation time and then id) has ended in a supersession whose successor is not one of the subscription's own provisions, that is, when the episode's `end` row and the successor's typed row form a row of `core.supersession_pairs` (`grant-resumption`, "The source that replaced a grant is identified from the transition audit"). **Another plan holds** the ladder while an `active` or `suspended` position on it belongs to a provision that is neither the subscription's nor default-sourced. One function, `core.subscription_displacements(subscription_id, pool_id)`, SHALL state both rules, returning each ladder on which the subscription is displaced and another plan holds it, with the subscription's product that was displaced there and the holder's provision and source; reconcile and the organization page SHALL read the rule through it and SHALL NOT restate it. Reconcile SHALL NOT confer a missing product any ladder of whose current shape is such a ladder: it SHALL leave the whole product alone, because a conferral cannot place part of a product's shape, log it, and change nothing at Stripe, so the subscription keeps billing. A default-sourced position never holds a ladder against a subscription, so once no other plan holds the ladder, the subscription's next reconcile confers the product again. A subscription never displaced on a ladder, a new purchase over a grant among them, confers as before and supersedes what holds the ladder.
|
||||
|
||||
When two or more desired products that are not left alone this way have current shapes that share a ladder, reconcile SHALL keep the one with the highest rank on any of its ladders, ties broken by the lower `product_id`: it walks the products in that order and keeps a product unless a product kept before it holds one of its ladders. A product it leaves out is logged with the product that outranked it, is not conferred, and is not ended. Reconcile SHALL confer and update the products it keeps in ascending `product_id` order, never in the order of Stripe's items or of a map.
|
||||
|
||||
Reconcile SHALL make its displacement read and its live read under the pool's row lock (`plan-transitions`, "IssueGrant serializes concurrent operators on the same pool"), so that they see every conferral that committed while it waited. A product present among the live provisions but absent from the desired state (surplus) SHALL be ended via `entitlements.end_conferral`. A product present in both whose quantity differs SHALL be updated via `entitlements.update_conferral_bounds`; this SHALL NOT emit a position transition, because a quantity delta is a bounds change, never a remove-plus-add and never a silent absorption. For a product whose conferral shape spans more than one ladder, the single `entitlements.confer` call SHALL write every junction row the product's shape requires, positioning the pool on all of its ladders in one operation; reconcile SHALL NOT implement a per-caller fork or loop over ladder count, and SHALL NOT silently skip position updates for any ladder beyond the first. A replayed webhook event or a re-run of reconcile for an already-converged subscription SHALL be idempotent: `entitlements.confer`'s per-source retry idempotence SHALL produce a `noop` outcome naming the existing provision, rather than a unique-constraint violation against the subscription+product live-uniqueness index.
|
||||
|
||||
#### Scenario: Missing product is conferred
|
||||
|
||||
- **WHEN** reconcile's desired state includes a product with no matching live provision for the subscription, none of whose ladders another plan took from the subscription and still holds
|
||||
- **THEN** the system SHALL call `entitlements.confer` for that product, sourced by the subscription, at the item's quantity
|
||||
|
||||
#### Scenario: Surplus product is ended
|
||||
|
||||
- **WHEN** a subscription's live provision names a product no longer present among the subscription's current items
|
||||
- **THEN** the system SHALL call `entitlements.end_conferral` for that provision
|
||||
- **AND** it SHALL NOT tear down or recreate the subscription's other, unaffected live provisions
|
||||
|
||||
#### Scenario: Quantity-only change updates bounds without a position transition
|
||||
|
||||
- **WHEN** a matched product's item quantity changes on the subscription while the product itself is unchanged
|
||||
- **THEN** the system SHALL call `entitlements.update_conferral_bounds` with the new quantity
|
||||
- **AND** no position transition (`initiate`/`upgrade`/`downgrade`/`transfer`/`end`) SHALL be recorded for that change
|
||||
|
||||
#### Scenario: A multi-ladder product is positioned in one confer call
|
||||
|
||||
- **WHEN** a missing product occupies more than one ladder
|
||||
- **THEN** the single `entitlements.confer` call SHALL create a junction row on every ladder the product's shape requires
|
||||
- **AND** the system SHALL NOT loop per ladder or skip any ladder beyond the first
|
||||
|
||||
#### Scenario: Webhook replay is idempotent, not a unique-violation error
|
||||
|
||||
- **WHEN** a webhook event that already produced a `confer` call for a subscription and product is replayed
|
||||
- **THEN** `entitlements.confer` SHALL return a `noop` outcome naming the existing provision
|
||||
- **AND** the replay SHALL NOT raise a unique-constraint violation against the subscription+product live-uniqueness index
|
||||
|
||||
#### Scenario: A grant over the subscription keeps the ladder at the next reconcile
|
||||
|
||||
- **WHEN** an organization pays for Plus through subscription S, an operator issues a Max grant that supersedes S's position, and S renews with its items unchanged
|
||||
- **THEN** reconcile SHALL NOT confer Plus, and SHALL write no position, transition or entitlement row
|
||||
- **AND** the Max grant SHALL keep delivering, S SHALL hold no live provision, and nothing SHALL be sent to Stripe
|
||||
|
||||
#### Scenario: A grant of the subscription's own tier keeps the ladder
|
||||
|
||||
- **WHEN** an operator issues a grant of Plus, the tier subscription S pays for, which takes S's position as a transfer, and S's next reconcile runs
|
||||
- **THEN** reconcile SHALL NOT confer Plus for S, and the grant SHALL keep delivering
|
||||
|
||||
#### Scenario: A quantity change while displaced writes nothing
|
||||
|
||||
- **WHEN** the item of a displaced subscription changes quantity at Stripe while the grant that displaced it holds the ladder
|
||||
- **THEN** reconcile SHALL write no position, transition or entitlement row, since the subscription has no live provision whose bounds could change
|
||||
|
||||
#### Scenario: A grant marked to resume is not recorded as replaced by the subscription
|
||||
|
||||
- **WHEN** an operator issues a grant marked to resume over subscription S's position, and S's next reconcile runs
|
||||
- **THEN** reconcile SHALL NOT confer over the grant, and `core.grant_replacements` SHALL have no row naming the grant and S
|
||||
|
||||
#### Scenario: The subscription delivers again over the default
|
||||
|
||||
- **WHEN** an operator's grant superseded subscription S's position, the grant is revoked, the floor confers the organization type's default on the ladder, and S's next reconcile runs
|
||||
- **THEN** reconcile SHALL confer S's product over the default, because a default never holds a ladder against a subscription
|
||||
|
||||
#### Scenario: The subscription delivers again on a vacant ladder
|
||||
|
||||
- **WHEN** an operator's grant superseded subscription S's position, the grant is revoked, the pool holds a position on another ladder so the floor confers nothing, and S's next reconcile runs
|
||||
- **THEN** reconcile SHALL confer S's product on the vacant ladder
|
||||
|
||||
#### Scenario: A grant issued over the default keeps the ladder
|
||||
|
||||
- **WHEN** grant G superseded subscription S's position, G was revoked and the floor conferred the default, an operator then issues grant G3 over the default, and S's next reconcile runs
|
||||
- **THEN** reconcile SHALL NOT confer over G3, because G3 is another plan holding a ladder S was displaced from, whether or not G3 replaced the plan that displaced S
|
||||
|
||||
#### Scenario: An extension of the grant keeps the ladder
|
||||
|
||||
- **WHEN** grant G superseded subscription S's position, an operator extends G, whose successor takes G's position, and S's next reconcile runs
|
||||
- **THEN** reconcile SHALL NOT confer over the extension
|
||||
|
||||
#### Scenario: A purchase over a grant still supersedes it
|
||||
|
||||
- **WHEN** an organization holds Plus by grant and buys Pro on the same ladder through Checkout, and the new subscription's first reconcile runs
|
||||
- **THEN** reconcile SHALL confer Pro, superseding the grant's position, because the new subscription was never displaced on the ladder
|
||||
|
||||
#### Scenario: A later subscription keeps the ladder it took
|
||||
|
||||
- **WHEN** subscription S2 of the same organization confers a tier of the ladder subscription S1 holds, superseding S1's position, and S1's next reconcile runs
|
||||
- **THEN** reconcile SHALL NOT confer S1's product over S2's position, and later reconciles of either subscription SHALL write nothing on that ladder
|
||||
|
||||
#### Scenario: The subscription's own tier change is not a displacement
|
||||
|
||||
- **WHEN** subscription S moves from Plus to Pro on one ladder and its later reconcile runs
|
||||
- **THEN** S SHALL NOT be displaced on that ladder, since the successor of its Plus episode is its own Pro provision
|
||||
|
||||
#### Scenario: A product of two ladders displaced on one is left alone
|
||||
|
||||
- **WHEN** subscription S pays for a product that is a tier of ladders L1 and L2, and an operator's grant of a product that is a tier of L2 only supersedes S's position, ending S's rungs on both ladders
|
||||
- **THEN** reconcile SHALL NOT confer S's product, and L1 SHALL stay vacant while the grant holds L2
|
||||
|
||||
#### Scenario: A grant issued while the reconcile waits is seen
|
||||
|
||||
- **WHEN** an operator's grant over subscription S's position holds the pool row, uncommitted, while S's reconcile, which would confer a changed item's product, starts
|
||||
- **THEN** the reconcile SHALL wait for the pool row, read the grant once it commits, and SHALL NOT confer over it
|
||||
|
||||
#### Scenario: Two tiers on one subscription confer the higher
|
||||
|
||||
- **WHEN** a subscription carries items for Plan and Plus, two tiers of one ladder with Plus ranked higher, whatever their order on the subscription
|
||||
- **THEN** reconcile SHALL confer Plus and SHALL NOT confer Plan, logging Plan with Plus as the product that outranked it
|
||||
- **AND** a later reconcile with the items unchanged SHALL write no position, transition or entitlement row
|
||||
|
||||
#### Scenario: The reconcile confers in a fixed order
|
||||
|
||||
- **WHEN** a subscription's first reconcile confers three products, each a tier of a ladder of its own, whose items Stripe lists in an order other than their ids'
|
||||
- **THEN** reconcile SHALL confer them in ascending `product_id` order, and a second run on another subscription with the items in another order SHALL do the same
|
||||
|
||||
### Requirement: Suspension and resume propagate via sync_source_status
|
||||
|
||||
When a subscription transitions to a suspending status (`past_due`) or resumes (returns to `active`), reconcile SHALL propagate the change to the subscription's live pool provisions by calling `entitlements.sync_source_status`, rather than by ending or recreating the provision. Suspension SHALL NEVER permanently end a provision: `sync_source_status` SHALL only ever move a provision's status between `suspended` and `active` and SHALL NOT write `status = 'ended'`. If a provision is superseded by a different source while suspended (per `entitlements.confer`'s supersession, which considers `suspended` incumbents equally with `active` ones), that provision SHALL remain ended, and a subsequent resume of its original source SHALL NOT resurrect it — `sync_source_status` SHALL find no non-ended target for that source and SHALL return the empty set. A recovery reaches the item pass, which follows the resume in the same transaction, and the item pass SHALL NOT confer the subscription's product again while another plan holds the ladder (see "Subscription reconcile computes a genuine diff, grouped by product"). The resulting condition, the original subscription billing for a position it no longer holds, SHALL stay in place until no other plan holds the ladder. It SHALL be surfaced through the operator-facing audit trail and the organization page's Billing card (`plan-enrollment-administration`, "The billing card says when a subscription delivers nothing while another plan holds its ladder"), and reconcile SHALL NOT attempt to stop or refund the subscription's billing.
|
||||
|
||||
#### Scenario: past_due suspends without ending
|
||||
|
||||
- **WHEN** a subscription transitions to `past_due`
|
||||
- **THEN** the system SHALL call `entitlements.sync_source_status` with status `suspended` for the subscription's live provisions
|
||||
- **AND** no provision SHALL be marked ended as a result
|
||||
|
||||
#### Scenario: Resume reactivates a still-live suspended provision
|
||||
|
||||
- **WHEN** a `past_due` subscription returns to `active` and its provision was not superseded while suspended
|
||||
- **THEN** the system SHALL call `entitlements.sync_source_status` with status `active`
|
||||
- **AND** the provision SHALL resume active status with its existing position intact
|
||||
|
||||
#### Scenario: Resume does not resurrect a provision superseded while suspended
|
||||
|
||||
- **WHEN** a subscription's provision was ended by a competing `confer` call while the subscription was suspended, and the subscription later resumes
|
||||
- **THEN** `entitlements.sync_source_status` SHALL find no non-ended provision for that source and SHALL return the empty set
|
||||
- **AND** the subscription SHALL NOT regain the superseded position, neither through the resume nor through the item pass that follows it, while another plan holds the ladder
|
||||
- **AND** the system SHALL surface the resulting billing/entitlement mismatch via the audit trail and the Billing card's line, rather than resolving it automatically
|
||||
|
||||
#### Scenario: A recovery through the reconcile leaves the grant in place
|
||||
|
||||
- **WHEN** an operator's grant superseded the position of a `past_due` subscription, and the subscription returns to `active` and is reconciled
|
||||
- **THEN** the resume SHALL change nothing, the item pass SHALL NOT confer the subscription's product, and the grant SHALL keep delivering
|
||||
|
||||
### Requirement: Scheduled moves converge with Stripe
|
||||
|
||||
For a move Stripe fires (a period-end downgrade through a subscription schedule, a period-end cancellation through `cancel_at_period_end`), Stripe is the source of truth and the scheduled-change row is the console's record of it. Reconcile of an active subscription SHALL, in its transaction and after its product diff:
|
||||
|
||||
- mark `applied` a `scheduled` `plan_switch` row with `effective_trigger='period_end'` whose target price is the subscription's current price, since Stripe's clock, not the console's, decides when the schedule's next phase begins;
|
||||
- mark `superseded` a `scheduled` `plan_switch` row with `effective_trigger='period_end'` when the Stripe subscription has no schedule attached and its price is not the row's target;
|
||||
- mark `superseded` a `scheduled` `cancellation` row with `effective_trigger='period_end'` when Stripe's `cancel_at_period_end` is false.
|
||||
|
||||
After that transaction commits, so that no lock is held while Stripe answers, reconcile SHALL release an attached schedule whose `scheduled_from_price_id` metadata is not the subscription's current price, since its downgrade has landed or the subscription changed after it was made; a schedule without that metadata SHALL be left alone. Only when Stripe accepts the release SHALL reconcile mark `superseded`, in a second transaction that takes the subscription's lock before it writes, each `scheduled` `plan_switch` row with `effective_trigger='period_end'` that the first transaction left `scheduled` because the schedule was still attached. When Stripe refuses the release, those rows SHALL stay `scheduled`, matching the schedule Stripe still holds.
|
||||
|
||||
A row is applied when the subscription carries its target price, whether or not the product diff confers the target tier: a downgrade that lands on a ladder another plan took from the subscription is applied, and its tier is left alone (see "Subscription reconcile computes a genuine diff, grouped by product").
|
||||
|
||||
The rules SHALL apply in the order: apply a landed row, supersede a row whose schedule is gone, supersede a cleared cancellation, commit, release a spent schedule, then supersede the rows the released schedule held. Rows with `effective_trigger='term_boundary'` SHALL NOT be changed by these rules; only the console fires them.
|
||||
|
||||
Stripe enters a schedule's next phase with a new subscription item rather than repricing the old one. Reconcile SHALL therefore set `ended_at` on every core subscription item of the subscription whose Stripe item the subscription no longer carries, and SHALL keep the row, which invoice lines reference. Every reader of a subscription's current items (the member's price line, the switch, the sweeper, the operator's monthly recurring figure) SHALL skip ended items. When a Stripe item arrives without an id, reconcile SHALL end nothing on that pass.
|
||||
|
||||
#### Scenario: A fired downgrade closes its row
|
||||
|
||||
- **WHEN** Stripe enters a downgrade schedule's next phase and reconcile runs on the resulting subscription event, on a ladder no other plan took from the subscription
|
||||
- **THEN** the `plan_switch` row SHALL be marked `applied` and the tier SHALL move to the target
|
||||
|
||||
#### Scenario: A downgrade that lands while a grant holds the ladder closes its row
|
||||
|
||||
- **WHEN** an operator's grant superseded the position of a subscription with a downgrade scheduled, and Stripe enters the schedule's next phase, whether the reconcile runs on the subscription event or from the sweeper's period-end check
|
||||
- **THEN** the `plan_switch` row SHALL be marked `applied`, because Stripe carries its target price, and the spent schedule SHALL be released after the commit
|
||||
- **AND** the target tier SHALL NOT be conferred, and the grant SHALL keep the ladder
|
||||
|
||||
#### Scenario: A schedule released outside the console supersedes its row
|
||||
|
||||
- **WHEN** an operator releases a downgrade's schedule in the Stripe Dashboard and reconcile next runs
|
||||
- **THEN** the `plan_switch` row SHALL be marked `superseded` and the member SHALL keep the current tier
|
||||
|
||||
#### Scenario: A cancellation cleared in Stripe supersedes its row
|
||||
|
||||
- **WHEN** `cancel_at_period_end` is false on a subscription whose period-end cancellation row is `scheduled`
|
||||
- **THEN** reconcile SHALL mark the row `superseded`, and the sweeper SHALL NOT fire it
|
||||
|
||||
#### Scenario: An upgrade approved later releases a stale schedule
|
||||
|
||||
- **WHEN** a member with a downgrade scheduled approves an upgrade's payment on Stripe's page and the subscription event arrives
|
||||
- **THEN** reconcile SHALL release the schedule, whose scheduled-from price is no longer the subscription's price, and mark the `plan_switch` row `superseded`
|
||||
|
||||
#### Scenario: A landed downgrade's schedule is released
|
||||
|
||||
- **WHEN** Stripe enters a downgrade schedule's next phase and reconcile runs
|
||||
- **THEN** reconcile SHALL mark the row `applied` and release the schedule, so a cancellation in the new period is not refused
|
||||
|
||||
#### Scenario: A landed downgrade's replaced item is ended
|
||||
|
||||
- **WHEN** a member on Pro at $30.00 a month has a downgrade to Plus at $12.00 land, and Stripe's subscription now carries a new item at Plus's price
|
||||
- **THEN** reconcile SHALL set `ended_at` on the Pro item's row and create the Plus item's row
|
||||
- **AND** the current plan's card SHALL read "$12.00/month", and the operator's monthly recurring figure SHALL count $12.00 for the subscription
|
||||
|
||||
#### Scenario: A spent schedule is released after the reconcile commits
|
||||
|
||||
- **WHEN** reconcile finds a spent schedule of the console's attached to the subscription
|
||||
- **THEN** its transaction SHALL have committed before the release is sent, so another reconcile of the subscription can take the subscription's lock while Stripe answers
|
||||
- **AND** the `plan_switch` row the schedule held SHALL be marked `superseded` only after Stripe accepts the release, in a transaction that waits for the subscription's lock
|
||||
|
||||
#### Scenario: A refused release keeps its row
|
||||
|
||||
- **WHEN** a member with a downgrade scheduled approves an upgrade's payment on Stripe's page, and Stripe refuses the release of the stale schedule when reconcile runs
|
||||
- **THEN** the `plan_switch` row SHALL stay `scheduled` and the member SHALL be on the upgraded tier
|
||||
- **AND** the next reconcile SHALL release the schedule again and mark the row `superseded`
|
||||
|
||||
#### Scenario: A row left scheduled after its schedule's release is superseded next time
|
||||
|
||||
- **WHEN** Stripe accepted the release of a spent schedule and the transaction that supersedes the row it held did not commit
|
||||
- **THEN** the `plan_switch` row SHALL stay `scheduled`, and the reconcile's other writes SHALL stay committed
|
||||
- **AND** the next reconcile, finding no schedule attached, SHALL mark the row `superseded`
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset; tests meet the same complexity limit as production code, so a table runs its rows through a named function. A Stripe test drives `stripetest.MockBackend` with the API state Stripe would return, never a hand-built event payload, and runs without `t.Parallel()`. A test that asserts that no Stripe call was made compares the mock's recorded calls before and after the act. A test of a lock holds one transaction open and waits for the block with `dbtest.AwaitLockWait`, never a sleep. A test of an order feeds its input in more than one order. A page's test compares the whole sentence with its entry in the design's Copy table.
|
||||
|
||||
This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves, which has landed: the reconcile takes the pool row in both branches and the traces already hold its lock statement. Sections 1 to 6 are one commit each, and their items are that commit's parts; section 7 is not a commit. Each section passes `make test` alone with the test stack up, which runs `make lint` first (`filler-copy`, `em-dash-copy`, `screen-coverage`, the notebook-citations check and `deadcode` among its checks) and then every package one at a time (`-p 1`). A query edit is followed by `make sqlc-generate`. A migration is applied by `test/reset-test-db.sh` before the section's tests run (`make test` runs it). No golden, route table or test result changes except where a section's items name it; any other change is a stop. A golden is rewritten with `-update` only where an item says so, and its diff is read before the commit. Section 4 adds a test that runs a reconcile on a goroutine, so it runs its named test with `go test -race -p 1 -count=1` after `test/reset-test-db.sh`. Section 6 changes a template, so it runs `make screens` after its tests pass. Section 6 starts only after `suspended-plans-stay-held`'s organization-page section is committed, because both edit `internal/server/operator_enrollment.go`, `operator_enrollment.html` and the `org_enrollment` goldens. The Copy table is approved by the maintainer before section 6 starts; a string that differs from its approved entry is a stop. A script's usage is learned from its header or source, never by running it.
|
||||
|
||||
**Breaks.** Each section's list is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the section's commit, from the repository root, with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run; the tests are new or rewritten in the section, so there is no old side. A break of Go code or of a generated sqlc file is a test record (`file`, `old`, `new`, `pkg`, `run_new`, `want_new`); a break of a sqlc query edits the generated `.sql.go` file beside the querier, whose constant the test runs, not the `.sql` file. A break of a migration's SQL is a command record, because the shared test database runs the view or the function as it was migrated: `cmd` is `(cd test && ./reset-test-db.sh) && go test -p 1 -count=1 -run '<pattern>' <pkg>`, `exit` 1, and the list carries `"prepare": "cd test && ./reset-test-db.sh"`, so each record starts from the restored migration and the last `prepare` restores it. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name; for a golden it is `<path> differs from what the code produced`, which `internal/testkit/golden/golden.go:42` prints only on a mismatch, with the path as the test passes it. Every record must be CAUGHT. An `old` text below is the code as the section's items write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped. A break that no test of its own section sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
|
||||
|
||||
## 1. Characterize the take-back
|
||||
|
||||
- [x] 1.1 `internal/fulfillment/reconcile_trace_db_test.go` (design, Tests): three cases join `reconcileCases`, each wanting today's answer:
|
||||
- `grant_over_suspended_recovery`: `_, plus, _ := onPlan(e, true)`; the mock's subscription set `past_due` and reconciled (`test:past_due`); `e.w.Issue(e.org, plus.product, world.IssueOpts{})`; the subscription set `active`; `e.start()`; the reconcile of `stripe:customer.subscription.updated:evt_3`. Today the resume touches nothing and the item pass confers Plan over the grant.
|
||||
- `second_subscription_over_first`: `onPlan(e, true)` (the first subscription on Plan); a second Stripe subscription of the same customer, id `sub_trace_b_` and the World's suffix, normalized `<stripe-subscription-b>`, with one item at Plus and a Stripe item id of its own, reconciled (`test:second`), which supersedes the first's Plan; the mock's subscription set back to the first, unchanged (the mock returns its `Sub` for every id, `internal/stripetest/mock.go:34`, so the case sets it to the subscription it reconciles before each reconcile); `e.start()`; the first's reconcile (`stripe:customer.subscription.updated:evt_3`). Today it confers Plan over the second subscription's Plus. The environment gains the second subscription through a helper beside `e.sub` and a `reconcileID(id, reason)` beside `reconcile`.
|
||||
- `two_tiers_one_ladder`: `plan, plus, _ := onPlan(e, true)`; the subscription's items set to Plan (`si_a`) and Plus (`si_b`) and reconciled (`test:two-items`), which confers Plus over Plan whatever the map order, because the live read made before the loop finds Plan live and makes it a `noop`; `e.start()`; the reconcile of `stripe:customer.subscription.updated:evt_3`. Today it confers Plan over Plus (a downgrade by the webhook).
|
||||
- [x] 1.2 `internal/fulfillment/scheduled_change_trace_db_test.go`: the case `downgrade_lands_while_displaced`, built as `downgrade_landed`: `plan, top := downgraded(e)`; `e.w.Issue(e.org, top.product, world.IssueOpts{})`, which supersedes the subscription's Plus; `e.onTierWith(plan)`; `e.start()`; the reconcile of `stripe:customer.subscription.updated:evt_2`. Today it confers Plan over the grant, applies the `plan_switch` row and releases the schedule after the commit.
|
||||
- [x] 1.3 The four goldens are written with `-update` on this commit's tree and read as the defect does today: each trace ends its item pass with a `core.confer`, and its rows show the grant's or the second subscription's provision `ended` and an `end` and a typed row by `webhook`. The commit message names them as the characterization of #212 and of the two-tier and two-subscription forms in its comment.
|
||||
- [x] 1.4 `TestReconcile_SupersededWhileSuspendedNotResurrected` (`internal/fulfillment/reconcile_conferral_test.go:221-274`) recovers through the reconcile (design D7): the direct `entQ.SyncSourceStatus` call is replaced by the mock's subscription set `active` and `fulfillment.ReconcileSubscription(..., "test:recovered")`. It keeps "incumbent status = %q, want ended (no resurrection)"; the empty-set assertion becomes "the subscription holds %d live provisions after the recovery, want 1" (a count of its provisions not ended); and the grant's assertion wants today's answer, "grant tier attach after the recovery = %d, want 0". Its doc comment says that it drives the recovery through the reconcile, so the item pass runs, and that the last assertion pins #212 until section 4 flips it.
|
||||
- [x] 1.5 Breaks:
|
||||
- `GetLivePoolProvisionsBySubscriptionID` reading ended provisions too (in `internal/entitlements/pool_provisions.sql.go`, the constant's `subscription_id = $1 AND status <> 'ended'` replaced by `subscription_id = $1`), so a superseded provision reads live and is a `noop`: caught by `TestReconcileTrace/grant_over_suspended_recovery`, `want_new` `testdata/reconcile_trace/grant_over_suspended_recovery.golden differs from what the code produced`; by `TestReconcileTrace/second_subscription_over_first` and `TestReconcileTrace/two_tiers_one_ladder`, with their own paths (three records, one edit); and by `TestReconcile_SupersededWhileSuspendedNotResurrected`, `want_new` `grant tier attach after the recovery = 1, want 0`;
|
||||
- `applyLandedDowngrades` applying nothing (`if !downgradeRowLanded(r, current) {` replaced by `if true {`): caught by `TestScheduledChangeTrace/downgrade_lands_while_displaced`, `want_new` `testdata/scheduled_change_trace/downgrade_lands_while_displaced.golden differs from what the code produced`.
|
||||
|
||||
## 2. The item pass writes in product id order
|
||||
|
||||
- [ ] 2.1 `internal/fulfillment/reconcile.go` (design D6): `sortedProducts(desired map[string]int32) []string` returns `slices.Sorted(maps.Keys(desired))`; `convergeConferrals` ranges over it and reads each quantity from `desired`. The doc comment says why: a loop that writes several rows walks a stated order (`docs/go-conventions.md`, "Output order"), and Stripe's item order can change on an edit; the ids are UUIDv7, so the order is the products' creation order.
|
||||
- [ ] 2.2 `internal/fulfillment/trace_db_test.go`: `sortConferralRuns` and `conferralWrites` are deleted, since the order they hid is now fixed; `lines()` returns `foldMaterialize(e.stmts.Names())` and its comment says it folds each materialization into one line. No golden changes: no committed golden holds a run of two conferral statements.
|
||||
- [ ] 2.3 The trace case `three_ladders_first_sight` (`reconcile_trace_db_test.go`): tiers A, B and C created in that order, so their product ids ascend, each the one tier of a ladder of its own (normalized `<ladder:A>`, `<ladder:B>`, `<ladder:C>`), and a first reconcile of a subscription whose items Stripe lists as C, A, B. Its golden, written with `-update`, lists the three `initiate` rows in the order A, B, C.
|
||||
- [ ] 2.4 Test. `TestSortedProducts` (a new `internal/fulfillment/reconcile_order_test.go`, package `fulfillment`, no database): five UUIDv7 ids generated in order, maps built by inserting them forward, last to first, and in one fixed shuffle, each called fifty times and each result compared with the ids in generated order; a failure reads `sortedProducts returned the products out of id order: %v`.
|
||||
- [ ] 2.5 Breaks:
|
||||
- the map's order (`slices.Sorted(maps.Keys(desired))` replaced by `slices.Collect(maps.Keys(desired))`): caught by `TestSortedProducts`, `want_new` `sortedProducts returned the products out of id order`. `three_ladders_first_sight` sees the same break on most runs but not on every run, because one of the map's orders is the sorted one, so it carries no record; the commit message says so;
|
||||
- the deletion of `sortConferralRuns` has no break: it removes test code whose effect no golden held, which the unchanged goldens show.
|
||||
|
||||
## 3. One statement of a supersession pair
|
||||
|
||||
- [ ] 3.1 `internal/db/migrations/00033_supersession_pairs.sql` (design D1, Migration plan): a header comment in the style of `00023` and `00025` saying what the view pairs, that `core.grant_replacements` now reads it with its rows unchanged, that it reads no `reason` so the reader role reads it, and that #179 would change only its body; `CREATE VIEW core.supersession_pairs` and `CREATE OR REPLACE VIEW core.grant_replacements` as the design writes them; `GRANT SELECT ON core.supersession_pairs TO core_reader, core_writer, core_owner;`. Down: `CREATE OR REPLACE VIEW core.grant_replacements` with `00023`'s body, then `DROP VIEW core.supersession_pairs;`. Every multi-statement section is wrapped in `StatementBegin` and `StatementEnd` (`docs/database-management.md`).
|
||||
- [ ] 3.2 `internal/migrate/testdata/console-reader-columns.txt`: the view's five columns, `effective_at`, `ended_provision_id`, `plan_ladder_id`, `pool_id`, `successor_provision_id`, each marked `read`, written by `go test ./internal/migrate/ -run TestConsoleReaderColumns -count=1 -update` and checked in the diff to be those five lines only.
|
||||
- [ ] 3.3 `docs/models/plan-ladders-transitions.md`, "Where this lives": a row for `core.supersession_pairs` (the one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason`; `00033_supersession_pairs.sql`), and the `core.grant_replacements` row says that it reads its pairs from that view (`00023_grant_resumption.sql`; `00033_supersession_pairs.sql`).
|
||||
- [ ] 3.4 Tests, `internal/entitlements/supersession_pairs_test.go` (package `entitlements_test`, over `newPlusProWorld`, in the world's rolled-back transaction):
|
||||
- `TestSupersessionPairs_NameBothProvisions`: a subscription confers Plus (`conferSub`), an operator's grant of Pro is conferred over it; the view has exactly one row in the pool, on Wiki, with the subscription's provision ended, the grant's provision as successor and the conferral's instant. A failure reads `pairs = %+v, want one on Wiki from the subscription's provision to the grant's`.
|
||||
- `TestSupersessionPairs_ARestorationIsNotAPair`: a grant of Plus whose `valid_until` has passed (`grantOpts{validUntil: p.now.Add(-time.Hour)}`), conferred and expired (`p.expire(g)`), so the floor restores Free in the same transaction by the same actor (`system`, no id, at `now()`, the one path where `from_rank` alone decides); no row names the default's provision as successor. Its precondition is `p.holdsLadder(p.wiki) == p.free`. A revocation cannot test this, because its `end` row is written by the operator and its floor row by the system, so the two never pair whether or not the view reads `from_rank`. A failure reads `the floor's restoration is paired with the expired grant's end`.
|
||||
- `TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder`: a product that is a tier of Wiki and of a second ladder, Docs, conferred from a subscription, then a grant of Pro (Wiki only) over it; the view has a row on Wiki and none on Docs. A failure reads `a pair on Docs, where the grant wrote no rung`.
|
||||
- The six `TestGrantReplacements_*` tests, the `TestResumableGrants_*` tests and the `TestGrantWaits_*` tests in `internal/entitlements/grant_waits_test.go` pass unedited, which is the proof that `core.grant_replacements` keeps its rows; `TestConsoleReaderColumns` and `TestRuntimeRolePrivileges` pass with the view.
|
||||
- [ ] 3.5 Breaks (command records, the migration file `internal/db/migrations/00033_supersession_pairs.sql`):
|
||||
- the view without `AND n.from_rank IS NOT NULL`: caught by `TestSupersessionPairs_ARestorationIsNotAPair` (`./internal/entitlements`), `want_new` `the floor's restoration is paired with the expired grant's end`;
|
||||
- the view without `AND n.plan_ladder_id = e.plan_ladder_id`: caught by `TestSupersessionPairs_AWholeBundleEndPairsOnlyTheContestedLadder`, `want_new` `a pair on Docs, where the grant wrote no rung`;
|
||||
- `core.grant_replacements` without `AND ng.extends_grant_id IS DISTINCT FROM ep.grant_id`: caught by `TestGrantReplacements_AnExtensionIsNotAReplacement`, `want_new` `want none for an extension`;
|
||||
- the `GRANT SELECT ON core.supersession_pairs` line removed: caught by `TestRuntimeRolePrivileges` (`./internal/db`), `want_new` `console_runtime cannot SELECT from core.supersession_pairs`;
|
||||
- the view selecting `e.reason` as a sixth column, which makes the reader script withhold it whole: caught by `TestConsoleReaderColumns` (`./internal/migrate`), `want_new` `core.supersession_pairs.pool_id is marked read, but console_reader does not read it`.
|
||||
|
||||
## 4. The reconcile leaves a ladder alone while another plan holds it
|
||||
|
||||
- [ ] 4.1 `internal/db/migrations/00034_subscription_displacements.sql` (design D1, D2): a header comment saying what the function returns (the ladders of the pool on which the subscription is displaced and another plan holds the ladder, with the displaced product, the instant and the holder), its two rules, that a default-sourced position never holds a ladder against a subscription, that it is the one statement the reconcile and the organization page read, and that it reads no `reason`; the function as the design writes it; `GRANT EXECUTE ON FUNCTION core.subscription_displacements(UUID, UUID) TO core_reader, core_writer, core_owner;`. Down: `DROP FUNCTION core.subscription_displacements(UUID, UUID);`.
|
||||
- [ ] 4.2 Queries, then `make sqlc-generate`:
|
||||
- `ListSubscriptionDisplacements :many` in `internal/entitlements/queries/pool_provision_transitions.sql`: `d.plan_ladder_id::uuid AS plan_ladder_id`, `pl.name AS ladder_name`, `d.displaced_product_id::uuid AS displaced_product_id`, `d.displaced_at::timestamptz AS displaced_at`, `d.holder_provision_id::uuid AS holder_provision_id`, `d.holder_source::text AS holder_source`, from `core.subscription_displacements(sqlc.arg(subscription_id)::uuid, sqlc.arg(pool_id)::uuid) AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)` joined to `core.plan_ladders pl` on the ladder, ordered `pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid`. Its comment says that the function is the rule's one statement, that the aliases repeat the function's columns because sqlc cannot read a function's `RETURNS TABLE` (as `ListResumableGrantsHeldBy` does), and that the reconcile calls it under the pool row.
|
||||
- `ListConferralShapes :many` in `internal/entitlements/queries/pool_provision_ladders.sql`: `s.product_id`, `s.plan_ladder_id`, `s.rank` from `core.product_conferral_shapes s` where `s.product_id = ANY(sqlc.arg(product_ids)::uuid[])` and `s.plan_ladder_id IS NOT NULL`, ordered `s.product_id, s.plan_ladder_id`. Its comment says that the rendezvous the reconcile holds keeps the ranks from changing under it (`docs/database-locks.md`, the rendezvous).
|
||||
- [ ] 4.3 `internal/fulfillment/reconcile.go` (design D2, D13):
|
||||
- `Rung` (a ladder id, `LadderID`, and a rank, `Rank`), `RungsByProduct(rows []entitlements.ListConferralShapesRow) map[string][]Rung`, reading no row, and `conferralRungs(ctx, entQ, products []string) (map[string][]Rung, error)`, reading `ListConferralShapes` once and calling `RungsByProduct`, each product's rungs in ladder id order. `Rung`, `RungsByProduct` and `DisplacedProducts` are exported because the organization page calls them too (section 6, design D4): a rule that a page shows and the reconcile enforces is one function that reads no row (`docs/go-conventions.md`).
|
||||
- `Displacement` (the ladder id `LadderID`, the ladder name, `DisplacedProductID`, the holder's provision id and its source), built from the rows of either displacement query, and `DisplacedProducts(missing []string, rungs map[string][]Rung, displacements []Displacement) map[string]Displacement`, reading no row: it indexes the displacements by ladder, keeping the first row of each, and maps each missing product to the displacement of the first of its rungs, in its rung order, whose ladder is indexed:
|
||||
|
||||
```go
|
||||
for _, p := range missing {
|
||||
for _, r := range rungs[p] {
|
||||
if d, ok := byLadder[r.LadderID]; ok {
|
||||
left[p] = d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `convergeConferrals` gains the logger and, after its live read, computes `order := sortedProducts(desired)`, reads `conferralRungs(order)` and `ListSubscriptionDisplacements` (subscription, pool), takes as missing the products of `order` with no live provision, computes `left := DisplacedProducts(missing, rungs, displacements)`, logs each left-out product in `order` once at Info (Copy, log row L1: `reconcile: product not conferred; another plan holds its ladder`, attributes `subscription_id`, `product_id`, `plan_ladder_id`, `holder_provision_id`), and calls `conferOrBound` for every other product of `order`, its loop skipping a left-out one with `if _, out := left[productID]; out { continue }`. `endSurplusConferrals` is unchanged: a left-out product stays desired. The doc comments of `reconcileItems` and `convergeConferrals` state the rule in one sentence and point at `core.subscription_displacements`. The functions stay under the complexity limit by splitting, not by a lint exception.
|
||||
- [ ] 4.4 `internal/fulfillment/trace_db_test.go`: `nameWritten` names each provision of the fixture's pool, `<provision:{product} {source}>`, with ` 2`, ` 3` and on after the first of one product and source in activation order, so that L1's `holder_provision_id` prints a name and `check`'s unnamed-id guard passes. It also names the core subscription the second Stripe subscription of `second_subscription_over_first` makes `<subscription-b>`, where it names every other core subscription of the account `<subscription>` (`trace_db_test.go:315-320`), so that the flipped golden's L1 line says which subscription was left alone.
|
||||
- [ ] 4.5 Tests, `internal/entitlements/subscription_displacements_test.go` (package `entitlements_test`, over `newPlusProWorld` or a world with the ladders a test names, in the world's transaction, reading through `ListSubscriptionDisplacements`); each failure prints the rows it got:
|
||||
- `TestSubscriptionDisplacements_AGrantOverTheSubscription`: a subscription on Plus, a grant of Pro over it: one row, on Wiki, with the ladder's name, displaced product Plus, the grant's provision as holder and source `grant`. A failure reads `displacements = %+v, want one on Wiki naming the grant's provision`.
|
||||
- `TestSubscriptionDisplacements_AnExtensionStillHolds`: the same, then the grant extended (`ExtendGrantTx`): one row naming the extension's provision. A failure reads `displacements = %+v, want the extension's provision as the holder`.
|
||||
- `TestSubscriptionDisplacements_TheDefaultDoesNotHold`: the grant revoked, so the floor confers Free: no row. A failure reads `displacements = %+v, want none while only the default holds Wiki`.
|
||||
- `TestSubscriptionDisplacements_ASuccessorWithNoFromSideIsNotADisplacement`: a subscription on Plus, Plus removed from Wiki by `removeTier` (a bare `DELETE FROM core.plan_ladder_tiers`, which ends no rung), then a grant of Pro, whose conferral supersedes the subscription's still-live rung and writes an `initiate` with no `from_rank`, because the deleted tier has no rank: no row. A failure reads `displacements = %+v, want none: the successor's row has no from side`. The real `RemoveTier` path (end, align, floor) is not driven here.
|
||||
- `TestSubscriptionDisplacements_AnEpisodeTheSubscriptionEndedIsNotADisplacement`: `conferSub(sub, p.plus)`, then `p.endProvision(<that provision>)` as the item pass ends a dropped product, then a grant of Pro conferred at the next tick (an `initiate` on the vacant ladder): no row. A failure reads `displacements = %+v, want none: the subscription ended its own episode`.
|
||||
- `TestSubscriptionDisplacements_TheSubscriptionsOwnSwitch`: a subscription on Plus, then on Pro: no row; then a grant of Max: one row with displaced product Pro. Failures read `displacements = %+v, want none after the subscription's own switch` and `displacements = %+v, want one with Pro displaced`.
|
||||
- `TestSubscriptionDisplacements_TheLatestEpisodeDecides`: a grant over the subscription, the grant's provision ended at a tick (`p.endProvision`, so the ladder is left vacant and no floor runs; a revocation would confer Free at `now()`, and conferring Plus over it at a tick before `now()` backdates under the incumbent and raises CF001), the subscription conferring Plus again (`conferSub`, an `initiate`): no row; then a second grant, of Max, conferred at the next tick: exactly one row, naming the second grant's provision. Failures read `displacements = %+v, want none once the subscription holds Wiki again` and `displacements = %+v, want the second grant's provision as the holder`.
|
||||
- `TestSubscriptionDisplacements_ASecondSubscription`: subscription S1 on Plus, subscription S2 conferring Pro over it: one row for S1 naming S2's provision with source `subscription`, and none for S2. Failures read `displacements of the first subscription = %+v, want one naming the second's provision` and `displacements of the second subscription = %+v, want none`.
|
||||
- `TestSubscriptionDisplacements_OneLadderOfABundle`: a product that is a tier of ladders Docs and Site, conferred from a subscription, and a grant of a product that is a higher tier of Site only: one row, on Site, and none on Docs. A failure reads `displacements = %+v, want one on Site and none on Docs`.
|
||||
- No test claims the pool scoping: the function filters by the pool three times (the episodes, the pairs, the holders), and a subscription's provisions all sit in its organization's default pool, so no single filter's removal changes a row (design, Tests).
|
||||
- [ ] 4.6 Tests, a new `internal/fulfillment/displaced_subscription_test.go` (package `fulfillment_test`, over `seedFixture`'s ladder with `secondTier` and `addTier` where named, the grants issued through `issueGrant`, revocations and extensions through a `BeginMaterializing` transaction as `grant_resumption_test.go` revokes, every Stripe answer from the mock):
|
||||
- T1 `TestReconcile_AGrantOverTheSubscriptionKeepsTheLadder`: the subscription on the fixture's tier, an unmarked grant of the second tier over it; `poolRows` and the mock's calls recorded; a reconcile with the items unchanged, then one with the item's quantity at 3. After each: `the grant stopped delivering after the subscription's reconcile`, `the subscription holds %d live provisions, want 0`, `the reconcile wrote %d rows` (from `poolRows`), and no Stripe call beyond the subscription's read.
|
||||
- T1b `TestReconcile_AGrantOfTheSubscriptionsOwnTierKeepsTheLadder`: a grant of the fixture's own tier (a `transfer`), then a reconcile: `the grant of the subscription's own tier stopped delivering`.
|
||||
- T2: `TestReconcile_SupersededWhileSuspendedNotResurrected` (item 1.4) flips on purpose: `grant tier attach after the recovery = %d, want 1`, and `the subscription holds %d live provisions after the recovery, want 0`; its doc comment drops the pin.
|
||||
- T3 `TestReconcile_AMarkedGrantOverTheSubscriptionIsNotReplaced`: a marked grant over the subscription, a reconcile: the grant delivers, and `core.grant_replacements` holds no row for the grant and the subscription: `core.grant_replacements pairs the marked grant with the subscription`.
|
||||
- T4 `TestReconcile_TheSubscriptionDeliversAgainOverTheDefault`: `addRankZeroDefault` and `setPersonalDefault` on the fixture's ladder (reset in cleanup), the subscription, a grant over it, the grant revoked (the floor confers the default), a reconcile: `the subscription's tier holds %d positions, want 1: the subscription did not deliver again over the default`.
|
||||
- T5 `TestReconcile_TheSubscriptionDeliversAgainOnAVacantLadder`: T4's default on the fixture's ladder, and the pool also holding a grant of a product on a ladder of its own (`addLadder`), so the floor confers nothing when the ladder empties; the subscription, a grant over it, the grant revoked (the ladder is vacant), a reconcile: `the subscription's tier holds %d positions, want 1: the subscription did not deliver again on the vacant ladder`.
|
||||
- T6 `TestReconcile_AGrantIssuedOverTheDefaultKeepsTheLadder`: T4's state before its reconcile, then a grant of the second tier over the default, then a reconcile: `the grant issued over the default stopped delivering`.
|
||||
- T6b `TestReconcile_AnExtendedGrantKeepsTheLadder`: a grant over the subscription, extended (`ExtendGrantTx`), then a reconcile: `the extended grant stopped delivering`.
|
||||
- T7 `TestReconcile_ASubscriptionBoughtOverAGrantSupersedesIt`: a grant of the fixture's tier, then a subscription of a higher tier reconciled over it, as `boughtOver` builds it but without its precondition, whose own failure would hide this test's message; then a second reconcile: `the subscription bought over the grant holds %d positions, want 1` after each, and the second writes no row.
|
||||
- T8 `TestSweep_ADowngradeThatLandsWhileDisplacedIsAppliedAndNotConferred`: `onTier2`; a downgrade to the fixture's tier through `SwitchPlan`; a grant of a third tier (`addTier`) over the subscription; the row made due, as `TestSweeperLeavesAPeriodEndDowngradeToStripe` makes it; the mock's subscription on the fixture's tier with the schedule kept; `SweepDueScheduledChanges`. `applied downgrades = %d, want 1`, `the spent schedule was not released`, `the landed tier holds %d positions, want 0: the downgrade was conferred over the grant`, and the grant delivers.
|
||||
- T9 `TestReconcile_ALaterSubscriptionKeepsTheLadder`: subscription S1 on the fixture's tier; a second Stripe subscription S2 of the same customer on the second tier, its item with a Stripe item id of its own, reconciled (it supersedes S1); S1 reconciled; then S2 and S1 again. The mock returns its `Sub` for every id (`internal/stripetest/mock.go:34`), so the test sets `mock.Sub` to the subscription it reconciles before each reconcile. `the later subscription's tier holds %d positions, want 1` after each, and the last two write no row: `the reconciles after the second subscription wrote %d rows`. A last step cancels S2 and reconciles it, then reconciles S1: `the first subscription's tier holds %d positions, want 1 after the second ended`.
|
||||
- T11 `TestReconcile_AGrantIssuedWhileTheReconcileWaitsIsSeen`: the subscription on the fixture's tier; a side transaction from `beginMaterializingSide` (the helper `reconcile-takes-pool-lock` adds beside `beginSide`, over `BeginMaterializing`) issues a grant of a third tier through `ConferGrantTx` and stays open; the mock's subscription moved to the second tier; the reconcile started in the background, waited on the side (`awaitBlockedBy`), the side committed, the reconcile ended. `the grant issued while the reconcile waited stopped delivering`, and `the changed item's tier holds %d positions, want 0`.
|
||||
- T12 `TestReconcile_ABundleDisplacedOnOneLadderIsLeftAlone`: a product B that is a tier of the fixture's ladder and of a second ladder created after it, and a product G that is a tier of the second ladder only, ranked above B; the subscription on B, a grant of G over it (which ends B's rungs on both ladders), a reconcile: `the bundle holds %d positions, want 0`, `the grant on the second ladder stopped delivering`, and `the fixture's ladder holds %d positions, want 0`.
|
||||
- T13 `TestReconcile_AnItemBackOnALadderItLeftSupersedesTheGrant`: the subscription on the fixture's tier; its item moved to an add-on (`addPriced`) and reconciled, which ends the tier as a surplus; a grant of the second tier on the vacant ladder; the item moved back to the tier and reconciled: `the subscription's tier holds %d positions, want 1`. The subscription ended its own episode, so it is not displaced.
|
||||
- `TestDisplacedProducts` (`internal/fulfillment/reconcile_order_test.go`, package `fulfillment`, no database), rows through `displacedProductsRow`: `a missing product on a displaced ladder is left out`; `a missing product on another ladder is kept`; `a lower tier of the displaced ladder is left out` (the displacement names another product, as F16's does); `a bundle whose second rung is displaced is left out`; `an add-on is kept`. A failure reads `DisplacedProducts(%s) = %v, want %v`, with the row's name.
|
||||
- [ ] 4.7 Goldens:
|
||||
- flipped on purpose: `reconcile_trace/grant_over_subscription`, `grant_over_suspended_recovery`, `second_subscription_over_first` and `scheduled_change_trace/downgrade_lands_while_displaced`: no `core.confer` in the item pass, the grant's or the second subscription's provision stays `active`, no `end` or typed row by `webhook`, and L1 in the logs with the holder's provision named; `second_subscription_over_first` also reads the second core subscription as `<subscription-b>` (item 4.4). `downgrade_lands_while_displaced` keeps its applied row and its release; `grant_over_subscription`'s case comment ("The fix flips this golden") is rewritten to say what the case now pins.
|
||||
- every golden of the three directories whose trace holds the item pass's `GetLivePoolProvisionsBySubscriptionID` gains `ListConferralShapes` and `ListSubscriptionDisplacements` directly after the first `GetLivePoolProvisionsBySubscriptionID` of the trace (every item-pass golden holds it twice: the convergence read and `endSurplusConferrals`' re-read), and no other line; `two_tiers_one_ladder` changes by those two lines only, since its choice is section 5's.
|
||||
- [ ] 4.8 Docs (`CONTRIBUTING.md`, "Docs move with the code"):
|
||||
- `docs/models/plan-ladders-transitions.md`: invariant 20, **[db-code]** and **[app]**: a subscription's reconcile does not take back a ladder another plan took from it; while a position of a non-default source other than the subscription holds a ladder on which the subscription's latest episode ended in a supersession by another source, the reconcile confers none of the subscription's products whose shape names that ladder, the subscription keeps billing and delivers nothing there, and its next reconcile after no such plan holds the ladder confers it again; `core.subscription_displacements` is the one statement. A "Where this lives" row for `core.subscription_displacements` (`00034_subscription_displacements.sql`), and the "Stripe-driven convergence" row names the rule. Invariant 7's "invariant 18 says when it delivers again" gains "and invariant 20 says when a superseded subscription does".
|
||||
- `docs/models/payments-billing.md`: the Reconciler row says that a desired product whose ladder another plan took from the subscription and holds is left alone, logged, while the subscription keeps billing, and that the reconcile writes in product id order under the pool row.
|
||||
- `docs/stripe.md`, "Plan changes": a bullet **A grant over a paid plan.**: an operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked; the subscription keeps billing and delivers nothing there, and the console changes nothing at Stripe; once only the organization type's default holds the ladder, or nothing does, the subscription's next reconcile delivers its tier again; a new purchase over a grant still replaces the grant.
|
||||
- `docs/plan-management.md`, "Issue Grant", the system's list: a bullet that a grant superseding a tier a subscription pays for keeps the ladder while it lasts, the subscription billing and delivering nothing there, and that the subscription delivers again at its next reconcile after the grant ends.
|
||||
- `docs/database-locks.md`, "Subscription reconcile", Active: the live read is followed by the shapes read and the displacement read, under the pool row.
|
||||
- [ ] 4.9 `go test -race -p 1 -count=1 -run '^TestReconcile_AGrantIssuedWhileTheReconcileWaitsIsSeen$' ./internal/fulfillment` passes after `test/reset-test-db.sh`; the commit message records it.
|
||||
- [ ] 4.10 Breaks:
|
||||
- `convergeConferrals` conferring the left-out products (`if _, out := left[productID]; out {` replaced by `if false {` in its loop), one edit and several records: caught by T1, `want_new` `the grant stopped delivering after the subscription's reconcile`; by T1b, `want_new` `the grant of the subscription's own tier stopped delivering`; by T2, `want_new` `grant tier attach after the recovery = 0, want 1`; by T3, `want_new` `core.grant_replacements pairs the marked grant with the subscription`; by T6b, `want_new` `the extended grant stopped delivering`; by T12, `want_new` `the grant on the second ladder stopped delivering`; and by `TestReconcileTrace/grant_over_subscription`, `want_new` `testdata/reconcile_trace/grant_over_subscription.golden differs from what the code produced`;
|
||||
- `DisplacedProducts` matching the product instead of the ladder (`if d, ok := byLadder[r.LadderID]; ok {` replaced by `if d, ok := byLadder[r.LadderID]; ok && d.DisplacedProductID == p {`): caught by T8, `want_new` `the downgrade was conferred over the grant`, and by `TestDisplacedProducts`, `want_new` `DisplacedProducts(a lower tier of the displaced ladder is left out) =`;
|
||||
- `DisplacedProducts` reading the first rung only (`for _, r := range rungs[p] {` replaced by `for _, r := range rungs[p][:min(1, len(rungs[p]))] {`): caught by T12, `want_new` `the grant on the second ladder stopped delivering`, and by `TestDisplacedProducts`, `want_new` `DisplacedProducts(a bundle whose second rung is displaced is left out) =`;
|
||||
- `reconcileItems` without its `LockPools` call (item 1.1 of `reconcile-takes-pool-lock`): caught by T11, `want_new` `the grant issued while the reconcile waited stopped delivering`;
|
||||
- the log line removed: caught by `TestReconcileTrace/grant_over_subscription` (its golden prints L1);
|
||||
- command records, the migration file `internal/db/migrations/00034_subscription_displacements.sql`:
|
||||
- `AND NOT h.default_sourced` removed, so a default holds a ladder against the subscription: caught by T4 (`./internal/fulfillment`), `want_new` `the subscription did not deliver again over the default`, and by `TestSubscriptionDisplacements_TheDefaultDoesNotHold` (`./internal/entitlements`), `want_new` `want none while only the default holds Wiki`;
|
||||
- displacement alone, an ended position counted as a holder, so a ladder stays held once anything took it (`AND h.status IN ('active', 'suspended')` replaced by `AND h.status IN ('active', 'suspended', 'ended')`): caught by T5, `want_new` `the subscription did not deliver again on the vacant ladder`. The holder join is not made optional instead, because a row without a holder would fail the query's scan of `holder_provision_id` before any assertion runs;
|
||||
- the displacer's chain, the holder required to be the displacement's own successor (`WHERE hp.subscription_id IS DISTINCT FROM p_subscription_id` gaining `AND EXISTS (SELECT 1 FROM core.supersession_pairs sp2 WHERE sp2.pool_id = p_pool_id AND sp2.plan_ladder_id = d.plan_ladder_id AND sp2.effective_at = d.ended_at AND sp2.successor_provision_id = h.provision_id)`): caught by T6, `want_new` `the grant issued over the default stopped delivering`, and by `TestSubscriptionDisplacements_AnExtensionStillHolds`, `want_new` `want the extension's provision as the holder`;
|
||||
- occupancy alone (the `displaced` CTE's body replaced by `SELECT DISTINCT x.plan_ladder_id, x.product_id, x.activated_at AS ended_at FROM core.pool_provision_ladders x WHERE x.pool_id = p_pool_id AND x.status = 'active'`): caught by T7, `want_new` `the subscription bought over the grant holds 0 positions, want 1`;
|
||||
- grant successors only (`AND succ.subscription_id IS DISTINCT FROM p_subscription_id` replaced by `AND succ.grant_id IS NOT NULL`): caught by T9, `want_new` `the later subscription's tier holds 0 positions, want 1`, and by `TestSubscriptionDisplacements_ASecondSubscription`, `want_new` `want one naming the second's provision`;
|
||||
- the pair condition removed (`AND EXISTS (` ... `)` of the `displaced` CTE replaced by `AND true`), so any ended latest episode under a holder counts: caught by `TestSubscriptionDisplacements_AnEpisodeTheSubscriptionEndedIsNotADisplacement`, `want_new` `want none: the subscription ended its own episode`, and by T13, `want_new` `the subscription's tier holds 0 positions, want 1`;
|
||||
- every episode read, not the latest (`SELECT DISTINCT ON (l.plan_ladder_id)` replaced by `SELECT`): caught by `TestSubscriptionDisplacements_TheLatestEpisodeDecides`, `want_new` `want the second grant's provision as the holder`, since the subscription's two displaced episodes then give two rows;
|
||||
- the earliest episode read (`l.activated_at DESC, l.provision_ladder_id DESC` replaced by `l.activated_at ASC, l.provision_ladder_id ASC`): caught by `TestSubscriptionDisplacements_TheSubscriptionsOwnSwitch`, `want_new` `want one with Pro displaced`, since the earliest episode is Plus, which the subscription's own Pro succeeded;
|
||||
- the view of `00033_supersession_pairs.sql` without `AND n.from_rank IS NOT NULL` (a command record): caught by `TestSubscriptionDisplacements_ASuccessorWithNoFromSideIsNotADisplacement` (`./internal/entitlements`), `want_new` `want none: the successor's row has no from side`, since the grant's `initiate` then pairs with the subscription's `end` (same operator, same instant);
|
||||
- breaks no test sees, named in the commit with the reasons in the design's Tests: the function's `succ.subscription_id IS DISTINCT FROM p_subscription_id` removed, its final `WHERE hp.subscription_id IS DISTINCT FROM p_subscription_id` removed, any one of its three pool filters removed, and its `GRANT EXECUTE` removed. The comeback assertion of `TestSubscriptionDisplacements_TheLatestEpisodeDecides` (no row once the subscription holds Wiki again) is decided by the latest-episode rule and by the holder's exclusion of the subscription, each alone, so no single edit shows it; the commit message says so.
|
||||
|
||||
## 5. A subscription that pays for two tiers of one ladder confers the higher
|
||||
|
||||
- [ ] 5.1 `internal/fulfillment/reconcile.go` (design D5, D13): `outrank` (the product that outranked and the ladder they share) and `chooseTiers(products []string, rungs map[string][]Rung) (kept []string, outranked map[string]outrank)`, reading no row. It walks the products in the order `slices.SortStableFunc` gives with `func(a, b candidate) int { if c := cmp.Compare(b.top, a.top); c != 0 { return c }; return strings.Compare(a.id, b.id) }`, `top` being a product's highest rank on any of its ladders (a product on no ladder walks last and is always kept); in its loop, `if taken := shared(c, held); taken != "" {` records the first ladder a kept product already holds and that product, and leaves the product out, and otherwise the product is kept and its ladders join `held`; it returns `kept` in the order of `products`. `convergeConferrals` passes it `order` without the products D2 left out, logs each outranked product in `order` once at Warn (Copy, log row L2: `reconcile: product not conferred; a higher tier of the subscription holds its ladder`, attributes `subscription_id`, `product_id`, `plan_ladder_id`, `outranked_by_product_id`), and confers and updates `kept` only. The doc comment of `convergeConferrals` states the choice in one sentence.
|
||||
- [ ] 5.2 Tests:
|
||||
- `TestChooseTiers` (`internal/fulfillment/reconcile_order_test.go`, no database), rows through `chooseTiersRow`, each run with its products in the given order and reversed: `two tiers of one ladder keep the higher`; `tiers of two ladders are both kept`; `a bundle outranks a tier on one of its ladders` (X ranked 3 on L1 and 1 on L2, Y ranked 2 on L2: X kept, Y outranked by X on L2); `a higher tier takes a shared ladder from a lower bundle` (Y ranked 2 on L2, Z ranked 1 on L2 and L3: Y kept, Z outranked by Y); `an add-on is always kept`; `equal ranks keep the lower product id`. Failures read `chooseTiers(%s) kept %v, want %v` and `chooseTiers(%s) outranked %v, want %v`, with the row's name and the order's.
|
||||
- T10 `TestReconcile_TwoTiersOfOneLadderConferTheHigher` (`internal/fulfillment/displaced_subscription_test.go`), rows `Plan then Plus` and `Plus then Plan`: a subscription whose items carry the fixture's tier and the second tier in the row's order; its first reconcile: `the higher tier holds %d positions, want 1` and `the lower tier holds %d positions, want 0`; a second reconcile: `the second reconcile wrote %d rows`.
|
||||
- `reconcile_trace/two_tiers_one_ladder` flips on purpose: no `core.confer`, Plus stays the subscription's, no `end` or downgrade by `webhook`, and L2 in the logs.
|
||||
- [ ] 5.3 `docs/models/payments-billing.md`, the Reconciler row: a subscription that pays for two tiers of one ladder gets the higher-ranked one, the other logged as a warning and not conferred; a subscription that alternated before this change switches once more, to the higher tier, at its first reconcile after it.
|
||||
- [ ] 5.4 Breaks:
|
||||
- the lower tier walked first (`cmp.Compare(b.top, a.top)` replaced by `cmp.Compare(a.top, b.top)`): caught by `TestChooseTiers`, `want_new` `chooseTiers(two tiers of one ladder keep the higher) kept`, and by T10, `want_new` `the higher tier holds 0 positions, want 1`;
|
||||
- every product kept (`if taken := shared(c, held); taken != "" {` replaced by `if taken := shared(c, held); false && taken != "" {`): caught by `TestChooseTiers`, `want_new` `chooseTiers(two tiers of one ladder keep the higher) kept`, and by T10, `want_new` `the second reconcile wrote`;
|
||||
- the tie broken the other way (`strings.Compare(a.id, b.id)` replaced by `strings.Compare(b.id, a.id)`): caught by `TestChooseTiers`, `want_new` `chooseTiers(equal ranks keep the lower product id) kept`;
|
||||
- a product on no ladder left out (the walk's loop gaining `if len(c.rungs) == 0 { outranked[c.id] = outrank{}; continue }` as its first statement): caught by `TestChooseTiers`, `want_new` `chooseTiers(an add-on is always kept) kept`;
|
||||
- the Warn line removed: caught by `TestReconcileTrace/two_tiers_one_ladder`, `want_new` `testdata/reconcile_trace/two_tiers_one_ladder.golden differs from what the code produced`.
|
||||
|
||||
## 6. The organization page says which ladder a subscription delivers nothing on
|
||||
|
||||
- [ ] 6.1 `internal/server/operator_enrollment.go` (design D4; the Copy table, rows O1 to O3, approved before this section starts):
|
||||
- `subscriptionMayBeDisplaced(status string) bool`, true for `active`, `trialing`, `past_due`, `unpaid` and `paused`.
|
||||
- `displacedSubscription` (each displaced ladder's id and display name, and each holder's provision id and source) and `displacementNote(subscriptions int, displaced []displacedSubscription) string`, reading no row: `""` when nothing is displaced; O1 when `subscriptions` is 1; O2 otherwise, singular for one displaced subscription and plural for more; `<ladders>` is the distinct displaced ladders, by ladder id: `displacementNote` walks its input in order, appends each ladder whose id it has not seen to a slice, then sorts the slice by `strings.Compare` on the names, whatever order the query returned them in; `<holder>` is as the Copy table fills it, counting distinct holder provisions.
|
||||
- `displacedOnPage(products []string, rungs map[string][]fulfillment.Rung, displacements []fulfillment.Displacement) displacedSubscription`, reading no row: it calls `fulfillment.DisplacedProducts` with the subscription's current products as the missing set (a product with a live provision is on no displaced ladder, design D2) and names the ladder of each displacement it picks, so a displacement whose ladder no current product names produces nothing. For a product of several ladders it names the one displaced ladder the function picked (design F21).
|
||||
- Queries, then `make sqlc-generate`: `ListAccountSubscriptionDisplacements :many` in `internal/entitlements/queries/pool_provision_transitions.sql`, `FROM core.subscriptions s CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, sqlc.arg(pool_id)::uuid) AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)` joined to `core.plan_ladders pl` for the ladder name, `WHERE s.billing_account_id = sqlc.arg(billing_account_id)::uuid`, with the columns of item 4.2's query and the subscription id, its comment saying the status filter stays in Go; `ListCurrentSubscriptionItemsByBillingAccountID :many` in `internal/billing/queries/subscription_items.sql`: `si.subscription_id`, `si.product_id` from `core.subscription_items si JOIN core.subscriptions s USING (subscription_id)` where `s.billing_account_id = sqlc.arg(billing_account_id)::uuid AND si.ended_at IS NULL`, ordered `si.subscription_id, si.product_id` (the rows `mirrorItems` writes), in one statement; and the item 4.2 query `ListConferralShapes` for the products those items name. The ladder names come from the displacement query's join, in one statement.
|
||||
- `BillingSummaryViewModel` gains `DisplacementNote string` and `DisplacementError string`, with a comment naming the requirement they render. `loadOrgBillingSummary(r, data, orgID, pools)` takes the pools `loadOrgPools` already read and the organization's default pool from them, so the pool is not read a second time, and runs the three statements above once for the account, never per subscription. With no active default pool among them, the three statements are not run and both fields stay empty. It filters the subscriptions the card already read through `subscriptionMayBeDisplaced`, builds each subscription's `displacedSubscription` with `displacedOnPage` and sets `DisplacementNote`. A failed items, shapes or displacement read logs L3 (`org enrollment: subscription displacement read failed`, attributes `org_id`, `billing_account_id`, `error`) and sets `DisplacementError` to O3, leaving `DisplacementNote` empty (`docs/go-conventions.md`, "A failed read is never an answer").
|
||||
- [ ] 6.2 `internal/embeds/templates/partials/operator_enrollment.html`: in the Subscriptions row, after the value line's `<div>`, `{{ if .BillingSummary.DisplacementError }}<div class="text-muted small">{{ .BillingSummary.DisplacementError }}</div>{{ else if .BillingSummary.DisplacementNote }}<div class="text-muted small">{{ .BillingSummary.DisplacementNote }}</div>{{ end }}`; the Go-template comment above the rows names the line and the error.
|
||||
- [ ] 6.3 Tests:
|
||||
- `TestDisplacementNote` (`internal/server/operator_enrollment_render_test.go`, beside `TestSummarizeSubscriptions`), rows through a named function: `a grant holds the ladder` (`Delivers nothing on Hosting, which a grant holds`); `another subscription holds the ladder` (`Delivers nothing on Hosting, which another subscription holds`); `a purchase holds the ladder` (`Delivers nothing on Hosting, which another plan holds`); `one grant holds two ladders` (`Delivers nothing on Hosting and Support, which a grant holds`); `three ladders` (`Delivers nothing on Docs, Hosting and Support, which a grant holds`); `two holders` (`Delivers nothing on Hosting and Support, which other plans hold`); `one of several subscriptions` (`1 delivers nothing on Hosting, which another subscription holds`); `two of several subscriptions` (`2 deliver nothing on Hosting and Support, which other plans hold`); `ladders out of name order` (the ladders handed over as Support, then Hosting; `Delivers nothing on Hosting and Support, which a grant holds`); `two subscriptions displaced on one ladder` (two displaced subscriptions, each naming Hosting with the same grant provision as its holder; `2 deliver nothing on Hosting, which a grant holds`); `nothing displaced` (`""`). A failure reads `displacementNote(%s) = %q, want %q`. The rows give each ladder's id alongside its name.
|
||||
- `TestOrgEnrollmentReadFailures` (`internal/server/operator_enrollment_read_failures_db_test.go`) gains three rows, each failing one read of the displacement line through a method added to the existing wrappers: `ListCurrentSubscriptionItemsByBillingAccountID` on `failingEnrollmentBillingQ` (the row's querier `billing`); `ListAccountSubscriptionDisplacements` and `ListConferralShapes` on `failingEnrollmentEntitlementsQ` (the row's querier `entitlements`). Each asserts `BillingSummary.DisplacementError == "Failed to load deliveries."` and that the render holds the O3 text. `checkEnrollmentBaseline`'s error case gains `|| d.BillingSummary.DisplacementError != ""`, so a displacement read that failed in the baseline fails the baseline. A failure reads `DisplacementError after a failed %s read = %q, want "Failed to load deliveries."`.
|
||||
- `TestDisplacedOnPage` (beside it), rows through a named function: `a displacement on a ladder a current product names` (one ladder); `the item moved off the ladder` (a displacement on Hosting, the current product names only another ladder: nothing, so the card reads no line); `the item removed` (no current product: nothing); `a bundle displaced on one ladder` (the held ladder only). A failure reads `displacedOnPage(%s) = %+v, want %+v`.
|
||||
- `TestSubscriptionMayBeDisplaced`, one row per status `subscriptionStatusFacets` lists (`internal/server/operator_billing.go`): true for `active`, `trialing`, `past_due`, `unpaid` and `paused`, false for `incomplete`, `incomplete_expired` and `canceled`. A failure reads `subscriptionMayBeDisplaced(%q) = %v, want %v`.
|
||||
- `TestOrgEnrollmentGolden` (`internal/server/operator_enrollment_golden_db_test.go`): the state `grant-over-subscription`, an organization of the plain type subscribed to the fixture's existing Small2 on ladder Box (`w.Subscribe`, period end noon on Jan 15, 2099) and issued a grant of Small2 over it (`f.grant("Small2", w.Issue(o, small2, world.IssueOpts{}))`), which takes the subscription's position as a `transfer`. It adds no product, because every state's Issue grant catalog lists all the fixture's products and a new one would change every golden. Its goldens are written with `-update`: `.html` reads the Subscriptions row with the line `Delivers nothing on Box, which a grant holds` and the Pools panel with the grant's delivery; `.json` carries the same `DisplacementNote`. Every other `org_enrollment` JSON golden gains `"DisplacementNote": ""` and `"DisplacementError": ""` in its `BillingSummary` and no other change; no other HTML golden changes. The commit message names the files.
|
||||
- The read-count row of the organization page (`internal/server/operator_read_count_db_test.go`, `TestOperatorPagesReadAFixedNumberOfTimes`) gains a subscription a grant displaced in its `grow` (a second subscription on its own ladder and a grant of its tier over it), so the page's statement count is pinned at no statement per displaced subscription; its `perRow` stays 0.
|
||||
- [ ] 6.4 Docs: `docs/plan-management.md`, "Issue Grant" (item 4.8's bullet): the organization page's Billing card then reads "Delivers nothing on {ladder}, which a grant holds" under the subscription; `docs/stripe.md`'s bullet **A grant over a paid plan.** gains the same sentence.
|
||||
- [ ] 6.5 `make screens`: no changed screen is expected, because the demo seed holds no subscription a grant displaced; a changed screen is a stop to report, never a baseline this change accepts.
|
||||
- [ ] 6.6 Breaks:
|
||||
- `DisplacementNote` never set (its assignment in `loadOrgBillingSummary` removed): caught by `TestOrgEnrollmentGolden`, `want_new` `testdata/org_enrollment/grant-over-subscription.html differs from what the code produced`;
|
||||
- the template without the line: caught by the same golden;
|
||||
- a subscription holder worded as a grant (`another subscription holds` replaced by `a grant holds`): caught by `TestDisplacementNote`, `want_new` `displacementNote(another subscription holds the ladder) =`;
|
||||
- several holders worded as one (the several-holders branch removed): caught by `TestDisplacementNote`, `want_new` `displacementNote(two holders) =`;
|
||||
- the ladders joined with commas only (the `and` before the last name dropped): caught by `TestDisplacementNote`, `want_new` `displacementNote(one grant holds two ladders) =`;
|
||||
- the plural form never chosen: caught by `TestDisplacementNote`, `want_new` `displacementNote(two of several subscriptions) =`;
|
||||
- the sort in `displacementNote` removed: caught by `TestDisplacementNote`, `want_new` `displacementNote(ladders out of name order) =`;
|
||||
- the ladders not deduplicated in `displacementNote`: caught by `TestDisplacementNote`, `want_new` `displacementNote(two subscriptions displaced on one ladder) =`;
|
||||
- the holders not deduplicated: caught by `TestDisplacementNote`, `want_new` `displacementNote(one grant holds two ladders) =`, which reads `which other plans hold` without the dedupe;
|
||||
- `canceled` admitted by `subscriptionMayBeDisplaced`: caught by `TestSubscriptionMayBeDisplaced`, `want_new` `subscriptionMayBeDisplaced("canceled") = true, want false`;
|
||||
- `displacedOnPage` reading displacements without the current products (the `DisplacedProducts` call replaced by the first displacement's ladder): caught by `TestDisplacedOnPage`, `want_new` `displacedOnPage(the item moved off the ladder) =`;
|
||||
- the displacement read moved into a loop over the subscriptions: caught by the organization page's read-count row, `want_new` `two more rows cost`;
|
||||
- the `DisplacementError` assignment and the L3 log line removed: caught by `TestOrgEnrollmentReadFailures`, `want_new` `DisplacementError after a failed ListAccountSubscriptionDisplacements read =`.
|
||||
|
||||
## 7. Landing
|
||||
|
||||
- [ ] 7.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate displaced-subscription-waits --strict` passes.
|
||||
- [ ] 7.2 The race run of item 4.9 is in its commit's message, and every section's break list reads CAUGHT or names its unseen breaks with the reason.
|
||||
- [ ] 7.3 The read-only count (design D14), run by whoever lands the change, before the push that deploys it, on each deployment that ran the console before it, as `console_reader` (`docs/database-management.md`, "The `console_reader` role"). It reads ids, instants, transition types and ranks, and the grant's reason and mark, which the role reads, and no `reason` of a transition, which it does not; it writes nothing. Zero rows means nothing to fix. Each row is a grant the defect took back; the rows go to the maintainer, and each is fixed by hand on his go (the grant issued again, revoked, or left), never by code in this change:
|
||||
|
||||
```sql
|
||||
WITH pairs AS (
|
||||
SELECT e.pool_id, e.plan_ladder_id, e.effective_at, e.actor_type,
|
||||
e.provision_id AS ended_provision_id,
|
||||
n.provision_id AS successor_provision_id
|
||||
FROM core.pool_provision_transitions e
|
||||
JOIN core.pool_provision_transitions n
|
||||
ON n.pool_id = e.pool_id
|
||||
AND n.plan_ladder_id = e.plan_ladder_id
|
||||
AND n.effective_at = e.effective_at
|
||||
AND n.actor_type = e.actor_type
|
||||
AND n.actor_id IS NOT DISTINCT FROM e.actor_id
|
||||
WHERE e.transition_type = 'end'
|
||||
AND n.transition_type IN ('initiate', 'upgrade', 'downgrade', 'transfer')
|
||||
AND n.from_rank IS NOT NULL
|
||||
)
|
||||
SELECT DISTINCT
|
||||
took.pool_id,
|
||||
gp.grant_id,
|
||||
g.resumes_after_supersession AS marked,
|
||||
sp.subscription_id,
|
||||
took.effective_at AS granted_over_at,
|
||||
back.effective_at AS taken_back_at
|
||||
FROM pairs took
|
||||
JOIN core.pool_provisions sp ON sp.provision_id = took.ended_provision_id
|
||||
JOIN core.pool_provisions gp ON gp.provision_id = took.successor_provision_id
|
||||
JOIN core.grants g ON g.grant_id = gp.grant_id
|
||||
JOIN pairs back
|
||||
ON back.pool_id = took.pool_id
|
||||
AND back.plan_ladder_id = took.plan_ladder_id
|
||||
AND back.ended_provision_id = gp.provision_id
|
||||
AND back.actor_type = 'webhook'
|
||||
JOIN core.pool_provisions bp ON bp.provision_id = back.successor_provision_id
|
||||
WHERE sp.subscription_id IS NOT NULL
|
||||
AND g.grant_reason <> 'default'
|
||||
AND bp.subscription_id = sp.subscription_id
|
||||
ORDER BY took.pool_id, taken_back_at, gp.grant_id;
|
||||
```
|
||||
|
||||
It reads direct pairs only, so a grant extended between taking the ladder and being taken back is not listed (design, What is unverified).
|
||||
- [ ] 7.4 After the push, post on #212 under `CONTRIBUTING.md`'s rules for forge text (item 1: it reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count), and close it. Draft, with the commits of sections 1 to 6 filled in:
|
||||
|
||||
> A grant that replaced a subscription's tier now keeps the ladder until it ends or is revoked. (A ladder is a sequence of tiers such as Plus, Pro and Max; an organization holds one tier on each ladder at a time, from a subscription or from a grant.)
|
||||
>
|
||||
> The subscription reconcile now checks, for each product the subscription pays for, whether another plan took one of that product's ladders from the subscription and still holds it. It reads this from the tier-change history: the subscription's last position on the ladder ended in a supersession, and the plan that took it is not the subscription. While a plan other than the organization type's default holds that ladder, the reconcile confers nothing there and logs it. The subscription keeps billing, as the issue form says, and nothing is sent to Stripe. Once the grant ends and only the default holds the ladder, or nothing does, the subscription's next reconcile puts its tier back.
|
||||
>
|
||||
> This holds on every path that reconciles a subscription: renewals and other edits, the checkout return, the invoice reconcile, the sweeper's runs, and a member's move on another ladder of the same subscription. A subscription that recovers from past due goes through the same check, so the recovery no longer takes the ladder back. A downgrade that lands while a grant holds the ladder is recorded as applied, and its tier is not conferred. A new purchase over a grant still replaces the grant.
|
||||
>
|
||||
> The case in the comment above is fixed as well: a subscription that carries two tiers of one ladder gets the higher-ranked one, and the other is logged as a warning, so the tier no longer alternates. The reconcile now confers in a fixed order, by product id. Two subscriptions of one organization on one ladder no longer alternate either: the later one keeps the ladder.
|
||||
>
|
||||
> On the organization page, the Billing card's Subscriptions row says when a subscription delivers nothing, on which ladder, and whether a grant or another subscription holds it.
|
||||
>
|
||||
> Not changed here: the member's Products page says nothing new about a subscription that delivers nothing, and the issue form still points at the billing view; both wait on a decision about what a member can do with such a subscription. A subscription delivers again at its next reconcile after the grant ends, not at the moment the grant ends.
|
||||
>
|
||||
> Commits: …
|
||||
|
||||
- [ ] 7.5 Handed to the maintainer, none filed without his go: the candidate issues in the proposal's "Out of scope" (what the member sees of a displaced subscription and whether the member can cancel it; the issue form's sentence; delivering again in the act that ends the holder, with the constraint that it locks by pool only; a bundle's vacated rungs; Checkout above the grant while the first subscription bills, or a note on #159), the note on #233 (the sweeper's term-boundary switch reads the first item), and the result of item 7.3. The candidate issue on what the member sees of a displaced subscription is member-harming: a member whose subscription a grant displaced cannot cancel it in the console while the grant holds the ladder.
|
||||
- [ ] 7.6 The archive waits for the maintainer's go, and only after `reconcile-takes-pool-lock` is archived; the deltas carry every spec change, and no Purpose needs an edit a delta cannot make. Nothing in this change writes to a production host: item 7.3 is a read under the reader role, and any fix it leads to is a separate act on the maintainer's go.
|
||||
Reference in New Issue
Block a user