Skip to content

fix(billing): add idempotency to billing#4157

Merged
TheodoreSpeaks merged 2 commits intostagingfrom
fix/idempotent-cost-reporting
Apr 14, 2026
Merged

fix(billing): add idempotency to billing#4157
TheodoreSpeaks merged 2 commits intostagingfrom
fix/idempotent-cost-reporting

Conversation

@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator

Summary

Our usage log has external dependencies to stripe. This can hang, causing copilot to retry and double-bill.
Added a optional idempotency key to skip billing if a request is duplicated.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Other: ___________

Testing

  • Tested locally.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Screenshots/Videos

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 14, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Apr 14, 2026 9:04pm

Request Review

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 14, 2026

PR Summary

Medium Risk
Touches billing/usage recording flow and introduces idempotency claims/release logic; incorrect handling could block legitimate charges or still allow duplicate billing on edge cases.

Overview
Adds optional idempotency support to POST /api/billing/update-cost to prevent double-billing on retries by atomically claiming a billingIdempotency key and rejecting duplicates with 409.

Extends the idempotency service with a release helper and introduces a new billingIdempotency instance (1-hour TTL); the API releases the claim on failures before usage is committed, but intentionally retains it if an error occurs after usage is recorded to avoid duplicate charges.

Reviewed by Cursor Bugbot for commit 93311f2. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 14, 2026

Greptile Summary

This PR adds an optional idempotencyKey field to the POST /api/billing/update-cost endpoint so that retried Copilot requests (caused by Stripe hangs) are de-duplicated via an atomic claim in Redis or PostgreSQL. A new billingIdempotency singleton with a 1-hour TTL is exported from the existing IdempotencyService.

  • P1: The catch block releases the idempotency claim unconditionally (line 177). If recordUsage succeeds but checkAndBillOverageThreshold later throws, the claim is deleted and a caller retry with the same key will re-run recordUsage — double-billing the user. The release should be guarded by a billingApplied flag so it only fires when no charge has been recorded yet.

Confidence Score: 3/5

Not safe to merge as-is — the unconditional claim release in the error handler can cause the double-billing this PR is designed to prevent.

One P1 logic bug: recordUsage may succeed before an exception is thrown, yet the catch block calls release, re-opening the idempotency window for retries and enabling double charges.

apps/sim/app/api/billing/update-cost/route.ts — error handler release logic (lines 176–184)

Important Files Changed

Filename Overview
apps/sim/app/api/billing/update-cost/route.ts Adds optional idempotency key deduplication to the billing endpoint; error handler releases the claim unconditionally, creating a double-billing window if recordUsage succeeds but a later step throws.
apps/sim/lib/core/idempotency/service.ts Adds billingIdempotency singleton with 1-hour TTL; no issues in the service itself — atomicallyClaim and release are correctly implemented.

Sequence Diagram

sequenceDiagram
    participant C as Copilot
    participant R as /api/billing/update-cost
    participant I as IdempotencyService
    participant DB as recordUsage / DB
    participant S as Stripe (checkAndBillOverage)

    C->>R: POST idempotencyKey abc
    R->>I: atomicallyClaim(update-cost, abc)
    I-->>R: claimed true
    R->>DB: recordUsage(...)
    DB-->>R: ok
    R->>S: checkAndBillOverageThreshold(userId)

    alt Success
        S-->>R: ok
        R-->>C: 200 OK
    end

    alt Stripe throws error
        S-->>R: throws
        R->>I: release(key) key deleted even though recordUsage succeeded
        R-->>C: 500 Error
        C->>R: POST retry same idempotencyKey
        R->>I: atomicallyClaim(update-cost, abc)
        I-->>R: claimed true key was deleted
        R->>DB: recordUsage(...) double-billing
    end

    alt Duplicate within TTL
        C->>R: POST retry same idempotencyKey
        R->>I: atomicallyClaim(update-cost, abc)
        I-->>R: claimed false
        R-->>C: 409 Conflict
    end
Loading

Comments Outside Diff (2)

  1. apps/sim/app/api/billing/update-cost/route.ts, line 167-185 (link)

    P1 Release after partial billing enables double-charge

    After recordUsage succeeds (line 127–139), if checkAndBillOverageThreshold throws, the catch block unconditionally releases the idempotency claim (line 177). A caller that retries with the same idempotencyKey will re-enter the success path, call recordUsage a second time, and double-bill the user — the exact scenario this PR is meant to prevent.

    The claim should only be released when billing has not yet been applied. A minimal fix tracks whether recordUsage completed:

    let billingApplied = false
    
    await recordUsage({ ... })
    billingApplied = true
    
    await checkAndBillOverageThreshold(userId)

    Then in the catch block:

    if (claim?.claimed && !billingApplied) {
      await billingIdempotency.release(claim.normalizedKey, claim.storageMethod).catch(...)
    }

    If billingApplied is true, keep the claim so subsequent retries get the 409 guard rather than re-running recordUsage.

  2. apps/sim/app/api/billing/update-cost/route.ts, line 155-166 (link)

    P2 Idempotency key not marked completed after success

    After a successful billing run the claim is left in in-progress state until the 1-hour TTL expires. For the simple duplicate-prevention pattern used here (check claimed, 409 on conflict) this is functionally sufficient, but it means any caller using waitForResult on a billing key would poll indefinitely until timeout. Consider calling billingIdempotency.storeResult(claim.normalizedKey, { success: true, status: 'completed' }, claim.storageMethod) before returning the 200 so the stored state accurately reflects reality.

Reviews (1): Last reviewed commit: "fix(billing): add idempotency to billing" | Re-trigger Greptile

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Releasing idempotency claim after billing committed risks double-billing
    • Added a billingCommitted guard so idempotency claims are released only when billing failed before recordUsage committed.

Create PR

Or push these changes by commenting:

@cursor push 31f2524bbc
Preview (31f2524bbc)
diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts
--- a/apps/sim/app/api/billing/update-cost/route.ts
+++ b/apps/sim/app/api/billing/update-cost/route.ts
@@ -31,6 +31,7 @@
   const requestId = generateRequestId()
   const startTime = Date.now()
   let claim: AtomicClaimResult | null = null
+  let billingCommitted = false
 
   try {
     logger.info(`[${requestId}] Update cost request started`)
@@ -137,6 +138,7 @@
       ],
       additionalStats,
     })
+    billingCommitted = true
 
     logger.info(`[${requestId}] Recorded usage`, {
       userId,
@@ -173,7 +175,7 @@
       duration,
     })
 
-    if (claim?.claimed) {
+    if (claim?.claimed && !billingCommitted) {
       await billingIdempotency
         .release(claim.normalizedKey, claim.storageMethod)
         .catch((releaseErr) => {

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 93311f2. Configure here.

@TheodoreSpeaks TheodoreSpeaks merged commit 6b2e83b into staging Apr 14, 2026
14 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the fix/idempotent-cost-reporting branch April 14, 2026 21:16
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