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

ledger

package module
v0.0.0-...-5453f10 Latest Latest
Warning

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

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

README

ledger

Go Reference

A double-entry ledger for Go and PostgreSQL. Money goes in, money goes out, and the books always add up to exactly zero. Not "roughly zero". Not "zero after the nightly job". Zero.

Use it as a Go library inside your app, or run it as a standalone HTTP service (ledgerd).

Why you might like it

  • The database has your back. Every transaction balances, entries never change, and balances can't drift. Postgres enforces it, so even a bug in your code can't cook the books.
  • No rounding. Ever. Amounts are exact up to 78 digits: cents, yen, or 18-decimal wei. Send "5403.23" and it's stored as exactly 540323 cents.
  • Retry without fear. Every write is idempotent on a reference you choose. Network blip? Send it again. Nobody gets charged twice.
  • Real-world money moves. Card holds that expire, partial captures, refunds, reversals, payroll batches, currency exchange, frozen accounts and overdraft limits.
  • Accountant-friendly. Trial balance, balance sheet, income statement, CSV statements and month-end close, plus webhooks for everything that happens.

Try it in a minute

You need Go 1.25+ and PostgreSQL.

createdb ledger
LEDGER_DATABASE_URL="postgres://postgres@localhost:5432/ledger?sslmode=disable" \
LEDGER_API_TOKEN=change-me \
go run ./cmd/ledgerd

ledgerd sets up its tables and listens on port 7710. Say hello:

curl -H "Authorization: Bearer change-me" -X POST localhost:7710/currencies -d '{"code": "USD", "decimals": 2}'

Prefer Docker? make docker-build, then see the handbook.

Or embed it in Go

go get github.com/otyang/ledger
svc := ledger.New(ledger.OpenDB(dsn))
svc.Migrate(ctx)
svc.RegisterCurrency(ctx, ledger.CurrencyParams{Code: "USD", Decimals: 2})

// Alice pays Bob 5403.23 USD. Debits equal credits, or it doesn't happen.
svc.Post(ctx, ledger.PostParams{Reference: "pay-1", Currency: "USD", Legs: []ledger.Leg{
	{AccountID: alice, Direction: ledger.Debit, Value: "5403.23"},
	{AccountID: bob, Direction: ledger.Credit, Value: "5403.23"},
}})

Where to next

I want to… Go here
Browse the HTTP API and try requests Interactive API reference on the website
Set it up properly, configure it, run it in production The handbook: configuration, Go library, concepts, guarantees, operations, troubleshooting
Understand double-entry bookkeeping from scratch Beginner guide, with a worked example
Look up every request and response field API reference (Markdown) or the OpenAPI spec
Know how it works inside before changing it Architecture and internals

Everything is also on the website: otyang.github.io/ledger/docs.

Documentation

All the docs in one list:

Doc What's in it
Website The handbook as a website, with a contents sidebar and copyable code
Interactive API reference Every HTTP endpoint with schemas and code samples, rendered from the OpenAPI spec
OpenAPI 3.0 spec The machine-readable API definition, for code generators, Postman or your own tools
Handbook Setup, configuration, the Go library, concepts, guarantees, operations and troubleshooting
Beginner guide Double-entry bookkeeping explained with a worked example, from top-up to month-end
API reference (Markdown) Every endpoint's fields, status codes, errors and real example responses
Architecture and internals How posting, locking, idempotency, holds, events and the database rules work
Go package docs Generated reference for every exported Go type and function

Contributing

Pull requests are welcome. Run make check first (formatting, vet, and every test with the race detector), add tests for new behaviour, and add your name or GitHub handle to AUTHORS. No email needed.

Maintained by otyang, with thanks to everyone who has contributed.

License

Apache License 2.0. Copyright 2026 The ledger Authors. Use it, change it, ship it in commercial products; just keep the LICENSE and NOTICE files.

Documentation

Overview

Package ledger is an embeddable double-entry ledger on PostgreSQL, built on the Bun ORM.

The caller owns the database connection:

db := ledger.OpenDB(os.Getenv("DATABASE_URL"))
svc := ledger.New(db)
if err := svc.Migrate(ctx); err != nil { ... }

All tables live in the "ledger" Postgres schema, so they do not collide with the host application's tables.

Index

Constants

View Source
const (
	EventCurrencyRegistered = "currency.registered"
	EventLedgerCreated      = "ledger.created"
	EventAccountCreated     = "account.created"
	EventAccountUpdated     = "account.updated"
	EventTransactionPosted  = "transaction.posted"
	EventTransactionUpdated = "transaction.updated"
	EventHoldCreated        = "hold.created"
	EventHoldCommitted      = "hold.committed"
	EventHoldVoided         = "hold.voided"
	EventHoldExpired        = "hold.expired"
	EventBatchPosted        = "batch.posted"
	EventPeriodClosed       = "period.closed"
)

Event types. Data holds the object after the change, as returned by the API: a Transaction, Hold, Account, Ledger, Batch, Currency or PeriodClose.

Variables

View Source
var (
	ErrNotFound          = errors.New("ledger: not found")
	ErrAlreadyExists     = errors.New("ledger: already exists")
	ErrInvalidID         = errors.New("ledger: invalid id")
	ErrInvalidParams     = errors.New("ledger: invalid params")
	ErrUnbalanced        = errors.New("ledger: debits and credits do not balance")
	ErrCurrencyMismatch  = errors.New("ledger: currency mismatch")
	ErrInsufficientFunds = errors.New("ledger: insufficient funds")
	ErrAmountOverflow    = errors.New("ledger: amount overflow")
	ErrReferenceConflict = errors.New("ledger: reference already used for a different transaction")
	ErrHoldNotPending    = errors.New("ledger: hold is not pending")
	ErrRefundExceeded    = errors.New("ledger: refund exceeds the refundable amount")
	ErrLedgerMismatch    = errors.New("ledger: accounts belong to different ledgers")
	ErrUnknownCurrency   = errors.New("ledger: unknown currency")
	ErrCurrencyConflict  = errors.New("ledger: currency already registered with different decimals")
	ErrAccountFrozen     = errors.New("ledger: account is frozen")
	ErrAccountClosed     = errors.New("ledger: account is closed")
	ErrHoldExpired       = errors.New("ledger: hold has expired")
	ErrPeriodClosed      = errors.New("ledger: period is closed")

	// ErrRetryable marks failures caused by concurrency (serialization
	// failures, deadlocks, lock timeouts, or a concurrent request with the
	// same idempotency key that the current snapshot can't see yet). Retrying
	// the whole operation, or the caller's whole transaction, is safe.
	ErrRetryable = errors.New("ledger: concurrent update, retry")

	// ErrTxAborted means a *Tx call could not roll back its own savepoint, so
	// the state of the caller's transaction is unknown. The caller must roll
	// it back.
	ErrTxAborted = errors.New("ledger: caller's transaction must be rolled back")
)

