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>
5 files changed,
+305
−50
db.go+24 −0main.go+12 −8subscriptions.go+133 −41subscriptions_handlers.go+34 −0subscriptions_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") |
| + } |
| +} |