package main import ( "context" "encoding/json" "net/http" "strings" "testing" "time" ) // fakePay is a scriptable PayProvider so the entitlement/webhook flow is // testable end-to-end without touching Stripe. Configured() true so handlers // don't early-return. type fakePay struct { checkoutURL string switched [][2]string // (extSub, newPriceRef) pairs seen by SwitchLevel cancelled []string resumed []string events []SubEvent // returned by the next ParseWebhook subStates map[string]SubEvent // SubState fixtures: extSub → current provider truth } func (f *fakePay) Configured() bool { return true } func (f *fakePay) Checkout(context.Context, CheckoutReq) (string, error) { return f.checkoutURL, nil } func (f *fakePay) Cancel(_ context.Context, sub string) error { f.cancelled = append(f.cancelled, sub) return nil } func (f *fakePay) Resume(_ context.Context, sub string) error { f.resumed = append(f.resumed, sub) return nil } func (f *fakePay) SwitchLevel(_ context.Context, sub, price, _ string) error { f.switched = append(f.switched, [2]string{sub, price}) return nil } func (f *fakePay) CreatePrice(context.Context, string, int, string, string) (string, error) { return "price_created", nil } func (f *fakePay) PortalURL(context.Context, string, string) (string, error) { return "https://portal.example/x", nil } func (f *fakePay) ParseWebhook(string, []byte) ([]SubEvent, error) { return f.events, nil } func (f *fakePay) SubState(_ context.Context, extSub string) (SubEvent, bool, error) { ev, ok := f.subStates[extSub] return ev, ok, nil } // mkTierMember seeds a level + a keyed member; returns (tierID, uid). func seedTierAndMember(t *testing.T, srv *Server, level, label string) (string, string) { t.Helper() tid, err := srv.db.createTier(level) if err != nil { t.Fatalf("createTier: %v", err) } u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser: %v", err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey: %v", err) } return tid, u } // TestEntitlementStateMachine drives applySubEvent through the lifecycle that // docs/community-and-tiers.md § revocation specifies, with no Stripe. func TestEntitlementStateMachine(t *testing.T) { _, srv := newTestServer(t) billingGrace = 30 * 24 * time.Hour tid, uid := seedTierAndMember(t, srv, "Gold", "bea") now := int64(1_000_000) month := int64(30 * 24 * 3600) // 1. Active, healthy → access, no end. active, err := applySubEvent(srv.db, SubEvent{ExtSub: "sub1", ExtCustomer: "cus1", Status: "active", CurrentPeriodEnd: now + month}, tid, uid, now) if err != nil || !active { t.Fatalf("active: active=%v err=%v", active, err) } e, ok := srv.db.memberEntitlement(tid, uid) if !ok || e.Status != "active" || e.PaidUntil != now+month || e.GraceDeadline != 0 { t.Fatalf("after active: %+v ok=%v", e, ok) } // 2. Cancel at period end → still active until the period end, then it ends // there (no bonus grace). active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "active", CancelAtPeriodEnd: true, CurrentPeriodEnd: now + month}, tid, uid, now) e, _ = srv.db.memberEntitlement(tid, uid) if !active || e.Status != "canceled" || e.GraceDeadline != now+month { t.Fatalf("cancel-at-period-end: active=%v %+v", active, e) } // …and past that period end, not active. active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "active", CancelAtPeriodEnd: true, CurrentPeriodEnd: now}, tid, uid, now+1) if active { t.Fatalf("cancel past period end should be inactive") } // 3. Payment failure → 30-day grace from the paid-until, active during it. active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "past_due", CurrentPeriodEnd: now}, tid, uid, now) e, _ = srv.db.memberEntitlement(tid, uid) if !active || e.Status != "past_due" || e.GraceDeadline != now+int64(billingGrace.Seconds()) { t.Fatalf("past_due: active=%v %+v", active, e) } if a, _ := applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "past_due", CurrentPeriodEnd: now}, tid, uid, now+int64(billingGrace.Seconds())+1); a { t.Fatalf("past grace should be inactive") } // 4. Outright cancel/delete → inactive, revoke on next sweep. active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "canceled", Deleted: true, CurrentPeriodEnd: now}, tid, uid, now) e, _ = srv.db.memberEntitlement(tid, uid) if active || e.GraceDeadline != now { t.Fatalf("deleted: active=%v %+v", active, e) } } // TestBillingSweepRevokes: a membership past its grace deadline is unassigned // (key + membership dropped) by the sweep; one still in grace is left alone. func TestBillingSweepRevokes(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "bea") tid2, uid2 := seedTierAndMember(t, srv, "Silver", "cal") now := int64(2_000_000) // Bea: past grace. Cal: still within grace. srv.db.assignTier(tid, uid, "stripe") srv.db.grantTierKey(tid, uid, "wrapped-bea") srv.db.setEntitlement(Entitlement{TierID: tid, UserID: uid, Status: "past_due", PaidUntil: now - 100, GraceDeadline: now - 1, ExtSub: "sub-bea"}) srv.db.assignTier(tid2, uid2, "stripe") srv.db.grantTierKey(tid2, uid2, "wrapped-cal") srv.db.setEntitlement(Entitlement{TierID: tid2, UserID: uid2, Status: "past_due", PaidUntil: now, GraceDeadline: now + 1000, ExtSub: "sub-cal"}) if n := srv.runBillingSweep(now); n != 1 { t.Fatalf("sweep revoked %d, want 1", n) } if srv.db.tierMember(tid, uid) { t.Fatalf("Bea should have been revoked") } if srv.db.tierHolder(tid, uid) { t.Fatalf("Bea's key row should be gone") } if !srv.db.tierMember(tid2, uid2) { t.Fatalf("Cal (in grace) should be kept") } } // TestWebhookActivatesAndMigrates: a checkout activates the membership (assign // + key prod path via assignTier), and a switch-level event migrates it. func TestWebhookActivates(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "bea") now := int64(3_000_000) // Activate via a normalized checkout event carrying our refs (AllowCreate — // only a genuine new-subscription signal creates a membership). srv.applyWebhookEvent(context.Background(), SubEvent{ ExtSub: "sub9", ExtCustomer: "cus9", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 100000, AllowCreate: true, Created: now, }, now) if !srv.db.tierMember(tid, uid) { t.Fatalf("member not assigned after active webhook") } e, ok := srv.db.memberEntitlement(tid, uid) if !ok || e.Status != "active" || e.ExtSub != "sub9" { t.Fatalf("entitlement after activate: %+v ok=%v", e, ok) } // Switch level: same sub, new tier metadata → old membership dropped, new one on. tid2, err := srv.db.createTier("Platinum") if err != nil { t.Fatalf("createTier: %v", err) } srv.applyWebhookEvent(context.Background(), SubEvent{ ExtSub: "sub9", ExtCustomer: "cus9", UserRef: uid, TierRef: tid2, Status: "active", CurrentPeriodEnd: now + 100000, }, now) if srv.db.tierMember(tid, uid) { t.Fatalf("old level should be dropped after switch") } if !srv.db.tierMember(tid2, uid) { t.Fatalf("new level should be assigned after switch") } // A delete revokes. srv.applyWebhookEvent(context.Background(), SubEvent{ ExtSub: "sub9", UserRef: uid, TierRef: tid2, Status: "canceled", Deleted: true, }, now) if srv.db.tierMember(tid2, uid) { t.Fatalf("delete should revoke") } } // TestSubscribeHandlers exercises the HTTP surface with fakePay: admin sets a // price, a member subscribes (gets the checkout URL), and cancel routes to the // provider. Confirms the endpoints are wired + gated. func TestSubscribeHandlers(t *testing.T) { ts, srv := newTestServer(t) fake := &fakePay{checkoutURL: "https://pay.example/checkout"} srv.pay = fake mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser: %v", err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite: %v", err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claim: ok=%v err=%v", ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey: %v", err) } return tk, u } post := func(path, body string) (int, map[string]any) { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", path, err) } defer resp.Body.Close() var out map[string]any _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out } tokAdmin, uidAdmin := mkUser("Root") if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } tokBea, uidBea := mkUser("Bea") tid, _ := srv.db.createTier("Gold") // A member can't set a price; an admin can (fakePay mints the provider ref). if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokBea+`","cents":500,"currency":"eur","interval":"month"}`); code != 403 { t.Fatalf("member set price: %d, want 403", code) } if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokAdmin+`","cents":500,"currency":"eur","interval":"month"}`); code != 200 { t.Fatalf("admin set price: %d", code) } cents, _, _, ref, sellable := srv.db.tierPrice(tid) if cents != 500 || ref != "price_created" || !sellable { t.Fatalf("price stored wrong: %d %q %v", cents, ref, sellable) } // Member subscribe → checkout URL. code, out := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`) if code != 200 || out["url"] != "https://pay.example/checkout" { t.Fatalf("subscribe: %d %v", code, out) } // Simulate the webhook activating Bea, then cancel routes to the provider. srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uidBea, TierRef: tid, Status: "active", CurrentPeriodEnd: 9999999999, AllowCreate: true, Created: 1000}, 1000) if code, _ := post("/api/tiers/"+tid+"/cancel-subscription", `{"token":"`+tokBea+`"}`); code != 200 { t.Fatalf("cancel: %d", code) } if len(fake.cancelled) != 1 || fake.cancelled[0] != "s1" { t.Fatalf("cancel didn't route to provider: %v", fake.cancelled) } // Bea already holds a subscription here → a second subscribe is refused (no // duplicate checkout, no double charge), and Resume routes to the provider. if code, _ := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`); code != 409 { t.Fatalf("duplicate subscribe: %d, want 409", code) } if code, _ := post("/api/tiers/"+tid+"/resume", `{"token":"`+tokBea+`"}`); code != 200 { t.Fatalf("resume: %d", code) } if len(fake.resumed) != 1 || fake.resumed[0] != "s1" { t.Fatalf("resume didn't route to provider: %v", fake.resumed) } // A zero-decimal currency (would be charged 100×) is rejected. if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokAdmin+`","cents":50000,"currency":"jpy","interval":"month"}`); code != 400 { t.Fatalf("jpy price: %d, want 400", code) } // Dormant provider → subscribe refuses cleanly. srv.pay = noPay{} if code, _ := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`); code != 503 { t.Fatalf("dormant subscribe: %d, want 503", code) } } // TestWebhookNoResurrect: after a subscription is revoked (row gone), a stale // redelivered "active" (a bare update, not a new-subscription signal) must NOT // re-create the membership. func TestWebhookNoResurrect(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "bea") now := int64(5_000_000) must := func(err error) { t.Helper() if err != nil { t.Fatalf("applyWebhookEvent: %v", err) } } must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now)) must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 10}, now+10)) if srv.db.tierMember(tid, uid) { t.Fatalf("should be revoked after delete") } // Stale redelivered active (older, no AllowCreate) → dropped, not resurrected. must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, Created: now + 5}, now+20)) if srv.db.tierMember(tid, uid) { t.Fatalf("stale active resurrected a revoked membership") } } // TestWebhookSupersededSubIgnored: a member active on a NEW subscription isn't // kicked (nor is their record corrupted) by a late event for the OLD one. func TestWebhookSupersededSubIgnored(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "bea") now := int64(6_000_000) srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "S2", ExtCustomer: "c", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now) // Late delete for the superseded S1 must not touch the S2 membership. srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "S1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 5}, now+5) if !srv.db.tierMember(tid, uid) { t.Fatalf("member on S2 wrongly revoked by S1's delete") } e, _ := srv.db.memberEntitlement(tid, uid) if e.ExtSub != "S2" || e.Status != "active" { t.Fatalf("S1 event corrupted the S2 entitlement: %+v", e) } } // TestSubscriptionLapseKeepsAdminGrant: a paid subscription over an admin comp // tracks its lifecycle but never flips the sticky source, so a lapse clears the // subscription yet keeps the admin-granted access + key. func TestSubscriptionLapseKeepsAdminGrant(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "bea") now := int64(7_000_000) if err := srv.db.assignTier(tid, uid, "admin"); err != nil { t.Fatalf("assignTier: %v", err) } srv.db.grantTierKey(tid, uid, "wrapped") srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now) if src, _ := srv.db.tierSource(tid, uid); src != "admin" { t.Fatalf("source flipped to %q, want admin", src) } srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 10}, now+10) if !srv.db.tierMember(tid, uid) || !srv.db.tierHolder(tid, uid) { t.Fatalf("admin-granted access/key wrongly removed on subscription lapse") } if e, ok := srv.db.memberEntitlement(tid, uid); ok && e.ExtSub != "" { t.Fatalf("subscription tracking not cleared: %+v", e) } } // TestPaidUntilZeroGuard: a checkout event (no period end) arriving after an // update that already stored the renewal date must not zero it. func TestPaidUntilZeroGuard(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "bea") now := int64(8_000_000) applySubEvent(srv.db, SubEvent{ExtSub: "s1", Status: "active", CurrentPeriodEnd: now + 5000, Created: now}, tid, uid, now) applySubEvent(srv.db, SubEvent{ExtSub: "s1", Status: "active", CurrentPeriodEnd: 0, Created: now + 1}, tid, uid, now+1) if e, _ := srv.db.memberEntitlement(tid, uid); e.PaidUntil != now+5000 { t.Fatalf("paid_until zeroed to %d, want %d", e.PaidUntil, now+5000) } } // TestBillingSweepBackstop: the sweep revokes a subscription whose paid period // lapsed more than the grace window ago even if no lapse webhook ever arrived // (grace_deadline still 0) — the true backstop — while leaving a healthy sub. func TestBillingSweepBackstop(t *testing.T) { _, srv := newTestServer(t) billingGrace = 30 * 24 * time.Hour grace := int64(billingGrace.Seconds()) now := int64(9_000_000) tid, uid := seedTierAndMember(t, srv, "Gold", "bea") // silently lapsed tid2, uid2 := seedTierAndMember(t, srv, "Silver", "cal") // healthy srv.db.assignTier(tid, uid, "stripe") srv.db.grantTierKey(tid, uid, "w") srv.db.setEntitlement(Entitlement{TierID: tid, UserID: uid, Status: "active", PaidUntil: now - grace - 100, GraceDeadline: 0, ExtSub: "s1"}) srv.db.assignTier(tid2, uid2, "stripe") srv.db.grantTierKey(tid2, uid2, "w2") srv.db.setEntitlement(Entitlement{TierID: tid2, UserID: uid2, Status: "active", PaidUntil: now + grace + 100000, GraceDeadline: 0, ExtSub: "s2"}) if n := srv.runBillingSweep(now); n != 1 { t.Fatalf("backstop sweep revoked %d, want 1", n) } if srv.db.tierMember(tid, uid) { t.Fatalf("silently-lapsed sub not revoked") } if !srv.db.tierMember(tid2, uid2) { t.Fatalf("healthy sub wrongly revoked") } } // TestCheckoutFirstDeliveryKeepsPeriodEnd pins the delivery-order race found // live on the first real sandbox run: Stripe stamps checkout.session.completed // one second AFTER customer.subscription.created but delivered it FIRST. The // checkout receipt (which carries no period end) must not claim the ordering // watermark, or the created event — the only one with current_period_end — is // dropped as stale and paid_until sticks at 0. func TestCheckoutFirstDeliveryKeepsPeriodEnd(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "cara") now := int64(2_000_000) month := int64(30 * 24 * 3600) // Receipts resolve to provider truth now; give the fake the sub's state. srv.pay = &fakePay{subStates: map[string]SubEvent{ "subX": {Status: "active", CurrentPeriodEnd: now + month}, }} // Delivered first: the checkout receipt, stamped now (later than the // subscription.created it reports on). if err := srv.applyWebhookEvent(context.Background(), SubEvent{ ExtSub: "subX", ExtCustomer: "cusX", UserRef: uid, TierRef: tid, Status: "active", Created: now, AllowCreate: true, Checkout: true, }, now); err != nil { t.Fatalf("checkout event: %v", err) } // Delivered second: subscription.created, stamped a second EARLIER, carrying // the real period end. It must still fold. if err := srv.applyWebhookEvent(context.Background(), SubEvent{ ExtSub: "subX", ExtCustomer: "cusX", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + month, Created: now - 1, AllowCreate: true, }, now); err != nil { t.Fatalf("created event: %v", err) } e, ok := srv.db.memberEntitlement(tid, uid) if !ok || e.PaidUntil != now+month { t.Fatalf("paid_until = %d, want %d (subscription.created dropped by the watermark?)", e.PaidUntil, now+month) } // The watermark still guards real lifecycle reordering: a stale pre-dating // "canceled" must be ignored. if err := srv.applyWebhookEvent(context.Background(), SubEvent{ ExtSub: "subX", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now - 2, }, now); err != nil { t.Fatalf("stale delete: %v", err) } if e, ok = srv.db.memberEntitlement(tid, uid); !ok || e.Status != "active" { t.Fatalf("status = %q after stale delete, want active", e.Status) } // And the reverse order stays correct: a late checkout receipt neither // zeroes paid_until nor winds the watermark back. if err := srv.applyWebhookEvent(context.Background(), SubEvent{ ExtSub: "subX", ExtCustomer: "cusX", UserRef: uid, TierRef: tid, Status: "active", Created: now + 1, AllowCreate: true, Checkout: true, }, now); err != nil { t.Fatalf("late checkout: %v", err) } if e, ok = srv.db.memberEntitlement(tid, uid); !ok || e.PaidUntil != now+month || e.EventTS != now-1 { t.Fatalf("after late checkout: paid_until=%d eventTS=%d, want %d/%d", e.PaidUntil, e.EventTS, now+month, now-1) } } // TestLevelSwitchResetsWatermark pins the review finding on the MAX() rewrite: // when a level switch moves a subscription onto a row that already carried a // DIFFERENT subscription's higher watermark, the watermark must reset to the // incoming event's stamp — a blanket MAX() would keep the stale-high mark and // drop the moved sub's later, lower-stamped lifecycle events (a cancel would // never fold; the member would keep access past period end). func TestLevelSwitchResetsWatermark(t *testing.T) { _, srv := newTestServer(t) tidGold, uid := seedTierAndMember(t, srv, "Gold", "dana") tidSilver, err := srv.db.createTier("Silver") if err != nil { t.Fatalf("createTier: %v", err) } now := int64(3_000_000) must := func(err error) { t.Helper() if err != nil { t.Fatalf("applyWebhookEvent: %v", err) } } // Member holds Silver via subB, whose watermark sits HIGH (an event stamped // now+5 was delivered early), and Gold via subA (stamped now). must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subB", ExtCustomer: "c", UserRef: uid, TierRef: tidSilver, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now + 5}, now)) must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subA", ExtCustomer: "c", UserRef: uid, TierRef: tidGold, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now)) // Level switch: subA's metadata now points at Silver; its update (stamped // now) moves it onto the Silver row, displacing subB. must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subA", ExtCustomer: "c", UserRef: uid, TierRef: tidSilver, Status: "active", CurrentPeriodEnd: now + 2000, Created: now}, now)) // The member cancels subA. The delete is stamped now+2 — LOWER than subB's // leftover now+5 — and must still fold and revoke. must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subA", UserRef: uid, TierRef: tidSilver, Status: "canceled", Deleted: true, Created: now + 2}, now+2)) if e, ok := srv.db.memberEntitlement(tidSilver, uid); ok && e.Status == "active" { t.Fatalf("subA's cancel was dropped by the displaced sub's stale watermark (status=%q)", e.Status) } } // TestCheckoutReceiptCannotResurrect pins the resurrection hole the #846 // review confirmed: a checkout receipt redelivered after the subscription was // deleted and the membership revoked must not re-create it — receipts resolve // to provider truth before folding. func TestCheckoutReceiptCannotResurrect(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "elif") now := int64(4_000_000) fake := &fakePay{subStates: map[string]SubEvent{}} srv.pay = fake must := func(err error) { t.Helper() if err != nil { t.Fatalf("applyWebhookEvent: %v", err) } } // Subscribe, then the sub is deleted at the provider → revoked, row gone. must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subY", ExtCustomer: "c", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now)) must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subY", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 10}, now+10)) if _, ok := srv.db.memberEntitlement(tid, uid); ok { t.Fatalf("membership should be revoked after the delete") } // Stripe retries the receipt; the provider no longer knows the sub. must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subY", ExtCustomer: "c", UserRef: uid, TierRef: tid, Status: "active", AllowCreate: true, Checkout: true, Created: now + 1}, now+20)) if _, ok := srv.db.memberEntitlement(tid, uid); ok { t.Fatalf("redelivered checkout receipt resurrected a revoked membership (sub unknown at provider)") } // Even when the provider still knows the sub — as canceled — nothing sticks. fake.subStates["subY"] = SubEvent{Status: "canceled"} must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subY", ExtCustomer: "c", UserRef: uid, TierRef: tid, Status: "active", AllowCreate: true, Checkout: true, Created: now + 2}, now+20)) if e, ok := srv.db.memberEntitlement(tid, uid); ok && e.Status == "active" { t.Fatalf("canceled-at-provider receipt granted access (status=%q)", e.Status) } } // TestCheckoutReceiptResolvesRealState: a receipt whose subscription events // never arrive (lost past the provider's retry window) still lands the real // period end — the row stays sweepable instead of immortal. func TestCheckoutReceiptResolvesRealState(t *testing.T) { _, srv := newTestServer(t) tid, uid := seedTierAndMember(t, srv, "Gold", "hana") now := int64(5_000_000) srv.pay = &fakePay{subStates: map[string]SubEvent{ "subZ": {Status: "active", CurrentPeriodEnd: now + 500}, }} if err := srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subZ", ExtCustomer: "c", UserRef: uid, TierRef: tid, Status: "active", AllowCreate: true, Checkout: true, Created: now}, now); err != nil { t.Fatalf("applyWebhookEvent: %v", err) } e, ok := srv.db.memberEntitlement(tid, uid) if !ok || e.Status != "active" || e.PaidUntil != now+500 { t.Fatalf("receipt did not resolve to provider truth: ok=%v status=%q paid_until=%d", ok, e.Status, e.PaidUntil) } if e.EventTS != 0 { t.Fatalf("receipt advanced the watermark (EventTS=%d)", e.EventTS) } } // TestBillingSweepRepairsLostLifecycle: rows stuck active with no period end // are re-fetched from the provider and folded — or revoked when the provider // no longer knows the sub. func TestBillingSweepRepairsLostLifecycle(t *testing.T) { _, srv := newTestServer(t) tidA, uidA := seedTierAndMember(t, srv, "Gold", "fay") tidB, uidB := seedTierAndMember(t, srv, "Silver", "gus") now := int64(6_000_000) // Two poisoned rows: active, sub tracked, no period end (the pre-fix state). for _, r := range []struct{ sub, tid, uid string }{{"sA", tidA, uidA}, {"sB", tidB, uidB}} { if _, err := applySubEvent(srv.db, SubEvent{ExtSub: r.sub, ExtCustomer: "c", Status: "active"}, r.tid, r.uid, now); err != nil { t.Fatalf("seed: %v", err) } if err := srv.db.assignTier(r.tid, r.uid, "stripe"); err != nil { t.Fatalf("assignTier: %v", err) } } // The provider knows sA (active, real period end); sB is gone. srv.pay = &fakePay{subStates: map[string]SubEvent{ "sA": {Status: "active", CurrentPeriodEnd: now + 900}, }} srv.runBillingSweep(now) if e, ok := srv.db.memberEntitlement(tidA, uidA); !ok || e.PaidUntil != now+900 { t.Fatalf("repair did not restore sA's period end (ok=%v paid_until=%d)", ok, e.PaidUntil) } if _, ok := srv.db.memberEntitlement(tidB, uidB); ok { t.Fatalf("repair kept a membership whose sub the provider no longer knows") } } // TestBillingAccountIDFormats pins the link endpoint's id validation: classic // "acct_…" ids and Stripe's newer unprefixed opaque ids both link (a real live // connect returned a bare 32-char id, 2026-07-18 — the old acct_-prefix check // silently killed the whole OAuth flow), while junk is still rejected. func TestBillingAccountIDFormats(t *testing.T) { ts, srv := newTestServer(t) u, err := srv.db.createUser("Root") if err != nil { t.Fatalf("createUser: %v", err) } if err := srv.db.createKeyInvite("inv-root", u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite: %v", err) } tok, ok, err := srv.db.claimKeyInvite("inv-root", "Root", "", testClaimSecret) if err != nil || !ok { t.Fatalf("claim: ok=%v err=%v", ok, err) } if err := srv.db.setUserRole(u, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } link := func(acct string) int { resp, err := http.Post(ts.URL+"/api/admin/billing/account", "application/json", strings.NewReader(`{"token":"`+tok+`","account":"`+acct+`"}`)) if err != nil { t.Fatalf("post: %v", err) } resp.Body.Close() return resp.StatusCode } for _, good := range []string{"acct_1Trl9YB043v2tsWJ", "IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx", ""} { if code := link(good); code != 200 { t.Fatalf("link %q: %d, want 200", good, code) } } // The last good link ("") cleared it; relink and confirm it round-trips. if code := link("IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx"); code != 200 { t.Fatal("relink failed") } if acct, _, _ := srv.db.getConfig("stripe_connect_account"); acct != "IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx" { t.Fatalf("stored account = %q", acct) } for _, bad := range []string{"short", "has spaces in it", "acct_%3B;drop", strings.Repeat("x", 70)} { if code := link(bad); code != 400 { t.Fatalf("link %q: %d, want 400", bad, code) } } }