Skip to content

fix(checkout): redirect to hosted Dodo checkout instead of the overlay iframe (#4449)#4454

Merged
koala73 merged 3 commits into
mainfrom
fix/dodo-checkout-redirect-mode-4449
Jun 26, 2026
Merged

fix(checkout): redirect to hosted Dodo checkout instead of the overlay iframe (#4449)#4454
koala73 merged 3 commits into
mainfrom
fix/dodo-checkout-redirect-mode-4449

Conversation

@koala73

@koala73 koala73 commented Jun 26, 2026

Copy link
Copy Markdown
Owner

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-Policy and the Dodo SDK's own iframe allow attribute.

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) and pro-test/src/services/checkout.ts (/pro — same bug, would also hang): replace DodoPayments.Checkout.open(checkout_url) with window.location.assign(<validated hosted url>).
  • safeHostedCheckoutUrl() open-redirect guard: only navigates to https://(test.)checkout.dodopayments.com; a missing/untrusted/unparseable URL falls through to the existing 200-without-checkout_url contract-violation handler.
  • The return path is already live: fix(checkout): return Dodo 3DS flows to dashboard #4447 sends Dodo's redirect back to /dashboard?wm_checkout=return, which handleCheckoutReturn reconciles.
  • Overlay machinery (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 rebuild pro-test).

Tests

  • checkout-overlay-lifecycle.test.mts: the success-path test now asserts startCheckout navigates (assignedUrls) and does not open the overlay (openedUrls empty). Written red-first, then green.
  • Full checkout-* suite: 108/108. Dashboard tsc: 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:

  • You should be taken to checkout.dodopayments.com full-page, complete 3DS there, and land back on /dashboard?wm_checkout=return as Pro — no "Processing…" hang, no permission violations.

Closes #4449. Part of epic #4440.

…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
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jun 26, 2026 12:46pm

Request Review

@koala73 koala73 added bug Something isn't working critical Critical issue requiring immediate attention P0 Critical, blocks release or core functionality labels Jun 26, 2026
@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the 3DS "Processing…" hang by switching both the dashboard and /pro checkout surfaces from Dodo's overlay iframe (DodoPayments.Checkout.open) to a top-level redirect (window.location.assign) to Dodo's hosted checkout page, where the nested 3DS/fraud stack runs unconstrained.

  • src/services/checkout.ts / pro-test/src/services/checkout.ts: startCheckout / doCheckout now validate the server-returned checkout_url through a strict safeHostedCheckoutUrl() allowlist (checkout.dodopayments.com, test.checkout.dodopayments.com, HTTPS only) and call window.location.assign(); the overlay machinery is left dormant pending a follow-up cleanup.
  • tests/checkout-overlay-lifecycle.test.mts: First test updated to assert the redirect path — assignedUrls contains the Dodo hosted URL and openedUrls is empty; overlay lifecycle tests continue to exercise the dormant openCheckout path directly.

Confidence Score: 4/5

Safe 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 safeHostedCheckoutUrl guard is well-constructed and the test coverage for the redirect path is solid. The only gaps are on the /pro side: initOverlay still triggers a dead SDK load if the call site hasn't been updated, and a missing checkout_url from the server goes unreported to Sentry. Neither affects correctness of the payment flow itself.

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

Filename Overview
src/services/checkout.ts Core change: replaces DodoPayments.Checkout.open() with window.location.assign(safeHostedCheckoutUrl()) in startCheckout(). Adds HOSTED_CHECKOUT_HOSTS allowlist and safeHostedCheckoutUrl() open-redirect guard. Overlay machinery left dormant. 200-without-checkout_url path now properly reports to Sentry instead of silently returning false.
pro-test/src/services/checkout.ts Same redirect fix applied to /pro surface: DodoPayments.Checkout.open() replaced with window.location.assign(safeHostedCheckoutUrl()). initOverlay() is left in place (still dynamically imports dodopayments-checkout if called), meaning the SDK may still be loaded on /pro even though the overlay is never opened. No Sentry report on missing/invalid checkout_url — silently returns false with only console.error.
tests/checkout-overlay-lifecycle.test.mts First test updated to assert redirect behavior: assignedUrls contains Dodo's hosted checkout URL and openedUrls is empty. Remaining four tests exercise the still-live overlay lifecycle machinery directly via openCheckout(). Test design is sound.
public/pro/assets/index-KDquVatS.js Rebuilt /pro bundle reflecting the checkout.ts changes. Not directly reviewable as a minified artifact; correctness is implied by the tsc and test results cited in the PR description.
public/pro/index.html HTML entry point rebuilt with updated asset hash referencing the new bundle. No logic changes.

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
Loading
%%{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
Loading

Comments Outside Diff (1)

  1. pro-test/src/services/checkout.ts, line 86-256 (link)

    P2 initOverlay still loads the overlay SDK when called

    initOverlay dynamically imports dodopayments-checkout on every call. If the /pro app component still calls initOverlay on mount (as it did before this PR), the full Dodo overlay SDK bundle is fetched and executed even though DodoPayments.Checkout.open is now never invoked. The src/services/checkout.ts dashboard path avoids this problem because it never enters ensureCheckoutOverlayInitialized from startCheckout anymore — but /pro keeps the import alive through initOverlay. Worth short-circuiting or gating the SDK load behind a flag when the cleanup PR lands, or suppressing the call at the call site now.

Reviews (1): Last reviewed commit: "fix(checkout): redirect to hosted Dodo c..." | Re-trigger Greptile

Comment thread pro-test/src/services/checkout.ts
…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.
@koala73

koala73 commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Review findings addressed in dcb046a67.

#1 (P1) /pro return URL pre-marks success — ✅ Valid, fixed

Confirmed in code: /pro sent Dodo returnUrl: DASHBOARD_CHECKOUT_SUCCESS_URL (?wm_checkout=success) while the dashboard correctly uses the guarded ?wm_checkout=return. With redirect now the primary flow, Dodo sends the buyer to that URL for every outcome — and checkout-return.ts:113 (if (hasWmSuccess) return success) false-succeeds a failed/cancelled/pending/no-ID return (e.g. payment_id + status=processing skips the lines 110-111 status checks and hits 113). Fix: /pro now uses a new DASHBOARD_CHECKOUT_RETURN_URL (?wm_checkout=return), so success reconciles only against authoritative Dodo evidence.

#3 (P2) open-redirect guard untested — ✅ Valid, fixed

Extracted safeHostedCheckoutUrl + HOSTED_CHECKOUT_HOSTS to src/services/hosted-checkout-url.ts (dependency-free → directly unit-testable) and added tests/hosted-checkout-url.test.mts: http downgrade, third-party host, look-alike suffix host (…dodopayments.com.evil.com), unlisted subdomain, javascript: URL, unparseable/empty strings, non-string inputs, plus test.checkout.dodopayments.com. 10 cases, green.

#4 (P2) stale overlay comments — ✅ Valid, fixed

Updated the checkout.ts and pro-test/checkout.ts file headers + the dormant redirect_requested comment to state redirect is the active path and the overlay machinery is dormant.

#2 (P2) /pro redirect path lacks coverage — ⚠️ Not addressed here (justified)

pro-test has no test runner at all — adding one is a separate infra task, disproportionate to this PR. The security-critical validator is now unit-tested (#3), and /pro's navigation mirrors the dashboard path that is covered (checkout-overlay-lifecycle asserts navigate-not-overlay). Recommend a follow-up to stand up pro-test test infra (the #1 bug shows /pro is under-tested).

Verification: full checkout suite 118/118, validator 10/10, lifecycle 6/6, dashboard tsc 0 errors, pro-test tsc 0 errors, biome lint clean, public/pro rebuilt.

… + 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.
@koala73
koala73 merged commit 2694f82 into main Jun 26, 2026
26 checks passed
@koala73
koala73 deleted the fix/dodo-checkout-redirect-mode-4449 branch June 26, 2026 12:46
koala73 added a commit that referenced this pull request Jun 26, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working critical Critical issue requiring immediate attention P0 Critical, blocks release or core functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(checkout): P0 — overlay iframe hangs Dodo 3DS/fraud stack at 'Processing' (move to redirect mode)

1 participant