Kolo.Ledger 0.0.3

There is a newer version of this package available.
See the version list below for details.
dotnet add package Kolo.Ledger --version 0.0.3
                    
NuGet\Install-Package Kolo.Ledger -Version 0.0.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Kolo.Ledger" Version="0.0.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Kolo.Ledger" Version="0.0.3" />
                    
Directory.Packages.props
<PackageReference Include="Kolo.Ledger" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Kolo.Ledger --version 0.0.3
                    
#r "nuget: Kolo.Ledger, 0.0.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Kolo.Ledger@0.0.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Kolo.Ledger&version=0.0.3
                    
Install as a Cake Addin
#tool nuget:?package=Kolo.Ledger&version=0.0.3
                    
Install as a Cake Tool

Kolo.Ledger

kolo (Yoruba) — piggy box / savings box

Production-grade double-entry ledger library with HMAC-SHA256 hash-chain integrity, period-end closing, multi-book support, and a fully typed audit trail. Built for .NET 10 on EF Core + PostgreSQL.

Package Version
Kolo.Ledger NuGet

Features

  • Double-entry accounting — automatic debit/credit balance tracking with contra-account awareness
  • HMAC-SHA256 hash chain — every entry is cryptographically linked to its predecessor, providing tamper-evidence and non-repudiation
  • Append-only audit trailJournalEntry and JournalLine are immutable after creation; SaveChangesAsync throws ImmutableRecordException on any modify or delete
  • Transaction reversal — creates fully offsetting entries (never deletes originals), preserving audit history
  • Multi-book (ledger) architecture — run primary, supplemental, budget, tax, and statutory books side-by-side with cross-book reconciliation
  • Period management — lock/unlock accounting periods to prevent post-close adjustments
  • Period-end closing — automated closing entries that zero out income statement accounts into retained earnings
  • Keyset (cursor-based) pagination — all list queries use MR.EntityFrameworkCore.KeysetPagination; no Skip/Take anywhere
  • Hash-chain integrity verification — full ledger scan that validates every entry's hash, sequence, and normal balance
  • Multi-tenancyITenantProvider-based data isolation per organisation
  • Role-based access controlIFinanceAuthorizationService with granular permissions (finance.journal.post, finance.period.lock, etc.)
  • Export — built-in JSON and CSV export for audit and reporting
  • Export service — built-in JSON/CSV export for journal entries, trial balance, and GL
  • Access logging — all sensitive operations are logged to FinanceAuditEntry for SOX compliance
  • EF Core migrations — ready-to-run PostgreSQL schema under the finance schema

Quickstart

1. Install

dotnet add package Kolo.Ledger

2. Configure services

builder.Services.AddLedger(
    connectionString: "Host=localhost;Port=7203;Database=finance");

3. Set the HMAC signing key

The hash chain requires a symmetric key (minimum 32 bytes). Set it via environment variable or configuration:

export KoloLedger__HmacKey="$(openssl rand -base64 32)"

Or in appsettings.json:

{
  "KoloLedger": {
    "HmacKey": "your-base64-32-byte-key"
  }
}

4. Run migrations

dotnet ef database update --project Kolo.Ledger

Or apply programmatically:

await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
await db.Database.MigrateAsync();

5. Seed the chart of accounts

var seeder = scope.ServiceProvider.GetRequiredService<ChartOfAccountsSeeder>();
await seeder.SeedAsync();

6. Record your first journal entry

var recordEntry = scope.ServiceProvider.GetRequiredService<IRecordEntryCommand>();

var entry = await recordEntry.ExecuteAsync(new JournalEntryRequest(
    Reference: "INV-001",
    Description: "Invoice payment received",
    BookId: primaryBookId,
    Lines:
    [
        new(accountId: cashAccountId, amount: 1000m, entryType: LedgerEntryType.Debit),
        new(accountId: revenueAccountId, amount: 1000m, entryType: LedgerEntryType.Credit),
    ],
    CreatedByUserId: "user-123"));

Configuration

Connection string

// Simplest form
services.AddLedger("Host=localhost;Port=7203;Database=finance");

// With schema override
services.AddLedger("Host=localhost;Port=7203;Database=finance", schemaName: "my_finance");

// With full options
services.AddLedger(options =>
{
    options.ConnectionString = "Host=localhost;Port=7203;Database=finance";
    options.SchemaName = "finance";
    options.MaxPageSize = 200;
    options.RetryMaxAttempts = 3;
    options.SeedDefaultBook = true;
});

LedgerOptions reference

Option Default Description
SchemaName "finance" PostgreSQL schema for all ledger tables
MaxPageSize 100 Maximum items per keyset-paginated page (overridable via KOLO_PAGE_SIZE_MAX env var)
HmacKeyMinimumLength 32 Minimum bytes for the HMAC signing key
RetryMaxAttempts 5 Retry attempts for sequence conflict resolution
RetryBaseDelayMs 100 Base delay (ms) for exponential backoff
RetryMaxDelayMs 5000 Maximum delay (ms) between retries
LockTimeoutMs 5000 Lock timeout (ms) for period operations
IncludeErrorDetails true Include detailed error messages in exceptions
SeedDefaultBook true Auto-seed a default primary book on first use

HMAC key sources (priority order)

  1. KoloLedger__HmacKey environment variable
  2. KOLO_LEDGER_HMAC_KEY environment variable
  3. configuration["KoloLedger:HmacKey"] from IConfiguration
  4. Custom IHmacKeyProvider implementation

Implement IHmacKeyProvider to provide the key from your own secure store (e.g., Azure Key Vault, AWS KMS, HashiCorp Vault):

services.AddSingleton<IHmacKeyProvider>(sp => new MyVaultHmacKeyProvider());

Multi-tenancy

Implement ITenantProvider to scope data by organisation:

services.AddScoped<ITenantProvider, MyTenantProvider>();

public class MyTenantProvider : ITenantProvider
{
    private readonly IHttpContextAccessor _http;
    public MyTenantProvider(IHttpContextAccessor http) => _http = http;

    public Guid? GetOrganisationId()
    {
        var claim = _http.HttpContext?.User.FindFirst("org_id");
        return claim is not null ? Guid.Parse(claim.Value) : null;
    }
}

In single-tenant mode, NullTenantProvider is used (no filtering).


Core Concepts

Double-entry accounting

Every journal entry has at least two lines: one debit and one credit. Total debits must equal total credits. The library enforces this at the domain level and will throw UnbalancedTransactionException if violated.

Account types & normal balances

Account Type Normal Balance Contra Normal Classification
Asset Debit Credit (contra-asset) Balance Sheet
Liability Credit Debit (contra-liability) Balance Sheet
Equity Credit Debit (contra-equity) Balance Sheet
Revenue Credit Debit (contra-revenue) Income Statement
Expense Debit Credit (contra-expense) Income Statement

Use AccountExtensions.CalculateBalance() to determine an account's net position:

var balance = AccountExtensions.CalculateBalance(account.Type, totalDebits, totalCredits);

Books (ledgers)

A Book represents a logical ledger. You can have multiple books for different purposes:

  • Primary — the main general ledger
  • Supplemental — sub-ledgers for specific departments or entities
  • Budget — budget vs actual tracking
  • Tax — tax-basis accounting
  • Statutory — regulatory reporting

Books can be reconciled against each other via IReconciliationService:

var report = await reconciliationService.ReconcileAsync(
    sourceBookId: primaryBookId,
    targetBookId: taxBookId);
// report.IsBalanced, report.Differences, report.TotalAbsoluteDifference

The hash chain

Every JournalEntry stores:

  • SequenceNumber — monotonically increasing (unique per book)
  • PreviousHash — the Hash of the preceding entry in the same book
  • HashHMAC-SHA256(PreviousHash + EntryData + LineData, key)
  • SignatureVersion — algorithm version for future-proofing