Sentinel errors. Returned errors wrap these with detail, so match them with errors.Is.

Functions

func OpenDB

func OpenDB(dsn string) *bun.DB

OpenDB is a convenience for opening a Postgres-backed *bun.DB from a DSN such as "postgres://user:pass@localhost:5432/app?sslmode=disable". Sessions use UTC so timestamps read back match the UTC values written.

Types

type Account

type Account struct {
	bun.BaseModel `bun:"table:ledger.accounts,alias:a"`

	ID            AccountID `bun:",pk" json:"id"`
	LedgerID      LedgerID  `bun:",notnull" json:"ledger_id"`
	Indicator     *string   `bun:",nullzero" json:"indicator,omitempty"`
	Currency      string    `bun:",notnull" json:"currency"`
	AllowNegative bool      `bun:",notnull" json:"allow_negative"`
	// OverdraftLimit lets an account without AllowNegative go this far below
	// zero. Nil means no overdraft.
	OverdraftLimit *Amount        `json:"overdraft_limit,omitempty"`
	Untracked      bool           `bun:",notnull" json:"untracked"`
	Status         AccountStatus  `bun:",notnull,default:'active'" json:"status"`
	StatusReason   string         `bun:",nullzero" json:"status_reason,omitempty"`
	Type           AccountType    `bun:",nullzero" json:"type,omitempty"`
	CreditTotal    Amount         `bun:",notnull" json:"credit_total"`
	DebitTotal     Amount         `bun:",notnull" json:"debit_total"`
	Balance        Amount         `bun:",notnull" json:"balance"`
	PendingDebit   Amount         `bun:",notnull" json:"pending_debit"`
	PendingCredit  Amount         `bun:",notnull" json:"pending_credit"`
	Version        int64          `bun:",notnull" json:"version"`
	Metadata       map[string]any `bun:",type:jsonb,nullzero" json:"metadata,omitempty"`
	CreatedAt      time.Time      `bun:",notnull" json:"created_at"`
	UpdatedAt      time.Time      `bun:",notnull" json:"updated_at"`
}

Account holds a running balance in a single currency. Amounts are whole numbers of the currency's minor unit (e.g. cents, or wei for ETH).

Funds reserved by pending holds are in PendingDebit and PendingCredit; they are not part of Balance until the hold is committed. Version counts the entries posted to the account.

An Untracked account keeps no cached totals and takes no lock when posted to, so it never becomes a bottleneck. Reads compute its totals from entries. It suits high-traffic system accounts such as "@world", and must allow negative balances since its funds cannot be checked.

func (*Account) Available

func (a *Account) Available() Amount

Available is the balance that can still be spent: Balance minus funds reserved by pending holds. With an overdraft limit, the account can spend down to minus that limit.

type AccountID

type AccountID string

Every ID is "<prefix>_<uuidv7>", e.g. "txn_01926f3a-7c2e-7b4a-9f1d-3e8c5a2b1d40". UUIDv7 is time-ordered, which keeps primary-key indexes append-friendly. Distinct types make it a compile error to pass an AccountID where a TransactionID is expected.

func NewAccountID

func NewAccountID() AccountID

func ParseAccountID

func ParseAccountID(s string) (AccountID, error)

type AccountStatus

type AccountStatus string

AccountStatus controls what may move through an account.

const (
	AccountActive AccountStatus = "active"
	// AccountFrozen accounts can receive money but not send it: debits and
	// holds that debit them are refused.
	AccountFrozen AccountStatus = "frozen"
	// AccountClosed accounts are empty and accept nothing. Closing is final.
	AccountClosed AccountStatus = "closed"
)

type AccountType

type AccountType string

AccountType classifies an account for financial statements.

const (
	AccountAsset     AccountType = "asset"
	AccountLiability AccountType = "liability"
	AccountEquity    AccountType = "equity"
	AccountRevenue   AccountType = "revenue"
	AccountExpense   AccountType = "expense"
)

type Amount

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

Amount is an exact whole number of a currency's minor unit: cents for USD, wei for ETH. Stored amounts range over ±(10^78 - 1).

The zero value is 0. Amounts are immutable, so arithmetic returns a new value. They can't be compared with ==; use Cmp or Sign.

In JSON an Amount is a string of digits ("5000"), so values beyond 2^53 survive JavaScript clients. Integer JSON numbers are accepted on input.

func AmountFromBig

func AmountFromBig(x *big.Int) Amount

AmountFromBig returns x as an Amount. It copies x.

func NewAmount

func NewAmount(n int64) Amount

func ParseAmount

func ParseAmount(s string) (Amount, error)

ParseAmount parses a base-10 integer such as "5000" or "-12". It fails with ErrInvalidParams for anything else (including "1.5" and "1e3") and with ErrAmountOverflow beyond 78 digits.

func (Amount) Add

func (a Amount) Add(b Amount) Amount

func (Amount) Big

func (a Amount) Big() *big.Int

Big returns a copy of a as a *big.Int.

func (Amount) Cmp

func (a Amount) Cmp(b Amount) int

Cmp returns -1, 0 or +1 as a is less than, equal to or greater than b.

func (Amount) Int64

func (a Amount) Int64() (int64, bool)

Int64 returns a as an int64, and whether it fits.

func (Amount) IsZero

func (a Amount) IsZero() bool

func (Amount) MarshalJSON

func (a Amount) MarshalJSON() ([]byte, error)

func (Amount) Neg

func (a Amount) Neg() Amount

func (*Amount) Scan

func (a *Amount) Scan(src any) error

Scan reads a NUMERIC (sent as text) or integer column.

func (Amount) Sign

func (a Amount) Sign() int

Sign returns -1, 0 or +1.

func (Amount) String

func (a Amount) String() string

func (Amount) Sub

func (a Amount) Sub(b Amount) Amount

func (*Amount) UnmarshalJSON

func (a *Amount) UnmarshalJSON(b []byte) error

