Skip to content

feat: pricing Pro CTA starts Stripe checkout directly from marketing - #56

Merged
sweetmantech merged 2 commits into
mainfrom
feat/1902-pricing-cta-intent
Jul 29, 2026
Merged

feat: pricing Pro CTA starts Stripe checkout directly from marketing#56
sweetmantech merged 2 commits into
mainfrom
feat/1902-pricing-cta-intent

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What

The Pro card's "Start 30-day trial" CTA now starts Stripe checkout directly from the marketing site instead of deep-linking into chat. Clicking it signs the visitor in with Privy (if needed), creates a checkout session against recoup-api, and redirects straight to Stripe. The free CTAs (Plus card "Start free" and the bottom "Get started free") keep the UTM-attributed links from the first commit. The intent=pro-trial param approach is dropped entirely.

Why (decision)

Scope change on 1902 M1, decided after the first cut of this PR: sending a buyer with checkout intent through the chat homepage adds redirects and a cross-domain session hop where intent gets lost. Checkout can start where the intent is expressed because the enablers are already in place (verified in code):

  • Marketing mounts the same Privy app as chat (contexts/PrivyAuthProvider.tsx), so a sign-in here is a real session.
  • POST /api/subscriptions/sessions accepts a Privy bearer with Access-Control-Allow-Origin: * and an OPTIONS handler (api/app/api/subscriptions/sessions/route.ts); request {successUrl}, response {id, url}.
  • The auth layer auto-provisions accounts for brand-new Privy users (api/lib/privy/getOrCreateAccountIdByAuthToken.ts), so first-time signups work.

How it works

  • hooks/useProCheckout.ts: Privy-gated trigger mirroring useCatalogValuation (signed out: open Privy, auto-resume checkout once authenticated), then getAccessToken -> POST ${siteConfig.apiUrl}/subscriptions/sessions -> window.location.href = url.
  • lib/checkout/createCheckoutSession.ts: bearer-authed session call, mirrors lib/valuation/runValuation.ts. TDD (4 tests).
  • components/pricing/ProCheckoutButton.tsx: visually identical to the anchor CTAs (same classes, same copy), busy state "Starting checkout...", visible error message with retry on failure.
  • lib/buildChatUrl.ts: intent replaced by an optional checkout flag; builds the Stripe success URL so the buyer lands in the app after paying with attribution preserved:
    https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial

CTA summary

CTA Behavior
Pro "Start 30-day trial" Privy sign-in (if needed) -> Stripe checkout -> success lands on https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial
Plus "Start free" Link: https://chat.recoupable.dev/?utm_source=marketing&utm_medium=pricing&utm_campaign=free
Bottom "Get started free" Link: https://chat.recoupable.dev/?utm_source=marketing&utm_medium=pricing&utm_campaign=free

How to test

  1. On the preview deployment, open /pricing signed out.
  2. Click "Start 30-day trial": the Privy email modal opens; complete login and checkout resumes automatically, redirecting to a Stripe checkout page (preview builds hit test-recoup-api, so the session comes from the test environment).
  3. Click it signed in: it goes straight to Stripe (button shows "Starting checkout..." while the session is created).
  4. Failure path: block the sessions request (devtools offline) and click; a visible error with retry guidance appears under the button.
  5. Free CTAs: hover/click shows the UTM-only chat links.

Notes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Pro plan checkout directly from the pricing page.
    • Unauthenticated visitors are prompted to sign in before checkout continues.
    • Added checkout progress feedback, error messaging, and retry support.
    • Updated free-plan links with marketing attribution tracking.
  • Bug Fixes

    • Improved checkout error handling for failed or incomplete checkout sessions.
  • Tests

    • Added coverage for checkout session creation and tracked chat URL generation.

The pricing page CTAs linked to bare https://chat.recoupable.dev/, dropping
purchase intent and attribution at the marketing-to-app handoff. Add a
buildChatUrl helper (TDD) and use it for the three chat-bound CTAs:

- Pro "Start 30-day trial" -> ?intent=pro-trial + UTM (pro-trial campaign)
- Plus "Start free" -> UTMs only (free campaign)
- Bottom "Get started free" -> UTMs only (free campaign)

Hrefs only; card copy untouched. Part of recoupable/chat#1902 (M1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
marketing Ready Ready Preview Jul 29, 2026 7:35pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Pricing CTAs now generate attributed chat URLs for free access. The Pro plan uses Privy-gated checkout, creates a Stripe session through the subscriptions API, handles pending and error states, and redirects to checkout.