The genesis entry uses "GENESIS" as its PreviousHash. The chain is verified by IVerifyIntegrityCommand, which checks:

  1. No sequence number gaps
  2. Every PreviousHash matches the predecessor's Hash
  3. Every entry's Hash recomputes correctly
  4. Every line's normal balance is correct

Immutability

JournalEntry and JournalLine use init-only setters and private constructors with static factory methods. The FinanceDbContext.SaveChangesAsync override explicitly prevents any modification or deletion of these entity types by throwing ImmutableRecordException.


Usage

Recording entries

var entry = await recordEntry.ExecuteAsync(new JournalEntryRequest(
    Reference: "PUR-042",
    Description: "Office supplies purchase",
    BookId: primaryBookId,
    EntryDate: DateTime.UtcNow,
    Source: "purchasing",
    Lines:
    [
        new(accountId: suppliesExpenseId, amount: 250.00m, entryType: LedgerEntryType.Debit),
        new(accountId: cashAccountId, amount: 250.00m, entryType: LedgerEntryType.Credit),
    ],
    CreatedByUserId: "alice",
    CreatedByUserName: "Alice Johnson",
    CorrelationId: "req-abc-123",
    RequestIp: "192.168.1.1",
    UserAgent: "ERP/v2.1"));

Reversing entries

Reversal creates a new entry with opposite debits/credits linked to the original:

var reversal = await reverseEntry.ExecuteAsync(
    entryId: originalEntry.Id,
    reason: "Customer refund — order cancelled",
    createdByUserId: "bob");

The reversal entry has IsReversal = true, ReversedEntryId pointing to the original, and is timestamped in the audit log.

Trial balance

var trialBalance = await trialBalanceQuery.ExecuteAsync(
    year: 2026,
    month: 7,
    bookId: primaryBookId);

Console.WriteLine($"Balanced: {trialBalance.IsBalanced}");
Console.WriteLine($"Total Debits: {trialBalance.TotalDebits}");
Console.WriteLine($"Total Credits: {trialBalance.TotalCredits}");

foreach (var entry in trialBalance.Entries)
{
    Console.WriteLine($"{entry.AccountCode} {entry.AccountName}: {entry.Balance}");
}

General ledger

var gl = await generalLedgerQuery.ExecuteAsync(new GeneralLedgerRequest(
    AccountId: cashAccountId,
    DateFrom: new DateTime(2026, 1, 1),
    DateTo: new DateTime(2026, 12, 31),
    BookId: primaryBookId));

Console.WriteLine($"Opening: {gl.OpeningBalance}, Closing: {gl.ClosingBalance}");

Listing entries (keyset pagination)

var result = await listJournalEntries.ExecuteAsync(new JournalEntryFilter
{
    BookId = primaryBookId,
    PageSize = 50,
    Direction = KeysetPaginationDirection.Forward,
});

foreach (var item in result.Items) { /* ... */ }

// Navigate forward
if (result.HasNext)
{
    var nextPage = await listJournalEntries.ExecuteAsync(new JournalEntryFilter
    {
        BookId = primaryBookId,
        ReferenceCursor = result.NextReference,
        Direction = KeysetPaginationDirection.Forward,
        PageSize = 50,
    });
}

Period management

// Lock a period to prevent new entries
await lockPeriod.ExecuteAsync(year: 2026, month: 6, lockedByUserId: "admin");

// Unlock (requires finance.period.lock permission)
await unlockPeriod.ExecuteAsync(year: 2026, month: 6, unlockedByUserId: "admin");

// Close a period — zeroes income statement accounts to retained earnings
await closePeriod.ExecuteAsync(year: 2026, month: 6, bookId: primaryBookId,
    createdByUserId: "admin");

Integrity verification

var integrity = await verifyIntegrity.ExecuteAsync();
Console.WriteLine($"Valid: {integrity.IsValid}");
Console.WriteLine($"Entries checked: {integrity.TotalEntriesChecked}");
Console.WriteLine($"Errors: {integrity.ErrorCount}");