UnmarshalJSON accepts a string of digits or an integer JSON number.

func (Amount) Value

func (a Amount) Value() (driver.Value, error)

type BalanceSheet

type BalanceSheet struct {
	LedgerID     LedgerID      `json:"ledger_id"`
	Currency     string        `json:"currency"`
	At           time.Time     `json:"at"`
	Assets       ReportSection `json:"assets"`
	Liabilities  ReportSection `json:"liabilities"`
	Equity       ReportSection `json:"equity"`
	NetIncome    Amount        `json:"net_income"`
	Unclassified ReportSection `json:"unclassified"`
}

BalanceSheet groups one currency's accounts by type as of at. It always satisfies assets = liabilities + equity + net_income + unclassified, where net_income is revenue minus expenses so far and unclassified holds accounts without a type (at their credit-positive balance).

type Batch

type Batch struct {
	bun.BaseModel `bun:"table:ledger.batches,alias:b"`

	ID          BatchID        `bun:",pk" json:"id"`
	LedgerID    LedgerID       `bun:",notnull" json:"ledger_id"`
	Reference   string         `bun:",notnull" json:"reference"`
	Kind        BatchKind      `bun:",notnull" json:"kind"`
	Rate        string         `bun:",nullzero" json:"rate,omitempty"`
	Description string         `bun:",nullzero" json:"description,omitempty"`
	Metadata    map[string]any `bun:",type:jsonb,nullzero" json:"metadata,omitempty"`
	RequestHash string         `bun:",notnull" json:"-"`
	CreatedAt   time.Time      `bun:",notnull" json:"created_at"`

	Transactions []*Transaction `bun:"rel:has-many,join:id=batch_id" json:"transactions"`
}

Batch groups transactions posted together, all or nothing. An exchange is a batch of two transactions in different currencies with the rate recorded.

type BatchID

type BatchID string

Every ID is "<prefix>_<uuidv7>", e.g. "txn_01926f3a-7c2e-7b4a-9f1d-3e8c5a2b1d40". UUIDv7 is time-ordered, which keeps primary-key indexes append-friendly. Distinct types make it a compile error to pass an AccountID where a TransactionID is expected.

func NewBatchID

func NewBatchID() BatchID

func ParseBatchID

func ParseBatchID(s string) (BatchID, error)

type BatchKind

type BatchKind string
const (
	BatchPlain    BatchKind = "batch"
	BatchExchange BatchKind = "exchange"
)

type BatchParams

type BatchParams struct {
	// Reference is the batch's idempotency key, unique per ledger.
	Reference   string         `json:"reference"`
	Description string         `json:"description,omitempty"`
	Metadata    map[string]any `json:"metadata,omitempty"`
	// Transactions are posted together: all of them or none. A member's
	// Reference is optional; if set it must be unique like any other.
	// Funds are checked once, on the combined effect of all members.
	Transactions []PostParams `json:"transactions"`
}

type CommitHoldParams

type CommitHoldParams struct {
	// Amount commits part of a two-leg hold. Value is the same as a decimal,
	// e.g. "12.50"; set one or the other.
	Amount Amount `json:"amount,omitzero"`
	Value  string `json:"value,omitempty"`
	// Legs commits part of a multi-leg hold; each (account, direction) total
	// must not exceed the held amount.
	Legs []Leg `json:"legs,omitempty"`
	// Description and Metadata default to the hold's.
	Description string         `json:"description,omitempty"`
	Metadata    map[string]any `json:"metadata,omitempty"`
}

CommitHoldParams selects how much of a hold to commit. Leave Amount, Value and Legs empty to commit the full hold; an explicit zero amount is an error. Whatever is not committed is released.

type CreateAccountParams

type CreateAccountParams struct {
	LedgerID LedgerID `json:"ledger_id"`
	Currency string   `json:"currency"`
	// Indicator optionally names the account, e.g. "@world". It is unique per
	// ledger and currency.
	Indicator     string `json:"indicator,omitempty"`
	AllowNegative bool   `json:"allow_negative"`
	// Untracked accounts take no lock when posted to and compute their
	// balance from entries on read. They require AllowNegative.
	Untracked bool `json:"untracked"`
	// OverdraftLimit (or OverdraftLimitValue, as a decimal) lets an account
	// without AllowNegative go this far below zero.
	OverdraftLimit      *Amount `json:"overdraft_limit,omitempty"`
	OverdraftLimitValue string  `json:"overdraft_limit_value,omitempty"`
	// Type classifies the account for financial statements. Optional; once
	// set it can't change.
	Type     AccountType    `json:"type,omitempty"`
	Metadata map[string]any `json:"metadata,omitempty"`
}

type CreateLedgerParams

type CreateLedgerParams struct {
	Name     string         `json:"name"`
	Metadata map[string]any `json:"metadata,omitempty"`
}

type Currency

type Currency struct {
	bun.BaseModel `bun:"table:ledger.currencies,alias:c"`

	Code string `bun:",pk" json:"code"`
	// Decimals is the number of digits after the decimal point: 2 for USD
	// (amount 540323 is 5403.23 USD), 0 for JPY, 18 for ETH. It is nil only
	// for a currency already in use before currencies were registered, until
	// RegisterCurrency sets it. Once set it never changes.
	Decimals  *int      `json:"decimals"`
	CreatedAt time.Time `bun:",notnull" json:"created_at"`
}

Currency says how to read amounts in a currency. Every account's currency must be registered with RegisterCurrency first.

func (*Currency) Format

func (c *Currency) Format(a Amount) (string, error)

Format writes a as a decimal value with exactly Decimals digits after the point: 540323 USD is "5403.23", 5 USD is "0.05".

func (*Currency) Parse

func (c *Currency) Parse(value string) (Amount, error)

Parse converts a decimal value such as "5403.23" into an Amount in minor units (540323 for USD). It never rounds: "5403.234" USD fails with ErrInvalidParams, though extra trailing zeros ("5403.230") are fine.

type CurrencyParams

type CurrencyParams struct {
	Code     string `json:"code"`
	Decimals int    `json:"decimals"`
}

type CurrencyTotal

type CurrencyTotal struct {
	Currency string `json:"currency"`
	Total    Amount `json:"total"`
}

CurrencyTotal sums balances in one currency. In a trial balance it is always zero: every amount that left one account arrived in another.

type Direction

type Direction string

