Add the offer decision and its table test

This commit is contained in:
2026-10-07 05:08:22 -05:00
parent 3e8a08ca8c
commit 2e1617cf4d
3 changed files with 587 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Package plans decides which move a member is offered toward a plan: Checkout,
// a switch, a downgrade at the period end, or none. It reads no database row
// and calls no payment provider; the caller supplies the organization's
// position on a ladder and the target's facts, and the Products page, Checkout,
// the plan switch and its preview all ask the same function, so no surface
// offers or accepts a move another refuses.
package plans
// Tier is one rung of a ladder.
type Tier struct {
ProductID string
Rank int32
}
// Position is what an organization holds on one ladder.
type Position struct {
// Tiers are the ladder's rungs.
Tiers []Tier
// Current is the product of the rung the organization holds, "" when it
// holds none.
Current string
// Subscribed is true when a subscription pays for the current rung, and
// false when a grant, the organization type's default or a purchase holds
// it.
Subscribed bool
// CancelScheduled is true when the subscription's cancellation is
// scheduled.
CancelScheduled bool
// ScheduledTo is the product an active scheduled downgrade moves to.
ScheduledTo string
// Held is the held rung: the tier of a grant marked to resume, which the
// subscription's end would put in place. Nil when there is none.
Held *Tier
// Predicted is true when a cancel prediction exists, which needs the
// subscription's period end.
Predicted bool
// Follows is the product the prediction puts on this ladder, "" when it
// puts nothing there.
Follows string
}
// Target is the product a move would land on and the price it would use.
type Target struct {
ProductID string
// Listed is true when the product clears the member gate: published,
// active and public.
Listed bool
Price Price
}
// Price is the target price.
type Price struct {
// ID is "" when the product has no active price.
ID string
// Default is true when the price is the product's default active price.
Default bool
Recurring bool
// Purchasable is true when the price's Stripe mapping carries an id the
// key mode can reach.
Purchasable bool
}
// Kind is the mechanism a move uses.
type Kind string
const (
// None is no move.
None Kind = ""
// Checkout creates a subscription.
Checkout Kind = "checkout"
// Switch moves a subscription to another paid rung.
Switch Kind = "switch"
// Cancel is the downgrade at the period end: it cancels the subscription,
// after which the held rung or the free rung follows.
Cancel Kind = "cancel"
)
// Reason says why a move is not offered or is disabled.
type Reason string
const (
// NoReason accompanies an enabled move.
NoReason Reason = ""
NotOnLadder Reason = "the target is not a tier of the ladder"
Unplaced Reason = "the organization holds a product that is not a tier of the ladder"
Current Reason = "the target is the current tier"
Scheduled Reason = "a downgrade to the target is already scheduled"
HeldMoveMade Reason = "the held rung's move is already made"
HeldAbove Reason = "the held rung is above the current rung"
BelowBoth Reason = "the target is below the held rung and the current rung"
FreeRungNotFollowing Reason = "the free rung is not what follows a cancellation"
Conferred Reason = "the free rung is conferred, never bought"
BelowGrant Reason = "no subscription pays for the rung above the target"
NotListed Reason = "the product is outside the member gate"
NoPrice Reason = "the tier has no price"
NotDefault Reason = "the price is not the product's default price"
OneTime Reason = "the price is one-time"
Unpurchasable Reason = "the price has no Stripe price the key can reach"
OtherLadder Reason = "another ladder of the product forbids it"
)
// Relation is the move relative to the current rung on the ladder. Offer sets
// it on every answer from rule 4 on; it is empty on the answers of rules 1 to
// 3, which name no move.
type Relation string
const (
// Available: the organization holds nothing on the ladder.
Available Relation = "available"
Upgrade Relation = "upgrade"
Downgrade Relation = "downgrade"
)
// Move is the answer of Offer.
type Move struct {
Kind Kind
Enabled bool
Reason Reason
Relation Relation
// Held marks the held rung.
Held bool
}
// Allows reports whether the move is an enabled move of kind k.
func (m Move) Allows(k Kind) bool {
return m.Kind == k && m.Enabled
}
// ask is one question to Offer with the facts every rule reads.
type ask struct {
Position
Target
rank int32
current int32
rel Relation
}
func (p Position) rankOf(productID string) (int32, bool) {
for _, t := range p.Tiers {
if t.ProductID == productID {
return t.Rank, true
}
}
return 0, false
}
// Offer returns the move the position is offered toward the target, applying
// these rules in order, the first match winning:
//
// 1. The target is not a tier of the ladder: none.
// 2. The organization holds a product that is not a tier of the ladder: none.
// 3. The target is the current product: none.
// 4. A subscription pays, no cancellation is scheduled, and the target is the
// scheduled downgrade's and not the held rung: none.
// 5. The target is the held rung and the cancellation is scheduled: none.
// 6. The target is the held rung above the current rung: none.
// 7. The target is the held rung: Cancel. Cancelling buys nothing, so the
// price rule does not apply.
// 8. The target ranks below both the held rung and the current rung: none.
// 9. A subscription pays, the target is the unpriced rank-0 tier below the
// current rung, and a prediction that does not put the target on the ladder
// exists or the cancellation is scheduled: none.
// 10. The target is the unpriced rank-0 tier and the organization holds nothing
// on the ladder: none, since that tier is conferred.
// 11. The target ranks below the current rung and no subscription pays for it:
// none, since a member cannot end a grant.
// 12. The organization holds nothing on the ladder, or the target ranks above a
// rung no subscription pays for: Checkout, through the price rule.
// 13. The target is the unpriced rank-0 tier: Cancel.
// 14. Otherwise: Switch, through the price rule.
//
// The price rule decides Checkout and Switch: a product outside the member
// gate is offered none; a tier with no price is offered the move disabled; a
// price that is not the default is offered none; a one-time price, or one with
// no reachable Stripe price, is offered the move disabled.
func Offer(pos Position, t Target) Move {
rank, ok := pos.rankOf(t.ProductID)
if !ok {
return Move{Reason: NotOnLadder}
}
a := ask{Position: pos, Target: t, rank: rank, rel: Available}
if pos.Current != "" {
cur, ok := pos.rankOf(pos.Current)
if !ok {
return Move{Reason: Unplaced}
}
a.current = cur
a.rel = Downgrade
if rank > cur {
a.rel = Upgrade
}
}
if t.ProductID == pos.Current {
return Move{Reason: Current}
}
if m, ok := a.heldRules(); ok {
return m
}
if m, ok := a.offersNothing(); ok {
return m
}
return a.route()
}
func (a ask) isHeld() bool {
return a.Held != nil && a.Held.ProductID == a.ProductID
}
// freeRung reports whether the target is the unpriced rank-0 tier.
func (a ask) freeRung() bool {
return a.rank == 0 && a.Price.ID == ""
}
func (a ask) answer(reason Reason) (Move, bool) {
return Move{Reason: reason, Relation: a.rel, Held: a.isHeld()}, true
}
// heldRules applies rules 4 to 8.
func (a ask) heldRules() (Move, bool) {
held := a.isHeld()
switch {
case a.Subscribed && !a.CancelScheduled && a.ProductID == a.ScheduledTo && !held:
return a.answer(Scheduled)
case held && a.CancelScheduled:
return a.answer(HeldMoveMade)
case held && a.rel == Upgrade:
return a.answer(HeldAbove)
case held:
return Move{Kind: Cancel, Enabled: true, Relation: a.rel, Held: true}, true
case a.Held != nil && a.rank < a.Held.Rank && a.rank < a.current:
return a.answer(BelowBoth)
}
return Move{}, false
}
// offersNothing applies rules 9 to 11.
func (a ask) offersNothing() (Move, bool) {
switch {
case a.Subscribed && a.freeRung() && a.rel == Downgrade &&
((a.Predicted && a.Follows != a.ProductID) || a.CancelScheduled):
return a.answer(FreeRungNotFollowing)
case a.freeRung() && a.rel == Available:
return a.answer(Conferred)
case a.rel == Downgrade && !a.Subscribed:
return a.answer(BelowGrant)
}
return Move{}, false
}
// route applies rules 12 to 14.
func (a ask) route() Move {
var m Move
switch {
case a.rel == Available || (a.rel == Upgrade && !a.Subscribed):
m = priceRule(Checkout, a.Target)
case a.freeRung():
m = Move{Kind: Cancel, Enabled: true}
default:
m = priceRule(Switch, a.Target)
}
m.Relation = a.rel
return m
}
// priceRule decides a Checkout or a Switch toward the target.
func priceRule(kind Kind, t Target) Move {
p := t.Price
switch {
case !t.Listed:
return Move{Reason: NotListed}
case p.ID == "":
return Move{Kind: kind, Reason: NoPrice}
case !p.Default:
return Move{Reason: NotDefault}
case !p.Recurring:
return Move{Kind: kind, Reason: OneTime}
case !p.Purchasable:
return Move{Kind: kind, Reason: Unpurchasable}
}
return Move{Kind: kind, Enabled: true}
}
// Across combines one product's moves on the ladders it is a tier of. A
// subscription to the product ends the incumbent position on every one of them
// (whole-bundle supersession), so Checkout is offered only when every ladder
// offers Checkout, and enabled only when every one is. When another ladder
// offers anything else the answer is none, with OtherLadder. The answer carries
// no relation: it differs per ladder. A single ladder's move is returned as it
// is, since no other ladder is involved.
func Across(moves []Move) Move {
switch len(moves) {
case 0:
return Move{Reason: NotOnLadder}
case 1:
return moves[0]
}
out := Move{Kind: Checkout, Enabled: true}
for _, m := range moves {
if m.Kind != Checkout {
return Move{Reason: OtherLadder}
}
if !m.Enabled && out.Enabled {
out.Enabled = false
out.Reason = m.Reason
}
}
return out
}
// OffLadder decides the offer for a product that is a tier of no ladder: the
// price rule, with Checkout the only move.
func OffLadder(t Target) Move {
return priceRule(Checkout, t)
}
+268
View File
@@ -0,0 +1,268 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package plans
import "testing"
// The ladder under test: free (rank 0, unpriced), basic (1), standard (2),
// plus (3), pro (4).
var ladderTiers = []Tier{
{ProductID: "free", Rank: 0},
{ProductID: "basic", Rank: 1},
{ProductID: "standard", Rank: 2},
{ProductID: "plus", Rank: 3},
{ProductID: "pro", Rank: 4},
}
func tierOf(id string) *Tier {
for _, t := range ladderTiers {
if t.ProductID == id {
return &t
}
}
return nil
}
// buyable is a default, recurring, reachable price.
var buyable = Price{ID: "price", Default: true, Recurring: true, Purchasable: true}
// holds is a position on the ladder with the current rung held.
func holds(current string, subscribed bool) Position {
return Position{Tiers: ladderTiers, Current: current, Subscribed: subscribed}
}
// target is a listed product with the buyable price; the free rung has none.
func target(id string) Target {
t := Target{ProductID: id, Listed: true, Price: buyable}
if id == "free" {
t.Price = Price{}
}
return t
}
func TestOffer(t *testing.T) {
// held adds a held rung to a subscription on plus.
held := func(mod func(*Position)) Position {
p := holds("plus", true)
p.Held = tierOf("standard")
if mod != nil {
mod(&p)
}
return p
}
// below is a subscription on plus whose cancellation prediction exists.
below := func(follows string, cancelScheduled bool) Position {
p := holds("plus", true)
p.Predicted = true
p.Follows = follows
p.CancelScheduled = cancelScheduled
return p
}
with := func(tg Target, mod func(*Target)) Target {
mod(&tg)
return tg
}
tests := []struct {
name string
pos Position
target Target
kind Kind
enabled bool
reason Reason
relation Relation
held bool
}{
// What pays for the current rung, against a target above, below and equal.
{name: "nothing held offers checkout of any tier", pos: holds("", false), target: target("basic"),
kind: Checkout, enabled: true, relation: Available},
{name: "a grant offers checkout above it", pos: holds("plus", false), target: target("pro"),
kind: Checkout, enabled: true, relation: Upgrade},
{name: "a grant offers nothing below it", pos: holds("plus", false), target: target("basic"),
reason: BelowGrant, relation: Downgrade},
{name: "a grant offers nothing on its own tier", pos: holds("plus", false), target: target("plus"),
reason: Current},
{name: "a subscription offers a switch above it", pos: holds("plus", true), target: target("pro"),
kind: Switch, enabled: true, relation: Upgrade},
{name: "a subscription offers a switch below it", pos: holds("plus", true), target: target("basic"),
kind: Switch, enabled: true, relation: Downgrade},
{name: "a subscription offers nothing on its own tier", pos: holds("plus", true), target: target("plus"),
reason: Current},
// The held rung.
{name: "the held rung below the current rung is a cancellation", pos: held(nil), target: target("standard"),
kind: Cancel, enabled: true, relation: Downgrade, held: true},
{name: "the held rung is a cancellation without a price", pos: held(nil), target: with(target("standard"), func(t *Target) { t.Price = Price{} }),
kind: Cancel, enabled: true, relation: Downgrade, held: true},
{name: "the held rung above the current rung offers nothing",
pos: held(func(p *Position) { p.Held = tierOf("pro") }), target: target("pro"),
reason: HeldAbove, relation: Upgrade, held: true},
{name: "the held rung with the cancellation scheduled offers nothing",
pos: held(func(p *Position) { p.CancelScheduled = true }), target: target("standard"),
reason: HeldMoveMade, relation: Downgrade, held: true},
{name: "a rung below the held rung and the current rung offers nothing", pos: held(nil), target: target("basic"),
reason: BelowBoth, relation: Downgrade},
{name: "a rung between the held rung and the current rung is a switch",
pos: held(func(p *Position) { p.Held = tierOf("basic") }), target: target("standard"),
kind: Switch, enabled: true, relation: Downgrade},
// A scheduled downgrade.
{name: "a scheduled downgrade's target offers nothing",
pos: held(func(p *Position) { p.Held = nil; p.ScheduledTo = "standard" }), target: target("standard"),
reason: Scheduled, relation: Downgrade},
{name: "a scheduled downgrade onto the held rung keeps the held move",
pos: held(func(p *Position) { p.ScheduledTo = "standard" }), target: target("standard"),
kind: Cancel, enabled: true, relation: Downgrade, held: true},
{name: "a scheduled downgrade's target is a switch again once the cancellation is scheduled",
pos: held(func(p *Position) { p.Held = nil; p.ScheduledTo = "standard"; p.CancelScheduled = true }), target: target("standard"),
kind: Switch, enabled: true, relation: Downgrade},
// The unpriced rank-0 tier.
{name: "the free rung is conferred when nothing is held", pos: holds("", false), target: target("free"),
reason: Conferred, relation: Available},
{name: "the free rung below a subscription whose prediction keeps it is a cancellation", pos: below("free", false), target: target("free"),
kind: Cancel, enabled: true, relation: Downgrade},
{name: "the free rung below a subscription whose prediction follows another product offers nothing", pos: below("basic", false), target: target("free"),
reason: FreeRungNotFollowing, relation: Downgrade},
{name: "the free rung below a subscription whose prediction follows nothing offers nothing", pos: below("", false), target: target("free"),
reason: FreeRungNotFollowing, relation: Downgrade},
{name: "the free rung below a scheduled cancellation offers nothing", pos: below("free", true), target: target("free"),
reason: FreeRungNotFollowing, relation: Downgrade},
{name: "the free rung below a subscription with no prediction is a cancellation", pos: holds("plus", true), target: target("free"),
kind: Cancel, enabled: true, relation: Downgrade},
{name: "the free rung below a grant offers nothing", pos: holds("plus", false), target: target("free"),
reason: BelowGrant, relation: Downgrade},
{name: "a priced rank-0 tier below a subscription is a switch", pos: holds("plus", true),
target: with(target("free"), func(t *Target) { t.Price = buyable }),
kind: Switch, enabled: true, relation: Downgrade},
{name: "a priced rank-0 tier with nothing held is checkout", pos: holds("", false),
target: with(target("free"), func(t *Target) { t.Price = buyable }),
kind: Checkout, enabled: true, relation: Available},
{name: "an unpriced tier above rank 0 is a disabled checkout", pos: holds("", false),
target: with(target("basic"), func(t *Target) { t.Price = Price{} }),
kind: Checkout, reason: NoPrice, relation: Available},
{name: "an unpriced tier above rank 0 is a disabled switch", pos: holds("basic", true),
target: with(target("plus"), func(t *Target) { t.Price = Price{} }),
kind: Switch, reason: NoPrice, relation: Upgrade},
// The price rule.
{name: "another price than the default offers nothing", pos: holds("", false),
target: with(target("basic"), func(t *Target) { t.Price.Default = false }),
reason: NotDefault, relation: Available},
{name: "another price than the default offers no switch", pos: holds("basic", true),
target: with(target("plus"), func(t *Target) { t.Price.Default = false }),
reason: NotDefault, relation: Upgrade},
{name: "a one-time price is a disabled checkout", pos: holds("", false),
target: with(target("basic"), func(t *Target) { t.Price.Recurring = false }),
kind: Checkout, reason: OneTime, relation: Available},
{name: "a one-time price is a disabled switch", pos: holds("basic", true),
target: with(target("plus"), func(t *Target) { t.Price.Recurring = false }),
kind: Switch, reason: OneTime, relation: Upgrade},
{name: "a price that is not purchasable is a disabled checkout", pos: holds("", false),
target: with(target("basic"), func(t *Target) { t.Price.Purchasable = false }),
kind: Checkout, reason: Unpurchasable, relation: Available},
// The member gate.
{name: "a product outside the member gate is offered no checkout", pos: holds("", false),
target: with(target("basic"), func(t *Target) { t.Listed = false }),
reason: NotListed, relation: Available},
{name: "a product outside the member gate is offered no switch", pos: holds("basic", true),
target: with(target("plus"), func(t *Target) { t.Listed = false }),
reason: NotListed, relation: Upgrade},
{name: "the held rung outside the member gate is still a cancellation", pos: held(nil),
target: with(target("standard"), func(t *Target) { t.Listed = false }),
kind: Cancel, enabled: true, relation: Downgrade, held: true},
// Placement.
{name: "a target that is not a tier of the ladder offers nothing", pos: holds("", false), target: target("elsewhere"),
reason: NotOnLadder},
{name: "a current product that is not a tier of the ladder offers nothing", pos: holds("elsewhere", true), target: target("plus"),
reason: Unplaced},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Offer(tc.pos, tc.target)
want := Move{Kind: tc.kind, Enabled: tc.enabled, Reason: tc.reason, Relation: tc.relation, Held: tc.held}
if got != want {
t.Errorf("Offer = %+v, want %+v", got, want)
}
})
}
}
func TestAcross(t *testing.T) {
enabled := Move{Kind: Checkout, Enabled: true, Relation: Available}
disabled := Move{Kind: Checkout, Reason: OneTime, Relation: Available}
none := Move{Reason: BelowGrant, Relation: Downgrade}
sw := Move{Kind: Switch, Enabled: true, Relation: Upgrade}
tests := []struct {
name string
moves []Move
want Move
}{
{"no ladder", nil, Move{Reason: NotOnLadder}},
{"one ladder", []Move{enabled}, enabled},
{"every ladder offering an enabled checkout", []Move{enabled, enabled},
Move{Kind: Checkout, Enabled: true}},
{"one ladder offering checkout disabled", []Move{enabled, disabled},
Move{Kind: Checkout, Reason: OneTime}},
{"one ladder offering none", []Move{enabled, none}, Move{Reason: OtherLadder}},
{"one ladder offering a switch", []Move{enabled, sw}, Move{Reason: OtherLadder}},
{"a disabled checkout beside none", []Move{disabled, none}, Move{Reason: OtherLadder}},
{"one lone ladder offering none keeps its own reason", []Move{none}, none},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Across(tc.moves); got != tc.want {
t.Errorf("Across = %+v, want %+v", got, tc.want)
}
})
}
}
func TestOffLadder(t *testing.T) {
tests := []struct {
name string
edit func(*Target)
want Move
}{
{"offered", func(*Target) {}, Move{Kind: Checkout, Enabled: true}},
{"outside the member gate", func(t *Target) { t.Listed = false }, Move{Reason: NotListed}},
{"no price", func(t *Target) { t.Price = Price{} }, Move{Kind: Checkout, Reason: NoPrice}},
{"another price", func(t *Target) { t.Price.Default = false }, Move{Reason: NotDefault}},
{"a one-time price", func(t *Target) { t.Price.Recurring = false }, Move{Kind: Checkout, Reason: OneTime}},
{"not purchasable", func(t *Target) { t.Price.Purchasable = false }, Move{Kind: Checkout, Reason: Unpurchasable}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tg := target("addon")
tc.edit(&tg)
if got := OffLadder(tg); got != tc.want {
t.Errorf("OffLadder = %+v, want %+v", got, tc.want)
}
})
}
}
func TestMoveAllows(t *testing.T) {
kinds := []Kind{None, Checkout, Switch, Cancel}
for _, k := range []Kind{Checkout, Switch, Cancel} {
on := Move{Kind: k, Enabled: true}
off := Move{Kind: k}
for _, other := range kinds {
if got, want := on.Allows(other), other == k; got != want {
t.Errorf("enabled %q move allows %q = %v, want %v", k, other, got, want)
}
if off.Allows(other) {
t.Errorf("disabled %q move allows %q", k, other)
}
}
}
for _, other := range kinds {
if (Move{}).Allows(other) {
t.Errorf("None allows %q", other)
}
}
}
+1
View File
@@ -31,6 +31,7 @@ internal/lint.Routes # the route table the forms registry test reads (permanent
internal/lint.Route.IsMutation # same (permanent)
# Owned: removed by the step or issue named in the parentheses.
internal/plans.* # the offer decision lands one commit before its callers (offer-enforcement section 2)
internal/entitlements.RuleChangeCommit # the tests' entry point to the rule-change commit (#166 step 5)
internal/entitlements.DrainObligation # the tests' entry point to the obligation drain (#166 step 5)
internal/httplimit.IsTooLarge # no production caller yet (#146)