# `Logistiki.Accounting.InvariantValidator`
[🔗](https://github.com/thanos/logistiki/blob/v0.1.0/lib/logistiki/accounting/invariant_validator.ex#L1)

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{}}`.

# `validate`
*since 0.1.0* 

```elixir
@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`
*since 0.1.0* 

```elixir
@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`
*since 0.1.0* 

```elixir
@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. `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`
*since 0.1.0* 

```elixir
@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`
*since 0.1.0* 

```elixir
@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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
