system-design50 min read

How to Design a Personal Finance & Digital Banking Platform — A Senior+ Guide | Ayodhyya

How to Design a Personal Finance & Digital Banking Platform

A comprehensive system design guide covering accounts, payments, investments, compliance, and everything in between

Senior+ Guide System Design 10,000+ Words Ayodhyya

1. Introduction & Requirements

A personal finance and digital banking platform is one of the most complex distributed systems you can design. It must handle money with zero tolerance for data loss, provide real-time transaction processing, meet strict regulatory requirements, and deliver a consumer-grade user experience. Unlike a social media feed where eventual consistency is acceptable, financial systems demand strong consistency, ACID guarantees, and audit trails for every single operation.

Functional Requirements

  • Account Management: Savings, checking, investment accounts; support for joint accounts and multiple owners
  • Real-Time Transactions: Instant balance updates, transaction history with sub-second latency
  • ACH & Wire Transfers: Domestic and international transfers, same-day ACH, SWIFT wires
  • P2P Payments: Send money to contacts via email, phone, or username; split bills
  • Bill Pay: Schedule recurring payments, eBills, payee management
  • Budgeting Tools: Category-based budgets, savings goals, spending limits with alerts
  • Spending Analytics: Transaction categorization, trend analysis, merchant insights, cash flow projections
  • Investment Portfolio: Stock/ETF trading, portfolio tracking, dividend reinvestment, robo-advisory
  • Account Aggregation: Link external bank accounts via Plaid/Yodlee; open banking APIs (PSD2)
  • Card Management: Physical and virtual cards, spending controls, real-time freeze/unfreeze
  • Dispute & Chargeback: File disputes, track status, submit evidence, Reg E provisional credit
  • Multi-Currency: Hold, convert, and transact in multiple currencies with live exchange rates
  • KYC/AML: Identity verification, sanctions screening, suspicious activity reporting
  • Notifications: Push, SMS, email for transactions, security events, bill reminders
  • Document Management: Upload statements, tax forms, KYC documents

Non-Functional Requirements

RequirementTargetRationale
Availability99.99% (52 min/year downtime)Financial services are 24/7; downtime costs real money
Latency (p99)< 200ms for reads, < 500ms for writesReal-time balance checks at POS must be fast
DurabilityZero data loss (RPO = 0)Every transaction must be durably committed
ConsistencyStrong consistency for balancesDouble-spending is unacceptable
Throughput10,000+ TPS sustained, 50,000+ TPS peakScale for millions of concurrent users
SecurityPCI DSS Level 1, SOC 2 Type IIRegulatory mandate for handling card data
AuditComplete, immutable audit trailSOX, Reg E, and internal compliance requirements
Key Insight: The fundamental tension in banking system design is between strong consistency (required for balances) and high availability (required for user experience). We solve this by using a transaction-ledger architecture where the ledger is the single source of truth, and all views are derived from it.

Capacity Estimation

Assume 5 million active users, 50 million transactions per month:

  • Transactions per second: 50M / (30 x 24 x 3600) = ~19 TPS average, 200 TPS peak (10x factor)
  • Storage per transaction: ~500 bytes (amount, metadata, audit fields) = 25 GB/month
  • Storage for 5 years: 25 GB x 60 = 1.5 TB (plus indexes, replicas x 3 = ~5 TB)
  • Bandwidth: 19 TPS x 2 KB response = 38 KB/s read, negligible write bandwidth

2. High-Level Architecture

The platform follows a domain-driven microservices architecture with an event-driven backbone. Each bounded context (accounts, transactions, cards, etc.) owns its data and communicates asynchronously via events, with synchronous calls only for operations requiring immediate consistency.

graph TB subgraph "Client Layer" MOB[Mobile App - iOS/Android] WEB[Web App - React SPA] EXT[External Partners - API] end subgraph "Edge Layer" CDN[CDN - Static Assets] GW[API Gateway - Kong/Envoy] WAF[WAF - Rate Limiting] end subgraph "Auth and Security" IAM[Identity and Access Service] MFA[MFA Service] FRAUD[Fraud Detection Engine] FP[Device Fingerprint Service] end subgraph "Core Banking" ACCT[Account Service] TXN[Transaction Engine] LEDGER[Double-Entry Ledger] BAL[Balance Service] end subgraph "Payments" ACH[ACH Processing] WIRE[Wire Transfer] P2P[P2P Payments] BILL[Bill Pay] CARD[Card Management] DISPUTE[Dispute Engine] end subgraph "Personal Finance" BUDGET[Budgeting Service] ANALYTICS[Spending Analytics] INVEST[Investment Platform] AGG[Account Aggregation] end subgraph "Compliance" KYC[KYC/AML Engine] DOC[Document Management] AUDIT[Audit Service] end subgraph "Data Layer" PG[PostgreSQL - OLTP] REDIS[Redis - Cache] KAFKA[Kafka - Event Bus] ES[Elasticsearch] S3[S3 - Documents] end MOB --> CDN --> GW WEB --> CDN --> GW EXT --> GW GW --> WAF WAF --> IAM WAF --> ACCT WAF --> TXN ACCT --> LEDGER TXN --> LEDGER LEDGER --> BAL TXN --> KAFKA KAFKA --> ACH KAFKA --> WIRE KAFKA --> P2P KAFKA --> BILL KAFKA --> CARD KAFKA --> DISPUTE KAFKA --> BUDGET KAFKA --> ANALYTICS ACCT --> PG TXN --> PG BAL --> REDIS

Key Architectural Decisions

DecisionChoiceRationale
Service CommunicationAsync events (Kafka) + sync gRPCEvent-driven for eventual consistency; gRPC for latency-critical paths
Ledger ModelDouble-entry bookkeepingIndustry standard; self-balancing; audit-friendly
Database per ServicePostgreSQL per bounded contextData isolation; independent scaling; schema ownership
CachingRedis cluster for hot dataBalance lookups, session management, rate limiting
Event StreamingApache KafkaDurability, replayability, exactly-once semantics
API ProtocolREST (external) + gRPC (internal)REST for public API compatibility; gRPC for internal efficiency

3. Account Management System

The account management system is the foundation of the entire platform. Every financial operation flows through accounts. We model accounts as entities within a double-entry ledger system, where every monetary movement creates balanced debit and credit entries.

Account Types and Hierarchy

classDiagram class Customer { +Guid Id +string Email +string Phone +DateTime CreatedAt +AccountStatus Status } class Account { +Guid Id +Guid OwnerId +AccountType Type +Currency Currency +Decimal Balance +AccountStatus Status } class SubAccount { +Guid Id +Guid ParentAccountId +SubAccountType Type +Decimal Balance } class Transaction { +Guid Id +Guid AccountId +TransactionType Type +Decimal Amount +DateTime OccurredAt } Customer "1" --> "*" Account : owns Account "1" --> "*" SubAccount : contains Account "1" --> "*" Transaction : has

Account Service Implementation

C#
public class AccountService : IAccountService
{
    private readonly IAccountRepository _repo;
    private readonly ILedgerService _ledger;
    private readonly IEventPublisher _events;
    private readonly IDistributedLock _lock;

    public async Task<Account> CreateAccountAsync(CreateAccountRequest request)
    {
        var customer = await _repo.GetCustomerAsync(request.CustomerId)
            ?? throw new DomainException("Customer not found");

        if (customer.KycStatus != KycStatus.Verified)
            throw new DomainException("KYC verification required");

        var account = new Account
        {
            Id = Guid.NewGuid(),
            OwnerId = request.CustomerId,
            Type = request.AccountType,
            Currency = request.Currency,
            Status = AccountStatus.Active,
            OpenedAt = DateTime.UtcNow,
            Balance = 0m
        };

        await _repo.SaveAccountAsync(account);
        await _ledger.OpenLedgerAccountAsync(account.Id, account.Type);

        await _events.PublishAsync(new AccountOpenedEvent
        {
            AccountId = account.Id,
            CustomerId = request.CustomerId,
            AccountType = request.AccountType,
            OpenedAt = account.OpenedAt
        });

        return account;
    }

    public async Task<AccountBalance> GetBalanceAsync(Guid accountId)
    {
        var cached = await _cache.GetAsync<AccountBalance>(
            $"balance:{accountId}");
        if (cached != null) return cached;

        var balance = await _ledger.GetBalanceAsync(accountId);
        await _cache.SetAsync(
            $"balance:{accountId}", balance, TimeSpan.FromSeconds(30));
        return balance;
    }

    public async Task FreezeAccountAsync(Guid accountId, string reason)
    {
        var lockKey = $"account:{accountId}:modify";
        await using var _ = await _lock.AcquireAsync(
            lockKey, TimeSpan.FromSeconds(30));

        var account = await _repo.GetAccountAsync(accountId)
            ?? throw new DomainException("Account not found");

        account.Status = AccountStatus.Frozen;
        account.FrozenReason = reason;
        account.FrozenAt = DateTime.UtcNow;

        await _repo.UpdateAccountAsync(account);

        await _events.PublishAsync(new AccountFrozenEvent
        {
            AccountId = accountId,
            Reason = reason,
            FrozenAt = account.FrozenAt.Value
        });
    }
}

Double-Entry Ledger

Every financial transaction creates exactly two entries: a debit and a credit. This ensures the system is always balanced and provides a complete audit trail.

C#
public class LedgerEntry
{
    public Guid Id { get; set; }
    public Guid TransactionId { get; set; }
    public Guid AccountId { get; set; }
    public LedgerEntryType Type { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; }
    public decimal RunningBalance { get; set; }
    public DateTime CreatedAt { get; set; }
    public long SequenceNumber { get; set; }
}

public class LedgerService : ILedgerService
{
    public async Task PostEntryAsync(PostEntryCommand command)
    {
        await using var tx = await _db.BeginTransactionAsync(
            IsolationLevel.Serializable);

        try
        {
            if (await _repo.EntryExistsAsync(command.IdempotencyKey))
                return;

            var totalDebit = command.Entries
                .Where(e => e.Type == LedgerEntryType.Debit)
                .Sum(e => e.Amount);
            var totalCredit = command.Entries
                .Where(e => e.Type == LedgerEntryType.Credit)
                .Sum(e => e.Amount);

            if (totalDebit != totalCredit)
                throw new DomainException(
                    $"Entries unbalanced: debit={totalDebit}, credit={totalCredit}");

            foreach (var entry in command.Entries)
            {
                var seq = await _repo.GetNextSequenceNumberAsync(
                    entry.AccountId, tx);
                entry.SequenceNumber = seq;
                entry.IdempotencyKey = command.IdempotencyKey;
                entry.TransactionId = command.TransactionId;
                entry.CreatedAt = DateTime.UtcNow;

                await _repo.InsertEntryAsync(entry, tx);
            }

            await tx.CommitAsync();
        }
        catch
        {
            await tx.RollbackAsync();
            throw;
        }
    }
}
Critical Design Decision: The gapless sequence number is essential for financial ledgers. It prevents any gaps in the transaction history that could indicate data loss or tampering. The sequence is generated within the same transaction as the entry write, using a database-level atomic increment.

4. Real-Time Transaction Engine

The transaction engine is the heart of the system. It processes all monetary movements with strong consistency guarantees. The key challenge is maintaining strong consistency for balances while achieving high throughput.

Transaction Processing Flow

sequenceDiagram participant C as Client participant API as API Gateway participant TXN as Transaction Service participant FRAUD as Fraud Engine participant LEDGER as Ledger Service participant KAFKA as Kafka participant NOTIFY as Notification Service C->>API: Initiate Transaction API->>TXN: ProcessTransaction(cmd) TXN->>FRAUD: Evaluate Risk(fraudCheckRequest) FRAUD-->>TXN: RiskScore alt Risk Score Approved TXN->>LEDGER: PostEntry(debit + credit) LEDGER->>LEDGER: BEGIN SERIALIZABLE TXN LEDGER->>LEDGER: INSERT entries (atomic) LEDGER->>LEDGER: UPDATE balances (row lock) LEDGER->>LEDGER: COMMIT LEDGER-->>TXN: LedgerPosted TXN->>KAFKA: Publish TransactionCompleted TXN-->>API: TransactionResult(success) API-->>C: 200 OK KAFKA->>NOTIFY: Process notification else Risk Score Declined TXN->>TXN: Record Decline TXN-->>API: TransactionResult(declined) API-->>C: 403 Declined end

Transaction Processing Service

C#
public class TransactionService : ITransactionService
{
    private readonly IFraudEngine _fraud;
    private readonly ILedgerService _ledger;
    private readonly IEventPublisher _events;
    private readonly ITransactionRepository _repo;

    public async Task<TransactionResult> ProcessAsync(
        ProcessTransactionCommand cmd)
    {
        var existing = await _repo.GetByIdempotencyKeyAsync(
            cmd.IdempotencyKey);
        if (existing != null)
            return TransactionResult.AlreadyProcessed(existing);

        var riskAssessment = await _fraud.EvaluateAsync(
            new FraudCheckRequest
            {
                TransactionId = cmd.TransactionId,
                AccountId = cmd.SourceAccountId,
                Amount = cmd.Amount,
                MerchantId = cmd.MerchantId,
                Location = cmd.Location,
                DeviceFingerprint = cmd.DeviceFingerprint
            });

        if (riskAssessment.Decision == RiskDecision.Declined)
        {
            await RecordDeclinedTransactionAsync(cmd, riskAssessment);
            return TransactionResult.Declined(riskAssessment.Reason);
        }

        var sourceAccount = await GetValidatedAccountAsync(
            cmd.SourceAccountId);

        var currentBalance = await _ledger.GetBalanceAsync(
            cmd.SourceAccountId);
        if (currentBalance.Available < cmd.Amount)
            return TransactionResult.InsufficientFunds();

        var transactionId = Guid.NewGuid();
        await _ledger.PostEntryAsync(new PostEntryCommand
        {
            TransactionId = transactionId,
            IdempotencyKey = cmd.IdempotencyKey,
            Entries = new[]
            {
                new LedgerEntryInput
                {
                    AccountId = cmd.SourceAccountId,
                    Type = LedgerEntryType.Debit,
                    Amount = cmd.Amount,
                    Currency = cmd.Currency
                },
                new LedgerEntryInput
                {
                    AccountId = cmd.DestinationAccountId,
                    Type = LedgerEntryType.Credit,
                    Amount = cmd.Amount,
                    Currency = cmd.Currency
                }
            }
        });

        var transaction = new Transaction
        {
            Id = transactionId,
            SourceAccountId = cmd.SourceAccountId,
            DestinationAccountId = cmd.DestinationAccountId,
            Amount = cmd.Amount,
            Currency = cmd.Currency,
            Type = cmd.TransactionType,
            Status = TransactionStatus.Completed,
            IdempotencyKey = cmd.IdempotencyKey,
            CreatedAt = DateTime.UtcNow,
            Metadata = cmd.Metadata
        };
        await _repo.SaveTransactionAsync(transaction);

        await _events.PublishAsync(new TransactionCompletedEvent
        {
            Transaction = transaction,
            RiskScore = riskAssessment.Score
        });

        return TransactionResult.Success(transaction);
    }
}

