Kolo.Ledger 0.2.1

dotnet add package Kolo.Ledger --version 0.2.1
                    
NuGet\Install-Package Kolo.Ledger -Version 0.2.1
                    
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.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Kolo.Ledger" Version="0.2.1" />
                    
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.2.1
                    
#r "nuget: Kolo.Ledger, 0.2.1"
                    
#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.2.1
                    
#: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.2.1
                    
Install as a Cake Addin
#tool nuget:?package=Kolo.Ledger&version=0.2.1
                    
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

Upgrading from 0.1.0? 0.2.0 closes the 2026-10-30 security audit. The API and the schema are additive and historical hashes keep verifying forever, but authorization now fails closed: if you never registered an ICurrentUser, set AllowAnonymousAccess or the ledger starts denying. Read MIGRATION-0.2.0.md before you bump the package.


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. The preimage is versioned (v1, v2, v3): stored versions are never rewritten, so upgrading never invalidates history, and v3 covers organisation, creator, approver, book and key id so attribution cannot be rewritten undetected
  • Deferred balance constraint — an entry with unequal debits and credits fails at COMMIT, not silently
  • Append-only audit trail — JournalEntry 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-tenancy — ITenantProvider-based data isolation per organisation
  • Role-based access control — IFinanceAuthorizationService 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.AddKoloLedger(
    connectionString: "Host=localhost;Port=7203;Database=finance");

3. Set the HMAC signing key

The hash chain requires a symmetric key (minimum 32 bytes, Base64-encoded). Pass it via LedgerOptions:

builder.Services.AddKoloLedger(
    "Host=localhost;Port=7203;Database=finance",
    options =>
    {
        options.HmacSecretKey = configuration["KoloLedger:HmacSecretKey"];
    });

You supply the key from your own configuration or secure store (KMS, vault, etc.). The key persists across restarts — it is never generated at startup.

4. Apply migrations

dotnet ef database update --project Kolo.Ledger

Or apply at startup:

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

5. Seed the chart of accounts

var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
var accountProvider = scope.ServiceProvider.GetRequiredService<ISystemAccountProvider>();
await ChartOfAccountsSeeder.SeedAsync(db, accountProvider);

6. Inject into your own services

builder.Services.AddScoped<InvoiceService>();

public class InvoiceService
{
    private readonly ILedgerService _ledger;

    public InvoiceService(ILedgerService ledger) => _ledger = ledger;

    public async Task RecordPayment(Guid cashAccountId, Guid revenueAccountId, Guid bookId)
    {
        var entry = await _ledger.Journal.RecordEntryAsync(new JournalEntryRequest(
            Reference: "INV-001",
            Description: "Invoice payment received",
            BookId: bookId,
            Lines:
            [
                new(cashAccountId, 1000m, LedgerEntryType.Debit),
                new(revenueAccountId, 1000m, LedgerEntryType.Credit),
            ]));
    }
}

You can also inject individual service interfaces instead of the full facade:

public class BalanceChecker
{
    private readonly IBalanceService _balance;

    public BalanceChecker(IBalanceService balance) => _balance = balance;

    public async Task<decimal> GetCashBalance(Guid cashAccountId)
        => await _balance.GetBalanceAsync(cashAccountId);
}

Full project setup

A complete example from dotnet new to working ledger:

1. Create a new project

dotnet new webapi -n MyFinanceApp
cd MyFinanceApp
dotnet add package Kolo.Ledger

2. Configure Program.cs

using Kolo.Ledger.Extensions;
using Kolo.Ledger.Seeding;
using Kolo.Ledger.Entities;

var builder = WebApplication.CreateBuilder(args);

// Register ledger services
builder.Services.AddKoloLedger(builder.Configuration.GetConnectionString("FinanceDb")!);

// Register your own services
builder.Services.AddScoped<MyFinanceService>();

// Add controllers
builder.Services.AddControllers();

var app = builder.Build();

// Migrate + seed at startup
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<Kolo.Ledger.Data.FinanceDbContext>();
await db.Database.MigrateAsync();

var accountProvider = scope.ServiceProvider.GetRequiredService<Kolo.Ledger.Seeding.ISystemAccountProvider>();
await ChartOfAccountsSeeder.SeedAsync(db, accountProvider);
var book = await BookSeeder.SeedDefaultBookAsync(db);

app.MapControllers();
app.Run();

3. Use in a controller

[ApiController]
[Route("invoices")]
public class InvoicesController : ControllerBase
{
    private readonly ILedgerService _ledger;