foreach (var error in integrity.Errors)
{
    Console.WriteLine($"[{error.ErrorType}] Entry {error.SequenceNumber}: {error.Message}");
}

Export

string json = await exportService.ExportToJsonAsync(entries);
string csv = await exportService.ExportToCsvAsync(trialBalance.Entries);

Get account balance

decimal balance = await getBalanceQuery.ExecuteAsync(accountId: cashAccountId);

Authorization & Permissions

The library includes a built-in permission system accessed via IFinanceAuthorizationService:

Permission Constant Operation
finance.journal.post FinancePermissions.JournalPost Record journal entries
finance.journal.reverse FinancePermissions.JournalReverse Reverse entries
finance.period.lock FinancePermissions.PeriodLock Lock/unlock periods
finance.period.close FinancePermissions.PeriodClose Close accounting periods
finance.audit.view FinancePermissions.AuditView View audit logs and integrity reports
await authorizationService.AssertPermissionAsync(FinancePermissions.JournalPost, ct);
string userId = authorizationService.CurrentUserId;

Implement IFinanceAuthorizationService or override ICurrentUser for custom authorization logic. The default NullCurrentUser grants all permissions.


Exceptions

Exception Cause
UnbalancedTransactionException Debits do not equal credits in a journal entry
ImmutableRecordException Attempted to modify or delete a JournalEntry or JournalLine
PeriodLockedException Attempted to post to a locked accounting period
SequenceConflictException Concurrent sequence number allocation conflict (auto-retried)
ContraNormalBalanceException Zero or negative balance on a normal-balance account that disallows negatives
InsufficientBalanceException Insufficient funds for a debit on a balance-based account
NegativeBalanceForbiddenException Balance would go negative on an account with AllowNegativeBalance = false
SystemAccountConstraintException System account assigned to an organisation or vice versa

Architecture

Kolo.Ledger/
├── Abstractions/           — Public interfaces for all injectable services
│   ├── IRecordEntryCommand
│   ├── IReverseEntryCommand
│   ├── IGetBalanceQuery
│   ├── IGetTrialBalanceQuery
│   ├── IClosePeriodCommand
│   ├── IVerifyIntegrityCommand
│   ├── IExportService
│   ├── IReconciliationService
│   ├── ITenantProvider
│   ├── IFinanceAuthorizationService
│   └── ICurrentUser
│
├── Configuration/          — EF Core IEntityTypeConfiguration<T> classes
├── Core/                   — FinanceConstants, FinancePermissions, LedgerOptions
├── DTOs/                   — Request/response types
├── Entities/               — Domain model with init-only properties
├── Exceptions/             — Typed exception hierarchy
├── Extensions/             — DI registration (AddLedger)
├── Features/               — Feature-first commands & queries
│   ├── Accounts/           — GetBalance, GetAccountBalance
│   ├── Audit/              — VerifyIntegrity
│   ├── Books/              — CreateBook, ListBooks, ReconcileBooks
│   ├── Closing/            — ClosePeriod
│   ├── JournalEntries/     — RecordEntry, ReverseEntry, ValidateEntry
│   ├── Periods/            — LockPeriod, UnlockPeriod, EnsurePeriodExists
│   └── Reporting/          — TrialBalance, GeneralLedger, ListJournalEntries
├── Mapper/                 — Shared mapping extensions
├── Migrations/             — EF Core PostgreSQL migrations
├── Seeding/                — ChartOfAccountsSeeder, BookSeeder
└── Services/               — AccessLogger, HmacKeyProvider, ExportService

Design principles

  • Feature-first folders — each feature (Accounts, JournalEntries, Periods, etc.) is self-contained with its own Commands/, Queries/, and Mapper/ sub-folders
  • Action classes — every use case is a single class with one public ExecuteAsync(...) method; all EF queries live in private methods
  • No unit of work wrapper — commands interact with FinanceDbContext directly; SaveChangesAsync is called explicitly
  • Keyset pagination everywhere — all list queries use cursor-based pagination via MR.EntityFrameworkCore.KeysetPagination; never Skip/Take
  • Explicit DI registration — no Scrutor or assembly scanning; all services registered individually in ServiceCollectionExtensions