Idempotency Strategy

Every transaction carries an idempotency key (client-generated UUID). The system guarantees exactly-once processing by:

  1. Checking for existing transactions with the same key before processing
  2. Using database unique constraints on idempotency_key
  3. Returning the original result if a duplicate is detected
  4. Setting a 72-hour TTL for idempotency key retention

Transaction Types

TypeDirectionSettlementReversible
Debit Card PurchaseOutboundInstantVia dispute (90 days)
Credit Card PurchaseInboundT+1 to T+30Via chargeback
ACH CreditInboundT+1 to T+2Via return (60 days)
ACH DebitOutboundT+1 to T+2Via return (60 days)
Wire TransferBothSame dayGenerally non-reversible
P2P TransferBothInstantWithin 60 days (Reg E)
Bill PayOutboundT+1 to T+5Before settlement
Investment TradeBothT+1 (SEC)Subject to trading rules

5. ACH & Wire Transfers

ACH (Automated Clearing House) and wire transfers are the backbone of interbank money movement. ACH handles ~30 billion transactions annually for recurring payments, payroll, and bill payments. Wire transfers handle high-value, time-sensitive transactions.

ACH Processing Architecture

flowchart LR subgraph Origination USER[User/API] --> ORIG[Origination Service] ORIG --> VALIDATE[Validation] VALIDATE --> BATCH[Batch Builder] end subgraph Processing BATCH --> NACHA[NACHA File Generator] NACHA --> ODFI[ODFI Submission] ODFI --> ACH_NET[ACH Network] ACH_NET --> RDFI[RDFI Processing] end subgraph Settlement RDFI --> SETTLE[Settlement Service] SETTLE --> LEDGER[Internal Ledger] SETTLE --> NOTIFY[Notifications] end subgraph Exceptions ACH_NET --> RETURN[Return Processing] RETURN --> EXCEPTION[Exception Queue] end

ACH File Structure

C#
public class NachaFileBuilder
{
    public NachaFile BuildAchFile(AchBatch batch)
    {
        var file = new NachaFile();

        file.Header = new FileHeader
        {
            PriorityCode = 1,
            ImmediateDestination = batch.DestinationRoutingNumber,
            ImmediateOrigin = batch.OriginRoutingNumber,
            FileCreationDate = DateTime.Now,
            RecordSize = 94,
            BlockingFactor = 10,
            FormatCode = '1',
            DestinationName = batch.DestinationName,
            OriginName = batch.OriginName
        };

        var batchHeader = new BatchHeader
        {
            ServiceClassCode = batch.Transactions.All(t =>
                t.Amount >= 0) ? 200 : 220,
            CompanyName = batch.CompanyName,
            CompanyIdentification = batch.CompanyId,
            StandardEntryClass = batch.SecCode,
            EffectiveEntryDate = batch.EffectiveDate,
            OriginatorStatusCode = 0,
            OriginatingDfiIdentification =
                batch.OriginRoutingNumber[..8],
            BatchNumber = batch.BatchNumber
        };

        foreach (var txn in batch.Transactions)
        {
            var entry = new EntryDetail
            {
                TransactionCode = GetTransactionCode(txn),
                RoutingNumber = txn.RoutingNumber,
                AccountNumber = txn.AccountNumber,
                Amount = (int)(txn.Amount * 100),
                IndividualIdentificationNumber =
                    txn.IdentificationNumber,
                IndividualName = txn.CustomerName,
                TraceNumber = GenerateTraceNumber(batch, txn)
            };
            file.Entries.Add(entry);
        }

        file.BatchControl = new BatchControl
        {
            EntryCount = file.Entries.Count,
            EntryHash = file.Entries.Sum(e =>
                long.Parse(e.RoutingNumber)),
            TotalDebitAmount = file.Entries
                .Where(e => e.TransactionCode % 10 == 2)
                .Sum(e => e.Amount),
            TotalCreditAmount = file.Entries
                .Where(e => e.TransactionCode % 10 == 7)
                .Sum(e => e.Amount),
            BatchNumber = batch.BatchNumber
        };

        file.Control = new FileControl
        {
            BatchCount = 1,
            EntryCount = file.Entries.Count,
            EntryHash = file.BatchControl.EntryHash,
            TotalDebitAmount =
                file.BatchControl.TotalDebitAmount,
            TotalCreditAmount =
                file.BatchControl.TotalCreditAmount
        };

        return file;
    }

    private string GetTransactionCode(AchTransaction txn) =>
        txn.Type switch
    {
        AchType.Credit => "27",
        AchType.Debit => "22",
        AchType.PreNoteCredit => "23",
        AchType.PreNoteDebit => "28",
        _ => throw new ArgumentException($"Unknown type: {txn.Type}")
    };
}

Wire Transfer Processing

Wire transfers use Fedwire (domestic) or SWIFT (international) networks. Unlike ACH, wires are irrevocable once settled.

C#
public class WireTransferService : IWireTransferService
{
    public async Task<WireTransferResult> InitiateWireAsync(
        WireRequest request)
    {
        var routingInfo = await _routingService.ValidateRoutingNumber(
            request.DestinationRoutingNumber);

        var ofacResult = await _complianceScreening.ScreenAsync(
            request.BeneficiaryName,
            request.BeneficiaryBank,
            request.CountryCode);

        if (ofacResult.Hit)
        {
            await _complianceReview.QueueForReviewAsync(
                request, ofacResult);
            return WireTransferResult.PendingComplianceReview();
        }

        var message = routingInfo.Type == RoutingType.Domestic
            ? BuildFedwireMessage(request)
            : BuildSwiftMessage(request);

        var submission = await _wireGateway.SubmitAsync(message);

        await _ledger.PostEntryAsync(new PostEntryCommand
        {
            TransactionId = request.WireId,
            Entries = new[]
            {
                new LedgerEntryInput
                {
                    AccountId = request.SourceAccountId,
                    Type = LedgerEntryType.Debit,
                    Amount = request.Amount + request.Fee,
                    Currency = "USD"
                },
                new LedgerEntryInput
                {
                    AccountId = _clearingAccount.Id,
                    Type = LedgerEntryType.Credit,
                    Amount = request.Amount,
                    Currency = "USD"
                },
                new LedgerEntryInput
                {
                    AccountId = _feeIncomeAccount.Id,
                    Type = LedgerEntryType.Credit,
                    Amount = request.Fee,
                    Currency = "USD"
                }
            }
        });

        return WireTransferResult.Success(submission.MessageId);
    }
}
Regulatory Alert: Wire transfers over $3,000 must be reported via CTR (Currency Transaction Report). International wires require OFAC screening and may trigger SAR (Suspicious Activity Report) filings. The system must never allow processing of a wire that hasn't passed compliance screening.

6. P2P Payments

Peer-to-peer payments allow users to send money instantly to other platform users or to external recipients via email, phone number, or username. The system must handle split bills, recurring transfers, and request-to-pay flows.

P2P Architecture

flowchart TB SENDER[Sender] --> API[P2P API] API --> LOOKUP[Recipient Lookup] API --> RISK[Risk Assessment] LOOKUP --> BY_PHONE[By Phone] LOOKUP --> BY_EMAIL[By Email] LOOKUP --> BY_USERNAME[By Username] RISK --> |Approved| TRANSFER[Transfer Service] RISK --> |Declined| DECLINE[Decline] TRANSFER --> INTERNAL{Internal or External?} INTERNAL --> |Internal| LEDGER[Ledger Transfer] INTERNAL --> |External| EXT_PAY[External Payment Rails] LEDGER --> POST[Post Ledger Entries] POST --> NOTIFY_S[Notify Sender] POST --> NOTIFY_R[Notify Recipient]

P2P Service Implementation

C#
public class P2pPaymentService : IP2pPaymentService
{
    private readonly IPaymentRepository _repo;
    private readonly ILedgerService _ledger;
    private readonly IRecipientResolver _resolver;
    private readonly IFraudEngine _fraud;
    private readonly IEventPublisher _events;

    public async Task<P2pResult> SendAsync(SendMoneyCommand cmd)
    {
        var recipient = await _resolver.ResolveAsync(
            cmd.RecipientIdentifier);
        if (recipient == null)
            return P2pResult.RecipientNotFound();

        var isInternal = recipient.IsPlatformUser;

        if (isInternal)
            return await ProcessInternalTransferAsync(cmd, recipient);

        return await ProcessExternalTransferAsync(cmd, recipient);
    }

    private async Task<P2pResult> ProcessInternalTransferAsync(
        SendMoneyCommand cmd, Recipient recipient)
    {
        var risk = await _fraud.EvaluateP2pAsync(new P2pFraudRequest
        {
            SenderAccountId = cmd.SourceAccountId,
            RecipientId = recipient.UserId,
            Amount = cmd.Amount,
            Note = cmd.Note
        });

        if (risk.Decision == RiskDecision.Declined)
            return P2pResult.Declined(risk.Reason);

        await _ledger.PostEntryAsync(new PostEntryCommand
        {
            TransactionId = Guid.NewGuid(),
            Entries = new[]
            {
                new LedgerEntryInput
                {
                    AccountId = cmd.SourceAccountId,
                    Type = LedgerEntryType.Debit,
                    Amount = cmd.Amount,
                    Currency = cmd.Currency
                },
                new LedgerEntryInput
                {
                    AccountId = recipient.AccountId,
                    Type = LedgerEntryType.Credit,
                    Amount = cmd.Amount,
                    Currency = cmd.Currency
                }
            }
        });

        var payment = new P2pPayment
        {
            Id = Guid.NewGuid(),
            SenderAccountId = cmd.SourceAccountId,
            RecipientAccountId = recipient.AccountId,
            Amount = cmd.Amount,
            Currency = cmd.Currency,
            Note = cmd.Note,
            Status = P2pPaymentStatus.Completed,
            CreatedAt = DateTime.UtcNow
        };
        await _repo.SaveAsync(payment);
        await _events.PublishAsync(
            new P2pCompletedEvent { Payment = payment });

        return P2pResult.Success(payment.Id);
    }

    public async Task<SplitBillResult> CreateSplitAsync(
        CreateSplitBillCommand cmd)
    {
        var split = new SplitBill
        {
            Id = Guid.NewGuid(),
            InitiatorId = cmd.InitiatorId,
            TotalAmount = cmd.TotalAmount,
            Currency = cmd.Currency,
            Description = cmd.Description,
            Participants = cmd.Participants.Select(p =>
                new SplitParticipant
            {
                UserId = p.UserId,
                Amount = p.Amount ??
                    cmd.TotalAmount / cmd.Participants.Count,
                Status = SplitParticipantStatus.Pending
            }).ToList(),
            CreatedAt = DateTime.UtcNow
        };

        await _repo.SaveSplitAsync(split);
        await _events.PublishAsync(
            new SplitCreatedEvent { Split = split });

        return SplitBillResult.Success(split.Id);
    }
}

7. Bill Pay System

The bill pay system enables users to schedule one-time or recurring payments to payees. It handles payment scheduling, eBill retrieval, and automatic retry logic for failed payments.

Bill Pay Architecture

flowchart LR USER[User] --> SCHEDULE[Schedule Payment] SCHEDULE --> CRON[Scheduler Service] CRON --> EXEC[Payment Executor] EXEC --> VALIDATE[Validate Payee] VALIDATE --> RESERVE[Reserve Funds] RESERVE --> FULFILL[Payment Fulfillment] FULFILL --> CHECK[Paper Check] FULFILL --> ELECTRONIC[Electronic] FULFILL --> ACH_BILL[ACH Transfer] FULFILL --> STATUS[Status Tracking] STATUS --> NOTIFY[Notifications]
C#
public class BillPayService : IBillPayService
{
    public async Task SchedulePaymentAsync(
        ScheduleBillPaymentCommand cmd)
    {
        var payee = await _payeeRepo.GetAsync(cmd.PayeeId);

        var hold = await _ledger.PlaceHoldAsync(
            cmd.SourceAccountId,
            cmd.Amount,
            holdDuration: cmd.PaymentDate.AddDays(3));

        var payment = new BillPayment
        {
            Id = Guid.NewGuid(),
            SourceAccountId = cmd.SourceAccountId,
            PayeeId = cmd.PayeeId,
            Amount = cmd.Amount,
            PaymentDate = cmd.PaymentDate,
            Recurrence = cmd.Recurrence,
            DeliveryMethod = payee.AcceptsElectronic
                ? DeliveryMethod.Electronic
                : DeliveryMethod.PaperCheck,
            HoldId = hold.Id,
            Status = BillPaymentStatus.Scheduled,
            CreatedAt = DateTime.UtcNow
        };

        await _repo.SaveAsync(payment);

        if (cmd.RecurringSchedule != null)
            await CreateRecurringScheduleAsync(
                payment, cmd.RecurringSchedule);
    }

