mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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).
This commit is contained in:
@@ -100,12 +100,12 @@ import { CustomWidgetPanel } from '@/components/CustomWidgetPanel';
|
||||
import { openWidgetChatModal } from '@/components/WidgetChatModal';
|
||||
import { loadWidgets, saveWidget } from '@/services/widget-store';
|
||||
import type { CustomWidgetSpec } from '@/services/widget-store';
|
||||
import { initEntitlementSubscription, destroyEntitlementSubscription, isEntitled, onEntitlementChange } from '@/services/entitlements';
|
||||
import { initEntitlementSubscription, destroyEntitlementSubscription, isEntitled, onEntitlementChange, shouldReloadOnEntitlementChange } from '@/services/entitlements';
|
||||
import { initSubscriptionWatch, destroySubscriptionWatch } from '@/services/billing';
|
||||
import { getUserId } from '@/services/user-identity';
|
||||
import { initPaymentFailureBanner } from '@/components/payment-failure-banner';
|
||||
import { handleCheckoutReturn } from '@/services/checkout-return';
|
||||
import { initCheckoutOverlay, destroyCheckoutOverlay, showCheckoutSuccess } from '@/services/checkout';
|
||||
import { initCheckoutOverlay, destroyCheckoutOverlay, showCheckoutSuccess, consumePostCheckoutFlag } from '@/services/checkout';
|
||||
import { McpDataPanel } from '@/components/McpDataPanel';
|
||||
import { openMcpConnectModal } from '@/components/McpConnectModal';
|
||||
import { loadMcpPanels, saveMcpPanel } from '@/services/mcp-store';
|
||||
@@ -161,7 +161,16 @@ export class PanelLayoutManager implements AppModule {
|
||||
// Free users need the subscription active so they receive real-time
|
||||
// entitlement updates after purchasing (P1: newly upgraded users must
|
||||
// see their premium access without a manual page reload).
|
||||
if (handleCheckoutReturn()) {
|
||||
//
|
||||
// Two return paths need to seed the transition detector as post-checkout:
|
||||
// 1. Full-page Dodo redirect — handleCheckoutReturn() reads
|
||||
// subscription_id/status URL params and cleans them.
|
||||
// 2. Dodo overlay success — setTimeout(reload) with no URL params;
|
||||
// we stash a session flag before the reload and consume it here.
|
||||
const returnedFromCheckoutUrl = handleCheckoutReturn();
|
||||
const returnedFromOverlay = consumePostCheckoutFlag();
|
||||
const returnedFromCheckout = returnedFromCheckoutUrl || returnedFromOverlay;
|
||||
if (returnedFromCheckout) {
|
||||
showCheckoutSuccess();
|
||||
}
|
||||
|
||||
@@ -174,16 +183,24 @@ export class PanelLayoutManager implements AppModule {
|
||||
|
||||
initCheckoutOverlay(() => showCheckoutSuccess());
|
||||
|
||||
// Listen for entitlement changes — reload panels to pick up new gating state.
|
||||
// Skip the initial snapshot to avoid a reload loop for users who already have
|
||||
// premium via legacy signals (API key / wm-pro-key).
|
||||
let skipInitialSnapshot = true;
|
||||
// Reload only on a free→pro transition. Legacy-pro users whose first
|
||||
// snapshot is already pro (lastEntitled === null) must not trigger a
|
||||
// reload loop, but a user who pays mid-session (false → true) must see
|
||||
// their panels unlock without manual refresh.
|
||||
//
|
||||
// When we just returned from a Dodo full-page redirect checkout, seed
|
||||
// lastEntitled = false instead of null. The webhook may have already
|
||||
// landed by the time the user's browser comes back, so the first
|
||||
// entitlement snapshot can arrive as pro. Without this seed the
|
||||
// transition detector would swallow that snapshot as "legacy-pro" and
|
||||
// the user would see locked panels until a manual refresh — exactly the
|
||||
// symptom that caused the 2026-04-17/18 duplicate-subscription incident.
|
||||
let lastEntitled: boolean | null = returnedFromCheckout ? false : null;
|
||||
this.unsubscribeEntitlementChange = onEntitlementChange(() => {
|
||||
if (skipInitialSnapshot) {
|
||||
skipInitialSnapshot = false;
|
||||
return;
|
||||
}
|
||||
if (isEntitled()) {
|
||||
const entitled = isEntitled();
|
||||
const reload = shouldReloadOnEntitlementChange(lastEntitled, entitled);
|
||||
lastEntitled = entitled;
|
||||
if (reload) {
|
||||
console.log('[entitlements] Subscription activated — reloading to unlock panels');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
@@ -20,8 +20,38 @@ const CHECKOUT_PRODUCT_PARAM = 'checkoutProduct';
|
||||
const CHECKOUT_REFERRAL_PARAM = 'checkoutReferral';
|
||||
const CHECKOUT_DISCOUNT_PARAM = 'checkoutDiscount';
|
||||
const PENDING_CHECKOUT_KEY = 'wm-pending-checkout';
|
||||
const POST_CHECKOUT_FLAG_KEY = 'wm-post-checkout';
|
||||
const APP_CHECKOUT_BASE_URL = 'https://worldmonitor.app/';
|
||||
|
||||
/**
|
||||
* Session flag set just before the post-overlay reload. Lets panel-layout
|
||||
* detect "we just returned from an overlay checkout" on the reloaded page —
|
||||
* the overlay uses manualRedirect:true so there are no subscription_id URL
|
||||
* params to key off, unlike the full-page redirect return handled by
|
||||
* handleCheckoutReturn. Exported as a pair (consume+mark) to keep the key
|
||||
* centralized with the rest of the checkout storage constants.
|
||||
*/
|
||||
export function consumePostCheckoutFlag(): boolean {
|
||||
try {
|
||||
if (sessionStorage.getItem(POST_CHECKOUT_FLAG_KEY) === '1') {
|
||||
sessionStorage.removeItem(POST_CHECKOUT_FLAG_KEY);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Private browsing / storage disabled — fall through to false.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function markPostCheckout(): void {
|
||||
try {
|
||||
sessionStorage.setItem(POST_CHECKOUT_FLAG_KEY, '1');
|
||||
} catch {
|
||||
// Storage denied — the reload will still run; transition detector will
|
||||
// fall back to its null baseline, matching the pre-flag behavior.
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingCheckoutIntent {
|
||||
productId: string;
|
||||
referralCode?: string;
|
||||
@@ -52,6 +82,15 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
|
||||
case 'checkout.status':
|
||||
if (event.data?.status === 'succeeded') {
|
||||
onSuccessCallback?.();
|
||||
// Belt-and-braces: reload after the webhook is likely to have
|
||||
// landed (median <5s). Mark a session flag so the reloaded page
|
||||
// can seed the entitlement transition detector as post-checkout
|
||||
// — the overlay uses manualRedirect:true so the reload lands at
|
||||
// the original URL without subscription_id params, and the
|
||||
// detector would otherwise treat the first pro snapshot as the
|
||||
// legacy-pro baseline and swallow it.
|
||||
markPostCheckout();
|
||||
setTimeout(() => window.location.reload(), 3_000);
|
||||
}
|
||||
break;
|
||||
case 'checkout.closed':
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* is not configured or ConvexClient is unavailable.
|
||||
*/
|
||||
|
||||
import { getConvexClient, getConvexApi } from './convex-client';
|
||||
import { getConvexClient, getConvexApi, waitForConvexAuth } from './convex-client';
|
||||
|
||||
export interface EntitlementState {
|
||||
planKey: string;
|
||||
@@ -49,6 +49,18 @@ export async function initEntitlementSubscription(_userId?: string): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for Convex to confirm auth before subscribing. Otherwise the first
|
||||
// getEntitlementsForUser snapshot runs unauthenticated and returns
|
||||
// FREE_TIER_DEFAULTS, which can race with the post-payment panel gating
|
||||
// decision (the UI renders as free before the auth-ready pro snapshot
|
||||
// arrives). Unauthenticated visitors time out after 10s and we skip the
|
||||
// subscription entirely — they don't need entitlement updates.
|
||||
const authed = await waitForConvexAuth(10_000);
|
||||
if (!authed) {
|
||||
console.log('[entitlements] Convex auth not established — skipping subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
const watch = client.onUpdate(
|
||||
api.entitlements.getEntitlementsForUser,
|
||||
{},
|
||||
@@ -149,3 +161,22 @@ export function isEntitled(): boolean {
|
||||
currentState.validUntil >= Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether to reload the page when an entitlement snapshot arrives.
|
||||
*
|
||||
* Rules:
|
||||
* - First snapshot ever (last === null): never reload. A legacy-pro user
|
||||
* whose first snapshot is already `true` must not trigger a reload loop
|
||||
* on every page load.
|
||||
* - Free → pro transition (last === false, next === true): reload. This is
|
||||
* the post-payment activation case — panels rendered against free-tier
|
||||
* gating need to re-render to pick up the new entitlement.
|
||||
* - Everything else (free→free, pro→pro, pro→free): no reload.
|
||||
*/
|
||||
export function shouldReloadOnEntitlementChange(
|
||||
last: boolean | null,
|
||||
next: boolean,
|
||||
): boolean {
|
||||
return last === false && next === true;
|
||||
}
|
||||
|
||||
98
tests/entitlement-transition.test.mts
Normal file
98
tests/entitlement-transition.test.mts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user