The data models we built to handle accounting for 25k+ investment vehicles
Apr 1, 2026 — 11 min read

New information changes our understanding of the past. In fund accounting, you can't just update a record and move on. The system has to preserve provenance, make corrections explicit, and be able to explain what's recorded in the ledger: "This is what we knew yesterday, so we booked X, which explains why the report we ran says Y even though today it says Z, since we also booked W." Getting that right, reliably, across 25k+ investment vehicles, is the core engineering problem discussed in this post.
In a previous blog post, I explained that part of our team's mandate is programmatically turning the legal and economic reality of our customers' funds into deterministic accounting. This post goes a level deeper, not the what but the how, by defining the data models we built to enable it.
Venture funds, and the assets they hold, evolve over time. We learn about these updates as we receive documents. Sometimes those are simple notes in an email or a signed PDF. Other times, they can be complex waterfall models and proformas in Excel spreadsheets. We ingest these actions through a mix of internal and external user input in our UI, with both human and AI operations agents triggering chains of updates. These real-world events are piped through and translated into general ledger (GL) entries, which aggregate into financial metrics.
Roughly, the motion looks like this:
real-world event → document processed → application db → accounting transaction → ledger entries → financial statements
Think of it as version control for financial reality. Bitemporal filtering markers allow us to distinguish between effective date and knowledge date:
Every transaction and general ledger entry has these two dates associated with it. That distinction is one of the attributes that turns a CRUD application into an accounting system, enabling rigorous review and reporting workflows.
On top of this time construct, the core abstraction that allows us to track provenance is split between two concepts, which allow us to reproduce outputs given known inputs:
Command: what changed in the world?AccountingTransaction: given that change, what ledger entries should we book?A Command operates close to user intent. Our users do not ask our system to debit one account and credit another. They buy shares in a company. Or later, they learn those shares should be marked up because the company raised a new round. The Command updates the fund state in our database, and creates the relevant transactions once it's applied. The AccountingTransaction stores the event data needed to book the accounting and generates the corresponding ledger entries. For each workflow supported by our system, we worked closely with a dedicated team of CPAs to understand the myriad edge cases and break them into atomic units.
A Command is our API for changing fund state.
That state is broader than any one model or subsystem. It includes the legal and economic facts we store about a fund, including commitments, fees, assets, and cash obligations. A command is the thing that says: this specific operation is happening, under these rules, with this effective date, initiated by this user or system, with this evidence.
This model is useful because:
A command updates state in our database. When applied, it creates the necessary AccountingTransaction records as part of the same atomic operation. It does not touch the ledger directly. That separation is deliberate.
An AccountingTransaction represents an accounting event, downstream of a general state mutation.
The job of an accounting transaction is to hold the event data needed to do the accounting for that event. For each type of transaction, a handler generates metadata and creates ledger entries according to the rules for that event type. This is an important design rule: ledger entries are generated only from the transaction’s event_data and existing ledger entries, never by reaching back into broader fund state.
Why be this strict?
Because otherwise, the booking logic becomes nondeterministic. If a transaction handler can time travel and peek at whatever the current fund models say today, then rerunning the same accounting event tomorrow can produce different entries than it did yesterday. That is how you accidentally rewrite history.
The accounting transaction stores inputs that define a change. We record the relevant amounts, dates, and identifiers as understood when the event was created. This boundary is enforced in code.
Suppose a venture fund wires $100k to buy SomeCo shares on October 1st.
At the business-logic layer, that is a Command:
command = BuyAssetCommand.create!(fund: fund,company: someco,effective_date: "2025-10-01",shares: 10_000,purchase_price_usd: 100_000)
When applied, the Command updates the fund state: in the application database the fund now owns the asset. It also creates an accounting transaction representing the purchase:
AssetPurchasedAccountingTransaction.create!(event_data: {effective_date: "2025-10-01",knowledge_date: "2025-10-02",asset_id: someco_asset.id,amount_usd: 100_000})
That transaction books a simple entry:
effective_date: "2025-10-01"knowledge_date: "2025-10-02"debit SomeCo Investment (Cost) 100_000credit Cash 100_000
The important thing is that the accounting transaction stores the change, not a pointer to the current state. The command is allowed to look at the fund state and decide what happened. The accounting transaction is not.
Now suppose SomeCo raises a new round, on 12/15, implying our position is worth $150k. But we do not learn about that immediately. The round happens on 12/15, the books for 12/31 get closed on 1/15, and we only learn about the new round on 1/30. That new knowledge should come in as a new command:
command = MarkupAssetCommand.create!(fund: fund,asset: someco_asset,effective_date: "2025-12-15",fair_value_usd: 150_000)
The business logic compares that to the existing balances, and turns the $150k into a new accounting transaction for the $50k change:
AssetMarkedAccountingTransaction.create!(event_data: {effective_date: "2025-12-15",knowledge_date: "2026-01-30",asset_id: someco_asset.id,change_in_value_usd: 50_000})
That transaction books an unrealized gains entry:
effective_date: "2025-12-15"knowledge_date: "2026-01-30"debit Investment unrealized gains 50_000credit Net change in unrealized gains on investments 50_000
And with this entry in place, even though the books for 12/31 are closed, without rewriting the past we've established why the value on our balance sheet was $100k when we produced the report, even though new data tells us the real value should have been $150k.
When funds require cash to make investments, they call capital from their limited partners. The capital call flow is a good example of why we split Command and AccountingTransaction instead of writing directly to the ledger from workflow code.
In this flow, there are at least three distinct moments:
In our code, those map to separate commands and transactions:
# simplified, illustrative flowCPTR::Command::CapitalCalls::InitiateCapitalCall.create!(...)CPTR::Command::CapitalCalls::RecognizeCapitalCallDue.create!(...)CPTR::Command::RecordMemberCommitmentFunding.create!(...)
Initiate capital call: state, not booking.
InitiateCapitalCall creates a CapitalCall record, tying the percentage called, the relevant dates and the fund's identifier. This captures legal/economic intent, but does not book to the ledger. “We called capital” is not yet the same thing as “it is due now.”
Recognize due: create accounting obligation
RecognizeCapitalCallDue computes the cumulative percent due as of the effective date, calculates each impacted member’s due amount based on their commitment, and creates the relevant AccountingTransaction.
From there, the transaction handler generates metadata and ledger entries. In the common case, think of it as:
debit Outstanding Called Contributions 100_000credit Capital Contributions 100_000
The production logic is more nuanced than this textbook line. It true-ups against balances already on the books, including advanced and in-kind contribution buckets, so we can easily see who owes what.
3) Record funding: settle the receivable with cash
When cash arrives, RecordMemberCommitmentFunding creates its own accounting transactions, which books for every member:
debit Capital Collection Cash 100_000credit Outstanding Capital Contributions 100_000
If the member had overfunded, after outstanding is relieved the remaining amounts are credited to Advanced Contributions rather than distorting contribution balances. If needed, late-admission penalties are also handled in this same deterministic sequence.
Each accounting transaction carries the event payload needed for booking, then derives entries from that payload plus ledger history.
Buying an asset is a simple example, but this shape allows us to model future markups, liquidations, capital calls, and money movements. They all follow the same shape:
Ultimately, what this setup enables us to do is to separate concerns into very small actions with clearly defined business logic, which we can book against the accounts that our fund accountants specified. The AccountingTransaction handlers understand which balances should be consumed if anything needs to be trued up with explicit after-the-fact corrections, which accounts are to be credited or debited, and can operate independently of the rest of the system state.
Growing into our current system presented us with a lot of challenges, but we've proven that it works. The team now has a core platform on which we can grow incrementally, taking on more complex fund structures, and continuing to deal with the creative legal constructs our customers demand.
