docs(admins): POST /api/admins/credits grant contract (chat#1948) - #295
Conversation
There is no staff-facing way to grant an account credits today — every route under /api/admins/credits is a GET, so a top-up means a direct database write with no record of who did it or why. Documents the write side first, per docs-leads-api sequencing: - POST /api/admins/credits — sets an absolute balance (not a delta), requires a non-empty reason, and records the acting admin. 400/401/ 403/404/500 documented against the shapes the handler will actually return, reusing ErrorEnvelope and AccountErrorResponse. - The monthly-reset caveat is stated plainly rather than left to be discovered: a grant restarts the reset clock, holds until expires_at (one month out), and is then silently overwritten with the plan total on the first balance read after that. The response carries expires_at so the expiry is machine-readable, not just prose. - GET /api/admins/credits/events grows a `grants` array, so a grant is auditable through the same endpoint that already answers "where did this account's credits go". Implements items 1-3 of recoupable/chat#1948.
|
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. |
📝 WalkthroughWalkthroughThe API reference adds an admin credit-grant operation, grant audit schemas, and validation responses. Credit event responses now include capped, period-filtered admin grants. A new documentation page is linked in the Admins navigation. ChangesAdmin credit grants
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@api-reference/openapi/accounts.json`:
- Around line 3214-3218: Update previous_credits in both
AdminGrantCreditsResponse and EventsAdminCreditsGrantListedResponse to use
OpenAPI 3.1 type-union null semantics, replacing the nullable keyword with an
integer/null type declaration while preserving the existing description.
- Around line 2912-2916: Add a regex pattern constraint to the reason property
in the grant schema, alongside minLength, requiring at least one non-whitespace
character so whitespace-only strings are rejected by generated clients and
validators.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9c83abae-382f-488f-9882-38b434fe48dc
📒 Files selected for processing (3)
api-reference/admins/credits-grant.mdxapi-reference/openapi/accounts.jsondocs.json
| "reason": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "description": "Why the grant was made, in plain language — e.g. `Trial headroom for the Aug 12 label demo`. Required and non-empty (a whitespace-only string is rejected): this is the field that makes a grant distinguishable from a Stripe top-up or a monthly reset when someone asks months later. Stored verbatim and returned by the events endpoint." | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Make the whitespace rule machine-checkable.
The description states that a whitespace-only reason is rejected. minLength: 1 accepts " ". Generated clients and schema validators will therefore accept a body the API rejects. Add a pattern that requires one non-whitespace character.
🔧 Proposed schema constraint
"reason": {
"type": "string",
"minLength": 1,
+ "pattern": "\\S",
"description": "Why the grant was made, in plain language — e.g. `Trial headroom for the Aug 12 label demo`. Required and non-empty (a whitespace-only string is rejected): this is the field that makes a grant distinguishable from a Stripe top-up or a monthly reset when someone asks months later. Stored verbatim and returned by the events endpoint."
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "reason": { | |
| "type": "string", | |
| "minLength": 1, | |
| "description": "Why the grant was made, in plain language — e.g. `Trial headroom for the Aug 12 label demo`. Required and non-empty (a whitespace-only string is rejected): this is the field that makes a grant distinguishable from a Stripe top-up or a monthly reset when someone asks months later. Stored verbatim and returned by the events endpoint." | |
| } | |
| "reason": { | |
| "type": "string", | |
| "minLength": 1, | |
| "pattern": "\\S", | |
| "description": "Why the grant was made, in plain language — e.g. `Trial headroom for the Aug 12 label demo`. Required and non-empty (a whitespace-only string is rejected): this is the field that makes a grant distinguishable from a Stripe top-up or a monthly reset when someone asks months later. Stored verbatim and returned by the events endpoint." | |
| } |
🤖 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 `@api-reference/openapi/accounts.json` around lines 2912 - 2916, Add a regex
pattern constraint to the reason property in the grant schema, alongside
minLength, requiring at least one non-whitespace character so whitespace-only
strings are rejected by generated clients and validators.
| "previous_credits": { | ||
| "type": "integer", | ||
| "nullable": true, | ||
| "description": "Balance immediately before the grant. Null when the account had no credits row and the grant created one." | ||
| }, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Report the declared OpenAPI version and count nullable idioms in the spec.
rg -n '"openapi"' api-reference/openapi/accounts.json
echo "--- nullable: true occurrences ---"
rg -c '"nullable": true' api-reference/openapi/accounts.json
echo "--- type array with null occurrences ---"
rg -c '"null"' api-reference/openapi/accounts.jsonRepository: recoupable/docs
Length of output: 258
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "--- relevant schema lines for nullable/null integer-like schemas ---"
rg -n -A2 -B2 '"previous_credits"|"oneOf"|"type": \["string", "null"\]|"type": "null"|previousCredits|PreviousCredits' api-reference/openapi/accounts.json | sed -n '1,240p'
echo "--- all nullable true occurrences with context ---"
rg -n -C1 '"nullable": true' api-reference/openapi/accounts.json | sed -n '1,240p'
echo "--- first 40 lines for OpenAPI version ---"
sed -n '1,40p' api-reference/openapi/accounts.jsonRepository: recoupable/docs
Length of output: 6777
Use OpenAPI 3.1 null semantics for nullable integers.
accounts.json declares "openapi": "3.1.0", where nullable is not a schema keyword. previous_credits in both AdminGrantCreditsResponse and EventsAdminCreditsGrantListedResponse should use a null union such as {"type": ["integer", "null"]} so nullable tooling does not treat null as invalid.
🤖 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 `@api-reference/openapi/accounts.json` around lines 3214 - 3218, Update
previous_credits in both AdminGrantCreditsResponse and
EventsAdminCreditsGrantListedResponse to use OpenAPI 3.1 type-union null
semantics, replacing the nullable keyword with an integer/null type declaration
while preserving the existing description.
There was a problem hiding this comment.
3 issues found across 3 files
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="api-reference/openapi/accounts.json">
<violation number="1" location="api-reference/openapi/accounts.json:2914">
P2: The `AdminGrantCreditsRequest.reason` schema only sets `minLength: 1`, which accepts whitespace-only strings (a single space has length 1), while the same field's description and the endpoint prose state “a whitespace-only string is rejected” and that non-empty `reason` is required for the audit trail. Consumers validating against this schema will accept `" "`, leaving an unverifiable audit reason. Add a pattern requiring at least one non-whitespace character so the schema matches the documented guarantee.</violation>
<violation number="2" location="api-reference/openapi/accounts.json:2952">
P3: This schema is declared as OpenAPI 3.1.0, which uses JSON Schema 2020-12 semantics where `nullable` is not a valid keyword (it was removed in favor of type unions). `previous_credits` here should use `"type": ["integer", "null"]` instead of `"type": "integer", "nullable": true`, otherwise JSON Schema-aware tooling and generated clients may reject valid null values.</violation>
<violation number="3" location="api-reference/openapi/accounts.json:3200">
P2: The grant timestamp is named inconsistently across the two contracts that are explicitly meant to correlate. `POST /api/admins/credits` returns it as `granted_at` (required), while the `grants` array item in `GET /api/admins/credits/events` calls the same value `created_at`. The POST `grant_id` doc even says it "Matches the `id` of the corresponding entry in the `grants` array", so a client reconciles these two representations, but the field name changes for the same timestamp. In addition, `expires_at` is a required field in the POST response yet is absent from the `grants` array item that is described as the "write-side counterpart" — so a consumer reading the events history cannot recover when a grant expires. Consider aligning the `grants` item to use `granted_at`/`expires_at` (or explicitly documenting the `created_at` naming) so the two endpoints form one consistent audit contract.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| }, | ||
| "reason": { | ||
| "type": "string", | ||
| "minLength": 1, |
There was a problem hiding this comment.
P2: The AdminGrantCreditsRequest.reason schema only sets minLength: 1, which accepts whitespace-only strings (a single space has length 1), while the same field's description and the endpoint prose state “a whitespace-only string is rejected” and that non-empty reason is required for the audit trail. Consumers validating against this schema will accept " ", leaving an unverifiable audit reason. Add a pattern requiring at least one non-whitespace character so the schema matches the documented guarantee.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api-reference/openapi/accounts.json, line 2914:
<comment>The `AdminGrantCreditsRequest.reason` schema only sets `minLength: 1`, which accepts whitespace-only strings (a single space has length 1), while the same field's description and the endpoint prose state “a whitespace-only string is rejected” and that non-empty `reason` is required for the audit trail. Consumers validating against this schema will accept `" "`, leaving an unverifiable audit reason. Add a pattern requiring at least one non-whitespace character so the schema matches the documented guarantee.</comment>
<file context>
@@ -2809,6 +2895,108 @@
+ },
+ "reason": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Why the grant was made, in plain language — e.g. `Trial headroom for the Aug 12 label demo`. Required and non-empty (a whitespace-only string is rejected): this is the field that makes a grant distinguishable from a Stripe top-up or a monthly reset when someone asks months later. Stored verbatim and returned by the events endpoint."
+ }
</file context>
| "minLength": 1, | |
| "minLength": 1, | |
| "pattern": ".*\\S.*", |
| "format": "uuid", | ||
| "description": "UUID of the grant row — the `grant_id` returned by `POST /api/admins/credits`" | ||
| }, | ||
| "created_at": { |
There was a problem hiding this comment.
P2: The grant timestamp is named inconsistently across the two contracts that are explicitly meant to correlate. POST /api/admins/credits returns it as granted_at (required), while the grants array item in GET /api/admins/credits/events calls the same value created_at. The POST grant_id doc even says it "Matches the id of the corresponding entry in the grants array", so a client reconciles these two representations, but the field name changes for the same timestamp. In addition, expires_at is a required field in the POST response yet is absent from the grants array item that is described as the "write-side counterpart" — so a consumer reading the events history cannot recover when a grant expires. Consider aligning the grants item to use granted_at/expires_at (or explicitly documenting the created_at naming) so the two endpoints form one consistent audit contract.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api-reference/openapi/accounts.json, line 3200:
<comment>The grant timestamp is named inconsistently across the two contracts that are explicitly meant to correlate. `POST /api/admins/credits` returns it as `granted_at` (required), while the `grants` array item in `GET /api/admins/credits/events` calls the same value `created_at`. The POST `grant_id` doc even says it "Matches the `id` of the corresponding entry in the `grants` array", so a client reconciles these two representations, but the field name changes for the same timestamp. In addition, `expires_at` is a required field in the POST response yet is absent from the `grants` array item that is described as the "write-side counterpart" — so a consumer reading the events history cannot recover when a grant expires. Consider aligning the `grants` item to use `granted_at`/`expires_at` (or explicitly documenting the `created_at` naming) so the two endpoints form one consistent audit contract.</comment>
<file context>
@@ -2981,6 +3178,51 @@
+ "format": "uuid",
+ "description": "UUID of the grant row — the `grant_id` returned by `POST /api/admins/credits`"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
</file context>
| "type": "integer", | ||
| "description": "The balance the account now holds — the value supplied in the request" | ||
| }, | ||
| "previous_credits": { |
There was a problem hiding this comment.
P3: This schema is declared as OpenAPI 3.1.0, which uses JSON Schema 2020-12 semantics where nullable is not a valid keyword (it was removed in favor of type unions). previous_credits here should use "type": ["integer", "null"] instead of "type": "integer", "nullable": true, otherwise JSON Schema-aware tooling and generated clients may reject valid null values.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api-reference/openapi/accounts.json, line 2952:
<comment>This schema is declared as OpenAPI 3.1.0, which uses JSON Schema 2020-12 semantics where `nullable` is not a valid keyword (it was removed in favor of type unions). `previous_credits` here should use `"type": ["integer", "null"]` instead of `"type": "integer", "nullable": true`, otherwise JSON Schema-aware tooling and generated clients may reject valid null values.</comment>
<file context>
@@ -2809,6 +2895,108 @@
+ "type": "integer",
+ "description": "The balance the account now holds — the value supplied in the request"
+ },
+ "previous_credits": {
+ "type": "integer",
+ "nullable": true,
</file context>
Local preview verification — 2026-08-06Ran Results
The three error envelopes render distinctlyThis was the point of not reusing one error schema everywhere, so I checked each tab's rendered body rather than trusting the
Each matches what the handler in api#824 actually returns — ScreenshotsFull page — description, request schema, response schema Status-code tabs, 404 selected — 400 selected — the distinct validation envelope with
Not covered
|
…chat#1948) (#55) * feat: credit_grants - who set an account's balance by hand, and why (chat#1948) Topping up an account today means a direct write to credits_usage, which records nothing about who did it or what for. A grant is indistinguishable from a Stripe top-up or a monthly reset minutes afterwards. credits_usage is current state and a grant is an event, so this is a new table rather than columns there — columns would only ever hold the most recent grant. usage_events is the wrong home too: it is deduction-shaped (tokens, provider, model_id) with no actor and no reason column. The two constraints that carry the intent: granted_by is ON DELETE RESTRICT so attribution cannot be silently orphaned, and reason is CHECK-constrained non-empty after trimming so a blank reason is rejected by the schema rather than only by Zod. Implements item 2 of recoupable/chat#1948. Contract: recoupable/docs#295. * feat: grant_credits_with_audit - atomic balance set + grant record (chat#1948) Two separate PostgREST calls (move the balance, then insert the grant) can drift on partial failure. deduct_credits_with_audit exists for that exact reason on the debit side. Here the drift is worse than an accounting hiccup: a moved balance with no grant row is the untraceable write this change exists to eliminate, and would be indistinguishable from the hand-written database updates being replaced. A plpgsql body runs in an implicit transaction. previous_credits is captured inside the function so it cannot be stale by the time the balance moves, and the timestamp bump that restarts the monthly-reset clock happens in the same transaction as the grant.
… (chat#1948) (#824) * feat(admins): POST /api/admins/credits sets a balance and records why (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. * fix(credits): clamp grant expiry, drop dead param, stabilise grant order 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.
…h end (chat#1948) (#296) Two corrections found by verifying api#824 against the live preview, the step where the documented contract meets what the endpoint actually returns. - Each entry in the events `grants` array is the full stored grant row, so it carries `account_id`. The schema omitted it. It always equals the top-level `account_id`, but documenting only six of the seven fields makes the contract wrong rather than merely terse. - `expires_at` is now clamped to the last day of a short target month. The naive one-month add overflowed (Jan 31 -> Mar 3) and landed after the reset actually fires, so the API was over-promising the guarantee window by a day. The description now states the clamp and the direction of the remaining error: it can under-promise, never over-promise. Follows #295. Verified against api#824.




Contract for the staff-facing credit grant. First of three PRs for chat#1948; docs leads, per house sequencing.
Merge order: this PR → recoupable/database (the
credit_grantstable) → recoupable/api (the endpoint).Why
Every route under
/api/admins/creditsis aGET. The only writers tocredits_usageare internal libs reachable from the Stripe webhook and the credits read path, so topping up an account today means a direct database write — which leaves no record of who did it or why. 155 accounts are sitting at or below zero with no supported way for staff to unblock any of them.What this documents
POST /api/admins/creditsGET /api/admins/credits/eventsgrantsarray — the write-side counterpart toevents.Three new schemas:
AdminGrantCreditsRequest,AdminGrantCreditsResponse,AdminGrantCreditsValidationError. 401 reuses the existingErrorEnvelope, 403/404/500 reuseAccountErrorResponserather than adding near-duplicates.Decisions worth reviewing
Absolute, not a delta.
remaining_creditsis the balance the account is left holding.checkAndResetCreditsis already SET-shaped and that has caused repeated confusion; an endpoint whose result depends on the balance at the moment the request lands would compound it. No add/subtract variant is documented.reasonis required and must be non-empty. Auditability is the point of the endpoint, not convenience — an endpoint that writes the same untraceable row has only made the untraceable write easier. Whitespace-only is rejected.The one-month expiry is documented, not buried.
checkAndResetCreditsresets acredits_usagerow older than a month to the plan total, and it fires fromGET /api/accounts/{id}/credits— so reading a balance mutates it. A grant restarts that clock, and the docs state exactly when the balance stops being guaranteed (expires_at, returned in the response) and what to do instead (re-grant, or put the account on a pro subscription). Worth notingisProis derived from Stripe, so an account with no Stripe customer resets to 333, not 9,999.grantsis not paginated.page/limit/total_countcontinue to describeusage_eventsonly;grantsis capped at the 500 most recent in the period. Grants are rare — the array is empty for nearly every account. Documented on the field rather than left ambiguous.Verification
accounts.jsonanddocs.jsonboth parse; all 70$refs resolve; the diff is additive apart from three replaced lines (the events description, itsrequiredarray, and the nav entry above the new page).Implements items 1-3 of recoupable/chat#1948.
Summary by cubic
Documents
POST /api/admins/creditsto let admins set an account’s balance with an audited actor and reason, and adds grant visibility alongside usage events for better traceability.POST /api/admins/creditsas an absolute balance set (not a delta) with a required non-emptyreason.expires_atso expiry is machine-readable.GET /api/admins/credits/eventsto return agrantsarray (not paginated; capped at 500) alongsideevents.AdminGrantCreditsRequest,AdminGrantCreditsResponse,AdminGrantCreditsValidationError; reusedErrorEnvelopeandAccountErrorResponse.Written for commit 49b5d91. Summary will update on new commits.
Summary by CodeRabbit
New Features
Documentation