eleven / messenger Public

server: checkout receipts resolve to provider truth; repair + serialize folds (#850)

Closes the three ordering holes the #846 review confirmed/flagged beyond the
watermark itself:

- A checkout receipt now resolves to the subscription's CURRENT state via the
  new PayProvider.SubState (GET /v1/subscriptions/{id} on the connected
  account) before folding: a receipt redelivered after a cancel+revoke can't
  resurrect the membership, and a receipt whose subscription events are lost
  still lands the real period end instead of an active/paid_until=0 row no
  sweep can ever revoke.
- The hourly sweep gains a repair pass: rows stuck active with no period end
  (lost lifecycle events — pre-fix poisoning, or an instance asleep past the
  provider's retry window) are re-fetched and folded, or revoked when the
  provider no longer knows the sub. Capped at 20/sweep.
- Webhook folds are serialized (subFold mutex): the ordering decision spans a
  read and a write, and Stripe delivers concurrently.

The webhook payload decode and status mapping are unified in subObject, shared
by ParseWebhook and SubState.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 694baf52653e793ef621509ca078e53be8dc694a parent 6f164c0
5 files changed, +305 −50
  • db.go +24 −0
  • main.go +12 −8
  • subscriptions.go +133 −41
  • subscriptions_handlers.go +34 −0
  • subscriptions_test.go +102 −1
diff --git a/db.go b/db.go
index 78a5257..97c5887 100644
--- a/db.go
+++ b/db.go
@@ -2504,6 +2504,30 @@ func (db *DB) entitlementsDueForRevoke(now int64) ([]Entitlement, error) {
return out, rows.Err()
}
+// entitlementsNeedingRepair finds paid memberships stuck ACTIVE with no
+// period end — rows whose lifecycle events were lost, which neither
+// entitlementsDueForRevoke clause can ever match. The sweep re-fetches
+// provider truth for these (repairEntitlements).
+func (db *DB) entitlementsNeedingRepair(limit int) ([]Entitlement, error) {
+ rows, err := db.sql.Query(
+ `SELECT tier_id, user_id, ext_sub FROM user_tiers
+ WHERE ext_sub IS NOT NULL AND ext_sub != '' AND status = 'active' AND paid_until = 0
+ LIMIT ?`, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Entitlement{}
+ for rows.Next() {
+ var e Entitlement
+ if err := rows.Scan(&e.TierID, &e.UserID, &e.ExtSub); err != nil {
+ return nil, err
+ }
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
// grantTierKey stores one member's wrapped copy of a tier key. DO NOTHING,
// never overwrite — the grantInstanceKey anti-brick rule, per tier.
func (db *DB) grantTierKey(tierID, userID, wrapped string) error {
diff --git a/main.go b/main.go
index b27cb72..d90687e 100644
--- a/main.go
+++ b/main.go
@@ -89,9 +89,9 @@ type Server struct {
entitlement string
vapidPub string
vapidPriv string
- apns apnsSender // nil unless APNs (direct p8 or relay) is configured
- maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES)
- blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES)
+ apns apnsSender // nil unless APNs (direct p8 or relay) is configured
+ maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES)
+ blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES)
// The usage-pipe relay (usage.go, #857): where sidecar usage reports go
// and the per-space meter token that authenticates them. Pushed into the
// env by the provisioner; empty = unmetered (self-hosted / BYO key).
@@ -126,6 +126,10 @@ type Server struct {
// pay is the payment provider for paid access levels (subscriptions.go).
// noPay unless Stripe Connect is configured — the feature is dormant then.
pay PayProvider
+ // subFold serializes subscription-event folds: the ordering decision
+ // (read the watermark, then write the row) spans separate statements,
+ // and webhooks arrive concurrently.
+ subFold sync.Mutex
// Tamper evidence (checkpoint.go): the per-instance checkpoint signing key.
// Empty ckptKeyPath (the default) stores the seed in the DB config so it
// rides restores; LCHAT_CHECKPOINT_KEY_FILE overrides to a file. ckptMu
@@ -277,11 +281,11 @@ func main() {
s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(),
appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin,
entitlement: strings.TrimSpace(os.Getenv("LCHAT_ENTITLEMENT")),
- maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes),
- blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes),
- meterURL: strings.TrimSpace(os.Getenv("LCHAT_METER_URL")),
- meterToken: strings.TrimSpace(os.Getenv("LCHAT_METER_TOKEN")),
- appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))}
+ maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes),
+ blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes),
+ meterURL: strings.TrimSpace(os.Getenv("LCHAT_METER_URL")),
+ meterToken: strings.TrimSpace(os.Getenv("LCHAT_METER_TOKEN")),
+ appsDir: strings.TrimSpace(os.Getenv("LCHAT_APPS_DIR"))}
s.brakes = newModBrakes()
// Tamper evidence (checkpoint.go): by default the signing seed lives in
diff --git a/subscriptions.go b/subscriptions.go
index d7c6bb0..ae6991d 100644
--- a/subscriptions.go
+++ b/subscriptions.go
@@ -58,11 +58,11 @@ type SubEvent struct {
Deleted bool // the subscription is fully gone (revoke now)
Created int64 // the provider event's `created` time (event ordering)
AllowCreate bool // a genuine "new subscription" signal (checkout completed,
- // or subscription.created) — the ONLY kind that may CREATE a
- // membership, so a bare update/delete can't resurrect a revoked one
- Checkout bool // a checkout receipt: a creation signal carrying NO lifecycle
- // data (no period end, no cancel state) — must never advance the
- // per-sub ordering watermark (see applySubEvent)
+ // or subscription.created) — the ONLY kind that may CREATE a
+ // membership, so a bare update/delete can't resurrect a revoked one
+ Checkout bool // a checkout receipt: a creation signal carrying NO lifecycle
+ // data (no period end, no cancel state) — must never advance the
+ // per-sub ordering watermark (see applySubEvent)
}
// CheckoutReq is what a member's subscribe needs from the provider.
@@ -78,6 +78,11 @@ type CheckoutReq struct {
// false) keeps billing dormant until real credentials are wired.
type PayProvider interface {
Configured() bool
+ // SubState fetches a subscription's CURRENT state from the provider —
+ // found=false when the provider no longer knows it. Resolves checkout
+ // receipts (which carry no lifecycle data) and repairs rows whose
+ // lifecycle events were lost (see runBillingSweep).
+ SubState(ctx context.Context, extSub string) (SubEvent, bool, error)
Checkout(ctx context.Context, req CheckoutReq) (url string, err error)
Cancel(ctx context.Context, extSub string) error
// Resume un-cancels a subscription still in its paid period (clears
@@ -110,6 +115,9 @@ func (noPay) CreatePrice(context.Context, string, int, string, string) (string,
}
func (noPay) PortalURL(context.Context, string, string) (string, error) { return "", errBillingOff }
func (noPay) ParseWebhook(string, []byte) ([]SubEvent, error) { return nil, errBillingOff }
+func (noPay) SubState(context.Context, string) (SubEvent, bool, error) {
+ return SubEvent{}, false, errBillingOff
+}
// newPayProvider wires the payment backend from env. Tri-state, like
// newStripeBilling: all-empty → dormant (noPay); a partial config → dormant
@@ -224,9 +232,49 @@ func (s *Server) runBillingSweep(now int64) int {
n++
s.prodTiersChanged(e.UserID) // their tabs learn their access ended
}
+ s.repairEntitlements(now)
return n
}
+// repairEntitlements re-fetches provider truth for rows stuck ACTIVE with no
+// period end. Those rows lost their lifecycle events (e.g. only a checkout
+// receipt ever arrived — the pre-#846 watermark bug — or the instance slept
+// past the provider's webhook retry window); neither revoke clause in
+// entitlementsDueForRevoke can ever match them, so without this they'd hold
+// access forever. Capped per sweep so a bad day can't turn the sweep into a
+// provider crawl.
+func (s *Server) repairEntitlements(now int64) {
+ if s.pay == nil || !s.pay.Configured() {
+ return
+ }
+ broken, err := s.db.entitlementsNeedingRepair(20)
+ if err != nil {
+ return
+ }
+ for _, e := range broken {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ cur, found, err := s.pay.SubState(ctx, e.ExtSub)
+ cancel()
+ if err != nil {
+ continue // transient — the next sweep retries
+ }
+ s.subFold.Lock()
+ if !found {
+ if _, err := s.db.revokeEntitlement(e.TierID, e.UserID); err == nil {
+ s.prodTiersChanged(e.UserID)
+ }
+ } else {
+ // Out-of-band truth, not an ordered event: fold without advancing
+ // the per-sub watermark, exactly like a checkout receipt. The row's
+ // sub id is what we fetched by — keep it authoritative.
+ cur.ExtSub = e.ExtSub
+ cur.Checkout = true
+ s.foldAndSettle(cur, e.TierID, e.UserID, now)
+ }
+ s.subFold.Unlock()
+ }
+}
+
// startBillingSweep runs the grace→revoke sweep hourly for the instance's life.
func (s *Server) startBillingSweep() {
go func() {
@@ -449,50 +497,94 @@ func (p *stripeConnect) ParseWebhook(sig string, body []byte) ([]SubEvent, error
Status: "active", Created: ev.Created, AllowCreate: true, Checkout: true,
}}, nil
case strings.HasPrefix(ev.Type, "customer.subscription."):
- var o struct {
- ID string `json:"id"`
- Customer string `json:"customer"`
- Status string `json:"status"`
- CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
- CurrentPeriodEnd int64 `json:"current_period_end"`
- Metadata map[string]string `json:"metadata"`
- Items struct {
- Data []struct {
- CurrentPeriodEnd int64 `json:"current_period_end"`
- } `json:"data"`
- } `json:"items"`
- }
+ var o subObject
if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
return nil, err
}
- cpe := o.CurrentPeriodEnd
- if cpe == 0 && len(o.Items.Data) > 0 { // newer API: period end moved to the item
- cpe = o.Items.Data[0].CurrentPeriodEnd
- }
- se := SubEvent{
- ExtSub: o.ID, ExtCustomer: o.Customer,
- UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
- CurrentPeriodEnd: cpe,
- CancelAtPeriodEnd: o.CancelAtPeriodEnd,
- Deleted: ev.Type == "customer.subscription.deleted",
- Created: ev.Created,
- AllowCreate: ev.Type == "customer.subscription.created",
- }
- // Stripe's active|trialing keep access; past_due keeps it too but starts
- // the grace clock; canceled/unpaid end it.
- switch o.Status {
- case "active", "trialing":
- se.Status = "active"
- case "past_due", "unpaid":
- se.Status = "past_due"
- default:
- se.Status = "canceled"
- }
+ se := o.subEvent()
+ se.Deleted = ev.Type == "customer.subscription.deleted"
+ se.Created = ev.Created
+ se.AllowCreate = ev.Type == "customer.subscription.created"
return []SubEvent{se}, nil
}
return nil, nil
}
+// subObject is the slice of a provider subscription we act on — the same
+// shape arrives in webhook payloads and from a SubState fetch.
+type subObject struct {
+ ID string `json:"id"`
+ Customer string `json:"customer"`
+ Status string `json:"status"`
+ CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ Metadata map[string]string `json:"metadata"`
+ Items struct {
+ Data []struct {
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ } `json:"data"`
+ } `json:"items"`
+}
+
+// subEvent normalizes the object; the caller stamps the event-only fields
+// (Created, Deleted, AllowCreate).
+func (o *subObject) subEvent() SubEvent {
+ cpe := o.CurrentPeriodEnd
+ if cpe == 0 && len(o.Items.Data) > 0 { // newer API: period end moved to the item
+ cpe = o.Items.Data[0].CurrentPeriodEnd
+ }
+ // Stripe's active|trialing keep access; past_due keeps it too but starts
+ // the grace clock; canceled/unpaid end it.
+ status := "canceled"
+ switch o.Status {
+ case "active", "trialing":
+ status = "active"
+ case "past_due", "unpaid":
+ status = "past_due"
+ }
+ return SubEvent{
+ ExtSub: o.ID, ExtCustomer: o.Customer,
+ UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
+ Status: status,
+ CurrentPeriodEnd: cpe,
+ CancelAtPeriodEnd: o.CancelAtPeriodEnd,
+ }
+}
+
+// SubState fetches the subscription's current truth from Stripe. Checkout
+// receipts are resolved through this (see applyWebhookEvent): a receipt
+// carries no lifecycle data, and trusting one blind could plant an active
+// row with no period end — invisible to every sweep — or resurrect a
+// membership whose cancellation already folded.
+func (p *stripeConnect) SubState(ctx context.Context, extSub string) (SubEvent, bool, error) {
+ acct := p.connectAccount()
+ if acct == "" {
+ return SubEvent{}, false, errBillingOff
+ }
+ req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+"/v1/subscriptions/"+url.PathEscape(extSub), nil)
+ if err != nil {
+ return SubEvent{}, false, err
+ }
+ req.Header.Set("Authorization", "Bearer "+p.key)
+ req.Header.Set("Stripe-Account", acct)
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return SubEvent{}, false, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return SubEvent{}, false, nil // Stripe no longer knows this sub
+ }
+ var o subObject
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&o); err != nil {
+ return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d", resp.StatusCode)
+ }
+ return o.subEvent(), true, nil
+}
+
func (p *stripeConnect) CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error) {
// A recurring price with an inline product (product_data), so one call
// makes both. The connected account owns them.
diff --git a/subscriptions_handlers.go b/subscriptions_handlers.go
index 142bead..fd1e233 100644
--- a/subscriptions_handlers.go
+++ b/subscriptions_handlers.go
@@ -339,6 +339,12 @@ func (s *Server) stripeConnectWebhook(w http.ResponseWriter, r *http.Request) {
return
}
now := time.Now().Unix()
+ // One fold at a time: the ordering decision (read the watermark, then
+ // write the row) spans separate statements, and Stripe delivers webhooks
+ // concurrently — two racing events for one sub could both pass the
+ // watermark check and fold in the wrong order.
+ s.subFold.Lock()
+ defer s.subFold.Unlock()
failed := false
for _, ev := range events {
if err := s.applyWebhookEvent(r.Context(), ev, now); err != nil {
@@ -372,6 +378,26 @@ func (s *Server) stripeConnectWebhook(w http.ResponseWriter, r *http.Request) {
// a member who has since re-subscribed), and it keeps any admin/invite grant
// underneath (revokeEntitlement is source-aware).
func (s *Server) applyWebhookEvent(ctx context.Context, ev SubEvent, now int64) error {
+ // A checkout receipt carries no lifecycle data, and Stripe may deliver it
+ // before, after, or INSTEAD OF the subscription events (delivery is
+ // unordered and retries are finite). Resolve the receipt to the sub's
+ // current state, so a receipt alone can never plant a degenerate
+ // active/paid_until=0 row (invisible to every sweep), and a receipt
+ // redelivered after a cancel+revoke can't resurrect the membership.
+ if ev.Checkout && ev.ExtSub != "" && s.pay != nil {
+ cur, found, err := s.pay.SubState(ctx, ev.ExtSub)
+ if err != nil {
+ return err // transient — the webhook 500s and the provider retries
+ }
+ if !found {
+ return nil // the sub is gone at the provider; grant nothing
+ }
+ ev.Status = cur.Status
+ ev.CurrentPeriodEnd = cur.CurrentPeriodEnd
+ ev.CancelAtPeriodEnd = cur.CancelAtPeriodEnd
+ // Still Checkout=true: fetched state is out-of-band truth, not an
+ // ordered event — it must not advance the per-sub watermark.
+ }
tierID, userID := ev.TierRef, ev.UserRef
bySub, haveBySub := s.db.entitlementBySub(ev.ExtSub)
if tierID == "" || userID == "" {
@@ -410,6 +436,14 @@ func (s *Server) applyWebhookEvent(ctx context.Context, ev SubEvent, now int64)
}
s.prodTiersChanged(userID)
}
+ return s.foldAndSettle(ev, tierID, userID, now)
+}
+
+// foldAndSettle folds a normalized event and settles access: grant + key prod
+// when the membership is active, revoke otherwise (source-aware — an
+// admin/invite grant underneath survives). Shared by the webhook path and the
+// sweep's repair pass.
+func (s *Server) foldAndSettle(ev SubEvent, tierID, userID string, now int64) error {
active, err := applySubEvent(s.db, ev, tierID, userID, now)
if err != nil {
return err // transient — let Stripe retry (webhook responds 500)
diff --git a/subscriptions_test.go b/subscriptions_test.go
index 3d8817d..0db31ec 100644
--- a/subscriptions_test.go
+++ b/subscriptions_test.go
@@ -17,7 +17,8 @@ type fakePay struct {
switched [][2]string // (extSub, newPriceRef) pairs seen by SwitchLevel
cancelled []string
resumed []string
- events []SubEvent // returned by the next ParseWebhook
+ events []SubEvent // returned by the next ParseWebhook
+ subStates map[string]SubEvent // SubState fixtures: extSub → current provider truth
}
func (f *fakePay) Configured() bool { return true }
@@ -43,6 +44,10 @@ 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) {
@@ -399,6 +404,10 @@ func TestCheckoutFirstDeliveryKeepsPeriodEnd(t *testing.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).
@@ -484,3 +493,95 @@ func TestLevelSwitchResetsWatermark(t *testing.T) {
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")
+ }
+}