| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | // fakePay is a scriptable PayProvider so the entitlement/webhook flow is |
| 13 | // testable end-to-end without touching Stripe. Configured() true so handlers |
| 14 | // don't early-return. |
| 15 | type fakePay struct { |
| 16 | checkoutURL string |
| 17 | switched [][2]string // (extSub, newPriceRef) pairs seen by SwitchLevel |
| 18 | cancelled []string |
| 19 | resumed []string |
| 20 | events []SubEvent // returned by the next ParseWebhook |
| 21 | subStates map[string]SubEvent // SubState fixtures: extSub → current provider truth |
| 22 | } |
| 23 | |
| 24 | func (f *fakePay) Configured() bool { return true } |
| 25 | func (f *fakePay) Checkout(context.Context, CheckoutReq) (string, error) { |
| 26 | return f.checkoutURL, nil |
| 27 | } |
| 28 | func (f *fakePay) Cancel(_ context.Context, sub string) error { |
| 29 | f.cancelled = append(f.cancelled, sub) |
| 30 | return nil |
| 31 | } |
| 32 | func (f *fakePay) Resume(_ context.Context, sub string) error { |
| 33 | f.resumed = append(f.resumed, sub) |
| 34 | return nil |
| 35 | } |
| 36 | func (f *fakePay) SwitchLevel(_ context.Context, sub, price, _ string) error { |
| 37 | f.switched = append(f.switched, [2]string{sub, price}) |
| 38 | return nil |
| 39 | } |
| 40 | func (f *fakePay) CreatePrice(context.Context, string, int, string, string) (string, error) { |
| 41 | return "price_created", nil |
| 42 | } |
| 43 | func (f *fakePay) PortalURL(context.Context, string, string) (string, error) { |
| 44 | return "https://portal.example/x", nil |
| 45 | } |
| 46 | func (f *fakePay) ParseWebhook(string, []byte) ([]SubEvent, error) { return f.events, nil } |
| 47 | func (f *fakePay) SubState(_ context.Context, extSub string) (SubEvent, bool, error) { |
| 48 | ev, ok := f.subStates[extSub] |
| 49 | return ev, ok, nil |
| 50 | } |
| 51 | |
| 52 | // mkTierMember seeds a level + a keyed member; returns (tierID, uid). |
| 53 | func seedTierAndMember(t *testing.T, srv *Server, level, label string) (string, string) { |
| 54 | t.Helper() |
| 55 | tid, err := srv.db.createTier(level) |
| 56 | if err != nil { |
| 57 | t.Fatalf("createTier: %v", err) |
| 58 | } |
| 59 | u, err := srv.db.createUser(label) |
| 60 | if err != nil { |
| 61 | t.Fatalf("createUser: %v", err) |
| 62 | } |
| 63 | if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| 64 | t.Fatalf("setUserPublicKey: %v", err) |
| 65 | } |
| 66 | return tid, u |
| 67 | } |
| 68 | |
| 69 | // TestEntitlementStateMachine drives applySubEvent through the lifecycle that |
| 70 | // docs/community-and-tiers.md § revocation specifies, with no Stripe. |
| 71 | func TestEntitlementStateMachine(t *testing.T) { |
| 72 | _, srv := newTestServer(t) |
| 73 | billingGrace = 30 * 24 * time.Hour |
| 74 | tid, uid := seedTierAndMember(t, srv, "Gold", "bea") |
| 75 | now := int64(1_000_000) |
| 76 | month := int64(30 * 24 * 3600) |
| 77 | |
| 78 | // 1. Active, healthy → access, no end. |
| 79 | active, err := applySubEvent(srv.db, SubEvent{ExtSub: "sub1", ExtCustomer: "cus1", Status: "active", CurrentPeriodEnd: now + month}, tid, uid, now) |
| 80 | if err != nil || !active { |
| 81 | t.Fatalf("active: active=%v err=%v", active, err) |
| 82 | } |
| 83 | e, ok := srv.db.memberEntitlement(tid, uid) |
| 84 | if !ok || e.Status != "active" || e.PaidUntil != now+month || e.GraceDeadline != 0 { |
| 85 | t.Fatalf("after active: %+v ok=%v", e, ok) |
| 86 | } |
| 87 | |
| 88 | // 2. Cancel at period end → still active until the period end, then it ends |
| 89 | // there (no bonus grace). |
| 90 | active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "active", CancelAtPeriodEnd: true, CurrentPeriodEnd: now + month}, tid, uid, now) |
| 91 | e, _ = srv.db.memberEntitlement(tid, uid) |
| 92 | if !active || e.Status != "canceled" || e.GraceDeadline != now+month { |
| 93 | t.Fatalf("cancel-at-period-end: active=%v %+v", active, e) |
| 94 | } |
| 95 | // …and past that period end, not active. |
| 96 | active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "active", CancelAtPeriodEnd: true, CurrentPeriodEnd: now}, tid, uid, now+1) |
| 97 | if active { |
| 98 | t.Fatalf("cancel past period end should be inactive") |
| 99 | } |
| 100 | |
| 101 | // 3. Payment failure → 30-day grace from the paid-until, active during it. |
| 102 | active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "past_due", CurrentPeriodEnd: now}, tid, uid, now) |
| 103 | e, _ = srv.db.memberEntitlement(tid, uid) |
| 104 | if !active || e.Status != "past_due" || e.GraceDeadline != now+int64(billingGrace.Seconds()) { |
| 105 | t.Fatalf("past_due: active=%v %+v", active, e) |
| 106 | } |
| 107 | if a, _ := applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "past_due", CurrentPeriodEnd: now}, tid, uid, now+int64(billingGrace.Seconds())+1); a { |
| 108 | t.Fatalf("past grace should be inactive") |
| 109 | } |
| 110 | |
| 111 | // 4. Outright cancel/delete → inactive, revoke on next sweep. |
| 112 | active, _ = applySubEvent(srv.db, SubEvent{ExtSub: "sub1", Status: "canceled", Deleted: true, CurrentPeriodEnd: now}, tid, uid, now) |
| 113 | e, _ = srv.db.memberEntitlement(tid, uid) |
| 114 | if active || e.GraceDeadline != now { |
| 115 | t.Fatalf("deleted: active=%v %+v", active, e) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // TestBillingSweepRevokes: a membership past its grace deadline is unassigned |
| 120 | // (key + membership dropped) by the sweep; one still in grace is left alone. |
| 121 | func TestBillingSweepRevokes(t *testing.T) { |
| 122 | _, srv := newTestServer(t) |
| 123 | tid, uid := seedTierAndMember(t, srv, "Gold", "bea") |
| 124 | tid2, uid2 := seedTierAndMember(t, srv, "Silver", "cal") |
| 125 | now := int64(2_000_000) |
| 126 | |
| 127 | // Bea: past grace. Cal: still within grace. |
| 128 | srv.db.assignTier(tid, uid, "stripe") |
| 129 | srv.db.grantTierKey(tid, uid, "wrapped-bea") |
| 130 | srv.db.setEntitlement(Entitlement{TierID: tid, UserID: uid, Status: "past_due", PaidUntil: now - 100, GraceDeadline: now - 1, ExtSub: "sub-bea"}) |
| 131 | srv.db.assignTier(tid2, uid2, "stripe") |
| 132 | srv.db.grantTierKey(tid2, uid2, "wrapped-cal") |
| 133 | srv.db.setEntitlement(Entitlement{TierID: tid2, UserID: uid2, Status: "past_due", PaidUntil: now, GraceDeadline: now + 1000, ExtSub: "sub-cal"}) |
| 134 | |
| 135 | if n := srv.runBillingSweep(now); n != 1 { |
| 136 | t.Fatalf("sweep revoked %d, want 1", n) |
| 137 | } |
| 138 | if srv.db.tierMember(tid, uid) { |
| 139 | t.Fatalf("Bea should have been revoked") |
| 140 | } |
| 141 | if srv.db.tierHolder(tid, uid) { |
| 142 | t.Fatalf("Bea's key row should be gone") |
| 143 | } |
| 144 | if !srv.db.tierMember(tid2, uid2) { |
| 145 | t.Fatalf("Cal (in grace) should be kept") |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // TestWebhookActivatesAndMigrates: a checkout activates the membership (assign |
| 150 | // + key prod path via assignTier), and a switch-level event migrates it. |
| 151 | func TestWebhookActivates(t *testing.T) { |
| 152 | _, srv := newTestServer(t) |
| 153 | tid, uid := seedTierAndMember(t, srv, "Gold", "bea") |
| 154 | now := int64(3_000_000) |
| 155 | |
| 156 | // Activate via a normalized checkout event carrying our refs (AllowCreate — |
| 157 | // only a genuine new-subscription signal creates a membership). |
| 158 | srv.applyWebhookEvent(context.Background(), SubEvent{ |
| 159 | ExtSub: "sub9", ExtCustomer: "cus9", UserRef: uid, TierRef: tid, |
| 160 | Status: "active", CurrentPeriodEnd: now + 100000, AllowCreate: true, Created: now, |
| 161 | }, now) |
| 162 | if !srv.db.tierMember(tid, uid) { |
| 163 | t.Fatalf("member not assigned after active webhook") |
| 164 | } |
| 165 | e, ok := srv.db.memberEntitlement(tid, uid) |
| 166 | if !ok || e.Status != "active" || e.ExtSub != "sub9" { |
| 167 | t.Fatalf("entitlement after activate: %+v ok=%v", e, ok) |
| 168 | } |
| 169 | |
| 170 | // Switch level: same sub, new tier metadata → old membership dropped, new one on. |
| 171 | tid2, err := srv.db.createTier("Platinum") |
| 172 | if err != nil { |
| 173 | t.Fatalf("createTier: %v", err) |
| 174 | } |
| 175 | srv.applyWebhookEvent(context.Background(), SubEvent{ |
| 176 | ExtSub: "sub9", ExtCustomer: "cus9", UserRef: uid, TierRef: tid2, |
| 177 | Status: "active", CurrentPeriodEnd: now + 100000, |
| 178 | }, now) |
| 179 | if srv.db.tierMember(tid, uid) { |
| 180 | t.Fatalf("old level should be dropped after switch") |
| 181 | } |
| 182 | if !srv.db.tierMember(tid2, uid) { |
| 183 | t.Fatalf("new level should be assigned after switch") |
| 184 | } |
| 185 | |
| 186 | // A delete revokes. |
| 187 | srv.applyWebhookEvent(context.Background(), SubEvent{ |
| 188 | ExtSub: "sub9", UserRef: uid, TierRef: tid2, Status: "canceled", Deleted: true, |
| 189 | }, now) |
| 190 | if srv.db.tierMember(tid2, uid) { |
| 191 | t.Fatalf("delete should revoke") |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | // TestSubscribeHandlers exercises the HTTP surface with fakePay: admin sets a |
| 196 | // price, a member subscribes (gets the checkout URL), and cancel routes to the |
| 197 | // provider. Confirms the endpoints are wired + gated. |
| 198 | func TestSubscribeHandlers(t *testing.T) { |
| 199 | ts, srv := newTestServer(t) |
| 200 | fake := &fakePay{checkoutURL: "https://pay.example/checkout"} |
| 201 | srv.pay = fake |
| 202 | |
| 203 | mkUser := func(label string) (tok, uid string) { |
| 204 | u, err := srv.db.createUser(label) |
| 205 | if err != nil { |
| 206 | t.Fatalf("createUser: %v", err) |
| 207 | } |
| 208 | if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { |
| 209 | t.Fatalf("createKeyInvite: %v", err) |
| 210 | } |
| 211 | tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| 212 | if err != nil || !ok { |
| 213 | t.Fatalf("claim: ok=%v err=%v", ok, err) |
| 214 | } |
| 215 | if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { |
| 216 | t.Fatalf("setUserPublicKey: %v", err) |
| 217 | } |
| 218 | return tk, u |
| 219 | } |
| 220 | post := func(path, body string) (int, map[string]any) { |
| 221 | resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) |
| 222 | if err != nil { |
| 223 | t.Fatalf("post %s: %v", path, err) |
| 224 | } |
| 225 | defer resp.Body.Close() |
| 226 | var out map[string]any |
| 227 | _ = json.NewDecoder(resp.Body).Decode(&out) |
| 228 | return resp.StatusCode, out |
| 229 | } |
| 230 | |
| 231 | tokAdmin, uidAdmin := mkUser("Root") |
| 232 | if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { |
| 233 | t.Fatalf("setUserRole: %v", err) |
| 234 | } |
| 235 | tokBea, uidBea := mkUser("Bea") |
| 236 | tid, _ := srv.db.createTier("Gold") |
| 237 | |
| 238 | // A member can't set a price; an admin can (fakePay mints the provider ref). |
| 239 | if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokBea+`","cents":500,"currency":"eur","interval":"month"}`); code != 403 { |
| 240 | t.Fatalf("member set price: %d, want 403", code) |
| 241 | } |
| 242 | if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokAdmin+`","cents":500,"currency":"eur","interval":"month"}`); code != 200 { |
| 243 | t.Fatalf("admin set price: %d", code) |
| 244 | } |
| 245 | cents, _, _, ref, sellable := srv.db.tierPrice(tid) |
| 246 | if cents != 500 || ref != "price_created" || !sellable { |
| 247 | t.Fatalf("price stored wrong: %d %q %v", cents, ref, sellable) |
| 248 | } |
| 249 | |
| 250 | // Member subscribe → checkout URL. |
| 251 | code, out := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`) |
| 252 | if code != 200 || out["url"] != "https://pay.example/checkout" { |
| 253 | t.Fatalf("subscribe: %d %v", code, out) |
| 254 | } |
| 255 | |
| 256 | // Simulate the webhook activating Bea, then cancel routes to the provider. |
| 257 | srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uidBea, TierRef: tid, Status: "active", CurrentPeriodEnd: 9999999999, AllowCreate: true, Created: 1000}, 1000) |
| 258 | if code, _ := post("/api/tiers/"+tid+"/cancel-subscription", `{"token":"`+tokBea+`"}`); code != 200 { |
| 259 | t.Fatalf("cancel: %d", code) |
| 260 | } |
| 261 | if len(fake.cancelled) != 1 || fake.cancelled[0] != "s1" { |
| 262 | t.Fatalf("cancel didn't route to provider: %v", fake.cancelled) |
| 263 | } |
| 264 | |
| 265 | // Bea already holds a subscription here → a second subscribe is refused (no |
| 266 | // duplicate checkout, no double charge), and Resume routes to the provider. |
| 267 | if code, _ := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`); code != 409 { |
| 268 | t.Fatalf("duplicate subscribe: %d, want 409", code) |
| 269 | } |
| 270 | if code, _ := post("/api/tiers/"+tid+"/resume", `{"token":"`+tokBea+`"}`); code != 200 { |
| 271 | t.Fatalf("resume: %d", code) |
| 272 | } |
| 273 | if len(fake.resumed) != 1 || fake.resumed[0] != "s1" { |
| 274 | t.Fatalf("resume didn't route to provider: %v", fake.resumed) |
| 275 | } |
| 276 | |
| 277 | // A zero-decimal currency (would be charged 100×) is rejected. |
| 278 | if code, _ := post("/api/tiers/"+tid+"/price", `{"token":"`+tokAdmin+`","cents":50000,"currency":"jpy","interval":"month"}`); code != 400 { |
| 279 | t.Fatalf("jpy price: %d, want 400", code) |
| 280 | } |
| 281 | |
| 282 | // Dormant provider → subscribe refuses cleanly. |
| 283 | srv.pay = noPay{} |
| 284 | if code, _ := post("/api/tiers/"+tid+"/subscribe", `{"token":"`+tokBea+`"}`); code != 503 { |
| 285 | t.Fatalf("dormant subscribe: %d, want 503", code) |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | // TestWebhookNoResurrect: after a subscription is revoked (row gone), a stale |
| 290 | // redelivered "active" (a bare update, not a new-subscription signal) must NOT |
| 291 | // re-create the membership. |
| 292 | func TestWebhookNoResurrect(t *testing.T) { |
| 293 | _, srv := newTestServer(t) |
| 294 | tid, uid := seedTierAndMember(t, srv, "Gold", "bea") |
| 295 | now := int64(5_000_000) |
| 296 | must := func(err error) { |
| 297 | t.Helper() |
| 298 | if err != nil { |
| 299 | t.Fatalf("applyWebhookEvent: %v", err) |
| 300 | } |
| 301 | } |
| 302 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now)) |
| 303 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 10}, now+10)) |
| 304 | if srv.db.tierMember(tid, uid) { |
| 305 | t.Fatalf("should be revoked after delete") |
| 306 | } |
| 307 | // Stale redelivered active (older, no AllowCreate) → dropped, not resurrected. |
| 308 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, Created: now + 5}, now+20)) |
| 309 | if srv.db.tierMember(tid, uid) { |
| 310 | t.Fatalf("stale active resurrected a revoked membership") |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | // TestWebhookSupersededSubIgnored: a member active on a NEW subscription isn't |
| 315 | // kicked (nor is their record corrupted) by a late event for the OLD one. |
| 316 | func TestWebhookSupersededSubIgnored(t *testing.T) { |
| 317 | _, srv := newTestServer(t) |
| 318 | tid, uid := seedTierAndMember(t, srv, "Gold", "bea") |
| 319 | now := int64(6_000_000) |
| 320 | srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "S2", ExtCustomer: "c", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now) |
| 321 | // Late delete for the superseded S1 must not touch the S2 membership. |
| 322 | srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "S1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 5}, now+5) |
| 323 | if !srv.db.tierMember(tid, uid) { |
| 324 | t.Fatalf("member on S2 wrongly revoked by S1's delete") |
| 325 | } |
| 326 | e, _ := srv.db.memberEntitlement(tid, uid) |
| 327 | if e.ExtSub != "S2" || e.Status != "active" { |
| 328 | t.Fatalf("S1 event corrupted the S2 entitlement: %+v", e) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | // TestSubscriptionLapseKeepsAdminGrant: a paid subscription over an admin comp |
| 333 | // tracks its lifecycle but never flips the sticky source, so a lapse clears the |
| 334 | // subscription yet keeps the admin-granted access + key. |
| 335 | func TestSubscriptionLapseKeepsAdminGrant(t *testing.T) { |
| 336 | _, srv := newTestServer(t) |
| 337 | tid, uid := seedTierAndMember(t, srv, "Gold", "bea") |
| 338 | now := int64(7_000_000) |
| 339 | if err := srv.db.assignTier(tid, uid, "admin"); err != nil { |
| 340 | t.Fatalf("assignTier: %v", err) |
| 341 | } |
| 342 | srv.db.grantTierKey(tid, uid, "wrapped") |
| 343 | srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", ExtCustomer: "c1", UserRef: uid, TierRef: tid, Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now) |
| 344 | if src, _ := srv.db.tierSource(tid, uid); src != "admin" { |
| 345 | t.Fatalf("source flipped to %q, want admin", src) |
| 346 | } |
| 347 | srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "s1", UserRef: uid, TierRef: tid, Status: "canceled", Deleted: true, Created: now + 10}, now+10) |
| 348 | if !srv.db.tierMember(tid, uid) || !srv.db.tierHolder(tid, uid) { |
| 349 | t.Fatalf("admin-granted access/key wrongly removed on subscription lapse") |
| 350 | } |
| 351 | if e, ok := srv.db.memberEntitlement(tid, uid); ok && e.ExtSub != "" { |
| 352 | t.Fatalf("subscription tracking not cleared: %+v", e) |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | // TestPaidUntilZeroGuard: a checkout event (no period end) arriving after an |
| 357 | // update that already stored the renewal date must not zero it. |
| 358 | func TestPaidUntilZeroGuard(t *testing.T) { |
| 359 | _, srv := newTestServer(t) |
| 360 | tid, uid := seedTierAndMember(t, srv, "Gold", "bea") |
| 361 | now := int64(8_000_000) |
| 362 | applySubEvent(srv.db, SubEvent{ExtSub: "s1", Status: "active", CurrentPeriodEnd: now + 5000, Created: now}, tid, uid, now) |
| 363 | applySubEvent(srv.db, SubEvent{ExtSub: "s1", Status: "active", CurrentPeriodEnd: 0, Created: now + 1}, tid, uid, now+1) |
| 364 | if e, _ := srv.db.memberEntitlement(tid, uid); e.PaidUntil != now+5000 { |
| 365 | t.Fatalf("paid_until zeroed to %d, want %d", e.PaidUntil, now+5000) |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | // TestBillingSweepBackstop: the sweep revokes a subscription whose paid period |
| 370 | // lapsed more than the grace window ago even if no lapse webhook ever arrived |
| 371 | // (grace_deadline still 0) — the true backstop — while leaving a healthy sub. |
| 372 | func TestBillingSweepBackstop(t *testing.T) { |
| 373 | _, srv := newTestServer(t) |
| 374 | billingGrace = 30 * 24 * time.Hour |
| 375 | grace := int64(billingGrace.Seconds()) |
| 376 | now := int64(9_000_000) |
| 377 | tid, uid := seedTierAndMember(t, srv, "Gold", "bea") // silently lapsed |
| 378 | tid2, uid2 := seedTierAndMember(t, srv, "Silver", "cal") // healthy |
| 379 | srv.db.assignTier(tid, uid, "stripe") |
| 380 | srv.db.grantTierKey(tid, uid, "w") |
| 381 | srv.db.setEntitlement(Entitlement{TierID: tid, UserID: uid, Status: "active", PaidUntil: now - grace - 100, GraceDeadline: 0, ExtSub: "s1"}) |
| 382 | srv.db.assignTier(tid2, uid2, "stripe") |
| 383 | srv.db.grantTierKey(tid2, uid2, "w2") |
| 384 | srv.db.setEntitlement(Entitlement{TierID: tid2, UserID: uid2, Status: "active", PaidUntil: now + grace + 100000, GraceDeadline: 0, ExtSub: "s2"}) |
| 385 | if n := srv.runBillingSweep(now); n != 1 { |
| 386 | t.Fatalf("backstop sweep revoked %d, want 1", n) |
| 387 | } |
| 388 | if srv.db.tierMember(tid, uid) { |
| 389 | t.Fatalf("silently-lapsed sub not revoked") |
| 390 | } |
| 391 | if !srv.db.tierMember(tid2, uid2) { |
| 392 | t.Fatalf("healthy sub wrongly revoked") |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | // TestCheckoutFirstDeliveryKeepsPeriodEnd pins the delivery-order race found |
| 397 | // live on the first real sandbox run: Stripe stamps checkout.session.completed |
| 398 | // one second AFTER customer.subscription.created but delivered it FIRST. The |
| 399 | // checkout receipt (which carries no period end) must not claim the ordering |
| 400 | // watermark, or the created event — the only one with current_period_end — is |
| 401 | // dropped as stale and paid_until sticks at 0. |
| 402 | func TestCheckoutFirstDeliveryKeepsPeriodEnd(t *testing.T) { |
| 403 | _, srv := newTestServer(t) |
| 404 | tid, uid := seedTierAndMember(t, srv, "Gold", "cara") |
| 405 | now := int64(2_000_000) |
| 406 | month := int64(30 * 24 * 3600) |
| 407 | // Receipts resolve to provider truth now; give the fake the sub's state. |
| 408 | srv.pay = &fakePay{subStates: map[string]SubEvent{ |
| 409 | "subX": {Status: "active", CurrentPeriodEnd: now + month}, |
| 410 | }} |
| 411 | |
| 412 | // Delivered first: the checkout receipt, stamped now (later than the |
| 413 | // subscription.created it reports on). |
| 414 | if err := srv.applyWebhookEvent(context.Background(), SubEvent{ |
| 415 | ExtSub: "subX", ExtCustomer: "cusX", UserRef: uid, TierRef: tid, |
| 416 | Status: "active", Created: now, AllowCreate: true, Checkout: true, |
| 417 | }, now); err != nil { |
| 418 | t.Fatalf("checkout event: %v", err) |
| 419 | } |
| 420 | // Delivered second: subscription.created, stamped a second EARLIER, carrying |
| 421 | // the real period end. It must still fold. |
| 422 | if err := srv.applyWebhookEvent(context.Background(), SubEvent{ |
| 423 | ExtSub: "subX", ExtCustomer: "cusX", UserRef: uid, TierRef: tid, |
| 424 | Status: "active", CurrentPeriodEnd: now + month, Created: now - 1, AllowCreate: true, |
| 425 | }, now); err != nil { |
| 426 | t.Fatalf("created event: %v", err) |
| 427 | } |
| 428 | e, ok := srv.db.memberEntitlement(tid, uid) |
| 429 | if !ok || e.PaidUntil != now+month { |
| 430 | t.Fatalf("paid_until = %d, want %d (subscription.created dropped by the watermark?)", e.PaidUntil, now+month) |
| 431 | } |
| 432 | |
| 433 | // The watermark still guards real lifecycle reordering: a stale pre-dating |
| 434 | // "canceled" must be ignored. |
| 435 | if err := srv.applyWebhookEvent(context.Background(), SubEvent{ |
| 436 | ExtSub: "subX", UserRef: uid, TierRef: tid, |
| 437 | Status: "canceled", Deleted: true, Created: now - 2, |
| 438 | }, now); err != nil { |
| 439 | t.Fatalf("stale delete: %v", err) |
| 440 | } |
| 441 | if e, ok = srv.db.memberEntitlement(tid, uid); !ok || e.Status != "active" { |
| 442 | t.Fatalf("status = %q after stale delete, want active", e.Status) |
| 443 | } |
| 444 | |
| 445 | // And the reverse order stays correct: a late checkout receipt neither |
| 446 | // zeroes paid_until nor winds the watermark back. |
| 447 | if err := srv.applyWebhookEvent(context.Background(), SubEvent{ |
| 448 | ExtSub: "subX", ExtCustomer: "cusX", UserRef: uid, TierRef: tid, |
| 449 | Status: "active", Created: now + 1, AllowCreate: true, Checkout: true, |
| 450 | }, now); err != nil { |
| 451 | t.Fatalf("late checkout: %v", err) |
| 452 | } |
| 453 | if e, ok = srv.db.memberEntitlement(tid, uid); !ok || e.PaidUntil != now+month || e.EventTS != now-1 { |
| 454 | t.Fatalf("after late checkout: paid_until=%d eventTS=%d, want %d/%d", e.PaidUntil, e.EventTS, now+month, now-1) |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | // TestLevelSwitchResetsWatermark pins the review finding on the MAX() rewrite: |
| 459 | // when a level switch moves a subscription onto a row that already carried a |
| 460 | // DIFFERENT subscription's higher watermark, the watermark must reset to the |
| 461 | // incoming event's stamp — a blanket MAX() would keep the stale-high mark and |
| 462 | // drop the moved sub's later, lower-stamped lifecycle events (a cancel would |
| 463 | // never fold; the member would keep access past period end). |
| 464 | func TestLevelSwitchResetsWatermark(t *testing.T) { |
| 465 | _, srv := newTestServer(t) |
| 466 | tidGold, uid := seedTierAndMember(t, srv, "Gold", "dana") |
| 467 | tidSilver, err := srv.db.createTier("Silver") |
| 468 | if err != nil { |
| 469 | t.Fatalf("createTier: %v", err) |
| 470 | } |
| 471 | now := int64(3_000_000) |
| 472 | must := func(err error) { |
| 473 | t.Helper() |
| 474 | if err != nil { |
| 475 | t.Fatalf("applyWebhookEvent: %v", err) |
| 476 | } |
| 477 | } |
| 478 | // Member holds Silver via subB, whose watermark sits HIGH (an event stamped |
| 479 | // now+5 was delivered early), and Gold via subA (stamped now). |
| 480 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subB", ExtCustomer: "c", UserRef: uid, TierRef: tidSilver, |
| 481 | Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now + 5}, now)) |
| 482 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subA", ExtCustomer: "c", UserRef: uid, TierRef: tidGold, |
| 483 | Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now)) |
| 484 | // Level switch: subA's metadata now points at Silver; its update (stamped |
| 485 | // now) moves it onto the Silver row, displacing subB. |
| 486 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subA", ExtCustomer: "c", UserRef: uid, TierRef: tidSilver, |
| 487 | Status: "active", CurrentPeriodEnd: now + 2000, Created: now}, now)) |
| 488 | // The member cancels subA. The delete is stamped now+2 — LOWER than subB's |
| 489 | // leftover now+5 — and must still fold and revoke. |
| 490 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subA", UserRef: uid, TierRef: tidSilver, |
| 491 | Status: "canceled", Deleted: true, Created: now + 2}, now+2)) |
| 492 | if e, ok := srv.db.memberEntitlement(tidSilver, uid); ok && e.Status == "active" { |
| 493 | t.Fatalf("subA's cancel was dropped by the displaced sub's stale watermark (status=%q)", e.Status) |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | // TestCheckoutReceiptCannotResurrect pins the resurrection hole the #846 |
| 498 | // review confirmed: a checkout receipt redelivered after the subscription was |
| 499 | // deleted and the membership revoked must not re-create it — receipts resolve |
| 500 | // to provider truth before folding. |
| 501 | func TestCheckoutReceiptCannotResurrect(t *testing.T) { |
| 502 | _, srv := newTestServer(t) |
| 503 | tid, uid := seedTierAndMember(t, srv, "Gold", "elif") |
| 504 | now := int64(4_000_000) |
| 505 | fake := &fakePay{subStates: map[string]SubEvent{}} |
| 506 | srv.pay = fake |
| 507 | must := func(err error) { |
| 508 | t.Helper() |
| 509 | if err != nil { |
| 510 | t.Fatalf("applyWebhookEvent: %v", err) |
| 511 | } |
| 512 | } |
| 513 | // Subscribe, then the sub is deleted at the provider → revoked, row gone. |
| 514 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subY", ExtCustomer: "c", UserRef: uid, TierRef: tid, |
| 515 | Status: "active", CurrentPeriodEnd: now + 1000, AllowCreate: true, Created: now}, now)) |
| 516 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subY", UserRef: uid, TierRef: tid, |
| 517 | Status: "canceled", Deleted: true, Created: now + 10}, now+10)) |
| 518 | if _, ok := srv.db.memberEntitlement(tid, uid); ok { |
| 519 | t.Fatalf("membership should be revoked after the delete") |
| 520 | } |
| 521 | // Stripe retries the receipt; the provider no longer knows the sub. |
| 522 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subY", ExtCustomer: "c", UserRef: uid, TierRef: tid, |
| 523 | Status: "active", AllowCreate: true, Checkout: true, Created: now + 1}, now+20)) |
| 524 | if _, ok := srv.db.memberEntitlement(tid, uid); ok { |
| 525 | t.Fatalf("redelivered checkout receipt resurrected a revoked membership (sub unknown at provider)") |
| 526 | } |
| 527 | // Even when the provider still knows the sub — as canceled — nothing sticks. |
| 528 | fake.subStates["subY"] = SubEvent{Status: "canceled"} |
| 529 | must(srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subY", ExtCustomer: "c", UserRef: uid, TierRef: tid, |
| 530 | Status: "active", AllowCreate: true, Checkout: true, Created: now + 2}, now+20)) |
| 531 | if e, ok := srv.db.memberEntitlement(tid, uid); ok && e.Status == "active" { |
| 532 | t.Fatalf("canceled-at-provider receipt granted access (status=%q)", e.Status) |
| 533 | } |
| 534 | } |
| 535 | |
| 536 | // TestCheckoutReceiptResolvesRealState: a receipt whose subscription events |
| 537 | // never arrive (lost past the provider's retry window) still lands the real |
| 538 | // period end — the row stays sweepable instead of immortal. |
| 539 | func TestCheckoutReceiptResolvesRealState(t *testing.T) { |
| 540 | _, srv := newTestServer(t) |
| 541 | tid, uid := seedTierAndMember(t, srv, "Gold", "hana") |
| 542 | now := int64(5_000_000) |
| 543 | srv.pay = &fakePay{subStates: map[string]SubEvent{ |
| 544 | "subZ": {Status: "active", CurrentPeriodEnd: now + 500}, |
| 545 | }} |
| 546 | if err := srv.applyWebhookEvent(context.Background(), SubEvent{ExtSub: "subZ", ExtCustomer: "c", UserRef: uid, TierRef: tid, |
| 547 | Status: "active", AllowCreate: true, Checkout: true, Created: now}, now); err != nil { |
| 548 | t.Fatalf("applyWebhookEvent: %v", err) |
| 549 | } |
| 550 | e, ok := srv.db.memberEntitlement(tid, uid) |
| 551 | if !ok || e.Status != "active" || e.PaidUntil != now+500 { |
| 552 | t.Fatalf("receipt did not resolve to provider truth: ok=%v status=%q paid_until=%d", ok, e.Status, e.PaidUntil) |
| 553 | } |
| 554 | if e.EventTS != 0 { |
| 555 | t.Fatalf("receipt advanced the watermark (EventTS=%d)", e.EventTS) |
| 556 | } |
| 557 | } |
| 558 | |
| 559 | // TestBillingSweepRepairsLostLifecycle: rows stuck active with no period end |
| 560 | // are re-fetched from the provider and folded — or revoked when the provider |
| 561 | // no longer knows the sub. |
| 562 | func TestBillingSweepRepairsLostLifecycle(t *testing.T) { |
| 563 | _, srv := newTestServer(t) |
| 564 | tidA, uidA := seedTierAndMember(t, srv, "Gold", "fay") |
| 565 | tidB, uidB := seedTierAndMember(t, srv, "Silver", "gus") |
| 566 | now := int64(6_000_000) |
| 567 | // Two poisoned rows: active, sub tracked, no period end (the pre-fix state). |
| 568 | for _, r := range []struct{ sub, tid, uid string }{{"sA", tidA, uidA}, {"sB", tidB, uidB}} { |
| 569 | if _, err := applySubEvent(srv.db, SubEvent{ExtSub: r.sub, ExtCustomer: "c", Status: "active"}, r.tid, r.uid, now); err != nil { |
| 570 | t.Fatalf("seed: %v", err) |
| 571 | } |
| 572 | if err := srv.db.assignTier(r.tid, r.uid, "stripe"); err != nil { |
| 573 | t.Fatalf("assignTier: %v", err) |
| 574 | } |
| 575 | } |
| 576 | // The provider knows sA (active, real period end); sB is gone. |
| 577 | srv.pay = &fakePay{subStates: map[string]SubEvent{ |
| 578 | "sA": {Status: "active", CurrentPeriodEnd: now + 900}, |
| 579 | }} |
| 580 | srv.runBillingSweep(now) |
| 581 | if e, ok := srv.db.memberEntitlement(tidA, uidA); !ok || e.PaidUntil != now+900 { |
| 582 | t.Fatalf("repair did not restore sA's period end (ok=%v paid_until=%d)", ok, e.PaidUntil) |
| 583 | } |
| 584 | if _, ok := srv.db.memberEntitlement(tidB, uidB); ok { |
| 585 | t.Fatalf("repair kept a membership whose sub the provider no longer knows") |
| 586 | } |
| 587 | } |
| 588 | |
| 589 | // TestBillingAccountIDFormats pins the link endpoint's id validation: classic |
| 590 | // "acct_…" ids and Stripe's newer unprefixed opaque ids both link (a real live |
| 591 | // connect returned a bare 32-char id, 2026-07-18 — the old acct_-prefix check |
| 592 | // silently killed the whole OAuth flow), while junk is still rejected. |
| 593 | func TestBillingAccountIDFormats(t *testing.T) { |
| 594 | ts, srv := newTestServer(t) |
| 595 | |
| 596 | u, err := srv.db.createUser("Root") |
| 597 | if err != nil { |
| 598 | t.Fatalf("createUser: %v", err) |
| 599 | } |
| 600 | if err := srv.db.createKeyInvite("inv-root", u, "wrap", testClaimHash, 0); err != nil { |
| 601 | t.Fatalf("createKeyInvite: %v", err) |
| 602 | } |
| 603 | tok, ok, err := srv.db.claimKeyInvite("inv-root", "Root", "", testClaimSecret) |
| 604 | if err != nil || !ok { |
| 605 | t.Fatalf("claim: ok=%v err=%v", ok, err) |
| 606 | } |
| 607 | if err := srv.db.setUserRole(u, "admin"); err != nil { |
| 608 | t.Fatalf("setUserRole: %v", err) |
| 609 | } |
| 610 | |
| 611 | link := func(acct string) int { |
| 612 | resp, err := http.Post(ts.URL+"/api/admin/billing/account", "application/json", |
| 613 | strings.NewReader(`{"token":"`+tok+`","account":"`+acct+`"}`)) |
| 614 | if err != nil { |
| 615 | t.Fatalf("post: %v", err) |
| 616 | } |
| 617 | resp.Body.Close() |
| 618 | return resp.StatusCode |
| 619 | } |
| 620 | |
| 621 | for _, good := range []string{"acct_1Trl9YB043v2tsWJ", "IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx", ""} { |
| 622 | if code := link(good); code != 200 { |
| 623 | t.Fatalf("link %q: %d, want 200", good, code) |
| 624 | } |
| 625 | } |
| 626 | // The last good link ("") cleared it; relink and confirm it round-trips. |
| 627 | if code := link("IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx"); code != 200 { |
| 628 | t.Fatal("relink failed") |
| 629 | } |
| 630 | if acct, _, _ := srv.db.getConfig("stripe_connect_account"); acct != "IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx" { |
| 631 | t.Fatalf("stored account = %q", acct) |
| 632 | } |
| 633 | for _, bad := range []string{"short", "has spaces in it", "acct_%3B;drop", strings.Repeat("x", 70)} { |
| 634 | if code := link(bad); code != 400 { |
| 635 | t.Fatalf("link %q: %d, want 400", bad, code) |
| 636 | } |
| 637 | } |
| 638 | } |
| 639 | |