Help us shape the future of Go! Take the 2026 Go Developer Survey

ledgo

package module
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

README

ledgo

Double-entry ledger library for Go, with multi-currency and FX support, append-only storage and pluggable storage backends.

Status: v0.0.1, the first release. The core API and the PostgreSQL and Oracle backends work and pass the conformance suite. Until v1.0.0 the API may still change between minor versions.

Installation

ledgo is split into modules, so you only download the driver you use:

go get github.com/izzyblues/ledgo@v0.0.1          # core: accounts, transactions, validation
go get github.com/izzyblues/ledgo/postgres@v0.0.1 # PostgreSQL store
go get github.com/izzyblues/ledgo/oracle@v0.0.1   # Oracle Database store

Create the tables with your store's Migrate, for example postgres.New(db).Migrate(ctx), or copy its schema.sql into your own migrations. Each store has its own README: PostgreSQL, Oracle.

Why ledgo

ledgo is a ledger you embed, not a ledger service you run. Its postings commit in the same database transaction as your own business changes: a payment's status, an outbox event and its ledger entries all commit or roll back together. There is nothing to reconcile between your application and its ledger, because they cannot disagree.

Dedicated ledger services such as TigerBeetle, Formance and Blnk are the better choice when you need very high throughput on contended accounts, or one central ledger shared by many services. They run in their own process and database, so every money movement becomes a two-system consistency problem.

ledgo is a good fit when:

  • your service already owns a transactional database (PostgreSQL or Oracle today), and you want its money movements in proper double-entry form;
  • you need postings to be atomic with your business state;
  • you want SQL access to your ledger for reporting and reconciliation, without running new infrastructure.

Goals

  • Double-entry: every transaction balances, per currency.
  • Multi-currency with FX: conversions go through trading accounts. You supply the rate, and ledgo stores it with the transaction.
  • Exact money: arbitrary-precision decimals (shopspring/decimal), never floats, with correct decimal places per currency, from JPY (0) to crypto (18).
  • Append-only: entries are never updated or deleted. Corrections are reversal entries.
  • Idempotent: every transaction carries an idempotency key, so retries are safe.
  • Pluggable storage: each backend is a separate Go module, so you only download the driver you need.
  • Overdraft protection without hot-account contention: accounts can forbid going below zero, TigerBeetle-style. Only those accounts are locked by commits. System accounts (clearing, FX, revenue) are insert-only and never locked.

Example

store := postgres.InTx(sqlTx) // your own transaction
ledger := ledgo.New(store)

customer := ledgo.Account{ID: "customer:42", Currency: "EUR", DebitsMustNotExceedCredits: true}
bank := ledgo.Account{ID: "bank:EUR", Currency: "EUR"}

tx := ledgo.NewTransaction("payment:123:collected", "payment:123").
	Debit(bank, amount).
	Credit(customer, amount).
	Build()

err := ledger.Commit(ctx, tx) // same transaction as your business update
  • Keep your chart of accounts in your application. Account names such as customer:{id}:available or clearing:card-acquirer:EUR are business decisions, not library ones.
  • Let your entities carry their transactions, like outbox message records: a domain method that moves money records a transaction, and the repository commits it with postgres.InTx in the same database transaction as the entity.
  • Derive idempotency keys from the business event (for example order:{id}:refunded), so retries and message redeliveries are no-ops.

Backends

Backend Status Guarantees
PostgreSQL (also Aurora, AlloyDB, Neon, Supabase) Available Full ACID
Oracle Available Full ACID
MySQL (InnoDB) Planned Full ACID
SQL Server Planned Full ACID
MongoDB Planned ACID multi-document transactions, replica set required
IBM Db2 Planned Full ACID, amounts up to 13 integer digits at 18 decimal places
SQLite Planned Full ACID, single writer
MariaDB Planned Full ACID
Cloud Spanner Planned Full ACID, amounts up to 9 decimal places
CockroachDB Planned Full ACID
TiDB Planned Full ACID
YugabyteDB Planned Full ACID

ledgo supports transactional databases only, because committing inside your own transaction needs one.

Versioning

