Skip to content

fix(credits): make the monthly refill a floor, not an assignment - #837

Merged
sweetmantech merged 3 commits into
mainfrom
fix/credits-refill-is-a-floor
Aug 18, 2026
Merged

fix(credits): make the monthly refill a floor, not an assignment#837
sweetmantech merged 3 commits into
mainfrom
fix/credits-refill-is-a-floor

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Implements the third defect in recoupable/chat#1960. Row 5 of that issue's PR matrix. Independent of the auto-recharge PRs; no shared files.

checkAndResetCredits.ts L54-60 writes remaining_credits: isPro ? PRO_CREDITS : DEFAULT_CREDITS unconditionally once a monthly refill is due. A "refill" that lowers a balance is not a refill: any account holding more than its plan total, from a top-up or an admin grant, is cut on its next balance read. And because the refill is lazy and read-triggered, the loss fires on a GETreading a balance is what destroys it, and nothing records that it happened.

Measured on prod 2026-08-17:

accounts with a credits row 1,714
holding more than 333 (free plan total) 119
holding >333 with a reset already pending 50
holding >333 with a reset due inside 30 days 117
holding more than 9,999 (pro plan total) 4

What changes

-  const refilled = await updateCreditsUsage({
-    account_id: accountId,
-    updates: {
-      remaining_credits: isPro ? PRO_CREDITS : DEFAULT_CREDITS,
-      timestamp: new Date().toISOString(),
-    },
-  });
+  const planTotal = isPro ? PRO_CREDITS : DEFAULT_CREDITS;
+  const remaining = creditsUsage.remaining_credits ?? 0;
+
+  const updates: Partial<Pick<CreditsUsage, "remaining_credits" | "timestamp">> = {
+    timestamp: new Date().toISOString(),
+  };
+  if (remaining < planTotal) updates.remaining_credits = planTotal;
+
+  const refilled = await updateCreditsUsage({ account_id: accountId, updates });

Two decisions worth flagging, because the issue's Fix text asks for something slightly self-contradictory:

The timestamp always advances, so the write is not skipped. The issue says to "skip the updateCreditsUsage call entirely when remaining_credits >= planTotal" and to "still bump timestamp in that branch". Those cannot both hold — and the issue's own reasoning says why the timestamp must win: without it the account re-evaluates as refill-due on every subsequent read, forever. So the write stays; what is skipped is the remaining_credits field, not the call. The issue's Done-when ("credits_usage.timestamp advances in all three cases") agrees.

remaining_credits is omitted rather than written as max(remaining, planTotal). Both satisfy the floor rule, but writing back a value read moments earlier would resurrect credits a concurrent deduction had spent in between. Omitting the field leaves the column alone and closes that window instead of preserving it.

No grant provenance is needed. chat#1948 deferred this fix on the grounds that protecting a grant "would mean teaching checkAndResetCredits about grant provenance", and said to revisit if a granted account were ever found reset in the wild. It does not need provenance: a refill that never reduces protects a granted balance automatically, without the read path knowing where the balance came from. That is why the fix is much smaller than #1948 assumed, and there is a test asserting exactly it.

Verification

TDD, red before green. Added a describe("the refill is a floor, not an assignment") block with six cases, then ran it: RED, 4 failed / 9 passed (the four asserting the update payload for at-or-above-total balances). Implemented, then GREEN, 13 passed.

Case Balance Plan total Expected update Result
below the total, free 100 333 { remaining_credits: 333, timestamp }
above the total, free 9,999 333 { timestamp } only, balance stays 9,999
exactly at the total 333 333 { timestamp } only
above the total, pro 25,000 9,999 { timestamp } only, balance stays 25,000
admin grant on a free account 9,999 333 no remaining_credits in the update, balance stays 9,999
newly-subscribed refill trigger 12,000 9,999 { timestamp } only, balance stays 12,000

The seven pre-existing tests, including both original refill-to-plan-total cases, still pass unmodified.

Full runs:

Check Result
vitest run lib/credits lib/admins app/api/accounts 44 files / 257 tests passed
eslint lib/credits clean
tsc --noEmit 202 errors, identical to baseline, zero in either file this PR touches

Not yet done: the issue's live Done-when checks — seed an account at 9,999 on the free plan with a month-old timestamp on preview and read it back, and after deploy confirm none of the 50 at-risk prod accounts lose credits. Flagging rather than implying it.

Merge order

