mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
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
181 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|