Auth: all routes use bearer JWT.
- User-scoped routes (
/users/{user_id}/...) require a user JWT wherejwt.sub == user_id. - Admin routes (flat
/accounts/...) require an admin JWT (Actor::AdminUser). Mutating an account (status, external id, details) and soft-delete are admin-only.
Overview
Theaccounts table holds accounts for any holder kind (today: user,
sponsor; extensible). Each row carries a single holder field encoded
as a URN string — user:<12-digit-id> or sponsor:<uuid>. The same
encoding is used everywhere: DB storage, API responses, admin filters,
audit logs.
Accounts are created with status = pending by default and soft-deleted
via admin DELETE (status flips to inactive; rows are never removed).
Holder encoding
| Holder | Wire format |
|---|---|
| User | user:012345678901 (12-digit numeric) |
| Sponsor | sponsor:0194e0f3-4b2a-7123-8f4a-9d5e2c8b1a3d (UUID) |
Account Types
| Value | Description | Legal holders |
|---|---|---|
savings | Real-world bank account | user, sponsor |
hsa | PBA-backed Health Savings Account | user |
education | PBA-backed education savings account | user |
sponsor | Sponsor’s contribution-funding wallet | sponsor |
(holder, account_type) legality is enforced at create time and at
list-filter time. An illegal combination returns 400 (AE-807)
rather than a silent empty list.
Auth Guards by Endpoint
| Endpoint | Auth | Notes |
|---|---|---|
POST /users/{user_id}/accounts | bearer (self) | User self-create |
GET /users/{user_id}/accounts | bearer (self) | Filter by status, account_type |
GET /users/{user_id}/accounts/{id} | bearer (self) | |
GET /accounts | admin | Filter by holder, holder_kind, status, account_type |
GET /accounts/{id} | admin | Fetch any account by UUID |
PATCH /accounts/{id} | admin | Update status, external_account_id, account_details |
DELETE /accounts/{id} | admin | Soft-delete (status → inactive) |
GET /users/{user_id}/balance | bearer (self) | PBA balance proxy |
GET /users/{user_id}/ledger | bearer (self) | PBA transaction ledger proxy |
user_id does not match jwt.sub.
Admin routes return 403 if the caller is not an admin.
Account Lifecycle
Soft delete only — rows persist for audit.Admin list filters
| Param | Type | Behaviour |
|---|---|---|
holder | URN string (user:<12d> / sponsor:<uuid>) | Exact-holder filter (btree equality) |
holder_kind | user | sponsor | Kind-scan filter; ignored when holder is set |
status | active | inactive | pending | |
account_type | savings | hsa | education | sponsor | Returns 400 if combined with a holder of an incompatible kind |
limit | i32 | default 50, max 200 |
offset | i32 | default 0 |
Request / Response Examples
Error Codes
| Code | HTTP | Description |
|---|---|---|
AE-800 | 500 | Internal server error |
AE-801 | 404 | Account not found |
AE-802 | 400 | Validation error |
AE-803 | 500 | Upstream PBA call failed |
AE-804 | 500 | PBA is not configured for this environment |
AE-805 | 404 | No PBA-backed account exists for user |
AE-806 | 404 | Holder not found |
AE-807 | 400 | (holder, account_type) combination is not allowed |