Logistiki v0.1.0 — End-to-End Demo

Copy Markdown View Source
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, and account_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."
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

StepWhat happened
SetupSQLite in-memory database created and migrated — no PostgreSQL needed
1Loaded the Datalog knowledge program
2Created the Acme Holdings + Bluewater Trust entity trees
3Created the Assets / Liabilities / Income / Expenses / Suspense account trees
4Linked entities to accounts with :owner relationships
5Processed a $1,000 deposit — Logistiki.process/1
6Datalog selected :cash_deposit
7Journal built and posted (status "posted")
8Two postings: debit cash, credit client liability
9Simulation backend executed
10Balances: cash +$1,000, client -$1,000
11Statement with running balance
12Processed a $25 wire fee — :corporate_wire_fee
13Reversed the mistaken fee — new journal, original marked reversed
14Balances restored: client back to -$1,000, fee income back to $0
15Audit trail explains every step
16Trial balance balances per currency
17–18Balance sheet and income statement
19No-accounting-impact event (account opened) — no journal, audit only
20Beancount oracle backend verifies journals
21General ledger view
22Simulation and Beancount backends agree