    public async Task ExecuteScheduledPaymentsAsync()
    {
        var duePayments = await _repo.GetDuePaymentsAsync(
            DateTime.UtcNow.Date);

        foreach (var payment in duePayments)
        {
            try
            {
                payment.Status = BillPaymentStatus.Processing;
                await _repo.UpdateAsync(payment);

                var holdValid = await _ledger.ValidateHoldAsync(
                    payment.HoldId);
                if (!holdValid)
                {
                    payment.Status = BillPaymentStatus.Failed;
                    payment.FailureReason = "Fund hold expired";
                    await _repo.UpdateAsync(payment);
                    continue;
                }

                var fulfillment = payment.DeliveryMethod switch
                {
                    DeliveryMethod.Electronic =>
                        await _electronicPayAsync(payment),
                    DeliveryMethod.PaperCheck =>
                        await _checkPayAsync(payment),
                    DeliveryMethod.Ach =>
                        await _achPayAsync(payment),
                    _ => throw new NotSupportedException()
                };

                payment.Status = BillPaymentStatus.Completed;
                payment.FulfillmentId = fulfillment.Id;
                await _repo.UpdateAsync(payment);

                await _events.PublishAsync(
                    new BillPaymentCompletedEvent
                    {
                        PaymentId = payment.Id,
                        Amount = payment.Amount
                    });
            }
            catch (Exception ex)
            {
                payment.Status = BillPaymentStatus.Failed;
                payment.FailureReason = ex.Message;
                payment.RetryCount++;
                await _repo.UpdateAsync(payment);

                if (payment.RetryCount < MaxRetries)
                {
                    payment.PaymentDate =
                        CalculateNextRetryDate(payment.RetryCount);
                    payment.Status = BillPaymentStatus.Scheduled;
                    await _repo.UpdateAsync(payment);
                }
            }
        }
    }
}

8. Budgeting & Spending Analytics

Budgeting tools help users manage their finances by setting spending limits and tracking progress. The analytics engine categorizes transactions, identifies trends, and generates cash flow projections.

Transaction Categorization Pipeline

flowchart TB TXN[New Transaction] --> ENRICH[Enrichment Service] ENRICH --> MERCHANT[Merchant Lookup] ENRICH --> CATEGORY[Category Classification] MERCHANT --> MCC[MCC Code Lookup] CATEGORY --> RULES[Rule-Based Engine] CATEGORY --> ML[ML Classifier] CATEGORY --> USER[User Override] RULES --> PRIMARY[Primary Category] ML --> PRIMARY USER --> PRIMARY PRIMARY --> SUBCAT[Sub-Category] PRIMARY --> BUDGET_UPDATE[Budget Tracker] BUDGET_UPDATE --> ALERTS[Spending Alerts]

Budget Service

C#
public class BudgetService : IBudgetService
{
    public async Task<Budget> CreateBudgetAsync(
        CreateBudgetCommand cmd)
    {
        var budget = new Budget
        {
            Id = Guid.NewGuid(),
            UserId = cmd.UserId,
            Name = cmd.Name,
            Period = cmd.Period,
            TotalLimit = cmd.TotalAmount,
            Currency = cmd.Currency,
            Categories = cmd.Categories.Select(c =>
                new BudgetCategory
            {
                Category = c.Category,
                Limit = c.Amount,
                AlertThreshold = c.AlertPercentage ?? 80m
            }).ToList(),
            StartsAt = cmd.StartDate,
            EndsAt = cmd.EndDate,
            CreatedAt = DateTime.UtcNow
        };

        await _repo.SaveBudgetAsync(budget);
        return budget;
    }

    public async Task<BudgetSummary> GetSummaryAsync(
        Guid userId, BudgetPeriod period)
    {
        var budget = await _repo.GetActiveBudgetAsync(
            userId, period);
        if (budget == null) return BudgetSummary.Empty();

        var startDate = budget.StartsAt;
        var endDate = Math.Min(budget.EndsAt, DateTime.UtcNow);

        var spending = await _transactionRepo
            .GetSpendingByCategoryAsync(
                userId, startDate, endDate);

        var summary = new BudgetSummary
        {
            BudgetId = budget.Id,
            TotalBudget = budget.TotalLimit,
            TotalSpent = spending.Sum(s => s.Total),
            Remaining = budget.TotalLimit -
                spending.Sum(s => s.Total),
            CategorySummaries = budget.Categories.Select(bc =>
            {
                var spent = spending.FirstOrDefault(
                    s => s.Category == bc.Category);
                return new CategorySummary
                {
                    Category = bc.Category,
                    Budget = bc.Limit,
                    Spent = spent?.Total ?? 0m,
                    Remaining = bc.Limit -
                        (spent?.Total ?? 0m),
                    PercentageUsed = bc.Limit > 0
                        ? ((spent?.Total ?? 0m) / bc.Limit) * 100
                        : 0,
                    IsOverBudget =
                        (spent?.Total ?? 0m) > bc.Limit
                };
            }).ToList()
        };

        return summary;
    }
}

Spending Analytics Engine

C#
public class SpendingAnalyticsService :
    ISpendingAnalyticsService
{
    public async Task<SpendingInsights> GetInsightsAsync(
        Guid userId, DateRange range)
    {
        var transactions = await _transactionRepo
            .GetTransactionsAsync(userId, range);

        var insights = new SpendingInsights
        {
            MonthlyTrend = transactions
                .GroupBy(t => new {
                    t.OccurredAt.Year, t.OccurredAt.Month })
                .Select(g => new MonthlySpend
                {
                    Month = new DateTime(
                        g.Key.Year, g.Key.Month, 1),
                    TotalSpent = g.Where(t => t.Amount < 0)
                        .Sum(t => Math.Abs(t.Amount)),
                    TotalIncome = g.Where(t => t.Amount > 0)
                        .Sum(t => t.Amount)
                })
                .OrderBy(m => m.Month)
                .ToList(),

            CategoryBreakdown = transactions
                .Where(t => t.Amount < 0)
                .GroupBy(t => t.Category)
                .Select(g => new CategoryInsight
                {
                    Category = g.Key,
                    TotalAmount = g.Sum(
                        t => Math.Abs(t.Amount)),
                    TransactionCount = g.Count(),
                    AverageTransaction = g.Average(
                        t => Math.Abs(t.Amount)),
                    TopMerchants = g.GroupBy(
                        t => t.MerchantName)
                        .OrderByDescending(mg => mg.Sum(
                            mt => Math.Abs(mt.Amount)))
                        .Take(5)
                        .Select(mg => new MerchantInsight
                        {
                            Name = mg.Key,
                            TotalSpent = mg.Sum(
                                mt => Math.Abs(mt.Amount)),
                            VisitCount = mg.Count()
                        })
                        .ToList()
                })
                .OrderByDescending(
                    c => c.TotalAmount)
                .ToList(),

            CashFlowProjection = await ProjectCashFlowAsync(
                userId, transactions)
        };

        return insights;
    }

    private async Task<List<CashFlowProjection>>
        ProjectCashFlowAsync(
            Guid userId,
            List<Transaction> historicalTransactions)
    {
        var recurringIncome = DetectRecurringTransactions(
            historicalTransactions
                .Where(t => t.Amount > 0).ToList());
        var recurringExpenses = DetectRecurringTransactions(
            historicalTransactions
                .Where(t => t.Amount < 0).ToList());

        var projections = new List<CashFlowProjection>();
        var runningBalance = await _ledger
            .GetCurrentBalanceAsync(userId);

        for (int day = 1; day <= 30; day++)
        {
            var date = DateTime.UtcNow.Date.AddDays(day);
            var expectedIncome = recurringIncome
                .Where(r => r.OccursOn(date))
                .Sum(r => r.Amount);
            var expectedExpenses = recurringExpenses
                .Where(r => r.OccursOn(date))
                .Sum(r => Math.Abs(r.Amount));

            runningBalance += expectedIncome - expectedExpenses;

            projections.Add(new CashFlowProjection
            {
                Date = date,
                ProjectedBalance = runningBalance,
                ExpectedIncome = expectedIncome,
                ExpectedExpenses = expectedExpenses
            });
        }

        return projections;
    }
}

9. Investment Portfolio Management

The investment platform enables users to buy and sell securities, track portfolio performance, and use robo-advisory features. This requires real-time market data, order management, and compliance with SEC/FINRA regulations.

Investment System Architecture

flowchart TB USER[Investor] --> PORTFOLIO[Portfolio Dashboard] PORTFOLIO --> ORDER_MGR[Order Management] PORTFOLIO --> HOLDINGS[Holdings View] PORTFOLIO --> PERF[Performance Analytics] ORDER_MGR --> PRE_TRADE[Pre-Trade Compliance] PRE_TRADE --> |Pass| ROUTE[Order Router] PRE_TRADE --> |Fail| REJECT[Reject Order] ROUTE --> MARKET[Market Data Service] ROUTE --> BROKER[Brokerage Integration] BROKER --> FILL[Fill Processing] FILL --> SETTLE[Settlement T+1] FILL --> LEDGER[Update Ledger] HOLDINGS --> CUSTODY[Custodian Data] MARKET --> REALTIME[Real-Time Pricing]
C#
public class InvestmentService : IInvestmentService
{
    public async Task<OrderResult> PlaceOrderAsync(
        PlaceOrderCommand cmd)
    {
        var complianceResult = await _preTradeCompliance
            .EvaluateAsync(new PreTradeCheck
            {
                AccountId = cmd.AccountId,
                SecurityId = cmd.SecurityId,
                OrderType = cmd.OrderType,
                Side = cmd.Side,
                Quantity = cmd.Quantity,
                LimitPrice = cmd.LimitPrice
            });

        if (!complianceResult.Approved)
            return OrderResult.Rejected(
                complianceResult.Reasons);

        var quote = await _marketData.GetQuoteAsync(
            cmd.SecurityId);
        if (quote == null)
            return OrderResult.MarketDataUnavailable();

        if (cmd.Side == OrderSide.Buy)
        {
            var estimatedCost = cmd.Quantity * (
                cmd.OrderType == OrderType.Market
                    ? quote.AskPrice
                    : cmd.LimitPrice.Value);

            var availableFunds = await _ledger
                .GetAvailableBalanceAsync(cmd.AccountId);

            if (availableFunds < estimatedCost)
                return OrderResult.InsufficientFunds(
                    availableFunds, estimatedCost);
        }

        var order = new BrokerageOrder
        {
            Id = Guid.NewGuid(),
            AccountId = cmd.AccountId,
            Symbol = cmd.SecurityId,
            Side = cmd.Side,
            OrderType = cmd.OrderType,
            Quantity = cmd.Quantity,
            LimitPrice = cmd.LimitPrice,
            TimeInForce = cmd.TimeInForce ?? TimeInForce.Day,
            ClientOrderId = cmd.ClientOrderId
        };

        var submission = await _broker.SubmitOrderAsync(order);

        var internalOrder = new InvestmentOrder
        {
            Id = order.Id,
            AccountId = cmd.AccountId,
            SecurityId = cmd.SecurityId,
            Side = cmd.Side,
            OrderType = cmd.OrderType,
            Quantity = cmd.Quantity,
            LimitPrice = cmd.LimitPrice,
            Status = OrderStatus.Submitted,
            BrokerOrderId = submission.BrokerOrderId,
            SubmittedAt = DateTime.UtcNow
        };

        await _repo.SaveOrderAsync(internalOrder);

        return OrderResult.Success(
            internalOrder.Id, submission.ExpectedFillTime);
    }

    public async Task ProcessFillAsync(OrderFill fill)
    {
        var order = await _repo.GetOrderAsync(
            fill.BrokerOrderId);
        order.Status = OrderStatus.Filled;
        order.AverageFillPrice = fill.AveragePrice;
        order.FilledQuantity = fill.Quantity;
        order.FilledAt = fill.Timestamp;

        if (order.Side == OrderSide.Buy)
        {
            await _holdingsService.AddSharesAsync(
                order.AccountId, order.SecurityId,
                fill.Quantity, fill.AveragePrice);
        }
        else
        {
            await _holdingsService.RemoveSharesAsync(
                order.AccountId, order.SecurityId,
                fill.Quantity, fill.AveragePrice);
        }

        var tradeAmount = fill.Quantity * fill.AveragePrice;
        await _ledger.PostEntryAsync(new PostEntryCommand
        {
            TransactionId = order.Id,
            Entries = order.Side == OrderSide.Buy
                ? new[]
                {
                    new LedgerEntryInput
                    {
                        AccountId = order.AccountId,
                        Type = LedgerEntryType.Debit,
                        Amount = tradeAmount
                    },
                    new LedgerEntryInput
                    {
                        AccountId = _clearingAccount.Id,
                        Type = LedgerEntryType.Credit,
                        Amount = tradeAmount
                    }
                }
                : new[]
                {
                    new LedgerEntryInput
                    {
                        AccountId = _clearingAccount.Id,
                        Type = LedgerEntryType.Debit,
                        Amount = tradeAmount
                    },
                    new LedgerEntryInput
                    {
                        AccountId = order.AccountId,
                        Type = LedgerEntryType.Credit,
                        Amount = tradeAmount
                    }
                }
        });

        await _repo.UpdateOrderAsync(order);
    }

    public async Task<PortfolioSummary> GetPortfolioAsync(
        Guid accountId)
    {
        var holdings = await _holdingsService
            .GetHoldingsAsync(accountId);
        var quotes = await _marketData.GetQuotesAsync(
            holdings.Select(h => h.SecurityId));

        return new PortfolioSummary
        {
            TotalValue = holdings.Sum(h =>
                h.Quantity *
                quotes[h.SecurityId].LastPrice),
            TotalCost = holdings.Sum(h =>
                h.Quantity * h.AverageCostBasis),
            TotalGainLoss = holdings.Sum(h =>
                (quotes[h.SecurityId].LastPrice
                    - h.AverageCostBasis) * h.Quantity),
            Holdings = holdings.Select(h => new HoldingView
            {
                Symbol = h.SecurityId,
                Quantity = h.Quantity,
                CurrentPrice =
                    quotes[h.SecurityId].LastPrice,
                MarketValue = h.Quantity *
                    quotes[h.SecurityId].LastPrice,
                CostBasis = h.Quantity *
                    h.AverageCostBasis,
                GainLoss =
                    (quotes[h.SecurityId].LastPrice
                        - h.AverageCostBasis) * h.Quantity
            }).ToList()
        };
    }
}

