Kolo.Ledger
0.0.3
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
<PackageReference Include="Kolo.Ledger" Version="0.0.3" />
<PackageVersion Include="Kolo.Ledger" Version="0.0.3" />
<PackageReference Include="Kolo.Ledger" />
paket add Kolo.Ledger --version 0.0.3
#r "nuget: Kolo.Ledger, 0.0.3"
#:package Kolo.Ledger@0.0.3
#addin nuget:?package=Kolo.Ledger&version=0.0.3
#tool nuget:?package=Kolo.Ledger&version=0.0.3
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 |
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 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.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)
KoloLedger__HmacKeyenvironment variableKOLO_LEDGER_HMAC_KEYenvironment variableconfiguration["KoloLedger:HmacKey"]fromIConfiguration- Custom
IHmacKeyProviderimplementation
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— theHashof the preceding entry in the same bookHash—HMAC-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:
- No sequence number gaps
- Every
PreviousHashmatches the predecessor'sHash - Every entry's
Hashrecomputes correctly - 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
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
| 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:
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 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 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.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 |