Skip to content

fix: checkout flow for 0 dollar subscription#1465

Merged
nams1570 merged 2 commits into
devfrom
fix-free-sub-checkout
May 21, 2026
Merged

fix: checkout flow for 0 dollar subscription#1465
nams1570 merged 2 commits into
devfrom
fix-free-sub-checkout

Conversation

@nams1570
Copy link
Copy Markdown
Collaborator

@nams1570 nams1570 commented May 21, 2026

Context

There was a small bug via dashboard checkout flow where it would fail on trying to create a checkout flow for a free product subscription because no client secret is generated for a 0 dollar subscription.

Summary of Changes

The flow should be fine now. There's special carve out logic for it. That being said, users attempting to mimic a free plan grant are encouraged to follow the ensureFreePlan pattern.

Summary by CodeRabbit

  • New Features
    • Free subscription selections bypass payment processing and set a free-activated return path.
  • Bug Fixes
    • Return flow reliably recognizes and activates free subscriptions without requiring payment.
    • Checkout now flags free selections so the flow redirects appropriately instead of attempting payment.
    • Checkout dialog shows a clear error when the selected product is already owned.

Review Change Stack

0 dollar subs on stripe don't create any client secrets
Copilot AI review requested due to automatic review settings May 21, 2026 21:17
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

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

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Ready Ready Preview, Comment May 21, 2026 9:42pm
stack-auth-mcp Ready Ready Preview, Comment May 21, 2026 9:42pm
stack-auth-skills Ready Ready Preview, Comment May 21, 2026 9:42pm
stack-backend Ready Ready Preview, Comment May 21, 2026 9:42pm
stack-dashboard Ready Ready Preview, Comment May 21, 2026 9:42pm
stack-demo Ready Ready Preview, Comment May 21, 2026 9:42pm
stack-docs Ready Ready Preview, Comment May 21, 2026 9:42pm
stack-preview-backend Ready Ready Preview, Comment May 21, 2026 9:42pm
stack-preview-dashboard Ready Ready Preview, Comment May 21, 2026 9:42pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b04a45b0-67d2-424f-a743-109ad558c545

📥 Commits

Reviewing files that changed from the base of the PR and between 32172d3 and fe0c734.

📒 Files selected for processing (2)
  • apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
  • apps/dashboard/src/components/payments/create-checkout-dialog.tsx

📝 Walkthrough

Walkthrough

Adds end-to-end free ($0) subscription support: detect free prices in the purchase UI, skip Stripe confirmation in checkout, propagate free=1 via the return URL, and finalize subscription activation without PaymentIntent retrieval.

Changes

Free Subscription Checkout Bypass

Layer / File(s) Summary
Free price detection and setup acceptance
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
PageClient computes isFreeSelected for $0 prices, updates setupSubscription to accept a missing client_secret only for free selections, and passes isFree={isFreeSelected} to CheckoutForm.
CheckoutForm free payment bypass
apps/dashboard/src/components/payments/checkout.tsx
CheckoutForm adds isFree prop and an early branch in handleSubmit that sets free=1 on the return URL, redirects via window.location.assign, and skips stripe.confirmPayment when free.
Return page free flag propagation
apps/dashboard/src/app/(main)/purchase/return/page.tsx
Server Page adds optional free query param to searchParams type and forwards params.free to ReturnClient.
ReturnClient free subscription completion
apps/dashboard/src/app/(main)/purchase/return/page-client.tsx
ReturnClient accepts an optional free prop and adds a free === "1" branch in updateViewState that calls checkAndReturnUser() asynchronously, shows "Free subscription activated", and bypasses Stripe/PaymentIntent initialization.
Create-checkout dialog error handling
apps/dashboard/src/components/payments/create-checkout-dialog.tsx
Add KnownErrors.ProductAlreadyGranted handling to show a destructive toast when the customer already owns the selected product.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • hexclave/stack-auth#1455: Backend /purchase-session behavior that can omit client_secret for free prices; frontend here checks and handles that condition.

Suggested reviewers

  • BilalG1

Poem

🐰 A hop, a skip — the checkout's light,

Free as air, no payment fight.
The URL waves the tiny flag,
Subscription blooms without a snag,
Hooray for zero—soft and bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing the checkout flow for $0 subscriptions by handling missing client secrets.
Description check ✅ Passed The description provides context about the bug and summarizes the fix, though it lacks implementation details and testing information.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 fix-free-sub-checkout

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 and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes the dashboard purchase/checkout flow for $0 subscription prices where the backend intentionally does not return a Stripe client_secret, causing the existing “confirm payment” flow to fail/hang.

Changes:

  • Adds an isFree path in CheckoutForm to skip Stripe confirmation and route through /purchase/return?free=1.
  • Updates the purchase session setup to allow missing client_secret when the selected price is $0.
  • Updates the return page to render a terminal success state when free=1 is present.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
