Enforces hard accounting invariants in deterministic Elixir.
These checks never depend on Datalog. Datalog may say what should happen; Elixir enforces what must always be true.
Invariants enforced
- a journal has at least two postings
- postings reference existing accounts
- postings target leaf, active, posting-allowed accounts
- amounts are positive
- currencies are present
- debits equal credits per currency
- idempotency key is unique among posted journals
- frozen/closed accounts reject postings
- reversal exactly negates the original postings
- posted journals are immutable (checked at the context layer)
Returns :ok or {:error, %Logistiki.Error{}}.
Summary
Functions
Validates a journal's full invariant set (postings + accounts + idempotency).
Validates that each posting targets an existing, leaf, active, posting- allowed account.
Validates that the idempotency key is unique among posted journals.
Validates a list of posting changesets (or maps) for the invariants that don't require the DB.
Validates that reversal_postings exactly negate original_postings.
Functions
@spec validate(Logistiki.Accounting.Journal.t(), [ Logistiki.Accounting.Posting.t() | map() ]) :: :ok | {:error, Logistiki.Error.t()}
Validates a journal's full invariant set (postings + accounts + idempotency).
Combines validate_postings/1, validate_accounts/1, and
validate_idempotency/1 in sequence.
Arguments
journal—%Journal{}— the journal (for the idempotency key).postings—[Posting.t()]— the journal's postings.
Returns
:ok— all invariants hold.{:error, %Error{}}— the first invariant that failed.
Examples
iex> Logistiki.Accounting.InvariantValidator.validate(journal, postings)
:ok
@spec validate_accounts([Logistiki.Accounting.Posting.t() | map()]) :: :ok | {:error, Logistiki.Error.t()}
Validates that each posting targets an existing, leaf, active, posting- allowed account.
Arguments
postings—[Posting.t()]or[map()]— the postings to validate.
Returns
:ok— all accounts are valid posting targets.{:error, %Error{code: :account_not_found}}— an account code doesn't exist.{:error, %Error{code: :account_not_postable}}— an account is not a leaf posting account.
Examples
iex> Logistiki.Accounting.InvariantValidator.validate_accounts([
...> %Posting{account_code: "ASSETS:CASH:USD:NOSTRO", debit_credit: "debit", amount: Decimal.new("100"), currency: "USD", sequence: 1},
...> %Posting{account_code: "LIABILITIES:CLIENT_DEPOSITS:USD:ACME:OPERATING", debit_credit: "credit", amount: Decimal.new("100"), currency: "USD", sequence: 2}
...> ])
:ok
@spec validate_idempotency(String.t() | nil) :: :ok | {:error, Logistiki.Error.t()}
Validates that the idempotency key is unique among posted journals.
Arguments
idempotency_key—String.t() | nil— the key to check.nilalways passes.
Returns
:ok— no posted journal with this key exists.{:error, %Error{code: :duplicate_idempotency_key}}— a posted journal with this key already exists.
Examples
iex> Logistiki.Accounting.InvariantValidator.validate_idempotency(nil)
:ok
iex> Logistiki.Accounting.InvariantValidator.validate_idempotency("evt:1:policy:cash_deposit")
:ok
@spec validate_postings([ Logistiki.Accounting.Posting.t() | Ecto.Changeset.t() | map() ]) :: :ok | {:error, Logistiki.Error.t()}
Validates a list of posting changesets (or maps) for the invariants that don't require the DB.
Checks: at least two postings, positive amounts, currencies present, valid directions, and debits equal credits per currency.
Arguments
postings—[Posting.t()]or[Ecto.Changeset.t()]— the postings to validate.
Returns
:ok— all invariants hold.{:error, %Error{code: :unbalanced_journal}}— an invariant was violated.
Examples
iex> Logistiki.Accounting.InvariantValidator.validate_postings([
...> %Posting{account_code: "A", debit_credit: "debit", amount: Decimal.new("100"), currency: "USD", sequence: 1},
...> %Posting{account_code: "B", debit_credit: "credit", amount: Decimal.new("100"), currency: "USD", sequence: 2}
...> ])
:ok
iex> {:error, %{code: :unbalanced_journal}} = Logistiki.Accounting.InvariantValidator.validate_postings([
...> %Posting{account_code: "A", debit_credit: "debit", amount: Decimal.new("100"), currency: "USD", sequence: 1}
...> ])
@spec validate_reversal([Logistiki.Accounting.Posting.t()], [ Logistiki.Accounting.Posting.t() ]) :: :ok | {:error, Logistiki.Error.t()}
Validates that reversal_postings exactly negate original_postings.
Checks: same count, same account codes, same amounts, opposite directions, same currencies — pairwise in sequence order.
Arguments
original_postings—[Posting.t()]— the original journal's postings.reversal_postings—[Posting.t()]— the reversal's postings.
Returns
:ok— the reversal exactly negates the original.{:error, %Error{code: :unbalanced_journal}}— the reversal does not match.
Examples
iex> original = [%Posting{account_code: "A", debit_credit: "debit", amount: Decimal.new("100"), currency: "USD", sequence: 1}]
iex> reversal = [%Posting{account_code: "A", debit_credit: "credit", amount: Decimal.new("100"), currency: "USD", sequence: 1}]
iex> Logistiki.Accounting.InvariantValidator.validate_reversal(original, reversal)
:ok