The context for hierarchical virtual accounts.
Virtual accounts represent accounting accounts and account-like projections.
They form a tree maintained through a closure table
(Logistiki.VirtualAccounts.VirtualAccountClosure).
Rules enforced
- account code is unique
- only leaf accounts (no children) may allow postings
- posting accounts require a currency
- aggregation accounts may have a nil currency
- a child cannot be created under a posting account
- cycles are forbidden (the closure table is rewritten on move)
Summary
Functions
Returns the ids of all ancestors of account (including itself).
Creates a new virtual account. When :parent_id is given the account is
linked as a child and the closure table is updated.
Returns the ids of all descendants of account (including itself).
Fetches a single account by id.
Fetches a single account by id, raising if not found.
Fetches a single account by code.
Fetches a single account by code, raising if not found.
True when the account is a leaf (has no children). Roots are aggregation nodes and are not leaves.
Lists accounts, optionally filtered.
Lists all ancestors of account ordered from nearest to farthest, using the
closure table.
Lists direct children of account.
Lists all descendants of account (excluding the account itself), using the
closure table for O(rows) performance.
Moves account under new_parent (which may be nil for a root), rewriting
the closure table for the entire subtree. Cycles are prevented.
True when the account accepts postings — i.e. posting_allowed: true and
status: "active".
Resolves an account from either a %VirtualAccount{}, an id, or an account
code. Tries code first for binary inputs, then falls back to numeric id.
Updates a virtual account's mutable attributes (not its parent — use
move_account/2 to change the parent).
Functions
@spec ancestor_ids(Logistiki.VirtualAccounts.VirtualAccount.t()) :: [integer()]
Returns the ids of all ancestors of account (including itself).
Arguments
account—%VirtualAccount{}.
Returns
[integer()]— ancestor ids includingaccount.id.
Examples
iex> Logistiki.VirtualAccounts.ancestor_ids(nostro_account)
[4, 3, 2, 1]
@spec create_account(map()) :: {:ok, Logistiki.VirtualAccounts.VirtualAccount.t()} | {:error, term()}
Creates a new virtual account. When :parent_id is given the account is
linked as a child and the closure table is updated.
Arguments
attrs—map()orkeyword()of account attributes::code—String.t— required (e.g."ASSETS:CASH:USD:NOSTRO"):name—String.t— required (e.g."Nostro USD"):account_type—String.t— required (e.g."asset","liability"):status—String.t— defaults to"active":parent_id—integer() | nil— the parent account id;nilfor roots:currency—String.t— required whenposting_allowed: true(e.g."USD"):posting_allowed—boolean()— defaults tofalse; onlytruefor leaf accounts:normal_balance—String.t—"debit"or"credit":external_id—String.t:metadata—map()
Returns
{:ok, %VirtualAccount{}}— the created account.{:error, %Ecto.Changeset{}}— validation failed.{:error, :parent_not_found}— theparent_iddoes not reference an existing account.{:error, :parent_is_posting_account}— the parent is a posting account (cannot have children).
Examples
iex> {:ok, root} = Logistiki.VirtualAccounts.create_account(%{
...> code: "ASSETS", name: "Assets", account_type: "asset"
...> })
iex> root.parent_id
nil
iex> {:ok, leaf} = Logistiki.VirtualAccounts.create_account(%{
...> code: "ASSETS:CASH:USD:NOSTRO", name: "Nostro USD", account_type: "asset",
...> currency: "USD", posting_allowed: true, normal_balance: "debit",
...> parent_id: root.id
...> })
iex> leaf.posting_allowed
true
iex> {:error, changeset} = Logistiki.VirtualAccounts.create_account(%{
...> code: "BAD", name: "Bad", account_type: "asset", posting_allowed: true
...> })
iex> errors_on(changeset)[:currency]
["can't be blank when posting is allowed"]
@spec descendant_ids(Logistiki.VirtualAccounts.VirtualAccount.t()) :: [integer()]
Returns the ids of all descendants of account (including itself).
Uses the closure table directly — faster than list_descendants/1 when only
ids are needed (e.g. for balance aggregation queries).
Arguments
account—%VirtualAccount{}.
Returns
[integer()]— descendant ids includingaccount.id.
Examples
iex> Logistiki.VirtualAccounts.descendant_ids(assets_root)
[1, 2, 3, 4]
@spec get_account(integer()) :: {:ok, Logistiki.VirtualAccounts.VirtualAccount.t()} | {:error, :not_found}
Fetches a single account by id.
Arguments
id—integer()— the account primary key.
Returns
{:ok, %VirtualAccount{}}— the account was found.{:error, :not_found}— no account with that id.
Examples
iex> {:ok, account} = Logistiki.VirtualAccounts.get_account(10)
iex> account.code
"ASSETS:CASH:USD:NOSTRO"
iex> {:error, :not_found} = Logistiki.VirtualAccounts.get_account(999)
@spec get_account!(integer()) :: Logistiki.VirtualAccounts.VirtualAccount.t()
Fetches a single account by id, raising if not found.
Arguments
id—integer()— the account primary key.
Returns
%VirtualAccount{}— the account. RaisesEcto.NoResultsErrorif not found.
Examples
iex> account = Logistiki.VirtualAccounts.get_account!(10)
iex> account.code
"ASSETS:CASH:USD:NOSTRO"
@spec get_account_by_code(String.t()) :: {:ok, Logistiki.VirtualAccounts.VirtualAccount.t()} | {:error, :not_found}
Fetches a single account by code.
Arguments
code—String.t()— the account code (e.g."ASSETS:CASH:USD:NOSTRO").
Returns
{:ok, %VirtualAccount{}}— the account was found.{:error, :not_found}— no account with that code.
Examples
iex> {:ok, account} = Logistiki.VirtualAccounts.get_account_by_code("ASSETS:CASH:USD:NOSTRO")
iex> account.code
"ASSETS:CASH:USD:NOSTRO"
iex> {:error, :not_found} = Logistiki.VirtualAccounts.get_account_by_code("UNKNOWN")
@spec get_account_by_code!(String.t()) :: Logistiki.VirtualAccounts.VirtualAccount.t()
Fetches a single account by code, raising if not found.
Arguments
code—String.t()— the account code (e.g."ASSETS:CASH:USD:NOSTRO").
Returns
%VirtualAccount{}— the account. RaisesEcto.NoResultsErrorif not found.
Examples
iex> account = Logistiki.VirtualAccounts.get_account_by_code!("ASSETS:CASH:USD:NOSTRO")
iex> account.currency
"USD"
@spec leaf?(Logistiki.VirtualAccounts.VirtualAccount.t()) :: boolean()
True when the account is a leaf (has no children). Roots are aggregation nodes and are not leaves.
Arguments
account—%VirtualAccount{}.
Returns
boolean()—trueif the account has no children,falseotherwise.
Examples
iex> Logistiki.VirtualAccounts.leaf?(nostro_account)
true
iex> Logistiki.VirtualAccounts.leaf?(assets_root)
false
@spec list_accounts(keyword()) :: [Logistiki.VirtualAccounts.VirtualAccount.t()]
Lists accounts, optionally filtered.
Arguments
opts—keyword()of options::status—String.t— e.g."active","frozen":account_type—String.t— e.g."asset","liability":currency—String.t— e.g."USD":parent_id—integer() | nil— filter by parent;nillists roots
Returns
[VirtualAccount.t()]— ordered by code ascending. Empty list if none match.
Examples
iex> Logistiki.VirtualAccounts.list_accounts(account_type: "asset")
[%VirtualAccount{code: "ASSETS", ...}, %VirtualAccount{code: "ASSETS:CASH", ...}]
iex> Logistiki.VirtualAccounts.list_accounts(status: "active", currency: "USD")
[%VirtualAccount{code: "ASSETS:CASH:USD:NOSTRO", ...}]
@spec list_ancestors(Logistiki.VirtualAccounts.VirtualAccount.t()) :: [ Logistiki.VirtualAccounts.VirtualAccount.t() ]
Lists all ancestors of account ordered from nearest to farthest, using the
closure table.
Arguments
account—%VirtualAccount{}— the descendant account.
Returns
[VirtualAccount.t()]— ordered bydepthascending. Empty list if the account is a root.
Examples
iex> Logistiki.VirtualAccounts.list_ancestors(nostro_account)
[%VirtualAccount{code: "ASSETS:CASH:USD", ...}, %VirtualAccount{code: "ASSETS:CASH", ...}, %VirtualAccount{code: "ASSETS", ...}]
@spec list_children(Logistiki.VirtualAccounts.VirtualAccount.t()) :: [ Logistiki.VirtualAccounts.VirtualAccount.t() ]
Lists direct children of account.
Arguments
account—%VirtualAccount{}— the parent account.
Returns
[VirtualAccount.t()]— ordered by code. Empty list if no children.
Examples
iex> Logistiki.VirtualAccounts.list_children(assets_root)
[%VirtualAccount{code: "ASSETS:CASH", ...}]
@spec list_descendants(Logistiki.VirtualAccounts.VirtualAccount.t()) :: [ Logistiki.VirtualAccounts.VirtualAccount.t() ]
Lists all descendants of account (excluding the account itself), using the
closure table for O(rows) performance.
Arguments
account—%VirtualAccount{}— the root of the subtree.
Returns
[VirtualAccount.t()]— ordered by code. Empty list if no descendants.
Examples
iex> Logistiki.VirtualAccounts.list_descendants(cash_root)
[%VirtualAccount{code: "ASSETS:CASH:USD", ...}, %VirtualAccount{code: "ASSETS:CASH:USD:NOSTRO", ...}]
@spec move_account( Logistiki.VirtualAccounts.VirtualAccount.t(), Logistiki.VirtualAccounts.VirtualAccount.t() | integer() | nil ) :: {:ok, Logistiki.VirtualAccounts.VirtualAccount.t()} | {:error, :cycle_detected | term()}
Moves account under new_parent (which may be nil for a root), rewriting
the closure table for the entire subtree. Cycles are prevented.
Arguments
account—%VirtualAccount{}— the account to move.new_parent— one of:%VirtualAccount{}— the new parentnil— move to rootinteger()— the new parent's id
Returns
{:ok, %VirtualAccount{}}— the moved account with updatedparent_id.{:error, :cycle_detected}— moving would create a cycle.{:error, term()}— the move failed (rolled back).
Examples
iex> {:ok, moved} = Logistiki.VirtualAccounts.move_account(cash_account, new_root)
iex> moved.parent_id
20
iex> {:error, :cycle_detected} = Logistiki.VirtualAccounts.move_account(parent, child)
@spec posting_account?(Logistiki.VirtualAccounts.VirtualAccount.t()) :: boolean()
True when the account accepts postings — i.e. posting_allowed: true and
status: "active".
Arguments
account—%VirtualAccount{}or any term.
Returns
boolean()—trueonly whenposting_allowed == trueandstatus == "active".
Examples
iex> Logistiki.VirtualAccounts.posting_account?(nostro_account)
true
iex> Logistiki.VirtualAccounts.posting_account?(assets_root)
false
iex> Logistiki.VirtualAccounts.posting_account?(%{posting_allowed: true, status: "frozen"})
false
@spec resolve(Logistiki.VirtualAccounts.VirtualAccount.t() | String.t() | integer()) :: {:ok, Logistiki.VirtualAccounts.VirtualAccount.t()} | {:error, :not_found}
Resolves an account from either a %VirtualAccount{}, an id, or an account
code. Tries code first for binary inputs, then falls back to numeric id.
Arguments
account— one of:%VirtualAccount{}— returned directlyString.t— interpreted as an account code first, then as a numeric idinteger()— interpreted as an account id
Returns
{:ok, %VirtualAccount{}}— the account was found.{:error, :not_found}— the account could not be resolved.
Examples
iex> {:ok, account} = Logistiki.VirtualAccounts.resolve("ASSETS:CASH:USD:NOSTRO")
iex> account.id
10
iex> {:ok, account} = Logistiki.VirtualAccounts.resolve(10)
iex> account.code
"ASSETS:CASH:USD:NOSTRO"
iex> {:ok, account} = Logistiki.VirtualAccounts.resolve(existing_account_struct)
@spec update_account(Logistiki.VirtualAccounts.VirtualAccount.t(), map()) :: {:ok, Logistiki.VirtualAccounts.VirtualAccount.t()} | {:error, Ecto.Changeset.t()}
Updates a virtual account's mutable attributes (not its parent — use
move_account/2 to change the parent).
Arguments
account—%VirtualAccount{}— the account to update.attrs—map()of attributes to change (e.g.%{status: "frozen", normal_balance: "credit"}).
Returns
{:ok, %VirtualAccount{}}— the updated account.{:error, %Ecto.Changeset{}}— validation failed.
Examples
iex> {:ok, updated} = Logistiki.VirtualAccounts.update_account(account, %{status: "frozen"})
iex> updated.status
"frozen"