Direction is the side of an entry. A credit increases an account's balance and a debit decreases it (balance = credit_total - debit_total).

const (
	Debit  Direction = "debit"
	Credit Direction = "credit"
)

type Entry

type Entry struct {
	bun.BaseModel `bun:"table:ledger.entries,alias:e"`

	ID             EntryID       `bun:",pk" json:"id"`
	TransactionID  TransactionID `bun:",notnull" json:"transaction_id"`
	AccountID      AccountID     `bun:",notnull" json:"account_id"`
	Direction      Direction     `bun:",notnull" json:"direction"`
	Amount         Amount        `bun:",notnull" json:"amount"`
	BalanceAfter   *Amount       `json:"balance_after,omitempty"`
	AccountVersion *int64        `json:"account_version,omitempty"`
	CreatedAt      time.Time     `bun:",notnull" json:"created_at"`
}

Entry is one immutable leg of a transaction against one account.

For tracked accounts, AccountVersion is the account's version after this entry was applied: gap-free per account, it gives a total order for the account's history, and BalanceAfter is the balance at that point. Both are nil for entries on untracked accounts.

type EntryID

type EntryID string

Every ID is "<prefix>_<uuidv7>", e.g. "txn_01926f3a-7c2e-7b4a-9f1d-3e8c5a2b1d40". UUIDv7 is time-ordered, which keeps primary-key indexes append-friendly. Distinct types make it a compile error to pass an AccountID where a TransactionID is expected.

func NewEntryID

func NewEntryID() EntryID

func ParseEntryID

func ParseEntryID(s string) (EntryID, error)

type Event

type Event struct {
	bun.BaseModel `bun:"table:ledger.events,alias:ev"`

	Seq       int64           `bun:",pk,autoincrement" json:"-"`
	ID        EventID         `bun:",notnull" json:"id"`
	Type      string          `bun:",notnull" json:"type"`
	LedgerID  *LedgerID       `bun:",nullzero" json:"ledger_id,omitempty"`
	SubjectID string          `bun:",notnull" json:"subject_id"`
	Data      json.RawMessage `bun:",type:jsonb,notnull" json:"data"`
	CreatedAt time.Time       `bun:",notnull" json:"created_at"`
}

Event records one change. Events are written in the same database transaction as the change, so they exist exactly when the change does.

type EventID

type EventID string

Every ID is "<prefix>_<uuidv7>", e.g. "txn_01926f3a-7c2e-7b4a-9f1d-3e8c5a2b1d40". UUIDv7 is time-ordered, which keeps primary-key indexes append-friendly. Distinct types make it a compile error to pass an AccountID where a TransactionID is expected.

func NewEventID

func NewEventID() EventID

func ParseEventID

func ParseEventID(s string) (EventID, error)

type ExchangeParams

type ExchangeParams struct {
	Reference string `json:"reference"`
	// Source is what is sold, typically from the customer's account to your
	// liquidity account in that currency; Target is what is bought, from
	// your liquidity account in the other currency to the customer's.
	Source ExchangeSide `json:"source"`
	Target ExchangeSide `json:"target"`
	// Rate is recorded with the exchange, as a decimal such as "0.92". It is
	// informational: the ledger doesn't derive one amount from the other.
	Rate        string         `json:"rate,omitempty"`
	Description string         `json:"description,omitempty"`
	Metadata    map[string]any `json:"metadata,omitempty"`
}

type ExchangeSide

type ExchangeSide struct {
	Currency string    `json:"currency"`
	From     AccountID `json:"from"`
	To       AccountID `json:"to"`
	Amount   Amount    `json:"amount,omitzero"`
	Value    string    `json:"value,omitempty"`
}

ExchangeSide is one currency's half of an exchange: Amount (or Value) moves From one account To another, in Currency.

type Hold

type Hold struct {
	bun.BaseModel `bun:"table:ledger.holds,alias:h"`

	ID          HoldID         `bun:",pk" json:"id"`
	LedgerID    LedgerID       `bun:",notnull" json:"ledger_id"`
	Reference   string         `bun:",notnull" json:"reference"`
	Status      HoldStatus     `bun:",notnull" json:"status"`
	Currency    string         `bun:",notnull" json:"currency"`
	Description string         `bun:",nullzero" json:"description,omitempty"`
	Metadata    map[string]any `bun:",type:jsonb,nullzero" json:"metadata,omitempty"`
	Legs        []Leg          `bun:",type:jsonb,notnull" json:"legs"`
	RequestHash string         `bun:",notnull" json:"-"`
	// TransactionID is the transaction that committed the hold.
	TransactionID *TransactionID `bun:",nullzero" json:"transaction_id,omitempty"`
	ExpiresAt     *time.Time     `bun:",nullzero" json:"expires_at,omitempty"`
	CreatedAt     time.Time      `bun:",notnull" json:"created_at"`
	ResolvedAt    *time.Time     `bun:",nullzero" json:"resolved_at,omitempty"`
}

Hold reserves funds without moving them. It stays pending until it is committed (posting a transaction for all or part of it), voided (releasing the funds), or, if it has an ExpiresAt, expired (released by ExpireHolds). Holds without ExpiresAt never expire.

type HoldID

type HoldID string

Every ID is "<prefix>_<uuidv7>", e.g. "txn_01926f3a-7c2e-7b4a-9f1d-3e8c5a2b1d40". UUIDv7 is time-ordered, which keeps primary-key indexes append-friendly. Distinct types make it a compile error to pass an AccountID where a TransactionID is expected.

func NewHoldID

func NewHoldID() HoldID

func ParseHoldID

func ParseHoldID(s string) (HoldID, error)

type HoldParams

type HoldParams struct {
	// Reference is the idempotency key for creating the hold, unique per ledger.
	Reference   string         `json:"reference"`
	Currency    string         `json:"currency"`
	Description string         `json:"description,omitempty"`
	Metadata    map[string]any `json:"metadata,omitempty"`
	// Legs are the postings the hold reserves; they must balance.
	Legs []Leg `json:"legs"`
	// ExpiresAt, if set, is when the hold lapses: it can no longer be
	// committed, and ExpireHolds releases its funds. Not part of the
	// idempotency check, so a retry may compute it again.
	ExpiresAt *time.Time `json:"expires_at,omitempty"`
}

type HoldStatus