Compliance & Audit

Framework Focus Status
GAAP (ASC 205-250) Revenue recognition, balance sheet presentation 85/100
IFRS (IAS 1, 21, 37) Financial statement presentation, provisions 78/100
SOX 404 (ICFR) Internal controls over financial reporting 78/100
ACID Transactional integrity, isolation, durability 82/100

The library provides:

  • Append-only journal tables — no deletions or silent edits
  • HMAC-SHA256 hash chain — tamper evidence with cryptographic verification
  • Full access logging — every sensitive operation recorded in FinanceAuditEntry
  • RowVersion concurrency — optimistic locking on all entities
  • Transaction reversal — audit-preserving offset entries (never deletes originals)

Migrations & Deployment

The package ships with EF Core migrations for PostgreSQL under the finance schema:

# Generate a new migration
dotnet ef migrations add AddNewFeature --project Kolo.Ledger

# Apply to database
dotnet ef database update --project Kolo.Ledger

# Apply programmatically
var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
await db.Database.MigrateAsync();

Operational requirements

For production PostgreSQL deployment:

  1. fsync=on — committed writes survive power loss
  2. synchronous_commit=on — COMMIT returns after WAL flush
  3. WAL archiving to S3/GCS — point-in-time recovery capability
  4. Hourly backups of the finance schema
  5. Quarterly restore testing
  6. RTO < 1 hour, RPO < 1 minute

Seeding

The library includes two seeders for initial setup:

// Seeds system accounts (Cash, Accounts Receivable, Revenue, etc.)
var coaSeeder = scope.ServiceProvider.GetRequiredService<ChartOfAccountsSeeder>();
await coaSeeder.SeedAsync();

// Seeds default books (Primary, Supplemental, Tax)
var bookSeeder = scope.ServiceProvider.GetRequiredService<BookSeeder>();
await bookSeeder.SeedAsync();

System accounts are defined in SystemAccountDefinition records and are identifiable by IsSystem = true. They don't belong to any organisation.


Development

Build

dotnet build Kolo.Ledger/Kolo.Ledger.csproj

Run tests

dotnet test Kolo.Ledger/tests/Kolo.Ledger.Tests

Generate a migration

export FINANCE_DB_CONNECTION="Host=localhost;Database=finance"
dotnet ef migrations add MigrationName --project Kolo.Ledger

Pack for NuGet

dotnet pack Kolo.Ledger/Kolo.Ledger.csproj -c Release -o ./nupkg

Publish to NuGet

dotnet nuget push ./nupkg/Kolo.Ledger.0.1.0.nupkg \
    --api-key $NUGET_API_KEY \
    --source https://api.nuget.org/v3/index.json

Package Contents

When installed, Kolo.Ledger provides:

  • Kolo.Ledger.dll — the compiled library
  • Kolo.Ledger.xml — IntelliSense XML documentation
  • Kolo.Ledger.pdb — symbols (via snupkg) for debugging
  • README.md — this documentation (visible on nuget.org)

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.0.22 107 8/12/2026
0.0.21 60 8/12/2026
0.0.20 68 8/12/2026
0.0.19 72 8/12/2026
0.0.18 85 8/10/2026
0.0.17 81 8/10/2026
0.0.16 72 8/10/2026
0.0.15 77 8/10/2026
0.0.13 72 8/10/2026
0.0.12 76 8/10/2026
0.0.11 80 8/10/2026
0.0.10 121 7/27/2026
0.0.9 212 7/23/2026
0.0.8 95 7/23/2026
0.0.7 100 7/23/2026
0.0.6 93 7/23/2026
0.0.5 113 7/23/2026
0.0.4 103 7/22/2026
0.0.3 101 7/22/2026
0.0.2 92 7/22/2026
Loading failed