Skip to content

fix(credits): stop minting a Checkout Session on every credit-gated 402 - #836

Merged
sweetmantech merged 1 commit into
mainfrom
chore/credits-no-session-on-402
Aug 18, 2026
Merged

fix(credits): stop minting a Checkout Session on every credit-gated 402#836
sweetmantech merged 1 commit into
mainfrom
chore/credits-no-session-on-402

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Implements the second defect in recoupable/chat#1960. Row 4 of that issue's PR matrix.

Stacked on api#835. Both PRs edit checkCreditsAvailable.ts and buildInsufficientCreditsResponse.ts (the charge and the session-minting lived in the same function), so this branch is based on that one. The issue's matrix originally called the two removals independent and mergeable in any order; that was wrong and is corrected there. Review the last commit only, or diff against chore/credits-remove-auto-recharge. Once #835 merges, this diff reduces to that commit.

The credit gate called createCreditsStripeSession on both exit paths (L57-62 for opted-out, L98-103 as the fallback) purely to populate checkoutUrl in the 402 body. So every credit-gated request that came up short minted a live Stripe Checkout Session, whether or not a human was there to see it.

Measured on prod for the 30 days to 2026-08-17: 2,779 sessions, 46 accounts, 0 with a customer_email, 0 paid. They cluster on cron minutes (244 at 06:01, 206 at 06:02, 117 at 09:03), which is scheduled-task traffic hitting the gate, not people. Removing the off-session charge in #835 does not fix this on its own.

What changes

checkCreditsAvailable loses the session minting, the Stripe customer resolution and the successUrl parameter. It reads remaining_credits and returns. It makes no Stripe calls on any path. 61 lines → 42.

buildInsufficientCreditsResponse returns a static billingUrl in place of checkoutUrl:

{
  "error": "insufficient_credits",
  "remaining_credits": 12,
  "required_credits": 100,
  "billingUrl": "https://chat.recoupable.dev"
}

CREDIT_BILLING_URL is CHAT_APP_URL. Deliberately the app root rather than a deep link, so the app can move where a card is saved without changing this contract.

successUrl is gone from ensureCreditsOrShortCircuit and all eight credit-gated call sites (lib/chat/validateChatRequest.ts, lib/socials/ensureSocialScrapeCredits.ts, and six under lib/research/). CREDIT_SHORTFALL_TOPUP_CREDITS and CREDIT_SHORTFALL_SUCCESS_URL are deleted. The latter pointed at sandbox.recoupable.com, which is not a host we serve, so a share of those 2,779 sessions would have returned users to the wrong place even if anyone had paid one.

POST /api/credits/sessions is untouched. That is where someone asked for a session, so that is where a session is still minted, and it keeps declineReason. open-agents' credits-topup dialog parses that { id, url, declineReason? } response, not the 402, so it keeps working. The buildInsufficientCreditsResponse docstring now says the two shapes are deliberately different; claiming they were the same is what conflated them.

Out of scope, but worth knowing

lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts still mints a createCardOnFileSession on every denied request. It is a payment-method gate, not a credit gate, so it is outside this issue, but it is the same shape of problem and now carries a comment saying so. Its successUrl is repointed at CREDIT_BILLING_URL rather than the dead sandbox host, since the constant it used is being deleted.

Verification

TDD, red before green:

  1. Rewrote checkCreditsAvailable.test.ts and added buildInsufficientCreditsResponse.test.ts for the new contract, including a loop that drives 20 consecutive shortfalls and asserts createCreditsStripeSession was called 0 times, plus assertions that billingUrl is identical across calls and contains neither stripe nor sandbox. Ran them: RED, 6 failed (expected { … } to not have property "checkoutUrl", expected undefined to be 'https://chat.recoupable.dev', and 3× Cannot read properties of undefined (reading 'url') from the mocked session no longer being reached).
  2. Implemented. GREEN.
  3. Updated ensureCreditsOrShortCircuit.test.ts for the two-field signature, asserting the gate is called with exactly { accountId, creditsToDeduct }.