type HoldStatus string
const (
	HoldPending   HoldStatus = "pending"
	HoldCommitted HoldStatus = "committed"
	HoldVoided    HoldStatus = "voided"
	// HoldExpired holds passed their ExpiresAt and were released.
	HoldExpired HoldStatus = "expired"
)

type IncomeStatement

type IncomeStatement struct {
	LedgerID  LedgerID      `json:"ledger_id"`
	Currency  string        `json:"currency"`
	From      time.Time     `json:"from"`
	To        time.Time     `json:"to"`
	Revenue   ReportSection `json:"revenue"`
	Expenses  ReportSection `json:"expenses"`
	NetIncome Amount        `json:"net_income"`
}

IncomeStatement shows one currency's revenue and expenses over a period: the change in each such account after from, up to and including to.

type Ledger

type Ledger struct {
	bun.BaseModel `bun:"table:ledger.ledgers,alias:l"`

	ID       LedgerID       `bun:",pk" json:"id"`
	Name     string         `bun:",notnull" json:"name"`
	Metadata map[string]any `bun:",type:jsonb,nullzero" json:"metadata,omitempty"`
	// ClosedThrough is the end of the last closed period: nothing can be
	// posted at or before it.
	ClosedThrough *time.Time `bun:",nullzero" json:"closed_through,omitempty"`
	CreatedAt     time.Time  `bun:",notnull" json:"created_at"`
}

type LedgerID

type LedgerID string

Every ID is "<prefix>_<uuidv7>", e.g. "txn_01926f3a-7c2e-7b4a-9f1d-3e8c5a2b1d40". UUIDv7 is time-ordered, which keeps primary-key indexes append-friendly. Distinct types make it a compile error to pass an AccountID where a TransactionID is expected.

func NewLedgerID

func NewLedgerID() LedgerID

func ParseLedgerID

func ParseLedgerID(s string) (LedgerID, error)

type Leg

type Leg struct {
	AccountID AccountID `json:"account_id"`
	Direction Direction `json:"direction"`
	// Amount is in minor units: 540323 for 5403.23 USD.
	Amount Amount `json:"amount"`
	// Value is the same amount as a decimal, "5403.23", converted using the
	// currency's registered decimals. Set Amount or Value; if both are set
	// they must agree. The ledger stores and returns only Amount.
	Value string `json:"value,omitempty"`
}

Leg moves an amount into or out of one account.

type ListEntriesParams

type ListEntriesParams struct {
	// After is the pagination cursor: pass the ID of the last entry from the
	// previous page. Empty starts from the beginning.
	After EntryID
	// Limit defaults to 100 and is capped at 1000.
	Limit int
}

type ListEventsParams

type ListEventsParams struct {
	// After is the pagination cursor: the ID of the last event received.
	// Empty starts from the oldest event.
	After EventID
	// LedgerID, if set, returns only that ledger's events.
	LedgerID LedgerID
	// Limit defaults to 100 and is capped at 1000.
	Limit int
}

type ListTransactionsParams

type ListTransactionsParams struct {
	// LedgerID is required.
	LedgerID LedgerID
	// AccountID, if set, returns only transactions with an entry on it.
	AccountID AccountID
	// From and To, if set, bound created_at: From <= created_at < To.
	From, To time.Time
	// Metadata, if set, returns only transactions whose metadata contains
	// all of these keys and values, e.g. {"customer_id": "c_77"}.
	Metadata map[string]any
	// After is the pagination cursor: the ID of the last transaction from the
	// previous page.
	After TransactionID
	// Limit defaults to 100 and is capped at 1000.
	Limit int
}

type PeriodClose

type PeriodClose struct {
	bun.BaseModel `bun:"table:ledger.period_closes,alias:pc"`

	LedgerID      LedgerID      `bun:",pk" json:"ledger_id"`
	ClosedThrough time.Time     `bun:",pk" json:"closed_through"`
	ClosedAt      time.Time     `bun:",notnull" json:"closed_at"`
	TrialBalance  *TrialBalance `bun:",type:jsonb,notnull" json:"trial_balance"`
}

PeriodClose records a closed accounting period and the trial balance it was closed with.

type PostParams

type PostParams struct {
	// Reference is the idempotency key. Posting the same reference again
	// returns the original transaction; reusing it with different currency or
	// legs fails with ErrReferenceConflict.
	Reference   string         `json:"reference"`
	Currency    string         `json:"currency"`
	Description string         `json:"description,omitempty"`
	Metadata    map[string]any `json:"metadata,omitempty"`
	// Legs must contain at least two entries whose debits equal their credits.
	Legs []Leg `json:"legs"`
}

func Transfer

func Transfer(reference, currency string, from, to AccountID, amount Amount) PostParams

Transfer is shorthand for the common two-leg posting: debit from, credit to.

type RefundParams

type RefundParams struct {
	// Reference is the refund's idempotency key. Each partial refund needs
	// its own reference.
	Reference string `json:"reference"`
	// Amount refunds part of a two-leg transaction. Value is the same as a
	// decimal, e.g. "12.50"; set one or the other.
	Amount Amount `json:"amount,omitzero"`
	Value  string `json:"value,omitempty"`
	// Legs refunds part of a multi-leg transaction, deciding which accounts
	// give money back (e.g. the merchant but not the fee account). Each leg
	// must reverse a leg of the original, within what is left to refund.
	Legs        []Leg          `json:"legs,omitempty"`
	Description string         `json:"description,omitempty"`
	Metadata    map[string]any `json:"metadata,omitempty"`
}

RefundParams selects how much of a transaction to refund. Leave Amount, Value and Legs empty to refund everything not yet refunded; an explicit zero amount is an error.

type ReportLine

type ReportLine struct {
	AccountID AccountID `json:"account_id"`
	Indicator *string   `json:"indicator,omitempty"`
	Amount    Amount    `json:"amount"`
}

ReportLine is one account in a financial statement. Amount is shown the way accountants expect: positive in the account's normal direction (debit for assets and expenses, credit for the rest).

type ReportSection

type ReportSection struct {
	Accounts []ReportLine `json:"accounts"`
	Total    Amount       `json:"total"`
}

type ReverseParams

type ReverseParams struct {
	// Reference is the reversal's idempotency key.
	Reference string `json:"reference"`
	// Reason is required and stored on the reversal, e.g. "posted to the
	// wrong account".
	Reason      string         `json:"reason"`
	Description string         `json:"description,omitempty"`
	Metadata    map[string]any `json:"metadata,omitempty"`
}

