Logistiki.Accounting.InvariantValidator (logistiki v0.1.0)

Copy Markdown View Source

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

validate(journal, postings)

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

validate_accounts(postings)

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

validate_idempotency(idempotency_key)

(since 0.1.0)
@spec validate_idempotency(String.t() | nil) :: :ok | {:error, Logistiki.Error.t()}

Validates that the idempotency key is unique among posted journals.

Arguments

  • idempotency_keyString.t() | nil — the key to check. nil always 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

validate_postings(postings)

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

validate_reversal(original_postings, reversal_postings)

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