Full runs:

Check Result
vitest run lib/credits lib/stripe lib/billing lib/research lib/socials lib/chat app/api/webhooks app/api/credits app/api/accounts 221 files / 1256 tests passed
eslint lib/research lib/socials lib/chat lib/credits lib/stripe clean
tsc --noEmit 202 errors, identical to baseline, zero in any file this PR touches
grep -rn checkoutUrl lib app outside tests only ensureSongstatsPaymentMethod.ts, the out-of-scope gate above

Not yet done: the issue's live Done-when check (drive a credit-gated endpoint to 402 twenty times on preview and confirm zero new Checkout Sessions on that account in Stripe, and that open-agents' dialog still renders a decline against POST /api/credits/sessions). The 20-iteration unit test covers the code path but not the live Stripe account. Flagging rather than implying it.

Merge order

docs#299chat#1961api#835this PR.


Summary by cubic

Stops creating Stripe Checkout Sessions on credit‑gated 402s. Previously the gate minted a live session (and could charge a saved card); now it only checks balance and returns a static billingUrl, so unattended calls create nothing and no charge happens without consent.

  • checkCreditsAvailable is read‑only. It drops Stripe calls and the successUrl parameter.
  • buildInsufficientCreditsResponse returns billingUrl and removes checkoutUrl and any declineReason.
  • ensureCreditsOrShortCircuit no longer accepts successUrl; all call sites in lib/chat, lib/research, and lib/socials are updated.
  • Replace deleted CREDIT_SHORTFALL_* constants with CREDIT_BILLING_URL (https://app.recoupable.dev).
  • POST /api/credits/sessions is unchanged and remains the only place a Checkout Session is minted.
  • ensureSongstatsPaymentMethod still mints a per‑denial session (payment‑method gate) and now uses CREDIT_BILLING_URL for its success URL.

Migration

  • Read billingUrl in 402 bodies instead of checkoutUrl. Do not expect declineReason on 402.
  • Remove the successUrl argument from all ensureCreditsOrShortCircuit calls. No changes to POST /api/credits/sessions.

Written for commit 95e9bd7. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

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, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 72b7b6ab-fe29-40b4-8cf0-41369c4a8e63

📥 Commits

Reviewing files that changed from the base of the PR and between 892dc7a and 95e9bd7.

⛔ Files ignored due to path filters (3)
  • lib/credits/__tests__/buildInsufficientCreditsResponse.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/credits/__tests__/checkCreditsAvailable.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/credits/__tests__/ensureCreditsOrShortCircuit.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (13)
  • lib/chat/validateChatRequest.ts
  • lib/credits/buildInsufficientCreditsResponse.ts
  • lib/credits/checkCreditsAvailable.ts
  • lib/credits/const.ts
  • lib/credits/ensureCreditsOrShortCircuit.ts
  • lib/research/ensureEventsResearchCredits.ts
  • lib/research/ensureResearchCredits.ts
  • lib/research/ensureWebResearchCredits.ts
  • lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts
  • lib/research/validatePostResearchDeepRequest.ts
  • lib/research/validatePostResearchEnrichRequest.ts
  • lib/research/validatePostResearchExtractRequest.ts
  • lib/socials/ensureSocialScrapeCredits.ts

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.

3 issues found across 38 files

Confidence score: 3/5

  • In lib/stripe/stripeWebhookHandler.ts, the /api/credits/sessions saved-card off-session success path no longer sends the PaymentIntent sales notification, so some successful top-ups could become invisible to sales/ops workflows—restore the PaymentIntent notification import and call in that branch.
  • In lib/stripe/__tests__/stripeWebhookHandler.test.ts, the renamed payment_intent.succeeded test appears fully duplicated by another test, which adds noise and can hide true coverage gaps over time—remove or refocus it on a distinct behavior.
  • In lib/credits/__tests__/buildInsufficientCreditsResponse.test.ts, hardcoding "https://chat.recoupable.dev" instead of using CREDIT_BILLING_URL can cause brittle failures or miss config regressions when CHAT_APP_URL changes—assert against the imported constant to keep the test aligned with runtime configuration.
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/stripe/__tests__/stripeWebhookHandler.test.ts">

<violation number="1" location="lib/stripe/__tests__/stripeWebhookHandler.test.ts:165">
P3: The renamed "grants credits on payment_intent.succeeded" test is now redundant: its only assertion (that `processCreditsTopupPaymentIntentMock` is called with the payment intent) is fully covered by the existing "delegates payment_intent.succeeded to processCreditsTopupPaymentIntent" test. Since removing the notify assertion left this test without any unique coverage, drop this block to avoid two identical tests.</violation>
</file>

<file name="lib/stripe/stripeWebhookHandler.ts">

<violation number="1" location="lib/stripe/stripeWebhookHandler.ts:29">
P2: When `/api/credits/sessions` succeeds through a saved-card off-session charge, this deletion removes the only sales notification for that top-up. Restore the PaymentIntent notification import and call; keep the credit grant and notification as separate webhook steps.</violation>
</file>

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

<violation number="1" location="lib/credits/__tests__/buildInsufficientCreditsResponse.test.ts:40">
P3: The final test hardcodes the URL literal `"https://chat.recoupable.dev"` instead of comparing against the imported `CREDIT_BILLING_URL`. If `CHAT_APP_URL` moves (the PR's stated reason for deriving `CREDIT_BILLING_URL` from it), this assertion breaks and must be updated in a second place. Compare against `CREDIT_BILLING_URL` and assert URL-shape constraints separately so the test stays coupled to the single source of truth.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as API Client / open-agents
    participant Route as Credit-Gated Route
    participant Gate as ensureCreditsOrShortCircuit
    participant Check as checkCreditsAvailable
    participant DB as Supabase credits_usage
    participant Stripe as Stripe API
    participant Sessions as POST /api/credits/sessions

    Note over Client,Sessions: Credit Gate Flow - No Stripe Objects on 402

    Client->>Route: Request (chat, research, socials)
    Route->>Gate: ensureCreditsOrShortCircuit({accountId, creditsToDeduct})
    Gate->>Check: checkCreditsAvailable({accountId, creditsToDeduct})
    Check->>DB: selectCreditsUsage()
    DB-->>Check: remaining_credits

    alt Sufficient credits (remaining >= cost)
        Check-->>Gate: {kind: "available"}
        Gate-->>Route: null
        Route-->>Client: Proceed with work
    else Insufficient credits (remaining < cost)
        Check-->>Gate: {kind: "insufficient_credits", remaining, required}
        Note over Gate: No Stripe customer resolution<br/>No Checkout Session minted<br/>No off-session charge
    
        Gate->>Gate: buildInsufficientCreditsResponse()
        Gate-->>Route: 402 NextResponse
        Note right of Gate: Body: {error, remaining_credits,<br/>required_credits, billingUrl}
        Route-->>Client: 402 + billingUrl (static CHAT_APP_URL)
    end

    Note over Client,Sessions: Manual Top-Up - Only Place Sessions Are Minted

    Client->>Sessions: POST /api/credits/sessions
    Sessions->>Stripe: createCreditsStripeSession()
    Stripe-->>Sessions: {id, url, declineReason?}
    Sessions-->>Client: {id, url, declineReason?}
Loading

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

Re-trigger cubic

Comment thread lib/stripe/stripeWebhookHandler.ts Outdated
await notifyCreditsTopupSession(event.data.object as Stripe.Checkout.Session);
} else if (event.type === "payment_intent.succeeded") {
await processCreditsTopupPaymentIntent(event.data.object as Stripe.PaymentIntent);
await notifyCreditsTopupPaymentIntent(event.data.object as Stripe.PaymentIntent);

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: When /api/credits/sessions succeeds through a saved-card off-session charge, this deletion removes the only sales notification for that top-up. Restore the PaymentIntent notification import and call; keep the credit grant and notification as separate webhook steps.

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

<comment>When `/api/credits/sessions` succeeds through a saved-card off-session charge, this deletion removes the only sales notification for that top-up. Restore the PaymentIntent notification import and call; keep the credit grant and notification as separate webhook steps.</comment>

<file context>
@@ -26,7 +25,6 @@ export async function stripeWebhookHandler(request: NextRequest): Promise<NextRe
       await processCreditsTopupPaymentIntent(event.data.object as Stripe.PaymentIntent);
-      await notifyCreditsTopupPaymentIntent(event.data.object as Stripe.PaymentIntent);
     } else if (event.type === "invoice.paid") {
       await processInvoicePaid(event.data.object as Stripe.Invoice);
     } else if (event.type === "customer.subscription.created") {
</file context>

Comment thread lib/research/validatePostResearchDeepRequest.ts Outdated

it("notifies alongside the credits grant on payment_intent.succeeded", async () => {
const pi = { id: "pi_1", metadata: { purpose: "credits_auto_recharge" } };
it("grants credits on payment_intent.succeeded", async () => {

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: The renamed "grants credits on payment_intent.succeeded" test is now redundant: its only assertion (that processCreditsTopupPaymentIntentMock is called with the payment intent) is fully covered by the existing "delegates payment_intent.succeeded to processCreditsTopupPaymentIntent" test. Since removing the notify assertion left this test without any unique coverage, drop this block to avoid two identical tests.

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

<comment>The renamed "grants credits on payment_intent.succeeded" test is now redundant: its only assertion (that `processCreditsTopupPaymentIntentMock` is called with the payment intent) is fully covered by the existing "delegates payment_intent.succeeded to processCreditsTopupPaymentIntent" test. Since removing the notify assertion left this test without any unique coverage, drop this block to avoid two identical tests.</comment>

<file context>
@@ -167,14 +162,13 @@ describe("stripeWebhookHandler", () => {
 
-  it("notifies alongside the credits grant on payment_intent.succeeded", async () => {
-    const pi = { id: "pi_1", metadata: { purpose: "credits_auto_recharge" } };
+  it("grants credits on payment_intent.succeeded", async () => {
+    const pi = { id: "pi_1", metadata: { purpose: "credits_topup" } };
     verifyStripeWebhookEventMock.mockResolvedValue({
</file context>

Comment thread lib/socials/ensureSocialScrapeCredits.ts Outdated
requiredCredits: 1,
});

expect(billingUrl).toBe("https://chat.recoupable.dev");

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: The final test hardcodes the URL literal "https://chat.recoupable.dev" instead of comparing against the imported CREDIT_BILLING_URL. If CHAT_APP_URL moves (the PR's stated reason for deriving CREDIT_BILLING_URL from it), this assertion breaks and must be updated in a second place. Compare against CREDIT_BILLING_URL and assert URL-shape constraints separately so the test stays coupled to the single source of truth.

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

<comment>The final test hardcodes the URL literal `"https://chat.recoupable.dev"` instead of comparing against the imported `CREDIT_BILLING_URL`. If `CHAT_APP_URL` moves (the PR's stated reason for deriving `CREDIT_BILLING_URL` from it), this assertion breaks and must be updated in a second place. Compare against `CREDIT_BILLING_URL` and assert URL-shape constraints separately so the test stays coupled to the single source of truth.</comment>

<file context>
@@ -0,0 +1,45 @@
+      requiredCredits: 1,
+    });
+
+    expect(billingUrl).toBe("https://chat.recoupable.dev");
+    expect(billingUrl).not.toContain("stripe");
+    // The URL it replaces defaulted to sandbox.recoupable.com, the wrong host.
</file context>

@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Aug 18, 2026 12:06am

Request Review

@cursor

cursor Bot commented Aug 17, 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.

The credit gate called createCreditsStripeSession on both of its exit paths
purely to populate checkoutUrl in the 402 body, so every scheduled task that
ran out of credits minted a live Stripe Checkout Session, unattended and
forever. 2,779 of them across 46 accounts in 30 days, none paid
(recoupable/chat#1960). Removing the off-session charge did not fix this on
its own.

- checkCreditsAvailable drops the session minting, the Stripe customer
  resolution and the successUrl parameter. It now reads the balance and
  returns, making no Stripe calls on any path.
- buildInsufficientCreditsResponse returns a static billingUrl
  (CREDIT_BILLING_URL = CHAT_APP_URL) in place of checkoutUrl. Retrying the
  gate returns the same URL and creates nothing.
- successUrl comes off ensureCreditsOrShortCircuit and all eight credit-gated
  call sites. CREDIT_SHORTFALL_TOPUP_CREDITS and CREDIT_SHORTFALL_SUCCESS_URL
  are deleted; the latter also pointed at sandbox.recoupable.com, a host we
  do not serve, so some of those sessions would have returned users to the
  wrong place anyway.

A real Checkout Session is still minted where someone asked for one, at
POST /api/credits/sessions. That path keeps declineReason and is untouched,
so open-agents' credits-topup dialog keeps working.

ensureSongstatsPaymentMethod still mints a session per denied request. It is
a payment-method gate rather than a credit gate, out of scope here, and now
carries a comment saying so.
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — the issue's exact Done-when, against live Stripe

Rebased onto main post-#835 (now a single clean commit, 95e9bd71) and tested that head's preview (api-3mcxnggxm-recoup.vercel.app, deployment found by sha). Balance on the saved-card account lowered to 1 via an audited [TEST] admin grant and restored after (verified by re-read). No prod charge-guard needed this round: #835 is live on prod, so nothing charges anywhere.

Documented vs actual

# Check (issue Done-when) Expected Actual
1 Drive a credit-gated endpoint to 402 twenty times 20× 402 20× GET /api/research/metrics at balance 1 → HTTP codes {402} across all 20
2 Zero new Checkout Sessions 0 Stripe checkout/sessions?created[gte]=T0: 0 for the customer, and 0 account-wide (so no mis-attribution hiding anything). PaymentIntents: 0. Charges: 0
3 402 body gives the caller somewhere to go static billingUrl Every one of the 20 bodies is exactly {error, remaining_credits: 1, required_credits: 5, billingUrl: "https://app.recoupable.dev"} — one field-set, one URL value, no checkoutUrl, no declineReason
4 Balance untouched still 1 remaining_credits: 1 after all 20 probes, then restored to 999,949,777
5 POST /api/credits/sessions decline path keeps working dialog renders decline Not run live — exercising it would charge the real saved Visa. Verified by diff instead: this PR does not touch createCreditsSessionHandler, chargeCustomerOffSession, or createCreditsStripeSession, so the { id, url, declineReason? } response open-agents parses is unchanged ⚠️ by-diff

For contrast: the identical scenario on the #835 preview (before this change) minted one cs_live_ session per 4024 of them, unpaid. Same gate, same account, now zero.

Conflict-resolution note

The branch was stacked on #835; after that squash-merged, this PR was rebased with --onto origin/main, dropping the already-merged commits. Full affected suites re-run post-rebase: 168 files / 1043 tests green, tsc at the 202-error baseline, lint clean. CREDIT_BILLING_URL carries the review decision from #835: https://app.recoupable.dev as a literal.

Reminder for merge

The docs merged in docs#299 show billingUrl: "https://chat.recoupable.dev" in the 402 examples; the live value is app.recoupable.dev. One-line docs follow-up needed once this merges.

Ready to merge.

@sweetmantech
sweetmantech merged commit 18f5b28 into main Aug 18, 2026
6 checks passed
sweetmantech added a commit to recoupable/skills that referenced this pull request Aug 18, 2026
The credit gate's 402 body changed in recoupable/api#836 (chat#1960):
billingUrl is a static app link and no Stripe object is created. Two
example references still named checkoutUrl.
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