Logistiki.VirtualAccounts (logistiki v0.1.0)

Copy Markdown View Source

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

ancestor_ids(virtual_account)

(since 0.1.0)

Returns the ids of all ancestors of account (including itself).

Arguments

  • account%VirtualAccount{}.

Returns

  • [integer()] — ancestor ids including account.id.

Examples

iex> Logistiki.VirtualAccounts.ancestor_ids(nostro_account)
[4, 3, 2, 1]

create_account(attrs \\ %{})

(since 0.1.0)
@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

  • attrsmap() or keyword() of account attributes:
    • :codeString.trequired (e.g. "ASSETS:CASH:USD:NOSTRO")
    • :nameString.trequired (e.g. "Nostro USD")
    • :account_typeString.trequired (e.g. "asset", "liability")
    • :statusString.t — defaults to "active"
    • :parent_idinteger() | nil — the parent account id; nil for roots

    • :currencyString.trequired when posting_allowed: true (e.g. "USD")
    • :posting_allowedboolean() — defaults to false; only true for leaf accounts
    • :normal_balanceString.t"debit" or "credit"
    • :external_idString.t
    • :metadatamap()

Returns

  • {:ok, %VirtualAccount{}} — the created account.
  • {:error, %Ecto.Changeset{}} — validation failed.
  • {:error, :parent_not_found} — the parent_id does 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"]

descendant_ids(virtual_account)

(since 0.1.0)
@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 including account.id.

Examples

iex> Logistiki.VirtualAccounts.descendant_ids(assets_root)
[1, 2, 3, 4]

get_account(id)

(since 0.1.0)
@spec get_account(integer()) ::
  {:ok, Logistiki.VirtualAccounts.VirtualAccount.t()} | {:error, :not_found}

Fetches a single account by id.

Arguments

  • idinteger() — 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)

get_account!(id)

(since 0.1.0)

Fetches a single account by id, raising if not found.

Arguments

  • idinteger() — the account primary key.

Returns

Examples

iex> account = Logistiki.VirtualAccounts.get_account!(10)
iex> account.code
"ASSETS:CASH:USD:NOSTRO"

get_account_by_code(code)

(since 0.1.0)
@spec get_account_by_code(String.t()) ::
  {:ok, Logistiki.VirtualAccounts.VirtualAccount.t()} | {:error, :not_found}

Fetches a single account by code.

Arguments

  • codeString.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")

get_account_by_code!(code)

(since 0.1.0)
@spec get_account_by_code!(String.t()) :: Logistiki.VirtualAccounts.VirtualAccount.t()

Fetches a single account by code, raising if not found.

Arguments

  • codeString.t() — the account code (e.g. "ASSETS:CASH:USD:NOSTRO").

Returns

Examples

iex> account = Logistiki.VirtualAccounts.get_account_by_code!("ASSETS:CASH:USD:NOSTRO")
iex> account.currency
"USD"

leaf?(virtual_account)

(since 0.1.0)

True when the account is a leaf (has no children). Roots are aggregation nodes and are not leaves.

Arguments

  • account%VirtualAccount{}.

Returns

  • boolean()true if the account has no children, false otherwise.

Examples

iex> Logistiki.VirtualAccounts.leaf?(nostro_account)
true

iex> Logistiki.VirtualAccounts.leaf?(assets_root)
false

list_accounts(opts \\ [])

(since 0.1.0)
@spec list_accounts(keyword()) :: [Logistiki.VirtualAccounts.VirtualAccount.t()]

Lists accounts, optionally filtered.

Arguments

  • optskeyword() of options:
    • :statusString.t — e.g. "active", "frozen"
    • :account_typeString.t — e.g. "asset", "liability"
    • :currencyString.t — e.g. "USD"
    • :parent_idinteger() | nil — filter by parent; nil lists 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", ...}]

list_ancestors(virtual_account)

(since 0.1.0)

Lists all ancestors of account ordered from nearest to farthest, using the closure table.

Arguments

  • account%VirtualAccount{} — the descendant account.

Returns

  • [VirtualAccount.t()] — ordered by depth ascending. 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", ...}]

list_children(virtual_account)

(since 0.1.0)

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", ...}]

list_descendants(virtual_account)

(since 0.1.0)

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", ...}]

move_account(account, new_parent)

(since 0.1.0)

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 parent
    • nil — move to root
    • integer() — the new parent's id

Returns

  • {:ok, %VirtualAccount{}} — the moved account with updated parent_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)

posting_account?(arg1)

(since 0.1.0)
@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()true only when posting_allowed == true and status == "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

resolve(account)

(since 0.1.0)

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 directly
    • String.t — interpreted as an account code first, then as a numeric id
    • integer() — 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)

update_account(account, attrs)

(since 0.1.0)

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.
  • attrsmap() 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"