ledgo follows semantic versioning. Each module is tagged separately: vX.Y.Z for the core module and postgres/vX.Y.Z, oracle/vX.Y.Z and so on for each store. A store release always requires a core release with the same version or newer.

License

Apache-2.0

Documentation

Overview

Package ledgo is a double-entry ledger with multi-currency and FX support.

Amounts are signed: a positive amount debits the account, a negative amount credits it. A transaction balances when its amounts sum to zero in every currency. Every account holds a single currency and must be created before it is used.

Index

Constants

View Source
const MaxDecimalPlaces = 18

MaxDecimalPlaces is the most decimal places a currency can have: the stores' amount columns keep 18, and some databases round extra places silently.

Variables

View Source
var (
	ErrUnknownAccount    = errors.New("ledgo: unknown account")
	ErrAccountConflict   = errors.New("ledgo: account already exists with different settings")
	ErrCurrencyMismatch  = errors.New("ledgo: entry currency differs from its account's")
	ErrInsufficientFunds = errors.New("ledgo: commit would break an account's balance limit")
)

Errors about accounts.

View Source
var (
	ErrUnknownCurrency = errors.New("ledgo: unknown currency")
	ErrTooManyDecimals = errors.New("ledgo: amount has more decimal places than its currency allows")
)

Errors returned for amounts that don't fit their currency.

View Source
var (
	ErrNoAccount  = errors.New("ledgo: missing account")
	ErrZeroAmount = errors.New("ledgo: zero amount")
)

Errors returned for invalid entries.

View Source
var (
	ErrNoIdempotencyKey = errors.New("ledgo: missing idempotency key")
	ErrTooFewEntries    = errors.New("ledgo: a transaction needs at least two entries")
	ErrUnbalanced       = errors.New("ledgo: transaction does not balance")
)

Errors returned by Transaction.Validate.

View Source
var ErrIdempotencyConflict = errors.New("ledgo: idempotency key reused with different content")

ErrIdempotencyConflict is returned when an idempotency key is committed again with different content.

View Source
var ErrInvalidRate = errors.New("ledgo: invalid FX rate")

ErrInvalidRate is returned for a rate with unknown or identical currencies, a non-positive value, or a missing source or time.

Functions

func CheckEntries

func CheckEntries(entries []Entry, accounts map[string]Account) error

CheckEntries checks that every entry's account is in accounts and holds the entry's currency. Stores call it before writing entries.

func RegisterCurrency

func RegisterCurrency(code string, decimalPlaces int32)

RegisterCurrency adds a currency that the ISO 4217 table doesn't know, such as crypto, or overrides one. Codes are case-sensitive. It is safe for concurrent use. It panics if decimalPlaces is not between 0 and MaxDecimalPlaces.

Types

type Account

type Account struct {
	ID                         string
	Currency                   string
	DebitsMustNotExceedCredits bool
	CreditsMustNotExceedDebits bool
}

Account holds money in a single currency. Its balance is debits minus credits.

The two limits work like TigerBeetle's account flags: a commit that would make debits exceed credits, or credits exceed debits, fails with ErrInsufficientFunds. Accounts with no limit, typically system accounts such as clearing, FX or revenue, are never locked by commits.

func (Account) CheckBalance

func (a Account) CheckBalance(balance decimal.Decimal) error

CheckBalance returns ErrInsufficientFunds if balance (debits minus credits) breaks the account's limits.

func (Account) HasLimit

func (a Account) HasLimit() bool

HasLimit reports whether either balance limit is set.

type Entry

type Entry struct {
	Account  string
	Currency string
	Amount   decimal.Decimal
}

Entry is one leg of a transaction. A positive Amount debits the account, a negative one credits it.

type Ledger

type Ledger struct {
	// contains filtered or unexported fields
}

Ledger validates accounts and transactions before handing them to a Store.

func New

func New(store Store) *Ledger

New returns a Ledger backed by store.

func (*Ledger) Balance

func (l *Ledger) Balance(ctx context.Context, account string) (decimal.Decimal, error)

Balance is the account's debits minus credits.

func (*Ledger) Commit

func (l *Ledger) Commit(ctx context.Context, tx Transaction) error

