Introduction
Every enterprise application eventually needs some form of accounting system.
It may not look like traditional finance, but the challenges are similar:
Track credits and debits
Maintain balances across accounts
Log every transaction with full auditability
Preserve historical states
Prevent tampering
Ensure consistency and integrity
Domains that require ledger systems:
ERP (Purchasing, Inventory, Sales, Finance)
Banking & Fintech
Wallet-based apps
CRM with credit/points system
Manufacturing costing
Subscription billing
Tax management
Inventory and stock movement
Any system involving “value transfer”
This article walks through designing and implementing a production-grade ledger engine using:
.NET Core (primary engine)
SQL Server (ACID storage)
Angular (UI for posting/viewing transactions)
The system implements:
Double-entry accounting
Strong integrity rules
Versioning and audit trails
Transaction locking rules
Ledger posting pipelines
Validation and reconciliation
Query and reporting patterns
Business requirements
A robust ledger system should support:
Account creation and classification
Journals and transactions
Double-entry requirement
Atomic posting (all entries succeed or none)
Preventing unbalanced transactions
Preventing updates to posted transactions
Complete audit trail (who did what and when)
Reverse entries for corrections
Period locking (prevent posting old dates)
Reporting (trial balance, summaries)
Architecture overview
The ledger engine consists of:
Workflow diagram
User (Angular)
|
v
Create Transaction
|
v
Send to .NET API
|
v
Ledger Validation Pipeline
- Check account types
- Check debit/credit balance
- Check business rules
- Check period lock
|
v
Ledger Posting Engine (.NET)
- Write journal header
- Write ledger entries
- Update account balances
- Insert audit logs
|
v
Commit SQL Transaction
Flowchart: posting a ledger transaction
Start
|
v
Validate request structure
|
v
Entries balanced? (Debits == Credits)
| \
| \
Yes No -> Reject with error
|
v
Validate account rules
|
v
Posting period open?
| \
Yes No -> Error
|
v
Begin SQL Transaction
|
v
Insert Journal Header
|
v
Insert Ledger Entries
|
v
Update Account Balances
|
v
Insert Audit Record
|
v
Commit Transaction
|
v
End
Core database design (sql server)
table: LedgerAccount
| Column | Type | Description |
|---|
| AccountId | bigint | PK |
| AccountCode | nvarchar(50) | Unique code |
| AccountName | nvarchar(200) | |
| AccountType | tinyint | Asset, Liability, Expense, Income |
| ParentAccountId | bigint | Hierarchy support |
| IsActive | bit | |
table: LedgerTransaction
| Column | Type |
|---|
| TransactionId | bigint |
| ReferenceNo | nvarchar(50) |
| Description | nvarchar(500) |
| PostingDate | date |
| PostedByUserId | bigint |
| Status | tinyint (1=Posted, 2=Reversed) |
| CreatedOn | datetime |
table: LedgerEntry
| Column | Type |
|---|
| EntryId | bigint |
| TransactionId | bigint |
| AccountId | bigint |
| Debit | decimal(18, 2) |
| Credit | decimal(18, 2) |
| CreatedOn | datetime |
important rule
For every transaction:
Sum(Debit) == Sum(Credit)
table: LedgerAudit
| Column | Type |
|---|
| AuditId | bigint |
| TransactionId | bigint |
| Action | nvarchar(100) |
| OldValue | nvarchar(max) |
| NewValue | nvarchar(max) |
| UserId | bigint |
| Timestamp | datetime |
.NET backend: core posting service
transaction request model
public class LedgerTransactionRequest
{
public string ReferenceNo { get; set; }
public string Description { get; set; }
public DateTime PostingDate { get; set; }
public List<LedgerEntryRequest> Entries { get; set; }
}
entry model
public class LedgerEntryRequest
{
public long AccountId { get; set; }
public decimal Debit { get; set; }
public decimal Credit { get; set; }
}
Validation: debit-credit equality
if (request.Entries.Sum(x => x.Debit) != request.Entries.Sum(x => x.Credit))
throw new InvalidOperationException("Transaction is not balanced.");
Validation: ledger rules
foreach (var e in request.Entries)
{
var account = await _accountRepo.GetAsync(e.AccountId);
if (account == null || !account.IsActive)
throw new InvalidOperationException("Invalid account.");
if (account.IsLeaf == false)
throw new InvalidOperationException("Posting allowed only on leaf accounts.");
}
Period lock validation
if (!_periodService.IsPostingAllowed(request.PostingDate))
throw new InvalidOperationException("Posting period is locked.");
Posting engine
public async Task<long> PostAsync(LedgerTransactionRequest request, long userId)
{
using var tx = _db.Database.BeginTransaction();
var transaction = new LedgerTransaction
{
ReferenceNo = request.ReferenceNo,
Description = request.Description,
PostingDate = request.PostingDate,
PostedByUserId = userId,
Status = 1,
CreatedOn = DateTime.UtcNow
};
_db.LedgerTransactions.Add(transaction);
await _db.SaveChangesAsync();
foreach (var e in request.Entries)
{
var entry = new LedgerEntry
{
TransactionId = transaction.TransactionId,
AccountId = e.AccountId,
Debit = e.Debit,
Credit = e.Credit,
CreatedOn = DateTime.UtcNow
};
_db.LedgerEntries.Add(entry);
await _accountBalance.UpdateBalance(e.AccountId, e.Debit, e.Credit);
}
await _auditService.Log("POST", transaction.TransactionId, userId);
await tx.CommitAsync();
return transaction.TransactionId;
}
Updating balances safely
public async Task UpdateBalance(long accountId, decimal debit, decimal credit)
{
var bal = await _db.AccountBalances.FirstOrDefaultAsync(x => x.AccountId == accountId);
bal.DebitTotal += debit;
bal.CreditTotal += credit;
bal.ClosingBalance = bal.DebitTotal - bal.CreditTotal;
await _db.SaveChangesAsync();
}
Integrity rules
1. double-entry rule
Every transaction must have equal debit and credit.
2. atomic posting
Either:
All entries are saved
Or none are saved
This requires SQL transaction scope.
3. immutability
Posted transactions cannot be updated or deleted.
4. reversal mechanism
Incorrect postings should be reversed using an opposite transaction.
5. audit trail
Mandatory tracking for every action.
6. hierarchical accounts
Balances must roll up to parent accounts.
7. period locking
No postings allowed beyond closed periods.
Angular front-end (simple UI layer)
Angular is only responsible for:
sample Angular form
transactionForm = this.fb.group({
referenceNo: [''],
postingDate: [''],
description: [''],
entries: this.fb.array([])
});
adding entry row
addEntry() {
const entry = this.fb.group({
accountId: [''],
debit: [0],
credit: [0]
});
this.entries.push(entry);
}
calling API
this.http.post('/api/ledger/post', this.transactionForm.value)
.subscribe(() => this.message = 'Posted successfully');
Reporting (trial balance, ledger details)
Trial balance query
SELECT
A.AccountCode,
A.AccountName,
B.DebitTotal,
B.CreditTotal,
(B.DebitTotal - B.CreditTotal) AS ClosingBalance
FROM LedgerAccount A
JOIN AccountBalance B ON A.AccountId = B.AccountId
Ledger statement query
SELECT
T.TransactionId,
T.PostingDate,
T.ReferenceNo,
E.Debit,
E.Credit
FROM LedgerEntry E
JOIN LedgerTransaction T ON E.TransactionId = T.TransactionId
WHERE E.AccountId = @AccountId
ORDER BY T.PostingDate
Real-world scenarios
scenario 1: wallet transactions
Add money → Debit Wallet Clearing, Credit User Wallet
Spend money → Debit Vendor Account, Credit User Wallet
scenario 2: inventory costing
Goods receipt → Debit Inventory, Credit GRNI
Goods issue → Debit COGS, Credit Inventory
scenario 3: tax calculations
Debit Customer
Credit Tax Liability
Credit Revenue
scenario 4: refunds
Testing strategy
Unit tests
Integration tests
Load tests
Summary
This article showcased how to build a production-grade ledger engine suitable for ERP, accounting modules, wallets, fintech systems, CRMs, inventory costing, and any value-based system.
You learned:
How to model accounts, transactions, and entries
How to enforce double-entry rule
How to design SQL tables
How to build posting pipeline in .NET
How to implement integrity rules and immutability
How to log audit trails
How to expose simple Angular UI screens
How to calculate balances and run reports