Files
at-container-registry/pkg/billing/tier_resolution_test.go
T
Evan JarrettandClaude Opus 5 3263156067 billing: key entitlements on the Stripe product, not the price
Tier resolution matched a subscription's price ID against the configured
stripe_price_monthly/stripe_price_yearly, which inverts what a price change
is supposed to do. Stripe prices are immutable, so changing what a tier costs
means creating a new price, and Stripe never migrates existing subscribers off
the old one. Updating the config to the new price IDs therefore un-tiers
precisely the subscribers a price change is meant to leave alone.

They did not even drop cleanly to free. An unresolved tier logs a warning,
returns nil, and the event is recorded in stripe_processed_events -- so Stripe
answers 200, never redelivers, and a later dashboard Resend is swallowed by the
idempotency check. Reproduced against the sandbox: a subscription on a price
the config does not list granted nothing, and the event could not be replayed
afterwards.

A tier has one product and many prices over its life, so the product is the
durable key for an entitlement. Tiers gain a stripe_product field, and
resolution tries the product first, falling back to the price IDs so configs
without it keep working unchanged. Checkout still keys on price -- that
direction has to name a specific price to charge.

Verified live: a subscription on a price created outside the config, under the
Pro product, resolved to tierName=Pro tierRank=2 and landed on the hold. The
same shape with an unknown product produced the silent no-op before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB
2026-08-25 16:34:26 -05:00

181 lines
6.2 KiB
Go

