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
Draft
fix(payments): bound on_hold subscription coverage to the paid-through period (GHSA-hw94-8c4h-m9qp)#5601lspassos1 wants to merge 3 commits into
lspassos1 wants to merge 3 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lspassos1
force-pushed
the
claude/onhold-subscriptions-entitlement-q9dm8d
branch
from
July 29, 2026 00:03
8adf276 to
5da6353
Compare
…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
force-pushed
the
claude/onhold-subscriptions-entitlement-q9dm8d
branch
from
July 29, 2026 10:40
5da6353 to
546cbc7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes GHSA-hw94-8c4h-m9qp ("On-hold subscriptions can retain paid entitlement beyond the paid-through period").
isCoveringAttreatedon_holdas covering unconditionally, unlikecancelled(bounded bycurrentPeriodEnd > at). Dodo keeps a payment-failed subscription inon_holdindefinitely — no further webhook is guaranteed — so a dead hold kept counting as the "best covering sub" forever. Concrete consequences onmaintoday:api_businesshold were never revoked (revokeBusinessProGrantsIfNotCovering/reconcileBusinessProGrantsboth saw "still covering"), keeping seat state alive and the owner's 4-seat cap consumed indefinitely.getCheckoutBlockingSubscriptionblocked new checkout in the billing family while anyon_holdrow existed — a user whose payment failed once could never self-serve re-subscribe.handleSubscriptionOnHoldnever recomputed entitlements, so nothing ever clamped the entitlement if Dodo went silent after the hold.Fix
isCoveringAt(convex/payments/subscriptionHelpers.ts):on_holdnow carries the samecurrentPeriodEndbound ascancelled.activestays 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.handleSubscriptionOnHold: recomputes entitlements after the status patch. Policy is unchanged for the normal case — a paid-through hold keeps access exactly untilcurrentPeriodEnd— 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.getCheckoutBlockingSubscription(convex/payments/billing.ts):on_holdblocks checkout only while paid-through (same boundcancelledalready had), ending the permanent re-subscribe lockout. Within-period holds still block — recovery belongs in the customer portal, not a duplicate checkout.billing.ts): replaced an inline (previously unbounded) copy of the coverage predicate withisCoveringAt.Type of change
Affected areas
/api/*)Checklist
api/rss-proxy.jsallowlist (if adding feeds)npm run typecheck)Documentation Alignment Checklist
N/A — no documentation claims, generated docs, or Redis key contracts change in this PR.
Verification
convex/__tests__/onhold-entitlement-bound.test.ts(9 tests): unit bounds for all four statuses; paid-through hold preserved and clamped tocurrentPeriodEnd; 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.subscription.on_holdwebhook, dunning-email, business-seats, and checkout-block tests, all unchanged).npm run typecheck,npm run typecheck:api+audit-convex-string-callsclean.Notes / consciously out of scope
isLapsedAtstill excludeson_holdfrom 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.validUntilchecks 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-validUntilsurfaces listed above.Screenshots
N/A — backend billing lifecycle change.