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). Unlike the TradingView UDF surface, there is no UDF-style root-level error — portfolio is a normal authenticated REST API.
Error envelope
{
"message": "<human-friendly summary>",
"errors": [{ "field": "<path>", "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, unknown field, validate-tag rule violated, business-rule rejected at request layer (e.g., cgtAmount on a BUY). |
| 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-exceeds-holding, currently 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 all 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 |
Body contains _unknown field | unknown field <name> |
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 timestamp |
Per-endpoint quick lookup
| Endpoint | 400 | 401 | 404 | 500 |
|---|---|---|---|---|
POST /portfolios | name missing/too long, description too long, body issues | yes | — | DB |
PATCH /portfolios/{id} | bad UUID, name/description too long | yes | not yours | DB |
DELETE /portfolios/{id} | bad UUID | yes | not yours | DB |
| Endpoint | 400 | 401 | 404 | 422 | 500 |
|---|---|---|---|---|---|
POST /portfolios/{id}/trades | type, qty, price, totalAmount mismatch, tradedAt malformed/future, cgt-on-BUY | yes | portfolio | SELL exceeds holding | DB |
GET /portfolios/{id}/trades | bad pagination, bad type/from/to | yes | portfolio | — | DB |
PATCH /portfolios/{id}/trades/{tradeId} | same as Add (after merge) | yes | portfolio or trade | SELL exceeds holding | DB |
DELETE /portfolios/{id}/trades/{tradeId} | bad UUIDs | yes | portfolio or trade | — | DB |
Total-amount mismatch message: totalAmount mismatch: expected X.XX. Reject threshold is NPR 1.
CGT mismatch is not a 400 — it surfaces as cgtMismatch: true on the response and is informational.
| Endpoint | 400 | 401 | 404 | 500 |
|---|---|---|---|---|
POST /.../dividends | amount < 0, receivedAt malformed, body | yes | portfolio or symbol | DB |
GET /.../dividends | bad pagination | yes | portfolio | DB |
GET /portfolios/{id}/dividends | bad pagination/from/to | yes | portfolio | DB |
PATCH /.../dividends/{dividendId} | same as Add | yes | any of portfolio/symbol/dividend | DB |
DELETE /.../dividends/{dividendId} | bad UUIDs | yes | any of portfolio/symbol/dividend | DB |
| Endpoint | 400 | 401 | 404 | 500 |
|---|---|---|---|---|
POST /.../actions | type not in oneof, qty/price/fees invalid, dates malformed, type-specific rule | yes | portfolio or symbol | DB |
GET /.../actions | bad pagination | yes | portfolio | DB |
PATCH /.../actions/{actionId} | same as Add | yes | any of portfolio/symbol/action | DB |
DELETE /.../actions/{actionId} | bad UUIDs | yes | any of portfolio/symbol/action | 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 holding for company |
Validation rule sources
The struct tags drive the bulk of validation. Examples from trades/types.go:
type AddTradeRequest struct {
CompanyID string `json:"companyId" validate:"required,uuid"`
Type string `json:"type" validate:"required,oneof=BUY SELL"`
Quantity int `json:"quantity" validate:"required,min=1"`
PricePerUnit float64 `json:"pricePerUnit" validate:"required,gt=0"`
TotalAmount float64 `json:"totalAmount" validate:"required,gte=0"`
TradedAt string `json:"tradedAt" validate:"required,iso8601"`
Notes *string `json:"notes" validate:"omitempty,max=500"`
// ...
}Beyond struct tags, three layers of business validation kick in within services:
- Trade fee math —
completeTradeFees+computedTradeTotalrejecttotalAmountdeviation > NPR 1. - Sell quantity check —
holdings.CurrentUnitsForHoldingrejects oversell with 422. - Type-specific corporate-action validation —
validateCorporateActionenforces "BONUS needs qty>=1", "RIGHT_ISSUE needs price if qty>0", etc.
Soft warnings vs errors
Some "wrong" inputs are accepted with a warning rather than rejected:
| Case | Behaviour |
|---|---|
tradedAt is not a NEPSE trading day (Mon–Fri or holiday) | Trade saved; warnings: ["traded_at <date> is not a NEPSE trading day"] on the response. |
cgtAmount differs from server-computed cgtComputed | Trade saved; cgtMismatch: true on the response. |
| Trade in the future by ≤ 1 minute | Allowed (skews TZ-tolerant). |
to > now on trade list filter | Allowed. |
These are deliberate UX calls. The server holds opinions about what's plausible but doesn't reject straight-line user data unless the FIFO math would actually be wrong.
Examples
$ curl -i -H "Authorization: Bearer $JWT" \
"$BASE/portfolios/not-a-uuid"
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"message":"Invalid id format"}$ curl -X POST -i -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"companyId":"...","type":"BUY","quantity":10,"pricePerUnit":100,"cgtAmount":50,"totalAmount":1000,"tradedAt":"2026-04-01T00:00:00.000Z"}' \
"$BASE/portfolios/$PID/trades"
HTTP/1.1 400 Bad Request
{"message":"cgtAmount is only valid on SELL trades"}$ curl -X POST -i -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"companyId":"...","type":"BUY","quantity":100,"pricePerUnit":1250.00,"totalAmount":99999.00,"tradedAt":"2026-04-01T00:00:00.000Z"}' \
"$BASE/portfolios/$PID/trades"
HTTP/1.1 400 Bad Request
{"message":"totalAmount mismatch: expected 125481.25"}# Portfolio holds 50 units of NABIL; try to sell 100
$ curl -X POST -i ... -d '{"type":"SELL","quantity":100,...}' "$BASE/portfolios/$PID/trades"
HTTP/1.1 422 Unprocessable Entity
{"message":"SELL quantity exceeds current holding (50 units)"}# 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/validator - Domain-specific errors:
response.NewValidation,response.NewNotFound,response.NewStatus(http.StatusUnprocessableEntity, ...)