//go:build billing
package billing
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/stripe/stripe-go/v84"
)
// Tier resolution used to key on the price ID alone, which quietly inverted
// what a price change is supposed to do. Stripe prices are immutable, so
// raising a price means creating a new one, and Stripe never moves existing
// subscribers off the old one. Matching on price therefore dropped exactly the
// subscribers a price change is meant to leave untouched — and dropped them
// into the silent branch, where the event is answered 200, recorded as
// processed, and can never be redelivered.
func productPrice(priceID, productID string) *stripe.Price {
p := &stripe.Price{ID: priceID}
if productID != "" {
p.Product = &stripe.Product{ID: productID}
}
return p
}
func tierTestConfig() *Config {
return &Config{Tiers: []BillingTierConfig{
{Name: "free"},
{
Name: "supporter",
StripeProduct: "prod_supporter",
StripePriceMonthly: "price_supporter_v2",
},
{
Name: "pro",
StripePriceMonthly: "price_pro_only",
},
}}
}
// TestResolveTier_GrandfatheredPriceResolvesByProduct is the whole point of
// keying on the product: a subscriber still billing on price_supporter_v1,
// which no longer appears anywhere in the config, keeps their tier.
func TestResolveTier_GrandfatheredPriceResolvesByProduct(t *testing.T) {
m := &Manager{cfg: tierTestConfig()}
name, rank := m.resolveTier(productPrice("price_supporter_v1_retired", "prod_supporter"))
if name != "supporter" || rank != 1 {
t.Errorf("resolveTier(retired price, live product) = (%q, %d), want (\"supporter\", 1) — "+
"a subscriber on the old price lost their tier", name, rank)
}
}
// TestResolveTier_FallsBackToPriceWhenNoProductConfigured: a config written
// before stripe_product existed must keep resolving exactly as it did.
func TestResolveTier_FallsBackToPriceWhenNoProductConfigured(t *testing.T) {
m := &Manager{cfg: tierTestConfig()}
name, rank := m.resolveTier(productPrice("price_pro_only", "prod_pro_unconfigured"))
if name != "pro" || rank != 2 {
t.Errorf("resolveTier = (%q, %d), want (\"pro\", 2) — the price fallback stopped working", name, rank)
}
}
// TestResolveTier_ProductWinsOverPrice: when both could match, the product
// decides. Otherwise a price ID left behind on the wrong tier could outvote the
// product and grant the wrong entitlement.
func TestResolveTier_ProductWinsOverPrice(t *testing.T) {
m := &Manager{cfg: &Config{Tiers: []BillingTierConfig{
{Name: "free"},
{Name: "supporter", StripeProduct: "prod_supporter"},
{Name: "pro", StripePriceMonthly: "price_shared"},
}}}
name, _ := m.resolveTier(productPrice("price_shared", "prod_supporter"))
if name != "supporter" {
t.Errorf("resolveTier = %q, want \"supporter\" — the price ID outvoted the product", name)
}
}
// TestResolveTier_UnknownEverythingIsUnresolved keeps the negative honest: a
// price on a product the config has never heard of must not resolve to a tier.
func TestResolveTier_UnknownEverythingIsUnresolved(t *testing.T) {
m := &Manager{cfg: tierTestConfig()}
if name, rank := m.resolveTier(productPrice("price_unknown", "prod_unknown")); name != "" || rank != -1 {
t.Errorf("resolveTier(unknown) = (%q, %d), want (\"\", -1)", name, rank)
}
if name, rank := m.resolveTier(nil); name != "" || rank != -1 {
t.Errorf("resolveTier(nil) = (%q, %d), want (\"\", -1)", name, rank)
}
}
// TestHandleSubscriptionChange_GrandfatheredSubscriberKeepsTier runs the same
// property through the real webhook path, so it covers the payload shape too:
// Stripe sends price.product as a bare ID string, and this fails if that is
// ever read wrongly.
//
// It asserts on the rank the managed hold is actually asked to apply. An
// earlier draft asserted that the event was recorded as processed, and passed
// against price-only resolution — because an unresolved tier ALSO records the
// event and returns nil. That is the defect itself, so any assertion it
// satisfies cannot be measuring the fix.
func TestHandleSubscriptionChange_GrandfatheredSubscriberKeepsTier(t *testing.T) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
const secret = "whsec_grandfather_test"
m, _ := newTestManager(t, secret)
m.cfg.Tiers = tierTestConfig().Tiers
stripeAPIReturning(t, http.StatusOK,
`{"id":"cus_gf","object":"customer","metadata":{"user_did":"did:plc:grandfathered"}}`)
type tierPush struct {
UserDID string `json:"userDid"`
TierRank int `json:"tierRank"`
}
pushes := make(chan tierPush, 4)
hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var got tierPush
_ = json.NewDecoder(r.Body).Decode(&got)
pushes <- got
w.WriteHeader(http.StatusOK)
}))
defer hold.Close()
m.managedHolds = []string{"did:web:" + strings.ReplaceAll(
strings.TrimPrefix(hold.URL, "http://"), ":", "%3A")}
priv, err := atcrypto.GeneratePrivateKeyP256()
if err != nil {
t.Fatalf("generate key: %v", err)
}
m.privateKey = priv
// price_supporter_v1_retired appears nowhere in the config. Only its
// product does — this is the subscriber a price change left behind.
payload := fmt.Sprintf(`{
"id": "evt_grandfathered",
"object": "event",
"api_version": %q,
"type": "customer.subscription.updated",
"created": %d,
"data": {"object": {
"id": "sub_gf",
"object": "subscription",
"status": "active",
"customer": "cus_gf",
"items": {"object":"list","data":[{"price":{
"id":"price_supporter_v1_retired",
"product":"prod_supporter"
}}]}
}}
}`, stripe.APIVersion, time.Now().Unix())
if err := postWebhook(t, m, secret, []byte(payload)); err != nil {
t.Fatalf("webhook: %v", err)
}
select {
case got := <-pushes:
if got.TierRank != 1 {
t.Errorf("hold was asked for tierRank %d, want 1 (supporter)", got.TierRank)
}
if got.UserDID != "did:plc:grandfathered" {
t.Errorf("hold was asked to update %q, want did:plc:grandfathered", got.UserDID)
}
case <-time.After(2 * time.Second):
t.Fatal("no tier push reached the hold — the grandfathered subscriber's tier was never applied")
}
}