DoubleEntryLedger.Account (double_entry_ledger v0.1.0)

View Source

Manages financial accounts in the Double Entry Ledger system.

This module defines the Account schema and provides functions for account creation, updates, and balance management following double-entry bookkeeping principles.

Key Concepts

  • Normal Balance: Each account has a default balance direction (debit/credit) based on its type. Normal balances are automatically assigned but can be overridden for special cases like contra accounts.
  • Balance Types: Accounts track both posted (finalized) and pending balances separately.
  • Available Balance: The calculated balance that accounts for both posted transactions and pending holds/authorizations.

Schema Fields

  • id: UUID primary key
  • address: Human-readable account address in the format "abc1:def2:(:[a-zA-Z_0-9]+){0,}" (required)
  • name: Human-readable account name
  • description: Optional text description
  • currency: The currency code as an atom (e.g., :USD, :EUR)
  • type: Account classification (:asset, :liability, :equity, :revenue, :expense)
  • normal_balance: Whether the account normally increases with :debit or :credit entries
  • available: Calculated available balance (posted minus relevant pending)
  • allowed_negative: Whether the account can have a negative available balance
  • context: JSON map for additional metadata
  • posted: Embedded Balance struct for settled transactions
  • pending: Embedded Balance struct for pending transactions
  • lock_version: Integer for optimistic concurrency control
  • instance_id: Foreign key to the ledger instance
  • inserted_at/updated_at: Timestamps

Transaction Processing

The module handles various transaction types:

  • :posted - Direct postings to finalized balance
  • :pending - Holds/authorizations for future settlement
  • :pending_to_posted - Converting pending entries to posted
  • :pending_to_pending - Modifying pending entries
  • :pending_to_archived - Removing pending entries

Relationships

  • Belongs to: instance
  • Has many: entries
  • Has many: balance_history_entries

Summary

Types

t()

Represents a financial account in the double-entry ledger system.

Functions

Creates a changeset for validating and creating a new Account.

Creates a changeset for safely deleting an account.

Updates account balances based on an entry and transaction type.

Creates a changeset for updating an existing Account.

Types

t()

@type t() :: %DoubleEntryLedger.Account{
  __meta__: term(),
  address: String.t() | nil,
  allowed_negative: boolean(),
  available: integer(),
  balance_history_entries: term(),
  context: map() | nil,
  currency: DoubleEntryLedger.Utils.Currency.currency_atom() | nil,
  description: String.t() | nil,
  entries: term(),
  id: binary() | nil,
  inserted_at: DateTime.t() | nil,
  instance: term(),
  instance_id: binary() | nil,
  journal_event_account_links: term(),
  journal_events: term(),
  lock_version: integer(),
  name: String.t() | nil,
  normal_balance: DoubleEntryLedger.Types.credit_or_debit() | nil,
  pending: DoubleEntryLedger.Balance.t() | nil,
  posted: DoubleEntryLedger.Balance.t() | nil,
  type: DoubleEntryLedger.Types.account_type() | nil,
  updated_at: DateTime.t() | nil
}

Represents a financial account in the double-entry ledger system.

An account is the fundamental unit that holds balances and participates in transactions. Each account has a type, normal balance direction, and tracks both pending and posted amounts.

Fields

  • id: UUID primary key
  • name: Account name must unique. Can't be changed after creation
  • address: Human-readable account address in the format "abc1:def2:(:[a-zA-Z_0-9]+){0,}" (required)
  • description: Optional text description
  • currency: Currency code atom (e.g., :USD, :EUR). Can't be changed after creation
  • type: Account classification. Can't be changed after creation
  • normal_balance: Default balance direction. Can't be changed after creation
  • available: Calculated available balance
  • allowed_negative: Whether negative balances are allowed
  • context: Additional metadata as a map
  • posted: Balance struct for posted transactions
  • pending: Balance struct for pending transactions
  • lock_version: Version for optimistic concurrency control
  • instance_id: Reference to ledger instance
  • inserted_at: Creation timestamp
  • updated_at: Last update timestamp

Functions

account_types()

@spec account_types() :: [DoubleEntryLedger.Types.account_type()]

address_regex()

@spec address_regex() :: Regex.t()

changeset(account, attrs)

@spec changeset(t(), map()) :: Ecto.Changeset.t()

Creates a changeset for validating and creating a new Account.

Enforces required fields, validates types, and automatically sets the normal_balance based on the account type if not explicitly provided.

Parameters

  • account - The account struct to create a changeset for
  • attrs - Map of attributes to apply to the account

Returns

  • An Ecto.Changeset with validations applied

Examples

# Create a valid asset account
iex> changeset = Account.changeset(%Account{}, %{
...>   address: "cash:main:1",
...>   currency: :USD,
...>   instance_id: "550e8400-e29b-41d4-a716-446655440000",
...>   type: :asset
...> })
iex> changeset.valid?
true
iex> Ecto.Changeset.get_field(changeset, :normal_balance)
:debit

# Invalid without required fields
iex> changeset = Account.changeset(%Account{}, %{})
iex> changeset.valid?
false
iex> MapSet.new(Keyword.keys(changeset.errors))
MapSet.new([:currency, :address, :instance_id, :type])

delete_changeset(account)

@spec delete_changeset(t()) :: Ecto.Changeset.t()

Creates a changeset for safely deleting an account.

Validates that there are no associated entries (transactions) before deletion, ensuring accounting integrity is maintained.

Parameters

  • account - The account to delete

Returns

  • An Ecto.Changeset that will fail if the account has entries

Examples

# Attempt to delete an account with no entries
iex> alias DoubleEntryLedger.Stores.AccountStore
iex> alias DoubleEntryLedger.Stores.InstanceStore
iex> {:ok, instance} = InstanceStore.create(%{address: "instance1"})
iex> {:ok, account} = AccountStore.create(instance.address, %{
...>    name: "account1", address: "cash:main:1", type: :asset, currency: :EUR}, "unique_id_123")
iex> {:ok, %{id: account_id}} = Repo.delete(Account.delete_changeset(account))
iex> account.id == account_id
true

# Attempt to delete an account with entries
# This is a database constraint error, not a changeset error
iex> alias DoubleEntryLedger.Stores.AccountStore
iex> alias DoubleEntryLedger.Stores.InstanceStore
iex> alias DoubleEntryLedger.Apis.CommandApi
iex> {:ok, instance} = InstanceStore.create(%{address: "instance1"})
iex> {:ok, account1} = AccountStore.create(instance.address, %{
...>    name: "account1", address: "cash:main:1", type: :asset, currency: :EUR}, "unique_id_123")
iex> {:ok, account2} = AccountStore.create(instance.address, %{
...>    name: "account2", address: "cash:main:2", type: :liability, currency: :EUR}, "unique_id_456")
iex> {:ok, _, _} = CommandApi.process_from_params(%{"instance_address" => instance.address,
...>  "source" => "s1", "source_idempk" => "1", "action" => "create_transaction",
...>  "payload" => %{"status" => "pending", "entries" => [
...>      %{"account_address" => account1.address, "amount" => 100, "currency" => "EUR"},
...>      %{"account_address" => account2.address, "amount" => 100, "currency" => "EUR"},
...>  ]}})
iex> {:error, changeset} = Account.delete_changeset(account1)
...> |> Repo.delete()
iex> [entries: {"are still associated with this entry", _}] = changeset.errors

update_balances(account, map)

@spec update_balances(t(), %{
  entry: DoubleEntryLedger.Entry.t() | Ecto.Changeset.t(),
  trx: DoubleEntryLedger.Types.trx_types()
}) :: Ecto.Changeset.t()

Updates account balances based on an entry and transaction type.

This function handles all the complex account balance updates for different transaction scenarios like posting, pending holds, and settlement.

Parameters

  • account - The account to update
  • params - Map containing:
    • entry - Entry struct or changeset with the transaction details
    • trx - Transaction type (:posted, :pending, :pending_to_posted, etc.)

Returns

  • A changeset with updated balance fields and optimistic lock version increment

Implementation Notes

  • Validates that entry and account currencies match
  • Handles various transaction types with different balance update logic
  • Enforces non-negative balance if allowed_negative is false
  • Uses optimistic locking to prevent concurrent balance modifications

Transaction Types

  • :posted - Direct posting to finalized balance
  • :pending - Holds/authorizations for future settlement
  • :pending_to_posted - Converting pending entries to posted
  • :pending_to_pending - Modifying existing pending entries
  • :pending_to_archived - Removing pending entries without posting

update_changeset(account, attrs)

@spec update_changeset(t(), map()) :: Ecto.Changeset.t()

Creates a changeset for updating an existing Account.

Limited to updating only the description, and context fields, protecting critical fields like type and currency from modification.

Parameters

  • account - The account struct to update
  • attrs - Map of attributes to update

Returns

  • An Ecto.Changeset with validations applied

Examples

# Update account description
iex> account = %Account{description: "Old Description", instance_id: "inst-123"}
iex> changeset = Account.update_changeset(account, %{
...>   description: "Updated Description"
...> })
iex> changeset.valid?
true
iex> Ecto.Changeset.get_change(changeset, :description)
"Updated Description"