10. Account Aggregation & Open Banking (PSD2)

Account aggregation allows users to link external bank accounts for a unified financial picture. Open banking regulations (PSD2 in Europe) mandate that banks provide API access to customer data with explicit consent.

Aggregation Architecture

flowchart TB USER[User] --> LINK[Link Account] LINK --> PROVIDER[Aggregation Provider] PROVIDER --> PLAID[Plaid] PROVIDER --> TINK[Tink - PSD2] PLAID --> ACCESS_TOKEN[Access Token] ACCESS_TOKEN --> SYNC[Sync Service] SYNC --> ACCOUNTS[External Accounts] SYNC --> TRANSACTIONS_EXT[External Transactions] SYNC --> BALANCES_EXT[External Balances] ACCOUNTS --> UNIFIED[Unified Account Model] TRANSACTIONS_EXT --> UNIFIED BALANCES_EXT --> UNIFIED UNIFIED --> READ_ONLY[Read-Only Views] UNIFIED --> ANALYTICS[Cross-Account Analytics] subgraph "PSD2 Open Banking" TPP[Third Party Provider] --> CONSENT[Consent Management] CONSENT --> BANK_API[Bank Open API] end
C#
public class AccountAggregationService :
    IAccountAggregationService
{
    private readonly IAggregationProvider _provider;
    private readonly IConsentManager _consent;

    public async Task<LinkResult> LinkExternalAccountAsync(
        LinkAccountRequest request)
    {
        var tokenResult = await _provider
            .ExchangePublicTokenAsync(request.PublicToken);

        var linkedAccount = new LinkedAccount
        {
            Id = Guid.NewGuid(),
            UserId = request.UserId,
            InstitutionId = request.InstitutionId,
            InstitutionName = request.InstitutionName,
            AccessToken = await _encryption.EncryptAsync(
                tokenResult.AccessToken),
            ItemId = tokenResult.ItemId,
            Status = LinkStatus.Active,
            LinkedAt = DateTime.UtcNow,
            LastSyncAt = null
        };

        await _repo.SaveLinkedAccountAsync(linkedAccount);
        await SyncAccountDataAsync(linkedAccount.Id);

        return LinkResult.Success(linkedAccount.Id);
    }

    public async Task SyncAccountDataAsync(Guid linkedAccountId)
    {
        var account = await _repo
            .GetLinkedAccountAsync(linkedAccountId);
        var accessToken = await _encryption.DecryptAsync(
            account.AccessToken);

        var externalAccounts = await _provider
            .GetAccountsAsync(accessToken);

        var fromDate = account.LastSyncAt ??
            DateTime.UtcNow.AddDays(-30);
        var transactions = await _provider
            .GetTransactionsAsync(
                accessToken, fromDate, DateTime.UtcNow);

        var balances = await _provider
            .GetBalancesAsync(accessToken);

        foreach (var extAccount in externalAccounts)
        {
            var unified = new UnifiedAccount
            {
                Id = extAccount.AccountId,
                LinkedAccountId = account.Id,
                UserId = account.UserId,
                Type = MapAccountType(extAccount.Type),
                Name = extAccount.Name,
                Mask = extAccount.Mask,
                Balance = balances
                    .FirstOrDefault(b =>
                        b.AccountId == extAccount.AccountId)
                    ?.Current ?? 0,
                Currency = extAccount.Currency,
                SyncedAt = DateTime.UtcNow
            };
            await _unifiedRepo.UpsertAsync(unified);
        }

        foreach (var extTxn in transactions)
        {
            var unifiedTxn = new UnifiedTransaction
            {
                ExternalId = extTxn.TransactionId,
                LinkedAccountId = account.Id,
                Amount = extTxn.Amount,
                Date = extTxn.Date,
                Name = extTxn.Name,
                MerchantName = extTxn.MerchantName,
                Category = MapCategory(extTxn.Category),
                Pending = extTxn.Pending
            };
            await _unifiedRepo
                .UpsertTransactionAsync(unifiedTxn);
        }

        account.LastSyncAt = DateTime.UtcNow;
        await _repo.UpdateLinkedAccountAsync(account);
    }
}
PSD2 Compliance: Under PSD2, Third Party Providers (TPPs) can access customer account data (AISP) and initiate payments (PISP) with explicit customer consent. The bank must provide a dedicated Open Banking API and maintain a register of authorized TPPs. Consent must be refreshed every 90 days.

11. Card Management & Virtual Cards

The card management system handles physical and virtual debit/credit cards, including issuance, authorization, spending controls, and real-time card status management. Virtual cards provide enhanced security for online transactions.

Card Authorization Flow

sequenceDiagram participant M as Merchant POS participant N as Card Network participant I as Issuer Gateway participant A as Authorization Service participant F as Fraud Engine participant L as Ledger Service M->>N: Authorization Request N->>I: Forward Authorization I->>A: Process Authorization A->>A: Validate Card Status A->>A: Check Spending Limits A->>F: Score Transaction F-->>A: Risk Score alt Approved A->>L: Hold Funds L-->>A: Hold Placed A->>I: Approval I->>N: Approval + Auth Code N->>M: Approved else Declined A->>I: Decline I->>N: Decline N->>M: Declined end
C#
public class CardService : ICardService
{
    public async Task<AuthorizationResult> AuthorizeAsync(
        AuthorizationRequest request)
    {
        var card = await _cardRepo.GetByPanAsync(
            request.EncryptedPan);
        if (card == null || card.Status != CardStatus.Active)
            return AuthorizationResult.Declined("Card not active");

        if (!await _cryptoService.VerifyCvvAsync(
            card.CvvHash, request.Cvv))
            return AuthorizationResult.Declined("Invalid CVV");

        var todaySpend = await _ledger.GetDailySpendAsync(
            card.AccountId);
        if (todaySpend + request.Amount > card.DailyLimit)
            return AuthorizationResult.Declined(
                "Daily limit exceeded");

        if (card.BlockedMerchantCategories?.Contains(
            request.MccCode) == true)
            return AuthorizationResult.Declined(
                "Category not permitted");

        var risk = await _fraud.EvaluateCardAsync(
            new CardFraudRequest
            {
                CardId = card.Id,
                Amount = request.Amount,
                MerchantId = request.MerchantId,
                MccCode = request.MccCode,
                Location = request.Location,
                IsCardPresent = request.IsCardPresent,
                DeviceFingerprint = request.DeviceFingerprint
            });

        if (risk.Decision == RiskDecision.Declined)
            return AuthorizationResult.Declined(
                "Transaction blocked");

        var hold = await _ledger.PlaceAuthorizationHoldAsync(
            card.AccountId,
            request.Amount,
            request.AuthorizationId,
            request.MerchantName);

        return AuthorizationResult.Approved(
            hold.Id,
            card.AvailableBalance - request.Amount);
    }

    public async Task<VirtualCard> CreateVirtualCardAsync(
        CreateVirtualCardCommand cmd)
    {
        var virtualCard = new VirtualCard
        {
            Id = Guid.NewGuid(),
            AccountId = cmd.AccountId,
            CardNumber = await _cryptoService
                .GenerateVirtualPanAsync(),
            ExpiryDate = DateTime.UtcNow.AddYears(3),
            Cvv = await _cryptoService
                .GenerateRandomCvvAsync(),
            SpendingLimit = cmd.SpendingLimit,
            DailyLimit = cmd.DailyLimit,
            MerchantRestrictions =
                cmd.AllowedMerchantCategories,
            Status = CardStatus.Active,
            CreatedAt = DateTime.UtcNow,
            IsSingleUse = cmd.IsSingleUse,
            ValidFrom = cmd.ValidFrom ?? DateTime.UtcNow,
            ValidUntil = cmd.ValidUntil ??
                DateTime.UtcNow.AddYears(1)
        };

        await _cardRepo.SaveVirtualCardAsync(virtualCard);

        await _encryption.EncryptAndStoreAsync(
            "virtual_cards",
            virtualCard.Id.ToString(),
            new { virtualCard.CardNumber, virtualCard.Cvv });

        await _events.PublishAsync(
            new VirtualCardCreatedEvent
            {
                CardId = virtualCard.Id,
                AccountId = virtualCard.AccountId,
                MaskedNumber = $"****{virtualCard.CardNumber[^4..]}"
            });

        return virtualCard;
    }

    public async Task FreezeCardAsync(
        Guid cardId, string reason)
    {
        var card = await _cardRepo.GetCardAsync(cardId);
        card.Status = CardStatus.Frozen;
        card.FrozenReason = reason;
        card.FrozenAt = DateTime.UtcNow;

        await _cardRepo.UpdateCardAsync(card);
        await _ledger.CancelPendingHoldsAsync(
            card.AccountId);

        await _events.PublishAsync(new CardFrozenEvent
        {
            CardId = cardId,
            Reason = reason
        });
    }
}

Card Features Summary

FeaturePhysical CardVirtual Card
Card-present transactionsYes (chip/contactless)No
Card-not-present transactionsYesYes (primary use case)
ATM withdrawalsYesNo
Real-time freeze/unfreezeYesYes
Spending limitsGlobal + per-categoryGlobal + merchant-specific
Single-use optionNoYes
Merchant restrictionsYesYes (more granular)
Tokenization for mobile walletsYesYes
ReplacementShip physical cardInstant regeneration

12. Dispute & Chargeback Processing

Dispute processing handles customer-reported unauthorized transactions, billing errors, and merchant disputes. Under Regulation E, consumers have specific rights and timeframes for disputing electronic fund transfers.

Dispute Lifecycle

stateDiagram-v2 [*] --> Filed: Customer Files Filed --> UnderReview: Assigned to Analyst UnderReview --> EvidenceCollection: Needs Info UnderReview --> ProvisionalCredit: Reg E Met EvidenceCollection --> UnderReview: Evidence Submitted ProvisionalCredit --> Investigation: Bank Investigates Investigation --> Resolved_Won: For Customer Investigation --> Resolved_Lost: Against Customer Resolved_Won --> [*] Resolved_Lost --> ProvisionalCreditRevoked ProvisionalCreditRevoked --> [*] UnderReview --> Escalated: Complexity Escalated --> Arbitration: Network
C#
public class DisputeService : IDisputeService
{
    private const int RegEProvisionalCreditDays = 10;
    private const int RegEInvestigationDays = 45;

    public async Task<Dispute> FileDisputeAsync(
        FileDisputeCommand cmd)
    {
        var transaction = await _transactionRepo.GetAsync(
            cmd.TransactionId);

        if (transaction == null)
            throw new DomainException("Transaction not found");

        var daysSinceTransaction = (DateTime.UtcNow
            - transaction.OccurredAt).Days;

        var timeLimit = cmd.DisputeType switch
        {
            DisputeType.Unauthorized => 60,
            DisputeType.NotAsDescribed => 60,
            DisputeType.DuplicateCharge => 60,
            DisputeType.CreditNotProcessed => 60,
            _ => 60
        };

        if (daysSinceTransaction > timeLimit)
            throw new DomainException(
                $"Dispute window expired ({timeLimit} days)");

        var dispute = new Dispute
        {
            Id = Guid.NewGuid(),
            AccountId = transaction.AccountId,
            TransactionId = cmd.TransactionId,
            DisputeType = cmd.DisputeType,
            Reason = cmd.Reason,
            CustomerNarrative = cmd.Narrative,
            Status = DisputeStatus.Filed,
            FiledAt = DateTime.UtcNow,
            ProvisionalCreditDeadline = DateTime.UtcNow
                .AddDays(RegEProvisionalCreditDays),
            InvestigationDeadline = DateTime.UtcNow
                .AddDays(RegEInvestigationDays),
            SupportingDocuments = cmd.DocumentIds.ToList()
        };

        await _repo.SaveDisputeAsync(dispute);

        if (cmd.DisputeType == DisputeType.Unauthorized)
            await _blockMerchantAsync(transaction.MerchantId);

        await _scheduler.ScheduleAsync(
            "ProvisionalCreditCheck",
            dispute.ProvisionalCreditDeadline,
            new { DisputeId = dispute.Id });

        return dispute;
    }

    public async Task IssueProvisionalCreditAsync(
        Guid disputeId)
    {
        var dispute = await _repo.GetDisputeAsync(disputeId);
        if (dispute.Status !=
            DisputeStatus.UnderInvestigation)
            return;

        await _ledger.PostEntryAsync(new PostEntryCommand
        {
            TransactionId = Guid.NewGuid(),
            Entries = new[]
            {
                new LedgerEntryInput
                {
                    AccountId = dispute.AccountId,
                    Type = LedgerEntryType.Credit,
                    Amount = dispute.TransactionAmount
                },
                new LedgerEntryInput
                {
                    AccountId = _provisionalCreditLiability.Id,
                    Type = LedgerEntryType.Debit,
                    Amount = dispute.TransactionAmount
                }
            }
        });

        dispute.Status =
            DisputeStatus.ProvisionalCreditIssued;
        dispute.ProvisionalCreditIssuedAt = DateTime.UtcNow;
        await _repo.UpdateDisputeAsync(dispute);
    }

    public async Task ResolveDisputeAsync(
        Guid disputeId, DisputeResolution resolution)
    {
        var dispute = await _repo.GetDisputeAsync(disputeId);

        if (resolution.FavorCustomer)
        {
            dispute.Status =
                DisputeStatus.ResolvedInFavor;
        }
        else
        {
            if (dispute.Status ==
                DisputeStatus.ProvisionalCreditIssued)
            {
                await _ledger.PostEntryAsync(
                    new PostEntryCommand
                    {
                        TransactionId = Guid.NewGuid(),
                        Entries = new[]
                        {
                            new LedgerEntryInput
                            {
                                AccountId = dispute.AccountId,
                                Type = LedgerEntryType.Debit,
                                Amount = dispute.TransactionAmount
                            },
                            new LedgerEntryInput
                            {
                                AccountId =
                                    _provisionalCreditLiability.Id,
                                Type = LedgerEntryType.Credit,
                                Amount = dispute.TransactionAmount
                            }
                        }
                    });
            }
            dispute.Status =
                DisputeStatus.ResolvedAgainst;
        }

        dispute.Resolution = resolution;
        dispute.ResolvedAt = DateTime.UtcNow;
        await _repo.UpdateDisputeAsync(dispute);
    }
}

