Files
worldmonitor/src
Elie Habib fc94829ce7 fix(settings): prevent paying users hitting 409 on stale Upgrade CTA (#3349)
* fix(settings): stop paying users hitting 409 on stale Upgrade CTA

UnifiedSettings.renderUpgradeSection branched on `isEntitled()`, which
returns false while the Convex entitlement snapshot is still null during
a cold WebSocket load. The modal's `onEntitlementChange` listener only
re-rendered the `api-keys` tab, so the stale "Upgrade to Pro" button in
the Settings tab was never replaced once the snapshot arrived.

Paying users who opened settings in that window saw "Upgrade to Pro",
clicked it, hit `/api/create-checkout`, got 409 `duplicate_subscription`,
and cascaded into the billing-portal fallback path (which itself can
dead-end on a generic Dodo login). Same class of bug as the 2026-04-17/18
panel-overlay duplicate-subscription incident called out in
panel-gating.ts:20-22 -- different surface, same race.

Three-part fix in src/components/UnifiedSettings.ts:

- renderUpgradeSection() returns a hidden wrapper for non-Dodo premium
  (API key / tester key / Clerk pro role) so those users don't get
  stuck on the loading placeholder indefinitely.
- A signed-in user whose Convex entitlement snapshot is still null gets
  a neutral loading placeholder, not "Upgrade to Pro". An early click
  can no longer submit before Convex hydrates.
- open()'s onEntitlementChange handler now swaps the .upgrade-pro-section
  element in place when the snapshot arrives. Click handlers are
  delegated at overlay level, so replacing the node needs no rebind.

Observed signal behind this fix:
- WORLDMONITOR-NY (2026-04-23): Checkout error: duplicate_subscription
- WORLDMONITOR-NZ (2026-04-23): getCustomerPortalUrl Server Error, same
  session, 4s later, triggered by the duplicate-subscription dialog.

* fix(settings): hide loading placeholder to avoid empty bordered card

The base .upgrade-pro-section CSS (main.css:22833) applies margin, padding,
border, and surface background. Without 'hidden', the empty loading
placeholder paints a visibly blank bordered box during the Convex
cold-load window — swapping one bad state (stale 'Upgrade to Pro') for
another (confusing empty card).

Adding 'hidden' lets the browser's default [hidden] { display: none }
suppress the card entirely. Element stays queryable for the replaceWith
swap in open(), so the onEntitlementChange listener still finds it.

* fix(settings): bounded readiness window + click-time isEntitled guard

Addresses P1 review on PR #3349: the initial fix treated
getEntitlementState() === null as "still loading", but null is ALSO a
terminal state when Convex is disabled (no VITE_CONVEX_URL), auth times
out at waitForConvexAuth (10s), or initEntitlementSubscription throws
(entitlements.ts:41,47,58,78). In those cases a signed-in free user
would have seen a permanently empty placeholder instead of the Upgrade
to Pro CTA — a real regression on the main conversion surface.

Changes in src/components/UnifiedSettings.ts:

- Add `entitlementReady` class flag + `entitlementReadyTimer`. The flag
  flips true on first snapshot OR after a 12s fallback timer kicks in.
  12s > the 10s waitForConvexAuth timeout so healthy-but-slow paths land
  on the real state before the fallback fires.
- Seed `entitlementReady = getEntitlementState() !== null` BEFORE the
  first render() so the initial paint branches on the current snapshot,
  not the stale value from a prior open/close cycle.
- renderUpgradeSection() now gates the loading placeholder on
  `!this.entitlementReady` so the signed-in-free branch eventually
  renders the Upgrade CTA even when Convex never hydrates.
- handleUpgradeClick() defensively re-checks isEntitled() at click time:
  if the snapshot arrives AFTER the 12s timer but BEFORE the user's
  click, route to the billing portal instead of triggering
  /api/create-checkout against an active subscription (which would 409
  and re-enter the exact duplicate_subscription → getCustomerPortalUrl
  cascade this PR is trying to eliminate).
- Extract replaceUpgradeSection() helper so both the listener and the
  fallback timer share the same in-place swap path.
- close() clears the timer.

* fix(settings): clear entitlementReadyTimer in destroy()

Mirror the close() cleanup. Without this, if destroy() is called during
the 12s fallback window the timer fires after teardown and invokes
replaceUpgradeSection() against a detached overlay. The early-return
inside replaceUpgradeSection (querySelector returns null) makes the
callback a no-op, but the stray async callback + DOM reference stay
alive until fire — tidy them up at destroy time.
2026-04-23 21:00:55 +04:00
..
2026-03-20 12:37:24 +04:00