Skip to content

docs(admins): POST /api/admins/credits grant contract (chat#1948) - #295

Merged
sweetmantech merged 1 commit into
mainfrom
docs/admin-credits-grant
Aug 6, 2026
Merged

docs(admins): POST /api/admins/credits grant contract (chat#1948)#295
sweetmantech merged 1 commit into
mainfrom
docs/admin-credits-grant

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

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_grants table) → recoupable/api (the endpoint).

Why

Every route under /api/admins/credits is a GET. The only writers to credits_usage are 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/credits New. Sets an account's balance, records actor + reason.
GET /api/admins/credits/events Grows a grants array — the write-side counterpart to events.

Three new schemas: AdminGrantCreditsRequest, AdminGrantCreditsResponse, AdminGrantCreditsValidationError. 401 reuses the existing ErrorEnvelope, 403/404/500 reuse AccountErrorResponse rather than adding near-duplicates.

Decisions worth reviewing

Absolute, not a delta. remaining_credits is the balance the account is left holding. checkAndResetCredits is 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.

reason is 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. checkAndResetCredits resets a credits_usage row older than a month to the plan total, and it fires from GET /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 noting isPro is derived from Stripe, so an account with no Stripe customer resets to 333, not 9,999.

grants is not paginated. page/limit/total_count continue to describe usage_events only; grants is 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.json and docs.json both parse; all 70 $refs resolve; the diff is additive apart from three replaced lines (the events description, its required array, and the nav entry above the new page).

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


Summary by cubic

Documents POST /api/admins/credits to let admins set an account’s balance with an audited actor and reason, and adds grant visibility alongside usage events for better traceability.

  • New Features
    • Documented POST /api/admins/credits as an absolute balance set (not a delta) with a required non-empty reason.
    • Made the monthly reset behavior explicit; response includes expires_at so expiry is machine-readable.
    • Updated GET /api/admins/credits/events to return a grants array (not paginated; capped at 500) alongside events.
    • Added schemas: AdminGrantCreditsRequest, AdminGrantCreditsResponse, AdminGrantCreditsValidationError; reused ErrorEnvelope and AccountErrorResponse.
    • Added the “Grant Credits (Admin)” docs page and OpenAPI link.

Written for commit 49b5d91. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added an admin API endpoint to set an account’s credit balance.
    • Added validation, authentication, account-not-found, and server-error responses.
    • Credit grants now record the administrator, reason, previous balance, grant time, and expiration.
    • Credit event responses now include a capped list of grant records alongside usage events.
  • Documentation

    • Added API reference documentation for granting credits.
    • Added the new page to the Admins documentation navigation.

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

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

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

Changes

Admin credit grants

Layer / File(s) Summary
Grant endpoint contract
api-reference/openapi/accounts.json, api-reference/admins/credits-grant.mdx, docs.json
Adds POST /api/admins/credits with authentication, authorization, validation, account lookup, server-error responses, grant audit data, and navigation wiring.
Grant event reporting
api-reference/openapi/accounts.json
Documents admin grants in credit events and adds the required capped grants collection with grant IDs, timestamps, admin IDs, reasons, prior balances, and resulting balances.

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

Possibly related issues

  • recoupable/chat#1948 — Covers the staff-facing credit-grant endpoint, audit fields, event reporting, and documentation added here.

Suggested reviewers: arpitgupta1214

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the documented admin credit-grant endpoint and its contract.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/admin-credits-grant

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

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9a242ce and 49b5d91.

📒 Files selected for processing (3)
  • api-reference/admins/credits-grant.mdx
  • api-reference/openapi/accounts.json
  • docs.json

Comment on lines +2912 to +2916
"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."
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.

Suggested change
"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.

Comment on lines +3214 to +3218
"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."
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.json

Repository: 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.json

Repository: 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.

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

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,

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: 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>
Suggested change
"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": {

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: 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": {

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: 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>

@sweetmantech

Copy link
Copy Markdown
Collaborator Author

Local preview verification — 2026-08-06

Ran npx mintlify@latest dev on docs/admin-credits-grant @ 49b5d91 and drove the rendered site with Chrome DevTools MCP at 1440×1000.

Results

Check Expected Actual
New page renders POST /api/admins/credits reference page ✅ Title Grant Credits (Admin), resolved from the OpenAPI block
Nav entry Appears under Admins, after Credit Usage Events POST Grant Credits (Admin), last in the Admins group
Page URL /api-reference/admins/credits-grant ✅ HTTP 200
Request schema 3 required body fields account_id string<uuid>, remaining_credits integer (Required range x >= 0), reason string (Minimum string length 1)
Response schema 9 required fields incl. nullable prior balance ✅ all 9; previous_credits renders `integer
Status codes 200, 400, 401, 403, 404, 500 ✅ all six tabs present
Cross-link → events Resolves /api-reference/admins/credits-events HTTP 200
Cross-link → balance read Resolves /api-reference/accounts/credits-get HTTP 200
grants on events response New array with 6 subfields grants.id, created_at, granted_by, reason, previous_credits, remaining_credits
Console No errors ✅ Only Connected to Socket.io (dev-server hot reload)

The three error envelopes render distinctly

This was the point of not reusing one error schema everywhere, so I checked each tab's rendered body rather than trusting the \$refs:

Code Schema Rendered body
400 AdminGrantCreditsValidationError { status, error, missing_fields[] }
401 ErrorEnvelope { status, error }
403 / 404 / 500 AccountErrorResponse { status, message }

Each matches what the handler in api#824 actually returns — validateGrantCreditsRequest emits the Zod path as missing_fields, validateAuthContext emits error on 401, and validateAdminAuth/the handler emit message on 403/404/500.

Screenshots

Full page — description, request schema, response schema

Status-code tabs, 404 selected — { status, message }

400 selected — the distinct validation envelope with missing_fields

grants array on the existing events endpoint, child attributes expanded

Not covered

  • llms.txt — the local dev server returns the app shell for /llms.txt rather than the generated file, so the nav-entry-to-llms.txt path is unverified here. It generates at deploy from the docs.json entry, which is confirmed present.
  • Try it playground — not exercised; POST /api/admins/credits does not exist until api#824 ships, and it needs an admin key.
  • Light mode — captured in dark only.

@sweetmantech
sweetmantech merged commit 7b8890f into main Aug 6, 2026
3 checks passed
sweetmantech added a commit to recoupable/database that referenced this pull request Aug 6, 2026
…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.
sweetmantech added a commit to recoupable/api that referenced this pull request Aug 6, 2026
… (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.
sweetmantech added a commit that referenced this pull request Aug 7, 2026
…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.
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