Skip to content

feat(admins): POST /api/admins/credits sets a balance and records why (chat#1948) - #824

Merged
sweetmantech merged 2 commits into
mainfrom
feat/admin-grant-credits
Aug 6, 2026
Merged

feat(admins): POST /api/admins/credits sets a balance and records why (chat#1948)#824
sweetmantech merged 2 commits into
mainfrom
feat/admin-grant-credits

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The staff-facing credit grant. Third of three PRs for chat#1948.

Merge order: docs#295database#55 → this PR. database#55 is a hard dependency: this code calls grant_credits_with_audit and reads credit_grants.

Why

Every route under /api/admins/credits is a GET, and the internal writers to credits_usage are only reachable from the Stripe webhook and the credits read path. So a top-up today is a direct database write that records nothing about who made it or why — indistinguishable from a Stripe top-up or a monthly reset minutes later. Measured on prod 2026-08-06: 155 accounts at or below zero, with no supported way for staff to unblock any of them.

What ships

POST /api/admins/credits Sets an absolute balance, records actor + reason, returns expires_at.
GET /api/admins/credits/events Now returns a grants array beside events.
grant_credits_with_audit Both writes in one transaction (database#55).

Decisions worth reviewing

The admin gate is validateAdminAuth, not canAccessAccount. The issue proposed reusing canAccessAccount because it grants RECOUP_ORG_ID members universal access. It does — but it also returns true whenever the caller merely shares any organization with the target (canAccessAccount.ts L40-44), which on a credits write would let two members of the same customer org grant each other credits. validateAdminAuthcheckIsAdmin gates on Recoup org membership alone, and is what every other admin route already uses. Flagged on the issue as a decision callout.

The two writes are atomic. Moving the balance and recording the grant are separate writes, and through PostgREST they can drift on partial failure — which is why deduct_credits_with_audit exists on the debit side. Here the drift is the bug: a balance that moved with no grant row is the untraceable write this endpoint exists to remove. previous_credits is captured inside the function so it cannot be stale by the time the balance moves.

granted_by never comes from the body. It is resolved from credentials; a granted_by in the request body is ignored, and there is a test pinning that.

404 is checked, not left to the FK, so an unknown account_id is a clean 404 rather than a 500 from a constraint violation — and nothing is written on that path.

The one-month expiry is surfaced, not fixed. A grant bumps credits_usage.timestamp, so the reset clock restarts and the balance gets a full month; expires_at comes back in the response and the caveat is spelled out in the docs. Making a grant survive resets outright would mean teaching checkAndResetCredits about grant provenance — a change to the read path every account hits, for a case re-granting already covers. Deliberate, and recorded as such on the issue.

Verification

TDD throughout — every unit RED before GREEN, including the three new grants assertions on the existing events handler test, which failed against the unmodified handler before the change.

pnpm exec vitest run lib/admins lib/credits lib/supabase/credit_grants lib/supabase/credits_usage
  Test Files  46 passed (46)
       Tests  258 passed (258)

tsc --noEmit clean for every file in this diff (the remaining errors are the pre-existing lib/trigger baseline, untouched here). eslint and prettier clean.

Not yet verified against the previewgrant_credits_with_audit and credit_grants do not exist in the database until database#55 is applied, so the live run happens after that lands. Results will be posted here as a documented-vs-actual matrix covering the happy path, 400 (non-UUID, negative, blank reason), 401, 403, 404, and the grant reading back through the events endpoint.

Implements items 1-3 of recoupable/chat#1948.


Summary by cubic

Adds an admin endpoint to set an account’s credit balance and record who and why, and surfaces those grants in the events API. Requires credit_grants and grant_credits_with_audit in the database; implements items 1–3 of recoupable/chat#1948.

  • New Features

    • POST /api/admins/credits: sets remaining_credits, records granted_by from credentials and reason, returns expires_at.
    • Admin-only via validateAdminAuth; balance move and audit run together via grant_credits_with_audit; returns 404 for unknown account_id.
    • GET /api/admins/credits/events: adds a grants array for the same account/period; not counted in total_count.
  • Bug Fixes

    • Clamp expires_at to the last day of the target month and use UTC so it never overstates the reset window.
    • Stabilize selectCreditGrants ordering with an id DESC tiebreaker.
    • Remove the unused optional timestamp param from insertCreditsUsage.

Written for commit 758725c. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added an admin endpoint for granting account credits.
    • Added validation for credit amounts, account details, and grant reasons.
    • Credit grants now include audit information and expiration dates.
    • Credit history responses now include grant records alongside usage events.
  • Bug Fixes

    • Improved handling of malformed requests, unknown accounts, and unexpected errors.
    • Preserved existing credit usage behavior when no timestamp is provided.

… (chat#1948)

Every route under /api/admins/credits was a GET, so topping up an account
meant a direct database write with no record of who did it or why. 155
accounts sit at or below zero with no supported way to unblock any of them.

- POST /api/admins/credits sets an absolute balance, gated on Recoup org
  membership via validateAdminAuth. reason is required and non-empty;
  granted_by comes from credentials and a granted_by in the body is ignored.
- The balance move and the grant record go through the new
  grant_credits_with_audit function so they cannot drift apart. A balance
  that moved without the grant explaining it is the exact failure this
  endpoint exists to remove.
- The response carries expires_at, so the one-month reset window is a value
  the caller can act on rather than a footnote.
- GET /api/admins/credits/events returns a grants array beside events, so a
  hand-set balance is attributable through the endpoint that already answers
  "where did this account's credits go".
- insertCreditsUsage takes an optional timestamp so a grant that creates the
  first credits row starts the refill clock at a known moment. Omitted by
  every existing caller, whose behaviour is unchanged.

Implements items 1-3 of recoupable/chat#1948.
Contract: recoupable/docs#295. Schema: recoupable/database#55.
@cursor

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

@vercel

vercel Bot commented Aug 6, 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 6, 2026 11:51pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 24 minutes

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: c5711e74-bb94-41d2-b109-884ef90014d5

📥 Commits

Reviewing files that changed from the base of the PR and between b40aeaa and 758725c.

⛔ Files ignored due to path filters (3)
  • lib/admins/credits/__tests__/validateGrantCreditsRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/credits/__tests__/getGrantExpiresAt.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/credit_grants/__tests__/selectCreditGrants.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (2)
  • lib/credits/getGrantExpiresAt.ts
  • lib/supabase/credit_grants/selectCreditGrants.ts
📝 Walkthrough

Walkthrough

The PR adds an authenticated admin credit-grant endpoint. It validates requests, records audited balance updates, calculates grant expiration, and returns credit grants alongside usage events.

Changes

Admin credit management

Layer / File(s) Summary
Request validation and API route
app/api/admins/credits/route.ts, lib/admins/credits/validateGrantCreditsRequest.ts
The API route handles CORS preflight and POST requests. Validation authenticates administrators, parses JSON, validates grant fields, and normalizes the request.
Audited credit grant flow
lib/admins/credits/postAdminCreditsHandler.ts, lib/supabase/credit_grants/grantCreditsWithAudit.ts, lib/credits/getGrantExpiresAt.ts, lib/supabase/credits_usage/insertCreditsUsage.ts
The handler verifies the account, records an audited credit grant through Supabase, calculates expiration, and returns grant details. Usage insertion accepts an optional timestamp.
Grant history retrieval
lib/admins/credits/getAdminCreditsEventsHandler.ts, lib/supabase/credit_grants/selectCreditGrants.ts
The events handler retrieves account grants separately from paginated usage events and their count. Grant queries support a cutoff, newest-first ordering, and a 500-row limit.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AdminClient
  participant AdminCreditsRoute
  participant postAdminCreditsHandler
  participant validateGrantCreditsRequest
  participant grantCreditsWithAudit
  participant Supabase
  AdminClient->>AdminCreditsRoute: POST credit grant
  AdminCreditsRoute->>postAdminCreditsHandler: Forward request
  postAdminCreditsHandler->>validateGrantCreditsRequest: Authenticate and validate
  validateGrantCreditsRequest-->>postAdminCreditsHandler: Normalized request
  postAdminCreditsHandler->>grantCreditsWithAudit: Grant credits with audit data
  grantCreditsWithAudit->>Supabase: Call grant_credits_with_audit RPC
  Supabase-->>grantCreditsWithAudit: Recorded grant row
  grantCreditsWithAudit-->>postAdminCreditsHandler: Grant details
  postAdminCreditsHandler-->>AdminClient: Grant response with expiration
Loading

Possibly related issues

  • recoupable/chat#1948 — The PR adds the requested admin credit-grant endpoint, audit trail, atomic balance update, grant listing, and expiration behavior.

Poem

Credits flow with records bright,
An audit trail keeps grants in sight.
Validation guards each gate,
Expiration marks their date.
Usage and grants now share the view.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning New postAdminCreditsHandler spans 46 lines and validateGrantCreditsRequest spans 37 lines; both combine orchestration, response construction, and error handling beyond the 20-line guideline. Refactor request parsing/error responses and grant response mapping into focused helpers, leaving each handler and validator within the stated size and responsibility limits.
✅ 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 feat/admin-grant-credits

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.

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
lib/supabase/credit_grants/grantCreditsWithAudit.ts (1)

32-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename this Supabase operation to use an allowed operation prefix.

grantCreditsWithAudit.ts uses the grant prefix. Rename the file and exported function to an update* operation name, such as updateCreditGrantsWithAudit, and update its imports.

As per coding guidelines and path instructions, “Name Supabase operation files using select[TableName].ts, insert[TableName].ts, update[TableName].ts, delete[TableName].ts, or get[Descriptive].ts as appropriate.”

🤖 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 `@lib/supabase/credit_grants/grantCreditsWithAudit.ts` around lines 32 - 52,
Rename the Supabase operation file and exported function grantCreditsWithAudit
to updateCreditGrantsWithAudit, preserving its existing RPC behavior and return
handling. Update every import and call site to use the new symbol and align the
filename with the allowed update[TableName] operation convention.

Sources: Coding guidelines, Path instructions

lib/admins/credits/validateGrantCreditsRequest.ts (1)

37-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split the oversized request-flow functions into focused helpers.

Both functions exceed the 20-line limit and combine orchestration with parsing, lookup, or response construction.

  • lib/admins/credits/validateGrantCreditsRequest.ts#L37-L73: extract JSON parsing and validation-error response creation into private helpers.
  • lib/admins/credits/postAdminCreditsHandler.ts#L17-L62: extract account lookup and grant-response serialization into private helpers.

As per coding guidelines, “Flag functions longer than 20 lines” and “Keep functions small and focused.”

🤖 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 `@lib/admins/credits/validateGrantCreditsRequest.ts` around lines 37 - 73,
Split the oversized request flows into focused private helpers: in
lib/admins/credits/validateGrantCreditsRequest.ts lines 37-73, extract JSON
parsing and validation-error response construction while preserving existing
responses and orchestration; in lib/admins/credits/postAdminCreditsHandler.ts
lines 17-62, extract account lookup and grant-response serialization into
private helpers, keeping the handler’s behavior unchanged.

Source: Coding guidelines

lib/admins/credits/getAdminCreditsEventsHandler.ts (2)

30-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split the handler before adding more retrieval logic.

getAdminCreditsEventsHandler spans 47 lines and combines validation, period calculation, pagination, three database reads, response construction, and error handling. Extract the parallel data retrieval or response construction into a focused helper.

As per coding guidelines: “Flag functions longer than 20 lines” and “Keep functions small and focused.”

🤖 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 `@lib/admins/credits/getAdminCreditsEventsHandler.ts` around lines 30 - 42,
Split getAdminCreditsEventsHandler into focused helpers, extracting either the
parallel database retrieval or the response construction while keeping
validation, period calculation, pagination, and error handling behavior
unchanged. Ensure the handler delegates to the new helper and remains within the
small-function guideline.

Source: Coding guidelines


30-42: 🩺 Stability & Availability | 🔵 Trivial

Verify database#55 is deployed before this endpoint.

selectCreditGrants now runs for every request. If the credit_grants migration is absent, the selector rethrows the query error and this handler returns 500, including for accounts with no grants. Complete migration and preview verification before deployment.

As per PR objectives: preview verification remains pending database#55.

🤖 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 `@lib/admins/credits/getAdminCreditsEventsHandler.ts` around lines 30 - 42,
Before enabling the `selectCreditGrants` call in the `Promise.all` within the
handler, verify that database migration `#55` is deployed and confirmed in
preview; do not deploy this endpoint while the `credit_grants` migration remains
unavailable.
🤖 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 `@lib/admins/credits/validateGrantCreditsRequest.ts`:
- Around line 7-18: Remove account_id from grantCreditsBodySchema and update the
grant-credits handler contract to obtain the target account exclusively from
validated authentication context or another trusted server-side workflow. Ensure
account selection no longer depends on user-supplied request-body data while
preserving validation for remaining_credits and reason.

In `@lib/supabase/credits_usage/insertCreditsUsage.ts`:
- Around line 27-30: Update the timestamp conditional in the insert payload of
insertCreditsUsage to check specifically for undefined rather than truthiness,
ensuring supplied empty-string values are preserved and validated by the
database instead of omitted.

---

Nitpick comments:
In `@lib/admins/credits/getAdminCreditsEventsHandler.ts`:
- Around line 30-42: Split getAdminCreditsEventsHandler into focused helpers,
extracting either the parallel database retrieval or the response construction
while keeping validation, period calculation, pagination, and error handling
behavior unchanged. Ensure the handler delegates to the new helper and remains
within the small-function guideline.
- Around line 30-42: Before enabling the `selectCreditGrants` call in the
`Promise.all` within the handler, verify that database migration `#55` is deployed
and confirmed in preview; do not deploy this endpoint while the `credit_grants`
migration remains unavailable.

In `@lib/admins/credits/validateGrantCreditsRequest.ts`:
- Around line 37-73: Split the oversized request flows into focused private
helpers: in lib/admins/credits/validateGrantCreditsRequest.ts lines 37-73,
extract JSON parsing and validation-error response construction while preserving
existing responses and orchestration; in
lib/admins/credits/postAdminCreditsHandler.ts lines 17-62, extract account
lookup and grant-response serialization into private helpers, keeping the
handler’s behavior unchanged.

In `@lib/supabase/credit_grants/grantCreditsWithAudit.ts`:
- Around line 32-52: Rename the Supabase operation file and exported function
grantCreditsWithAudit to updateCreditGrantsWithAudit, preserving its existing
RPC behavior and return handling. Update every import and call site to use the
new symbol and align the filename with the allowed update[TableName] operation
convention.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: df639b84-be78-48b5-b4be-fab346cd3353

📥 Commits

Reviewing files that changed from the base of the PR and between a9b9393 and b40aeaa.

⛔ Files ignored due to path filters (8)
  • lib/admins/credits/__tests__/getAdminCreditsEventsHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/admins/credits/__tests__/postAdminCreditsHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/admins/credits/__tests__/validateGrantCreditsRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/credits/__tests__/getGrantExpiresAt.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/credit_grants/__tests__/grantCreditsWithAudit.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/credit_grants/__tests__/selectCreditGrants.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/credits_usage/__tests__/insertCreditsUsage.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • types/database.types.ts is excluded by none and included by none
📒 Files selected for processing (8)
  • app/api/admins/credits/route.ts
  • lib/admins/credits/getAdminCreditsEventsHandler.ts
  • lib/admins/credits/postAdminCreditsHandler.ts
  • lib/admins/credits/validateGrantCreditsRequest.ts
  • lib/credits/getGrantExpiresAt.ts
  • lib/supabase/credit_grants/grantCreditsWithAudit.ts
  • lib/supabase/credit_grants/selectCreditGrants.ts
  • lib/supabase/credits_usage/insertCreditsUsage.ts

Comment thread lib/admins/credits/validateGrantCreditsRequest.ts
Comment thread lib/supabase/credits_usage/insertCreditsUsage.ts Outdated

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

All reported issues were addressed across 16 files

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

Re-trigger cubic

Comment thread lib/credits/getGrantExpiresAt.ts Outdated
Comment thread lib/supabase/credits_usage/insertCreditsUsage.ts Outdated
Comment thread lib/admins/credits/__tests__/validateGrantCreditsRequest.test.ts
Comment thread lib/supabase/credit_grants/selectCreditGrants.ts Outdated
Comment thread lib/supabase/credits_usage/insertCreditsUsage.ts Outdated
Preview verification and bot review turned up three real defects.

1. expires_at overstated the guarantee for month-end grants. setMonth(+1)
   is not the inverse of the reset's setMonth(-1): a Jan 31 grant reported
   Mar 3 while the reset actually fires around Mar 2, so an admin was told
   the balance was safe a day longer than it was. Now clamped to the last
   day of the target month (Jan 31 -> Feb 28), which is at or before the
   real reset in every case. Erring late is the only direction that
   misleads, so the tests pin that as a property across six dates, checked
   against a replica of the refill predicate. UTC accessors so the result
   does not move with the host timezone. (cubic P2)

2. insertCreditsUsage's optional timestamp was dead code. It was written
   before the INSERT branch moved into grant_credits_with_audit; nothing
   calls it with a timestamp now. Reverted the file and dropped its test.
   This also moots the truthiness-vs-undefined comment on the same line.
   (cubic P3, CodeRabbit)

3. selectCreditGrants ordered only by created_at, so grants written in the
   same instant came back in a non-deterministic order, which near the
   500-row cap changes which rows return at all. Added the id DESC
   tiebreaker the sibling selectUsageEvents already has. (cubic P3)

Also pins the 400 envelope in the validator tests, including
missing_fields being absent on unparseable JSON, so a field moving to
message cannot pass a status-only assertion. (cubic P3)

Verified live on the preview before these changes; re-verifying after.
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — 2026-08-06

Run against the preview built from this PR's head, with credit_grants + grant_credits_with_audit live on prod (database#55 merged and applied, schema_migrations at 20260806230000).

Two builds, because the first pass found real defects:

  • b40aeaaapi-ps1l1vvnd-recoup.vercel.app (initial)
  • 758725capi-2zgapyddk-recoup.vercel.app (after fixes; deployment sha confirmed)

Credentials: a Privy bearer for sweetmantech@gmail.com (fb678396…, isAdmin: true) and, for the negative paths, a non-admin bearer plus an API key minted on the preview via POST /api/agents/signup. Target was that throwaway agent+ account, never a customer.

Auth

Case Documented Actual
No credentials 401 ✅ 401 {status, error}
Non-admin bearer 403 ✅ 403 {status, message}
Non-admin API key 403 ✅ 403 {status, message}
Both credentials at once 401 ✅ 401
Non-admin + malformed body 403 ✅ 403 — auth wins, body never parsed

Happy path

Step Result
333 → 9999 ✅ 200, previous_credits: 333, granted_by: fb678396…
9999 → 250 previous_credits: 9999absolute set, not a delta
250 → 0 ✅ zero accepted, as documented
No credits row → 750 previous_credits: null, INSERT branch
reason: " padded " ✅ stored trimmed
granted_by in body ✅ ignored; actor came from credentials

Deliberate failure probes

All 400s, each with {status, error, missing_fields}: non-UUID account_id, missing account_id, negative / fractional / string / missing remaining_credits, empty / whitespace-only / missing reason. Malformed JSON returned {status, error} without missing_fields — exactly as the contract states. Unknown-but-valid UUID → 404 {status, message}.

Source of truth

Queried Postgres directly rather than trusting the responses:

  • Atomicity holds. credits_usage."timestamp" and credit_grants.created_at are byte-identical (2026-08-06 23:45:37.487294) — both writes committed in one transaction, which is the point of the RPC.
  • Nothing leaked from rejected calls. Exactly one grant row per successful call; 0 rows from the 12 rejected probes, and 0 for the 404 target.
  • The reset clock restarted. timestamp moved to the grant instant, so the balance gets its full month.
  • GET /api/accounts/{id}/credits returned the granted balance.

Read-back

GET /api/admins/credits/events returns all 5 grants, newest first, every one carrying an actor and a reason. total_count stayed 0 — it still describes usage_events only, as documented.


Three defects found, all fixed in 758725c

1. expires_at over-promised the guarantee (cubic P2 — valid). setMonth(+1) is not the inverse of the reset's setMonth(-1); day clamping makes them diverge. Verified in Node: a Jan 31 grant reported Mar 3 while the reset fires by Mar 2, so an admin was told the balance was safe a day longer than it was. Since the field is defined as "the point after which the balance is no longer guaranteed", late is the one direction that misleads. Now clamped to the last day of the target month (Jan 31 → Feb 28), on UTC accessors. Tests pin it as a property across six dates against a replica of the refill predicate. My original test's claim that the overflow "agrees with the check" was simply wrong.

Not observable in live traffic: granted_at is server-now, so only a month-end grant exercises the clamp. Unit-verified, not preview-verified.

2. Dead code (cubic P3 + CodeRabbit — valid). insertCreditsUsage's optional timestamp was written before the INSERT branch moved into grant_credits_with_audit. Nothing calls it with a timestamp. File reverted, test dropped — which also moots the truthiness-vs-undefined comment on that line.

3. Non-deterministic grant order (cubic P3 — valid). selectCreditGrants ordered only by created_at, so grants in the same instant returned in arbitrary order, and near the 500 cap that changes which rows come back. Added the id DESC tiebreaker selectUsageEvents already has.

Also pinned the 400 envelope in the validator tests, including missing_fields being absent on unparseable JSON (cubic P3).

One bot finding rejected

CodeRabbit, Major: "Do not accept account_id from the request body." Not applicable here. That guideline exists to stop a caller impersonating another account by choosing their own identity — and this endpoint honours it: granted_by is taken from credentials and a granted_by in the body is ignored, with a test pinning that. The body's account_id is the target of an admin action, not the caller, exactly as in the sibling GET /api/admins/credits/events?account_id=…. Deriving the target from auth would mean an admin could only ever grant credits to themselves, which defeats the endpoint. Left as-is.

Docs reconciliation

The live grants[] rows carry account_id, which the merged schema omitted — docs#296 adds it and updates expires_at for the clamp.

Gate after fixes

pnpm exec vitest run lib/admins lib/credits lib/supabase/credit_grants lib/supabase/credits_usage
  Test Files  45 passed (45)
       Tests  264 passed (264)

tsc --noEmit clean for this diff; eslint and prettier clean. Happy path and 403 re-confirmed on the 758725c build.

Test debris (not cleaned up)

Previews share the prod database, so this left: one agent+ account (4eb56302…) with its Privy user and API key, 5 credit_grants rows, and its credits_usage row. All inert and clearly labelled. Say the word and I will remove them.

@sweetmantech
sweetmantech merged commit e53242d into main Aug 6, 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