Portfolio CRUD
Create, list, get, update, and delete portfolios — the core lifecycle endpoints
Five endpoints under the Portfolio Swagger tag manage the portfolio aggregate itself. All require a JWT.
| Method | Path | Service method |
|---|---|---|
POST | /portfolios | core.Service.Create |
GET | /portfolios | core.Service.FindAll |
GET | /portfolios/{id} | core.Service.FindDetail |
PATCH | /portfolios/{id} | core.Service.Update |
DELETE | /portfolios/{id} | core.Service.Delete |
POST /portfolios — Create
Creates a new portfolio. The first portfolio per customer is automatically marked default.
Request body
type CreatePortfolioRequest = {
name: string; // required, max 100
description?: string; // optional, max 500
};Success (201)
{
"message": "Portfolio created successfully",
"data": {
"id": "0192d4e5-6f7a-7b8c-9d0e-1f2a3b4c5d6e",
"name": "Long-term equities",
"description": null,
"isDefault": true,
"createdAt": "2026-04-26T11:00:00.000Z",
"updatedAt": "2026-04-26T11:00:00.000Z"
}
}Behaviour
idis generated server-side viauuid.NewV7(time-ordered).isDefaultis set whenCountPortfoliosByCustomer == 0at the moment of insert. Subsequent portfolios are non-default.- Cache scope:
CacheScopeNone. New portfolios have no cache entries to invalidate. - The whole insert (count + create) runs inside a
TxRunner.Runso the count-then-insert race is closed.
Errors
| Status | Cause |
|---|---|
| 400 | name missing, name > 100, description > 500, unknown field, body too large |
| 401 | missing / invalid JWT |
| 500 | DB write failed |
cURL
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"name":"Long-term equities","description":"Buy & hold"}' \
https://api.ranjanyadav.com.np/api/v1/portfoliosGET /portfolios — List
Paginated list of the authenticated user's portfolios.
Query params
| Param | Default | Notes |
|---|---|---|
page | 1 | 1-indexed |
size | 20 | max 100 |
Success (200)
{
"message": "Portfolios fetched successfully",
"data": [
{ "id": "...", "name": "Long-term equities", "isDefault": true, ... },
{ "id": "...", "name": "Short-term", "isDefault": false, ... }
],
"meta": { "total": 2, "page": 1, "size": 20 }
}Behaviour
- Two queries per call:
FindPortfoliosByCustomer(paginated) +CountPortfoliosByCustomer(total). - No caching. List cardinality per customer is small (typically 1–3) and changes rarely.
- Soft-deleted portfolios are excluded by the SQL filter
WHERE deleted_at IS NULL.
Errors
| Status | Cause |
|---|---|
| 400 | page or size invalid |
| 401 | missing / invalid JWT |
GET /portfolios/{id} — Get
Bundled portfolio dashboard payload: summary, suspended summary, sector distribution, and a lightweight positions[] projection of every active holding for heat maps and the by-company pie. The full holdings table — with all per-row fields and pagination — lives at GET /portfolios/{id}/holdings.
Path params
| Param | Notes |
|---|---|
id | Portfolio UUID |
Success (200)
{
"message": "Portfolio fetched successfully",
"data": {
"id": "...",
"name": "Long-term equities",
"description": null,
"isDefault": true,
"lastFetchedAt": "2026-04-26T10:59:50.000Z",
"createdAt": "...",
"updatedAt": "...",
"summary": {
"cUnits": 100, "sUnits": 30,
"totalInv": 125461.19, "curInv": 87822.83,
"curVal": 91000.00, "soldVal": 18750.00,
"div": 2000.00,
"unrealPnl": 3177.17, "realPnl": 1234.56,
"totalPnl": 6411.73, "totalPnlPct": 5.11,
"dayPnl": 350.00, "dayPnlPct": 0.39,
"recv": 5801.34
},
"suspendedSummary": {
"count": 1,
"lastKnownValue": 12000.00,
"note": "Excluded from main totals"
},
"distribution": {
"bySector": [{ "sec": "Commercial Banks", "v": 91000.00, "pct": 100.00 }],
"byInvested": [{ "sec": "Commercial Banks", "v": 87822.83, "pct": 100.00 }]
},
"positions": [
{
"sym": "NABIL",
"name": "Nabil Bank Limited",
"sec": "Commercial Banks",
"curInv": 87822.83,
"curVal": 91000.00,
"dayPnl": 350.00,
"pch": 0.39,
"holdingPct": 100.00
}
]
}
}suspendedSummary is omitted when no suspended holdings exist. positions[] carries only active holdings (status == 'A'); suspended stocks are surfaced via suspendedSummary.
Behaviour
- Cache key:
portfolio:detail:<id>, TTL: 15s. - Cache flow: ownership guard → cache GET → on miss: holdings + today's trades +
TouchPortfolioFetchedAt+buildDetailSnapshot→ cache SET. - Detail-cache hits skip the SQL queries entirely;
lastFetchedAtwon't update during the cache window. - Day P&L is computed per-company from today's trades fetched fresh on every cache miss.
- The bundle is request-parameter-independent — there are no query params on this endpoint. Use
/holdingsfor paginated/sorted/filtered table data.
Errors
| Status | Cause |
|---|---|
| 400 | id not a UUID |
| 401 | missing / invalid JWT |
| 404 | not yours, or doesn't exist |
| 500 | DB read failed |
cURL
curl -H "Authorization: Bearer $JWT" \
https://api.ranjanyadav.com.np/api/v1/portfolios/0192d4e5-6f7a-7b8c-9d0e-1f2a3b4c5d6ePATCH /portfolios/{id} — Update
Updates name and/or description. Both fields are optional; either or both can be sent.
Request body
type UpdatePortfolioRequest = {
name?: string; // optional, max 100
description?: string; // optional, max 500
};Success (200)
Same shape as Create response.
Behaviour
- Cache scope:
CacheScopeDetail— bustsportfolio:detail:<id>(not valuation). - Runs inside
TxRunner.Runfor uniformity even though the SQL is a singleUpdatePortfoliostatement. - A
pgx.ErrNoRowsfromUpdatePortfolio(no row matched theid + customer_idfilter) is mapped to 404 — same indistinguishable-from-not-found stance asGet.
Errors
| Status | Cause |
|---|---|
| 400 | id not a UUID, name > 100, description > 500, unknown field |
| 401 | missing / invalid JWT |
| 404 | not yours, or doesn't exist |
cURL
curl -X PATCH -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"name":"Renamed"}' \
https://api.ranjanyadav.com.np/api/v1/portfolios/0192d4e5-6f7a-7b8c-9d0e-1f2a3b4c5d6eDELETE /portfolios/{id} — Delete
Soft-deletes the portfolio and its event tables; hard-deletes its derived projections.
Cascade order (inside one transaction)
DeletePortfolioLotConsumptionsByPortfolio— hard delete (projection)DeletePortfolioLotsByPortfolio— hard delete (projection)DeletePortfolioHoldingsByPortfolio— hard delete (projection)SoftDeleteDividendsByPortfolio—deleted_at = now()(source)SoftDeleteCorporateActionsByPortfolio— soft delete (source)SoftDeleteTradesByPortfolio— soft delete (source)SoftDeletePortfolio— soft delete (root)
Source-of-truth event rows are soft-deleted so audit trails persist. Projection tables are hard-deleted because they're rebuildable from sources.
Success
204 No Content (no body).
Behaviour
- Cache scope:
CacheScopeDetailAndValuation— busts detail + all 6 valuation range keys. - Ownership guard runs before the transaction opens. A 404 short-circuits without touching the tx.
- The 7-step cascade and the cache busts both happen atomically — either all succeed or all roll back.
Errors
| Status | Cause |
|---|---|
| 400 | id not a UUID |
| 401 | missing / invalid JWT |
| 404 | not yours, or doesn't exist |
| 500 | DB write failed mid-cascade — full rollback, no partial state |
cURL
curl -X DELETE -H "Authorization: Bearer $JWT" \
https://api.ranjanyadav.com.np/api/v1/portfolios/0192d4e5-6f7a-7b8c-9d0e-1f2a3b4c5d6eImplementation
- Handler:
internal/modules/portfolio/core/handler.go→Create,List,Get,Update,Delete - Service:
internal/modules/portfolio/core/service.go - Mappers:
internal/modules/portfolio/core/mappers.go→portfolioToDTO,detailToDTO - DTOs:
internal/modules/portfolio/core/types.go - sqlc queries:
internal/platform/database/queries/portfolios.sql