Kolo.Ledger
0.2.1
dotnet add package Kolo.Ledger --version 0.2.1
NuGet\Install-Package Kolo.Ledger -Version 0.2.1
<PackageReference Include="Kolo.Ledger" Version="0.2.1" />
<PackageVersion Include="Kolo.Ledger" Version="0.2.1" />
<PackageReference Include="Kolo.Ledger" />
paket add Kolo.Ledger --version 0.2.1
#r "nuget: Kolo.Ledger, 0.2.1"
#:package Kolo.Ledger@0.2.1
#addin nuget:?package=Kolo.Ledger&version=0.2.1
#tool nuget:?package=Kolo.Ledger&version=0.2.1
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 |
Upgrading from 0.1.0?
0.2.0closes 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 anICurrentUser, setAllowAnonymousAccessor the ledger starts denying. ReadMIGRATION-0.2.0.mdbefore 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 —
JournalEntryandJournalLineare immutable after creation;SaveChangesAsyncthrowsImmutableRecordExceptionon 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; noSkip/Takeanywhere - 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 —
IFinanceAuthorizationServicewith 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
FinanceAuditEntryfor SOX compliance - EF Core migrations — ready-to-run PostgreSQL schema under the
financeschema
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 whenUseTenantScopedLedgeris on (legacy rows form one global band)PreviousHash— theHashof the preceding entry in the same chain bandHash—HMAC-SHA256(PreviousHash + EntryData + LineData, key)SignatureVersion— which preimage producedHash. 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:
- No sequence number gaps
- Every
PreviousHashmatches the predecessor'sHash - Every entry's
Hashrecomputes correctly (using the storedSignatureVersion) - 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
FinanceDbContextdirectly;SaveChangesAsyncis called explicitly - Keyset pagination everywhere — all list queries use cursor-based pagination via
MR.EntityFrameworkCore.KeysetPagination; neverSkip/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:
fsync=on— committed writes survive power losssynchronous_commit=on— COMMIT returns after WAL flush- WAL archiving to S3/GCS — point-in-time recovery capability
- Hourly backups of the
financeschema - Quarterly restore testing
- 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 libraryKolo.Ledger.xml— IntelliSense XML documentationKolo.Ledger.pdb— symbols (viasnupkg) for debuggingREADME.md— this documentation (visible on nuget.org)
License
MIT
| Product | Versions 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. |
-
net10.0
- Microsoft.EntityFrameworkCore (>= 10.0.7)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.7)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.7)
- MR.EntityFrameworkCore.KeysetPagination (>= 1.6.0)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.0)
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 |