Skip to content

fix(payments): bound on_hold subscription coverage to the paid-through period (GHSA-hw94-8c4h-m9qp) - #5601

Draft
lspassos1 wants to merge 3 commits into
mainfrom
claude/onhold-subscriptions-entitlement-q9dm8d
Draft

fix(payments): bound on_hold subscription coverage to the paid-through period (GHSA-hw94-8c4h-m9qp)#5601
lspassos1 wants to merge 3 commits into
mainfrom
claude/onhold-subscriptions-entitlement-q9dm8d

Conversation

@lspassos1

@lspassos1 lspassos1 commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes GHSA-hw94-8c4h-m9qp ("On-hold subscriptions can retain paid entitlement beyond the paid-through period").

isCoveringAt treated on_hold as covering unconditionally, unlike cancelled (bounded by currentPeriodEnd > at). Dodo keeps a payment-failed subscription in on_hold indefinitely — no further webhook is guaranteed — so a dead hold kept counting as the "best covering sub" forever. Concrete consequences on main today:

  • Entitlement recompute clobber: any recompute re-elected the stale hold; a higher-tier dead hold (e.g. enterprise) outranked a later paid subscription and overwrote the entitlement with an already-expired one — denying a paying customer.
  • Business seats: grants tied to an expired api_business hold were never revoked (revokeBusinessProGrantsIfNotCovering / reconcileBusinessProGrants both saw "still covering"), keeping seat state alive and the owner's 4-seat cap consumed indefinitely.
  • Activation/welcome flows counted an expired hold as current access.
  • Checkout lockout: getCheckoutBlockingSubscription blocked new checkout in the billing family while any on_hold row existed — a user whose payment failed once could never self-serve re-subscribe.
  • No terminal state: handleSubscriptionOnHold never recomputed entitlements, so nothing ever clamped the entitlement if Dodo went silent after the hold.

Fix

  1. isCoveringAt (convex/payments/subscriptionHelpers.ts): on_hold now carries the same currentPeriodEnd bound as cancelled. active stays unbounded on purpose — a late renewal webhook must not cut off a paying customer; staleness there is owned by the renewal-verification/reconciliation machinery. All consumers inherit the bound.
  2. handleSubscriptionOnHold: recomputes entitlements after the status patch. Policy is unchanged for the normal case — a paid-through hold keeps access exactly until currentPeriodEnd — but a hold event past the boundary now downgrades immediately unless another sub covers, and the dunning episode anchor (onHoldAt, feat(billing): dunning + winback email lifecycle — on_hold day-0/3/7 recovery, 30-day winback, suppression-aware #4932) is untouched.
  3. getCheckoutBlockingSubscription (convex/payments/billing.ts): on_hold blocks checkout only while paid-through (same bound cancelled already had), ending the permanent re-subscribe lockout. Within-period holds still block — recovery belongs in the customer portal, not a duplicate checkout.
  4. Anonymous-claim merge (billing.ts): replaced an inline (previously unbounded) copy of the coverage predicate with isCoveringAt.

Type of change

  • Bug fix

Affected areas

  • API endpoints (/api/*)
  • Other: Convex payments/entitlement lifecycle (webhooks, checkout guard, business seats)

Checklist

  • Tested on worldmonitor.app variant
  • Tested on tech.worldmonitor.app variant (if applicable)
  • New RSS feed domains added to api/rss-proxy.js allowlist (if adding feeds)
  • No API keys or secrets committed
  • TypeScript compiles without errors (npm run typecheck)

Documentation Alignment Checklist

N/A — no documentation claims, generated docs, or Redis key contracts change in this PR.

Verification

  • New convex/__tests__/onhold-entitlement-bound.test.ts (9 tests): unit bounds for all four statuses; paid-through hold preserved and clamped to currentPeriodEnd; post-boundary hold event downgrades to free with the dunning anchor unmoved; stale higher-tier hold no longer clobbers a later paid subscription; stale hold no longer blocks checkout while a live hold still does.
  • RED-before verified: 4 of the 9 tests fail against the pre-fix code (the on_hold bound, post-boundary downgrade, clobber regression, checkout unlock); the other 5 pass on both sides — they pin the behavior that must not change (active/cancelled/expired semantics, paid-through hold preservation, within-period checkout block).
  • Full convex suite: 56 files / 1010 tests green (includes the pre-existing subscription.on_hold webhook, dunning-email, business-seats, and checkout-block tests, all unchanged).
  • npm run typecheck, npm run typecheck:api + audit-convex-string-calls clean.

Notes / consciously out of scope

  • isLapsedAt still excludes on_hold from win-back cohorts; a past-boundary hold user is now neither "current" nor "lapsed" for email classification. If they should join win-back, that's a small follow-up.
  • Entitlement read paths (validUntil checks in gateway/MCP/API-key/Convex queries) already denied expired rows; this PR fixes the coverage-derivation layer that kept re-asserting stale coverage into those rows and into the non-validUntil surfaces listed above.

Screenshots

N/A — backend billing lifecycle change.

@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview Jul 30, 2026 5:31am

Request Review

claude and others added 2 commits July 29, 2026 10:37
…h period (GHSA-hw94-8c4h-m9qp)

isCoveringAt treated on_hold as covering unconditionally, unlike cancelled
(bounded by currentPeriodEnd > at). Dodo holds a payment-failed subscription
in on_hold indefinitely — no further webhook is guaranteed — so a dead hold
kept counting as the "best covering sub" forever: every entitlement recompute
re-elected it (clobbering later paid subs with an already-expired higher-tier
entitlement), business-seat grants tied to an expired api_business hold never
revoked, activation/welcome classification counted it as current access, and
getCheckoutBlockingSubscription locked the user out of ever re-subscribing in
that billing family.

Fix:
- isCoveringAt: on_hold now carries the same currentPeriodEnd bound as
  cancelled. active stays unbounded on purpose (late renewal webhooks must
  not cut off paying customers; staleness is the reconciliation machinery's
  job). All consumers (recompute, business grants revoke/reconcile,
  activation eligibility, welcome-email classification) inherit the bound.
- handleSubscriptionOnHold: recompute entitlements after the status patch,
  clamping validUntil to the best covering sub — a paid-through hold keeps
  access exactly until currentPeriodEnd (policy unchanged), a post-boundary
  hold event downgrades immediately unless another sub covers.
- getCheckoutBlockingSubscription: on_hold blocks checkout only while
  paid-through, ending the permanent re-subscribe lockout.
- billing.ts anonymous-claim merge: replace the inline (previously
  unbounded) coverage predicate with isCoveringAt.

Tests: convex/__tests__/onhold-entitlement-bound.test.ts — unit bounds for
all four statuses, paid-through hold preserved + clamped, post-boundary hold
downgrade (dunning anchor unmoved), stale higher-tier hold no longer clobbers
a later paid sub, stale hold no longer blocks checkout while a live hold
still does. 4 of 9 are RED on the pre-fix code. Full convex suite 1010/1010,
typecheck + typecheck:api + convex string-call audit clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H32uzF8i6fCvoB5s5XTkaf
@lspassos1
lspassos1 force-pushed the claude/onhold-subscriptions-entitlement-q9dm8d branch from 5da6353 to 546cbc7 Compare July 29, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants