Skip to content

fix(checkout): return Dodo 3DS flows to dashboard#4447

Merged
koala73 merged 2 commits into
mainfrom
codex/issue-4434-checkout-return-url
Jun 26, 2026
Merged

fix(checkout): return Dodo 3DS flows to dashboard#4447
koala73 merged 2 commits into
mainfrom
codex/issue-4434-checkout-return-url

Conversation

@koala73

@koala73 koala73 commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary

  • send dashboard-origin Dodo checkout sessions back to /dashboard?wm_checkout=return instead of bare origin /
  • teach checkout return handling to reconcile the dashboard return marker when a saved checkout attempt exists, while keeping marker-only spoof URLs without an attempt as no-ops
  • add focused coverage for the checkout session POST body, Dodo success/failure return params, marker-only gated returns, and return URL construction

Closes #4434.

Validation

  • ./node_modules/.bin/tsx --test tests/checkout-return-url.test.mts tests/checkout-return-discriminant.test.mts tests/checkout-overlay-lifecycle.test.mts
  • npm run typecheck
  • node --test tests/deploy-config.test.mjs
  • git diff --check
  • pre-push hook: typecheck, API typecheck, Convex audit, boundary lint, safe HTML, Sentry/rate-limit/premium-fetch checks, edge bundle/test checks, version sync

Review Notes

  • Adversarial review flagged marker-only /dashboard?wm_checkout=return as a possible gap.
  • Addressed by gating marker-only reconciliation on the existing wm-last-checkout-attempt session record, and by explicitly testing the no-attempt/no-Dodo-ID spoof cases.

@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 9:08am

Request Review

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes 3DS checkout flows by routing Dodo full-page returns to /dashboard?wm_checkout=return instead of the bare origin / (now the public welcome page), and teaches the return handler to reconcile the new marker only when a real checkout attempt exists in session storage.

  • checkout-return-url.ts — new utility that constructs the dashboard return URL; used in the startCheckout POST body.
  • checkout-return.ts — adds a fourth detection branch (wm_checkout=return): fires { kind: 'success' } only when !status (no Dodo status in URL) and loadCheckoutAttempt() returns a non-null attempt, preventing marker-only spoofing.
  • Tests — focused coverage for the POST body shape, Dodo success/failure wins over the marker, attempt-gated vs. no-attempt returns, and the spoof-without-Dodo-IDs guard.

Confidence Score: 4/5

Safe to merge; the return-marker branch is tightly gated and the Dodo priority ordering is correct for all tested paths.

The core logic is sound — Dodo status always wins when Dodo IDs are present, and the marker-only path requires a saved checkout attempt. The one gap is a combination where a Dodo ID arrives alongside wm_checkout=return but without a status param; !status evaluates to true and a saved attempt would push the result to success rather than none. Dodo virtually always includes status in its redirects, so the practical risk is low, but the combination is untested.

src/services/checkout-return.ts line 110 — the !status guard and its interaction with a Dodo-ID-present-but-status-absent URL is the only combination not covered by the new tests.

Important Files Changed

Filename Overview
src/services/checkout-return.ts Adds wm_checkout=return branch: success only when !status and a saved checkout attempt exists. Logic priority ordering is correct; one untested combination (Dodo ID present + no status + wm_checkout=return + attempt) silently emits success where the pre-PR path would have emitted none.
src/services/checkout-return-url.ts New utility module; builds the dashboard return URL cleanly using the URL API. Simple, correct, and well-tested.
src/services/checkout.ts Single-line change: returnUrl in the Dodo session POST body now uses buildDashboardCheckoutReturnUrl instead of the bare origin. Straightforward and correct.
tests/checkout-return-discriminant.test.mts Adds thorough new tests for wm_checkout=return: Dodo-wins cases, marker-only with/without attempt, and status-without-Dodo-IDs spoof guard. Missing test: Dodo ID present + no status + wm_checkout=return + saved attempt.
tests/checkout-return-url.test.mts New test file; covers both production origin and preview-host origin cases for the URL builder. Complete coverage for the small utility.
tests/checkout-overlay-lifecycle.test.mts Adds fetch body capture to the harness and a test that verifies the returnUrl in the Dodo session POST body. Harness extension is clean; deepEqual assertion precisely covers the changed field.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant App as WorldMonitor App
    participant Dodo as Dodo Payments
    participant Dashboard as /dashboard

    User->>App: Clicks "Upgrade" on /pro or dashboard
    App->>App: startCheckout(productId)
    App->>App: "buildDashboardCheckoutReturnUrl(origin)<br/>→ /dashboard?wm_checkout=return"
    App->>Dodo: "POST /api/create-checkout<br/>{ productId, returnUrl: ".../dashboard?wm_checkout=return" }"
    Dodo-->>App: "{ checkout_url }"
    App->>Dodo: Opens Dodo overlay / full-page checkout

    alt 3DS Required
        Dodo->>Dashboard: "Redirect → /dashboard?wm_checkout=return&subscription_id=...&status=active"
        Dashboard->>Dashboard: "handleCheckoutReturn()<br/>hasDodoParams=true → SUCCESS_STATUSES match → success"
    else 3DS Card Declined
        Dodo->>Dashboard: "Redirect → /dashboard?wm_checkout=return&payment_id=...&status=failed"
        Dashboard->>Dashboard: "handleCheckoutReturn()<br/>hasDodoParams=true → FAILED_STATUSES match → failed"
    else Marker-only (no Dodo params)
        Dodo->>Dashboard: "Redirect → /dashboard?wm_checkout=return"
        Dashboard->>Dashboard: "handleCheckoutReturn()<br/>hasWmReturn=true, !status=true"
        alt Checkout attempt saved in sessionStorage
            Dashboard->>Dashboard: loadCheckoutAttempt() → non-null → success
        else No saved attempt (spoof guard)
            Dashboard->>Dashboard: loadCheckoutAttempt() → null → none
        end
    end

    Dashboard->>Dashboard: Clean wm_checkout + Dodo params from URL
    Dashboard->>User: Show success/failure/normal banner
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 User
    participant App as WorldMonitor App
    participant Dodo as Dodo Payments
    participant Dashboard as /dashboard

    User->>App: Clicks "Upgrade" on /pro or dashboard
    App->>App: startCheckout(productId)
    App->>App: "buildDashboardCheckoutReturnUrl(origin)<br/>→ /dashboard?wm_checkout=return"
    App->>Dodo: "POST /api/create-checkout<br/>{ productId, returnUrl: ".../dashboard?wm_checkout=return" }"
    Dodo-->>App: "{ checkout_url }"
    App->>Dodo: Opens Dodo overlay / full-page checkout

    alt 3DS Required
        Dodo->>Dashboard: "Redirect → /dashboard?wm_checkout=return&subscription_id=...&status=active"
        Dashboard->>Dashboard: "handleCheckoutReturn()<br/>hasDodoParams=true → SUCCESS_STATUSES match → success"
    else 3DS Card Declined
        Dodo->>Dashboard: "Redirect → /dashboard?wm_checkout=return&payment_id=...&status=failed"
        Dashboard->>Dashboard: "handleCheckoutReturn()<br/>hasDodoParams=true → FAILED_STATUSES match → failed"
    else Marker-only (no Dodo params)
        Dodo->>Dashboard: "Redirect → /dashboard?wm_checkout=return"
        Dashboard->>Dashboard: "handleCheckoutReturn()<br/>hasWmReturn=true, !status=true"
        alt Checkout attempt saved in sessionStorage
            Dashboard->>Dashboard: loadCheckoutAttempt() → non-null → success
        else No saved attempt (spoof guard)
            Dashboard->>Dashboard: loadCheckoutAttempt() → null → none
        end
    end

    Dashboard->>Dashboard: Clean wm_checkout + Dodo params from URL
    Dashboard->>User: Show success/failure/normal banner
Loading

Reviews (1): Last reviewed commit: "fix(checkout): return Dodo 3DS flows to ..." | Re-trigger Greptile

Comment thread src/services/checkout-return.ts Outdated
if (FAILED_STATUSES.has(status)) return { kind: 'failed', rawStatus: status };
}
if (hasWmSuccess) return { kind: 'success' };
if (hasWmReturn && !status && loadCheckoutAttempt()) return { kind: 'success' };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Untested combination: Dodo ID + no status + wm_checkout=return + saved attempt

When Dodo appends a subscription/payment ID to the return URL but omits status (e.g. /dashboard?wm_checkout=return&subscription_id=sub_X), !status is true (because status defaults to ''), so this line fires and returns { kind: 'success' } if a checkout attempt is saved. Before this PR, that same URL would have fallen through to { kind: 'none' }. The existing test at the "checkout params present but status missing" case doesn't include wm_checkout=return, so this combination is uncovered. Dodo reliably includes a status in its redirects, making this a very unlikely path — but a defensive test for ?wm_checkout=return&subscription_id=sub_X (no status, attempt saved) would lock in the intended behaviour.

@koala73
koala73 force-pushed the codex/issue-4434-checkout-return-url branch from 2c591c0 to 2654bee Compare June 26, 2026 07:14
@mintlify

mintlify Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Jun 26, 2026, 7:17 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

The 'wm_checkout'/'return' literals were defined independently in the
producer (checkout-return-url.ts) and the consumer/parser
(checkout-return.ts). A rename on one side would silently break 3DS
return detection while each file's own tests kept passing, since each
hardcodes its expected string. Export CHECKOUT_RETURN_PARAM /
CHECKOUT_RETURN_MARKER from the producer (which owns the URL contract)
and import them into the parser, collapsing the duplicate literals into
one source of truth so a rename becomes a compile-time break.

No behavior change: handleCheckoutReturn still reads the same param and
honors the same marker value; WM_MARKER_SUCCESS stays local (the /pro
overlay-success value no producer module writes). Addresses the P2
maintainability finding from code review.

Claude-Session: https://claude.ai/code/session_017L8nACBdvQ5Sj7mTBfEBhy
@koala73
koala73 merged commit 628ad64 into main Jun 26, 2026
25 checks passed
@koala73
koala73 deleted the codex/issue-4434-checkout-return-url branch June 26, 2026 09:41
koala73 added a commit that referenced this pull request Jun 26, 2026
…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 added a commit that referenced this pull request Jun 26, 2026
…y iframe (#4449) (#4454)

* fix(checkout): redirect to hosted Dodo checkout instead of the overlay 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

* fix(checkout): address #4454 review — guard /pro return URL + test the 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.

* fix(checkout): #4454 review round 2 — drop dead /pro overlay SDK load + 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(checkout): P0 — post-3DS return_url lands on welcome page, never reconciled (regression from #4307)

1 participant