Skip to content

fix(credits): apply a due monthly refill at the gate before denying - #839

Merged
sweetmantech merged 1 commit into
mainfrom
fix/credit-gate-refill-aware
Aug 18, 2026
Merged

fix(credits): apply a due monthly refill at the gate before denying#839
sweetmantech merged 1 commit into
mainfrom
fix/credit-gate-refill-aware

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Implements recoupable/chat#1962 — the sole row in that issue's PR matrix.

The credit gate read the raw credits_usage balance, so the first credit-gated request after a monthly refill boundary saw the stale pre-refill balance and 402d spuriously until something called GET /api/accounts/{id}/credits, the only place the refill ran.

What changes

checkCreditsAvailable keeps the single-read happy path, and on a shortfall runs checkAndResetCredits — the same refill the balance GET applies — then re-evaluates with the refreshed balance:

  • Sufficient raw balance → available, one DB read, checkAndResetCredits never called. Safe to skip because the refill is a floor (api#837): it can only raise a balance, so it cannot turn a pass into a fail, and can wait for the next balance read.
  • Shortfall + refill due → the refill applies (floor semantics, timestamp advances) and the request passes if the refilled balance covers the cost. This is the bug case.
  • Shortfall + refill applied but still short, or no refill due → 402 with the refreshed balance in remaining_credits.

All eight credit-gated call sites go through ensureCreditsOrShortCircuit, whose contract is unchanged — no call-site edits.

Verification

TDD, red before green: added a describe("a due-but-unapplied monthly refill") block with 4 cases (applied-on-shortfall passes, still-short 402s with the refreshed balance, happy path never consults the refill, missing row → zero). Ran it: RED, 2 failed (the two behavior-changing cases). Implemented: GREEN.

Check Result
vitest run lib/credits 15 files / 95 tests passed
eslint lib/credits clean
tsc --noEmit 202 errors, identical to the pre-existing baseline, zero in touched files

Not yet done: the issue's live Done-when (seed a spent balance + month-old timestamp, confirm the first credit-gated call passes with no prior GET /credits, and the timestamp advances) against the preview. Flagging rather than implying it.


Summary by cubic

Applies a due monthly credits refill during gating before denying, preventing false 402s right after a refill boundary. Previously the gate read the raw credits_usage balance; the first gated call after the boundary could 402 until GET /api/accounts/{id}/credits applied the refill.

  • On shortfall, checkCreditsAvailable now calls checkAndResetCredits(accountId) and re-evaluates; it passes if the refilled balance covers the cost, otherwise 402s with the refreshed remainingCredits.
  • Happy path unchanged: sufficient balance returns available with a single DB read; the refill is skipped since it only raises the balance.
  • Contract and call sites unchanged; no changes to ensureCreditsOrShortCircuit.
  • Tests cover applied-on-shortfall pass, still-short denial with refreshed balance, single-read happy path, and missing-row treated as zero.

Written for commit 0cc653d. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Monthly credits now reset when due before insufficient-credit status is reported.
    • Credit availability checks now reflect the refreshed balance after a reset.

The credit gate read the raw balance, so the first credit-gated request
after a refill boundary saw the stale pre-refill balance and 402d until
something called GET /accounts/{id}/credits, the only checkAndResetCredits
caller (recoupable/chat#1962).

On a shortfall the gate now runs checkAndResetCredits and re-evaluates with
the refreshed balance. The happy path stays a single read: the refill is a
floor, so it can only raise the balance and cannot turn a pass into a fail,
which makes skipping it safe whenever the raw balance already covers the
cost.
@vercel

vercel Bot commented Aug 18, 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 1:04am

Request Review

@cursor

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

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8dbb5aed-3456-43e4-9b17-f746f3fcc04a

📥 Commits

Reviewing files that changed from the base of the PR and between 7a0c590 and 0cc653d.

⛔ Files ignored due to path filters (1)
  • lib/credits/__tests__/checkCreditsAvailable.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (1)
  • lib/credits/checkCreditsAvailable.ts

Included review availability: Your plan includes up to 1 review per rolling hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

checkCreditsAvailable now applies a due monthly reset after an initial credit shortfall. It then rechecks availability and reports the refreshed balance.

Changes

Credit availability recovery

Layer / File(s) Summary
Reset and recheck insufficient balances
lib/credits/checkCreditsAvailable.ts
The check uses checkAndResetCredits after an initial shortfall, reevaluates credit availability, and reports the refreshed remaining balance. The comments describe the fast path and reset flow.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 0cc65

The credit gate now applies a due monthly refill before denying a request, with localized tests covering the changed behavior; no actionable merge-blocking risk remains after normal checks and review.

Possibly related issues

  • recoupable/chat#1962 — The change addresses stale balances by applying the monthly reset and rechecking credits before returning insufficient-credit status.

Poem

A balance falls below the line,
The monthly reset makes it shine.
Credits refresh, the check runs twice,
The new balance reports the price.
Clean flow, clear state, precise advice.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning The changed checkCreditsAvailable function spans 21 physical lines (35–55), exceeding the check's 20-line SRP/clean-code limit. Reduce checkCreditsAvailable below 20 lines, or move the refill/re-evaluation responsibility to a separate helper file named after that helper.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/credit-gate-refill-aware

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/checkCreditsAvailable.ts, concurrent calls can both pass the refill path and apply the same monthly reset twice, which can over-credit an account and distort authorization decisions/billing outcomes — make the refill update atomic (conditional update/lock/transaction) so only one caller can perform it.
  • In lib/credits/checkCreditsAvailable.ts, shortfall handling now routes through checkAndResetCredits, which can throw via subscription-state lookup or credits reads/writes; that turns a normal “insufficient credits” path into request failures under transient DB/service issues — add defensive error handling/fallback behavior and cover this path with failure-mode tests.
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/checkCreditsAvailable.ts">

<violation number="1" location="lib/credits/checkCreditsAvailable.ts:45">
P1: When concurrent requests hit a due, low balance, this call can apply the same monthly refill twice. Make the refill conditional or serialize it so only one caller can raise the balance before authorization.</violation>

<violation number="2" location="lib/credits/checkCreditsAvailable.ts:45">
P2: On a shortfall, the gate now calls `checkAndResetCredits`, which can throw: it runs `getAccountSubscriptionState` and, when a refill is due, `updateCreditsUsage` — and both `selectCreditsUsage`/`updateCreditsUsage` throw on Supabase errors. `ensureCreditsOrShortCircuit` has no try/catch, so any of these failures now propagates and turns what used to be a guaranteed graceful 402 into a 500 on the shortfall path. The updated JSDoc also still claims "It reads, and does nothing else", which is no longer true once a refill is applied. Decide explicitly whether the gate should swallow refill errors (return the 402 with the pre-refill balance) or let them bubble, and update the doc comment to reflect that the shortfall path performs a write/refill.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Client / Caller
    participant Gate as ensureCreditsOrShortCircuit
    participant Checker as checkCreditsAvailable
    participant DB as Supabase credits_usage
    participant Refill as checkAndResetCredits

    Note over Client,Refill: Credit Gating Flow

    Client->>Gate: Request with creditsToDeduct
    Gate->>Checker: checkCreditsAvailable(accountId, creditsToDeduct)

    Note over Checker: Single-read happy path
    Checker->>DB: selectCreditsUsage(accountId)
    DB-->>Checker: remaining_credits row

    alt Sufficient balance (remaining >= creditsToDeduct)
        Checker-->>Gate: { kind: "available" }
        Gate-->>Client: Allow request
    else Shortfall (remaining < creditsToDeduct)
        Note over Checker,Refill: Apply due monthly refill before denying
        Checker->>Refill: checkAndResetCredits(accountId)
        
        alt Refill due
            Refill->>DB: Apply floor refill, advance timestamp
            DB-->>Refill: Refreshed credits_usage row
            Refill-->>Checker: creditsUsage with refreshed balance
        else No refill due or already applied
            Refill->>DB: Read current state
            DB-->>Refill: Current credits_usage
            Refill-->>Checker: creditsUsage (unchanged)
        end

        Checker->>Checker: Extract refreshed balance (null => 0)

        alt Refilled balance covers cost
            Checker-->>Gate: { kind: "available" }
            Gate-->>Client: Allow request
        else Still insufficient after refill
            Checker-->>Gate: { kind: "insufficient_credits", remainingCredits: refreshed }
            Gate-->>Client: Deny (402) with refreshed balance
        end
    end
Loading

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

Re-trigger cubic


if (remaining >= creditsToDeduct) return { kind: "available" };

const { creditsUsage } = await checkAndResetCredits(accountId);

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 concurrent requests hit a due, low balance, this call can apply the same monthly refill twice. Make the refill conditional or serialize it so only one caller can raise the balance before authorization.

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

<comment>When concurrent requests hit a due, low balance, this call can apply the same monthly refill twice. Make the refill conditional or serialize it so only one caller can raise the balance before authorization.</comment>

<file context>
@@ -35,9 +42,14 @@ export async function checkCreditsAvailable(
 
   if (remaining >= creditsToDeduct) return { kind: "available" };
 
+  const { creditsUsage } = await checkAndResetCredits(accountId);
+  const refreshed = creditsUsage?.remaining_credits ?? 0;
+
</file context>


if (remaining >= creditsToDeduct) return { kind: "available" };

const { creditsUsage } = await checkAndResetCredits(accountId);

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: On a shortfall, the gate now calls checkAndResetCredits, which can throw: it runs getAccountSubscriptionState and, when a refill is due, updateCreditsUsage — and both selectCreditsUsage/updateCreditsUsage throw on Supabase errors. ensureCreditsOrShortCircuit has no try/catch, so any of these failures now propagates and turns what used to be a guaranteed graceful 402 into a 500 on the shortfall path. The updated JSDoc also still claims "It reads, and does nothing else", which is no longer true once a refill is applied. Decide explicitly whether the gate should swallow refill errors (return the 402 with the pre-refill balance) or let them bubble, and update the doc comment to reflect that the shortfall path performs a write/refill.

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

<comment>On a shortfall, the gate now calls `checkAndResetCredits`, which can throw: it runs `getAccountSubscriptionState` and, when a refill is due, `updateCreditsUsage` — and both `selectCreditsUsage`/`updateCreditsUsage` throw on Supabase errors. `ensureCreditsOrShortCircuit` has no try/catch, so any of these failures now propagates and turns what used to be a guaranteed graceful 402 into a 500 on the shortfall path. The updated JSDoc also still claims "It reads, and does nothing else", which is no longer true once a refill is applied. Decide explicitly whether the gate should swallow refill errors (return the 402 with the pre-refill balance) or let them bubble, and update the doc comment to reflect that the shortfall path performs a write/refill.</comment>

<file context>
@@ -35,9 +42,14 @@ export async function checkCreditsAvailable(
 
   if (remaining >= creditsToDeduct) return { kind: "available" };
 
+  const { creditsUsage } = await checkAndResetCredits(accountId);
+  const refreshed = creditsUsage?.remaining_credits ?? 0;
+
</file context>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — the issue's exact Done-when, with a live prod contrast

Tested the PR-head preview (0cc653dfapi-gpt8quonw-recoup.vercel.app, deployment found by sha). Method: seed a spent balance (0) via an audited [TEST] admin grant, backdate credits_usage.timestamp to 2026-06-15 via Supabase, then make a credit-gated request as the first call — deliberately no GET /credits between seed and call, which is the precondition the bug depends on.

Documented vs actual

# Check (issue Done-when) Expected Actual
1 First credit-gated call with a due-but-unapplied refill passes the gate GET /api/research/metrics at balance 0 → not 402. Response was a downstream 404 (artist ID resolution is not yet implemented) — past the gate, which is the assertion; under the old gate this exact request 402s (see contrast)
2 Refill applied by the gate balance refilled DB read-back: remaining_credits: 0 → 9999 (pro floor; account is pro, so PRO_CREDITS — the free-plan 333 case is covered by the unit suite)
3 credits_usage.timestamp advances June → now 2026-06-15T00:00:002026-08-18T12:58:08.942
4 No deduction without success balance intact The endpoint failed downstream, and the balance stayed 9,999 — deduct-on-success preserved

Live prod contrast (prod has #835#838 but not this PR)

Same account, same seed (0 credits + June timestamp), same endpoint Result DB after
Prod (old gate) HTTP 402 {remaining_credits: 0, …, billingUrl} remaining_credits: 0, timestamp still June — the account stays stuck until something reads the balance
Preview (this PR) past the gate 9999, timestamp advanced

That prod row is the bug reproduced live: the 402 didn't just deny the request, it left the refill unapplied for the next request too.

State ledger

Restored to the true pre-test balance 999,949,748 — the seed grant's previous_credits showed ~29 credits of real usage since the last test cycle, so the restore used that value rather than the earlier constant. Final DB state verified. Two [TEST]-reason grants on the audit trail per usual.

Local verification (from the PR body, unchanged)

TDD red→green (2 RED on the behavior-changing cases), vitest run lib/credits 15 files / 95 tests, lint clean, tsc at the 202 pre-existing baseline.

Ready to merge — closes the only open item chain in chat#1962.

@sweetmantech
sweetmantech merged commit 50c1cfc 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