fix(checkout): redirect to hosted Dodo checkout instead of the overlay iframe (#4449)#4454
Conversation
…y iframe (#4449) The overlay-checkout iframe cannot host Dodo's nested 3DS/fraud stack (Hyperswitch → Airwallex → Sardine): the device sensors it fingerprints with are blocked two frames deep by BOTH our Permissions-Policy AND the Dodo SDK's own iframe `allow` attribute — HAR-confirmed, loosening our header to `*` changed nothing (#4450). Card payments requiring 3DS hung at "Processing…" forever. Switch both checkout surfaces to navigate the top window to Dodo's HOSTED checkout (their documented primary flow): 3DS/fraud run unconstrained top-level, and #4447 returns the customer to /dashboard?wm_checkout=return to reconcile. - src/services/checkout.ts (dashboard) + pro-test/src/services/checkout.ts (/pro): replace DodoPayments.Checkout.open(checkout_url) with a validated window.location.assign. New safeHostedCheckoutUrl() guards open-redirect (https + checkout.dodopayments.com origin only); a missing/untrusted URL falls through to the existing contract-violation handler. - Overlay machinery (Initialize/onEvent/watchdog/openCheckout) left dormant pending removal — the overlay SDK is now never loaded on the dashboard. - Test: checkout-overlay-lifecycle asserts startCheckout navigates (assignedUrls) and does NOT open the overlay (openedUrls empty). Red→green. Refs #4449 #4450
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryFixes the 3DS "Processing…" hang by switching both the dashboard and
Confidence Score: 4/5Safe to merge; the core redirect logic and open-redirect guard are correctly implemented and covered by tests. The two observations are confined to the /pro surface and are both pre-existing gaps rather than new regressions. The pro-test/src/services/checkout.ts — verify whether initOverlay is still called at the app level (unnecessary SDK load), and consider adding a Sentry capture for the missing-checkout_url path to match the dashboard surface. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant U as User
participant D as Dashboard / /pro
participant API as /api/create-checkout
participant Guard as safeHostedCheckoutUrl()
participant Dodo as checkout.dodopayments.com
participant Return as /dashboard?wm_checkout=return
U->>D: Click Upgrade
D->>API: "POST /api/create-checkout {productId, returnUrl}"
API-->>D: "200 {checkout_url: https://checkout.dodopayments.com/…}"
D->>Guard: validate checkout_url
Guard-->>D: validated URL (or null → error)
D->>U: window.location.assign(hostedCheckoutUrl)
U->>Dodo: Full-page navigation (3DS/fraud run unconstrained)
Dodo-->>Return: Redirect after payment
Return->>D: handleCheckoutReturn() reconciles
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant U as User
participant D as Dashboard / /pro
participant API as /api/create-checkout
participant Guard as safeHostedCheckoutUrl()
participant Dodo as checkout.dodopayments.com
participant Return as /dashboard?wm_checkout=return
U->>D: Click Upgrade
D->>API: "POST /api/create-checkout {productId, returnUrl}"
API-->>D: "200 {checkout_url: https://checkout.dodopayments.com/…}"
D->>Guard: validate checkout_url
Guard-->>D: validated URL (or null → error)
D->>U: window.location.assign(hostedCheckoutUrl)
U->>Dodo: Full-page navigation (3DS/fraud run unconstrained)
Dodo-->>Return: Redirect after payment
Return->>D: handleCheckoutReturn() reconciles
|
…e redirect guard #1 (P1) /pro pre-marked success: /pro sent Dodo `?wm_checkout=success` as the return_url. With hosted redirect now the primary flow Dodo sends the buyer there for EVERY outcome — a failed/cancelled/pending/no-ID return false-succeeded via the bare success marker (checkout-return.ts:113). Switch /pro to the GUARDED `?wm_checkout=return` contract (mirrors the dashboard / #4447): success reconciles only against authoritative Dodo evidence (subscription_id/payment_id + success status); a non-success return can no longer succeed. #3 (P2) untested open-redirect guard: extract safeHostedCheckoutUrl + HOSTED_CHECKOUT_HOSTS to src/services/hosted-checkout-url.ts (dependency-free, imported by checkout.ts) and add tests/hosted-checkout-url.test.mts — http downgrade, third-party host, look-alike suffix host, unlisted subdomain, javascript: URL, unparseable/empty strings, non-string inputs, plus both valid hosts. 10 cases. #4 (P2) stale comments: update the checkout.ts and pro-test file headers + the dormant redirect_requested comment so they describe redirect as the active flow and the overlay machinery as dormant. #2 (P2 /pro test coverage): not addressed — pro-test has no test runner; adding one is a separate infra task. The shared validator (#3) and the dashboard navigation test cover the redirect logic, which /pro mirrors. Rebuilt public/pro bundle. Refs #4449 #4454.
|
Review findings addressed in #1 (P1) /pro return URL pre-marks success — ✅ Valid, fixedConfirmed in code: #3 (P2) open-redirect guard untested — ✅ Valid, fixedExtracted #4 (P2) stale overlay comments — ✅ Valid, fixedUpdated the #2 (P2) /pro redirect path lacks coverage —
|
… + Sentry parity + docs-stats CI docs-stats: the new src/services/hosted-checkout-url.ts bumped serviceTopLevelEntries 187→188 — regenerate docs/generated/stats.json and update the AGENTS.md count. Greptile (a) dead overlay SDK load on /pro: App.tsx called initOverlay() on mount, which dynamically imported the heavy `dodopayments-checkout` SDK and registered a success banner that can never fire under redirect mode (the buyer lands on the dashboard, not /pro). Remove the call (+ now-unused imports). Symmetric with the dashboard, which simply stopped invoking its overlay init. Bonus: initOverlay is now an unused export, so the bundler tree-shakes the overlay SDK chunk out of the /pro bundle entirely (index.esm-*.js removed). Greptile (b) Sentry parity: the /pro missing/untrusted checkout_url path was console.error-only; add a Sentry.captureMessage so the server-contract violation is observable, matching the dashboard's missing-checkout-url path. Rebuilt public/pro. Refs #4449 #4454.
…before i18n loaded (#4455) After the #4449 redirect-mode switch, a failed payment does a full-page redirect back to the dashboard, and panel-layout renders the checkout-failure banner during the cold init — before i18next's full bundle has loaded. `t('components.checkoutFailureBanner.message')` returned undefined, so the user saw a red "undefined" banner (confirmed on prod after a real declined 3DS payment). The success banner is unaffected — its strings are hardcoded, and it also defers render via waitForEntitlement. Give every t() call in the banner a defaultValue (English, matching en.json) so it renders correctly regardless of i18n readiness and still picks up translations once the bundle loads. Refs #4449 #4454
The fix
Card payments requiring 3DS hung forever at "Processing…" because Dodo's overlay checkout runs its nested 3DS/fraud stack (Hyperswitch → Airwallex → Sardine) inside an iframe, and that stack's device-fingerprinting sensors are blocked two frames deep — by our
Permissions-Policyand the Dodo SDK's own iframeallowattribute.We proved this is not fixable from our side: #4450 loosened our header to
accelerometer=* / bluetooth=* / …and shipped it to prod — the nested Sardine frame still logged "accelerometer is not allowed in this document" and the payment still hung (#4450 has since been reverted in #4453). The block is below our control. Dodo's own FAQ (Q14) confirms the hosted checkout "does not support embedding in an iframe" and lists redirect as the primary integration.This switches both checkout surfaces from the overlay iframe to a top-level navigation to Dodo's hosted checkout, where 3DS/fraud run unconstrained.
Changes
src/services/checkout.ts(dashboard) andpro-test/src/services/checkout.ts(/pro— same bug, would also hang): replaceDodoPayments.Checkout.open(checkout_url)withwindow.location.assign(<validated hosted url>).safeHostedCheckoutUrl()open-redirect guard: only navigates tohttps://(test.)checkout.dodopayments.com; a missing/untrusted/unparseable URL falls through to the existing 200-without-checkout_url contract-violation handler./dashboard?wm_checkout=return, whichhandleCheckoutReturnreconciles.Initialize/onEvent/watchdog/openCheckout) left dormant pending a separate cleanup — bonus: the overlay SDK is now never loaded on the dashboard.public/pro/rebuilt (Vercel ships the committed bundle; it does not rebuildpro-test).Tests
checkout-overlay-lifecycle.test.mts: the success-path test now assertsstartCheckoutnavigates (assignedUrls) and does not open the overlay (openedUrlsempty). Written red-first, then green.checkout-*suite: 108/108. Dashboardtsc: 0 errors.pro-test tsc: no new errors (29 pre-existing module-resolution, identical on baseline). biome lint clean.Verify before merge (the real proof)
Automated tests can't exercise a live 3DS purchase. On the Vercel preview, do a real card purchase from the dashboard upgrade CTA and from
/pro:checkout.dodopayments.comfull-page, complete 3DS there, and land back on/dashboard?wm_checkout=returnas Pro — no "Processing…" hang, no permission violations.Closes #4449. Part of epic #4440.