Skip to content
  • Pricing
Sign inContact sales
Blog

Category

Engineering

Written by

Mehul Rastogi
Mehul Rastogi
Software Engineer
Copied link
Blog
Engineering

Designing Accounting Transactions as First-Class Domain Events

Jun 29, 2026 — 20 min read

Written by

Mehul Rastogi
Mehul Rastogi
Software Engineer
Copied link

If you've never had to build a fund accounting system, it sounds like the most boring software on earth. Money comes in from LPs. Money goes out to startups. A spreadsheet, basically, with stricter formatting and a quarterly statement at the end. Slow, conservative, mostly addition.

It isn't.

A year after the fund wires $500k into a startup, the company raises a Series A and that original investment converts into 120,000 shares of preferred stock. Six months later a stock split quintuples the share count. Then a competitor acquires part of the company in a share swap, leaving the fund holding pieces of two companies. A special dividend gets paid. Some shares get sold in a secondary round. The company IPOs. And finally the fund returns the remaining proceeds to its investors in cash.

Seven economic events. One investment. Every one of them has to land in the same books, balance to the penny, and still make sense two years later when an auditor pulls on the thread.

The instinct that mostly works

The natural way to build a fund accounting system is the way an accountant would build it on paper. Every event becomes a journal entry. You read off the debits and credits, post them to the general ledger, and reconcile at period end. The general ledger (GL) is what auditors actually verify, what GAAP defines down to the account, where the trial balance has to net to zero. Make it the source of truth in your code and you've matched five hundred years of practice.

The instinct holds up for a while. The wire-out is two lines, the dividend is two lines, the interest accrual is two lines. You learn the mapping in your head, the system stays legible, and at twenty event types it really does feel like the GL is enough.

The catch is that the ledger captures motion, not meaning. It records that cost basis moved from one asset to another, but not that it moved because a SAFE converted into preferred, or because the company was partially acquired in a stock-for-stock deal, or because of a non-taxable reorganization. Those are wildly different events. The ledger flattens them into the same shape. Mix them up, say, handle a stock split as if it were a sale, or a SAFE conversion as if it were a dividend, or an in-kind distribution as if it were cash, and the books fail in different, expensive ways. A 9x phantom gain. Income that was never earned. Cost basis consumed twice. Each is the kind of bug that costs three engineers a weekend to unwind and an accountant a quarter to trust the numbers again. The GL knows what moved. It can't tell you why.

The first response is to bolt on discriminators: a memo column, a transaction-type field, a side table tagging each ledger entry with what kind of event produced it. The second response, once those drift out of sync with reality, is defensive reconstruction code that tries to recover the event type from the shape of the entries it left behind. You're now reverse-engineering meaning from movement, two years after the fact, because the meaning was never recorded anywhere.

Inverting the dependency

The fix is to invert the dependency.

Treat each event as an immutable domain object. The event itself is the source of truth, and the ledger is one of several projections derived from it. The event row says: this is a non-taxable partial reorganization of 30% of asset X into asset Y, effective on date D, recorded on date R. A handler attached to that event type writes the ledger entries and an asset metadata snapshot: the asset's unit count and descriptive facts like company name, investment type, and dates.

We named that domain object AccountingTransaction (AT for short). An accounting transaction is already a standard accounting concept - a measurable economic event that affects the financial statements - so the name isn't something we invented. The happy accident is that it also lines up with the database sense of transaction, and the parallels run deep. A database transaction commits a unit of work atomically; the database's current state is whatever its full sequence of transactions added up to. An AccountingTransaction lifts that same idea to the accounting layer. The write is atomic: the event, its metadata snapshot, and its ledger entries land inside one database transaction, or none of them do. The event itself is the record of what happened, a dated, dimensioned description of the corporate action, written once and not overwritten with a different story. The ledger and metadata are projections of that record: queryable, derivable, but not authoritative on their own. Corrections arrive the same way any other fact does, as a new event.

There are ~75 subclasses of AccountingTransaction in our system, one for each kind of economic event a fund can witness, and every one is paired with a handler that produces two projections:

  1. Asset metadata. Unit count, dates, and descriptive attributes like company name and investment type. Written by apply_metadata!.
  2. Ledger entries. Balanced debits and credits with dimensions. Written by generate_general_ledger_entries!.

Every write starts as a Command, our name for the request layer that captures user or system intent ("book a dividend for this asset"). The Command's job is to construct the right AccountingTransaction subclass and call its handler. The handler does the rest. The shape is always:

The Command and the AccountingTransaction it creates capture two different things. The Command captures intent; the AccountingTransaction captures what is now true about the world ("a $105,000 dividend was paid against a portfolio company on March 12"). The ledger entries and metadata fall out of that, deterministically, from the handler.

A handful of properties follow from this design, and they're worth understanding up front because every section below leans on at least one:

  • Handlers stay small. Each one only knows how to project one event type. There is no giant if_dividend_else_reorg_else_split switch. The type system is the switch. The handler for a dividend doesn't need to know that a partial-liquidation handler exists.
  • The ledger is reproducible. Both metadata and ledger entries are projections of the event table. Drop them, replay the events in effective-date order, get the same answer.
  • Two clocks, not one. Every event carries effective_datetime (when the thing happened in the world) and knowledge_datetime (when we learned about it). You can correct a 2024 booking in 2026 and the ledger stays coherent at both moments.
  • Dimensions, not accounts, do the slicing. Rather than create a new ledger account per company or per LP, every debit and credit carries dimensions (asset_id, member_id, company_name). The chart of accounts stays bounded, a few hundred named accounts regardless of how many companies or LPs the fund touches, and queries slice on dimensions. "Cost basis in company X across all vehicles" is one GROUP BY away.

That's the whole system in a nutshell. The interesting parts only surface under load, when you trace a single asset through every weird, cost basis bending event the fund world can throw at it. Vextra AI, a fictional company we'll use throughout, is going to be that asset.

Vextra AI

In March 2024, our fund wires $500,000 into Vextra AI, a Delaware C-corp building inference infrastructure. The instrument is a SAFE (Simple Agreement for Future Equity) with a $20M post-money cap. Every one of the events from the opening is going to be an AccountingTransaction.

1. The wire goes out: NewInvestmentWired

The instant the wire leaves the fund's capital collection account, an AccountingTransaction::NewInvestmentWired is written. It carries the asset and the wired amount, which becomes the initial cost basis (the dollar amount the fund will measure future gains and losses against). The handler turns that into one journal entry:

DR 110 Investment Cost (Vextra SAFE) $500,000
CR 101 Capital Collection Cash $500,000

Rather than create a separate ledger account per company, every debit and credit tags itself with dimensions like asset_id and company_name. Cost basis in Vextra is then a GROUP BY asset_id away, and the same query shape works for any company in the portfolio.

2. The SAFE converts: AssetLiquidated

A year later, Vextra closes a Series A at a $60M post-money. Our $500k SAFE converts to 120,000 shares of Series A Preferred at the cap. No cash changes hands; this is a pure conversion.

A SAFE-to-preferred conversion in the same corporation moves cost basis without realizing any gain. The basis simply travels from the SAFE instrument to the new share lot.

DR 110 Investment Cost (Vextra Series A) $500,000
CR 110 Investment Cost (Vextra SAFE) $500,000

The two legs share the same account number, 110 Investment Cost. What distinguishes them is the asset_id dimension. The SAFE's balance, dimensioned, goes to zero. The Series A's balance, dimensioned, becomes $500k.

3. The stock split: StockSplit

Vextra does a 5-for-1 split before their next round. Our 120,000 shares become 600,000 shares.

The naive implementation books something. A transfer between two lots, maybe a restatement of basis. The share count quintupled, surely something hits the ledger.

It shouldn't. Cost basis is unchanged. Total fair value is unchanged. The economic substance is identical to a minute ago.

So the StockSplit handler does this:

def generate_general_ledger_entries!
# intentionally empty
end

The ledger gets nothing. The metadata gets one meaningful update: number_of_units is set to the new split-adjusted share count. Cost basis, total fair value, and every ledger account are untouched.

This is the cleanest example of why metadata and ledger live in separate paths. Splits are a fact about the asset, not a financial transaction.

4. Vextra acquires a competitor: NonTaxablePartialLiquidation

Vextra acquires a smaller competitor in a stock-for-stock deal. This takes the form of a partial reorganization: 30% of our Series A preferred is exchanged for shares of a new combined entity. No cash changes hands. Cost basis has to move, ratably.

NonTaxablePartialLiquidationHandler computes:

cost_per_unit = investment_cost_balance / num_units_before
= $500,000 / 600,000
= $0.8333.../unit
num_units_liquidated = 180,000 (30% of 600,000)
cost_to_downstream = cost_per_unit * num_units_liquidated
= $150,000

Ledger:

DR 110 Investment Cost (NewCo combined) $150,000
CR 110 Investment Cost (Vextra Series A) $150,000

The Vextra position's cost basis drops to $350,000 against 420,000 remaining shares, with the $0.8333/unit per-unit basis preserved. The new asset gets $150,000 of cost basis against whatever shares we received. Zero impact on the P&L (profit and loss statement). Nothing was sold, so nothing was gained or lost; basis just moved across the boundary between two assets.

5. A quarterly mark: ValuationChanged

Between deal events, quarter-end arrives. A third-party 409A valuation prices Vextra's Series A at $3/share. Our 420,000 shares are now worth $1,260,000. Cost basis sits at $350,000. The fund's books need to reflect that gap as unrealized gain.

A ValuationChanged AccountingTransaction is written. Its handler doesn't take a delta. It takes a target. You tell it: "this asset is now worth $1,260,000." The handler queries the ledger for the asset's current cost basis ($350k) and current unrealized gain balance ($0 on the first mark), computes what the target unrealized gain should be ($1.26M − $350k = $910k), and books the difference:

DR 111 Investment Unrealized Gain (Vextra) $910,000
CR 411 Income Statement Unrealized Gain $910,000

Cost basis is untouched. Unit count is untouched. Only the unrealized accounts move. The interesting design choice is the handler's shape. From ValuationChangedHandler:

unrealized_gain_target = total_fair_value - cost_basis_from_ledger
existing_unrealized = query_balance(account: 111, asset_id: ...)
change_to_book = unrealized_gain_target - existing_unrealized

The handler is cumulative-target, not incremental. Run it twice with the same fair value and the second run books nothing, because the delta is already zero. The handler is idempotent against its own output, which means retries, replays, and partial failures all converge on the same ledger state. You cannot double-book a quarterly mark.

Sales are where that idempotency earns its keep, though not in the handler you'd expect. A sale handler does two things: it relieves cost basis and books any realized gain. On a partial sale it never touches account 111. That would leave 111 stale right after every sale, because cost basis just changed but the unrealized number wasn't recomputed against it.

The fix lives in the orchestration. After every liquidation, the Command layer automatically fires a ValuationChanged on the asset that was sold, plus one on every non-cash asset it produced (the new shares from a reorganization, for instance). That mark is the same cumulative-target handler, so re-firing it can't double-count: it reads the new cost basis and fair value, computes the new target, and books only the delta against whatever is sitting in 111. The unrealized account trues itself up in the same workflow, automatically. And the whole thing is all-or-nothing: the sale and its follow-up re-mark either both commit, or neither does.

Full liquidations close out a different way. When the position drops to zero, the liquidation handler itself sweeps every per-asset account, including 111, in the same atomic transaction as the cost basis relief. The follow-up ValuationChanged still fires, but it finds 111 already at zero and books nothing. No manual unwind required in either path.

6. A special dividend: DividendReceived

Vextra has been quietly profitable on enterprise contracts. The board declares a $0.25/share special cash dividend. Our remaining 420,000 Series A shares entitle us to $105,000.

A dividend is pure income: it doesn't reduce cost basis, change unit count, or realize gain on the underlying position.

A DividendReceived AccountingTransaction records the income and the receivable:

DR 147 Dividend Receivable (Vextra) $105,000
CR 442 Income Statement Dividends $105,000

This is the cleanest counterexample to the partial reorg from one section ago: same fund, same asset, comparable dollar amount, completely different ledger. The reorg moved basis without touching income; the dividend touches income without moving basis. The decision lives in the AccountingTransaction subclass you pick, not in the handler's logic, and that's exactly why each handler stays small. There is no if_dividend_else_reorg switch anywhere. The type system is the switch.

When the dividend cash actually arrives, it lands in a dedicated dividend-cash account and later flows out to LPs through the standard distribution lifecycle we'll see in section 9. The same lifecycle handles any cash on the fund's books, regardless of which event produced it.

7. A secondary sale: TaxableLiquidation

Eighteen months later, we sell 60,000 of our remaining Vextra shares in a secondary at $5.50/share. This is a real sale to a real third party for real cash, so the position realizes gain.

TaxableLiquidationHandler does what NonTaxablePartialLiquidation does (relieves cost basis ratably) plus recognizes the gain to the income statement:

cost_relieved = ($350,000 / 420,000) * 60,000 = $50,000
proceeds = $5.50 * 60,000 = $330,000
realized_gain = $330,000 - $50,000 = $280,000

Ledger:

DR 102 Disbursement Cash $330,000
CR 110 Investment Cost (Vextra Series A) $50,000
CR 410 Income Statement Realized Gain $280,000

Three-way entry. Cash account takes the full $330k. Cost basis account relieves by the $50k tied to the units sold. The remaining $280k lands in P&L as realized gain. The $330k of cash sits in 102 and will eventually flow to LPs through the same standard distribution lifecycle shown in section 9, the same path any cash on the fund's books takes.

8. The IPO: convert, then sell

Two years later, Vextra goes public. The IPO prices at $10/share. Two AssetLiquidated events fire back-to-back: the Series A converts to Common, then the Common is sold at IPO.

Liquidation 1: Series A → Common. A clean single-leg conversion, the same shape as the earlier SAFE conversion. No cash, no gain. Basis just travels from one Vextra instrument to another:

DR 110 Investment Cost (Vextra Common) $300,000
CR 110 Investment Cost (Vextra Series A) $300,000

The Series A position closes. 360,000 Vextra Common now sits in 110 Investment Cost (Vextra Common) with $300,000 of basis, or $0.8333/share, preserved.

Liquidation 2: Common → cash. The fund sells all 360,000 Common shares at IPO at $10/share. A real sale to a real third party; gain realizes against the full position:

proceeds = 360,000 * $10 = $3,600,000
cost_relieved = $300,000 (full position)
realized_gain = $3,600,000 - $300,000 = $3,300,000

Ledger:

DR 102 Disbursement Cash $3,600,000
CR 110 Investment Cost (Vextra Common) $300,000
CR 410 Income Statement Realized Gain $3,300,000

After both events, the fund holds $3,600,000 cash in 102 and zero Vextra Common in 110. The $0.8333/share basis is fully recognized.

9. The cash distribution: DisbursementPaymentDue then DisbursementPaymentCompleted

The fund distributes the $3.6M cash proceeds to LPs. Two transactions, deliberately.

Accrual (DisbursementPaymentDue) is written per LP, with member_id as the dimension on every debit and credit. For a fund with three LPs holding 50/30/20%:

DR 561 Capital Distributions Paid (LP A) $1,800,000
DR 561 Capital Distributions Paid (LP B) $1,080,000
DR 561 Capital Distributions Paid (LP C) $720,000
CR 261 Capital Distributions Payable (LP A) $1,800,000
CR 261 Capital Distributions Payable (LP B) $1,080,000
CR 261 Capital Distributions Payable (LP C) $720,000

The $3.6M total only exists as a SUM over the member_id dimension. There's no aggregate entry written anywhere; the per-LP rows are all there is.

Settlement (DisbursementPaymentCompleted) is split the same way. Each LP's payable is relieved against cash, with dimensions intact:

DR 261 Capital Distributions Payable (LP A) $1,800,000
DR 261 Capital Distributions Payable (LP B) $1,080,000
DR 261 Capital Distributions Payable (LP C) $720,000
CR 102 Disbursement Cash (LP A) $1,800,000
CR 102 Disbursement Cash (LP B) $1,080,000
CR 102 Disbursement Cash (LP C) $720,000

Two transactions because they happen at different moments, often with different reconciliation states. The accrual fires the day the distribution is declared by the GP; the settlement fires the day cash actually moves through the wire. Until settlement, the fund's books reflect that each LP is individually owed money. After settlement, each LP's liability clears.

The point

The failure mode of traditional fund accounting is always the same: the ledger faithfully records what moved, but not why. A single line that moves cost basis from one asset to another could be a SAFE conversion, a stock-for-stock reorganization, or a preferred-to-common conversion at IPO, and the ledger shows you none of the difference. The fix is to invert the dependency. The event is the source of truth; the ledger is one of several projections of that event. Hand each event type its own handler, and the type system carries the weight that a switch statement usually does.

Half the design work is deciding what shouldn't get an entry. Splits aren't sales. Dividends aren't basis events. Conversions move basis without realizing gain. Each AccountingTransaction subclass is one of those decisions, made once at the type level.

The next unfamiliar instrument the fund has to support is a new subclass and a handler. No new columns, no new switches. The rest of the system doesn't notice.

This is one asset's journey through a handful of AT subclasses. There are ~75 in the system, and every one (including the ones we just walked through) has its own quirks worth a closer look. Future posts will dive deeper into specific transaction types: the long tail of AssetLiquidated patterns, the trickier corners of ValuationChanged and the distribution lifecycle, plus the ones we didn't touch at all (in-kind distributions, fee accruals, FX revaluation, convertible-note cash outs, rollovers, and more).


Latest articles

Engineering

Teaching a Spreadsheet Engine to Teach Itself

Jun 26, 2026 — 8 min read
Engineering

Everyone Is a Data Analyst Now

Jun 10, 2026 — 15 min read
Data

Is Venture Capital Intrinsically Cyclical?

Jun 8, 2026 — 16 min read
;
Contact salesSign in

Products

Fund Administration

  • Venture Funds
  • Rolling Funds
  • Scout Funds
  • SPVs
  • Roll Up Vehicles

Investor Management

  • Digital Subscriptions
  • Data Room

Pricing + Returns

  • Pricing
  • VC Fund Performance Calculator
  • RUV Calculator

Resources

Learn

  • Blog
  • Help Center
  • Education Center
  • Data Center

Company

  • About Us
  • Careers
  • Engineering

By AngelList

  • Rollups
  • Meridian
TermsPrivacyDisclosures© AL Advisors Management Inc.
Disclaimer:

The information contained herein is provided for informational and discussion purposes only and is not intended to be a recommendation for any investment, service, product, or other advice of any kind, and shall not constitute or imply an offer of any kind. Any investment opportunities and/or products or services shown here will only be completed pursuant to formal offering materials, a letter of intent, and/or any other agreements as determined by AngelList containing full details regarding risks, minimum investment, fees, and expenses of such transaction. The terms of any product, service, or particular investment opportunity, including size, costs, and other characteristics, are set forth in the applicable constituent documents for such product, service or particular investment opportunity and may differ materially from those presented in this presentation. Such terms are subject to change without notice. For more information on AngelList and its products and services, please see here.

Quotes included in these materials related to AngelList's services should not be construed in any way as an endorsement of AngelList's advice, analysis, or other service rendered to its clients.