Changes

Pricing checkout flow

Layer / File(s) Summary
Chat URL attribution
lib/buildChatUrl.ts, lib/copy/pricing.ts, lib/__tests__/buildChatUrl.test.ts
Adds UTM-aware chat URLs, makes Pro links optional, routes the Plus plan through the free campaign URL, and tests query construction.
Authenticated Pro checkout
lib/checkout/createCheckoutSession.ts, hooks/useProCheckout.ts, components/pricing/ProCheckoutButton.tsx, lib/checkout/__tests__/*
Creates authenticated subscription sessions, defers checkout until Privy authentication completes, and exposes pending and retryable error states with API coverage.
Pricing CTA wiring
app/pricing/page.tsx
Renders the Pro checkout button, routes other plan CTAs through links, shares CTA classes, and updates the bottom free CTA URL.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • recoupable/chat#1902 — Adds the Pro authentication and Stripe checkout flow, preserves checkout intent, and routes pricing CTAs through attributed chat URLs.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: the Pro pricing CTA now launches Stripe checkout directly from the marketing page.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1902-pricing-cta-intent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 4 files

Re-trigger cubic

Rework the Pro card CTA from a deep link into chat to a direct Stripe
checkout started on the marketing site (scope change on #1902 M1):

- hooks/useProCheckout.ts: Privy-gated trigger mirroring
  useCatalogValuation (login when signed out, auto-resume checkout on
  auth), then POST /api/subscriptions/sessions with the Privy bearer and
  redirect to the returned Stripe URL.
- lib/checkout/createCheckoutSession.ts: bearer-authed session call
  (TDD), mirrors lib/valuation/runValuation.ts.
- components/pricing/ProCheckoutButton.tsx: visually identical button
  with busy ("Starting checkout...") and error-with-retry states.
- buildChatUrl: intent param dropped entirely; optional checkout flag
  builds the Stripe success URL landing buyers in chat with attribution.
- Free CTAs keep the UTM-only links from the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sweetmantech sweetmantech changed the title feat: pricing CTAs carry checkout intent and UTM attribution feat: pricing Pro CTA starts Stripe checkout directly from marketing Jul 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
lib/copy/pricing.ts (1)

51-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Define the free CTA destination once.

Both locations independently build the same attributed URL, so a campaign change can split the card and bottom CTA behavior.

  • lib/copy/pricing.ts#L51-L51: export a shared free-pricing chat URL constant and use it for the Plus CTA.
  • app/pricing/page.tsx#L158-L158: import and use that shared constant for the bottom CTA.

As per coding guidelines, “Follow DRY principle: no code duplication.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/copy/pricing.ts` at line 51, Define and export a shared free-pricing chat
URL constant in lib/copy/pricing.ts near the Plus CTA, then use it for that CTA
instead of rebuilding the URL. In app/pricing/page.tsx at line 158, import and
use the same constant for the bottom CTA so both destinations remain
synchronized.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@hooks/useProCheckout.ts`:
- Around line 47-60: The deferred checkout flag in the checkout handler and the
auto-resume useEffect must only survive the login attempt that created it. Clear
pendingCheckout.current when login is cancelled or otherwise fails, and when
authentication becomes idle without a valid deferred request; after a successful
resume, clear it before calling doCheckout. Ensure later authentication changes
cannot trigger checkout without a new user action.

In `@lib/__tests__/buildChatUrl.test.ts`:
- Around line 6-14: Update the expected URLs in the buildChatUrl tests to derive
the host from the siteConfig.appUrl value imported from lib/config.ts, while
preserving the existing query-string assertions for checkout and free URLs.
Remove the hardcoded chat.recoupable.dev brand endpoint from both assertions.

In `@lib/checkout/createCheckoutSession.ts`:
- Around line 35-39: Update createCheckoutSession to validate the parsed
response with a Zod schema before returning data.url, requiring an absolute
Stripe checkout URL rather than accepting any truthy value. Treat schema
validation failures like other unsuccessful responses and preserve the existing
error behavior; add coverage for a malformed 2xx response.

---

Nitpick comments:
In `@lib/copy/pricing.ts`:
- Line 51: Define and export a shared free-pricing chat URL constant in
lib/copy/pricing.ts near the Plus CTA, then use it for that CTA instead of
rebuilding the URL. In app/pricing/page.tsx at line 158, import and use the same
constant for the bottom CTA so both destinations remain synchronized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e671d88-35c8-45d3-996e-173cf22ccce8

📥 Commits

Reviewing files that changed from the base of the PR and between e1a67c7 and 022e4fd.

📒 Files selected for processing (8)
  • app/pricing/page.tsx
  • components/pricing/ProCheckoutButton.tsx
  • hooks/useProCheckout.ts
  • lib/__tests__/buildChatUrl.test.ts
  • lib/buildChatUrl.ts
  • lib/checkout/__tests__/createCheckoutSession.test.ts
  • lib/checkout/createCheckoutSession.ts
  • lib/copy/pricing.ts

Comment thread hooks/useProCheckout.ts
Comment on lines +47 to +60
if (!authenticated) {
pendingCheckout.current = true;
login();
return;
}
await doCheckout();
}

// Auto-resume the deferred checkout once the user signs in.
useEffect(() => {
if (authenticated && pendingCheckout.current) {
pendingCheckout.current = false;
void doCheckout();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For @privy-io/react-auth version 3.31.0, how does usePrivy().login() signal a dismissed or failed login, and what is the supported way to clear deferred login intent?

💡 Result:

In @privy-io/react-auth version 3.31.0, the login process is managed via the login method provided by the useLogin hook [1][2]. When a user dismisses the login UI or an error occurs during the authentication flow, the SDK signals this event through the onError callback defined within the useLogin configuration [1][2]. To handle this, you should pass an onError callback when initializing the useLogin hook [1][2]: import { useLogin } from '@privy-io/react-auth'; const { login } = useLogin({ onComplete: (user) => { // Handle successful login }, onError: (error) => { // Handle dismissed login or other errors console.log('Login flow exited or failed:', error); } }); Regarding "deferred login intent," the Privy SDK does not explicitly expose a public API to "clear" an internal deferred intent state. The login flow is generally considered stateless regarding the SDK's internal UI state once the component unmounts or the flow is closed by the user [1][2]. If you are experiencing issues where a previous login attempt is interfering with a new one, the standard practice is to ensure the login UI is fully closed and, if necessary, ensure that any underlying auth state or wallet connections are cleared using the logout method [3] or by manual cleanup of the specific wallet provider (e.g., disconnecting the wallet directly if using a third-party provider) [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)hooks/useProCheckout\.ts$|package\.json|lockfile|pnpm-lock|yarn.lock|package-lock'

echo "== relevant file =="
if [ -f hooks/useProCheckout.ts ]; then
  nl -ba hooks/useProCheckout.ts | sed -n '1,140p'
fi

echo "== package privy versions =="
python3 - <<'PY'
import json, os, re
for name in ["package.json"]:
    if os.path.exists(name):
        data=json.load(open(name))
        deps={}
        for k in ["dependencies","devDependencies","peerDependencies"]:
            deps.update(data.get(k,{}) or {})
        for k,v in sorted(deps.items()):
            if "privy" in k.lower() or "`@privy`" in k.lower():
                print(k, v)
PY
if [ -f package-lock.json ]; then
  node - <<'JS'
const fs=require('fs');
const p=JSON.parse(fs.readFileSync('package-lock.json','utf8'));
const q='node_modules/@privy-io/react-auth';
if (p.packages && p.packages[q]) console.log(JSON.stringify({version:p.packages[q].version, dependencies:p.packages[q].dependencies}, null, 2));
JS
fi

echo "== usages of useProCheckout and privy related login =="
rg -n "useProCheckout|Privy|privy|onError|useLogin|login\\(" -S --glob '!node_modules' --glob '!dist' --glob '!build' .

Repository: recoupable/marketing

Length of output: 288


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant file =="
if [ -f hooks/useProCheckout.ts ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' hooks/useProCheckout.ts | sed -n '1,160p'
fi

echo "== package.json deps =="
python3 - <<'PY'
import json
from pathlib import Path
p=json.loads(Path('package.json').read_text())
for section in ('dependencies','devDependencies','peerDependencies'):
    deps=json.loads(json.dumps(p.get(section,{}) or {}, sort_keys=True))
    for k,v in sorted(deps.items()):
        if 'privy' in k.lower():
            print(section, k, v)
PY

echo "== lockfile privy packages =="
rg -n "`@privy-io/react-auth`|node_modules/@privy-io/react-auth|version:" pnpm-lock.yaml | sed -n '1,120p' || true

echo "== usages of useProCheckout and privy related login =="
rg -n "useProCheckout|Privy|privy|onError|useLogin|useAuthorized|login\\(" -S --glob '!node_modules' --glob '!dist' --glob '!build' .

Repository: recoupable/marketing

Length of output: 13800


🌐 Web query:

@privy-io/react-auth 3.31.0 login callback authentication error onPrivyLoginComplete usePrivy login

💡 Result:

The callback function mentioned as onPrivyLoginComplete is not a standard part of the @privy-io/react-auth API. Based on official Privy documentation, the correct way to handle login completion and errors is by passing onComplete and onError callbacks to the useLogin hook or related authentication hooks [1][2]. If you are encountering authentication errors during the login flow, ensure you are using the correct callback pattern: 1. Use the useLogin hook: Do not attempt to rely on a non-existent global or context-based callback named onPrivyLoginComplete [1]. 2. Implement callbacks correctly: tsx import { useLogin } from '`@privy-io/react-auth`'; function LoginButton() { const { login } = useLogin({ onComplete: ({ user, isNewUser, wasAlreadyAuthenticated, loginMethod, loginAccount }) => { console.log('User logged in successfully', user); }, onError: (error) => { console.error('Login failed', error); } }); return <button onClick={login}>Log in</button>; } 3. Check OAuth configuration: If your error occurs specifically during social logins (e.g., Google, Discord), ensure your app's URL scheme is correctly configured in your Privy App Client settings; otherwise, the login may fail silently [1]. 4. Troubleshooting: For specific error codes (e.g., linked_to_another_user), refer to the Privy client-side error documentation [3]. If you are using frameworks like Gatsby or Next.js, ensure the PrivyProvider is initialized correctly, as improper rendering (e.g., using wrapRootElement instead of wrapPageElement in Gatsby) can cause iframe initialization errors [4]. For version 3.31.0 specifically, ensure you are not using deprecated props from earlier versions, as recent updates have moved toward named arguments in these callbacks [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "== nearby useCatalogValuation for comparison =="
awk '{printf "%6d\t%s\n", NR, $0}' hooks/useCatalogValuation.ts | sed -n '80,120p' || true
echo "== current diff around hooks if available =="
git diff -- hooks/useProCheckout.ts hooks/useCatalogValuation.ts | sed -n '1,220p' || true

Repository: recoupable/marketing

Length of output: 1646


Clear deferred checkout if the login flow does not actually complete.

pendingCheckout.current stays true once login() is called, so any subsequent authentication while this hook is mounted will resume Stripe checkout without another button click. Clear the ref on successful resume, cancellation, or authenticated-idle state, or use an explicit request-id instead of a shared pending flag.

🧰 Tools
🪛 React Doctor (0.9.1)

[error] 56-56: This setter runs after await, so overlapping re-runs of the effect can resolve out of order and write stale state; gate it behind a cancellation/ignore flag or return a cleanup that cancels the work.

In a useEffect whose dependencies can change, guard any setter call that runs after an await behind a cancellation/ignore flag, or return a cleanup that cancels the async work.

(no-set-state-after-await-in-effect)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/useProCheckout.ts` around lines 47 - 60, The deferred checkout flag in
the checkout handler and the auto-resume useEffect must only survive the login
attempt that created it. Clear pendingCheckout.current when login is cancelled
or otherwise fails, and when authentication becomes idle without a valid
deferred request; after a successful resume, clear it before calling doCheckout.
Ensure later authentication changes cannot trigger checkout without a new user
action.

Comment on lines +6 to +14
expect(buildChatUrl({ checkout: "success", campaign: "pro-trial" })).toBe(
"https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial",
);
});

it("builds the free URL with UTMs only when no checkout flag is given", () => {
expect(buildChatUrl({ campaign: "free" })).toBe(
"https://chat.recoupable.dev/?utm_source=marketing&utm_medium=pricing&utm_campaign=free",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Derive the expected chat host from config.

Both assertions hardcode the brand endpoint. Import siteConfig and build the expected URL from siteConfig.appUrl so the test remains valid across configured environments. As per coding guidelines, “Never hardcode brand values; import from lib/config.ts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/__tests__/buildChatUrl.test.ts` around lines 6 - 14, Update the expected
URLs in the buildChatUrl tests to derive the host from the siteConfig.appUrl
value imported from lib/config.ts, while preserving the existing query-string
assertions for checkout and free URLs. Remove the hardcoded chat.recoupable.dev
brand endpoint from both assertions.

Source: Coding guidelines

Comment on lines +35 to +39
const data: CheckoutSessionResponse | null = await res.json().catch(() => null);
if (!res.ok || !data?.url) {
throw new Error(data?.error ?? `couldn't start checkout (${res.status})`);
}
return data.url;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -HI '^package\.json$' -x rg -n '"zod"' {}

Repository: recoupable/marketing

Length of output: 1034


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## locate files"
fd -HI '^createCheckoutSession\.ts$|^checkoutSession|^CheckoutSession' . || true

echo "## git ls target"
git ls-files | rg '(^|/)createCheckoutSession\.ts$|checkout|package\.json$' || true

echo "## package zips"
fd -HI '^package\.json$' -x sh -c 'echo "=== $1 ==="; rg -n "\"zod\"|name|dependencies|devDependencies" "$1" || true' sh {}

echo "## inspect target"
ast-grep outline lib/checkout/createCheckoutSession.ts --view compact || true
sed -n '1,220p' lib/checkout/createCheckoutSession.ts

Repository: recoupable/marketing

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## top-level package.json zod"
sed -n '1,120p' package.json | rg -n "zod|dependencies|devDependencies|name|version" -C 2 || true

echo "## locate createCheckoutSession"
fd -HI '^createCheckoutSession\.ts$' .
FILE="$(fd -HI '^createCheckoutSession\.ts$' . | head -n 1 || true)"
if [ -n "${FILE:-}" ]; then
  echo "## file: $FILE"
  wc -l "$FILE"
  ast-grep outline "$FILE" --view compact || true
  sed -n '1,160p' "$FILE"
fi

echo "## tests referencing createCheckoutSession / checkoutSession / url"
rg -n "createCheckoutSession|checkoutSession|window\.location|location\.href|malformed|2xx|200|url" --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' | head -n 200 || true

echo "## Zod URL transform/brand helpers"
sed -n '1,220p' "$FILE" | rg -n "URL|z\.refine|z\.url|brand|Url" -C 3 || true

Repository: recoupable/marketing

Length of output: 2477


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## all local test/spec files"
git ls-files '*.{test,spec}.{ts,tsx,js,jsx}' | sed -n '1,120p'

echo "## all local test content with createCheckoutSession/checkout"
rg -n "createCheckoutSession|checkoutSession|couldn't start checkout|session" --glob '*.{test,spec}.{ts,tsx,js,jsx}' --glob '!node_modules/**' || true

echo "## Node runtime probe for current conditional behavior"
node - <<'JS'
const samples = [
  { ok: true, data: { url: relative, status: "not-an-url" } },
  { ok: true, data: { url: relative, status: "not-an-url" } },
  { ok: false, data: { url: "https://host/" }, status: 500 },
  { ok: true, data: { error: "bad" }, status: 200 },
  { ok: true, data: null },
]
for (const {ok, data, status} of samples) {
  const threw = (() => {
    try {
      if (!ok || !(data?.url)) throw new Error(data?.error ?? `couldn't start checkout (${status})`)
      return data.url
    } catch (e) {
      return `THREW ${e.message}`
    }
  })()
  console.log(JSON.stringify({ ok, data, status, result: throw }))
}

// URL constructor is stricter than truthiness; `new URL(...)` can be tested in Node.
for (const value of ["", "   ", "not an url", "mailto:malicious", "https://host/", "/local?path="]) {
  try {
    console.log(value, "=>", new URL(value).href)
  } catch (e) {
    console.log(value, "=> throws", e.name)
  }
}
JS

Repository: recoupable/marketing

Length of output: 353


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Node runtime probe for current falsy-check behavior"
node - <<'JS'
const samples = [
  { ok: true, data: { url: "" }, status: 200 },
  { ok: true, data: { url: "not-an-url" }, status: 200 },
  { ok: false, data: null, status: 500 },
  { ok: true, data: null, status: 200 },
  { ok: true, data: { error: "bad" }, status: 200 },
]

for (const {ok, data, status} of samples) {
  try {
    const returned = (!ok || !(data?.url))
      ? (() => { throw new Error(data?.error ?? `couldn't start checkout (${status})`) })()
      : data.url
    console.log(JSON.stringify({ ok, data, status, returned }))
  } catch (e) {
    console.log(JSON.stringify({ ok, data, status, threw: e.message }))
  }
}

for (const value of ["", "   ", "not an url", "mailto:malicious", "https://host/", "/local?path="]) {
  try {
    console.log(value, "=>", new URL(value).href)
  } catch (e) {
    console.log(value, "=> throws", e.name)
  }
}
JS

Repository: recoupable/marketing

Length of output: 767


Validate the checkout response before returning the URL.

A 2xx response with any truthy url, including malformed values, is returned by createCheckoutSession; the caller can then assign it to window.location.href. Parse the response Zod-side with a schema requiring an absolute Stripe URL, and add coverage for a malformed 2xx response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/checkout/createCheckoutSession.ts` around lines 35 - 39, Update
createCheckoutSession to validate the parsed response with a Zod schema before
returning data.url, requiring an absolute Stripe checkout URL rather than
accepting any truthy value. Treat schema validation failures like other
unsuccessful responses and preserve the existing error behavior; add coverage
for a malformed 2xx response.

Source: Coding guidelines

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

6 issues found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/copy/pricing.ts">

<violation number="1" location="lib/copy/pricing.ts:71">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description's before/after table claims the Pro plan's "Start 30-day trial" CTA should link to a UTM-attributed href (`?intent=pro-trial&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial`), but the diff removes `ctaHref` entirely from that plan instead. This leaves a contradiction between the documented behavior and the actual implementation. Please align the PR description with the checkout-based approach the code now uses, or update the code to match the documented href if that was the intended behavior.</violation>
</file>

<file name="lib/__tests__/buildChatUrl.test.ts">

<violation number="1" location="lib/__tests__/buildChatUrl.test.ts:5">
P3: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description documents the helper as `buildChatUrl({ intent?, campaign })` with `intent=pro-trial` in the before/after table, but both the implementation (`lib/buildChatUrl.ts`) and the tests consistently use `checkout` as the parameter name instead. Updating the PR description to reference `checkout` rather than `intent` prevents confusion for reviewers and future readers who rely on the description to understand the change.</violation>
</file>

<file name="lib/checkout/createCheckoutSession.ts">

<violation number="1" location="lib/checkout/createCheckoutSession.ts:4">
P2: Malformed successful responses can be returned as checkout URLs because `CheckoutSessionResponse` is erased at runtime. Parse the request inputs and response envelope with Zod before sending or returning values, as required for this checkout boundary.</violation>
</file>

<file name="hooks/useProCheckout.ts">

<violation number="1" location="hooks/useProCheckout.ts:45">
P3: A click while Privy is initializing is silently discarded, because `startCheckout` returns when `ready` is false while the CTA remains enabled. Disable the CTA until readiness (or retain the click and resume it) so visitors do not need to click again.</violation>

<violation number="2" location="hooks/useProCheckout.ts:52">
P1: An already signed-in customer can start a second Pro subscription: the authenticated path always calls `doCheckout()`, while the subscriptions-session API creates a new Stripe subscription without checking whether the account already has an active subscription or trial. A backend entitlement/idempotency check (and a corresponding UI state for existing subscribers) would prevent duplicate subscriptions and charges.</violation>

<violation number="3" location="hooks/useProCheckout.ts:57">
P2: pendingCheckout.current is only cleared when the auto-resume effect fires on a successful login. If the user cancels the Privy login modal or authentication happens through another path while this hook stays mounted, the ref remains true and the next authenticated state change will silently kick off Stripe checkout without another click. Consider clearing the ref on cancellation or using a request-id keyed to the specific startCheckout invocation instead of a shared boolean flag.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread hooks/useProCheckout.ts
login();
return;
}
await doCheckout();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: An already signed-in customer can start a second Pro subscription: the authenticated path always calls doCheckout(), while the subscriptions-session API creates a new Stripe subscription without checking whether the account already has an active subscription or trial. A backend entitlement/idempotency check (and a corresponding UI state for existing subscribers) would prevent duplicate subscriptions and charges.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useProCheckout.ts, line 52:

<comment>An already signed-in customer can start a second Pro subscription: the authenticated path always calls `doCheckout()`, while the subscriptions-session API creates a new Stripe subscription without checking whether the account already has an active subscription or trial. A backend entitlement/idempotency check (and a corresponding UI state for existing subscribers) would prevent duplicate subscriptions and charges.</comment>

<file context>
@@ -0,0 +1,65 @@
+      login();
+      return;
+    }
+    await doCheckout();
+  }
+
</file context>

Comment thread lib/copy/pricing.ts
],
cta: "Start 30-day trial",
ctaHref: siteConfig.appUrl,
highlighted: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Custom agent: Flag AI Slop and Fabricated Changes

The PR description's before/after table claims the Pro plan's "Start 30-day trial" CTA should link to a UTM-attributed href (?intent=pro-trial&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial), but the diff removes ctaHref entirely from that plan instead. This leaves a contradiction between the documented behavior and the actual implementation. Please align the PR description with the checkout-based approach the code now uses, or update the code to match the documented href if that was the intended behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/copy/pricing.ts, line 71:

<comment>The PR description's before/after table claims the Pro plan's "Start 30-day trial" CTA should link to a UTM-attributed href (`?intent=pro-trial&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial`), but the diff removes `ctaHref` entirely from that plan instead. This leaves a contradiction between the documented behavior and the actual implementation. Please align the PR description with the checkout-based approach the code now uses, or update the code to match the documented href if that was the intended behavior.</comment>

<file context>
@@ -68,7 +69,6 @@ export const pricingCopy = {
         "API access",
       ],
       cta: "Start 30-day trial",
-      ctaHref: buildChatUrl({ intent: "pro-trial", campaign: "pro-trial" }),
       highlighted: true,
</file context>

import { siteConfig } from "@/lib/config";

/** Raw `POST /api/subscriptions/sessions` envelope. */
type CheckoutSessionResponse = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Malformed successful responses can be returned as checkout URLs because CheckoutSessionResponse is erased at runtime. Parse the request inputs and response envelope with Zod before sending or returning values, as required for this checkout boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/checkout/createCheckoutSession.ts, line 4:

<comment>Malformed successful responses can be returned as checkout URLs because `CheckoutSessionResponse` is erased at runtime. Parse the request inputs and response envelope with Zod before sending or returning values, as required for this checkout boundary.</comment>

<file context>
@@ -0,0 +1,40 @@
+import { siteConfig } from "@/lib/config";
+
+/** Raw `POST /api/subscriptions/sessions` envelope. */
+type CheckoutSessionResponse = {
+  id?: string;
+  url?: string;
</file context>

Comment thread hooks/useProCheckout.ts

// Auto-resume the deferred checkout once the user signs in.
useEffect(() => {
if (authenticated && pendingCheckout.current) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: pendingCheckout.current is only cleared when the auto-resume effect fires on a successful login. If the user cancels the Privy login modal or authentication happens through another path while this hook stays mounted, the ref remains true and the next authenticated state change will silently kick off Stripe checkout without another click. Consider clearing the ref on cancellation or using a request-id keyed to the specific startCheckout invocation instead of a shared boolean flag.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useProCheckout.ts, line 57:

<comment>pendingCheckout.current is only cleared when the auto-resume effect fires on a successful login. If the user cancels the Privy login modal or authentication happens through another path while this hook stays mounted, the ref remains true and the next authenticated state change will silently kick off Stripe checkout without another click. Consider clearing the ref on cancellation or using a request-id keyed to the specific startCheckout invocation instead of a shared boolean flag.</comment>

<file context>
@@ -0,0 +1,65 @@
+
+  // Auto-resume the deferred checkout once the user signs in.
+  useEffect(() => {
+    if (authenticated && pendingCheckout.current) {
+      pendingCheckout.current = false;
+      void doCheckout();
</file context>

import { buildChatUrl } from "@/lib/buildChatUrl";

describe("buildChatUrl", () => {
it("builds the checkout success URL with UTM attribution", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Custom agent: Flag AI Slop and Fabricated Changes

The PR description documents the helper as buildChatUrl({ intent?, campaign }) with intent=pro-trial in the before/after table, but both the implementation (lib/buildChatUrl.ts) and the tests consistently use checkout as the parameter name instead. Updating the PR description to reference checkout rather than intent prevents confusion for reviewers and future readers who rely on the description to understand the change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/__tests__/buildChatUrl.test.ts, line 5:

<comment>The PR description documents the helper as `buildChatUrl({ intent?, campaign })` with `intent=pro-trial` in the before/after table, but both the implementation (`lib/buildChatUrl.ts`) and the tests consistently use `checkout` as the parameter name instead. Updating the PR description to reference `checkout` rather than `intent` prevents confusion for reviewers and future readers who rely on the description to understand the change.</comment>

<file context>
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
-  it("builds the pro-trial URL with intent plus UTM attribution", () => {
-    expect(buildChatUrl({ intent: "pro-trial", campaign: "pro-trial" })).toBe(
-      "https://chat.recoupable.dev/?intent=pro-trial&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial",
+  it("builds the checkout success URL with UTM attribution", () => {
+    expect(buildChatUrl({ checkout: "success", campaign: "pro-trial" })).toBe(
+      "https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial",
</file context>

Comment thread hooks/useProCheckout.ts
}

async function startCheckout() {
if (!ready || isPending) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: A click while Privy is initializing is silently discarded, because startCheckout returns when ready is false while the CTA remains enabled. Disable the CTA until readiness (or retain the click and resume it) so visitors do not need to click again.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useProCheckout.ts, line 45:

<comment>A click while Privy is initializing is silently discarded, because `startCheckout` returns when `ready` is false while the CTA remains enabled. Disable the CTA until readiness (or retain the click and resume it) so visitors do not need to click again.</comment>

<file context>
@@ -0,0 +1,65 @@
+  }
+
+  async function startCheckout() {
+    if (!ready || isPending) return;
+    // Gate: signed out opens Privy and defers checkout to a successful login.
+    if (!authenticated) {
</file context>

@sweetmantech

sweetmantech commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Preview verification

Preview: https://marketing-5bpitfi0z-recoup.vercel.app — confirmed built from this PR's head commit 022e4fd (GitHub deployment 5663884369, state success).
Tested 2026-07-29, Chrome 1280x900, cookie-less isolated browser context, real signup on sweetman+july2920261525@recoupable.com.

Results

# Check Expected Actual Status
1 Pro CTA element type <button>, no href (no cross-domain link) {"tag":"BUTTON","text":"Start 30-day trial","href":null} PASS
2 intent param gone from the page zero occurrences zero intent= anywhere in the served /pricing DOM PASS
3 Free CTAs keep UTM links 2 links, UTM-only Plus card + bottom CTA both chat.recoupable.dev/?utm_source=marketing&utm_medium=pricing&utm_campaign=free PASS
4 Pro CTA visual parity identical to the old anchor CTA same white pill, same "Start 30-day trial" copy, "MOST POPULAR" card unchanged (shot 1) PASS
5 Signed-out click behavior Privy modal opens in place, no navigation to chat modal rendered over /pricing; page URL stayed marketing-5bpitfi0z-recoup.vercel.app/pricing (shot 2) PASS
6 OTP delivery code emailed to the entered address "Enter confirmation code" step reached, code delivered PASS
7 Auth completes then checkout auto-resumes no second click needed after login entering the code advanced straight to Stripe with no further interaction, so the pending-flag resume works PASS
8 Redirect target Stripe checkout, same tab (popup-blocker safe) same tab navigated to checkout.stripe.com/c/pay/cs_live_a1T6kj...; no popup, no new window PASS
9 Checkout contents Recoupable Pro, $99/mo, 30-day trial "Try Recoupable Pro / 30 days free / Then $99.00 per month starting August 28, 2026" (30 days out), merchant "Recoupable, LLC" (shot 3) PASS
10 CORS preflight from this origin 200 + permissive ACAO OPTIONS 200; access-control-allow-origin: *; allow-headers includes Authorization PASS
11 Auth gate, no credentials 401 401 {"error":"Exactly one of x-api-key or Authorization must be provided"} PASS
12 Auth gate, invalid bearer 401 401 {"error":"Failed to verify authentication token"} PASS
13 No side effects from the test no customer/subscription created Stripe customer search for the test address returns empty (session opened, never completed) PASS

13/13 pass. Rows 10 to 12 were run with curl against test-recoup-api.vercel.app/api/subscriptions/sessions with Origin: set to the preview host, i.e. exactly the cross-origin call the hook makes. That is the load-bearing architectural claim of this PR: the marketing origin can reach the sessions endpoint directly, and the endpoint refuses unauthenticated callers.

The end-to-end path measured here is pricing page click, Privy modal in place, code entry, Stripe with no intermediate stop on chat.recoupable.dev. The previous design routed the same visitor through the chat homepage first.

Screenshots

1. Pro card with the new button CTA (visually identical to the previous anchor)

Pro card with button CTA

2. Signed-out click opens Privy in place on the marketing site (pricing cards blurred behind the modal, no navigation to chat.recoupable.dev)

Privy modal on marketing pricing page

3. Stripe checkout reached from the pricing page (30 days free, then $99.00/month from August 28, 2026)

Stripe checkout with 30-day trial

Notes for reviewers

  • Payment entry was deliberately not completed. The goal was to prove the session is created with the right price and trial, not to open a live subscription. Verified by Stripe customer search that nothing was created.
  • Preview deployments mint live-mode Stripe sessions. The session id is cs_live_...: this preview resolves to test-recoup-api via siteConfig.apiUrl, and that deployment carries live Stripe credentials. Harmless as long as no card is entered, but worth knowing before anyone tests this flow with a real card on a preview.
  • The successUrl sent with the session is https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial, so post-purchase attribution survives into the app. Not exercised here because that requires completing payment.

Part of recoupable/chat#1902.

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.

1 participant