ReverseParams undoes a transaction. Unlike a refund, a reversal always takes back everything not yet refunded, and must say why.

type Service

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

func New

func New(db *bun.DB) *Service

func (*Service) BalanceAt

func (s *Service) BalanceAt(ctx context.Context, accountID AccountID, at time.Time) (Amount, error)

BalanceAt returns the account's balance as of time at. For tracked accounts it reads balance_after on the latest entry at or before at (entry timestamps never go backwards within an account, so the latest timestamp is also the latest version). For untracked ones it sums the entries up to at.

func (*Service) BalanceSheet

func (s *Service) BalanceSheet(ctx context.Context, ledgerID LedgerID, currency string, at time.Time) (*BalanceSheet, error)

func (*Service) CheckSchema

func (s *Service) CheckSchema(ctx context.Context) error

CheckSchema reports an error unless the database is reachable and every migration this binary knows about has been applied. Health checks use it.

func (*Service) ClosePeriod

func (s *Service) ClosePeriod(ctx context.Context, ledgerID LedgerID, through time.Time) (*PeriodClose, error)

ClosePeriod closes a ledger's books through a moment: from then on nothing can be posted at or before it, which the database enforces. The trial balance at that moment is stored with the close. Periods can't be reopened; closing the same moment again returns the existing close. through must be at least a minute in the past.

func (*Service) CommitHold

func (s *Service) CommitHold(ctx context.Context, id HoldID, p CommitHoldParams) (*Transaction, error)

CommitHold posts the hold (or part of it) as a transaction and releases the whole reservation. A hold commits at most once: retrying with the same params returns the same transaction; anything else fails with ErrHoldNotPending.

func (*Service) CommitHoldTx

func (s *Service) CommitHoldTx(ctx context.Context, db bun.IDB, id HoldID, p CommitHoldParams) (*Transaction, error)

func (*Service) CreateAccount

func (s *Service) CreateAccount(ctx context.Context, p CreateAccountParams) (*Account, error)

func (*Service) CreateHold

func (s *Service) CreateHold(ctx context.Context, p HoldParams) (*Hold, error)

CreateHold reserves funds for legs without moving them: debits count against the source accounts' available balance until the hold is committed or voided.

func (*Service) CreateHoldTx

func (s *Service) CreateHoldTx(ctx context.Context, db bun.IDB, p HoldParams) (*Hold, error)

func (*Service) CreateLedger

func (s *Service) CreateLedger(ctx context.Context, p CreateLedgerParams) (*Ledger, error)

func (*Service) DB

func (s *Service) DB() *bun.DB

DB returns the underlying database handle.

func (*Service) EnsureIndicatorAccount

func (s *Service) EnsureIndicatorAccount(ctx context.Context, p CreateAccountParams) (*Account, error)

EnsureIndicatorAccount returns the account with p's indicator in p's ledger and currency, creating it from p if it doesn't exist yet. An existing account is returned as is. System accounts such as "@world", which represent money entering or leaving the ledger, typically set AllowNegative and Untracked.

func (*Service) EventCursor

func (s *Service) EventCursor(ctx context.Context, name string) (EventID, error)

EventCursor returns the last event a named consumer (e.g. a webhook dispatcher) has processed, or "" if it hasn't processed any.

func (*Service) EventCursorTx

func (s *Service) EventCursorTx(ctx context.Context, db bun.IDB, name string) (EventID, error)

EventCursorTx is EventCursor through db, e.g. a pinned connection.

func (*Service) Exchange

func (s *Service) Exchange(ctx context.Context, p ExchangeParams) (*Batch, error)

Exchange converts between currencies: it posts the Source and Target transfers together as a batch of kind "exchange", with the rate recorded.

func (*Service) ExchangeTx

func (s *Service) ExchangeTx(ctx context.Context, db bun.IDB, p ExchangeParams) (*Batch, error)

func (*Service) ExpireHolds

func (s *Service) ExpireHolds(ctx context.Context, limit int) (int, error)

ExpireHolds releases up to limit pending holds whose ExpiresAt has passed, marking them expired, and returns how many it released. Each hold is released in its own database transaction. Holds being committed or voided at the same moment, or whose accounts stay locked by other transactions for more than a couple of seconds, are skipped and picked up by a later call. Run it periodically; ledgerd does.

func (*Service) GetAccount

func (s *Service) GetAccount(ctx context.Context, id AccountID) (*Account, error)

GetAccount returns the account with its current totals. For an untracked account the totals are computed from all of its entries.

func (*Service) GetAccountByIndicator

func (s *Service) GetAccountByIndicator(ctx context.Context, ledgerID LedgerID, indicator, currency string) (*Account, error)

func (*Service) GetAccountTx

func (s *Service) GetAccountTx(ctx context.Context, db bun.IDB, id AccountID) (*Account, error)

GetAccountTx reads through db, so inside a caller's transaction it sees that transaction's own uncommitted postings.

func (*Service) GetBatch

func (s *Service) GetBatch(ctx context.Context, id BatchID) (*Batch, error)

GetBatch returns a batch with its transactions and their entries.

func (*Service) GetCurrency

func (s *Service) GetCurrency(ctx context.Context, code string) (*Currency, error)

GetCurrency returns a registered currency, or ErrUnknownCurrency.

func (*Service) GetHold

func (s *Service) GetHold(ctx context.Context, id HoldID) (*Hold, error)

func (*Service) GetHoldTx

func (s *Service) GetHoldTx(ctx context.Context, db bun.IDB, id HoldID) (*Hold, error)

func (*Service) GetLedger

func (s *Service) GetLedger(ctx context.Context, id LedgerID) (*Ledger, error)

func (*Service) GetTransaction

func (s *Service) GetTransaction(ctx context.Context, id TransactionID) (*Transaction, error)

func (*Service) GetTransactionByReference

func (s *Service) GetTransactionByReference(ctx context.Context, ledgerID LedgerID, reference string) (*Transaction, error)

GetTransactionByReference finds a transaction by its reference, which is unique per ledger.

func (*Service) GetTransactionTx

func (s *Service) GetTransactionTx(ctx context.Context, db bun.IDB, id TransactionID) (*Transaction, error)

GetTransactionTx reads through db, so inside a caller's transaction it sees that transaction's own uncommitted postings.

func (*Service) IncomeStatement

func (s *Service) IncomeStatement(ctx context.Context, ledgerID LedgerID, currency string, from, to time.Time) (*IncomeStatement, error)

func (*Service) ListEntries

func (s *Service) ListEntries(ctx context.Context, accountID AccountID, p ListEntriesParams) ([]*Entry, error)

ListEntries returns an account's entries in a stable order that is safe to tail: an entry never appears behind a cursor that was already handed out.

Tracked accounts are ordered by AccountVersion, which is gap-free and assigned under the account lock, so every committed page is final.

Untracked accounts take no lock, so their entries are ordered by the ID of the database transaction that wrote them, and only entries from database transactions older than every transaction still in progress are returned. An entry from a long-running transaction therefore shows up once that transaction ends, never behind a cursor that has already moved past it. The cost is that any long-running transaction on the database delays new entries on untracked accounts.

func (*Service) ListEvents

func (s *Service) ListEvents(ctx context.Context, p ListEventsParams) ([]*Event, error)

ListEvents returns events in a stable order that is safe to tail: an event never appears behind a cursor that was already handed out. Like entries of untracked accounts, events are ordered by the database transaction that wrote them, and only shown once every older transaction has finished, so a long-running database transaction delays new events.

func (*Service) ListPeriodCloses

func (s *Service) ListPeriodCloses(ctx context.Context, ledgerID LedgerID) ([]*PeriodClose, error)

ListPeriodCloses returns a ledger's closed periods, oldest first.

func (*Service) ListTransactions

func (s *Service) ListTransactions(ctx context.Context, p ListTransactionsParams) ([]*Transaction, error)

ListTransactions searches a ledger's transactions, oldest first, with their entries. It is meant for browsing and support tools; to follow new transactions as they happen, use ListEvents, which never skips any.

func (*Service) Migrate

func (s *Service) Migrate(ctx context.Context) error

Migrate applies all pending schema migrations. It is safe to call on every startup and from several processes at once: a Postgres advisory lock makes concurrent callers wait their turn, and is released automatically if a process dies. Each migration and its bookkeeping row commit in one transaction, so a crash can never leave a migration half-recorded.

func (*Service) MigrateDown

func (s *Service) MigrateDown(ctx context.Context) error

MigrateDown reverts the most recently applied migration. It is meant for operators and tests; applications normally only call Migrate.

func (*Service) Post

func (s *Service) Post(ctx context.Context, p PostParams) (*Transaction, error)

Post records a balanced transaction in its own database transaction.

func (*Service) PostBatch

func (s *Service) PostBatch(ctx context.Context, p BatchParams) (*Batch, error)

PostBatch posts several transactions atomically, e.g. a payroll run or marketplace payouts. All accounts must be in one ledger; members may use different currencies. Posting the same reference again returns the original batch.

func (*Service) PostBatchTx

func (s *Service) PostBatchTx(ctx context.Context, db bun.IDB, p BatchParams) (*Batch, error)

func (*Service) PostTx

func (s *Service) PostTx(ctx context.Context, db bun.IDB, p PostParams) (*Transaction, error)

PostTx records a balanced transaction using db, which may be the caller's bun.Tx. That lets the caller commit ledger postings atomically with its own writes. Inside a caller's transaction the work runs in a savepoint, so a failed posting does not abort the caller's transaction.

func (*Service) RefreshSnapshots

func (s *Service) RefreshSnapshots(ctx context.Context) (int, error)

RefreshSnapshots updates the running totals of every untracked account, so reading their balance only has to add up entries written since. It changes no balance, only how fast it is computed. Run it periodically; ledgerd does. It returns how many accounts it refreshed.

A snapshot covers exactly the entries written by database transactions older than every transaction still running (txid < xmin): those can no longer change, and every later entry has a txid at or above the bound.

func (*Service) Refund

Refund posts a transaction reversing all or part of transaction id. Refunds can never add up to more than the original, even when requested concurrently: the original transaction is locked while a refund is posted.

func (*Service) RefundTx

func (s *Service) RefundTx(ctx context.Context, db bun.IDB, id TransactionID, p RefundParams) (*Transaction, error)

func (*Service) Refundable

func (s *Service) Refundable(ctx context.Context, id TransactionID) ([]Leg, error)

Refundable returns what is left to refund of transaction id, as the legs a full refund would post now.

func (*Service) RefundableTx

func (s *Service) RefundableTx(ctx context.Context, db bun.IDB, id TransactionID) ([]Leg, error)

func (*Service) RegisterCurrency

func (s *Service) RegisterCurrency(ctx context.Context, p CurrencyParams) (*Currency, error)

RegisterCurrency registers a currency and its decimals. Registering it again with the same decimals returns it unchanged; different decimals fail with ErrCurrencyConflict, since stored amounts would change meaning.

func (*Service) Reverse

func (s *Service) Reverse(ctx context.Context, id TransactionID, p ReverseParams) (*Transaction, error)

Reverse posts a transaction undoing everything left of transaction id, to correct a mistake. It is a refund of the remainder with a reason: refunds and reversals together never exceed the original, and afterwards nothing is left to refund.

func (*Service) ReverseTx

func (s *Service) ReverseTx(ctx context.Context, db bun.IDB, id TransactionID, p ReverseParams) (*Transaction, error)

func (*Service) SetEventCursor

func (s *Service) SetEventCursor(ctx context.Context, name string, id EventID) error

SetEventCursor records that a named consumer has processed every event up to and including id. A cursor only moves forward: setting it to an event before the current one does nothing.

func (*Service) SetEventCursorTx

func (s *Service) SetEventCursorTx(ctx context.Context, db bun.IDB, name string, id EventID) error

SetEventCursorTx is SetEventCursor through db, e.g. a pinned connection.

func (*Service) Statement

func (s *Service) Statement(ctx context.Context, accountID AccountID, from, to time.Time) (*Statement, error)

Statement returns an account's entries with from <= created_at < to, the balance before them and the running balance after each, like a bank statement. Periods with more than 100,000 entries are refused; split them.

func (*Service) TrialBalance

func (s *Service) TrialBalance(ctx context.Context, ledgerID LedgerID, at time.Time) (*TrialBalance, error)

TrialBalance returns every account in the ledger with its posted balance as of at (holds are not included), and the total per currency.

func (*Service) UpdateAccount

func (s *Service) UpdateAccount(ctx context.Context, id AccountID, p UpdateAccountParams) (*Account, error)

