Shop It Docs
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

CodeMeaning
400Validation error — bad UUID, malformed body, decode failure, body-too-large, validate-tag rule violated, cross-field consistency violated (e.g. amount ≠ qty × rate).
401Missing or invalid JWT. The auth middleware rejects before the handler runs.
404Resource not found. Always also returned for "not yours" — the server never reveals whether a UUID exists for another customer.
422Business rule rejected after parse — used for SELL quantity exceeding current holding (the only 422 in the module).
500Unexpected 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:

CauseMessage shape
id path param not a UUIDInvalid <param> format
Body fails to JSON-decodeMalformed JSON
Body exceeds BodyLimitRequest 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

Endpoint400401404500
POST /portfoliosname missing/too long, description too longyesDB
PATCH /portfolios/{id}bad UUID, name/description too long, isDefault: falseyesnot yoursDB
DELETE /portfolios/{id}bad UUIDyesnot yoursDB
Endpoint400401404422500
POST /portfolios/{id}/transactionstype missing/unknown, per-type required field missing, transactedAt malformed/future, gross ≠ qty × rate, netAmount mismatch, CGT outside bandyesportfolioSELL exceeds holdingDB
GET /portfolios/{id}/transactionsbad pagination, bad type/from/toyesportfolioDB
PATCH /portfolios/{id}/transactions/{txId}same as Add (after merge)yesportfolio or transactionSELL exceeds holdingDB
DELETE /portfolios/{id}/transactions/{txId}bad UUIDsyesportfolio or transactionDB
Endpoint400401404
GET /portfoliosbad paginationyes
GET /portfolios/{id}bad UUIDyesnot yours
GET /portfolios/{id}/summarybad UUIDyesnot yours
GET /portfolios/{id}/holdingsbad UUID, paginationyesnot yours
GET /portfolios/{id}/distributionbad UUIDyesnot yours
GET /portfolios/{id}/valuationbad UUID, range not in whitelistyesnot yours
GET /portfolios/{id}/companies/{symbol}bad UUID, empty symbolyesportfolio or company
GET /portfolios/{id}/broker-analysisbad UUIDyesnot yours

Cross-field validation messages

These fire from the validateCrossFields step in transactions/service.go after shape validation passes:

RuleMessage
Gross matches qty × rateamount (X.XX) must equal quantity × rate (Y.YY) within ±0.01
BUY net amountnetAmount (X.XX) must equal amount + fees (Y.YY) within ±0.01
SELL net amountnetAmount (X.XX) must equal amount − fees (Y.YY) within ±0.01
CGT loss checkcgtAmount must be 0 when rate ≤ basePrice
CGT bandcgtAmount (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 (custom iso8601 validator)
  • Domain-specific errors: response.NewValidation, response.NewNotFound, response.NewStatus(http.StatusUnprocessableEntity, ...)