Auth: all routes use bearer JWT.
  • User-scoped routes (/users/{user_id}/...) require a user JWT where jwt.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

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

HolderWire format
Useruser:012345678901 (12-digit numeric)
Sponsorsponsor:0194e0f3-4b2a-7123-8f4a-9d5e2c8b1a3d (UUID)
Validation runs at every entry point — JSON deserialization, DB row reads, admin query params. A malformed value returns 400 with the parse error.

Account Types

ValueDescriptionLegal holders
savingsReal-world bank accountuser, sponsor
hsaPBA-backed Health Savings Accountuser
educationPBA-backed education savings accountuser
sponsorSponsor’s contribution-funding walletsponsor
(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

EndpointAuthNotes
POST /users/{user_id}/accountsbearer (self)User self-create
GET /users/{user_id}/accountsbearer (self)Filter by status, account_type
GET /users/{user_id}/accounts/{id}bearer (self)
GET /accountsadminFilter by holder, holder_kind, status, account_type
GET /accounts/{id}adminFetch any account by UUID
PATCH /accounts/{id}adminUpdate status, external_account_id, account_details
DELETE /accounts/{id}adminSoft-delete (status → inactive)
GET /users/{user_id}/balancebearer (self)PBA balance proxy
GET /users/{user_id}/ledgerbearer (self)PBA transaction ledger proxy
User-scoped routes return 403 if the path 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

ParamTypeBehaviour
holderURN string (user:<12d> / sponsor:<uuid>)Exact-holder filter (btree equality)
holder_kinduser | sponsorKind-scan filter; ignored when holder is set
statusactive | inactive | pending
account_typesavings | hsa | education | sponsorReturns 400 if combined with a holder of an incompatible kind
limiti32default 50, max 200
offseti32default 0

Request / Response Examples

curl -X POST http://localhost:8080/users/047382910564/accounts \
  -H 'Authorization: Bearer eyJhbGci...' \
  -H 'Content-Type: application/json' \
  -d '{
    "external_account_id": "12345678",
    "account_type": "SAVINGS",
    "account_details": {
      "account_type": "SAVINGS",
      "ifsc_code": "HDFC0001234"
    }
  }'

Error Codes

CodeHTTPDescription
AE-800500Internal server error
AE-801404Account not found
AE-802400Validation error
AE-803500Upstream PBA call failed
AE-804500PBA is not configured for this environment
AE-805404No PBA-backed account exists for user
AE-806404Holder not found
AE-807400(holder, account_type) combination is not allowed