Shop It Docs
Portfolio Module

Corporate Actions

Add, list, update, and delete corporate actions — bonus, right issue, IPO, FPO, and auction events that change unit count or cost basis

Four endpoints under the Portfolio Corporate Actions Swagger tag manage non-trade events that affect unit counts or cost basis.

MethodPathService method
POST/portfolios/{id}/companies/{symbol}/actionsactions.Service.AddCorporateAction
GET/portfolios/{id}/companies/{symbol}/actionsactions.Service.FindCorporateActionsByCompany
PATCH/portfolios/{id}/companies/{symbol}/actions/{actionId}actions.Service.UpdateCorporateAction
DELETE/portfolios/{id}/companies/{symbol}/actions/{actionId}actions.Service.DeleteCorporateAction

Supported action types

NEPSE corporate-action vocabulary supported by this module:

TypeEffect on unitsEffect on cost basisLot source
BONUS+qtynoneBONUS (zero cost)
RIGHT_ISSUE+subscribed_qty+price × subscribed_qty + feesRIGHT_SUBSCRIBED
IPO+qty+price × qty + feesIPO
FPO+qty+price × qty + feesFPO
AUCTION+qty+price × qty + feesAUCTION

The validation layer currently accepts only BONUS and RIGHT_ISSUE from the public DTO (oneof=BONUS RIGHT_ISSUE). IPO, FPO, AUCTION are supported in the lot-source schema and the replay engine, but they're sourced via internal admin tooling rather than user submissions. See actions/types.go for the DTO enum.

POST /portfolios/{id}/companies/{symbol}/actions — AddCorporateAction

Records a corporate action.

Request body

type CreateCorporateActionRequest = {
  type:              "BONUS" | "RIGHT_ISSUE";
  quantity:          number;     // ≥ 0  (RIGHT_ISSUE: this is the subscribed qty, can be 0 if rejected)
  pricePerUnit?:     number;     // RIGHT_ISSUE / IPO / FPO / AUCTION
  fees?:             number;     // optional
  ratioNumerator?:   number;     // BONUS / RIGHT_ISSUE — display only, e.g. 10:3 bonus
  ratioDenominator?: number;
  entitledQty?:      number;     // RIGHT_ISSUE — entitlement cap
  subscribedQty?:    number;     // RIGHT_ISSUE — informational; the actual subscribed count is `quantity`
  bookClosureDate?:  string;     // YYYY-MM-DD
  effectiveDate:     string;     // ISO 8601 — required
  notes?:            string;     // max 500
};

Validation rules per type

Implemented in actions/validation.govalidateCorporateAction:

  • quantity ≥ 1 (must add bonus shares).
  • pricePerUnit ignored / forced to 0 in the persisted nepse_portfolio_lots.cost_per_unit.
  • fees typically 0; if non-zero it doesn't affect the lot tree (bonus lot is always zero-cost).
  • ratioNumerator / ratioDenominator are display-only — the engine only uses quantity.
  • quantity ≥ 0 — this is the subscribed quantity. 0 = rejected, no lot is created.
  • pricePerUnit required if quantity > 0.
  • fees optional.
  • entitledQty, subscribedQty are informational — the engine reads quantity as the actual subscribed count.
  • Cost lot per the formula cost = price × qty + fees.
  • Same shape as RIGHT_ISSUE: quantity units allotted at pricePerUnit + fees.
  • Distinguished only by the lot's source so the WACC breakdown can split them in the company-detail view.
  • Currently sourced via internal admin tooling; not part of the public DTO oneof whitelist.

Success (201)

{
  "message": "Corporate action added successfully",
  "data": {
    "id": "...",
    "portfolioId": "...",
    "companyId": "...",
    "sym": "NABIL",
    "type": "BONUS",
    "quantity": 30,
    "pricePerUnit": 0,
    "fees": 0,
    "ratioNumerator": 10,
    "ratioDenominator": 3,
    "entitledQty": null,
    "subscribedQty": null,
    "bookClosureDate": "2026-02-01",
    "effectiveDate": "2026-02-14T00:00:00.000Z",
    "notes": "10:3 bonus shares",
    "createdAt": "...",
    "updatedAt": "..."
  }
}

