Shop It Docs
Portfolio Module

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.

MethodPathService method
POST/portfolioscore.Service.Create
GET/portfolioscore.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

  • id is generated server-side via uuid.NewV7 (time-ordered).
  • isDefault is set when CountPortfoliosByCustomer == 0 at 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.Run so the count-then-insert race is closed.

Errors

StatusCause
400name missing, name > 100, description > 500, unknown field, body too large
401missing / invalid JWT
500DB 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/portfolios

GET /portfolios — List

Paginated list of the authenticated user's portfolios.

Query params

ParamDefaultNotes
page11-indexed
size20max 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

StatusCause
400page or size invalid
401missing / 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

ParamNotes
idPortfolio 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; lastFetchedAt won'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 /holdings for paginated/sorted/filtered table data.

Errors

StatusCause
400id not a UUID
401missing / invalid JWT
404not yours, or doesn't exist
500DB read failed

cURL

curl -H "Authorization: Bearer $JWT" \
  https://api.ranjanyadav.com.np/api/v1/portfolios/0192d4e5-6f7a-7b8c-9d0e-1f2a3b4c5d6e

PATCH /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 — busts portfolio:detail:<id> (not valuation).
  • Runs inside TxRunner.Run for uniformity even though the SQL is a single UpdatePortfolio statement.
  • A pgx.ErrNoRows from UpdatePortfolio (no row matched the id + customer_id filter) is mapped to 404 — same indistinguishable-from-not-found stance as Get.

Errors

StatusCause
400id not a UUID, name > 100, description > 500, unknown field
401missing / invalid JWT
404not 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-1f2a3b4c5d6e

DELETE /portfolios/{id} — Delete

Soft-deletes the portfolio and its event tables; hard-deletes its derived projections.

Cascade order (inside one transaction)

  1. DeletePortfolioLotConsumptionsByPortfolio — hard delete (projection)
  2. DeletePortfolioLotsByPortfolio — hard delete (projection)
  3. DeletePortfolioHoldingsByPortfolio — hard delete (projection)
  4. SoftDeleteDividendsByPortfoliodeleted_at = now() (source)
  5. SoftDeleteCorporateActionsByPortfolio — soft delete (source)
  6. SoftDeleteTradesByPortfolio — soft delete (source)
  7. 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

StatusCause
400id not a UUID
401missing / invalid JWT
404not yours, or doesn't exist
500DB 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-1f2a3b4c5d6e

Implementation

  • Handler: internal/modules/portfolio/core/handler.goCreate, List, Get, Update, Delete
  • Service: internal/modules/portfolio/core/service.go
  • Mappers: internal/modules/portfolio/core/mappers.goportfolioToDTO, detailToDTO
  • DTOs: internal/modules/portfolio/core/types.go
  • sqlc queries: internal/platform/database/queries/portfolios.sql