Portfolio Module
Errors
Validation rules, status codes, and the unified error envelope across all portfolio endpoints
Every portfolio endpoint returns errors via the standard project envelope (internal/http/response.JSON).
Error envelope
{
"message": "<human-friendly summary>",
"errors": [{ "field": "<path>", "code": "<rule>", "message": "<rule>" }]
}errors[] is populated for validation failures. Other failures (404, 500) carry only message.
Content-Type: application/json always.
Status code map
| Code | Meaning |
|---|---|
| 400 | Validation error — bad UUID, malformed body, decode failure, body-too-large, validate-tag rule violated, cross-field consistency violated (e.g. amount ≠ qty × rate). |
| 401 | Missing or invalid JWT. The auth middleware rejects before the handler runs. |
| 404 | Resource not found. Always also returned for "not yours" — the server never reveals whether a UUID exists for another customer. |
| 422 | Business rule rejected after parse — used for SELL quantity exceeding current holding (the only 422 in the module). |
| 500 | Unexpected DB or programming error. The recoverer middleware turns panics into 500. |
There is no 403 in this module. "Not yours" is 404 by design.
Common 400 messages
These surface from the request-decode layer (handlerutil.DecodeAndValidate) before any service code runs:
| Cause | Message shape |
|---|---|
id path param not a UUID | Invalid <param> format |
| Body fails to JSON-decode | Malformed JSON |
Body exceeds BodyLimit | Request body too large |
validate:"required" field missing | <field> is required |
validate:"max=N" exceeded | <field> must not exceed N characters |
validate:"min=N" violated | <field> must be at least N |
validate:"oneof=A B" violated | <field> must be one of: A B |
validate:"uuid" violated | <field> must be a valid UUID |
validate:"iso8601" violated | <field> must be a valid ISO 8601 date (YYYY-MM-DD) or timestamp (RFC3339) |
Per-endpoint quick lookup
| Endpoint | 400 | 401 | 404 | 500 |
|---|---|---|---|---|
POST /portfolios | name missing/too long, description too long | yes | — | DB |
PATCH /portfolios/{id} | bad UUID, name/description too long, isDefault: false | yes | not yours | DB |
DELETE /portfolios/{id} | bad UUID | yes | not yours | DB |
| Endpoint | 400 | 401 | 404 | 422 | 500 |
|---|---|---|---|---|---|
POST /portfolios/{id}/transactions | type missing/unknown, per-type required field missing, transactedAt malformed/future, gross ≠ qty × rate, netAmount mismatch, CGT outside band | yes | portfolio | SELL exceeds holding | DB |
GET /portfolios/{id}/transactions | bad pagination, bad type/from/to | yes | portfolio | — | DB |
PATCH /portfolios/{id}/transactions/{txId} | same as Add (after merge) | yes | portfolio or transaction | SELL exceeds holding | DB |
DELETE /portfolios/{id}/transactions/{txId} | bad UUIDs | yes | portfolio or transaction | — | DB |
| Endpoint | 400 | 401 | 404 |
|---|---|---|---|
GET /portfolios | bad pagination | yes | — |
GET /portfolios/{id} | bad UUID | yes | not yours |
GET /portfolios/{id}/summary | bad UUID | yes | not yours |
GET /portfolios/{id}/holdings | bad UUID, pagination | yes | not yours |
GET /portfolios/{id}/distribution | bad UUID | yes | not yours |
GET /portfolios/{id}/valuation | bad UUID, range not in whitelist | yes | not yours |
GET /portfolios/{id}/companies/{symbol} | bad UUID, empty symbol | yes | portfolio or company |
GET /portfolios/{id}/broker-analysis | bad UUID | yes | not yours |
Cross-field validation messages
These fire from the validateCrossFields step in transactions/service.go after shape validation passes:
| Rule | Message |
|---|---|
| Gross matches qty × rate | amount (X.XX) must equal quantity × rate (Y.YY) within ±0.01 |
| BUY net amount | netAmount (X.XX) must equal amount + fees (Y.YY) within ±0.01 |
| SELL net amount | netAmount (X.XX) must equal amount − fees (Y.YY) within ±0.01 |
| CGT loss check | cgtAmount must be 0 when rate ≤ basePrice |
| CGT band | cgtAmount (X.XX) exceeds maximum N% CGT on gain of Y.YY (max Z.ZZ) |
Examples
$ curl -i -H "Authorization: Bearer $JWT" \
"$BASE/portfolios/not-a-uuid"
HTTP/1.1 400 Bad Request
{"message":"Invalid id format"}$ curl -X POST -i -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"type":"BUY","companyId":"...","transactedAt":"2025-11-10","quantity":10,"rate":100,"amount":999,"sebonCommission":0,"brokerCommission":0,"dpFee":0,"netAmount":999}' \
"$BASE/portfolios/$PID/transactions"
HTTP/1.1 400 Bad Request
{"message":"amount (999.00) must equal quantity × rate (1000.00) within ±0.01"}# Portfolio holds 50 units of NABIL; try to sell 100
$ curl -X POST -i ... -d '{"type":"SELL","quantity":100,...}' "$BASE/portfolios/$PID/transactions"
HTTP/1.1 422 Unprocessable Entity
{"message":"SELL quantity 100 exceeds current holding of 50 units"}$ curl -X PATCH -i -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"isDefault": false}' "$BASE/portfolios/$PID"
HTTP/1.1 400 Bad Request
{"message":"isDefault cannot be set to false directly — promote a different portfolio to default instead"}# The portfolio exists, but it belongs to another customer
$ curl -i -H "Authorization: Bearer $JWT" \
"$BASE/portfolios/0192d4e5-6f7a-7b8c-9d0e-1f2a3b4c5d6e"
HTTP/1.1 404 Not Found
{"message":"Portfolio not found"}Implementation
- Envelope:
internal/http/response/response.go - Decode + validate:
internal/http/handlerutil - Validator tags: registered in
internal/http/handlerutil/handlerutil.go(customiso8601validator) - Domain-specific errors:
response.NewValidation,response.NewNotFound,response.NewStatus(http.StatusUnprocessableEntity, ...)