apps/dashboard/src/components/payments/checkout.tsx Adds isFree prop and redirects to return flow for free subscriptions instead of confirming via Stripe.
apps/dashboard/src/app/(main)/purchase/return/page.tsx Plumbs free query param through to the client component.
apps/dashboard/src/app/(main)/purchase/return/page-client.tsx Adds a free=1 terminal success branch on the return page.
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx Detects free selection and allows missing client_secret; passes isFree to CheckoutForm.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/dashboard/src/components/payments/checkout.tsx
Comment on lines 26 to 29
onTestModeBypass?: () => Promise<void>,
chargesEnabled: boolean,
isFree: boolean,
};
Comment on lines +57 to +61
if (free === "1") {
// $0 subs activate synchronously on the Stripe side and produce no
// PaymentIntent / client_secret, so there's nothing to retrieve —
// mirror the bypass branch and show terminal success.
runAsynchronously(checkAndReturnUser());
Comment on lines 161 to 165
const result = await response.json();
if (!result.client_secret) {
if (!result.client_secret && !isFreeSelected) {
throw new Error("Failed to setup subscription");
}
return result.client_secret;
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 21, 2026

Greptile Summary

This PR fixes the dashboard checkout flow for $0 (free) subscriptions, where Stripe does not generate a client_secret because the subscription activates synchronously. A new isFreeSelected flag is derived from the selected price, and dedicated carve-out branches are added in CheckoutForm and the return page to handle the no-payment-intent path.

  • page-client.tsx: adds isFreeSelected memo, relaxes the missing-client_secret guard, and passes isFree to CheckoutForm.
  • checkout.tsx: adds an isFree branch in handleSubmit that sets free=1 on the return URL and redirects without calling stripe.confirmPayment — but this branch currently comes after elements.submit(), which may block free checkout if Stripe validates payment info for mode:\"subscription\" with amount:0.
  • return/page-client.tsx / return/page.tsx: thread free through as a search param and mirror the bypass-branch logic to show terminal success without hitting Stripe's PaymentIntent retrieval API.

Confidence Score: 3/5

The free-plan redirect path in checkout.tsx is placed after elements.submit(), which means Stripe form validation can silently block free checkout for users who don't fill in payment info — the exact scenario this PR is trying to fix.

The core fix correctly guards the missing-client_secret error and threads the free=1 signal through to the return page, but the ordering of elements.submit() before the isFree check in handleSubmit means that for a $0 subscription in Stripe Elements subscription mode, the PaymentElement is still validated before the free-plan redirect is reached. Whether this silently breaks or accidentally works depends on how Stripe handles form submission with amount:0, which is not guaranteed to be a no-op for subscriptions. The return-page success state also fires unconditionally for anyone who navigates to the URL with free=1.

apps/dashboard/src/components/payments/checkout.tsx needs the isFree check moved above the elements.submit() call.

Important Files Changed

Filename Overview
apps/dashboard/src/components/payments/checkout.tsx Adds isFree prop and a redirect branch for $0 subscriptions, but the branch is placed after elements.submit(), which means Stripe Elements form validation still runs for free plans and can block the checkout entirely if the user hasn't provided payment info.
apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx Adds isFreeSelected memo to detect $0 prices and relaxes the missing-client_secret error guard; logic is correct and passes isFree to CheckoutForm.
apps/dashboard/src/app/(main)/purchase/return/page-client.tsx Adds free prop and mirrors the bypass branch for $0 subscriptions; the terminal success state is shown without verifying a purchase was actually completed, making it reachable by direct URL navigation.
apps/dashboard/src/app/(main)/purchase/return/page.tsx Adds free search-param forwarding to ReturnClient; straightforward and correct.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant CF as CheckoutForm
    participant BE as Backend API
    participant S as Stripe Elements
    participant RP as /purchase/return

    Note over U,RP: Free ($0) subscription flow (this PR)
    U->>CF: Click Submit
    CF->>S: elements.submit() ⚠️ runs before isFree check
    S-->>CF: OK (or validation error if no card)
    CF->>BE: "POST /purchase-session (price=$0)"
    BE-->>CF: "{ } (no client_secret)"
    CF->>RP: "redirect → /purchase/return?free=1&purchase_full_code=..."
    RP->>BE: validate-code (async, for redirect)
    RP-->>U: Free subscription activated

    Note over U,RP: Paid subscription flow (unchanged)
    U->>CF: Click Submit
    CF->>S: elements.submit()
    S-->>CF: OK
    CF->>BE: POST /purchase-session
    BE-->>CF: "{ client_secret }"
    CF->>S: stripe.confirmPayment(clientSecret)
    S-->>RP: "redirect → /purchase/return?payment_intent=..."
    RP->>S: retrievePaymentIntent
    S-->>RP: succeeded
    RP-->>U: Payment succeeded
Loading

Comments Outside Diff (1)

  1. apps/dashboard/src/components/payments/checkout.tsx, line 45-62 (link)

    P1 elements.submit() runs before the isFree short-circuit — for $0 subscriptions the PaymentElement is still rendered on screen (unconditionally, line 129) and elements.submit() is the first thing called when the user clicks Submit. Stripe's Elements in mode:"subscription" with amount:0 still performs payment-method form validation; if the user hasn't filled in a card (a reasonable expectation for a free plan), elements.submit() will return a validation error and the isFree redirect branch is never reached, completely blocking free checkout. Moving the isFree early-return above the elements.submit() call fixes this.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/dashboard/src/components/payments/checkout.tsx
    Line: 45-62
    
    Comment:
    **`elements.submit()` runs before the `isFree` short-circuit** — for `$0` subscriptions the `PaymentElement` is still rendered on screen (unconditionally, line 129) and `elements.submit()` is the first thing called when the user clicks Submit. Stripe's Elements in `mode:"subscription"` with `amount:0` still performs payment-method form validation; if the user hasn't filled in a card (a reasonable expectation for a free plan), `elements.submit()` will return a validation error and the `isFree` redirect branch is never reached, completely blocking free checkout. Moving the `isFree` early-return above the `elements.submit()` call fixes this.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/dashboard/src/components/payments/checkout.tsx:45-62
**`elements.submit()` runs before the `isFree` short-circuit** — for `$0` subscriptions the `PaymentElement` is still rendered on screen (unconditionally, line 129) and `elements.submit()` is the first thing called when the user clicks Submit. Stripe's Elements in `mode:"subscription"` with `amount:0` still performs payment-method form validation; if the user hasn't filled in a card (a reasonable expectation for a free plan), `elements.submit()` will return a validation error and the `isFree` redirect branch is never reached, completely blocking free checkout. Moving the `isFree` early-return above the `elements.submit()` call fixes this.

```suggestion
  const handleSubmit = async () => {
    if (!stripe || !elements) {
      return;
    }

    const stripeReturnUrl = new URL(`/purchase/return`, window.location.origin);
    stripeReturnUrl.searchParams.set("stripe_account_id", stripeAccountId);
    stripeReturnUrl.searchParams.set("purchase_full_code", fullCode);
    if (returnUrl) {
      stripeReturnUrl.searchParams.set("return_url", returnUrl);
    }

    if (isFree) {
      await setupSubscription();
      stripeReturnUrl.searchParams.set("free", "1");
      window.location.assign(stripeReturnUrl.toString());
      return;
    }

    const { error: submitError } = await elements.submit();
    if (submitError) {
      return setMessage(submitError.message ?? "An unexpected error occurred.");
    }

    const clientSecret = await setupSubscription();

    if (false as never) {
```

### Issue 2 of 2
apps/dashboard/src/app/(main)/purchase/return/page-client.tsx:57-65
**`free=1` success state is reachable without a real purchase** — anyone can navigate directly to `/purchase/return?free=1` and immediately see "Free subscription activated. No payment required." The `checkAndReturnUser()` call happens asynchronously and only redirects when both `returnUrl` and `purchaseFullCode` are present in the URL *and* the backend validates them. Without those params, the function is a no-op, and the terminal success UI is still shown unconditionally. A user who never completed a checkout could land here and believe their free subscription is active. The `bypass` branch has the same design, but bypass is only reachable from test-mode server-side logic — `free=1` can be appended to the URL by anyone.

Reviews (1): Last reviewed commit: "fix: checkout flow for 0 dollar subscrip..." | Re-trigger Greptile

Comment thread apps/dashboard/src/app/(main)/purchase/return/page-client.tsx
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@apps/dashboard/src/components/payments/checkout.tsx`:
- Around line 62-72: handle the $0 (isFree) checkout before any Stripe Elements
readiness or submission: inside handleSubmit, move the isFree check (the block
that sets stripeReturnUrl.searchParams.set("free","1") and
window.location.assign(...)) to occur before any gating on stripe or elements
and before calling elements.submit(); ensure that when isFree is true you return
immediately without referencing stripe, elements, or calling elements.submit();
update any related control flow so handleSubmit exits early for isFree and does
not await elements.submit() or require stripe readiness.
🪄 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

Run ID: d13a0098-a183-473a-a636-0c3f4ab8d474

📥 Commits

Reviewing files that changed from the base of the PR and between 0df594a and 32172d3.

📒 Files selected for processing (4)
  • apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
  • apps/dashboard/src/app/(main)/purchase/return/page-client.tsx
  • apps/dashboard/src/app/(main)/purchase/return/page.tsx
  • apps/dashboard/src/components/payments/checkout.tsx

Comment thread apps/dashboard/src/components/payments/checkout.tsx
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.

2 participants