The Data Model
Meet the core entities, understand how they connect, and learn the dependency chain that governs everything from onboarding to daily operations.
The Big Four
Every tenant schema revolves around four core entities. All calculations, reports, and dashboards ultimately read from these tables. Everything else in the system exists to support, describe, or connect them.
Click on any entity below to explore its fields and relationships:
id bigint PKclient_reference string, uniquename stringemail string, nullableguid UUID, auto-generatedsource string, nullablesource_id string, nullabletitle string, nullableforename string, nullablesurname string, nullabledeleted_at timestamp, soft deleteRelationships
- ↔ Assets — many-to-many via
investor_assetpivot - → CashFlows — one investor has many cash flows
- → AssetValuations — per-investor valuations (optional)
- → InvestorAssetUnits — unit holdings per asset over time
id bigint PKclient_reference string, uniquename stringuid DE{9}X format, autoguid UUID, auto-generatedclass AssetClass enumstructure DIRECT | PRIVATE_EQUITY_FUNDcurrency string (ISO 4217)parent_asset_id FK, self-ref, nullableAsset Class enum values:
Relationships
- ↔ Investors — many-to-many via
investor_assetpivot - → CashFlows — one asset has many cash flows
- → AssetValuations — point-in-time NAV records
- → AssetData — EAV metadata (vintage, style, sector, etc.)
- ↔ Assets (self) — parent/child via
parent_asset_id - ↔ Assets (sub-pos) — many-to-many via
asset_sub_positions
id bigint PKguid UUID, auto-generatedtype TransactionType enumTransactionType enum (17 values):
Relationships
- → CashFlows — one transaction groups many cash flows
id bigint PKtype CashFlowType enuminvestor_id FK → investorsasset_id FK → assetstransaction_id FK → transactionsvalue decimal(28,14)units decimal(28,14)total decimal(28,14)currency string (ISO 4217)invoice_date datesettlement_date datepaid booleancash_routing string, nullableCashFlowType enum (23+ values) — a subset:
Relationships
- ← Investor — belongs to one investor
- ← Asset — belongs to one asset
- ← Transaction — belongs to one transaction
- → Fees — one cash flow can have many fees
- → Taxes — one cash flow can have many taxes
- → DistributionComponents — breakdown of distribution amounts
- → CashFlowData — EAV metadata
Think of it like a bookshelf. Investors are the people. Assets are the shelves. Transactions are folders on those shelves. CashFlows are the individual receipts inside those folders. Every calculation in Portfolio opens those folders and reads the receipts.
All monetary values (value, units, total) use decimal(28,14) — that's 28 total digits with 14 after the decimal point. This is deliberate: private equity calculations involve pro-rata splits, unit-price calculations, and multi-currency conversions that compound rounding errors if you don't carry enough precision. Never truncate or round intermediate values.
How They Connect: The Relationship Map
The four core entities don't exist in isolation. Here's the full relationship picture:
parent_asset_id+ M:N via
asset_sub_positions
The critical takeaway: CashFlow sits at the intersection of everything. It's the junction table between Investors, Assets, and Transactions. When the Go calc service needs to compute DPI for investor X in asset Y, it's querying cash_flows filtered by investor_id and asset_id.
Supporting Entities
Beyond the Big Four, a number of supporting entities handle specialized concerns. Expand each to learn what it does:
Records the net asset value of an asset (or a specific investor's holding) at a point in time. This is how the system knows what things are worth, as opposed to what was paid.
asset_id FK → assetsinvestor_id FK, nullable (per-investor)value decimal(28,14)date datecurrency stringcreation_type enumWhen investor_id is null, the valuation is for the asset as a whole. When set, it's a per-investor holding valuation.
Tracks how many units an investor holds in an asset at a specific point in time. Used for pro-rata calculations, VPU (value per unit) calculations, and unit-based distribution splitting.
investor_id FK → investorsasset_id FK → assetsunits decimal(28,14)held_at timestampFee records are linked to a CashFlow and describe charges applied to a transaction. There are 13 fee types covering management fees, performance fees, organizational expenses, and more.
cash_flow_id FK → cash_flowstype FeeType enum (13 values)total decimal(28,14)percentage decimal, nullableTax follows the same pattern — linked to a cash flow with a type and total.
When money flows back to investors (distributions), it needs to be categorized for tax and reporting purposes. Each distribution cash flow can be broken into components:
This matters for investor reporting — LPs need to know whether they're getting their original capital back, earning gains, or receiving dividends, as each has different tax implications.
AssetTransfer records transfers between investors — when an LP sells their position to another LP. Tracks transferor_id, transferee_id, commitment_transferred, and commitment_percentage.
AdjustmentGroup / Adjustment handles adjustments to open committed capital. Each adjustment is either an INCREASE or DECREASE and modifies the effective commitment amount.
FundCash / FundCashPot manages fund-level cash — separate from investor cash flows. A fund can have multiple cash pots (e.g., general account, reserve account), each tracking inflows, outflows, and running balance.
When an investor requests a redemption or transfer through the portal, it doesn't happen immediately. Instead, a RequestedTransaction is created and follows a state machine:
Types: REDEMPTION, ASSET_TRANSFER, ASSET_TRANSFER_PIT (point-in-time transfer)
The EAV Pattern
Some data in Portfolio doesn't live in regular columns. Instead, it uses an Entity-Attribute-Value (EAV) pattern — key-value pairs stored in separate tables with a JSON value column.
AssetData stores flexible metadata about an asset. Each row is a key-value pair where type is the key and value is a JSON column.
| Type (key) | Example Value | Used For |
|---|---|---|
VINTAGE | "2019" | Allocation charts, filtering |
STYLE | "Buyout" | Allocation by style |
STAGE | "Growth" | Investment stage classification |
GEOGRAPHIC_ALLOCATION | {"North America": 60, "Europe": 40} | Geography pie charts |
SECTOR_ALLOCATION | {"Tech": 45, "Healthcare": 30, "Energy": 25} | Sector pie charts |
SUBSCRIPTION_TYPE | "drawdown", "units_assigned", or "fully_paid_up" | Determines subscription model |
FUND_TYPE | "OPEN_ENDED" or "CLOSED_ENDED" | Determines available operations |
CashFlowData attaches metadata to individual cash flows, following the same pattern.
| Type (key) | Example Value | Used For |
|---|---|---|
TYPE | "MANAGEMENT_FEE" | Sub-classification of the cash flow |
INVESTMENT_TYPE | "CO_INVESTMENT" | Categorizing investments |
FEE_TYPE | "ORGANIZATIONAL_EXPENSE" | Fee sub-classification |
Why use EAV? Flexibility. Portfolio's clients can have very different metadata requirements. One fund manager needs vintage and style; another needs custom sector breakdowns and geographic allocations that don't fit a fixed schema. EAV lets the system store arbitrary metadata without database migrations.
The tradeoff: You can't query these fields with normal SQL column filters, you can't enforce types at the database level, and the data doesn't show up in schema introspection tools. It's a pragmatic choice for metadata that varies per client, but it adds complexity.
If you're searching the codebase for where "vintage" or "geographic_allocation" is stored, you won't find a database column. These values live in the asset_data table as EAV rows with type = 'VINTAGE'. Look for the enum class AssetDataType and the AssetData model, not for column definitions in migrations.
Investors use soft deletes (deleted_at timestamp). When Laravel's exists validation rule checks whether an investor ID is valid, it automatically scopes the query to exclude soft-deleted records. This means an investor can appear to "not exist" even though their data is still in the database — and their cash flows still reference them. Watch for this when debugging validation errors on import.
The Dependency Chain
When onboarding a new client, data must be created in a specific order. Each entity depends on entities created before it. Violate this order and you'll get foreign key errors or broken references.
Tenant
The client's PostgreSQL schema must exist before anything else. Created via the Landlord API.
Investors
Create all investor records. These have no foreign key dependencies — they stand alone.
Assets
Create asset records. Parent assets must be created before children (due to parent_asset_id FK).
FundCash / FundCashPots
If the asset has fund-level cash management, create the fund cash record and its pots now.
Subscriptions
Link investors to assets via subscriptions. This creates the investor_asset pivot entry and the initial cash flow (DRAWDOWN, UNITS_ASSIGNED, or FULLY_PAID_UP depending on the asset's subscription type).
Capital Calls
Call capital from committed investors. Requires subscriptions to exist so the system knows who committed what.
Valuations
Set NAV values for assets and/or specific investor holdings. Can be asset-level or per-investor.
Distributions
Return capital to investors. Requires investors to hold units in the asset (from subscriptions/capital calls).
Fees, Adjustments, Transfers
All remaining transaction types. Fees need cash flows to attach to. Adjustments modify commitments. Transfers move positions between investors.
The XLSX import pipeline and the MCP import tools both follow this exact order. When bulk importing data for a new client, you upload Investors first, then Assets, then Subscriptions, and so on. The system will reject rows that reference entities that don't exist yet.
Step 5 (Subscriptions) is the critical bridge. Before a subscription exists, an investor and an asset are unrelated — they're just records in separate tables. The subscription is what links them together by creating both the investor_asset pivot entry and the first cash flow record. Without it, you can't create capital calls, distributions, or any other investor-asset transaction.
Knowledge Check
You need to find out which "vintage year" an asset belongs to. Where do you look?
asset_data table with type = 'VINTAGE'. It's not a column on the assets table. This is the EAV pattern in action — flexible metadata stored as key-value pairs rather than fixed columns.asset_data table as an EAV (Entity-Attribute-Value) row with type = 'VINTAGE'. This is one of the gotchas of the EAV pattern — you won't find it by looking at the schema.You're importing data for a new client and try to create a capital call, but the system rejects it. What's the most likely reason?
What's Next
You now understand the data model — the Big Four entities, the supporting cast, the EAV pattern, and the dependency chain. Next, we'll go deeper on asset structures — how parent/child hierarchies work, the difference between DIRECT and PRIVATE_EQUITY_FUND structures, sub-positions, and how these structures affect every calculation and report in the system.