Introduction

Every enterprise application eventually needs some form of accounting system.
It may not look like traditional finance, but the challenges are similar:

Domains that require ledger systems:

This article walks through designing and implementing a production-grade ledger engine using:

The system implements:

  1. Double-entry accounting

  2. Strong integrity rules

  3. Versioning and audit trails

  4. Transaction locking rules

  5. Ledger posting pipelines

  6. Validation and reconciliation

  7. Query and reporting patterns

Business requirements

A robust ledger system should support:

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

ColumnTypeDescription
AccountIdbigintPK
AccountCodenvarchar(50)Unique code
AccountNamenvarchar(200)
AccountTypetinyintAsset, Liability, Expense, Income
ParentAccountIdbigintHierarchy support
IsActivebit

table: LedgerTransaction

ColumnType
TransactionIdbigint
ReferenceNonvarchar(50)
Descriptionnvarchar(500)
PostingDatedate
PostedByUserIdbigint
Statustinyint (1=Posted, 2=Reversed)
CreatedOndatetime

table: LedgerEntry

ColumnType
EntryIdbigint
TransactionIdbigint
AccountIdbigint
Debitdecimal(18, 2)
Creditdecimal(18, 2)
CreatedOndatetime

important rule

For every transaction:

Sum(Debit) == Sum(Credit)

table: LedgerAudit

ColumnType
AuditIdbigint
TransactionIdbigint
Actionnvarchar(100)
OldValuenvarchar(max)
NewValuenvarchar(max)
UserIdbigint
Timestampdatetime

.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:

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

scenario 2: inventory costing

scenario 3: tax calculations

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: