Transactions
One unified ledger for all 8 event types — BUY, SELL, IPO, FPO, RIGHTS, AUCTION, DIVIDEND, BONUS
Four endpoints under the Portfolio Transactions Swagger tag manage the portfolio's event ledger. The unified table replaces the three legacy tables (trades, dividends, corporate-actions) and the three derived ones (holdings, lots, lot_consumptions).
| Method | Path | Service method |
|---|---|---|
POST | /portfolios/{id}/transactions | transactions.Service.Add |
GET | /portfolios/{id}/transactions | transactions.Service.Find |
PATCH | /portfolios/{id}/transactions/{txId} | transactions.Service.Update |
DELETE | /portfolios/{id}/transactions/{txId} | transactions.Service.Delete |
Transaction types and required fields
| Type | Required fields |
|---|---|
| BUY | companyId, transactedAt, quantity, rate, amount, sebonCommission, brokerCommission, dpFee, netAmount |
| SELL | BUY fields plus basePrice, term (SHORT / LONG), cgtAmount |
| IPO | companyId, transactedAt, quantity, rate, amount |
| FPO | companyId, transactedAt, quantity, rate, amount |
| RIGHTS | companyId, transactedAt, quantity, rate, amount |
| AUCTION | companyId, transactedAt, quantity, rate, amount |
| DIVIDEND | companyId, transactedAt, amount |
| BONUS | companyId, transactedAt, quantity |
transactedAt accepts three forms — all interpreted as the same instant:
2025-11-10— date-only, anchored to start of NPT day (UTC+05:45)2025-11-10T00:00:00.000Z- Any RFC3339 timestamp
POST /portfolios/{id}/transactions
The type field discriminates the variant. The backend stores the user-supplied numbers as-is and never recomputes fees, CGT, or net amounts.
Example: BUY
{
"type": "BUY",
"companyId": "019d8bf0-9b6e-7af8-a794-9da3c2bd1835",
"transactedAt": "2025-11-10",
"quantity": 10,
"rate": 500,
"amount": 5000,
"sebonCommission": 0.75,
"brokerCommission": 18.50,
"dpFee": 25,
"netAmount": 5044.25
}Example: SELL
{
"type": "SELL",
"companyId": "019d8bf0-9b6e-7af8-a794-9da3c2bd1835",
"transactedAt": "2025-11-12",
"quantity": 10,
"rate": 600,
"amount": 6000,
"sebonCommission": 0.90,
"brokerCommission": 22.20,
"dpFee": 25,
"basePrice": 500,
"term": "SHORT",
"cgtAmount": 75,
"netAmount": 5876.90
}Example: IPO
{
"type": "IPO",
"companyId": "019d8bf0-9b6e-7af8-a794-9da3c2bd1835",
"transactedAt": "2025-11-10",
"quantity": 10,
"rate": 100,
"amount": 1000
}Example: DIVIDEND
{
"type": "DIVIDEND",
"companyId": "019d8bf0-9b6e-7af8-a794-9da3c2bd1835",
"transactedAt": "2025-11-10",
"amount": 250
}Example: BONUS
{
"type": "BONUS",
"companyId": "019d8bf0-9b6e-7af8-a794-9da3c2bd1835",
"transactedAt": "2025-11-10",
"quantity": 3
}Validation rules
Two layers, both lightweight. The backend never recomputes fees from outside sources (no broker tier tables, no SEBON % derivation, no settlement-date lookup). It only checks that the user-supplied fields agree with each other.
Shape (hard reject)
transactedAtvalid; not in the future (1-min skew tolerance).companyIdreferences an existing company.quantity > 0where required by type.rate > 0where required by type.- All monetary fields
>= 0. term∈{SHORT, LONG}on SELL.- Per-type required-field shape (DB CHECK + service-layer pre-check for nicer error messages).
notes≤ 500 chars.
Cross-field consistency (hard reject, ±0.01 NPR)
| Check | Applies to | Rule |
|---|---|---|
| Gross matches qty × rate | BUY, SELL, IPO, FPO, RIGHTS, AUCTION | |amount − quantity × rate| ≤ 0.01 |
| Net amount on BUY | BUY | |netAmount − (amount + sebonCommission + brokerCommission + dpFee)| ≤ 0.01 |
| Net amount on SELL | SELL | |netAmount − (amount − sebonCommission − brokerCommission − dpFee − cgtAmount)| ≤ 0.01 |
| CGT band | SELL | cgtAmount falls within [0, gain × rate(term)] where gain = (rate − basePrice) × quantity and rate(term) = 0.05 (LONG) or 0.075 (SHORT). When gain ≤ 0, require cgtAmount = 0. |
| Oversell | SELL | quantity ≤ currentUnits(portfolio, company) from a single SUM aggregate query |
GET /portfolios/{id}/transactions
Paginated, newest-first listing with optional filters.
Query params
| Param | Default | Notes |
|---|---|---|
page | 1 | 1-indexed |
size | 20 | max 100 |
type | — | BUY / SELL / IPO / FPO / RIGHTS / AUCTION / DIVIDEND / BONUS |
from | — | inclusive YYYY-MM-DD |
to | — | inclusive YYYY-MM-DD |
symbol | — | exact case-insensitive match against the company symbol |
Rows + total count run inside one REPEATABLE READ snapshot for pagination consistency.
PATCH /portfolios/{id}/transactions/{txId}
Patches a transaction in place. All fields optional. The transaction's type cannot change — to switch type, delete and re-add.
The same shape and cross-field consistency checks re-run after merging the patch with the persisted row, so a partial update can never violate type invariants.
DELETE /portfolios/{id}/transactions/{txId}
Soft-deletes the transaction. The row stays in the database (audit) but is excluded from all subsequent reads, summaries, valuations, and oversell checks. 204 No Content on success. Detail and valuation caches are busted.
Response shape
Every endpoint returns the same TransactionResponse (PATCH/GET/POST), enriched with company identity for client convenience:
type TransactionResponse = {
id: string;
portfolioId: string;
companyId: string;
sym: string; // symbol
name: string; // company name
icon?: string;
type: 'BUY' | 'SELL' | 'IPO' | 'FPO' | 'RIGHTS' | 'AUCTION' | 'DIVIDEND' | 'BONUS';
transactedAt: string; // RFC3339, UTC
quantity?: number;
rate?: number;
amount?: number;
sebonCommission?: number;
brokerCommission?: number;
dpFee?: number;
netAmount?: number;
basePrice?: number; // SELL only
term?: 'SHORT' | 'LONG'; // SELL only
cgtAmount?: number; // SELL only
notes?: string;
createdAt: string;
updatedAt: string;
};Optional fields are omitted from the JSON when unset.