README
¶
NomLedger
NomLedger is a storage-agnostic, double-entry validation core for Go. It checks in-memory financial data against explicit accounting invariants before the data is persisted anywhere.
Release status: v1.0.0 verified at d16d9a6; G4b and G5 PASS
(2026-11-05). The tag is ready but not yet created. See the
verification report.
Overview
NomLedger validates transactions and returns structurally immutable, currency-aware values ready for persistence. It guarantees non-empty entries, exact zero-sum balancing, period closure rules, functional-currency consistency, and as-of exchange-rate integrity.
It does not store, aggregate, report, or source financial data. Its job is to reject invalid financial state before a caller writes it elsewhere.
Installation
go get github.com/Emin-ACIKGOZ/nomledger
Quick start
This is a complete, compilable program using the v1 API and an injected clock
for deterministic PostedAt values.
package main
import (
"context"
"fmt"
"time"
"github.com/shopspring/decimal"
"github.com/Emin-ACIKGOZ/nomledger/pkg/core"
"github.com/Emin-ACIKGOZ/nomledger/pkg/interfaces"
)
func main() {
config := core.LedgerConfig{
FunctionalCurrency: "USD",
ClosedDate: time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC),
RateTolerance: decimal.RequireFromString("0.00001"),
}
provider := interfaces.RateProviderFunc(func(ctx context.Context, req interfaces.RateRequest) (decimal.Decimal, error) {
return decimal.NewFromInt(1), nil
})
clock := core.ClockFunc(func() time.Time {
return time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
})
ledger, err := core.NewLedger(config, provider, core.WithClock(clock))
if err != nil {
panic(err)
}
cash, err := core.NewEntry(
"acc-cash",
decimal.RequireFromString("100.00"),
"USD",
decimal.NewFromInt(1),
"USD",
)
if err != nil {
panic(err)
}
revenue, err := core.NewEntry(
"acc-revenue",
decimal.RequireFromString("-100.00"),
"USD",
decimal.NewFromInt(1),
"USD",
)
if err != nil {
panic(err)
}
input := core.TransactionInput{
ID: "tx-101",
EffectiveDate: time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC),
Entries: []core.Entry{cash, revenue},
Metadata: map[string]any{"source": "readme"},
}
tx, err := ledger.ValidateTransaction(context.Background(), input)
if err != nil {
panic(err)
}
fmt.Printf("validated %s with %d entries at %s\n",
tx.ID(), len(tx.Entries()), tx.PostedAt().Format(time.RFC3339))
}
Error handling
Every validation failure matches a sentinel with errors.Is and exposes
structured context through errors.As.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/shopspring/decimal"
"github.com/Emin-ACIKGOZ/nomledger/pkg/core"
pkgerr "github.com/Emin-ACIKGOZ/nomledger/pkg/errors"
"github.com/Emin-ACIKGOZ/nomledger/pkg/interfaces"
)
func main() {
config := core.LedgerConfig{
FunctionalCurrency: "USD",
ClosedDate: time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC),
RateTolerance: decimal.Zero,
}
provider := interfaces.RateProviderFunc(func(ctx context.Context, req interfaces.RateRequest) (decimal.Decimal, error) {
return decimal.NewFromInt(1), nil
})
ledger, err := core.NewLedger(config, provider)
if err != nil {
panic(err)
}
debit, err := core.NewEntry(
"acc-cash",
decimal.RequireFromString("100.00"),
"USD",
decimal.NewFromInt(1),
"USD",
)
if err != nil {
panic(err)
}
credit, err := core.NewEntry(
"acc-revenue",
decimal.RequireFromString("-99.00"),
"USD",
decimal.NewFromInt(1),
"USD",
)
if err != nil {
panic(err)
}
input := core.TransactionInput{
ID: "tx-unbalanced",
EffectiveDate: time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC),
Entries: []core.Entry{debit, credit},
}
_, err = ledger.ValidateTransaction(context.Background(), input)
if err == nil {
fmt.Println("unexpected success")
return
}
var validationErr *pkgerr.ValidationError
if errors.As(err, &validationErr) {
fmt.Printf("operation=%s field=%s entry=%d\n",
validationErr.Operation, validationErr.Field, validationErr.EntryIndex)
}
if errors.Is(err, pkgerr.ErrZeroSumViolation) {
fmt.Println("zero-sum validation failed as expected")
}
}
Error taxonomy
Retryable means the caller can retry without changing the input, for example after a transient provider failure or with a fresh context. Non-retryable errors require changing the input, configuration, or caller usage.
| Sentinel | Message | Returned when | Retryable |
|---|---|---|---|
ErrZeroSumViolation |
transaction does not sum to zero |
Build sees functional amounts whose exact sum is non-zero. |
No |
ErrPeriodClosed |
effective date falls in closed period |
A non-adjusting transaction has an effective date at or before ClosedDate. |
No |
ErrRateDeviation |
exchange rate exceeds tolerance |
Provider and entry rates differ by more than RateTolerance. |
No |
ErrRateProvider |
rate provider error |
Context cancellation or deadline expiry before any validation/provider work (including same-currency transactions), cancellation during a provider call, or a provider call returns an error. | Yes |
ErrInvalidPrecision |
amount exceeds currency precision |
An Entry or Money amount has more decimal places than the currency's minor units. |
No |
ErrCurrencyMismatch |
entry functional currency does not match ledger |
An entry's functional currency differs from the ledger's, or Money arithmetic mixes currencies. |
No |
ErrEmptyTransaction |
transaction must contain at least one entry |
Build receives zero entries. |
No |
ErrZeroAmount |
transaction amount must be non-zero |
NewEntry receives a zero transaction amount. |
No |
ErrFunctionalAmountZero |
functional amount rounds to zero |
A non-zero transaction amount rounds to zero in the functional currency. | No |
ErrInvalidEntry |
invalid entry |
Entry.Validate sees a zero-value or internally inconsistent entry, or Build's entry check fails. |
No |
ErrNilRateProvider |
rate provider must not be nil |
NewLedger receives a nil provider, or a rate lookup is attempted without one. |
No |
ErrInvalidTransactionID |
invalid transaction ID |
Build receives an empty, over-255-byte, or control-character transaction ID. |
No |
ErrInvalidEffectiveDate |
invalid effective date |
Build receives the zero time as the effective date. |
No |
ErrInvalidMetadata |
invalid metadata |
Build receives a metadata key that is empty, over 64 bytes, or contains a control character. |
No |
ErrInvalidAccountID |
invalid account ID |
NewAccount, NewEntry, or their Validate methods receive an invalid account ID. |
No |
ErrInvalidAccountType |
invalid account type |
NewAccount or Account.Validate receives an unsupported account type. |
No |
ErrInvalidMeasurementCategory |
invalid measurement category |
NewAccount or Account.Validate receives an unsupported measurement category. |
No |
ErrInvalidExchangeRate |
exchange rate must be strictly positive |
An entry or provider rate is zero or negative, or a same-currency entry rate is not exactly one. | No |
ErrInvalidConfiguration |
invalid ledger configuration |
LedgerConfig has an invalid functional currency or a negative tolerance. |
No |
ErrUnknownCurrency |
unknown or unsupported currency code |
A well-formed code is absent from the active ISO 4217 registry. | No |
ErrInvalidCurrencyCode |
currency code must be exactly three ASCII letters |
A currency code is not exactly three ASCII letters. | No |
ErrCurrencyNotRatable |
currency has no defined minor units |
A currency with N.A. minor units is used where ratability is required. | No |
ErrInvalidRoundingMode |
invalid rounding mode |
Round receives an unknown RoundingMode. |
No |
ErrInexactRounding |
rounding would change the value |
RoundUnnecessary would change the numeric amount. |
No |
ErrBuilderFinalized |
builder is already finalized |
Build is called again after a successful build finalized the builder. |
No |
Guarantees
Every successfully validated transaction satisfies:
- Non-empty and valid entries: at least one entry; each entry has a bounded, control-character-free account ID, valid and ratable currencies, a non-zero amount at or below the currency's minor units, and a positive exchange rate.
- Exact zero-sum: the decimal sum of all functional amounts is exactly zero. Ties are not resolved with a tolerance.
- Period closure: non-adjusting transactions at or before
ClosedDateare rejected. Adjusting events bypass only the period-closure rule. - Currency consistency: every entry's functional currency equals the ledger's configured functional currency.
- As-of rate integrity: foreign entries carry an effective date in each rate
request. Provider rates are positive and must match the entry rate within the
inclusive
RateTolerance. The provider is called at most once per unique(base, quote, as-of)request per validation. - Immutable results:
Transaction.Entries()andTransaction.Metadata()return defensive copies; the transaction exposes no exported mutable fields. - Typed failures: every failure matches a sentinel with
errors.Isand carries a*ValidationErrorwith operation, entry index, field, and optional cause. - Determinism: the same configuration, input, provider responses, and clock
produce the same transaction, including a UTC
PostedAt. - Metadata LIM-001: the top-level metadata map is copied, but metadata values that are reference types remain caller-owned and shared.
Validation order is fixed: configuration, transaction ID, effective date, non-empty entries, metadata keys, entry validity, currency consistency, zero-sum, period closure, and rate integrity.
Conformance profile
NomLedger targets the NomLedger Core Conformance Profile v1. It is informed by IAS 21 (foreign currency), IAS 10 (adjusting events), and IFRS 9 classification tags. It claims only that explicit validation profile. It makes no blanket IFRS compliance claim and does not perform measurement, classification tests, or financial reporting.
API summary
pkg/core
LedgerConfigwithFunctionalCurrency,ClosedDate, andRateTolerance;Validate()checks the configuration.NewLedger(config, provider, opts...),WithClock,Config(), andUpdateConfig(config);NewLedgerandUpdateConfigreturn errors.NewTxBuilderreturns a builder and defers option errors toBuild.Ledger.ValidateTransaction(ctx, TransactionInput)is the canonical path.ValidateTransactionLegacypreserves the pre-1.0 variadic call shape and is deprecated.TransactionInputcarries ID, effective date, adjusting flag, entries, and metadata.TxBuilder/NewTxBuilder/WithBuilderClockprovide an alternate construction path that runs the same pipeline. A successfulBuildfinalizes the builder; later mutations are no-ops and a rebuild returnsErrBuilderFinalized.Entry/NewEntry,Account/NewAccount,Clock/ClockFunc,AccountType, andMeasurementCategory(includingFVOCIandNotApplicable).Transactionaccessors:ID,EffectiveDate,IsAdjustingEvent,PostedAt,Entries, andMetadata.
pkg/currency
CodeandCurrencywith case-insensitiveLookup,All,MinorUnits,Numeric,Name, andIsFund.- The registry contains 178 active codes from SIX ISO 4217 List One published 2026-09-17. Unknown codes are rejected; there is no default precision.
RoundingModeandRoundfor HalfEven, HalfUp, HalfDown, Floor, Ceiling, and Unnecessary rounding.Money/NewMoneywith currency-safeAdd,Sub,Neg,IsZero, andString.GetPrecisionandRoundToPrecisionremain as deprecated compatibility wrappers.
pkg/errors
- Typed sentinels matched with
errors.Is, includingErrZeroSumViolation,ErrPeriodClosed,ErrCurrencyMismatch,ErrRateProvider,ErrInvalidPrecision,ErrUnknownCurrency, andErrInvalidConfiguration. ValidationErrorplusNewValidationErrorandNewEntryValidationErrorfor structured context extracted witherrors.As.
pkg/interfaces
RateRequestcarriesBase(transaction currency),Quote(functional currency), and the UTCAsOfeffective date.RateProvider.GetRate(ctx, req)is context- and date-aware.RateProviderFuncadapts an ordinary function toRateProvider.
Migration from pre-1.0
- The module path is
github.com/Emin-ACIKGOZ/nomledger. NewLedgerandUpdateConfignow return(*Ledger, error).NewTxBuilderstill returns only*TxBuilder; builder option errors surface fromBuild.RateProvidernow usesGetRate(ctx context.Context, req RateRequest); the oldGetRate(base, quote string)method is gone.- Use
ValidateTransaction(ctx, TransactionInput); the legacy variadic method is deprecated. - Entry construction is strict: amounts beyond the transaction currency's minor units, zero amounts, non-positive rates, and non-one same-currency rates are rejected. Non-zero amounts that round to zero in the functional currency are also rejected.
MeasurementCategoryaddsNotApplicableandFVOCI.- The currency registry contains 178 active codes. Unknown currencies are no longer given a default precision.
- Provider errors are wrapped with
ErrRateProviderand preserve their cause; they are no longer returned verbatim. - A builder is final after one successful
Build.
Concurrency
Ledgeris safe for concurrent use.TxBuilderis not safe for concurrent use and should live within one execution scope.- A shared
RateProviderandClockmust be safe for concurrent use. The defaulttime.Nowclock is safe.
Metadata and LIM-001
Metadata is non-normative. The top-level map is shallowly copied on input and
output, so callers cannot add, remove, or replace keys in a transaction through
the original map or through Metadata(). Values that are reference types (for
example pointers, maps, or slices) remain caller-owned and may still be mutated
by their owner. This is the documented LIM-001 limitation.
Non-goals
NomLedger intentionally does not provide:
- Persistence or database integration
- Reporting, balances, or aggregation logic
- Natural-balance enforcement by account type
- FX sourcing or rate-provider implementations
- Tax calculations or full IFRS measurement
- Historical currencies
- An account registry or account storage
- Duplicate detection
- Serialization codecs
- Multi-tenancy
- Full IFRS compliance
Documentation
API documentation is available on demand through go doc:
go doc github.com/Emin-ACIKGOZ/nomledger/pkg/core
go doc github.com/Emin-ACIKGOZ/nomledger/pkg/currency
go doc github.com/Emin-ACIKGOZ/nomledger/pkg/errors
go doc github.com/Emin-ACIKGOZ/nomledger/pkg/interfaces
The stale generated documentation.txt snapshot has been removed.
See CHANGELOG.md for release history and RELEASE_NOTES.md for the v1.0.0 release details.
License
MIT
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
iso4217
Package iso4217 holds the generated ISO 4217 currency table and its source data.
|
Package iso4217 holds the generated ISO 4217 currency table and its source data. |
|
iso4217/gen
command
Package main implements the ISO 4217 table generator.
|
Package main implements the ISO 4217 table generator. |
|
pkg
|
|
|
core
Package core provides the normative validation engine for NomLedger.
|
Package core provides the normative validation engine for NomLedger. |
|
currency
Package currency provides the ISO 4217 currency registry, code validation, precision arithmetic, and rounding rules.
|
Package currency provides the ISO 4217 currency registry, code validation, precision arithmetic, and rounding rules. |
|
errors
Package errors defines the typed failure taxonomy returned by NomLedger.
|
Package errors defines the typed failure taxonomy returned by NomLedger. |
|
interfaces
Package interfaces defines the contracts required for external dependency injection.
|
Package interfaces defines the contracts required for external dependency injection. |