UpdateAccount applies p to an account and returns it. The account is locked FOR UPDATE while it changes. That conflicts with postings holding it (or, for untracked accounts, the key-share lock their entries take), so a status change waits for postings already in flight, and later ones see the new status.

func (*Service) UpdateTransactionMetadata

func (s *Service) UpdateTransactionMetadata(ctx context.Context, id TransactionID, patch map[string]any) (*Transaction, error)

UpdateTransactionMetadata merges patch into a transaction's metadata (keys set to nil are removed) and returns the transaction. Metadata is the only part of a transaction that can ever change.

func (*Service) VoidHold

func (s *Service) VoidHold(ctx context.Context, id HoldID) (*Hold, error)

VoidHold releases a pending hold's reserved funds. Voiding a voided hold returns it unchanged; voiding a committed hold fails with ErrHoldNotPending.

func (*Service) VoidHoldTx

func (s *Service) VoidHoldTx(ctx context.Context, db bun.IDB, id HoldID) (*Hold, error)

type Statement

type Statement struct {
	Account        *Account        `json:"account"`
	From           time.Time       `json:"from"`
	To             time.Time       `json:"to"`
	OpeningBalance Amount          `json:"opening_balance"`
	ClosingBalance Amount          `json:"closing_balance"`
	Lines          []StatementLine `json:"lines"`
}

type StatementLine

type StatementLine struct {
	EntryID       EntryID       `json:"entry_id"`
	TransactionID TransactionID `json:"transaction_id"`
	Reference     string        `json:"reference,omitempty"`
	Description   string        `json:"description,omitempty"`
	Direction     Direction     `json:"direction"`
	Amount        Amount        `json:"amount"`
	// Balance is the running balance after this line.
	Balance   Amount    `json:"balance"`
	CreatedAt time.Time `json:"created_at"`
}

StatementLine is one entry in an account statement.

type Transaction

type Transaction struct {
	bun.BaseModel `bun:"table:ledger.transactions,alias:t"`

	ID       TransactionID `bun:",pk" json:"id"`
	LedgerID LedgerID      `bun:",notnull" json:"ledger_id"`
	// Reference is the idempotency key, unique per ledger. It is empty on
	// the transaction that commits a hold, which is identified by HoldID.
	Reference   string            `bun:",nullzero" json:"reference,omitempty"`
	Status      TransactionStatus `bun:",notnull" json:"status"`
	Currency    string            `bun:",notnull" json:"currency"`
	Description string            `bun:",nullzero" json:"description,omitempty"`
	Metadata    map[string]any    `bun:",type:jsonb,nullzero" json:"metadata,omitempty"`
	// RefundOf is set on refunds and points at the refunded transaction.
	RefundOf *TransactionID `bun:",nullzero" json:"refund_of,omitempty"`
	// HoldID is set on the transaction that committed a hold.
	HoldID *HoldID `bun:",nullzero" json:"hold_id,omitempty"`
	// BatchID is set on transactions posted as part of a batch or exchange.
	BatchID *BatchID `bun:",nullzero" json:"batch_id,omitempty"`
	// ReversalReason is set on reversals: refunds of everything that was left,
	// made to correct a mistake.
	ReversalReason string    `bun:",nullzero" json:"reversal_reason,omitempty"`
	RequestHash    string    `bun:",notnull" json:"-"`
	CreatedAt      time.Time `bun:",notnull" json:"created_at"`

	Entries []*Entry `bun:"rel:has-many,join:id=transaction_id" json:"entries,omitempty"`
}

type TransactionID

type TransactionID string

Every ID is "<prefix>_<uuidv7>", e.g. "txn_01926f3a-7c2e-7b4a-9f1d-3e8c5a2b1d40". UUIDv7 is time-ordered, which keeps primary-key indexes append-friendly. Distinct types make it a compile error to pass an AccountID where a TransactionID is expected.

func NewTransactionID

func NewTransactionID() TransactionID

func ParseTransactionID

func ParseTransactionID(s string) (TransactionID, error)

type TransactionStatus

type TransactionStatus string
const StatusPosted TransactionStatus = "posted"

type TrialBalance

type TrialBalance struct {
	LedgerID LedgerID           `json:"ledger_id"`
	At       time.Time          `json:"at"`
	Accounts []TrialBalanceLine `json:"accounts"`
	Totals   []CurrencyTotal    `json:"totals"`
}

type TrialBalanceLine

type TrialBalanceLine struct {
	AccountID AccountID   `json:"account_id"`
	Indicator *string     `json:"indicator,omitempty"`
	Type      AccountType `json:"type,omitempty"`
	Currency  string      `json:"currency"`
	Balance   Amount      `json:"balance"`
}

TrialBalanceLine is one account's balance in a trial balance.

type UpdateAccountParams

type UpdateAccountParams struct {
	// Status freezes (no money leaves), unfreezes or closes the account.
	// Only an empty account (zero balance, nothing pending) can be closed,
	// and closing is final.
	Status *AccountStatus `json:"status,omitempty"`
	// StatusReason records why, e.g. "fraud review". It is replaced on every
	// status change, and cleared when one is made without a reason.
	StatusReason string `json:"status_reason,omitempty"`
	// OverdraftLimit (or OverdraftLimitValue) sets the overdraft limit; 0
	// removes it. It can't be lowered below what the account already owes.
	OverdraftLimit      *Amount `json:"overdraft_limit,omitempty"`
	OverdraftLimitValue string  `json:"overdraft_limit_value,omitempty"`
	// Type sets the account's type, if it has none yet.
	Type *AccountType `json:"type,omitempty"`
	// Metadata is merged into the account's metadata; keys set to null are
	// removed.
	Metadata map[string]any `json:"metadata,omitempty"`
}

UpdateAccountParams changes an account. Nil fields are left unchanged.

Directories

Path Synopsis
cmd
ledgerd command
Command ledgerd runs the ledger as a standalone HTTP service.
Command ledgerd runs the ledger as a standalone HTTP service.
Package httpapi exposes a ledger.Service over JSON/HTTP.
Package httpapi exposes a ledger.Service over JSON/HTTP.
Package ledgertest provides throwaway, migrated Postgres databases for tests, without Docker.
Package ledgertest provides throwaway, migrated Postgres databases for tests, without Docker.
Package webhook delivers ledger events to an HTTP endpoint.
Package webhook delivers ledger events to an HTTP endpoint.

Jump to

Keyboard shortcuts

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