Next-generation infrastructure for investment management

{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions) field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
endend
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :showend
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show {:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end {:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period), share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period), add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed -> add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance() import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do {:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3) {:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
callend
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t) use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t) statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
endend
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Workerend
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false


{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions) field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
endend
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :showend
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show {:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end {:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period), share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period), add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed -> add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance() import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do {:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3) {:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp matches?(lot, position) do
Decimal.equal?(lot.quantity, position.quantity) and
Decimal.equal?(lot.cost_basis, position.cost_basis) and
lot.settlement_date == position.settlement_date
end
scope "/portal", EvoWeb do
pipe_through [:browser, :require_investor]
live_session :portal, on_mount: {EvoWeb.Auth, :ensure_investor}
do
live "/", PortalLive.Index, :index
live "/documents", PortalLive.Documents, :index
live "/documents/:id", PortalLive.Documents, :show
live "/capital-account", PortalLive.CapitalAccount, :index
end
end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)end
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
callend
def allocate(%Call{amount: amount} = call, commitments) do
total = commitments |> Enum.map(& &1.amount) |> Enum.reduce(Dec
imal.new(0), &Decimal.add/2)
Enum.map(commitments, fn commitment ->
share = Decimal.div(commitment.amount, total)
%Allocation{
investor_id: commitment.investor_id,
call_id: call.id,
amount: Decimal.mult(amount, share) |> Decimal.round(2),
due_date: call.due_date
}
end)
end
test "closing the quarter rolls every capital account" do
fund = insert(:fund, investors: 3)
period = ~D[2026-06-30]
assert {:ok, snapshot} = Funds.close_quarter(fund, period)
assert length(snapshot.accounts) == 3
assert Enum.all?(snapshot.accounts, &Decimal.positive?(&1.closi
ng_balance))
end
defmodule Evo.Repo.Migrations.AddLineageToRecords do
use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t) use Ecto.Migration
def change do
alter table(:records) do
add :source_document_id, references(:documents, on_delete:
:nothing)
add :source_page, :integer
add :source_bbox, {:array, :float}
end
create index(:records, [:source_document_id])
end
end
{:ok, fund} = Ledger.open("fund-ii")
fund
|> CapitalCalls.pending()
|> Enum.each(fn call ->
call
|> Waterfall.allocate(fund.commitments)
|> Enum.map(&Notice.draft(&1, call.due_date))
|> Mailroom.send(track: true)
end)
def close_quarter(fund, period) do
with {:ok, nav} <- Valuations.settle(fund, period),
{:ok, accounts} <- CapitalAccounts.roll(fund, period, nav)
,
statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t) statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end statements = Enum.map(accounts, &Statement.render/1),
:ok <- Portal.publish(statements, period: period) do
Audit.snapshot(fund, period)
end
end
@impl true
def handle_info(:sync_custodians, state) do
feeds = Custodians.sync(state.fund)
diffs = reconcile(feeds, state.fund.positions)
if diffs != [], do: Review.queue(diffs)
Process.send_after(self(), :sync_custodians, :timer.hours(24))
{:noreply, state}
end
@impl true
def mount(%{"email" => email}, _session, socket) do
investor = CRM.get_by!(email: email)
docs = Dataroom.list_for(investor, since: investor.last_login_a
t)
{:ok, assign(socket, investor: investor, docs: docs)}
end
def reconcile(feeds, positions) do
by_isin = Map.new(positions, &{&1.isin, &1})
Enum.flat_map(feeds, fn feed ->
Enum.reject(feed.lots, &matches?(&1, by_isin[&1.isin]))
end)
end
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
endend
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Workerend
@waterfall Waterfall.define(preferred_return: 0.08, catch_up: 1.0
, carry: 0.2)
defmodule Evo.Funds.CapitalAccount do
use Ecto.Schema
import Ecto.Changeset
schema "capital_accounts" do
belongs_to :fund, Evo.Funds.Fund
belongs_to :investor, Evo.Investors.Investor
field :period, :date
field :opening_balance, :decimal
field :contributions, :decimal, default: Decimal.new(0)
field :distributions, :decimal, default: Decimal.new(0)
field :allocated_pnl, :decimal
field :closing_balance, :decimal
timestamps()
end
def changeset(account, attrs) do
account
|> cast(attrs, [:period, :opening_balance, :contributions, :d
istributions, :allocated_pnl])
|> validate_required([:period, :opening_balance])
|> put_closing_balance()
end
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
defp put_closing_balance(changeset) do
opening = get_field(changeset, :opening_balance)
pnl = get_field(changeset, :allocated_pnl) || Decimal.new(0)
contrib = get_field(changeset, :contributions)
dist = get_field(changeset, :distributions)
closing =
opening
|> Decimal.add(contrib)
|> Decimal.sub(dist)
|> Decimal.add(pnl)
put_change(changeset, :closing_balance, closing)
end
end
defmodule Evo.Workers.ExtractStatement do
use Oban.Worker, queue: :documents, max_attempts: 5
@impl Oban.Worker
def perform(%Oban.Job{args: %{"document_id" => id}}) do
document = Documents.get!(id)
with {:ok, pages} <- Documents.pages(document),
{:ok, fields} <- Extraction.run(pages, schema: :custodia
n_statement),
{:ok, record} <- Records.upsert(document, fields) do
Lineage.attach(record, document, fields.sources)
Phoenix.PubSub.broadcast(Evo.PubSub, "fund:" <> document.fu
nd_id, {:record_ready, record.id})
:ok
end
end
end
def handle_event("approve", %{"id" => id}, socket) do
{:ok, diff} = Review.approve(id, by: socket.assigns.current_use
r)
{:noreply, stream_insert(socket, :diffs, diff)}
end
def handle_event("reject", %{"id" => id, "reason" => reason}, soc
ket) do
{:ok, diff} = Review.reject(id, reason: reason)
{:noreply, stream_delete(socket, :diffs, diff)}
end
defp matches?(lot, nil), do: false
Evolutions provides technology solutions and operating expertise to firms across investment management, fund services, financial infrastructure and digital markets.
Evolutions provides technology solutions and operating expertise to firms across investment management, fund services, financial infrastructure and digital markets.
Our services
AI-native technologies we build ourselves
General Ledger
Entry
Account
Description
Amount
JE-1041
1000
Cash
Capital call received
4,250,000.00
JE-1042
1500
Investments
Purchase of portfolio stake
3,800,000.00
JE-1043
4100
Fees
Management fee, Q2
187,500.00
JE-1044
2200
Payables
Admin expense accrued
42,180.00
JE-1045
1000
Cash
Bank interest
3,912.44
JE-1046
1500
Investments
Valuation change, Q2
615,000.00
JE-1047
1000
Cash
Distribution paid to partners
1,200,000.00
JE-1048
2200
Payables
Audit fee accrued
36,000.00
JE-1049
4100
Fees
Management fee, Q3
187,500.00
General ledger reconstruction
Turn fragmented historical records into a coherent, auditable financial history across funds, entities and investors.
General Ledger
Entry
Account
Description
Amount
JE-1041
1000
Cash
Capital call received
4,250,000.00
JE-1042
1500
Investments
Purchase of portfolio stake
3,800,000.00
JE-1043
4100
Fees
Management fee, Q2
187,500.00
JE-1044
2200
Payables
Admin expense accrued
42,180.00
JE-1045
1000
Cash
Bank interest
3,912.44
JE-1046
1500
Investments
Valuation change, Q2
615,000.00
JE-1047
1000
Cash
Distribution paid to partners
1,200,000.00
JE-1048
2200
Payables
Audit fee accrued
36,000.00
JE-1049
4100
Fees
Management fee, Q3
187,500.00
General ledger reconstruction
Turn fragmented historical records into a coherent, auditable financial history across funds, entities and investors.








Data intelligence
Retrieve, extract and normalize information from documents, chats and handwritten notes into structured records with clear source lineage.
Strategy, technology and implementation
{}}}{{0_-:;/><<<<>=;+*0{}(((}1*+=<){1001})]>=:::;=/<]]]]<>/===/<{}}(}}{0*+:=><]]]<>=:-_1{}((}{0-;>[}{001{([</=;;=/><]]<</=;;;=/<1{}(((}10*+;/>]]]]<>=:-_1}((}{0*:/])}111{})]<>///><]]]<>=;:++;=>0{}(((}{1_-:=><]]]]</;+*01}}}{1_+;>[({{1{}([]<>><]][[]</;+---+;>_0{}(((}{0*+;=><]]]<>/;+*01{{{1_-:/<)(}{}}()[]]][[)))[</;+*_*-:/-_1{}}}}{1_-+;/><]]]<>=:-*01110_-:=>])(((()[[[[[)((}()]/;-_0_*+;+*_1{{}{{10*-:;/><<<<>/;:-*_00__-:=><[))))[[][[)(}{{{([>;-_00_-::+*_011{110_*+:=/>><>>/=;:-*****+:=/<]][[]]]]][(}{001{)<=+_00_*:;:+-*_00000_*-+:;=//>//=;;:+---+:;=/><<]<<>><<[)}10001}]/:*00_*+=;:+--*_____*--+:;=======;::::::;=/><<<<>>///>])}10110{)>;-___*+=;;::++---***---+::;;;;;;;;;;;;=/>><<<<>/==;=/<[}101101(<=:-**-+;;;;;;;::+++-----++++::::;;;;=//><]][]]>/=;::;/](101{10}[/;++++::;;======;;::++--------++::;==/>][[))[[</=:++:=<){01100}[<=;::;=+:;==//////=;::+--******--+:;=><[)((}()]>=:+++;/](10001})<>===//-+:;=/>><<>>/=;:+-**_____*-+:=><[(}{{{([</;+-+:/<){100{([]>>>><<*-+;=/><]]]<<>/;:+-__0000__-+;/<[({111{(]>;+++:=<[}{1{})[<<<<][[_*-:;/><][[[]<>/;:-*_011110_-:=>[({0001})<=:++:=>[(}}()[]<<<[)((0_*+:=/<][[[[]<>/;+-_01{{{10*-;/]({00001(]>=::;=>]))))]<>>>][(}}0_*-+;/><[[))[]<>=:+*01{{{{10*+=<){00100})</===/<][)[]>/=/><[({{0__-+:=/<][[)[[]</=:-_01{}{{1_-;>[}10110{(]>//><][[[]>=;;;/<[}{{__*--:;=><][[[[]<>=;+*_1{{{{10*+=<){0000{([]<<][)))[</;:+:=>[({{----++:;=/><]]]]<>/=:-*01{{{10_-;>[}1000{()[[[)(}}([<=:--+;/](}}:+++++::;=/>><<<>>/=;+-*001110_*:/]({11{}()[)(}{1{}[<=+**-:/<)((=;;::::::;;==/////==;:+-*_0000_*+=<[(}}}()[)(}1000{)<=+**-:=<[[[>>/=;;:::+:::;;;;;;;;::+-***_**-+=>[)))[[][)(101101(<=+---:=><]<[]<>/=;:++-----++:::::::++++-++:;/<][]]<><][}11{{10(]/:++:;/>>>/))[]>/;:+-*_____**--+::::;;;;;==/><<<>/==/>[}01}}10}]>;:;;=/>>/;}()[]>=;+*_0111110_*-+:;==//>><<]]]<>=;::;>]}01{{10}[</=//><>/;+{}()[</;+*_1{}}}}{10*-:;=><][[))[[]>=;+-+:/]}11{101([<<<<]]]</:-{{}([]>=+*01}(()((}10*+;/<[)(}}}()]>;+---:/](10001})[]][)))[</:*}}}([]>=:-_1}())))({0*+;>])(}{{{()</;+--+;/]({11{})[[[)(}{}(]/+_(())[<>=:-_1}())))({0*+;>])}{{{{([</;+++:=>])(}()[]][)({111}[/+_[[[]<>/=:-_0{}((((}{0*+;>])(}{}})]</;;:;=>][))[]<>><])}1000}[/:*>>>>>/=;:+*_01{}}{{1_*+;/<]))())[]>////><][[[]<>===>]){0000}[>:-====;;;:++-*__00000_*-+;=/><]]]<<<>>><][))))]</;::;/]){0001}[>;+++++++++++++---------++:;;===////>><])(}}}()]/;:++:/<){101{)]>=;*____**--+::::;;:::::+++++++++:;=/<[)}1101})</:+-+:/<)}{{}([<//=011110_*-+:;=/////==;:+-**____*+;/<)}10001})<=:++:;/][((()]<>//>1{}}{10_-+;=/><<<<>/=:+*_01111_-:/]({00101}[</;::;/>][[[]<//=/><{}}}}{1_*+;/><][[]<>=:-*0{{}}{0*:/]({0000{([</==//<]]]]</=;:;=>]1{}}}{1_*+;/>]][[]<>=:-_1{}}}{0-:/]({101{})[<<><<][[[]>=:+-+:=>[01{{{10_*+;=><]]]<>/;:-_0{{{{0_-;/])(}}}()[[]][))(()[</:-**-+=>[*_0000_*-+:;=/>>>>/=;+-*_000_*-:=/<][[[[]]]][)({{{{([>;+*__*+=>]+--****--++:;;;=;;;:++-****--+:;=/>>>>>/>><[)}10001}[>;-___*+=><=;;::+++---------------++::;;==///==;;:;;/<[(101100}[>;-*_*-:=/>>>>/==;:+-*__00000__*-+:;=/>>>>>/=;+----:=>[}101101}[>;+--+:;=//[[]]</=;+-_01{}}}{10_-:=/<]][]]>/;+-*__*+;>[}00110{(]>=;;;;=//=;)))[]</;+*0{}()))(}1_-;/<])))[]<=:-*_0_*+=>[}1000{})]>>//>>>>/;+
Ask a question
Technology modernization
Apply AI and modern infrastructure where accuracy, security, privacy, data quality and auditability matter.
Cross-border & regulatory
Plan market entry, licensing and regulatory architecture for funds and entities operating across jurisdictions.
Our mission
Investment management relies on scattered agreements, statements, notices, spreadsheets and networks. Most of this information is unstructured and disconnected, making it difficult to query and reconcile.
Our mission is to give every investment professional the tools to work with reliable financial information — linked, transparent and traceable — without manual effort and repeated friction.
We are building a new breed of infrastructure — one that we believe will dramatically increase market activity across all asset classes, and help capital reach more of the businesses and ideas that need it.