Repository navigation
fix(billing): report checkout top-ups fully - #3475
Conversation
Credit top-ups paid through Stripe Checkout set metadata only on the Checkout Session, so the PaymentIntent and its charge showed up bare in the Stripe dashboard — no organization id, credit amount, gross or fee. Set the same metadata on the PaymentIntent via payment_intent_data and mark it `source: "stripe_checkout"`, which the payment_intent.succeeded handler now skips so the top-up is still credited exactly once (and the payment_intent.payment_failed handler skips as well, since retries on the Stripe-hosted page share one PaymentIntent). The Discord notification was silently dropped whenever the session carried no userEmail, and carried no top-up detail either. It now always fires, falling back to the organization's billing email, and reports the credits, bonus, gross, fee, source and organization. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ku5DD6b3KNZQk8AC2Y7vQi
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
WalkthroughStripe credit top-ups now share metadata across Checkout and PaymentIntents, use structured Discord notifications, prevent duplicate webhook processing, and include integration tests for successful, duplicate, fallback, and declined-payment flows. ChangesCredit top-up checkout
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant Stripe
participant APIWebhook
participant Database
participant Discord
Stripe->>APIWebhook: checkout.session.completed
APIWebhook->>Database: credit organization and create transaction
APIWebhook->>Discord: notifyCreditsPurchased
Stripe->>APIWebhook: payment_intent.succeeded
APIWebhook-->>Database: skip duplicate Checkout processing
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 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 |
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/api/src/stripe.ts`:
- Around line 2052-2062: Make credit-purchase notifications durable by replacing
the direct notifyCreditsPurchased call in apps/api/src/stripe.ts lines 2052-2062
with a deduplicated notification-job enqueue tied to the successfully persisted
top-up, so delivery and retries occur outside the webhook. Apply the same
durable enqueue path to the saved-card and auto-top-up notification calls in
apps/api/src/stripe.ts lines 2922-2932; both sites require changes before
webhook completion.
🪄 Autofix
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: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 4a203755-0394-4b66-8f56-bb6b085b517b
📒 Files selected for processing (5)
apps/api/src/credit-topup-checkout.spec.tsapps/api/src/routes/payments.tsapps/api/src/stripe.tsapps/api/src/utils/discord.tsapps/worker/src/worker.ts
| await notifyCreditsPurchased({ | ||
| email: userEmail ?? organization.billingEmail, | ||
| name: resolvedUser?.name, | ||
| creditAmount, | ||
| bonusAmount, | ||
| grossAmount: totalAmountInDollars, | ||
| currency: (session.currency ?? "USD").toUpperCase(), | ||
| organizationId, | ||
| organizationName: organization.name, | ||
| source: "stripe_checkout", | ||
| }); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Make credit-purchase notifications durable.
A Discord failure occurs after the top-up is persisted. A Stripe retry then exits at the completed-transaction guard and never re-sends the notification. Store a deduplicated notification job with the successful top-up, then deliver it with retry handling outside the webhook.
apps/api/src/stripe.ts#L2052-L2062: enqueue the Checkout notification before webhook completion.apps/api/src/stripe.ts#L2922-L2932: enqueue saved-card and auto-top-up notifications through the same durable path.
📍 Affects 1 file
apps/api/src/stripe.ts#L2052-L2062(this comment)apps/api/src/stripe.ts#L2922-L2932
🤖 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/api/src/stripe.ts` around lines 2052 - 2062, Make credit-purchase
notifications durable by replacing the direct notifyCreditsPurchased call in
apps/api/src/stripe.ts lines 2052-2062 with a deduplicated notification-job
enqueue tied to the successfully persisted top-up, so delivery and retries occur
outside the webhook. Apply the same durable enqueue path to the saved-card and
auto-top-up notification calls in apps/api/src/stripe.ts lines 2922-2932; both
sites require changes before webhook completion.
Problem
Credit top-ups paid through Stripe Checkout on the credits page are credited and recorded as a
credit_topuptransaction correctly, but two things around them were incomplete:create-checkout-sessionset metadata on the Checkout Session only — deliberately, because addingpayment_intent_data.metadataused to makehandlePaymentIntentSucceededfulfil the same payment a second time. The consequence is that the PaymentIntent and its charge show up bare in the Stripe dashboard: no organization id, no credit amount, no gross, no fee. Every other top-up path (saved card, auto top-up) carries that metadata.userEmail—notifyCreditsPurchasedwas gated behindif (userEmail). And when it did fire it only reported email, name and credits, so a top-up was indistinguishable from any other and carried none of the amounts.Approach
Stripe metadata. The Checkout Session and its PaymentIntent now get the same metadata (
organizationId,type,baseAmount,platformFee,internationalFee,totalAmount,userEmail,userId), plus adescriptionon the PaymentIntent matching the saved-card path. The PaymentIntent copy is markedsource: "stripe_checkout", which replaces the old "just don't set any metadata" trick as the double-credit guard:handlePaymentIntentSucceededreturns early on that marker, socheckout.session.completedremains the single fulfilment path.handlePaymentIntentFailedskips the failed-credit_topup-row branch for the same marker. The Stripe-hosted page lets a customer retry a declined card on the same PaymentIntent, so recording a row per decline would spam the billing history, and no transaction exists yet at that point. ThepaymentFailurerow and dunning email still fire, exactly as before.internationalFeeis always0on this path — the checkout page collects the card itself, so the international-card fee is unknown up front and is not charged. It is included anyway so the metadata shape matches the other paths.Discord.
notifyCreditsPurchasedtakes an options object and now reports credits, bonus (when non-zero), gross, fee (gross − credits), source and organization. Both call sites pass the organization's billing email as a fallback so a missinguserEmailno longer swallows the notification. Source is reported asStripe Checkout,Saved cardorAuto top-up, distinguishing the three ways credits get bought.totalAmountandtype: "credit_topup"were also added to the saved-card and auto top-up PaymentIntent metadata so all three paths carry an identical shape.Verification
New
apps/api/src/credit-topup-checkout.spec.tsdrives real webhook events through the/webhookroute and covers: the checkout top-up credits the org and posts a Discord embed with the right credits/gross/fee/source/organization; the notification still fires with the billing email when the session has nouserEmail; a followingpayment_intent.succeededcarrying the checkout metadata does not credit twice or re-notify; a declined checkout attempt records apaymentFailurebut no failed transaction row.pnpm format,pnpm build, and the fullapps/api(849) andapps/worker(106) unit suites all pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ku5DD6b3KNZQk8AC2Y7vQi
Generated by Claude Code
Summary by CodeRabbit
New Features
Bug Fixes