    public InvoicesController(ILedgerService ledger) => _ledger = ledger;

    [HttpPost]
    public async Task<IActionResult> Post(JournalEntryRequest request)
    {
        var entry = await _ledger.Journal.RecordEntryAsync(request);
        return Ok(entry);
    }

    [HttpGet("balance/{accountId}")]
    public async Task<IActionResult> GetBalance(Guid accountId)
    {
        var balance = await _ledger.Balance.GetBalanceAsync(accountId);
        return Ok(balance);
    }
}

4. Add the connection string to appsettings.json

{
  "ConnectionStrings": {
    "FinanceDb": "Host=localhost;Port=7203;Database=finance"
  }
}

5. Run

dotnet run

Configuration

Connection string

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

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

// With full options
services.AddKoloLedger(
    "Host=localhost;Port=7203;Database=finance",
    options =>
    {
        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
HmacSecretKey null Base64-encoded HMAC-SHA256 signing key (minimum 32 bytes when decoded)
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 identifiers (account GUIDs/codes) in exception messages. false redacts the account, balance, general-ledger and validate paths through ErrorDetailMessages; the posting path's missing-accounts message is not redacted
SeedDefaultBook true Auto-seed a default primary book on first use
UseTenantScopedLedger true Per-tenant hash chain and per-tenant Reference/Code uniqueness
RequireReadPermissions true Read queries assert journal.read/account.read/book.read (or an implying permission). false restores pre-0.2.1 reads
BookScopedBalanceChecks true The posting overdraft guard reads the entry's own book through IGetBookBalanceQuery; false aggregates across books
ValidateEntryHashOnInsert true Re-hash every inserted entry and reject a malformed, unverifiable or mismatched digest
RequireDistinctApprover false Require an approver distinct from the authenticated actor, holding ApproverPermission, on every posting. Reversals are exempt
ApproverPermission finance.journal.post Permission asserted for the approver when RequireDistinctApprover is on
AllowAnonymousAccess false Permit NullCurrentUser to allow everything (0.1.0 behaviour; logs a warning). Breaking default — see MIGRATION-0.2.0.md
AllowPostingToSystemAccounts false Permit posting to IsSystem accounts (0.1.0 behaviour; logs a warning)
AllowMixedCurrencyEntries false Permit entries whose lines mix currencies (0.1.0 behaviour; logs a warning)
AllowNonSerializableAmbientTransaction false Permit an ambient transaction below Serializable (0.1.0 behaviour; logs a warning)
ReversalEnforcesNonNegativeBalance false Make a reversal that would overdraw a !AllowNegativeBalance account fail instead of warn
NeutraliseCsvFormulas true Prefix =, +, -, @, tab and CR in exported CSV cells so spreadsheets do not evaluate them
AuditFailureIsFatal true Abort the business operation when the audit row cannot be written
LegacyHmacSecretKey null Base64 key used to verify v1/v2 entries after a rotation
CurrentHmacKeyId null ("default") Key id stamped on v3 entries when a key ring is in use

HMAC key configuration

Pass the key (minimum 32 bytes, Base64-encoded) through LedgerOptions.HmacSecretKey:

services.AddKoloLedger("Host=localhost;Port=7203;Database=finance", options =>
{
    options.HmacSecretKey = configuration["KoloLedger:HmacSecretKey"];
});

You supply the key from your own configuration or secure store (Azure Key Vault, AWS KMS, HashiCorp Vault, etc.). Or provide a custom IHmacKeyProvider implementation:

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). When a provider returns an organisation, reads, writes, the hash chain (SequenceNumber/PreviousHash), and the uniqueness of Reference and account Code are all scoped to it; a NULL organisation is the legacy/global band and still verifies.

Tenancy is only half of isolation — authorization is the other half. Register an ICurrentUser (see MIGRATION-0.2.0.md §1); AllowAnonymousAccess defaults to false in 0.2.0.


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, contiguous, and scoped to the tenant when UseTenantScopedLedger is on (legacy rows form one global band)
  • PreviousHash — the Hash of the preceding entry in the same chain band
  • Hash — HMAC-SHA256(PreviousHash + EntryData + LineData, key)
  • SignatureVersion — which preimage produced Hash. Three versions exist, and the stored version is never rewritten:
Version Stamped on What the preimage covers
HMAC-SHA256 (v1) entries written before the amount columns widened the original layout; amounts rendered with 2 decimals
HMAC-SHA256-v2 entries written after the columns widened to numeric(20,8) the same layout; amounts rendered with 8 decimals
HMAC-SHA256-v3 every entry written by 0.2.0 or later v2 plus OrganisationId, KeyId, BookId, creator, approver and reversal target, with lines ordered by a total key (AccountId, IsDebit, Amount, BalanceBefore, Id)

v3 entries also record KeyId — the id of the HMAC key that signed them — so a key rotation does not invalidate history.

v1 and v2 preimages are frozen byte-for-byte and verified exactly as they were written, so an upgrade never invalidates history — a 0.1.0 database verifies unchanged after dotnet ef database update, with no data migration. v3 exists because the older preimages did not cover attribution (BookId, creator, approver), which made those fields rewritable without detection, and did not impose a total order on lines, which made verification nondeterministic. See MIGRATION-0.2.0.md §3.2.

The genesis entry uses "GENESIS" as its PreviousHash. The chain is verified by IVerifyIntegrityCommand, which groups entries into chain bands (one legacy/global band plus one per tenant) and, for each band, checks:

  1. No sequence number gaps
  2. Every PreviousHash matches the predecessor's Hash
  3. Every entry's Hash recomputes correctly (using the stored SignatureVersion)
  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

public class PurchasingService
{
    private readonly ILedgerService _ledger;

    public PurchasingService(ILedgerService ledger) => _ledger = ledger;

    public async Task RecordPurchase(Guid suppliesExpenseId, Guid cashAccountId, Guid bookId)
    {
        var entry = await _ledger.Journal.RecordEntryAsync(new JournalEntryRequest(
            Reference: "PUR-042",
            Description: "Office supplies purchase",
            BookId: bookId,
            EntryDate: DateTime.UtcNow,
            Source: "purchasing",
            Lines:
            [
                new(suppliesExpenseId, 250.00m, LedgerEntryType.Debit),
                new(cashAccountId, 250.00m, 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:

public class RefundService
{
    private readonly ILedgerService _ledger;

    public RefundService(ILedgerService ledger) => _ledger = ledger;

    public async Task RefundOrder(Guid originalEntryId)
    {
        var reversal = await _ledger.Journal.ReverseEntryAsync(
            entryId: originalEntryId,
            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

public class ReportingService
{
    private readonly ILedgerService _ledger;

    public ReportingService(ILedgerService ledger) => _ledger = ledger;

    public async Task PrintTrialBalance(Guid bookId)
    {
        var trialBalance = await _ledger.Reporting.GetTrialBalanceAsync(
            year: 2026, month: 7, bookId: bookId);

        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

public class GlReportService
{
    private readonly ILedgerService _ledger;

    public GlReportService(ILedgerService ledger) => _ledger = ledger;

    public async Task PrintGl(Guid cashAccountId, Guid bookId)
    {
        var gl = await _ledger.Reporting.GetGeneralLedgerAsync(new GeneralLedgerRequest(
            AccountId: cashAccountId,
            DateFrom: new DateTime(2026, 1, 1),
            DateTo: new DateTime(2026, 12, 31),
            BookId: bookId));

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

Listing entries (keyset pagination)

public class EntryLister
{
    private readonly ILedgerService _ledger;

    public EntryLister(ILedgerService ledger) => _ledger = ledger;

    public async Task ListEntries(Guid bookId)
    {
        var result = await _ledger.Journal.ListEntriesAsync(new JournalEntryFilter
        {
            BookId = bookId,
            PageSize = 50,
            Direction = KeysetPaginationDirection.Forward,
        });

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

        // Navigate forward
        if (result.HasNext)
        {
            var nextPage = await _ledger.Journal.ListEntriesAsync(new JournalEntryFilter
            {
                BookId = bookId,
                ReferenceCursor = result.NextReference,
                Direction = KeysetPaginationDirection.Forward,
                PageSize = 50,
            });
        }
    }
}

Period management

public class PeriodManager
{
    private readonly ILedgerService _ledger;

    public PeriodManager(ILedgerService ledger) => _ledger = ledger;

    public async Task ManagePeriods(Guid bookId)
    {
        // Lock a period to prevent new entries
        await _ledger.Periods.LockPeriodAsync(year: 2026, month: 6, lockedByUserId: "admin");

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

        // Close a period — zeroes income statement accounts to retained earnings
        await _ledger.Periods.ClosePeriodAsync(year: 2026, month: 6, bookId: bookId,
            createdByUserId: "admin");
    }
}

Integrity verification

public class AuditService
{
    private readonly ILedgerService _ledger;

    public AuditService(ILedgerService ledger) => _ledger = ledger;

    public async Task VerifyLedgerIntegrity()
    {
        var integrity = await _ledger.Audit.VerifyIntegrityAsync();
        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

public class ExportService
{
    private readonly IExportService _export;

    public ExportService(IExportService export) => _export = export;

    public async Task ExportData(List<JournalEntryDto> entries, TrialBalanceReport trialBalance)
    {
        string json = await _export.ExportToJsonAsync(entries);
        string csv = await _export.ExportToCsvAsync(trialBalance.Entries);
    }
}

Typed metadata (accounts, journal lines, journal entries)

Every Account, JournalLine, and JournalEntry supports strongly-typed metadata via extension methods. Metadata is stored as a single JSONB column per entity — one POCO per entity. If you need multiple data shapes, compose a single DTO:

public record WalletInfo(string WalletType, string Status);

Account — MetadataJson (JSONB column, string?):

account.SetMetadata(new WalletInfo("main", "active"));

var meta = account.GetMetadata<WalletInfo>();
// meta?.WalletType == "main"

bool has = account.HasMetadata<WalletInfo>();   // true
account.RemoveMetadata<WalletInfo>();

JournalLine — same pattern as Account:

line.SetMetadata(new WalletInfo("main", "active"));

var meta = line.GetMetadata<WalletInfo>();

JournalEntry — serializes/deserializes via the existing Dictionary<string, object?> property. Free-form dictionary access and typed access operate on the same data:

entry.SetMetadata(new WalletInfo("main", "active"));

// Typed access:
var meta = entry.GetMetadata<WalletInfo>();

// Free-form legacy access still works:
entry.Metadata["walletType"].ToString();   // "main"

// They share the same storage — SetMetadata replaces the entire dictionary.

Design notes:

  • One type per entity — compose a DTO if you need multiple data groups
  • If you rename a POCO type, existing JSONB data will silently fail to deserialize
  • For JournalEntry, SetMetadata<T>() replaces the entire dictionary; use either the typed API or free-form dict access on a given entry
  • Journal lines are immutable after save — set metadata before the first SaveChangesAsync
  • Metadata is not included in the hash chain (it is not part of the accounting integrity check)
public class CashBalanceService
{
    private readonly ILedgerService _ledger;

    public CashBalanceService(ILedgerService ledger) => _ledger = ledger;

    public async Task<decimal> GetCashBalance(Guid cashAccountId)
        => await _ledger.Balance.GetBalanceAsync(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
finance.journal.read FinancePermissions.JournalRead Read entry detail, entry lists, reference lookup, trial balance and general ledger
finance.account.read FinancePermissions.AccountRead Read the chart of accounts
finance.book.read FinancePermissions.BookRead List books and read reconciliation reports
await authorizationService.AssertPermissionAsync(FinancePermissions.JournalPost, ct);
string userId = authorizationService.CurrentUserId;

Read permissions and the implication rule

Since 0.2.1 the read queries assert a permission before touching the database (Abstractions/ReadAuthorizationExtensions.cs). A query constructed without an IFinanceAuthorizationService bypasses the check for source compatibility; resolve queries from the container in production. A read is granted by the domain read permission or by any permission that implies it, so a principal that could already act on the ledger does not lose access:

Read surface Granted by any one of
Journal entries and reports journal.read, journal.post, journal.reverse, audit.view
Accounts and balances account.read, account.manage, journal.post, journal.reverse, audit.view
Books and reconciliation book.read, account.manage, journal.post, journal.reverse, audit.view

A principal loses access only when it holds none of the read permissions and none of the implying ones — zero finance permissions, or only period.lock/period.close. RequireReadPermissions = false skips the check entirely. All read surfaces are gated, including GetBalanceQuery, GetAccountBalanceQuery and GetBookBalanceQuery: journal.reverse implies the account and book read sets because the reversal path reads balances to enforce its guards.

Implement IFinanceAuthorizationService or override ICurrentUser for custom authorization logic. The default NullCurrentUser denies every permission; it only allows them when LedgerOptions.AllowAnonymousAccess is explicitly set to true, which logs a warning on every process start.


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 (AddKoloLedger)
├── 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

This table states which controls the library actually enforces, not a score. An accounting system is compliant because of the controls around it as well as inside it; nothing here makes an organisation SOX-compliant on its own.

Controls that now hold (0.2.1)

Control Standard How it is enforced
Append-only journal — no update, no delete GAAP ASC 210/250, SOX 404 (ICFR) FinanceDbContext.SaveChangesAsync throws ImmutableRecordException; PostgreSQL trg_journalentry_immutable / trg_journalline_immutable
Entries cannot commit unbalanced GAAP, IFRS IAS 1 Deferred constraint trigger on JournalLines — evaluated at COMMIT, after all lines exist (fixed in 0.2.0; the old trigger never saw a line)
Period close cannot be bypassed GAAP ASC 855, SOX 404 LockPeriodCommand/UnlockPeriodCommand persist and assert the write; one PeriodScope rule rejects backdated postings into a locked period, with only the closing source exempt (fixed in 0.2.0; consolidated in 0.2.1)
Tamper-evidence with attribution SOX 404, PCAOB AS 2201 Hash v3 covers organisation, creator, approver, book, reversal target and key id; verification is order-stable; insert-time re-hashing rejects a well-formed wrong digest
Audit trail is complete and ordered SOX 404 FinanceAuditEntry append-only, chained, verifiable per tenant via VerifyAuditChainCommand
Tenant isolation on reads, writes and uniqueness — ITenantProvider on every path; per-tenant hash chain and per-tenant Reference/Code uniqueness
Read authorization SOX 404 (access control) journal.read/account.read/book.read asserted before the database round trip, with the implication rule; RequireReadPermissions (default true)
Separation of permissions SOX 404 IFinanceAuthorizationService with finance.journal.post, finance.account.manage, finance.period.lock, finance.audit.view, …; anonymous access is denied by default
Reversal, never deletion GAAP, IFRS Reversals are offsetting entries; an entry cannot be reversed twice (unique index)

Controls that remain open

Gap Impact Where it belongs
No approval workflow or routing RequireDistinctApprover (default false) can require a distinct, permissioned approver identity on each posting, and the approval is hashed. It does not route, notify, escalate or prove that a human reviewed the entry (SOX 404 segregation of duties) Host workflow
No period-close financial statement generation Closing entries are automated; the statements themselves are the host's Host reporting layer
No control over who runs verification and where the report is stored An insider with database superuser rights can disable triggers; verification only helps if reports are kept outside the database Deployment and audit process

What the library provides

  • Append-only journal tables — no deletions or silent edits
  • Versioned HMAC-SHA256 hash chain (v1/v2 frozen, v3 with attribution) — tamper evidence that survives upgrades
  • 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)

The audit that produced the 0.2.0 remediation is in docs/security-audit-2026-10-30.html. The pre-0.2.0 audit documents, including their 85/78/78 scores, are archived under docs/archive/2026-08/ and are superseded.


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 db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
var accountProvider = scope.ServiceProvider.GetRequiredService<ISystemAccountProvider>();
await ChartOfAccountsSeeder.SeedAsync(db, accountProvider);

// Seeds default books (Primary, Supplemental, Tax)
await BookSeeder.SeedDefaultBookAsync(db);

System accounts are defined in SystemAccountDefinition records and are identifiable by IsSystem = true. They don't belong to any organisation: they are headers for the chart of accounts. Since 0.2.0, posting a journal line directly to an IsSystem account is rejected unless you opt in with AllowPostingToSystemAccounts = true (which logs a warning); tenants post to their own child accounts and reports roll up per tenant. A legacy row that was posted to a shared system account still verifies and is attributed to the tenant whose entry produced it.


Development

Build

dotnet build Kolo.sln -c Release -warnaserror

Run tests

# unit + compatibility suite (in-memory)
dotnet test Kolo.sln

# real PostgreSQL 17 via Testcontainers — needs Docker
dotnet test Kolo.sln --filter Category=Postgres

The PostgreSQL suite skips itself cleanly when Docker is unavailable, so dotnet test Kolo.sln stays green on a machine without a container runtime.

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

Publishing is done by .github/workflows/publish.yml on a v* tag, gated on the unit and PostgreSQL suites. To do it by hand:

dotnet nuget push ./nupkg/Kolo.Ledger.0.2.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.2.1 105 10/31/2026
0.2.0 257 10/30/2026
0.1.0 108 10/28/2026
0.0.23 321 10/4/2026
0.0.22 489 8/12/2026
0.0.21 123 8/12/2026
0.0.20 133 8/12/2026
0.0.19 139 8/12/2026
0.0.18 127 8/10/2026
0.0.17 128 8/10/2026
0.0.16 116 8/10/2026
0.0.15 118 8/10/2026
0.0.13 117 8/10/2026
0.0.12 131 8/10/2026
0.0.11 125 8/10/2026
0.0.10 155 7/27/2026
0.0.9 246 7/23/2026
0.0.8 124 7/23/2026
0.0.7 132 7/23/2026
0.0.6 119 7/23/2026
Loading failed