Behaviour

  • Ownership guard → resolve symbol → validateCorporateAction(req, type)CreateCorporateActionholdings.Rebuild(portfolio, company) → cache bust.
  • Cache scope: CacheScopeDetailAndValuation. Corporate actions move both units (affects dayPnl next read) and cost basis (affects valuation series).
  • The replay engine sorts the new action into the chronological event list by effectiveDate, then createdAt, then id — back-dated actions are integrated correctly.

Errors

StatusCause
400type not in [BONUS, RIGHT_ISSUE], quantity < 0, malformed effectiveDate, malformed bookClosureDate, validation rule failed
401JWT missing/invalid
404portfolio not yours / not found, symbol not in catalog

cURL

curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{
    "type":"BONUS",
    "quantity":30,
    "ratioNumerator":10,"ratioDenominator":3,
    "effectiveDate":"2026-02-14T00:00:00.000Z",
    "notes":"10:3 bonus"
  }' \
  "$BASE/portfolios/$PID/companies/NABIL/actions"
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{
    "type":"RIGHT_ISSUE",
    "quantity":10, "pricePerUnit":100,
    "entitledQty":20, "subscribedQty":10,
    "effectiveDate":"2026-03-01T00:00:00.000Z",
    "notes":"Subscribed 10/20"
  }' \
  "$BASE/portfolios/$PID/companies/NABIL/actions"
# Subscribed = 0. No lot is created; the row is logged for audit only.
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{
    "type":"RIGHT_ISSUE",
    "quantity":0, "pricePerUnit":100,
    "entitledQty":20, "subscribedQty":0,
    "effectiveDate":"2026-03-01T00:00:00.000Z",
    "notes":"Did not subscribe"
  }' \
  "$BASE/portfolios/$PID/companies/NABIL/actions"

GET /portfolios/{id}/companies/{symbol}/actions — ListCorporateActions

Paginated list of corporate actions for one company.

Query params

ParamDefaultNotes
page1
size20max 100

Success (200)

Standard paginated envelope wrapping []CorporateActionResponse. Soft-deleted rows excluded. Ordered by effective_date DESC, created_at DESC.


PATCH /portfolios/{id}/companies/{symbol}/actions/{actionId} — UpdateCorporateAction

Partial update with the same shape as Add; all fields optional. Re-runs the replay.

Behaviour

  • FindCorporateActionForUpdate to capture the original company.
  • If the user changes type, quantity, pricePerUnit, fees, or effectiveDate, the replay sees a structurally different event and the lot tree changes accordingly.
  • Cache scope: CacheScopeDetailAndValuation.

Errors

Same as Add, plus 404 if actionId doesn't belong to this (portfolio, company).


DELETE /portfolios/{id}/companies/{symbol}/actions/{actionId} — DeleteCorporateAction

Soft-deletes the action and rebuilds the affected company's holdings.

Behaviour

  • SoftDeleteCorporateAction then Rebuild. The replay walks events without this row, leaving the lot tree as if the action never happened.
  • Cache scope: CacheScopeDetailAndValuation.

Success

204 No Content.


Lot-source mapping summary

After replay, a company's open lots show their origin in the source column. The company-detail endpoint surfaces this via waccBreakdown:

{
  "boughtCost":          50000.00, "boughtQty":          100,
  "bonusQty":            30,
  "rightSubscribedCost":  1000.00, "rightSubscribedQty":  10,
  "ipoCost":              0.00,    "ipoQty":              0,
  "fpoCost":              0.00,    "fpoQty":              0,
  "auctionCost":          0.00,    "auctionQty":          0,
  "totalCost":           51000.00, "totalQty":           140,
  "wacc":                  364.29
}

WACC is the open-lot cost ÷ open-lot units. Bonus dilutes; right/IPO/FPO/AUCTION add cost and units.

Implementation

  • Handler: internal/modules/portfolio/actions/handler.go
  • Service: internal/modules/portfolio/actions/service.go
  • Validation: internal/modules/portfolio/actions/validation.govalidateCorporateAction, corporateActionCreateParams, corporateActionUpdateParams
  • Mappers: internal/modules/portfolio/actions/mappers.go
  • DTOs: internal/modules/portfolio/actions/types.go
  • Replay (lot derivation): internal/modules/portfolio/holdings/replay.go
  • sqlc queries: internal/platform/database/queries/portfolio_corporate_actions.sql
  • Tests: actions/handler_test.go, actions/service_test.go