Debits and credits

Sides, gross balances that only grow, and the derived net.

On this page
  1. What to call
  2. A side, never a sign
  3. Gross balances that only grow
  4. The sign of net()
  5. Why
  6. Limits

What to call

Side (Debit, Credit); Balance with debit(), credit(), postings(), net(); AccountKind::normal_side.

A side, never a sign

Every posting has a side and a positive amount; zero or negative is refused (104). An entry balances per (currency, layer): for each currency and each layer (Settled, Pending), debits equal credits, and a refusal names the pair that does not (106).

use doubleentry::prelude::*;

let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.declare_currency(USD, 2, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.open("Assets:Bank", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Equity:Capital", AccountKind::Equity, date!("2026-01-01"))?;

books.record(
    Entry::new("capital", date!("2026-03-01"))
        .debit("Assets:Bank", amount!("1000.00"), EUR)
        .credit("Equity:Capital", amount!("1000.00"), EUR)
        .debit("Assets:Bank", amount!("500.00"), USD)
        .credit("Equity:Capital", amount!("500.00"), USD),
)?; // two currencies, each balanced on its own

let mixed = books.record(
    Entry::new("mixed", date!("2026-03-02"))
        .debit("Assets:Bank", amount!("10.00"), EUR)
        .credit("Equity:Capital", amount!("10.00"), USD),
);
assert_eq!(
    mixed.err().map(|e| e.to_string()).as_deref(),
    Some("rejected: [106] EUR Settled does not balance: debits 10.00, credits 0.00"),
);

Gross balances that only grow

A Balance is the T-account of one (account, currency, layer): gross debits, gross credits and the posting count. A posting only ever adds, so a balance never shrinks; net() = debit − credit is derived. Two balances are equal only if all three parts are; nets_equal compares nets alone. A reversal shows why: the net returns to zero, both totals grow, and the count records two movements.

use doubleentry::prelude::*;

let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.define_months(2026, 3..=3)?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;

let sale = books.record(
    Entry::new("sale-1", date!("2026-03-15"))
        .debit("Assets:Cash", amount!("50.00"), EUR)
        .credit("Income:Sales", amount!("50.00"), EUR),
)?;
books.record(books.reversal(sale.id, "sale-1-reversal", date!("2026-03-16"))?)?;

let cash = books.balance("Assets:Cash", EUR, &BalanceQuery::all())?;
assert!(cash.net().is_zero());
assert_eq!(cash.to_string(), "dr 50.00 cr 50.00 net 0.00 (2 postings)");

The sign of net()

net() is debit − credit for every kind, so a credit-normal account (liability, equity, income) normally shows a negative net — Income:Sales above would read -50.00 before the reversal. A report that prints those as positive negates the net when kind.normal_side() is Side::Credit.

Why

  • A side cannot be misread. "−50" depends on whose view the sign was taken from; "debit 50" does not.
  • Gross keeps what net loses: turnover, the number of movements, that a reversal happened. Gross totals are monotone, so a verifier can check that a seal's totals never decrease from one seal to the next.
  • One formula keeps a trial balance summing to zero per currency.

Limits

  • net() is a view; seals and balance proofs commit to the gross triple.
  • An entry that would overflow a gross total is refused (500); sums across accounts are checked and reported as an error, never wrapped.