Authenticated user reads and updates their monthly spending limit (USD). The limit is stored in user_budget.spending_limit_usd (nullable = unlimited). Soft alert at 80 %, hard soft-stop at 100 % (toast + email if notification preferences allow). Requires a valid JWT access token.
spending_limit_usdisNUMERIC(10,2) NULLonuser_budget.nullmeans unlimited.- The spending limit is a soft cap — it does not block AI inference mid-stream. It triggers:
- 80 % alert: amber toast in FE + email (if
notificationBillingEnabled = truein preferences — defaults to false in MVP since notifications are mockup; email stub only). - 100 % alert: rose toast in FE + email (if preferences allow). Mara is soft-stopped (next conversation start is blocked with a
SPENDING_LIMIT_REACHEDerror) until the limit is raised or the month resets.
- 80 % alert: amber toast in FE + email (if
user_budgetuses a credit model (migration0011): columnsai_credit_usd(current remaining credit) andai_credit_initial_usd(initial credit amount). There is noused_centscolumn. The “current spent” value is derived:currentSpentUsd = ai_credit_initial_usd - ai_credit_usd. FE reads it from the existingGET /api/v1/users/me/budgetendpoint (UC-08006); this UC only adds GET/PUT for thespending_limit_usdfield.- Validation rule: new
spending_limit_usdmust be > current spent amount in USD (i.e.,> ai_credit_initial_usd - ai_credit_usd; cannot set a limit you’ve already exceeded).nullis always valid (clears the limit). - Alert thresholds are evaluated BE-side on every Anthropic gateway call (UC-08001) after writing to
api_usage_ledgerand deducting fromuser_budget.ai_credit_usd. This UC documents the user-facing GET/PUT endpoints only. - DB migration:
0024-add-spending-limit-to-user-budget.xml(new file, immutable once applied). - Related: UC-08001 — cost tracking. UC-08006 — existing budget view.
sequenceDiagram
actor User
%% --- GET spending limit ---
User->>+FE: opens Profile → Billing & Usage section
FE->>FE: onMounted — trigger billing data load
FE->>+BE: GET /api/v1/users/me/billing/spending-limit <br> Authorization: Bearer {accessToken}
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>DB: SELECT spending_limit_usd, ai_credit_usd, ai_credit_initial_usd FROM user_budget WHERE user_id = userId
BE->>-FE: 200 OK <br> SpendingLimitResponse
FE->>-User: render spending limit input (value or "Unlimited" if null)
%% --- PUT spending limit ---
User->>+FE: changes spending limit input and clicks "Save"
FE->>FE: validate input (positive number or blank for unlimited)
alt input invalid
FE-->>User: show validation error
end
FE->>+BE: PUT /api/v1/users/me/billing/spending-limit <br> Authorization: Bearer {accessToken} <br> UpdateSpendingLimitRequest
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>BE: validate request
alt request is invalid
BE-->>FE: 400 Bad Request <br> ErrorResponse
end
BE->>DB: SELECT ai_credit_usd, ai_credit_initial_usd FROM user_budget WHERE user_id = userId
BE->>BE: derive currentSpentUsd = ai_credit_initial_usd - ai_credit_usd <br> check new limit > currentSpentUsd (if limit not null)
alt new limit <= currentSpentUsd
BE-->>FE: 400 Bad Request <br> ErrorResponse (SPENDING_LIMIT_BELOW_CURRENT_SPEND)
end
BE->>DB: UPDATE user_budget SET spending_limit_usd = newLimit WHERE user_id = userId
BE->>-FE: 200 OK <br> SpendingLimitResponse
FE->>-User: show "Spending limit updated" toast, update input value
GET Spending Limit
GET /api/v1/users/me/billing/spending-limit (no request body; authentication via JWT Bearer token)
200 OK SpendingLimitResponse (limit set):
{
"spendingLimitUsd": 100.00,
"currentSpentUsd": 23.45,
"alertThreshold80": false,
"alertThreshold100": false
}
200 OK SpendingLimitResponse (unlimited):
{
"spendingLimitUsd": null,
"currentSpentUsd": 23.45,
"alertThreshold80": null,
"alertThreshold100": null
}
Field notes:
spendingLimitUsd—null= unlimited; otherwise positive decimal.currentSpentUsd— current month spend in USD, derived asai_credit_initial_usd - ai_credit_usd. Always returned for FE to display progress.alertThreshold80/alertThreshold100—trueif the respective threshold has been crossed;nullwhen limit is null (unlimited). Used by FE to pre-fill the alert state on mount (e.g. if BE already sent the alert but FE re-mounted).
401 Unauthorized ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
PUT Spending Limit
PUT /api/v1/users/me/billing/spending-limit UpdateSpendingLimitRequest:
{
"spendingLimitUsd": 150.00
}
To clear the limit (set to unlimited):
{
"spendingLimitUsd": null
}
200 OK SpendingLimitResponse:
{
"spendingLimitUsd": 150.00,
"currentSpentUsd": 23.45,
"alertThreshold80": false,
"alertThreshold100": false
}
400 Bad Request (validation — format error) ErrorResponse:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "field": "spendingLimitUsd", "message": "must be a positive number" }
]
}
400 Bad Request (limit below current spend) ErrorResponse:
{
"status": 400,
"code": "SPENDING_LIMIT_BELOW_CURRENT_SPEND",
"message": "Spending limit cannot be lower than your current month spend of $23.45"
}
401 Unauthorized ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
Frontend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| spendingLimitUsd | optional; if provided: positive number | — | ^\d+(\.\d{1,2})?$ | Blank or empty input = null (unlimited). Reject negative and zero values. |
UX Guidelines
Spending limit row in BillingSection.vue:
Spending limit [$___100___] /month [Save]
(blank = unlimited)
- Input type
number,min="0",step="1"(whole dollars only in MVP). Placeholder: “Unlimited”. - “Save” button visible only when value changed from loaded value.
- After successful PUT: toast (green, 3 s): “Spending limit updated.”
Alert banners (rendered in BillingSection.vue above the usage content):
| Threshold | Condition | Display |
|---|---|---|
| 80 % | currentSpentUsd >= spendingLimitUsd * 0.8 | Amber banner: “You’ve used {pct}% of your ${limit} spending limit this month.” |
| 100 % | currentSpentUsd >= spendingLimitUsd | Rose banner: “You’ve reached your spending limit. New conversations are paused until the limit is raised or the month resets.” |
Banners are re-evaluated after each successful PUT (reactively from SpendingLimitResponse).
Email alert (BE-side, stub in MVP):
- At 80 %:
POST /api/internal/notifications/spending-alert(internal; logs only in MVP). - At 100 %: same stub; Mara soft-stop triggered separately by gateway check in UC-08001.
Backend
DB Migration
New migration file: 0024-add-spending-limit-to-user-budget.xml
<changeSet id="0024-add-spending-limit-to-user-budget" author="system">
<addColumn tableName="user_budget">
<column name="spending_limit_usd" type="NUMERIC(10,2)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
Validations
| Field | Constraints | Note |
|---|---|---|
JWT Authorization header | not_blank, valid signature, not expired | 401 if missing/invalid |
spendingLimitUsd (PUT) | optional; if not null: positive (> 0), max 2 decimal places | 400 VALIDATION_ERROR if negative or zero |
| Business rule | spendingLimitUsd (if not null) must be > (ai_credit_initial_usd - ai_credit_usd) | 400 SPENDING_LIMIT_BELOW_CURRENT_SPEND |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Authenticated user with spending_limit_usd = null | GET /spending-limit is called | 200 OK; spendingLimitUsd=null, currentSpentUsd reflects (ai_credit_initial_usd - ai_credit_usd), alertThreshold fields=null |
| Authenticated user with spending_limit_usd = 100.00, ai_credit_initial_usd = 50.00, ai_credit_usd = 26.55 (spent 23.45) | GET /spending-limit is called | 200 OK; spendingLimitUsd=100.00, currentSpentUsd=23.45, alertThreshold80=false, alertThreshold100=false |
| Authenticated user with spending_limit_usd = 100.00, ai_credit_initial_usd = 100.00, ai_credit_usd = 15.00 (spent 85.00, 85% threshold) | GET /spending-limit is called | 200 OK; alertThreshold80=true, alertThreshold100=false |
| Authenticated user, current spend $23.45, new limit $150.00 | PUT /spending-limit is called | 200 OK; spending_limit_usd updated to 150.00 in DB; SpendingLimitResponse returned |
| Authenticated user, current spend $23.45, new limit $20.00 (below spend) | PUT /spending-limit is called | 400 SPENDING_LIMIT_BELOW_CURRENT_SPEND returned |
spendingLimitUsd = 0 | PUT /spending-limit is called | 400 VALIDATION_ERROR (must be positive) returned |
spendingLimitUsd = -10 | PUT /spending-limit is called | 400 VALIDATION_ERROR (must be positive) returned |
spendingLimitUsd = null | PUT /spending-limit is called | 200 OK; spending_limit_usd set to null (unlimited); alertThreshold fields null in response |
| No Authorization header | GET /spending-limit or PUT /spending-limit is called | 401 AUTHENTICATION_FAILED returned |
| Authenticated user, spending limit reached (used >= limit) | next conversation start is attempted (UC-04002) | Mara gateway check raises SPENDING_LIMIT_REACHED; conversation cannot start until limit raised or month resets |
Thanks for the feedback.