Logistiki v0.1.0 — End-to-End Demo
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 Company
alias 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 Suspense
alias 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.policy
7. Show the generated journal
deposit_result.journal.status
deposit_result.journal.selected_policy
deposit_result.journal.idempotency_key
8. 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.backend
deposit_result.ledger_result.status
10. Show balance
Cash account (debit balance = positive):
{:ok, [cash_balance]} = Logistiki.balance("ASSETS:CASH:USD:NOSTRO")
cash_balance
Client liability account (credit balance = negative):
{:ok, [client_balance]} = Logistiki.balance("LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING")
client_balance
Parent 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.policy
Balances 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.net
13. 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.status
reversal_result.details[:reversal].reversal_of_id
The original journal is now reversed:
original = Accounting.get_journal!(fee_result.journal.id)
original.status
14. 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.net
Fee income is back to zero:
{:ok, [fee_restored]} = Logistiki.balance("INCOME:FEES:WIRE")
fee_restored.net
15. 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.balanced
tb.currencies
Enum.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.journal
open_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.backend
oracle_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.net
Both 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 |