13. Multi-Currency Support

Multi-currency support allows users to hold, convert, send, and receive money in multiple currencies. The system provides real-time exchange rates, handles FX conversions, and maintains accurate ledger entries in both source and destination currencies.

Currency Architecture

flowchart TB USER[User] --> MULTI[Multi-Currency Account] MULTI --> USD[USD Balance] MULTI --> EUR[EUR Balance] MULTI --> GBP[GBP Balance] MULTI --> JPY[JPY Balance] USER --> CONVERT[Currency Conversion] CONVERT --> RATE_FEED[Exchange Rate Feed] CONVERT --> FX_ENGINE[FX Engine] FX_ENGINE --> MARKUP[Fee Calculation] FX_ENGINE --> LEDGER[Double-Sided Ledger] RATE_FEED --> ECB[ECB Rates] RATE_FEED --> BLOOMBERG[Bloomberg Feed] LEDGER --> GAIN_LOSS[FX Gain/Loss Tracking]
C#
public class CurrencyService : ICurrencyService
{
    private readonly IExchangeRateFeed _rateFeed;
    private readonly IFxEngine _fxEngine;
    private readonly ILedgerService _ledger;

    public async Task<ConversionResult> ConvertAsync(
        ConvertCurrencyCommand cmd)
    {
        var rate = await _rateFeed.GetRateAsync(
            cmd.SourceCurrency, cmd.DestinationCurrency);
        var markup = await _fxEngine.GetMarkupAsync(
            cmd.SourceCurrency,
            cmd.DestinationCurrency,
            cmd.Amount);

        var convertedAmount = cmd.Amount * rate *
            (1 - markup);
        var fee = cmd.Amount * markup;

        var fxTransaction = new FxTransaction
        {
            Id = Guid.NewGuid(),
            SourceAccountId = cmd.SourceAccountId,
            SourceCurrency = cmd.SourceCurrency,
            SourceAmount = cmd.Amount,
            DestinationAccountId = cmd.DestinationAccountId,
            DestinationCurrency = cmd.DestinationCurrency,
            DestinationAmount = convertedAmount,
            ExchangeRate = rate,
            Markup = markup,
            Fee = fee,
            RateSource = rate.Source,
            RateTimestamp = rate.Timestamp,
            ExecutedAt = DateTime.UtcNow
        };

        await _ledger.PostEntryAsync(new PostEntryCommand
        {
            TransactionId = fxTransaction.Id,
            Entries = new[]
            {
                new LedgerEntryInput
                {
                    AccountId = cmd.SourceAccountId,
                    Type = LedgerEntryType.Debit,
                    Amount = cmd.Amount,
                    Currency = cmd.SourceCurrency
                },
                new LedgerEntryInput
                {
                    AccountId = cmd.DestinationAccountId,
                    Type = LedgerEntryType.Credit,
                    Amount = convertedAmount,
                    Currency = cmd.DestinationCurrency
                },
                new LedgerEntryInput
                {
                    AccountId = _fxFeeIncomeAccount.Id,
                    Type = LedgerEntryType.Credit,
                    Amount = fee,
                    Currency = cmd.SourceCurrency
                }
            }
        });

        return ConversionResult.Success(fxTransaction);
    }
}

14. KYC/AML Onboarding

Know Your Customer (KYC) and Anti-Money Laundering (AML) regulations require financial institutions to verify customer identity, assess risk, and monitor for suspicious activity.

KYC/AML Flow

flowchart TB START[New User] --> APPLICATION[Application Form] APPLICATION --> TIER{Account Tier?} TIER --> |Basic| LIGHT[Lightweight KYC] TIER --> |Standard| STANDARD[Standard KYC] TIER --> |Premium| ENHANCED[Enhanced Due Diligence] LIGHT --> ID_DOC[Upload ID Document] LIGHT --> SELFIE[Selfie Verification] STANDARD --> ID_DOC STANDARD --> SELFIE STANDARD --> PROOF_ADDR[Proof of Address] STANDARD --> SSN[SSN Verification] ENHANCED --> ID_DOC ENHANCED --> SELFIE ENHANCED --> PROOF_ADDR ENHANCED --> SSN ENHANCED --> SOURCE_FUNDS[Source of Funds] ID_DOC --> OCR[OCR Processing] SELFIE --> LIVENESS[Face Liveness Check] SELFIE --> MATCH[Face Match vs ID] OCR --> SANCTIONS[Sanctions Screening] LIVENESS --> SANCTIONS MATCH --> SANCTIONS SANCTIONS --> OFAC[OFAC List Check] SANCTIONS --> PEP[PEP Database Check] OFAC --> RISK_SCORE[Risk Score Calculation] PEP --> RISK_SCORE RISK_SCORE --> |Low Risk| AUTO_APPROVE[Auto-Approve] RISK_SCORE --> |Medium Risk| MANUAL_REVIEW[Manual Review] RISK_SCORE --> |High Risk| ESCALATE[Escalation] AUTO_APPROVE --> ACCOUNT_CREATE[Create Account]
C#
public class KycService : IKycService
{
    public async Task<KycResult> ProcessApplicationAsync(
        KycApplication application)
    {
        var result = new KycResult
        {
            ApplicationId = application.Id,
            Checks = new List<KycCheckResult>()
        };

        var docVerification = await _documentVerifier
            .VerifyAsync(application.IdDocument);
        result.Checks.Add(docVerification);

        var biometricResult = await _biometricVerifier
            .VerifyAsync(
                application.Selfie,
                docVerification.ExtractedFace);
        result.Checks.Add(biometricResult);

        if (application.ProofOfAddress != null)
        {
            var addressResult = await _addressVerifier
                .VerifyAsync(application.ProofOfAddress);
            result.Checks.Add(addressResult);
        }

        var ssnResult = await _ssnVerifier.VerifyAsync(
            application.Ssn,
            application.FullName,
            application.DateOfBirth);
        result.Checks.Add(ssnResult);

        var sanctionsResult = await _sanctionsChecker
            .ScreenAsync(new SanctionsCheckRequest
            {
                Name = application.FullName,
                DateOfBirth = application.DateOfBirth,
                Nationality = application.Nationality,
                IdNumber = application.IdNumber
            });
        result.Checks.Add(sanctionsResult);

        var mediaResult = await _mediaChecker
            .ScreenAsync(application.FullName);
        result.Checks.Add(mediaResult);

        result.OverallRiskScore = CalculateRiskScore(
            result.Checks);
        result.OverallDecision = result.OverallRiskScore switch
        {
            < 30 => KycDecision.AutoApproved,
            < 70 => KycDecision.ManualReviewRequired,
            _ => KycDecision.Escalated
        };

        if (result.OverallRiskScore >= 70)
        {
            await _sarService
                .FileSuspiciousActivityReportAsync(
                    application, result);
        }

        return result;
    }

    private int CalculateRiskScore(
        List<KycCheckResult> checks)
    {
        var weights = new Dictionary<string, int>
        {
            ["DocumentVerification"] = 25,
            ["BiometricMatch"] = 20,
            ["AddressVerification"] = 15,
            ["SsnVerification"] = 20,
            ["SanctionsScreening"] = 10,
            ["AdverseMedia"] = 10
        };

        int totalScore = 0;
        int totalWeight = 0;

        foreach (var check in checks)
        {
            if (weights.ContainsKey(check.CheckType))
            {
                totalScore += check.RiskScore *
                    weights[check.CheckType];
                totalWeight += weights[check.CheckType];
            }
        }

        return totalWeight > 0
            ? totalScore / totalWeight : 50;
    }
}

Transaction Monitoring (AML)

After onboarding, the system continuously monitors transactions for suspicious patterns:

  • Structuring: Multiple transactions just below reporting thresholds ($10,000 CTR, $3,000 wire)
  • Rapid Movement: Large deposits immediately transferred out
  • High-Risk Jurisdictions: Transactions involving FATF gray/black list countries
  • Unusual Patterns: Transaction patterns deviating significantly from customer profile
  • Velocity Checks: Abnormal transaction frequency or volume
Regulatory Requirement: Suspicious Activity Reports (SARs) must be filed within 30 days of detection. Currency Transaction Reports (CTRs) must be filed for cash transactions exceeding $10,000. Failure to file carries severe penalties including criminal prosecution.

15. Notification System

The notification system delivers real-time alerts for transactions, security events, bill reminders, and marketing communications across multiple channels (push, SMS, email, in-app).

Notification Architecture

flowchart TB EVENT[Platform Events] --> ROUTER[Notification Router] ROUTER --> RULES[Rules Engine] RULES --> PREF[User Preferences] RULES --> TEMPLATE[Template Engine] PREF --> CHANNEL_PUSH[Push Notifications] PREF --> CHANNEL_SMS[SMS] PREF --> CHANNEL_EMAIL[Email] PREF --> CHANNEL_INAPP[In-App] TEMPLATE --> PUSH_SVC[FCM/APNs] TEMPLATE --> SMS_SVC[Twilio/SNS] TEMPLATE --> EMAIL_SVC[SES/SendGrid] TEMPLATE --> INAPP_SVC[WebSocket] PUSH_SVC --> DELIVERY[Delivery Tracking] SMS_SVC --> DELIVERY EMAIL_SVC --> DELIVERY INAPP_SVC --> DELIVERY DELIVERY --> DEDUP[Deduplication] DELIVERY --> RATE_LIMIT[Rate Limiting]
C#
public class NotificationService : INotificationService
{
    private readonly INotificationRepository _repo;
    private readonly IUserPreferences _prefs;
    private readonly IPushProvider _push;
    private readonly ISmsProvider _sms;
    private readonly IEmailProvider _email;
    private readonly ITemplateEngine _templates;

    public async Task SendAsync(NotificationEvent evt)
    {
        var preferences = await _prefs.GetAsync(evt.UserId);
        var channels = preferences.GetChannels(
            evt.NotificationType);

        var recentDuplicate = await _repo.FindRecentAsync(
            evt.UserId, evt.NotificationType,
            evt.ReferenceId, TimeSpan.FromMinutes(5));

        if (recentDuplicate != null) return;

        var recentCount = await _repo.CountRecentAsync(
            evt.UserId, TimeSpan.FromMinutes(1));
        if (recentCount >= 10)
        {
            await _queue.EnqueueAsync(evt, Priority.Low);
            return;
        }

        var notifications = new List<Notification>();

        foreach (var channel in channels)
        {
            var template = await _templates.RenderAsync(
                evt.NotificationType, channel,
                evt.TemplateData);

            var notification = new Notification
            {
                Id = Guid.NewGuid(),
                UserId = evt.UserId,
                Channel = channel,
                Type = evt.NotificationType,
                Title = template.Title,
                Body = template.Body,
                Data = evt.TemplateData,
                Status = NotificationStatus.Pending,
                CreatedAt = DateTime.UtcNow
            };

            notifications.Add(notification);
            await _repo.SaveAsync(notification);
        }

        foreach (var notification in notifications)
        {
            try
            {
                switch (notification.Channel)
                {
                    case NotificationChannel.Push:
                        await _push.SendAsync(notification);
                        break;
                    case NotificationChannel.Sms:
                        await _sms.SendAsync(notification);
                        break;
                    case NotificationChannel.Email:
                        await _email.SendAsync(notification);
                        break;
                    case NotificationChannel.InApp:
                        await _inApp.SendAsync(notification);
                        break;
                }
                notification.Status = NotificationStatus.Sent;
                notification.SentAt = DateTime.UtcNow;
            }
            catch (Exception ex)
            {
                notification.Status =
                    NotificationStatus.Failed;
                notification.Error = ex.Message;
            }
            await _repo.UpdateAsync(notification);
        }
    }
}

Notification Types

TypePriorityChannelsExample
Transaction AlertHighPush, SMS, In-App"$42.50 spent at Starbucks"
Security AlertCriticalPush, SMS, Email"New device login detected"
Bill ReminderMediumPush, Email"Electricity bill due in 3 days"
Budget AlertMediumPush, In-App"80% of dining budget used"
Transfer CompleteLowPush, In-App"$500 sent to John Doe"
Dispute UpdateMediumEmail, In-App"Your dispute has been resolved"
Market AlertLowPush"AAPL up 5% today"

16. Document Management

The document management system handles storage and retrieval of sensitive financial documents: statements, tax forms, KYC documents, dispute evidence, and legal correspondence.

C#
public class DocumentService : IDocumentService
{
    private readonly IBlobStorage _storage;
    private readonly IDocumentRepository _repo;
    private readonly IEncryptionService _encryption;

    public async Task<DocumentReference> UploadAsync(
        UploadDocumentRequest request)
    {
        var allowedTypes = new HashSet<string>
        {
            "application/pdf",
            "image/jpeg",
            "image/png"
        };

        if (!allowedTypes.Contains(request.ContentType))
            throw new DomainException("Unsupported file type");

        if (request.Content.Length > 10 * 1024 * 1024)
            throw new DomainException("File too large");

        var encryptedContent = await _encryption.EncryptAsync(
            request.Content,
            keyVersion: _encryption.CurrentKeyVersion);

        var contentHash = await ComputeSha256Async(
            request.Content);

        var storagePath = $"/{request.UserId}/" +
            $"{request.Category}/" +
            $"{Guid.NewGuid()}/{request.FileName}";
        await _storage.PutAsync(
            storagePath, encryptedContent);

        var document = new Document
        {
            Id = Guid.NewGuid(),
            UserId = request.UserId,
            Category = request.Category,
            FileName = request.FileName,
            ContentType = request.ContentType,
            ContentHash = contentHash,
            StoragePath = storagePath,
            SizeBytes = request.Content.Length,
            UploadedAt = DateTime.UtcNow,
            RetentionExpiry = CalculateRetentionExpiry(
                request.Category)
        };

        await _repo.SaveAsync(document);

        return new DocumentReference
        {
            DocumentId = document.Id,
            FileName = document.FileName,
            UploadedAt = document.UploadedAt
        };
    }

