Angular  

Implementing a Complex Ledger System | Double Entry, Audit Trails, Integrity Rules in .NET with Angular Front-End

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:

  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:

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

  • Ledger Accounts

  • Ledger Transactions

  • Ledger Entries (credits and debits)

  • Posting pipeline

  • Validation rules

  • Audit logs

  • Reconciliation checks

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:

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

  • Fetching account list

  • Allowing user to enter debits and credits

  • Validating UI rules

  • Sending posting request

  • Displaying ledger reports

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

  • Reverse previous entries using opposite values

Testing strategy

Unit tests

  • Debit = Credit validation

  • Invalid accounts

  • Locked period validation

Integration tests

  • Posting valid transactions

  • Reversing transactions

  • Parent account balance updates

Load tests

  • Thousands of transactions per minute

  • Concurrent postings

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