feat: card on file credit bonus change - #449
Conversation
|
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @gianpaj's task in 4m 10s —— View job PR Review: Card-on-file credit bonusReviewed against
🟠 Should fix: partial-failure permanently burns the card and blocks recovery
If step 3 or 4 fails with a transient error after the claim row is committed, the state is unrecoverable:
This gap is specific to this PR: the topup/subscription paths dedupe purely on Suggested fix (most robust): make the claim insert + credit insert + balance increment atomic in a single 🟡 Minor: claim recorded even when the per-user check skips the grantBecause the claim insert precedes the per-user check (step 1 before step 2), a user who already claimed with card A but completes a second setup with card B gets card B's fingerprint written to 🟡 Minor: dead
|
There was a problem hiding this comment.
Code Review
This pull request introduces a card-on-file credit bonus feature, lowering the initial signup grant to 1,000 credits and offering a one-time 9,000 credit bonus when a user adds a payment method. Key feedback includes resolving a hardcoded English locale in the Stripe setup session by dynamically passing the language parameter from the client, correcting an ineffective try/catch block in Supabase queries by replacing .single() with .maybeSingle(), and adjusting Tailwind CSS syntax for backward compatibility.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
The latest updates on your projects. Learn more about Argos notifications ↗︎
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ad080f035
ℹ️ 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".
| const { error: claimError } = await supabase | ||
| .from('card_bonus_claims') | ||
| .insert({ | ||
| fingerprint, | ||
| user_id: userId, | ||
| setup_intent_id: setupIntentId, | ||
| }); |
There was a problem hiding this comment.
Check the user bonus before reserving a card fingerprint
When a user opens two setup sessions before either webhook completes and uses different cards, the second webhook inserts a card_bonus_claims row for the second fingerprint, then the later per-user card_bonus check returns without granting credits. That burns the second physical card globally even though it never unlocked a bonus, blocking another account from legitimately using it; check the existing user bonus before inserting the fingerprint claim, or make the claim/credit insert atomic so skipped grants roll back the claim.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Claude Code - Opus 4.8
Leaving this open for a human decision. It overlaps with the non-atomic claim/grant thread below, and the two suggest different orderings (check-user-first here vs. claim-first + atomicity there). A correct fix likely means making the fingerprint reservation, ledger insert, and balance increment atomic (a single SECURITY DEFINER Postgres function) rather than reordering — that is a money-path migration I did not want to land autonomously in this pass.
There was a problem hiding this comment.
Important
The card-bonus grant records the global per-card claim before granting credits, and the webhook always returns 200 (no Stripe retry). A transient failure between those two steps can leave a user who added a card without their 9,000 credits, unrecoverably. Details inline.
Reviewed changes — a freemium-model change: new signups get 1,000 credits (down from 10,000) and unlock the remaining 9,000 by adding a card on file (Stripe setup mode, no charge), granted once per user and once per physical card globally.
- Lower signup grant + drop redundant trigger —
handle_new_user()grants 1,000 instead of 10,000, andadd_credits_trigger/add_credits_on_event()are dropped so the ledger stops double-counting the freemium row. - New
card_bonustype + guards — adds thecard_bonusenum value (own migration), a per-user partial unique index, and acard_bonus_claimstable (fingerprint PK, RLS on, service-role only) for global per-card dedupe. - Grant logic —
insertCardBonusCreditTransactionreserves the card claim, pre-checks the per-user row, inserts the ledger entry, then increments the balance; any23505is treated as already-claimed. - Webhook —
checkout.session.completed(modesetup) and a backupsetup_intent.succeededboth route tograntCardBonusForSetupIntent, which re-retrieves the SetupIntent withpayment_methodexpanded, resolves the user, sets the default payment method, and grants. - Eligibility + UI —
computeCardBonusEligibility(not claimed, not paid, freemiumamount === 1000) drives the credits-page CTA and a new non-dismissiblecardBonusBanner(priority 1000); credit history renderscard_bonusas a positive/green entry with localized labels. - Copy, terms, i18n — all six locales updated (marketing 10,000→1,000, FAQ, banner copy);
terms.mdx/terms.es.mdxadd an "Eligibility and Age Requirement" section framing the card as an adult/abuse control. - Tests — unit coverage for
insertCardBonusCreditTransactiondedupe branches,computeCardBonusEligibility, the webhook setup-intent paths, andcreateCardBonusSetupSession.
ℹ️ Nitpicks
messages/*.jsonheronoCreditCardnow ends in"No credit card required*⃰"— the trailing asterisk has no footnote/disclaimer rendered on the landing page (app/[lang]/page.tsx:182just prints the string), and the*⃰sequence includes a stray combining-asterisk glyph. Either add the referenced disclaimer or drop the marks (applies to all six locales).
@v0 or keep the SHA fresh with Dependabot | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏
| fingerprint, | ||
| user_id: userId, | ||
| setup_intent_id: setupIntentId, | ||
| }); |
There was a problem hiding this comment.
credit_transactions insert or the increment_user_credits RPC below throws (e.g. a transient DB/RPC error), the claim row is already committed — and because the webhook POST handler swallows the error and always returns 200 (api/stripe/webhook/route.ts:39-54), Stripe never retries. On any later attempt (including the setup_intent.succeeded backup path), this card_bonus_claims insert now returns 23505 and short-circuits, so the user who added a card never receives their 9,000 credits, with no recovery and only a Sentry log.
The claim-first ordering is correct for abuse prevention (you must reserve the card globally before granting), so the fix isn't to reorder — it's to make reservation + grant recoverable.
Technical details
# Non-atomic per-card claim + no webhook retry can strand the bonus
## Affected sites
- `apps/web/lib/supabase/queries.ts` — `insertCardBonusCreditTransaction`: `card_bonus_claims` insert precedes the `credit_transactions` insert and `updateUserCredits` RPC; a throw after the claim insert leaves an orphaned claim.
- `apps/web/app/api/stripe/webhook/route.ts:39-54` — the top-level catch logs to Sentry and still returns `{ received: true }` (200), so Stripe does not retry the event.
- Backup path `grantCardBonusForSetupIntent` (`setup_intent.succeeded`) calls the same function, so it also hits the orphaned-claim `23505` and skips granting.
## Required outcome
- A transient failure during the grant must not permanently prevent the eligible user from receiving the 9,000-credit bonus.
## Suggested approach
- Perform the claim reservation, ledger insert, and balance increment in a single Postgres function (SECURITY DEFINER) so they commit or roll back together; or
- On the grant-failure path (non-23505 error from the `credit_transactions` insert or the RPC), delete the just-inserted `card_bonus_claims` row before rethrowing so a retry / the backup event can succeed; or
- Have the webhook return a non-2xx for these failures so Stripe retries — but that alone still needs the claim row cleaned up to be effective.
## Open questions for the human
- Is silent, unrecoverable loss of the bonus in this narrow window acceptable, or should this ship with atomicity before launch? The window is small (same DB, microseconds apart) but the failure is money-adjacent and self-heals nowhere.There was a problem hiding this comment.
Claude Code - Opus 4.8
Agreed this is the highest-value concern here, and leaving it open for a human call on your explicit open question (ship atomicity before launch vs. accept the narrow window). It needs a design decision + a Postgres function migration (reserve + ledger insert + increment committing/rolling back together), which is beyond a one-comment mechanical fix. Note: the .maybeSingle() change in 193c788 deliberately logs-and-continues on a read error instead of throwing, specifically to avoid widening this orphaned-claim window in the meantime.
There was a problem hiding this comment.
Heads-up for the still-open "ship atomicity before launch?" decision: commit 248107a (auto-grant the bonus on a new-regime user's first direct top-up/subscription) adds new callers of this same insertCardBonusCreditTransaction, so the non-atomic claim/grant window now also fires on the payment paths — not just the deliberate "add a card" setup flow. That widens the blast radius of this concern (more grants flowing through the same reserve-then-insert-then-increment sequence), but it doesn't change the nature of the fix: a single SECURITY DEFINER Postgres function committing the fingerprint reservation, ledger insert, and balance increment together would cover all callers at once. Flagging so the launch decision accounts for the expanded surface; deliberately not implementing the migration autonomously here.
Generated by Claude Code
Retrieve lang via useParams in CardBonusCta and forward it to createCardBonusSetupSession so users return to their locale after Stripe setup instead of always /en. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion Hiding the CTA is not a security boundary: any authenticated user could call createCardBonusSetupSession directly and mint a card_bonus setup session, letting paid or legacy 10,000-credit users claim the 9,000 bonus. Swap the weaker hasClaimedCardBonus check for isEligibleForCardBonus, which also enforces the non-paid and new-regime constraints server-side. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Supabase client returns { data, error } instead of throwing, so the try/catch never caught anything and .single() logged a PGRST116 error on every first-time claim. Switch to .maybeSingle(), which returns data: null with no error when no row matches, and log-and-continue on a genuine read error so a transient failure doesn't strand the grant (the partial unique index on the insert is the authoritative dedup guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new critical issues — the three fix commits are sound. One minor dead-code note inline.
Reviewed changes — the delta since the prior pullfrog review (3ad080f): three follow-up fixes to the card-on-file bonus setup path.
- Dynamic locale in setup session —
createCardBonusSetupSessionnow accepts{ lang }from the client (useParams().lang) instead of hardcoding'en', so the Stripe success/cancel redirects land on the user's active locale. - Server-side eligibility enforcement — the action's guard was upgraded from
hasClaimedCardBonustoisEligibleForCardBonus, re-checking not-claimed AND not-paid AND new-regime (1000 freemium) before minting a setup session. This closes the gap where any authenticated user could invoke the action directly. - Robust dedup lookup —
insertCardBonusCreditTransactionswapped.single()+ try/catch for.maybeSingle(), logging-and-continuing on a genuine read error rather than throwing, so a transient read failure doesn't strand the grant (the partial unique index remains the authoritative guard). Also resolves the earlier ineffective-try/catch note.
The headline atomicity concern from the prior review (non-atomic claim-before-grant + webhook always returning 200) remains open by design — the author is leaving it for a human call on whether to ship an atomic RPC before launch. The .maybeSingle() change deliberately avoids widening that window in the meantime.
@v0 or keep the SHA fresh with Dependabot | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏
The server action now guards with isEligibleForCardBonus (which already covers the not-claimed check), leaving hasClaimedCardBonus with no callers. Remove it and update the implementation plan to reference isEligibleForCardBonus as the CTA/banner and server-action guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New-regime users who pay directly (a top-up or an initial subscription) now receive the same one-time 9,000-credit card-on-file bonus as users who take the "add a card" CTA, so both paths reach 10,000 credits. Previously the bonus was only granted via the mode:'setup' Checkout flow; worse, once a user paid, computeCardBonusEligibility flipped hasPaid=true and hid the CTA, permanently disqualifying them from the bonus. - Add maybeGrantCardBonusFromPayment() in the Stripe webhook, called after crediting a topup and an initial subscription. It reuses the payment's card fingerprint so the existing global one-card-one-bonus dedupe (card_bonus_claims) and per-user uniqueness guard apply. Best-effort: it never throws, so a bonus hiccup can't fail the purchase crediting or trigger Stripe retries. Non-card methods without a fingerprint are skipped rather than granted unguarded. - Add isEligibleForCardBonusOnPayment()/computeCardBonusOnPaymentEligibility(): requires a new-regime user who hasn't already claimed, but — unlike the CTA — does NOT treat paying as a disqualifier. Runs from the webhook via the admin client (no user session). - Extract shared hasCardBonusTransaction()/isNewRegimeUser() predicates. - Document the behavior in docs/devops.md and add unit + webhook tests. Tests: pnpm type-check (green); card-bonus-credit-transaction.test.ts (18/18). The stripe-webhook integration test requires redis-memory-server, whose binary download is blocked in this sandbox, so it was not run here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CeGPfVo7CoSfaMhUqroiwP
|
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. |
There was a problem hiding this comment.
ℹ️ No new issues. The payment auto-grant reuses the already-guarded grant flow; the one carried-forward risk (claim/grant atomicity) is already tracked on the open thread and acknowledged there.
Reviewed changes — the delta since the prior pullfrog review (1cf7a82): commit 248107a auto-grants the 9,000 card-on-file bonus on a new-regime user's first direct payment.
- Auto-grant on first direct payment — a new
maybeGrantCardBonusFromPaymentfires after a top-up and after an initial subscription grant, so paying customers reach the same 10,000-credit total as users who take the "add a card" CTA. It reuses the payment's card fingerprint throughinsertCardBonusCreditTransaction, so the same global one-card-one-bonus dedupe and per-user uniqueness guards apply. - Best-effort, non-card safe — the grant is wrapped so it never throws (a failure logs to Sentry but can't fail or retrigger the purchase crediting), and a payment method with no card fingerprint (e.g. some wallets) is skipped rather than granted unguarded.
- On-payment eligibility — new
isEligibleForCardBonusOnPayment(admin client, since the webhook has no session) plus a purecomputeCardBonusOnPaymentEligibilityrequire a new-regime user (1,000 freemium row) who hasn't already been granted the bonus; unlike the CTA path, a prior payment does not disqualify because paying is the trigger. - Webhook refactor —
handleCheckoutSessionCompletedwas split intohandleTopupCheckoutSession,handleCardBonusSetupCheckoutSession, andgrantCardBonusForSetupIntent; the mode branching is behavior-preserving.computeCardBonusEligibilitywas refactored to reuse extractedhasCardBonusTransaction/isNewRegimeUserhelpers with identical logic. - Tests + docs — webhook tests cover eligible-grant, ineligible-skip, and no-fingerprint-skip for both top-up and subscription;
computeCardBonusOnPaymentEligibilitygets its own unit suite;docs/devops.mddocuments the payment path.
The reference_id reuse on the payment path is safe: the card_bonus row reuses the same paymentIntentId already consumed by the top-up/subscription transaction, but unique_reference_id_idx is partial to type IN ('purchase','topup'), so no 23505 collision — only the per-user credit_transactions_one_card_bonus_per_user index can fire, matching the inline comment.
The pre-existing non-atomic claim-before-grant concern now also flows through the payment paths, but that is already captured on the open thread and acknowledged, so it is not re-raised here.
@v0 or keep the SHA fresh with Dependabot | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏
Resolve types.d.ts conflict: keep main's new call_session_analysis and call_session_analytics table types alongside this branch's card_bonus_claims type (adjacent additions, no semantic overlap). docs/devops.md auto-merged, preserving both the new call-analysis env vars and this branch's card-on-file payment-path documentation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CeGPfVo7CoSfaMhUqroiwP
|
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. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |

No description provided.