    public async Task<Stream> DownloadAsync(
        Guid documentId, Guid userId)
    {
        var document = await _repo.GetAsync(documentId);
        if (document.UserId != userId)
            throw new UnauthorizedAccessException();

        var encryptedContent = await _storage.GetAsync(
            document.StoragePath);
        var content = await _encryption.DecryptAsync(
            encryptedContent);

        var hash = await ComputeSha256Async(content);
        if (hash != document.ContentHash)
            throw new IntegrityException(
                "Document integrity check failed");

        await _repo.RecordAccessAsync(
            documentId, userId, DocumentAccess.Download);

        return new MemoryStream(content);
    }

    private DateTime? CalculateRetentionExpiry(
        DocumentCategory category)
    {
        return category switch
        {
            DocumentCategory.KycDocument =>
                DateTime.UtcNow.AddYears(5),
            DocumentCategory.TaxForm =>
                DateTime.UtcNow.AddYears(7),
            DocumentCategory.BankStatement =>
                DateTime.UtcNow.AddYears(7),
            DocumentCategory.DisputeEvidence =>
                DateTime.UtcNow.AddYears(3),
            DocumentCategory.Legal =>
                DateTime.UtcNow.AddYears(10),
            _ => null
        };
    }
}

17. Fraud Detection Engine

The fraud detection engine evaluates every transaction in real-time, scoring risk based on behavioral patterns, device fingerprinting, transaction velocity, and anomaly detection. It must make decisions in under 50ms while maintaining low false-positive rates.

Fraud Detection Architecture

flowchart TB TXN[Transaction Event] --> ENRICH[Feature Enrichment] ENRICH --> FEATURES[Feature Vector] FEATURES --> RULES[Rule Engine] FEATURES --> ML_MODEL[ML Model] FEATURES --> VELOCITY[Velocity Checks] FEATURES --> DEVICE[Device Analysis] RULES --> RULE_SCORE[Rule Score] ML_MODEL --> ML_SCORE[ML Score] VELOCITY --> VEL_SCORE[Velocity Score] DEVICE --> DEV_SCORE[Device Score] RULE_SCORE --> ENSEMBLE[Ensemble Scorer] ML_SCORE --> ENSEMBLE VEL_SCORE --> ENSEMBLE DEV_SCORE --> ENSEMBLE ENSEMBLE --> DECISION{Decision} DECISION --> |Score < 30| APPROVE[Approve] DECISION --> |30-70| STEP_UP[Step-Up Auth] DECISION --> |70-90| REVIEW[Manual Review] DECISION --> |> 90| BLOCK[Block + Alert]
C#
public class FraudDetectionEngine : IFraudEngine
{
    private readonly IRuleEngine _rules;
    private readonly IMlModelClient _ml;
    private readonly IVelocityChecker _velocity;
    private readonly IDeviceAnalyzer _device;
    private readonly IFeatureStore _features;

    public async Task<RiskAssessment> EvaluateAsync(
        FraudCheckRequest request)
    {
        var featureVector = await _features.BuildAsync(
            request.AccountId,
            request.MerchantId,
            request.Amount,
            request.Location,
            request.DeviceFingerprint);

        var ruleTask = _rules.EvaluateAsync(featureVector);
        var mlTask = _ml.PredictAsync(featureVector);
        var velocityTask = _velocity.CheckAsync(
            request.AccountId, request.Amount);
        var deviceTask = _device.AnalyzeAsync(
            request.DeviceFingerprint, request.Location);

        await Task.WhenAll(
            ruleTask, mlTask, velocityTask, deviceTask);

        var ruleResult = await ruleTask;
        var mlResult = await mlTask;
        var velocityResult = await velocityTask;
        var deviceResult = await deviceTask;

        var compositeScore = CalculateCompositeScore(
            ruleResult.Score, mlResult.Score,
            velocityResult.Score, deviceResult.Score);

        var decision = compositeScore switch
        {
            < 30 => RiskDecision.Approved,
            < 70 => RiskDecision.StepUpRequired,
            < 90 => RiskDecision.ManualReview,
            _ => RiskDecision.Declined
        };

        await _features.RecordAsync(new FraudEvent
        {
            Request = request,
            CompositeScore = compositeScore,
            Decision = decision,
            FeatureVector = featureVector,
            EvaluatedAt = DateTime.UtcNow
        });

        return new RiskAssessment
        {
            Score = compositeScore,
            Decision = decision,
            RuleScore = ruleResult.Score,
            MlScore = mlResult.Score,
            VelocityScore = velocityResult.Score,
            DeviceScore = deviceResult.Score,
            TriggeredRules = ruleResult.TriggeredRules,
            Reason = GenerateDeclineReason(
                decision, ruleResult)
        };
    }

    private double CalculateCompositeScore(
        double ruleScore, double mlScore,
        double velocityScore, double deviceScore)
    {
        return (ruleScore * 0.15) +
               (mlScore * 0.50) +
               (velocityScore * 0.20) +
               (deviceScore * 0.15);
    }
}

public class VelocityChecker : IVelocityChecker
{
    public async Task<VelocityResult> CheckAsync(
        Guid accountId, decimal amount)
    {
        var checks = new List<VelocityCheck>
        {
            await CheckTransactionCount(accountId,
                TimeSpan.FromHours(1), 10),
            await CheckTransactionCount(accountId,
                TimeSpan.FromHours(24), 50),
            await CheckTotalAmount(accountId,
                TimeSpan.FromHours(1), 5000m),
            await CheckTotalAmount(accountId,
                TimeSpan.FromDays(7), 25000m),
            await CheckUniqueMerchants(accountId,
                TimeSpan.FromHours(24), 15),
            await CheckGeographicVelocity(accountId)
        };

        var maxVelocityScore = checks.Max(c => c.Score);
        var triggeredRules = checks
            .Where(c => c.Triggered)
            .Select(c => c.RuleName)
            .ToList();

        return new VelocityResult
        {
            Score = maxVelocityScore,
            Triggered = triggeredRules.Any(),
            TriggeredRules = triggeredRules
        };
    }
}

ML Model Features

Feature CategoryFeatures
TransactionAmount, merchant category, time of day, day of week, distance from last transaction
AccountAccount age, average balance, transaction frequency, historical fraud flags
DeviceDevice type, OS, browser, IP geolocation, device age, new device flag
BehavioralSession duration, typing patterns, navigation behavior, login frequency
NetworkIP reputation, proxy detection, VPN usage, Tor exit node
MerchantMerchant risk score, chargeback history, MCC category risk

18. Monitoring & Observability

In a financial system, monitoring is not optional — it is a regulatory requirement. Every transaction must be traceable, every service health must be tracked, and every anomaly must trigger an alert.

Monitoring Stack

flowchart LR subgraph Data Collection APP_LOGS[Application Logs] METRICS[Metrics Prometheus] TRACES[Traces Jaeger] AUDIT_LOGS[Audit Logs] end subgraph Processing LOG_AGG[Log Aggregator ELK] MET_PROC[Metrics Processor] TRAC_COL[Trace Collector] end subgraph Storage ES[Elasticsearch] PROM_DB[Prometheus TSDB] JAEG_DB[Jaeger Storage] end subgraph Visualization GRAFANA[Grafana Dashboards] KIBANA[Kibana Explorer] end subgraph Alerting ALERT_MGR[Alert Manager] PAGERDUTY[PagerDuty] SLACK[Slack Alerts] end APP_LOGS --> LOG_AGG --> ES --> KIBANA METRICS --> MET_PROC --> PROM_DB --> GRAFANA TRACES --> TRAC_COL --> JAEG_DB --> GRAFANA AUDIT_LOGS --> ES GRAFANA --> ALERT_MGR --> PAGERDUTY ALERT_MGR --> SLACK

Key Banking Metrics

MetricTargetAlert Threshold
Transaction Success Rate> 99.99%< 99.9%
Payment Processing Latency (p99)< 500ms> 1000ms
Balance Query Latency (p99)< 100ms> 200ms
Ledger Posting Latency (p99)< 200ms> 500ms
Fraud Detection Latency< 50ms> 100ms
API Error Rate< 0.01%> 0.1%
Kafka Consumer Lag< 1000 msgs> 10000
Database Connection Pool< 70% util> 85%
Cache Hit Rate> 95%< 90%
Dispute Queue Depth< 100 pending> 500
C#
public class TransactionTracingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<TransactionTracingMiddleware> _logger;

    public async Task InvokeAsync(HttpContext context)
    {
        var traceId = context.Request.Headers["X-Trace-Id"]
            .FirstOrDefault() ??
            Guid.NewGuid().ToString();

        using var activity = _activitySource.StartActivity(
            $"{context.Request.Method} " +
            $"{context.Request.Path}");
        activity?.SetTag("trace.id", traceId);
        activity?.SetTag("http.method",
            context.Request.Method);
        activity?.SetTag("http.url",
            context.Request.Path);

        var sw = Stopwatch.StartNew();

        try
        {
            await _next(context);
            sw.Stop();

            activity?.SetTag("http.status_code",
                context.Response.StatusCode);

            _logger.LogInformation(
                "[{TraceId}] {Method} {Path} -> " +
                "{StatusCode} ({Elapsed}ms)",
                traceId,
                context.Request.Method,
                context.Request.Path,
                context.Response.StatusCode,
                sw.ElapsedMilliseconds);

            _metrics.RecordRequestDuration(
                context.Request.Path,
                context.Response.StatusCode,
                sw.ElapsedMilliseconds);
        }
        catch (Exception ex)
        {
            sw.Stop();
            activity?.SetTag("error", true);
            activity?.SetTag("error.message", ex.Message);

            _logger.LogError(ex,
                "[{TraceId}] {Method} {Path} -> " +
                "ERROR ({Elapsed}ms)",
                traceId,
                context.Request.Method,
                context.Request.Path,
                sw.ElapsedMilliseconds);

            _metrics.RecordRequestError(
                context.Request.Path, ex.GetType().Name);

            throw;
        }
    }
}
SRE Practice: Maintain an error budget of 0.01% (52 minutes/year for 99.99% availability). When the error budget is exhausted, all feature releases are frozen until reliability improves. Conduct blameless post-mortems for every incident that impacts the error budget.

19. Security (2FA, Device Fingerprinting)

Security in banking is defense-in-depth. Multiple layers of authentication, encryption, and monitoring protect user accounts and sensitive data.

Authentication Flow

sequenceDiagram participant U as User participant API as Auth Service participant MFA as MFA Service participant FP as Device Fingerprint participant S as Session Manager U->>API: Login (email + password) API->>API: Validate credentials API->>FP: Check device fingerprint alt Known Device API->>MFA: Send OTP (SMS/TOTP) MFA-->>U: OTP sent U->>API: Submit OTP API->>MFA: Verify OTP MFA-->>API: OTP verified else Unknown Device API->>MFA: Send OTP + email alert MFA-->>U: OTP + security alert U->>API: Submit OTP API->>MFA: Verify OTP API->>FP: Register device end API->>S: Create session S-->>API: Session + refresh token API-->>U: Auth tokens
C#
public class AuthenticationService :
    IAuthenticationService
{
    private readonly IPasswordHasher _passwordHasher;
    private readonly IMfaService _mfa;
    private readonly IDeviceFingerprintService _deviceFingerprint;
    private readonly ISessionManager _sessions;
    private readonly IEventPublisher _events;

    public async Task<AuthResult> LoginAsync(
        LoginRequest request)
    {
        var attempts = await _rateLimiter
            .GetAttemptsAsync(request.Email);
        if (attempts >= 5)
            return AuthResult.TooManyAttempts(
                await _rateLimiter.GetCooldownAsync(
                    request.Email));

        var user = await _userRepo.GetByEmailAsync(
            request.Email);
        if (user == null ||
            !_passwordHasher.VerifyHash(
                user.PasswordHash, request.Password))
        {
            await _rateLimiter.IncrementAsync(
                request.Email);
            return AuthResult.InvalidCredentials();
        }

        if (user.Status != UserStatus.Active)
            return AuthResult.AccountLocked(
                user.LockReason);

        var deviceInfo = await _deviceFingerprint
            .AnalyzeAsync(request.DeviceFingerprint);
        var isKnownDevice = await _deviceRepo
            .IsKnownDeviceAsync(
                user.Id, deviceInfo.FingerprintHash);

        if (user.MfaEnabled)
        {
            var mfaResult = await _mfa.SendOtpAsync(
                user.Id,
                isKnownDevice
                    ? MfaChannel.Sms
                    : MfaChannel.SmsAndEmail);

            return AuthResult.MfaRequired(
                mfaResult.ChallengeId,
                isKnownDevice
                    ? MfaChallengeType.StandardOtp
                    : MfaChallengeType.EnhancedOtp);
        }

        return await CompleteLoginAsync(
            user, deviceInfo);
    }

    public async Task<AuthResult> CompleteMfaAsync(
        MfaVerificationRequest request)
    {
        var challenge = await _mfa.GetChallengeAsync(
            request.ChallengeId);
        if (challenge == null || challenge.IsExpired)
            return AuthResult.MfaExpired();

        var verified = await _mfa.VerifyOtpAsync(
            challenge.UserId, request.OtpCode);
        if (!verified)
            return AuthResult.MfaFailed();

        var user = await _userRepo.GetAsync(
            challenge.UserId);
        var deviceInfo = await _deviceFingerprint
            .AnalyzeAsync(request.DeviceFingerprint);

        return await CompleteLoginAsync(
            user, deviceInfo);
    }

    private async Task<AuthResult> CompleteLoginAsync(
        User user, DeviceInfo deviceInfo)
    {
        var isKnown = await _deviceRepo.IsKnownDeviceAsync(
            user.Id, deviceInfo.FingerprintHash);
        if (!isKnown)
        {
            await _deviceRepo.RegisterDeviceAsync(
                new UserDevice
                {
                    UserId = user.Id,
                    FingerprintHash =
                        deviceInfo.FingerprintHash,
                    DeviceType = deviceInfo.DeviceType,
                    Os = deviceInfo.OperatingSystem,
                    Browser = deviceInfo.Browser,
                    FirstSeenAt = DateTime.UtcNow,
                    LastSeenAt = DateTime.UtcNow,
                    Trusted = false
                });

            await _events.PublishAsync(
                new NewDeviceLoginEvent
                {
                    UserId = user.Id,
                    DeviceInfo = deviceInfo
                });
        }

        var session = await _sessions.CreateAsync(
            new CreateSessionRequest
            {
                UserId = user.Id,
                DeviceFingerprint =
                    deviceInfo.FingerprintHash,
                IpAddress = deviceInfo.IpAddress,
                UserAgent = deviceInfo.UserAgent,
                ExpiresAt =
                    DateTime.UtcNow.AddHours(24),
                RefreshExpiresAt =
                    DateTime.UtcNow.AddDays(30)
            });

        await _audit.LogAsync(new AuditEntry
        {
            UserId = user.Id,
            Action = AuditAction.Login,
            DeviceFingerprint =
                deviceInfo.FingerprintHash,
            IpAddress = deviceInfo.IpAddress,
            Timestamp = DateTime.UtcNow
        });

        return AuthResult.Success(
            session.AccessToken,
            session.RefreshToken,
            session.ExpiresAt);
    }
}

