mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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).
99 lines
3.9 KiB
TypeScript
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);
|
|
});
|
|
});
|