| index 16620a4..3d8817d 100644 |
| --- a/subscriptions_test.go |
| +++ b/subscriptions_test.go |
| @@ -387,3 +387,100 @@ func TestBillingSweepBackstop(t *testing.T) { |
| 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) |
| + |
| + // 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) |
| + } |
| +} |