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
- Variables
- func OpenDB(dsn string) *bun.DB
- type Account
- type AccountID
- type AccountStatus
- type AccountType
- type Amount
- func (a Amount) Add(b Amount) Amount
- func (a Amount) Big() *big.Int
- func (a Amount) Cmp(b Amount) int
- func (a Amount) Int64() (int64, bool)
- func (a Amount) IsZero() bool
- func (a Amount) MarshalJSON() ([]byte, error)
- func (a Amount) Neg() Amount
- func (a *Amount) Scan(src any) error
- func (a Amount) Sign() int
- func (a Amount) String() string
- func (a Amount) Sub(b Amount) Amount
- func (a *Amount) UnmarshalJSON(b []byte) error
- func (a Amount) Value() (driver.Value, error)
- type BalanceSheet
- type Batch
- type BatchID
- type BatchKind
- type BatchParams
- type CommitHoldParams
- type CreateAccountParams
- type CreateLedgerParams
- type Currency
- type CurrencyParams
- type CurrencyTotal
- type Direction
- type Entry
- type EntryID
- type Event
- type EventID
- type ExchangeParams
- type ExchangeSide
- type Hold
- type HoldID
- type HoldParams
- type HoldStatus
- type IncomeStatement
- type Ledger
- type LedgerID
- type Leg
- type ListEntriesParams
- type ListEventsParams
- type ListTransactionsParams
- type PeriodClose
- type PostParams
- type RefundParams
- type ReportLine
- type ReportSection
- type ReverseParams
- type Service
- func (s *Service) BalanceAt(ctx context.Context, accountID AccountID, at time.Time) (Amount, error)
- func (s *Service) BalanceSheet(ctx context.Context, ledgerID LedgerID, currency string, at time.Time) (*BalanceSheet, error)
- func (s *Service) CheckSchema(ctx context.Context) error
- func (s *Service) ClosePeriod(ctx context.Context, ledgerID LedgerID, through time.Time) (*PeriodClose, error)
- func (s *Service) CommitHold(ctx context.Context, id HoldID, p CommitHoldParams) (*Transaction, error)
- func (s *Service) CommitHoldTx(ctx context.Context, db bun.IDB, id HoldID, p CommitHoldParams) (*Transaction, error)
- func (s *Service) CreateAccount(ctx context.Context, p CreateAccountParams) (*Account, error)
- func (s *Service) CreateHold(ctx context.Context, p HoldParams) (*Hold, error)
- func (s *Service) CreateHoldTx(ctx context.Context, db bun.IDB, p HoldParams) (*Hold, error)
- func (s *Service) CreateLedger(ctx context.Context, p CreateLedgerParams) (*Ledger, error)
- func (s *Service) DB() *bun.DB
- func (s *Service) EnsureIndicatorAccount(ctx context.Context, p CreateAccountParams) (*Account, error)
- func (s *Service) EventCursor(ctx context.Context, name string) (EventID, error)
- func (s *Service) EventCursorTx(ctx context.Context, db bun.IDB, name string) (EventID, error)
- func (s *Service) Exchange(ctx context.Context, p ExchangeParams) (*Batch, error)
- func (s *Service) ExchangeTx(ctx context.Context, db bun.IDB, p ExchangeParams) (*Batch, error)
- func (s *Service) ExpireHolds(ctx context.Context, limit int) (int, error)
- func (s *Service) GetAccount(ctx context.Context, id AccountID) (*Account, error)
- func (s *Service) GetAccountByIndicator(ctx context.Context, ledgerID LedgerID, indicator, currency string) (*Account, error)
- func (s *Service) GetAccountTx(ctx context.Context, db bun.IDB, id AccountID) (*Account, error)
- func (s *Service) GetBatch(ctx context.Context, id BatchID) (*Batch, error)
- func (s *Service) GetCurrency(ctx context.Context, code string) (*Currency, error)
- func (s *Service) GetHold(ctx context.Context, id HoldID) (*Hold, error)
- func (s *Service) GetHoldTx(ctx context.Context, db bun.IDB, id HoldID) (*Hold, error)
- func (s *Service) GetLedger(ctx context.Context, id LedgerID) (*Ledger, error)
- func (s *Service) GetTransaction(ctx context.Context, id TransactionID) (*Transaction, error)
- func (s *Service) GetTransactionByReference(ctx context.Context, ledgerID LedgerID, reference string) (*Transaction, error)
- func (s *Service) GetTransactionTx(ctx context.Context, db bun.IDB, id TransactionID) (*Transaction, error)
- func (s *Service) IncomeStatement(ctx context.Context, ledgerID LedgerID, currency string, from, to time.Time) (*IncomeStatement, error)
- func (s *Service) ListEntries(ctx context.Context, accountID AccountID, p ListEntriesParams) ([]*Entry, error)
- func (s *Service) ListEvents(ctx context.Context, p ListEventsParams) ([]*Event, error)
- func (s *Service) ListPeriodCloses(ctx context.Context, ledgerID LedgerID) ([]*PeriodClose, error)
- func (s *Service) ListTransactions(ctx context.Context, p ListTransactionsParams) ([]*Transaction, error)
- func (s *Service) Migrate(ctx context.Context) error
- func (s *Service) MigrateDown(ctx context.Context) error
- func (s *Service) Post(ctx context.Context, p PostParams) (*Transaction, error)
- func (s *Service) PostBatch(ctx context.Context, p BatchParams) (*Batch, error)
- func (s *Service) PostBatchTx(ctx context.Context, db bun.IDB, p BatchParams) (*Batch, error)
- func (s *Service) PostTx(ctx context.Context, db bun.IDB, p PostParams) (*Transaction, error)
- func (s *Service) RefreshSnapshots(ctx context.Context) (int, error)
- func (s *Service) Refund(ctx context.Context, id TransactionID, p RefundParams) (*Transaction, error)
- func (s *Service) RefundTx(ctx context.Context, db bun.IDB, id TransactionID, p RefundParams) (*Transaction, error)
- func (s *Service) Refundable(ctx context.Context, id TransactionID) ([]Leg, error)
- func (s *Service) RefundableTx(ctx context.Context, db bun.IDB, id TransactionID) ([]Leg, error)
- func (s *Service) RegisterCurrency(ctx context.Context, p CurrencyParams) (*Currency, error)
- func (s *Service) Reverse(ctx context.Context, id TransactionID, p ReverseParams) (*Transaction, error)
- func (s *Service) ReverseTx(ctx context.Context, db bun.IDB, id TransactionID, p ReverseParams) (*Transaction, error)
- func (s *Service) SetEventCursor(ctx context.Context, name string, id EventID) error
- func (s *Service) SetEventCursorTx(ctx context.Context, db bun.IDB, name string, id EventID) error
- func (s *Service) Statement(ctx context.Context, accountID AccountID, from, to time.Time) (*Statement, error)
- func (s *Service) TrialBalance(ctx context.Context, ledgerID LedgerID, at time.Time) (*TrialBalance, error)
- func (s *Service) UpdateAccount(ctx context.Context, id AccountID, p UpdateAccountParams) (*Account, error)
- func (s *Service) UpdateTransactionMetadata(ctx context.Context, id TransactionID, patch map[string]any) (*Transaction, error)
- func (s *Service) VoidHold(ctx context.Context, id HoldID) (*Hold, error)
- func (s *Service) VoidHoldTx(ctx context.Context, db bun.IDB, id HoldID) (*Hold, error)
- type Statement
- type StatementLine
- type Transaction
- type TransactionID
- type TransactionStatus
- type TrialBalance
- type TrialBalanceLine
- type UpdateAccountParams
Constants ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
AmountFromBig returns x as an Amount. It copies x.
func ParseAmount ¶
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) MarshalJSON ¶
func (*Amount) UnmarshalJSON ¶
UnmarshalJSON accepts a string of digits or an integer JSON number.
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 ¶
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 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.
type CurrencyParams ¶
type CurrencyTotal ¶
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).
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 ¶
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 ¶
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 ParseHoldID ¶
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 ¶
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 ListEventsParams ¶
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"`
}
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 (*Service) BalanceAt ¶
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 (*Service) CheckSchema ¶
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 (*Service) CreateHold ¶
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 (*Service) CreateLedger ¶
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 ¶
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 ¶
EventCursorTx is EventCursor through db, e.g. a pinned connection.
func (*Service) Exchange ¶
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 (*Service) ExpireHolds ¶
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 ¶
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 (*Service) GetAccountTx ¶
GetAccountTx reads through db, so inside a caller's transaction it sees that transaction's own uncommitted postings.
func (*Service) GetCurrency ¶
GetCurrency returns a registered currency, or ErrUnknownCurrency.
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 (*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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 (*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 ¶
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 ¶
func (s *Service) Refund(ctx context.Context, id TransactionID, p RefundParams) (*Transaction, error)
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 ¶
Refundable returns what is left to refund of transaction id, as the legs a full refund would post now.
func (*Service) RefundableTx ¶
func (*Service) RegisterCurrency ¶
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 ¶
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 ¶
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.
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.
Source Files
¶
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. |