Shop It Docs
Portfolio Module

Architecture

Package layout, schema, and request lifecycle for the Portfolio module

The portfolio module is organised by concern: write-side CRUD lives in tag-aligned packages (core, transactions); read-side derivations live in views; one cross-cutting shared package owns the transaction runner and cache invalidator.

Package layout

internal/modules/portfolio/
├── core/             # Portfolio CRUD + valuation + broker-analysis endpoints
├── companies/        # Holdings list, distribution, per-company drilldown (read-only)
├── transactions/     # Unified Add/List/Update/Delete for all 8 tx types
├── views/            # Read-time derivations off transactions + market prices
│   ├── state.go      # BuildState — chronological replay → CompanyState
│   ├── daypnl.go     # DayPnL — overnight + today-buy/sell decomposition
│   ├── valuation.go  # BuildValuation + BuildIntradayValuation
│   ├── holdings.go   # Per-company holding rows for the table
│   ├── distribution.go # byCompany + bySector pies
│   └── company_detail.go # Single-symbol drilldown + timeline
├── shared/           # TxRunner, Invalidator, FindPortfolio guard, ParseTradeTime
├── holdings/         # Vestigial — only HoldingResponse DTO remains
└── portfolio.go      # Module aggregator + chi route table

Total LOC: ~3,000 (down from ~7,200 in the previous FIFO-based design).

Schema

Three tables back the entire module:

TablePurpose
nepse_portfoliosPortfolio header (name, description, is_default, customer_id, last_fetched_at, deleted_at)
nepse_portfolio_transactionsUnified ledger — one row per BUY/SELL/IPO/FPO/RIGHTS/AUCTION/DIVIDEND/BONUS
nepse_companiesReferenced via FK; not owned by this module

Per-type required-field shape is enforced by CHECK constraints on nepse_portfolio_transactions. See migration 000023_portfolio_transactions.up.sql.

Module wiring

portfolio.NewModule(q, pool, cacheLayer) constructs the four sub-handlers and registers their routes:

r.Post("/portfolios", m.core.Create)
r.Get("/portfolios", m.core.List)
r.Get("/portfolios/{id}", m.core.Get)
r.Get("/portfolios/{id}/summary", m.core.GetSummary)
r.Get("/portfolios/{id}/holdings", m.companies.ListHoldings)
r.Get("/portfolios/{id}/distribution", m.companies.GetDistribution)
r.Get("/portfolios/{id}/valuation", m.core.GetValuation)
r.Get("/portfolios/{id}/broker-analysis", m.core.GetBrokerAnalysis)
r.Get("/portfolios/{id}/companies/{symbol}", m.companies.GetCompanyDetail)
r.Patch("/portfolios/{id}", m.core.Update)
r.Delete("/portfolios/{id}", m.core.Delete)
r.Post("/portfolios/{id}/transactions", m.transactions.Add)
r.Get("/portfolios/{id}/transactions", m.transactions.List)
r.Patch("/portfolios/{id}/transactions/{txId}", m.transactions.Update)
r.Delete("/portfolios/{id}/transactions/{txId}", m.transactions.Delete)

Write request lifecycle

HTTP request

Handler — DecodeAndValidate (validator tags + iso8601 helper)

Service — shape validation + cross-field consistency checks (±0.01 NPR)

TxRunner.Run(ctx, portfolioID, scope, fn) ─── BEGIN

   1. Portfolio guard (FindPortfolio)
   2. Type-specific guards (e.g. SELL oversell check via SUM aggregate)
   3. SQL write

COMMIT

Invalidator.Apply(scope) — busts portfolio:detail:{id} and/or portfolio:valuation:{id}:*

Mapper → response envelope

Read request lifecycle

HTTP request

Handler — extract params

Service:
   1. cache.GetJSON(detail|valuation key)  → hit? return
   2. q.FindAllPortfolioTransactionsForViews(portfolioID)
   3. q.FindLivePricesForCompanies(...) (or FindBulkPriceHistoryForPortfolio for ranges)
   4. views.BuildState(txs, prices, todayStart)
   5. cache.SetJSON(...)

Mapper → response envelope

Pagination consistency

FindAll (portfolios) and Find (transactions) both run the rows query and the count query inside TxRunner.RunReadSnapshot — a REPEATABLE READ read-only transaction. This guarantees the (rows, count) pair always agrees, even under concurrent writes.

Middleware stack

All portfolio routes sit behind the JWT middleware. The handler reads the customer ID via handlerutil.MustCustomerID(r) and passes it down — service-layer queries always filter by customer_id so cross-tenant access is structurally impossible.