Independent of the auto-recharge PRs and can land at any point. Two things depend on it:

  • docs#300 documents the floor rule and should merge after this reaches prod, not before.
  • The follow-on PR retiring getGrantExpiresAt is only correct once this has shipped (row 6 of chat#1960).

Summary by cubic

Stops monthly refills from cutting balances by making the refill a floor instead of an assignment. Previously a due refill set remaining_credits to the plan total; now it only raises balances below the total and always advances timestamp.

  • In checkAndResetCredits: compute the plan total; on a due refill write timestamp and only set remaining_credits when below the total. Balances at or above the total are left unchanged.
  • Omits remaining_credits instead of writing back max(remaining, planTotal) to avoid resurrecting credits in concurrent-deduction races.
  • Tests cover below/at/above totals, free vs. pro, admin grant, and newly-subscribed refills; existing tests remain green.

Rollout

  • No migration. Verify that balances above plan totals do not decrease on read.

Written for commit 2f65b4f. Summary will update on new commits.

Review in cubic

checkAndResetCredits SET remaining_credits to the plan total once a refill
was due, so any account holding more than its plan total was cut on its next
balance read. Because the refill is lazy and read-triggered, the loss fired on
a GET: reading a balance was what destroyed it, and nothing recorded that it
had happened. On prod 2026-08-17, 119 accounts held more than the 333
free-plan total and 50 of those had a reset already pending
(recoupable/chat#1960).

The refill now raises a balance up to the plan total and never lowers one.

The timestamp still advances on every due refill, including the no-op ones,
or the account re-evaluates as refill-due on every subsequent read.
remaining_credits is omitted from the update rather than written back as
max(remaining, planTotal) when the balance already clears the total: writing a
value read moments earlier would resurrect credits a concurrent deduction had
spent.

chat#1948 deferred this on the grounds that protecting a grant "would mean
teaching checkAndResetCredits about grant provenance". It does not: a refill
that never reduces protects a granted balance automatically, without the read
path knowing where the balance came from. There is a test for exactly that.
@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: 17 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: de266cfb-4b65-489b-9806-4aeae1e4c8bc

📥 Commits

Reviewing files that changed from the base of the PR and between 18f5b28 and 2f65b4f.

⛔ Files ignored due to path filters (1)
  • lib/credits/__tests__/checkAndResetCredits.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (1)
  • lib/credits/checkAndResetCredits.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.

2 issues found across 2 files

Confidence score: 2/5

  • In lib/credits/checkAndResetCredits.ts, the non-atomic absolute write during refill can overwrite a concurrent debit or top-up, causing users to lose or gain credits incorrectly under load — move the floor/refill logic into a single atomic database update so it evaluates and writes the same row state.
  • In lib/credits/__tests__/checkAndResetCredits.test.ts, the two new tests currently cover the same free-tier/9999-credits path, so a real edge-case regression could still slip through undetected — split them to assert different branches or inputs (for example, distinct tiers or balance boundaries).
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/credits/__tests__/checkAndResetCredits.test.ts">

<violation number="1" location="lib/credits/__tests__/checkAndResetCredits.test.ts:249">
P3: The new tests "leaves a balance ABOVE the plan total untouched" and "protects an admin grant on a free account" exercise the exact same code path: free tier, remaining_credits 9999 (above DEFAULT_CREDITS), same updateCreditsUsage mock, and identical assertions on the update payload and returned balance. The only difference is the narrative comment. If the intent is just to document that a grant survives without provenance, keep it; otherwise the two are functionally duplicate coverage and one could be dropped or merged to avoid implying they guard distinct branches.</violation>
</file>

<file name="lib/credits/checkAndResetCredits.ts">

<violation number="1" location="lib/credits/checkAndResetCredits.ts:72">
P1: When the balance is below the plan total, this absolute write can erase a concurrent debit or top-up. Apply the floor atomically in the database so the refill observes the balance it updates.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant API as API Route / Balance Read
    participant C as checkAndResetCredits()
    participant DB as Credits Usage DB
    participant Sub as Subscription Service

    Note over API,Sub: Monthly Credit Refill Flow (Lazy, Read-Triggered)

    API->>C: Check credits usage for account
    C->>DB: SELECT credits_usage row
    DB-->>C: Row (remaining_credits, timestamp, account_id)
    C->>Sub: Get account subscription state
    Sub-->>C: isPro (boolean)

    alt Refill due (>= 1 month since last update OR subscription started after)
        C->>C: Determine planTotal (PRO_CREDITS or DEFAULT_CREDITS)
        
        alt Balance below planTotal
            C->>DB: UPDATE remaining_credits = planTotal, timestamp = now
        else Balance at or above planTotal
            Note over C: Floor rule applies - never lower balance
            C->>DB: UPDATE timestamp = now only (leave remaining_credits untouched)
        end
        
        DB-->>C: Updated row
        C-->>API: Return updated credits + isPro
    else No refill due
        C-->>API: Return existing credits + isPro
    end
Loading

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

Re-trigger cubic

const updates: Partial<Pick<CreditsUsage, "remaining_credits" | "timestamp">> = {
timestamp: new Date().toISOString(),
};
if (remaining < planTotal) updates.remaining_credits = planTotal;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When the balance is below the plan total, this absolute write can erase a concurrent debit or top-up. Apply the floor atomically in the database so the refill observes the balance it updates.

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

<comment>When the balance is below the plan total, this absolute write can erase a concurrent debit or top-up. Apply the floor atomically in the database so the refill observes the balance it updates.</comment>

<file context>
@@ -51,13 +58,20 @@ export async function checkAndResetCredits(accountId: string): Promise<CheckAndR
+  const updates: Partial<Pick<CreditsUsage, "remaining_credits" | "timestamp">> = {
+    timestamp: new Date().toISOString(),
+  };
+  if (remaining < planTotal) updates.remaining_credits = planTotal;
+
+  const refilled = await updateCreditsUsage({ account_id: accountId, updates });
</file context>

expect(result.creditsUsage?.remaining_credits).toBe(25000);
});

