Files
worldmonitor/tests/entitlement-transition.test.mts
Elie Habib c49c2f80f6 fix(pro): reliable post-payment activation (#3163)
* fix(pro): reliable post-payment activation (transition reload + auth wait + overlay-success reload)

Fixes a silent race where paying users saw locked panels after a successful
Dodo checkout and concluded PRO hadn't activated. Incident 2026-04-17/18:
one customer purchased Pro Monthly twice within 32 min on Google Pay then
Credit Card because the first charge showed no UI change; the duplicate
was refunded by Dodo with reason "Duplicate transaction".

Server path (webhook -> Convex entitlements row) was verified correct end
to end: all 9 webhook events processed, entitlement row written within
seconds, planKey=pro_monthly. The bug was client-side in three places.

1. panel-layout.ts replaced skipInitialSnapshot with a free->pro
   transition detector (shouldReloadOnEntitlementChange). The prior guard
   swallowed the first pro snapshot unconditionally, which collapsed two
   distinct cases: (a) legacy-pro user on normal page load (correctly no
   reload) and (b) free user whose post-payment pro snapshot arrives after
   panels rendered against free-tier gating (should reload). The
   transition detector distinguishes them by remembering the last observed
   entitlement.

2. entitlements.ts awaits waitForConvexAuth(10_000) before calling
   client.onUpdate. Mirrors the pattern already used in api-keys.ts and
   App.ts claimSubscription path. Eliminates the spurious FREE_TIER_DEFAULTS
   first snapshot from unauthenticated cold sessions that the transition
   detector would otherwise treat as the baseline.

3. checkout.ts on Dodo overlay checkout.status=succeeded schedules a
   window.location.reload() after 3s (median webhook latency <5s observed
   in prod). Belt-and-braces: guarantees the post-payment state is fresh
   even if the WS subscription is slow or the transition detector misses
   the edge for any reason.

Unit tests in tests/entitlement-transition.test.mts cover all six
(last, next) combinations plus the full incident-simulation sequence
(null -> false -> true -> true => exactly one reload) and the legacy-pro
reconnect sequence (null -> true -> true -> true => zero reloads).

Out of scope (tracked separately): server-side duplicate-subscription
guard in _createCheckoutSession.

* fix(pro): seed lastEntitled=false on redirect-return from checkout

Addresses a gap in the original PR: the transition detector still swallowed
the first pro snapshot when the user came back via Dodo's full-page
redirect flow (/pro page -> Dodo checkout -> return to worldmonitor.app
with ?subscription_id=...&status=active URL params handled by
handleCheckoutReturn).

On that path a fast webhook can land before the browser finishes the
return navigation. When the dashboard boots, Convex's first entitlement
snapshot already carries pro_monthly — which the detector treats as the
"legacy-pro on normal page load" case and does not reload. Panels rendered
against free-tier gating stay locked until manual refresh.

Fix: when handleCheckoutReturn() returns true, seed lastEntitled=false
instead of null. This biases the detector to treat the first pro snapshot
as the true free->pro transition that it is, not a legacy-pro baseline.

Adds two new unit tests covering both redirect-return timings (webhook
already landed; webhook still pending). Full transition suite is now
10/10 passing.

* fix(pro): seed lastEntitled=false across the overlay reload too

Prior amendment covered the full-page Dodo redirect return (URL carries
subscription_id params consumed by handleCheckoutReturn). But the overlay
success path does its own setTimeout(() => window.location.reload(), 3_000)
and the overlay uses manualRedirect:true, so the reload lands at the
original URL with no params. handleCheckoutReturn returns false there,
returnedFromCheckout stays false, lastEntitled seeds to null, and a fast
webhook's first-snapshot pro entitlement gets swallowed as legacy-pro
baseline — same class of bug that caused the 2026-04-17/18 incident, now
reproducible on the overlay path instead of the redirect path.

Fix: before the scheduled reload, set a session flag (wm-post-checkout).
On the reloaded page, panel-layout consumes the flag and treats it as a
post-checkout return, which makes the transition detector seed
lastEntitled=false and correctly route the first pro snapshot through the
reload.

Session storage is used (not local) so the flag is scoped to the tab and
doesn't leak across sessions. Silent try/catch keeps private-browsing
environments working — in that case we fall back to the pre-flag behavior
(risk bounded by the 3s reload + Convex WS catching up, same as before).
2026-04-18 15:19:12 +04:00

99 lines
3.9 KiB
TypeScript

/**
* Unit tests for shouldReloadOnEntitlementChange.
*
* This helper drives the post-payment reload in src/app/panel-layout.ts.
* A bug here is exactly what caused duplicate subscriptions in the
* 2026-04-18 incident (customer cus_0NcmwcAWw0jhVBHVOK58C): the prior
* skipInitialSnapshot guard swallowed the first pro snapshot unconditionally,
* even when it arrived mid-session after a successful Dodo webhook.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { shouldReloadOnEntitlementChange } from '@/services/entitlements';
describe('shouldReloadOnEntitlementChange', () => {
it('does not reload on the first snapshot when user is free', () => {
assert.equal(shouldReloadOnEntitlementChange(null, false), false);
});
it('does not reload on the first snapshot when user is already pro', () => {
// Legacy-pro user on page load — avoid reload loop.
assert.equal(shouldReloadOnEntitlementChange(null, true), false);
});
it('does not reload free → free (idempotent free-tier update)', () => {
assert.equal(shouldReloadOnEntitlementChange(false, false), false);
});
it('does not reload pro → pro (renewal, metadata refresh)', () => {
assert.equal(shouldReloadOnEntitlementChange(true, true), false);
});
it('does not reload pro → free (expiration, revocation) — handled elsewhere', () => {
// Revocation paths are handled by re-rendering; no forced reload.
assert.equal(shouldReloadOnEntitlementChange(true, false), false);
});
it('reloads on free → pro (post-payment activation — the incident case)', () => {
assert.equal(shouldReloadOnEntitlementChange(false, true), true);
});
it('simulates the incident sequence: free-tier default snapshot followed by authed pro snapshot → reload exactly once', () => {
// Before PR 1, this sequence produced no reload because skipInitialSnapshot
// swallowed the first snapshot. After the fix, the transition triggers a
// reload and the user's panels unlock without manual intervention.
let last: boolean | null = null;
let reloadCount = 0;
const snapshots = [false, true, true];
for (const entitled of snapshots) {
if (shouldReloadOnEntitlementChange(last, entitled)) reloadCount += 1;
last = entitled;
}
assert.equal(reloadCount, 1);
});
it('legacy-pro user reconnecting WS: pro, pro, pro → zero reloads', () => {
let last: boolean | null = null;
let reloadCount = 0;
for (const entitled of [true, true, true]) {
if (shouldReloadOnEntitlementChange(last, entitled)) reloadCount += 1;
last = entitled;
}
assert.equal(reloadCount, 0);
});
it('redirect-return from checkout with webhook already landed: seeded as free → first pro snapshot reloads', () => {
// When handleCheckoutReturn() fires, panel-layout seeds lastEntitled=false
// instead of null. Otherwise a fast webhook (pro snapshot arrives as the
// first snapshot after reload) would be swallowed as "legacy-pro".
let last: boolean | null = false; // seeded because returnedFromCheckout=true
let reloadCount = 0;
if (shouldReloadOnEntitlementChange(last, true)) reloadCount += 1;
last = true;
// WS reconnects and re-emits pro — no further reload.
if (shouldReloadOnEntitlementChange(last, true)) reloadCount += 1;
assert.equal(reloadCount, 1);
});
it('redirect-return when webhook is still pending: seeded false → free → pro sequence reloads exactly once', () => {
let last: boolean | null = false;
let reloadCount = 0;
// First snapshot comes back as free (webhook not landed yet).
if (shouldReloadOnEntitlementChange(last, false)) reloadCount += 1;
last = false;
// Webhook lands, pro snapshot arrives.
if (shouldReloadOnEntitlementChange(last, true)) reloadCount += 1;
last = true;
assert.equal(reloadCount, 1);
});
});