eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
15type 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
24func (f *fakePay) Configured() bool { return true }
25func (f *fakePay) Checkout(context.Context, CheckoutReq) (string, error) {
26 return f.checkoutURL, nil
27}
28func (f *fakePay) Cancel(_ context.Context, sub string) error {
29 f.cancelled = append(f.cancelled, sub)
30 return nil
31}
32func (f *fakePay) Resume(_ context.Context, sub string) error {
33 f.resumed = append(f.resumed, sub)
34 return nil
35}
36func (f *fakePay) SwitchLevel(_ context.Context, sub, price, _ string) error {
37 f.switched = append(f.switched, [2]string{sub, price})
38 return nil
39}
40func (f *fakePay) CreatePrice(context.Context, string, int, string, string) (string, error) {
41 return "price_created", nil
42}
43func (f *fakePay) PortalURL(context.Context, string, string) (string, error) {
44 return "https://portal.example/x", nil
45}
46func (f *fakePay) ParseWebhook(string, []byte) ([]SubEvent, error) { return f.events, nil }
47func (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).
53func 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.
71func 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.
121func 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.
151func 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.
198func 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.
292func 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.
316func 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.
335func 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.
358func 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.
372func 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.
402func 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).
464func 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.
501func 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.
539func 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.
562func 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.
593func 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