it("protects an admin grant on a free account without knowing it is a grant", 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 new tests "leaves a balance ABOVE the plan total untouched" and "protects an admin grant on a free account" exercise the exact same code path: free tier, remaining_credits 9999 (above DEFAULT_CREDITS), same updateCreditsUsage mock, and identical assertions on the update payload and returned balance. The only difference is the narrative comment. If the intent is just to document that a grant survives without provenance, keep it; otherwise the two are functionally duplicate coverage and one could be dropped or merged to avoid implying they guard distinct branches.

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

<comment>The new tests "leaves a balance ABOVE the plan total untouched" and "protects an admin grant on a free account" exercise the exact same code path: free tier, remaining_credits 9999 (above DEFAULT_CREDITS), same updateCreditsUsage mock, and identical assertions on the update payload and returned balance. The only difference is the narrative comment. If the intent is just to document that a grant survives without provenance, keep it; otherwise the two are functionally duplicate coverage and one could be dropped or merged to avoid implying they guard distinct branches.</comment>

<file context>
@@ -163,4 +163,124 @@ describe("checkAndResetCredits", () => {
+      expect(result.creditsUsage?.remaining_credits).toBe(25000);
+    });
+
+    it("protects an admin grant on a free account without knowing it is a grant", async () => {
+      // 9,999 granted to a free-tier account: no provenance is consulted, the
+      // floor rule alone keeps it.
</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:30am

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.

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

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — with a live before/after on the same account

Tested the PR-head preview (2f65b4f0api-je3xf2nox-recoup.vercel.app, deployment found by sha). Method: seed the balance via an audited [TEST] admin grant, backdate credits_usage.timestamp to 2026-06-15 via Supabase directly (no API can backdate a timestamp — that is the one place SQL was needed), then hit GET /api/accounts/{id}/credits on the preview, which is exactly the read-triggered path the refill fires on. Account is pro, so planTotal = 9,999.

Documented vs actual

# Case Seeded Old-code result Preview (this PR) Timestamp
1 Above the plan total 25,000 cut to 9,999 25,000 — unchanged advanced 2026-06-15 → now
2 Below the plan total 100 9,999 9,999 (the refill still refills) advanced
3 Exactly at the total 9,999 9,999 9,999 advanced (so the account does not re-evaluate as refill-due on every read)

The before/after, live on the shared DB

Prod still runs the SET-refill, so the same procedure against api.recoupable.dev is a controlled reproduction of the defect:

Same account, same seed (25,000), same June timestamp Read back
Prod (old code) 9,999 — a GET destroyed 15,001 credits
Preview (this PR) 25,000

Both runs restored to the pre-test balance immediately (verified by re-read, final state 999,949,777).

Notes

  • The issue's Done-when phrases case 1 as "9,999 on the free plan reads back 9,999, not 333". The test account is pro, so the live cases exercise the identical code path with PRO_CREDITS as the floor instead of DEFAULT_CREDITS; the free-plan constants are covered by the unit suite (6 floor cases, including an admin-grant-on-free-account case).
  • The remaining Done-when item — "verify against the 50 at-risk accounts on prod after deploy: none should lose credits" — is a post-merge check by nature. The at-risk cohort can be re-queried after this ships; with the floor in place their next balance read advances the timestamp and leaves the balance alone.
  • Auth note: preview API-key peppers differ from prod (prod key 401s on the preview), so the pass ran on a fresh preview-scoped Privy bearer.

Ready to merge. api#838 follows this one.

@sweetmantech
sweetmantech merged commit 4c69fea into main Aug 18, 2026
6 checks passed
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