public class DeviceFingerprintService :
    IDeviceFingerprintService
{
    public async Task<DeviceInfo> AnalyzeAsync(
        string rawFingerprint)
    {
        var components = JsonSerializer.Deserialize<
            Dictionary<string, string>>(
                rawFingerprint);

        var fingerprintHash = ComputeHash(
            components["screen_resolution"],
            components["timezone"],
            components["language"],
            components["platform"],
            components["fonts"],
            components["plugins"],
            components["canvas_hash"],
            components["webgl_hash"]);

        return new DeviceInfo
        {
            FingerprintHash = fingerprintHash,
            DeviceType = DetectDeviceType(
                components["user_agent"]),
            OperatingSystem = components["os"],
            Browser = components["browser"],
            IpAddress = components["ip"],
            UserAgent = components["user_agent"],
            ScreenResolution =
                components["screen_resolution"],
            Timezone = components["timezone"]
        };
    }

    private string ComputeHash(
        params string[] components)
    {
        var combined = string.Join("|", components);
        using var sha256 = SHA256.Create();
        var hash = sha256.ComputeHash(
            Encoding.UTF8.GetBytes(combined));
        return Convert.ToBase64String(hash);
    }
}

20. Compliance (PCI DSS, SOX, Reg E)

Financial platforms must comply with numerous regulations. Non-compliance can result in massive fines, loss of banking licenses, and criminal prosecution.

Regulatory Framework

RegulationScopeKey Requirements
PCI DSS Level 1Card data handlingEncryption, access controls, network segmentation, quarterly scans, annual audit
Sarbanes-Oxley (SOX)Financial reportingInternal controls, audit trails, management assessment, external auditor attestation
Regulation EElectronic fund transfersError resolution (10-day provisional credit), unauthorized transfer liability limits, disclosures
BSA/AMLAnti-money launderingKYC, CTR filing ($10K+), SAR filing, transaction monitoring, training
GDPRData privacy (EU)Consent, right to erasure, data portability, breach notification (72 hours)
PSD2Open banking (EU)Strong Customer Authentication (SCA), TPP access, consent management
Reg CCFunds availabilityHold policies, availability schedules, disclosure requirements
Reg Z (TILA)Credit card billingTruth in Lending disclosures, billing error rights, dispute procedures

PCI DSS Implementation

C#
public class PciDssService
{
    // Card numbers are NEVER stored in plaintext
    // Only the last 4 digits and token are stored
    public class CardToken
    {
        public Guid Id { get; set; }
        public string Token { get; set; }
        public string LastFourDigits { get; set; }
        public string ExpiryMonth { get; set; }
        public string ExpiryYear { get; set; }
        public string CardBrand { get; set; }
        // PAN is NEVER stored - only in HSM during txn
    }

    public class NetworkSegmentation
    {
        public string CdeVnetId { get; set; }
        public string AppVnetId { get; set; }
        public string DbVnetId { get; set; }

        public List<FirewallRule> Rules = new()
        {
            new FirewallRule
            {
                Source = "AppVnet",
                Destination = "CdeVnet",
                Port = 443,
                Protocol = "Tcp",
                Action = "Allow"
            },
            new FirewallRule
            {
                Source = "AppVnet",
                Destination = "DbVnet",
                Port = 5432,
                Protocol = "Tcp",
                Action = "Allow"
            },
            new FirewallRule
            {
                Source = "Internet",
                Destination = "CdeVnet",
                Action = "Deny"
            },
            new FirewallRule
            {
                Source = "Internet",
                Destination = "DbVnet",
                Action = "Deny"
            }
        };
    }

    public class AuditLogger
    {
        public async Task LogFinancialChangeAsync(
            FinancialChange change)
        {
            var auditEntry = new AuditLogEntry
            {
                Id = Guid.NewGuid(),
                Timestamp = DateTime.UtcNow,
                UserId = change.UserId,
                Action = change.Action,
                EntityType = change.EntityType,
                EntityId = change.EntityId,
                OldValue = change.OldValue,
                NewValue = change.NewValue,
                IpAddress = change.IpAddress,
                UserAgent = change.UserAgent,
                PreviousHash =
                    await GetPreviousHashAsync(),
            };

            auditEntry.CurrentHash =
                ComputeHash(auditEntry);

            await _auditStore.AppendAsync(auditEntry);
        }

        private string ComputeHash(
            AuditLogEntry entry)
        {
            var data = $"{entry.Timestamp:O}|" +
                $"{entry.UserId}|{entry.Action}|" +
                $"{entry.EntityType}|{entry.EntityId}|" +
                $"{entry.OldValue}|{entry.NewValue}|" +
                $"{entry.PreviousHash}";

            using var sha256 = SHA256.Create();
            var hash = sha256.ComputeHash(
                Encoding.UTF8.GetBytes(data));
            return Convert.ToBase64String(hash);
        }
    }
}
PCI DSS Scope: Minimize the Cardholder Data Environment (CDE). Use tokenization everywhere possible. Never store CVV/CVC after authorization. Use HSMs for encryption key management. Segment networks to reduce audit scope. Quarterly ASV scans and annual QSA audits are mandatory for Level 1 merchants.

21. API Design

The platform exposes RESTful APIs for external partners and mobile/web clients, with gRPC for internal service communication.

API Endpoints

EndpointMethodDescription
/api/v1/accountsGETList user accounts
/api/v1/accounts/{id}GETGet account details
/api/v1/accounts/{id}/balanceGETGet account balance
/api/v1/transactionsGETList transactions (paginated)
/api/v1/transactionsPOSTCreate transaction
/api/v1/transfers/internalPOSTInternal transfer
/api/v1/transfers/achPOSTACH transfer
/api/v1/transfers/wirePOSTWire transfer
/api/v1/p2p/sendPOSTSend P2P payment
/api/v1/cardsGET/POSTList/issue cards
/api/v1/cards/{id}/freezePOSTFreeze card
/api/v1/budgetsGET/POSTManage budgets
/api/v1/analytics/spendingGETSpending insights
/api/v1/investments/portfolioGETPortfolio summary
/api/v1/investments/ordersPOSTPlace order
/api/v1/disputesPOSTFile dispute
/api/v1/kyc/applicationPOSTSubmit KYC
/api/v1/documentsPOSTUpload document

API Response Standards

JSON
{
    "status": "success",
    "data": {
        "account": {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "type": "checking",
            "currency": "USD",
            "balance": {
                "current": 12543.67,
                "available": 12543.67,
                "pending": 0.00
            },
            "status": "active",
            "opened_at": "2024-01-15T00:00:00Z"
        }
    },
    "meta": {
        "request_id": "req_abc123",
        "timestamp": "2024-03-15T10:30:00Z",
        "version": "2024-03-01"
    }
}

{
    "status": "error",
    "error": {
        "code": "INSUFFICIENT_FUNDS",
        "message": "Insufficient available balance",
        "details": {
            "requested": 5000.00,
            "available": 2543.67,
            "currency": "USD"
        }
    },
    "meta": {
        "request_id": "req_def456",
        "timestamp": "2024-03-15T10:31:00Z"
    }
}

Rate Limiting

Endpoint CategoryRate LimitBurst
Account reads100 req/min20 req/sec
Transaction reads100 req/min20 req/sec
Transfers (ACH, Wire)10 req/min2 req/sec
P2P payments20 req/min5 req/sec
Card operations30 req/min5 req/sec
Investment orders15 req/min3 req/sec
Document uploads10 req/min2 req/sec

22. Database Schema Design

The database design follows the double-entry bookkeeping model with PostgreSQL as the primary OLTP database. Each service owns its schema, and cross-service data access is via events.

Core Tables

SQL
CREATE TABLE customers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    phone VARCHAR(20),
    full_name VARCHAR(255) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'active',
    kyc_status VARCHAR(20) NOT NULL DEFAULT 'pending',
    risk_score INTEGER DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE accounts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    owner_id UUID NOT NULL REFERENCES customers(id),
    account_type VARCHAR(30) NOT NULL,
    currency CHAR(3) NOT NULL DEFAULT 'USD',
    status VARCHAR(20) NOT NULL DEFAULT 'active',
    balance_current DECIMAL(18,4) NOT NULL DEFAULT 0,
    balance_available DECIMAL(18,4) NOT NULL DEFAULT 0,
    balance_pending DECIMAL(18,4) NOT NULL DEFAULT 0,
    opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    frozen_reason TEXT,
    frozen_at TIMESTAMPTZ,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT valid_balance
        CHECK (balance_current >= -10000)
);

CREATE INDEX idx_accounts_owner
    ON accounts(owner_id);
CREATE INDEX idx_accounts_status
    ON accounts(status);

CREATE TABLE ledger_entries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    transaction_id UUID NOT NULL,
    account_id UUID NOT NULL REFERENCES accounts(id),
    entry_type VARCHAR(6) NOT NULL
        CHECK (entry_type IN ('debit', 'credit')),
    amount DECIMAL(18,4) NOT NULL CHECK (amount > 0),
    currency CHAR(3) NOT NULL,
    running_balance DECIMAL(18,4) NOT NULL,
    sequence_number BIGINT NOT NULL,
    idempotency_key VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(account_id, sequence_number),
    UNIQUE(idempotency_key)
);

CREATE INDEX idx_ledger_account
    ON ledger_entries(account_id, sequence_number);
CREATE INDEX idx_ledger_transaction
    ON ledger_entries(transaction_id);

CREATE TABLE transactions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source_account_id UUID REFERENCES accounts(id),
    destination_account_id UUID REFERENCES accounts(id),
    transaction_type VARCHAR(30) NOT NULL,
    amount DECIMAL(18,4) NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    idempotency_key VARCHAR(255) UNIQUE NOT NULL,
    description TEXT,
    merchant_name VARCHAR(255),
    merchant_category VARCHAR(10),
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    completed_at TIMESTAMPTZ
);

CREATE INDEX idx_txn_source
    ON transactions(source_account_id, created_at DESC);
CREATE INDEX idx_txn_destination
    ON transactions(destination_account_id, created_at DESC);
CREATE INDEX idx_txn_status ON transactions(status);
CREATE INDEX idx_txn_created
    ON transactions(created_at DESC);

CREATE TABLE cards (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id UUID NOT NULL REFERENCES accounts(id),
    card_type VARCHAR(20) NOT NULL,
    card_brand VARCHAR(10) NOT NULL,
    last_four_digits CHAR(4) NOT NULL,
    token VARCHAR(255) UNIQUE NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'active',
    daily_limit DECIMAL(18,4),
    monthly_limit DECIMAL(18,4),
    is_virtual BOOLEAN DEFAULT FALSE,
    is_single_use BOOLEAN DEFAULT FALSE,
    blocked_mcc_codes TEXT[],
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL,
    frozen_at TIMESTAMPTZ,
    frozen_reason TEXT
);

CREATE TABLE disputes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id UUID NOT NULL REFERENCES accounts(id),
    transaction_id UUID NOT NULL REFERENCES transactions(id),
    dispute_type VARCHAR(30) NOT NULL,
    reason TEXT NOT NULL,
    customer_narrative TEXT NOT NULL,
    status VARCHAR(30) NOT NULL DEFAULT 'filed',
    filed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    provisional_credit_deadline TIMESTAMPTZ,
    investigation_deadline TIMESTAMPTZ NOT NULL,
    provisional_credit_issued_at TIMESTAMPTZ,
    resolved_at TIMESTAMPTZ,
    resolution JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE audit_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    user_id UUID,
    action VARCHAR(50) NOT NULL,
    entity_type VARCHAR(50) NOT NULL,
    entity_id UUID,
    old_value JSONB,
    new_value JSONB,
    ip_address INET,
    user_agent TEXT,
    previous_hash VARCHAR(255),
    current_hash VARCHAR(255) NOT NULL
);

CREATE RULE audit_no_update
    AS ON UPDATE TO audit_log DO INSTEAD NOTHING;
CREATE RULE audit_no_delete
    AS ON DELETE TO audit_log DO INSTEAD NOTHING;

Sharding Strategy

For scale, shard by customer_id using consistent hashing:

  • Shard key: Customer ID (all data for one customer is co-located)
  • Shard count: Start with 16 shards, expand to 64 as needed
  • Routing: Consistent hashing with virtual nodes for even distribution
  • Cross-shard transactions: Rare (inter-bank transfers); use 2PC or saga pattern

