feat(stripe): premium account purchase by card on the web (P6d)#990
Conversation
The premium signup page now offers a Stripe card flow when the publishable key is configured, instead of only the mobile-IAP QR. The buyer is anonymous (no Hive account yet): a Cloudflare Turnstile token gates create-intent, which goes through the anonymous vapi route (verified server-side) to ePoints (which re-validates the username + computes the amount before charging). - use-stripe-account-purchase: anonymous create-intent (sku 299accounts + captcha token + meta) + status poll (by username + payment_intent). - stripe-account-checkout: mints the intent ONCE on mount (single-use-token + StrictMode safe via a dedicated mount-ref), confirms with the reused StripeCheckoutForm/Elements, polls, terminal 'account requested - check your email for your keys' (keys are emailed async by the onboard service). - premium page: adds the Turnstile widget, gates submit on it, routes to the card flow when Stripe is enabled, keeps the QR/IAP as the fallback, resets the Turnstile on back. Web-only, gated behind isStripeEnabled. No new secret wiring (sitekey fallback + the NEXT_PUBLIC publishable key are already available to the build).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db78ae5acf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| tries += 1; | ||
| try { | ||
| const st = await fetchStripeAccountStatus(meta.username, paymentIntentId); | ||
| if (!pollingRef.current) { |
There was a problem hiding this comment.
Stop swallowing every account status response
When the post-payment poll receives any response, this undefined pollingRef reference throws before st.status is inspected, and the surrounding catch treats that as a transient polling failure. As a result the account checkout ignores both success and failed statuses and, after ~40 seconds, falls through to setStep("done"), so a failed account request can be shown to the buyer as requested instead of surfacing the delivery error.
Useful? React with 👍 / 👎.
… await Leftover from the StrictMode mount-ref refactor: the post-await guard still named pollingRef, which no longer exists. Use mountedRef so the poll bails cleanly when the component unmounts mid-request.
|
Warning Review limit reached
More reviews will be available in 16 minutes and 17 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughAdds a Stripe-based in-page payment flow to the premium signup page, gated by Cloudflare Turnstile CAPTCHA. Introduces ChangesStripe Account Checkout Flow
Sequence Diagram(s)sequenceDiagram
actor User
participant SignupPage
participant Turnstile
participant StripeAccountCheckout
participant Server
User->>SignupPage: fill form + solve CAPTCHA
Turnstile-->>SignupPage: captchaToken
User->>SignupPage: submit
SignupPage->>StripeAccountCheckout: render(meta, captchaToken)
StripeAccountCheckout->>Server: POST /private-api/stripe-account-intent
Server-->>StripeAccountCheckout: client_secret
StripeAccountCheckout->>User: render Stripe Elements (pay step)
User->>StripeAccountCheckout: confirm payment
StripeAccountCheckout->>StripeAccountCheckout: step → delivering
loop poll every ~2s
StripeAccountCheckout->>Server: POST /private-api/stripe-account-status
Server-->>StripeAccountCheckout: StripeAccountStatus
end
StripeAccountCheckout->>User: done / error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR adds Stripe card payment support to the premium account signup page, gated behind
Confidence Score: 5/5Safe to merge — the card payment path is well-guarded behind a feature flag, the StrictMode/single-use token logic is correct, and the redirect-resume flow properly covers the only path where page state is lost on navigation. The intent-creation guard (startedRef), the mounted-ref pattern (a standalone effect so StrictMode's cleanup→re-mount cycle leaves it true on the final mount), and the delivery-polling unmount guards all look correct. Previously flagged issues have been resolved. Remaining notes are style-level. No files require special attention. stripe-account-checkout.tsx and _page.tsx carry the most logic and both read cleanly. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant U as User (Browser)
participant P as PremiumSignUp page
participant CF as Cloudflare Turnstile
participant SAC as StripeAccountCheckout
participant V as vapi (anon)
participant S as Stripe
participant E as ePoints
U->>P: Fill username / email / referral
P->>CF: Render widget
CF-->>P: onVerify(captchaToken)
U->>P: Submit form
P->>P: Client-validate username (on-chain)
P->>SAC: Mount (meta, captchaToken)
SAC->>V: POST /stripe-account-intent (sku, nonce, meta, captcha)
V->>CF: Verify captcha
V->>E: Create PaymentIntent
E-->>V: client_secret
V-->>SAC: client_secret
SAC->>S: confirmPayment (Payment Element)
S-->>SAC: "paymentIntent.status = succeeded"
SAC->>SAC: "step = delivering"
loop Poll every 2s up to 20x
SAC->>V: POST /stripe-account-status
V-->>SAC: status pending/success/failed
end
SAC-->>U: Account requested - check email
note over P,SAC: Redirect-APM path (edge case)
S-->>U: Redirect to returnUrl with payment_intent params
U->>P: Browser returns to page
P->>P: Detect payment_intent in URL
P->>SAC: Mount with resumePaymentIntent
SAC->>SAC: "step = delivering (skip minting)"
SAC->>V: POST /stripe-account-status (poll)
%%{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 (Browser)
participant P as PremiumSignUp page
participant CF as Cloudflare Turnstile
participant SAC as StripeAccountCheckout
participant V as vapi (anon)
participant S as Stripe
participant E as ePoints
U->>P: Fill username / email / referral
P->>CF: Render widget
CF-->>P: onVerify(captchaToken)
U->>P: Submit form
P->>P: Client-validate username (on-chain)
P->>SAC: Mount (meta, captchaToken)
SAC->>V: POST /stripe-account-intent (sku, nonce, meta, captcha)
V->>CF: Verify captcha
V->>E: Create PaymentIntent
E-->>V: client_secret
V-->>SAC: client_secret
SAC->>S: confirmPayment (Payment Element)
S-->>SAC: "paymentIntent.status = succeeded"
SAC->>SAC: "step = delivering"
loop Poll every 2s up to 20x
SAC->>V: POST /stripe-account-status
V-->>SAC: status pending/success/failed
end
SAC-->>U: Account requested - check email
note over P,SAC: Redirect-APM path (edge case)
S-->>U: Redirect to returnUrl with payment_intent params
U->>P: Browser returns to page
P->>P: Detect payment_intent in URL
P->>SAC: Mount with resumePaymentIntent
SAC->>SAC: "step = delivering (skip minting)"
SAC->>V: POST /stripe-account-status (poll)
Reviews (3): Last reviewed commit: "ci(stripe): wire TURNSTILE_SECRET into t..." | Re-trigger Greptile |
…tail Addresses the PR review: - (greptile P1) A redirect-based payment method returns to a fresh page load with empty form state, so the checkout never remounted and a charged buyer saw a blank form. Only reachable if a redirect-only APM is enabled (card + wallets + 3DS all confirm in-page via redirect:if_required), but a charge-then-blank-form is the worst outcome for an anonymous buyer, so resume it defensively. Mirror the Points resume convention: carry the (public) username on returnUrl (?u=), and on the premium page mount read payment_intent + redirect_status + u and resume straight into the delivery poll for the already-created order. No re-mint and no re-spend of the single-use captcha token (the mint effect latches startedRef and bails when resuming); failed/canceled returns surface an error toast. - (greptile P2) poll()'s tries>=20 / reschedule tail ran without re-checking mountedRef after the catch, a state-update-after-unmount. Added the guard. - (codex P1) the line-115 pollingRef bug was already fixed in 453c207 (stale-commit review).
|
Thanks for the review — addressed in the latest commits: Codex P1 (line 115, Greptile P1 (redirect-based payment loses post-payment state on return) — real, fixed defensively. Note the currently-enabled methods (card + Google/Apple Pay + 3DS) confirm in-page via Greptile P2 ( |
There was a problem hiding this comment.
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/web/src/features/shared/purchase-stripe/stripe-account-checkout.tsx`:
- Around line 57-64: The createIntentError function parameter is typed as any
which bypasses TypeScript strict mode. Change the parameter type from any to
unknown, then add proper type narrowing before accessing the response and status
properties. You can use optional chaining with type guards or a conditional
check to safely narrow the unknown type and verify that e is an object with a
response property before accessing e?.response?.status. This ensures type safety
compliance while maintaining the same error handling logic.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 02344184-3eef-4732-a6a7-f4651af6a636
📒 Files selected for processing (5)
apps/web/src/app/signup/premium/_page.tsxapps/web/src/features/i18n/locales/en-US.jsonapps/web/src/features/shared/purchase-stripe/index.tsapps/web/src/features/shared/purchase-stripe/stripe-account-checkout.tsxapps/web/src/features/shared/purchase-stripe/use-stripe-account-purchase.ts
| function createIntentError(e: any): string { | ||
| const status = e?.response?.status; | ||
| // 406 = captcha rejected; 409 = name taken/reserved; 503 = could not verify availability. | ||
| if (status === 406) return i18next.t("sign-up.captcha-failed"); | ||
| if (status === 409) return i18next.t("sign-up.username-exists"); | ||
| if (status === 503) return i18next.t("sign-up.account-verify-retry"); | ||
| return i18next.t("sign-up.account-pay-failed"); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify explicit any usage in TS/TSX changed areas
rg -n --type ts --type tsx '\b:\s*any\b' -C2Repository: ecency/vision-next
Length of output: 90
🏁 Script executed:
cd apps/web && cat -n src/features/shared/purchase-stripe/stripe-account-checkout.tsx | head -70Repository: ecency/vision-next
Length of output: 3680
🏁 Script executed:
cat tsconfig.json | grep -A5 -B5 '"strict"'Repository: ecency/vision-next
Length of output: 108
🏁 Script executed:
# Also check for any type usage with a different approach
rg -n 'any' --glob '*.tsx' --glob '*.ts' apps/web/src/features/shared/purchase-stripe/stripe-account-checkout.tsxRepository: ecency/vision-next
Length of output: 110
🏁 Script executed:
find . -maxdepth 3 -name "tsconfig.json" -type fRepository: ecency/vision-next
Length of output: 249
🏁 Script executed:
# Check the repo root tsconfig for strict mode
cat tsconfig.json | grep -A10 '"compilerOptions"' | grep '"strict'Repository: ecency/vision-next
Length of output: 108
🏁 Script executed:
# Check if there are other functions with similar error handling to understand pattern
rg 'function.*Error.*e:\s*(any|unknown)' --max-count 10Repository: ecency/vision-next
Length of output: 50376
🏁 Script executed:
cat apps/web/tsconfig.jsonRepository: ecency/vision-next
Length of output: 747
Replace any with properly typed unknown parameter.
The function parameter e: any bypasses TypeScript strict mode. With strict mode enabled in tsconfig.json, narrow unknown before reading properties:
Suggested patch
-function createIntentError(e: any): string {
- const status = e?.response?.status;
+function createIntentError(e: unknown): string {
+ const status =
+ typeof e === "object" && e !== null && "response" in e
+ ? (e as { response?: { status?: number } }).response?.status
+ : undefined;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function createIntentError(e: any): string { | |
| const status = e?.response?.status; | |
| // 406 = captcha rejected; 409 = name taken/reserved; 503 = could not verify availability. | |
| if (status === 406) return i18next.t("sign-up.captcha-failed"); | |
| if (status === 409) return i18next.t("sign-up.username-exists"); | |
| if (status === 503) return i18next.t("sign-up.account-verify-retry"); | |
| return i18next.t("sign-up.account-pay-failed"); | |
| } | |
| function createIntentError(e: unknown): string { | |
| const status = | |
| typeof e === "object" && e !== null && "response" in e | |
| ? (e as { response?: { status?: number } }).response?.status | |
| : undefined; | |
| // 406 = captcha rejected; 409 = name taken/reserved; 503 = could not verify availability. | |
| if (status === 406) return i18next.t("sign-up.captcha-failed"); | |
| if (status === 409) return i18next.t("sign-up.username-exists"); | |
| if (status === 503) return i18next.t("sign-up.account-verify-retry"); | |
| return i18next.t("sign-up.account-pay-failed"); | |
| } |
🤖 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 `@apps/web/src/features/shared/purchase-stripe/stripe-account-checkout.tsx`
around lines 57 - 64, The createIntentError function parameter is typed as any
which bypasses TypeScript strict mode. Change the parameter type from any to
unknown, then add proper type narrowing before accessing the response and status
properties. You can use optional chaining with type guards or a conditional
check to safely narrow the unknown type and verify that e is an object with a
response property before accessing e?.response?.status. This ensures type safety
compliance while maintaining the same error handling logic.
Source: Coding guidelines
vapi (#33) verifies the Cloudflare Turnstile token server-side via verifyTurnstile, which reads TURNSTILE_SECRET. vapi runs in the 'vision' swarm stack defined in this repo, so its runtime env is set by this repo's deploy pipeline (same path as STRIPE_INTERNAL_SECRET): - docker-compose.yml + docker-compose.production.yml: pass TURNSTILE_SECRET through to the vapi service environment. - master.yml (deploy-EU/US/SG) + staging.yml: add it to env:, the envs: passthrough list, and the export line. Ships dark: vapi's captchaMode defaults to off, so an unset/empty secret is inert until the operator sets the repo secret and flips the mode at cutover. Not a NEXT_PUBLIC build-arg, so it never enters the web bundle; server-side in vapi only.
|
Bundled the
Ships dark — vapi's One manual step (founder): add the |
P6d (web) of the Stripe-on-web work: buy a premium Hive account with a card on the signup page, instead of only the mobile-IAP QR. Depends on the merged vapi #33 (anonymous routes + Turnstile) and ePoints #22 (the accounts rail).
Flow
The premium page already collects + validates username/email/referral. When
isStripeEnabled()it now:StripeAccountCheckout, which mints a PaymentIntent via the anonymous vapi route/private-api/stripe-account-intent(novalidateCode; sendssku=299accounts, a per-checkout nonce,{username,email,referral}, and the single-use captcha token);StripeCheckoutForm/<Elements>;/private-api/stripe-account-status(by username + payment_intent) → terminal "account requested — check your email for your keys."Keys + the on-chain account are created async by the onboard service, so "done" means requested, not delivered. When Stripe is disabled the QR/IAP fallback is unchanged.
Notes
isStripeEnabled. No new secret wiring — the Turnstile sitekey has a build fallback and the Stripe publishable key is alreadyNEXT_PUBLIC.Review
Adversarial review = GO-with-fixes; fixed: a StrictMode-remount hang (now a dedicated mount-ref), and 2 display nits (em-dash → dash, price from the shared
STRIPE_ACCOUNT_USDconstant). Full typecheck/lint/build left to CI.Summary by CodeRabbit
New Features
Documentation