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 tableTotal LOC: ~3,000 (down from ~7,200 in the previous FIFO-based design).
Schema
Three tables back the entire module:
| Table | Purpose |
|---|---|
nepse_portfolios | Portfolio header (name, description, is_default, customer_id, last_fetched_at, deleted_at) |
nepse_portfolio_transactions | Unified ledger — one row per BUY/SELL/IPO/FPO/RIGHTS/AUCTION/DIVIDEND/BONUS |
nepse_companies | Referenced 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 envelopeRead 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 envelopePagination 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.