23. Testing Strategy

Testing in financial systems must be comprehensive. Bugs in financial code can result in real monetary losses. The strategy covers unit tests, integration tests, contract tests, chaos engineering, and compliance testing.

Testing Pyramid

graph TB E2E[E2E Tests 10%] --> INTEGRATION[Integration Tests 30%] INTEGRATION --> UNIT[Unit Tests 60%] E2E --> |Playwright| UI[UI Flows] E2E --> |Postman| API[API Flows] INTEGRATION --> |Testcontainers| DB[Database] INTEGRATION --> |WireMock| EXT[External Services] INTEGRATION --> |Embedded Kafka| EVENTS[Event Flows] UNIT --> |xUnit| BIZ[Business Logic] UNIT --> |Moq| MOCK[Mocked Dependencies]

Financial-Specific Tests

C#
public class LedgerTests
{
    [Fact]
    public async Task PostEntry_BalancesMustAlwaysEqual()
    {
        var ledger = new LedgerService(_db, _cache);
        var accountId = await CreateTestAccountAsync(
            ledger, 1000m);
        var destAccountId = await CreateTestAccountAsync(
            ledger, 0m);

        await ledger.PostEntryAsync(new PostEntryCommand
        {
            TransactionId = Guid.NewGuid(),
            Entries = new[]
            {
                new LedgerEntryInput
                {
                    AccountId = accountId,
                    Type = LedgerEntryType.Debit,
                    Amount = 250m,
                    Currency = "USD"
                },
                new LedgerEntryInput
                {
                    AccountId = destAccountId,
                    Type = LedgerEntryType.Credit,
                    Amount = 250m,
                    Currency = "USD"
                }
            }
        });

        var sourceBalance = await ledger
            .GetBalanceAsync(accountId);
        var destBalance = await ledger
            .GetBalanceAsync(destAccountId);

        Assert.Equal(750m, sourceBalance.Available);
        Assert.Equal(250m, destBalance.Available);

        var totalDebits = await ledger
            .GetTotalDebitsAsync(accountId, destAccountId);
        var totalCredits = await ledger
            .GetTotalCreditsAsync(accountId, destAccountId);
        Assert.Equal(totalDebits, totalCredits);
    }

    [Fact]
    public async Task PostEntry_UnbalancedEntries_Rejected()
    {
        var ledger = new LedgerService(_db, _cache);
        var accountId = await CreateTestAccountAsync(
            ledger, 1000m);

        await Assert.ThrowsAsync<DomainException>(
            async () =>
        {
            await ledger.PostEntryAsync(
                new PostEntryCommand
            {
                TransactionId = Guid.NewGuid(),
                Entries = new[]
                {
                    new LedgerEntryInput
                    {
                        AccountId = accountId,
                        Type = LedgerEntryType.Debit,
                        Amount = 300m,
                        Currency = "USD"
                    },
                    new LedgerEntryInput
                    {
                        AccountId = accountId,
                        Type = LedgerEntryType.Credit,
                        Amount = 200m,
                        Currency = "USD"
                    }
                }
            });
        });
    }

    [Fact]
    public async Task IdempotentRequest_ReturnsSameResult()
    {
        var ledger = new LedgerService(_db, _cache);
        var accountId = await CreateTestAccountAsync(
            ledger, 1000m);
        var destAccountId = await CreateTestAccountAsync(
            ledger, 0m);
        var idempotencyKey = Guid.NewGuid().ToString();

        var result1 = await ledger.PostEntryAsync(
            new PostEntryCommand
        {
            TransactionId = Guid.NewGuid(),
            IdempotencyKey = idempotencyKey,
            Entries = new[]
            {
                new LedgerEntryInput
                {
                    AccountId = accountId,
                    Type = LedgerEntryType.Debit,
                    Amount = 100m,
                    Currency = "USD"
                },
                new LedgerEntryInput
                {
                    AccountId = destAccountId,
                    Type = LedgerEntryType.Credit,
                    Amount = 100m,
                    Currency = "USD"
                }
            }
        });

        var result2 = await ledger.PostEntryAsync(
            new PostEntryCommand
        {
            TransactionId = Guid.NewGuid(),
            IdempotencyKey = idempotencyKey,
            Entries = new[]
            {
                new LedgerEntryInput
                {
                    AccountId = accountId,
                    Type = LedgerEntryType.Debit,
                    Amount = 100m,
                    Currency = "USD"
                },
                new LedgerEntryInput
                {
                    AccountId = destAccountId,
                    Type = LedgerEntryType.Credit,
                    Amount = 100m,
                    Currency = "USD"
                }
            }
        });

        // Balance should only change once
        var balance = await ledger
            .GetBalanceAsync(accountId);
        Assert.Equal(900m, balance.Available);
    }
}

Test Types for Banking

Test TypeFocus AreaTools
Unit TestsLedger math, validation rules, categorizationxUnit, FluentAssertions, AutoFixture
Integration TestsDatabase transactions, Kafka events, API contractsTestcontainers, WireMock, Embeddable Kafka
Contract TestsAPI compatibility between servicesPact, Spring Cloud Contract
Chaos TestsFailure scenarios, network partitionsChaos Monkey, Toxiproxy, Litmus
Security TestsPenetration testing, SQL injection, XSSOWASP ZAP, Burp Suite, Snyk
Performance TestsLoad testing, stress testing, soak testingk6, Gatling, Locust
Compliance TestsPCI DSS controls, SOX audit requirementsCustom test suites, Chef InSpec

24. Cost Estimation

Estimating infrastructure costs for a digital banking platform at scale requires careful analysis of compute, storage, network, and third-party service costs.

Infrastructure Cost Breakdown (Monthly)

ComponentSpecQuantityUnit CostMonthly Cost
Application Servers (EKS)c6g.2xlarge (8 vCPU, 16 GB)12$0.34/hr$2,938
PostgreSQL (RDS)db.r6g.2xlarge Multi-AZ3 (primary + 2 replicas)$1.34/hr$29,016
Redis Cluster (ElastiCache)r6g.xlarge6 nodes$0.26/hr$11,232
Kafka (MSK)kafka.m5.2xlarge6 brokers$0.47/hr$20,304
Elasticsearchr6g.xlarge.elasticsearch6 nodes$0.26/hr$11,232
S3 StorageStandard + IA5 TB$0.023/GB$115
Data TransferInter-AZ + Internet5 TB outbound$0.09/GB$450
CloudFront CDNGlobal edge locations10 TB transfer$0.085/GB$850
WAFWeb ACL + Rules1Fixed$350
Snowflake (Analytics)Medium warehouse1$4/credit$2,000
Total Infrastructure$78,487

Third-Party Service Costs (Monthly)

ServiceProviderMonthly Cost
Account Aggregation (Plaid)Plaid$5,000 - $25,000
SMS (Twilio)Twilio$3,000 - $8,000
Email (SES)AWS SES$500 - $2,000
Push Notifications (FCM/APNs)Google/Apple$0 (free tier)
Market Data FeedIEX/Polygon$2,000 - $10,000
Exchange RatesFrankfurter/Open Exchange$200 - $1,000
KYC/Identity VerificationJumio/Onfido$3,000 - $15,000
Sanctions ScreeningDow Jones/Refinitiv$5,000 - $20,000
HSM (CloudHSM)AWS$1,500
PCI DSS ComplianceQSA Audit (annualized)$4,000
Total Third-Party$24,200 - $86,500

Personnel Costs (Annual)

RoleHeadcountAverage SalaryAnnual Cost
Backend Engineers8$180,000$1,440,000
Frontend Engineers4$160,000$640,000
DevOps/SRE3$170,000$510,000
Data Engineers2$165,000$330,000
Security Engineers2$175,000$350,000
QA Engineers3$130,000$390,000
Product Manager1$155,000$155,000
Compliance Officer1$140,000$140,000
Engineering Manager2$190,000$380,000
Total Personnel26$4,335,000
Total Estimated Monthly Cost: $127,000 - $190,000 infrastructure + third-party, plus $361,250/month personnel. Total annual burn rate: approximately $6.2M - $6.6M for a mid-scale platform serving 5 million users with 50M monthly transactions.

Cost Optimization Strategies

  • Reserved Instances: 1-year commitments save 30-40% on EC2/RDS
  • Spot Instances: Use for non-critical batch processing (ACH file generation, analytics)
  • Right-sizing: Monitor CPU/memory utilization and downsize over-provisioned instances
  • Data lifecycle: Move transactions older than 90 days to S3 Glacier
  • Connection pooling: PgBouncer to reduce PostgreSQL connection overhead
  • Cache optimization: Increase Redis hit rate to reduce database reads

25. Interview Q&A

Common system design interview questions for a digital banking platform, with detailed answers covering trade-offs and design decisions.

Q1: How do you handle double-spending?

Answer: Double-spending is prevented through the combination of: (1) PostgreSQL SERIALIZABLE isolation level on all ledger transactions, (2) SELECT FOR UPDATE row-level locks on account balance rows, (3) gapless sequence numbers to detect any gaps, and (4) idempotency keys to prevent duplicate processing. The balance check and deduction happen in the same database transaction, making it atomic.

Q2: How do you ensure zero data loss for transactions?

Answer: We use a multi-layer approach: (1) PostgreSQL with synchronous replication to at least one standby (RPO = 0), (2) Write-ahead log (WAL) archiving to S3 for point-in-time recovery, (3) Kafka with replication factor 3 and min.insync.replicas = 2 for event durability, (4) Every transaction is durably committed before returning success to the client. We never acknowledge a transaction before the ledger entry is flushed to disk.

Q3: How do you handle the 2008 Financial Crisis scenario where the entire system is under extreme load?

Answer: (1) Auto-scaling groups with pre-warmed capacity, (2) Circuit breakers on non-critical services (analytics, notifications) to protect core banking, (3) Priority queues ensuring balance checks and transfers take precedence over reports, (4) Read replicas for all read-heavy operations, (5) Degrading gracefully — if analytics is overloaded, show cached results; if notifications are delayed, queue them for later. Core transaction processing must remain available at all costs.

Q4: Design the idempotency mechanism for ACH transfers that take 2-3 days to settle.

Answer: ACH transfers use a two-phase approach: (1) On submission, record the transfer with status "submitted" and idempotency key, (2) Return a pending status to the user immediately, (3) ACH returns arrive asynchronously via webhook/file, (4) We match returns to submissions using trace numbers, (5) The idempotency key is retained for 90 days (the ACH return window), (6) If a user resubmits within this window, we return the existing transfer status instead of creating a new one. For NACHA file generation, we deduplicate by batch + trace number.

Q5: How do you handle split bills where one participant fails to pay?

Answer: Split bills use an escrow model: (1) The initiator's funds are placed on hold, (2) Each participant's share is tracked separately, (3) As participants pay, their share is marked complete, (4) The initiator pays their share and any remaining from the escrow, (5) If a participant fails to pay within the deadline (configurable, default 7 days), the initiator is notified and can choose to cover the difference, (6) The split settles when all shares are paid or the initiator covers the shortfall. The initiator assumes credit risk for unpaid portions.

Q6: How do you handle currency conversion when rates change between request and execution?

Answer: We use a "quote and lock" pattern: (1) When the user initiates a conversion, we fetch the current rate and lock it for 30 seconds (configurable), (2) The quote includes the rate, markup, and exact amounts, (3) If the user confirms within the lock window, we execute at the locked rate, (4) If the lock expires, we return an error and the user must request a new quote, (5) For large conversions (>$10K), we add an additional risk check, (6) The FX gain/loss is tracked separately in the ledger for accounting purposes.

Q7: How would you design the system to support both real-time and batch payment processing?

Answer: We use the Transaction Outbox pattern: (1) All payment requests are first written to an "outbox" table in the same transaction as the ledger entry, (2) A background poller picks up pending outbox entries, (3) Real-time payments (P2P, card transactions) are processed immediately via synchronous APIs, (4) Batch payments (ACH, bill pay) are collected and processed in NACHA file batches on a schedule (3x daily for same-day ACH), (5) The outbox ensures at-least-once delivery and provides audit trail, (6) Status updates flow back via the same outbox pattern in reverse.

Q8: How do you handle regulatory requirements across multiple jurisdictions?

Answer: We use a policy engine architecture: (1) Each jurisdiction has a configuration file defining its regulatory requirements (KYC tiers, transaction limits, reporting thresholds), (2) The core banking logic is jurisdiction-agnostic, (3) A compliance middleware layer applies jurisdiction-specific rules before and after each transaction, (4) Reporting is jurisdiction-specific with pluggable report generators (CTR for US, STR for EU, etc.), (5) Data residency requirements are handled by routing data to the appropriate regional cluster, (6) We maintain a regulatory change management process with quarterly compliance reviews.

Q9: Design the dispute resolution workflow for Reg E compliance.

Answer: Reg E has strict timelines: (1) Customer files dispute within 60 days of statement, (2) We have 10 business days to investigate or issue provisional credit, (3) If investigation takes longer (up to 45 days, 90 for international), provisional credit must be issued first, (4) The system tracks all deadlines and escalates approaching deadlines, (5) Evidence collection is tracked with document upload and categorization, (6) Resolution generates a formal letter per Reg E requirements, (7) If provisional credit is reversed, we provide 5 business days notice before debiting the account, (8) All interactions are logged for regulatory examination.

Q10: How do you test the fraud detection system without blocking real transactions?

Answer: We use shadow testing: (1) The production fraud engine runs on all real transactions, (2) A shadow copy of the engine runs in parallel with experimental models, (3) Shadow decisions are logged but NOT acted upon, (4) We compare shadow vs production decisions for false positive/negative rates, (5) Promising models are deployed to a small percentage of traffic (canary), (6) If canary shows improvement, gradual rollout to 100%, (7) A/B testing framework tracks fraud catch rate, false positive rate, and customer friction metrics, (8) All model changes require compliance officer sign-off before production deployment.

Personal Finance & Digital Banking Platform — Senior+ Guide | Ayodhyya