Confer the higher tier when one subscription pays for two of one ladder
A subscription whose items name two tiers of one ladder (Plan and Plus,
say) conferred whichever its live read left, so the member could keep
the lower tier while paying for both. The item pass now runs
chooseTiers over the desired products, after the displacement rule has
left its products out: it walks them by their highest rank, keeps a
product whose ladders no kept product has taken, and outranks the rest;
a tie goes to the lower product id, and a product on no ladder is always
kept. Each outranked product is logged once at Warn ("reconcile:
product not conferred; a higher tier of the subscription holds its
ladder", with the product, the ladder and the product that took it). The rank compared is
a product's top rank across its ladders, so a bundle at rank 3 on one
ladder outranks a tier at rank 2 on a ladder they share.
reconcile_trace/two_tiers_one_ladder flips on purpose: Plus stays the
ladder's position, no core.confer, the Warn line names Plan. The
productsLeftAlone helper now receives the rungs the item pass read once,
which keeps the statement order of section 4's goldens unchanged.
docs/models/payments-billing.md states the choice in the Reconciler row.
Tests: TestChooseTiers (two tiers keep the higher, tiers of two ladders
are both kept, a bundle outranks a tier on one of its ladders, a higher
tier takes a shared ladder from a lower bundle, a tie, an add-on always
kept), each row in the given and the reversed order; T10
TestReconcile_TwoTiersOfOneLadderConferTheHigher with Plan then Plus and
Plus then Plan.
Breaks (scripts/mutation-check, 11 of 11 CAUGHT):
- the lower tier walked first (TestChooseTiers, T10)
- every product kept (TestChooseTiers, T10)
- the tie broken the other way
- a product on no ladder left out
- the Warn line removed
- top from the last rung
- any held ladder blocks
- a bundle never left out
- the left-out skip dropped (T1; section 4's skip break restated against
this section's filter)
The first run reported four records WRONG-MESSAGE because the test
printed each row's order inside the parentheses; the test now prints
the row's name alone there.
The suite on reset databases passes, 4652 tests, 15 skipped; after the
review's fixes (test output, comments, a rename), ./internal/fulfillment
passes. make lint passes; openspec validate --strict passes.
This commit is contained in:
@@ -37,7 +37,7 @@ Database objects sit in the `core` schema (defined in `internal/db/migrations/00
|
||||
| Invoice reconcile | `ReconcileInvoice`: reads the invoice, its lines and the charges of its payment intent back from the Stripe API at the SDK's version, takes a per-invoice lock, and converges the invoice (created and numbered by the first run that finds it issued, then only moved forward), its lines (inserted once), and one payment per settled charge | `internal/integrations/stripe/workflows/invoice_reconcile.go` |
|
||||
| Invoice sweep | The hourly Temporal schedule (`stripe-invoice-sweep`), also triggered at every boot, that reads back invoice mappings never read back, open invoices last read more than a day ago, and invoices named by dead-lettered invoice events, then fills payment methods and subscription links mapped since; it never renumbers, deletes, or reconciles a subscription. The Stripe provider page states when it last finished | `internal/integrations/stripe/workflows/invoice_sweep.go` |
|
||||
| Outbox drainer | The recurring worker that executes outbox rows (create Stripe product / price / customer), retrying with backoff until a cap parks the row as `dead_letter` for operator-driven retry | `internal/integrations/stripe/workflows/outbox.go` |
|
||||
| Reconciler | `ReconcileSubscription`: refetches the subscription from the Stripe API and converges console records and provisions to match. A desired product whose ladder another plan took from the subscription and holds is left alone and logged, while the subscription keeps billing; the reconcile writes in product id order under the pool row | `internal/fulfillment/reconcile.go` |
|
||||
| Reconciler | `ReconcileSubscription`: refetches the subscription from the Stripe API and converges console records and provisions to match. A desired product whose ladder another plan took from the subscription and holds is left alone and logged, while the subscription keeps billing; 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 between the two before the reconcile chose by rank moves to the higher-ranked tier at its next reconcile and stays there); the reconcile writes in product id order under the pool row | `internal/fulfillment/reconcile.go` |
|
||||
| Plan-change mechanics | Member-initiated switch and cancel, a preview of the price difference before switching, the commitment gating, and the scheduled-change bookkeeping | `internal/fulfillment/plan_change.go` |
|
||||
| Scheduled-change sweeper | The hourly backstop that fires due scheduled changes Stripe itself cannot fire | `internal/fulfillment/sweep.go`; `internal/workflows/billing/` |
|
||||
| Checkout | Runs the guard checks and starts the Stripe Checkout session; also creates the Stripe customer synchronously if the organization has none yet | `internal/server/billing.go` |
|
||||
|
||||
@@ -5,7 +5,8 @@ package fulfillment_test
|
||||
|
||||
// The reconcile leaves a ladder alone while another plan holds it that took it
|
||||
// from the subscription (openspec change displaced-subscription-waits, design
|
||||
// D2, tasks 4.6). The fixture is seedFixture's: its product is the
|
||||
// D2, tasks 4.6), and the higher of two tiers of one ladder is conferred (D5,
|
||||
// tasks 5.2). The fixture is seedFixture's: its product is the
|
||||
// subscription's tier, secondTier and addTier put the tiers a grant takes
|
||||
// above it, and every Stripe answer is the mock's.
|
||||
|
||||
@@ -469,3 +470,50 @@ func TestReconcile_AnItemBackOnALadderItLeftSupersedesTheGrant(t *testing.T) {
|
||||
t.Errorf("the subscription's tier holds %d positions, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// twoTiersRow is one case of T10: whether the fixture's tier or the second
|
||||
// tier is the subscription's first item.
|
||||
type twoTiersRow struct {
|
||||
name string
|
||||
planFirst bool
|
||||
}
|
||||
|
||||
// checkTwoTiers runs one row: the first reconcile of a subscription with both
|
||||
// tiers confers the higher, the second writes nothing.
|
||||
func checkTwoTiers(t *testing.T, row twoTiersRow) {
|
||||
t.Helper()
|
||||
database := dbtest.Open(t)
|
||||
fx := seedFixture(t, database)
|
||||
prod2, _, stripePrice2 := secondTier(t, database, fx)
|
||||
plan, plus := fx.planItem(), item("si_plus_"+fx.sfx, stripePrice2)
|
||||
items := []*stripe.SubscriptionItem{plan, plus}
|
||||
if !row.planFirst {
|
||||
items = []*stripe.SubscriptionItem{plus, plan}
|
||||
}
|
||||
mock := &stripetest.MockBackend{Sub: fx.activeSub(items...)}
|
||||
t.Cleanup(stripetest.Install(mock))
|
||||
|
||||
reconcile(t, database, fx.stripeSubID, "test:created")
|
||||
if n := activeAttachCount(t, database, fx.ladderID, prod2); n != 1 {
|
||||
t.Errorf("%s: the higher tier holds %d positions, want 1", row.name, n)
|
||||
}
|
||||
if n := activeAttachCount(t, database, fx.ladderID, fx.productID); n != 0 {
|
||||
t.Errorf("%s: the lower tier holds %d positions, want 0", row.name, n)
|
||||
}
|
||||
before := poolRows(t, database, fx)
|
||||
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
|
||||
if n := poolRows(t, database, fx) - before; n != 0 {
|
||||
t.Errorf("%s: the second reconcile wrote %d rows", row.name, n)
|
||||
}
|
||||
}
|
||||
|
||||
// T10: a subscription that pays for two tiers of one ladder confers the
|
||||
// higher, whatever order Stripe lists its items in, and stays put.
|
||||
func TestReconcile_TwoTiersOfOneLadderConferTheHigher(t *testing.T) {
|
||||
for _, row := range []twoTiersRow{
|
||||
{"Plan then Plus", true},
|
||||
{"Plus then Plan", false},
|
||||
} {
|
||||
checkTwoTiers(t, row)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package fulfillment
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
@@ -754,7 +756,9 @@ func DisplacedProducts(missing []string, rungs map[string][]Rung, displacements
|
||||
// a missing product is conferred, a quantity-only delta updates the bounds,
|
||||
// and a provision no longer desired is ended. A missing product whose ladder
|
||||
// another plan took from the subscription and holds is left alone
|
||||
// (core.subscription_displacements). It reports whether it wrote anything.
|
||||
// (core.subscription_displacements), and of two products that share a ladder
|
||||
// only the higher-ranked is conferred (chooseTiers). It reports whether it
|
||||
// wrote anything.
|
||||
func convergeConferrals(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
@@ -775,17 +779,36 @@ func convergeConferrals(
|
||||
}
|
||||
|
||||
order := sortedProducts(desired)
|
||||
left, err := productsLeftAlone(ctx, logger, entQ, order, liveByProduct, subUUID, poolID)
|
||||
rungs, err := conferralRungs(ctx, entQ, order)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
left, err := productsLeftAlone(ctx, logger, entQ, order, rungs, liveByProduct, subUUID, poolID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
remaining := make([]string, 0, len(order))
|
||||
for _, productID := range order {
|
||||
if _, out := left[productID]; !out {
|
||||
remaining = append(remaining, productID)
|
||||
}
|
||||
}
|
||||
kept, outranked := chooseTiers(remaining, rungs)
|
||||
for _, productID := range remaining {
|
||||
o, out := outranked[productID]
|
||||
if !out {
|
||||
continue
|
||||
}
|
||||
logger.Warn("reconcile: product not conferred; a higher tier of the subscription holds its ladder",
|
||||
slog.String("subscription_id", subUUID.String()),
|
||||
slog.String("product_id", productID),
|
||||
slog.String("plan_ladder_id", o.ladderID),
|
||||
slog.String("outranked_by_product_id", o.byProductID))
|
||||
}
|
||||
|
||||
// Missing product -> confer; quantity-only delta -> update_conferral_bounds.
|
||||
changed := false
|
||||
for _, productID := range order {
|
||||
if _, out := left[productID]; out {
|
||||
continue
|
||||
}
|
||||
for _, productID := range kept {
|
||||
wrote, err := conferOrBound(ctx, entQ, liveByProduct, productID, desired[productID], subUUID, poolID, actor)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -797,23 +820,20 @@ func convergeConferrals(
|
||||
return changed || ended, err
|
||||
}
|
||||
|
||||
// productsLeftAlone reads the shapes of the desired products and the
|
||||
// subscription's displacements, takes as missing the products with no live
|
||||
// provision, and returns those DisplacedProducts leaves alone, logging each in
|
||||
// order once. Both reads run under the pool row the caller holds.
|
||||
// productsLeftAlone reads the subscription's displacements, takes as missing
|
||||
// the products with no live provision, and returns those DisplacedProducts
|
||||
// leaves alone, logging each in order once. The read runs under the pool row
|
||||
// the caller holds.
|
||||
func productsLeftAlone(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
entQ *entitlements.Queries,
|
||||
order []string,
|
||||
rungs map[string][]Rung,
|
||||
liveByProduct map[string]entitlements.PoolProvision,
|
||||
subUUID uuid.UUID,
|
||||
poolID string,
|
||||
) (map[string]Displacement, error) {
|
||||
rungs, err := conferralRungs(ctx, entQ, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := entQ.ListSubscriptionDisplacements(ctx, entitlements.ListSubscriptionDisplacementsParams{
|
||||
SubscriptionID: subUUID.String(),
|
||||
PoolID: poolID,
|
||||
@@ -842,6 +862,72 @@ func productsLeftAlone(
|
||||
return left, nil
|
||||
}
|
||||
|
||||
// outrank is the product that outranked another and the ladder they share.
|
||||
type outrank struct {
|
||||
byProductID string
|
||||
ladderID string
|
||||
}
|
||||
|
||||
// candidate is a product in chooseTiers' walk: its id, its highest rank on any
|
||||
// of its ladders (-1 for a product on none) and its rungs.
|
||||
type candidate struct {
|
||||
id string
|
||||
top int32
|
||||
rungs []Rung
|
||||
}
|
||||
|
||||
// shared returns the first ladder of c's rungs that a kept product holds, or
|
||||
// "" when c shares none.
|
||||
func shared(c candidate, held map[string]string) string {
|
||||
for _, r := range c.rungs {
|
||||
if _, ok := held[r.LadderID]; ok {
|
||||
return r.LadderID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// chooseTiers keeps, of products that share a ladder, the one with the highest
|
||||
// rank on any of its ladders, then the lower product id, and records the
|
||||
// shared ladder and the product that outranked each one it leaves out. A
|
||||
// product on no ladder is always kept. It returns kept in the order of
|
||||
// products and reads no row.
|
||||
func chooseTiers(products []string, rungs map[string][]Rung) (kept []string, outranked map[string]outrank) {
|
||||
cands := make([]candidate, 0, len(products))
|
||||
for _, id := range products {
|
||||
c := candidate{id: id, top: -1, rungs: rungs[id]}
|
||||
for _, r := range c.rungs {
|
||||
c.top = max(c.top, r.Rank)
|
||||
}
|
||||
cands = append(cands, c)
|
||||
}
|
||||
slices.SortStableFunc(cands, func(a, b candidate) int {
|
||||
if c := cmp.Compare(b.top, a.top); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.id, b.id)
|
||||
})
|
||||
outranked = make(map[string]outrank)
|
||||
held := make(map[string]string)
|
||||
keep := make(map[string]bool, len(cands))
|
||||
for _, c := range cands {
|
||||
if taken := shared(c, held); taken != "" {
|
||||
outranked[c.id] = outrank{byProductID: held[taken], ladderID: taken}
|
||||
continue
|
||||
}
|
||||
keep[c.id] = true
|
||||
for _, r := range c.rungs {
|
||||
held[r.LadderID] = c.id
|
||||
}
|
||||
}
|
||||
for _, id := range products {
|
||||
if keep[id] {
|
||||
kept = append(kept, id)
|
||||
}
|
||||
}
|
||||
return kept, outranked
|
||||
}
|
||||
|
||||
// conferOrBound confers productID from the subscription when it has no live
|
||||
// provision, or updates the live provision's bounds when only its quantity
|
||||
// differs. It reports whether it wrote anything.
|
||||
|
||||
@@ -106,3 +106,85 @@ func TestDisplacedProducts(t *testing.T) {
|
||||
checkDisplacedProducts(t, row)
|
||||
}
|
||||
}
|
||||
|
||||
// chooseTiersRow is one case of chooseTiers: the products' rungs, the products
|
||||
// kept and the products left out with what outranked each.
|
||||
type chooseTiersRow struct {
|
||||
name string
|
||||
products []string
|
||||
rungs map[string][]Rung
|
||||
kept []string
|
||||
outranked map[string]outrank
|
||||
}
|
||||
|
||||
// checkChooseTiers runs one row with its products in the given order and
|
||||
// reversed; kept is wanted in the order of the products given.
|
||||
func checkChooseTiers(t *testing.T, row chooseTiersRow) {
|
||||
t.Helper()
|
||||
reversed := slices.Clone(row.products)
|
||||
slices.Reverse(reversed)
|
||||
for _, order := range [][]string{row.products, reversed} {
|
||||
gotKept, gotOut := chooseTiers(order, row.rungs)
|
||||
var wantKept []string
|
||||
for _, p := range order {
|
||||
if slices.Contains(row.kept, p) {
|
||||
wantKept = append(wantKept, p)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(gotKept, wantKept) {
|
||||
t.Errorf("chooseTiers(%s) kept %v, want %v, products in the order %v", row.name, gotKept, wantKept, order)
|
||||
}
|
||||
if !maps.Equal(gotOut, row.outranked) {
|
||||
t.Errorf("chooseTiers(%s) outranked %v, want %v, products in the order %v", row.name, gotOut, row.outranked, order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChooseTiers pins which of the products that share a ladder the
|
||||
// subscription confers: the highest rank on any ladder, then the lower id.
|
||||
func TestChooseTiers(t *testing.T) {
|
||||
for _, row := range []chooseTiersRow{
|
||||
{
|
||||
name: "two tiers of one ladder keep the higher",
|
||||
products: []string{"P", "Q"},
|
||||
rungs: map[string][]Rung{"P": {{"L1", 0}}, "Q": {{"L1", 1}}},
|
||||
kept: []string{"Q"},
|
||||
outranked: map[string]outrank{"P": {byProductID: "Q", ladderID: "L1"}},
|
||||
},
|
||||
{
|
||||
name: "tiers of two ladders are both kept",
|
||||
products: []string{"P", "Q"},
|
||||
rungs: map[string][]Rung{"P": {{"L1", 1}}, "Q": {{"L2", 1}}},
|
||||
kept: []string{"P", "Q"},
|
||||
},
|
||||
{
|
||||
name: "a bundle outranks a tier on one of its ladders",
|
||||
products: []string{"X", "Y"},
|
||||
rungs: map[string][]Rung{"X": {{"L1", 3}, {"L2", 1}}, "Y": {{"L2", 2}}},
|
||||
kept: []string{"X"},
|
||||
outranked: map[string]outrank{"Y": {byProductID: "X", ladderID: "L2"}},
|
||||
},
|
||||
{
|
||||
name: "a higher tier takes a shared ladder from a lower bundle",
|
||||
products: []string{"Y", "Z"},
|
||||
rungs: map[string][]Rung{"Y": {{"L2", 2}}, "Z": {{"L2", 1}, {"L3", 1}}},
|
||||
kept: []string{"Y"},
|
||||
outranked: map[string]outrank{"Z": {byProductID: "Y", ladderID: "L2"}},
|
||||
},
|
||||
{
|
||||
name: "an add-on is always kept",
|
||||
products: []string{"A", "Q"},
|
||||
rungs: map[string][]Rung{"Q": {{"L1", 0}}},
|
||||
kept: []string{"A", "Q"},
|
||||
},
|
||||
{
|
||||
name: "equal ranks keep the lower product id",
|
||||
products: []string{"P", "Q"},
|
||||
rungs: map[string][]Rung{"P": {{"L1", 1}}, "Q": {{"L1", 1}}},
|
||||
kept: []string{"P"},
|
||||
outranked: map[string]outrank{"Q": {byProductID: "P", ladderID: "L1"}},
|
||||
},
|
||||
} {
|
||||
checkChooseTiers(t, row)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,13 @@ GetSubscriptionItemsBySubscriptionID
|
||||
GetLivePoolProvisionsBySubscriptionID
|
||||
ListConferralShapes
|
||||
ListSubscriptionDisplacements
|
||||
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
|
||||
WARN reconcile: product not conferred; a higher tier of the subscription holds its ladder subscription_id=<subscription> product_id=<product:Plan> plan_ladder_id=<ladder:Ladder> outranked_by_product_id=<product:Plus>
|
||||
== 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}
|
||||
@@ -47,19 +46,15 @@ commit
|
||||
{"stripe_id" : "si_b", "product" : "<product:Plus>", "price" : "<price:Plus>", "quantity" : 1, "ended" : false, "sync_status" : "synced", "livemode" : false}
|
||||
-- scheduled_changes
|
||||
-- provisions
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
|
||||
{"product" : "<product:Plan>", "set" : "<set:Plan>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "subscription", "quantity" : 1, "status" : "ended", "ended" : true}
|
||||
{"product" : "<product:Plus>", "set" : "<set:Plus>", "source" : "subscription", "quantity" : 1, "status" : "active", "ended" : false}
|
||||
-- ladder_positions
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "active"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plan>", "status" : "ended"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "ended"}
|
||||
{"ladder" : "<ladder:Ladder>", "product" : "<product:Plus>", "status" : "active"}
|
||||
-- transitions
|
||||
{"type" : "initiate", "actor" : "webhook", "reason" : "test:setup", "from_rank" : null, "to_rank" : 0, "ladder" : "<ladder:Ladder>", "product" : "<product:Plan>"}
|
||||
{"type" : "end", "actor" : "webhook", "reason" : "test: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}
|
||||
{"key" : "fedwiki_sites", "type" : "limit", "limit" : 3}
|
||||
-- boolean_entitlements
|
||||
|
||||
@@ -133,17 +133,21 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
|
||||
|
||||
## 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.
|
||||
- [x] 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.
|
||||
- [x] 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, products in the order %v` and `chooseTiers(%s) outranked %v, want %v, products in the order %v`, with the row's name inside the parentheses and the order after.
|
||||
- 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:
|
||||
- [x] 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 between the two before the reconcile chose by rank moves to the higher-ranked tier at its next reconcile and stays there.
|
||||
- [x] 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 top taken from the last rung, not the highest (`c.top = max(c.top, r.Rank)` replaced by `c.top = r.Rank`): caught by `TestChooseTiers`, `want_new` `chooseTiers(a bundle outranks a tier on one of its ladders) kept`;
|
||||
- any held ladder blocks (`if _, ok := held[r.LadderID]; ok {` replaced by `if len(held) > 0 {`): caught by `TestChooseTiers`, `want_new` `chooseTiers(tiers of two ladders are both kept) kept`;
|
||||
- a bundle never left out (`if _, ok := held[r.LadderID]; ok {` replaced by `if _, ok := held[r.LadderID]; ok && len(c.rungs) == 1 {`): caught by `TestChooseTiers`, `want_new` `chooseTiers(a higher tier takes a shared ladder from a lower bundle) kept`;
|
||||
- the left-out skip dropped (`if _, out := left[productID]; !out {` replaced by `if _, out := left[productID]; true || !out {`): caught by `TestReconcile_AGrantOverTheSubscriptionKeepsTheLadder`, `want_new` `the grant stopped delivering after the subscription's reconcile`;
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user