Commit validates tx and stores it. See Store.Commit.

func (*Ledger) CreateAccount

func (l *Ledger) CreateAccount(ctx context.Context, account Account) error

CreateAccount validates account and registers it. See Store.CreateAccount.

type Rate

type Rate struct {
	From   string
	To     string
	Value  decimal.Decimal
	Source string
	Time   time.Time
}

Rate is the FX rate the caller used: one unit of From buys Value units of To. It is stored with the transaction and never recomputed.

func (Rate) Convert

func (r Rate) Convert(amount decimal.Decimal) (decimal.Decimal, error)

Convert multiplies amount by the rate and rounds half-even (RoundBank) to the target currency's decimal places. It never divides.

type Store

type Store interface {
	// CreateAccount registers an account. Creating the same account again is
	// a no-op; with different settings it returns ErrAccountConflict.
	CreateAccount(ctx context.Context, account Account) error
	// Commit stores tx atomically. Committing the same idempotency key with
	// the same content again is a no-op; with different content it returns
	// ErrIdempotencyConflict. It returns ErrUnknownAccount,
	// ErrCurrencyMismatch or ErrInsufficientFunds without storing anything.
	Commit(ctx context.Context, tx Transaction) error
	// Balance is the account's debits minus credits.
	Balance(ctx context.Context, account string) (decimal.Decimal, error)
}

Store persists accounts and transactions. Implementations live in their own modules and must pass the ledgotest conformance suite.

type Transaction

type Transaction struct {
	IdempotencyKey string
	// Reference links the transaction to what caused it, such as a payment ID.
	Reference string
	Entries   []Entry
	// Rate is the FX rate used, for transactions that convert currencies.
	Rate *Rate
}

Transaction is a set of entries committed atomically.

func (Transaction) Fingerprint

func (tx Transaction) Fingerprint() []byte

Fingerprint is a hash of the transaction's content, excluding its idempotency key. Stores use it to tell a retry from a conflicting reuse.

func (Transaction) Validate

func (tx Transaction) Validate() error

Validate checks that tx is well formed and balances in every currency.

type TransactionBuilder

type TransactionBuilder struct {
	// contains filtered or unexported fields
}

TransactionBuilder assembles a Transaction from debits and credits. Each entry takes its currency from its account, and zero amounts are skipped, so optional legs such as a zero fee need no special casing. Builders are values: every method returns a new builder and leaves the receiver as is.

func NewTransaction

func NewTransaction(idempotencyKey, reference string) TransactionBuilder

NewTransaction starts a transaction with its idempotency key and the reference to what caused it.

func (TransactionBuilder) Accounts

func (b TransactionBuilder) Accounts() []Account

Accounts returns the distinct accounts the transaction touches, in the order they were added, so callers can create them before committing.

func (TransactionBuilder) Build

func (b TransactionBuilder) Build() Transaction

Build returns the transaction. It does not validate it: Ledger.Commit does.

func (TransactionBuilder) Credit

func (b TransactionBuilder) Credit(account Account, amount decimal.Decimal) TransactionBuilder

Credit adds a credit of amount to account.

func (TransactionBuilder) Debit

func (b TransactionBuilder) Debit(account Account, amount decimal.Decimal) TransactionBuilder

Debit adds a debit of amount to account.

func (TransactionBuilder) IsEmpty

func (b TransactionBuilder) IsEmpty() bool

IsEmpty reports whether the transaction has no entries, for instance because all its amounts were zero.

func (TransactionBuilder) Negated

Negated turns every debit into a credit and every credit into a debit, for example to move money back the way it came.

func (TransactionBuilder) WithRate

func (b TransactionBuilder) WithRate(rate Rate) TransactionBuilder

WithRate records the FX rate used by a transaction that converts currencies.

Directories

Path Synopsis
internal
sqlstore
Package sqlstore is the database/sql ledgo.Store shared by the SQL backends.
Package sqlstore is the database/sql ledgo.Store shared by the SQL backends.
Package ledgotest is the conformance suite every ledgo.Store must pass.
Package ledgotest is the conformance suite every ledgo.Store must pass.
mysql module
oracle module
postgres module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL