System.put_env("LOGISTIKI_DB_ADAPTER", "sqlite")
Mix.install(
[
# {:logistiki, path: Path.expand("../..", __DIR__)},
{:logistiki, "~> 0.1.0"},
{:ecto_sqlite3, "~> 0.24.1"}
],
config: [
{:logistiki, [{Logistiki.Repo, [database: ":memory:", pool_size: 1]}]},
{:beancount_ex, [{:engine, Beancount.Engine.Elixir}, {Beancount.Repo, [database: ":memory:", pool_size: 1]}]}
]
)Section
# Run migrations against the in-memory SQLite database.
Ecto.Migrator.run(
Logistiki.Repo,
Application.app_dir(:logistiki, "priv/repo/migrations"),
:up,
all: true
)
"Migrations complete — in-memory SQLite ready."No external dependencies. This livebook runs entirely in-memory using SQLite. No PostgreSQL installation is required.
1. Load the knowledge program
The Datalog knowledge base (Logistiki.Knowledge.Program) holds business rules,
accounting policies, posting templates, and account mappings as facts and rules.
program = Logistiki.Knowledge.load_program()The program module declares:
- Relations for per-event runtime facts (
event_type,event_amount_cents,event_account, …) - Rules that derive
blocked,requires_approval,policy, andaccount_role - Static facts for templates, template postings, required dimensions, and static account mappings
Logistiki.Knowledge.Rules.categories()2. Create the business entity tree
Acme Holdings
Acme Trading Ltd
Acme Treasury Ltd
Bluewater Trust
Bluewater Operating Companyalias Logistiki.BusinessEntities
{:ok, acme_holdings} = BusinessEntities.create_entity(%{name: "Acme Holdings", entity_type: "company", status: "active"})
{:ok, acme_trading} = BusinessEntities.create_entity(%{name: "Acme Trading Ltd", entity_type: "company", status: "active", parent_id: acme_holdings.id})
{:ok, acme_treasury} = BusinessEntities.create_entity(%{name: "Acme Treasury Ltd", entity_type: "company", status: "active", parent_id: acme_holdings.id})
{:ok, bluewater} = BusinessEntities.create_entity(%{name: "Bluewater Trust", entity_type: "trust", status: "active"})
{:ok, bluewater_op} = BusinessEntities.create_entity(%{name: "Bluewater Operating Company", entity_type: "company", status: "active", parent_id: bluewater.id})
entities = %{acme_holdings: acme_holdings, acme_trading: acme_trading, acme_treasury: acme_treasury, bluewater: bluewater, bluewater_op: bluewater_op}Verify the closure table:
BusinessEntities.list_descendants(acme_holdings) |> Enum.map(& &1.name)3. Create the virtual account tree
Assets
Cash
USD
Nostro USD
Liabilities
Client Deposits
USD
Acme Holdings
Operating
Payroll
Escrow
Bluewater Trust
Operating
Income
Fees
Wire Fees
Expenses
Interest Expense
Suspense
USD Suspensealias Logistiki.VirtualAccounts
# Helper to create the tree recursively
create = fn parent_id, code, type, normal, currency, children, f ->
{:ok, account} = VirtualAccounts.create_account(%{
code: code,
name: code |> String.split(":") |> List.last() |> String.capitalize(),
account_type: type,
currency: currency,
normal_balance: normal,
posting_allowed: children == [],
parent_id: parent_id
})
child_accounts = Enum.reduce(children, %{}, fn {c, t, n, cur, ch}, acc ->
Map.merge(acc, f.(account.id, c, t, n, cur, ch, f))
end)
Map.put(child_accounts, code, account)
end
accounts = create.(nil, "ASSETS", "asset", "debit", nil, [
{"ASSETS:CASH", "asset", "debit", nil, [
{"ASSETS:CASH:USD", "asset", "debit", nil, [
{"ASSETS:CASH:USD:NOSTRO", "asset", "debit", "USD", []}
]}
]}
], create)
accounts = Map.merge(accounts, create.(nil, "LIABILITIES", "liability", "credit", nil, [
{"LIABILITIES:CLIENT_DEPOSITS", "liability", "credit", nil, [
{"LIABILITIES:CLIENT_DEPOSITS:USD", "liability", "credit", nil, [
{"LIABILITIES:CLIENT_DEPOSITS:USD:ACME", "client", "credit", nil, [
{"LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING", "client", "credit", "USD", []},
{"LIABILITIES:CLIENT_DEPOSITS:USD:ACME:PAYROLL", "client", "credit", "USD", []},
{"LIABILITIES:CLIENT_DEPOSITS:USD:ACME:ESCROW", "client", "credit", "USD", []}
]},
{"LIABILITIES:CLIENT_DEPOSITS:USD:BLUEWATER", "client", "credit", nil, [
{"LIABILITIES:CLIENT_DEPOSITS:USD:BLUEWATER:OPERATING", "client", "credit", "USD", []}
]}
]}
]}
], create))
accounts = Map.merge(accounts, create.(nil, "INCOME", "income", "credit", nil, [
{"INCOME:FEES", "income", "credit", nil, [
{"INCOME:FEES:WIRE", "fee", "credit", "USD", []}
]}
], create))
accounts = Map.merge(accounts, create.(nil, "EXPENSES", "expense", "debit", nil, [
{"EXPENSES:INTEREST", "expense", "debit", "USD", []}
], create))
accounts = Map.merge(accounts, create.(nil, "SUSPENSE", "suspense", "debit", nil, [
{"SUSPENSE:USD", "suspense", "debit", "USD", []}
], create))
"Created #{map_size(accounts)} accounts."4. Link entities to accounts
alias Logistiki.Relationships
Relationships.link_entity_account(entities.acme_holdings, accounts["LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING"], :owner)
Relationships.link_entity_account(entities.acme_holdings, accounts["LIABILITIES:CLIENT_DEPOSITS:USD:ACME:PAYROLL"], :owner)
Relationships.link_entity_account(entities.acme_holdings, accounts["LIABILITIES:CLIENT_DEPOSITS:USD:ACME:ESCROW"], :owner)
Relationships.link_entity_account(entities.bluewater, accounts["LIABILITIES:CLIENT_DEPOSITS:USD:BLUEWATER:OPERATING"], :owner)
"Entities linked to accounts."5. Process a deposit event
A $1,000 deposit received into Acme Operating. The pipeline will:
normalize → generate Datalog facts → evaluate business rules → select a policy →
resolve a template → build a journal → validate invariants → execute via the
ledger backend → record audit evidence.
deposit = Logistiki.Demo.Seeds.deposit_event(
"LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING",
"1000.00"
)
{:ok, deposit_result} = Logistiki.process(deposit)6. Show the policy selected by Datalog
The knowledge layer evaluated the event's facts and derived
policy(:evt, :cash_deposit) — the only matching policy.
deposit_result.policy7. Show the generated journal
deposit_result.journal.statusdeposit_result.journal.selected_policydeposit_result.journal.idempotency_key8. Show the generated postings
Two postings: debit cash (nostro), credit client liability.
alias Logistiki.Accounting
Accounting.list_postings(deposit_result.journal)9. Show the ledger backend result
deposit_result.ledger_result.backenddeposit_result.ledger_result.status10. Show balance
Cash account (debit balance = positive):
{:ok, [cash_balance]} = Logistiki.balance("ASSETS:CASH:USD:NOSTRO")
cash_balanceClient liability account (credit balance = negative):
{:ok, [client_balance]} = Logistiki.balance("LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING")
client_balanceParent account aggregates descendants:
{:ok, acme_balances} = Logistiki.balance("LIABILITIES:CLIENT_DEPOSITS:USD:ACME")
Enum.reduce(acme_balances, Decimal.new(0), fn b, acc -> Decimal.add(b.net, acc) end)Entity-owned accounts:
{:ok, entity_balances} = Logistiki.balance_for_entity(entities.acme_holdings)
Enum.reduce(entity_balances, Decimal.new(0), fn b, acc -> Decimal.add(b.net, acc) end)11. Show statement (with running balance)
{:ok, lines} = Logistiki.statement("LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING")
Enum.map(lines, fn line ->
%{
date: line.date,
account: line.account_code,
debit: line.debit,
credit: line.credit,
running_balance: line.running_balance,
policy: line.selected_policy
}
end)12. Process a wire fee event
A $25 wire fee assessed to Acme Operating.
fee = Logistiki.Demo.Seeds.fee_event(
"LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING",
"25.00"
)
{:ok, fee_result} = Logistiki.process(fee)The knowledge layer selected :corporate_wire_fee because:
event_type(:evt, :fee_assessed)event_fee_type(:evt, :wire_fee)event_entity_type(:evt, :corporate)
fee_result.policyBalances after the fee (client liability reduced by $25):
{:ok, [client_after_fee]} = Logistiki.balance("LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING")
client_after_fee.net{:ok, [fee_income]} = Logistiki.balance("INCOME:FEES:WIRE")
fee_income.net13. Reverse the mistaken fee
The fee was applied in error. Reversing creates a new journal that exactly
negates the original postings, and marks the original reversed.
{:ok, reversal_result} = Logistiki.Ledger.reverse_journal(fee_result.journal, %{reason: "mistaken fee"})reversal_result.statusreversal_result.details[:reversal].reversal_of_idThe original journal is now reversed:
original = Accounting.get_journal!(fee_result.journal.id)
original.status14. Show balance restored
The reversal cancels the fee. The client balance is back to -$1,000:
{:ok, [client_restored]} = Logistiki.balance("LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING")
client_restored.netFee income is back to zero:
{:ok, [fee_restored]} = Logistiki.balance("INCOME:FEES:WIRE")
fee_restored.net15. Show audit evidence explaining everything
Every pipeline stage was recorded as an audit event. The full trail explains why each posting exists — the business event, the rules, the policy, the template, the journal, and the ledger result.
trail = Logistiki.Audit.trail_for_event(deposit.id)
Enum.map(trail, fn e ->
%{action: e.action, resource_type: e.resource_type, journal_id: e.journal_id}
end)The fee event's audit trail:
fee_trail = Logistiki.Audit.trail_for_event(fee.id)
Enum.map(fee_trail, & &1.action)16. Trial balance
A trial balance across all posted and reversed journals. Debits and credits must balance per currency.
{:ok, tb} = Logistiki.trial_balance()tb.balancedtb.currenciesEnum.map(tb.lines, fn line ->
%{
account: line.account_code,
debits: line.debit_total,
credits: line.credit_total,
net: line.net,
currency: line.currency
}
end)17. Balance sheet
bs = Logistiki.balance_sheet()%{
assets: bs.totals_by_currency["USD"][:assets],
liabilities: bs.totals_by_currency["USD"][:liabilities],
equity: bs.totals_by_currency["USD"][:equity]
}18. Income statement
is = Logistiki.income_statement()is.net_profit_by_currency["USD"]19. Process a no-accounting-impact event
A customer account was opened. This event has has_accounting_impact: false —
it produces audit evidence but no journal.
account_opened = Logistiki.Demo.Seeds.account_opened_event(
"LIABILITIES:CLIENT_DEPOSITS:USD:ACME:ESCROW"
)
{:ok, open_result} = Logistiki.process(account_opened)open_result.journalopen_result.explanation[:reason]20. Switch to the Beancount oracle backend
The Beancount backend verifies every journal with Beancount.check/1 before
persisting. It is the executable specification for Logistiki accounting
behavior.
Logistiki.put_ledger_backend(Logistiki.Ledger.Beancount)
another_deposit = Logistiki.Demo.Seeds.deposit_event(
"LIABILITIES:CLIENT_DEPOSITS:USD:ACME:PAYROLL",
"500.00"
)
{:ok, oracle_result} = Logistiki.process(another_deposit)oracle_result.ledger_result.backendoracle_result.ledger_result.details[:oracle]Verify the full ledger with Beancount.check/1:
Logistiki.Ledger.Beancount.verify_ledger()Get oracle balances (BQL SELECT account, sum(position) GROUP BY account):
{:ok, oracle_balances} = Logistiki.Ledger.Beancount.oracle_balances()# Show the oracle's view of the cash account
{:ok, cash_account} = Logistiki.VirtualAccounts.get_account_by_code("ASSETS:CASH:USD:NOSTRO")
bc_name = Logistiki.Ledger.BeancountMapper.to_beancount_account(cash_account)
oracle_balances[bc_name]21. General ledger view
All posted and reversed postings, ordered by effective date and journal:
gl = Logistiki.general_ledger()
Enum.map(gl.lines, fn line ->
%{
date: line.date,
account: line.account_code,
direction: if(line.debit, do: "debit", else: "credit"),
amount: line.debit || line.credit,
currency: line.currency,
policy: line.selected_policy
}
end)22. Backend agreement
The simulation and Beancount backends should agree. Both compute balances from the same persisted postings; the Beancount backend additionally verifies each journal with the oracle.
# Switch back to simulation for the final comparison
Logistiki.put_ledger_backend(Logistiki.Ledger.Simulation)
{:ok, [sim_cash]} = Logistiki.balance("ASSETS:CASH:USD:NOSTRO")# Oracle view
oracle_balances[bc_name]# Simulation view
sim_cash.netBoth should show the same positive (debit) balance for the cash account.
Summary
| Step | What happened |
|---|---|
| Setup | SQLite in-memory database created and migrated — no PostgreSQL needed |
| 1 | Loaded the Datalog knowledge program |
| 2 | Created the Acme Holdings + Bluewater Trust entity trees |
| 3 | Created the Assets / Liabilities / Income / Expenses / Suspense account trees |
| 4 | Linked entities to accounts with :owner relationships |
| 5 | Processed a $1,000 deposit — Logistiki.process/1 |
| 6 | Datalog selected :cash_deposit |
| 7 | Journal built and posted (status "posted") |
| 8 | Two postings: debit cash, credit client liability |
| 9 | Simulation backend executed |
| 10 | Balances: cash +$1,000, client -$1,000 |
| 11 | Statement with running balance |
| 12 | Processed a $25 wire fee — :corporate_wire_fee |
| 13 | Reversed the mistaken fee — new journal, original marked reversed |
| 14 | Balances restored: client back to -$1,000, fee income back to $0 |
| 15 | Audit trail explains every step |
| 16 | Trial balance balances per currency |
| 17–18 | Balance sheet and income statement |
| 19 | No-accounting-impact event (account opened) — no journal, audit only |
| 20 | Beancount oracle backend verifies journals |
| 21 | General ledger view |
| 22 | Simulation and Beancount backends agree |