How to Design a Personal Finance & Digital Banking Platform
A comprehensive system design guide covering accounts, payments, investments, compliance, and everything in between
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
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.99% (52 min/year downtime) | Financial services are 24/7; downtime costs real money |
| Latency (p99) | < 200ms for reads, < 500ms for writes | Real-time balance checks at POS must be fast |
| Durability | Zero data loss (RPO = 0) | Every transaction must be durably committed |
| Consistency | Strong consistency for balances | Double-spending is unacceptable |
| Throughput | 10,000+ TPS sustained, 50,000+ TPS peak | Scale for millions of concurrent users |
| Security | PCI DSS Level 1, SOC 2 Type II | Regulatory mandate for handling card data |
| Audit | Complete, immutable audit trail | SOX, Reg E, and internal compliance requirements |
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.
Key Architectural Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Service Communication | Async events (Kafka) + sync gRPC | Event-driven for eventual consistency; gRPC for latency-critical paths |
| Ledger Model | Double-entry bookkeeping | Industry standard; self-balancing; audit-friendly |
| Database per Service | PostgreSQL per bounded context | Data isolation; independent scaling; schema ownership |
| Caching | Redis cluster for hot data | Balance lookups, session management, rate limiting |
| Event Streaming | Apache Kafka | Durability, replayability, exactly-once semantics |
| API Protocol | REST (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
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;
}
}
}
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
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:
- Checking for existing transactions with the same key before processing
- Using database unique constraints on
idempotency_key - Returning the original result if a duplicate is detected
- Setting a 72-hour TTL for idempotency key retention
Transaction Types
| Type | Direction | Settlement | Reversible |
|---|---|---|---|
| Debit Card Purchase | Outbound | Instant | Via dispute (90 days) |
| Credit Card Purchase | Inbound | T+1 to T+30 | Via chargeback |
| ACH Credit | Inbound | T+1 to T+2 | Via return (60 days) |
| ACH Debit | Outbound | T+1 to T+2 | Via return (60 days) |
| Wire Transfer | Both | Same day | Generally non-reversible |
| P2P Transfer | Both | Instant | Within 60 days (Reg E) |
| Bill Pay | Outbound | T+1 to T+5 | Before settlement |
| Investment Trade | Both | T+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
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);
}
}
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
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
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
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
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
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);
}
}
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
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
| Feature | Physical Card | Virtual Card |
|---|---|---|
| Card-present transactions | Yes (chip/contactless) | No |
| Card-not-present transactions | Yes | Yes (primary use case) |
| ATM withdrawals | Yes | No |
| Real-time freeze/unfreeze | Yes | Yes |
| Spending limits | Global + per-category | Global + merchant-specific |
| Single-use option | No | Yes |
| Merchant restrictions | Yes | Yes (more granular) |
| Tokenization for mobile wallets | Yes | Yes |
| Replacement | Ship physical card | Instant 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
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
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
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
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
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
| Type | Priority | Channels | Example |
|---|---|---|---|
| Transaction Alert | High | Push, SMS, In-App | "$42.50 spent at Starbucks" |
| Security Alert | Critical | Push, SMS, Email | "New device login detected" |
| Bill Reminder | Medium | Push, Email | "Electricity bill due in 3 days" |
| Budget Alert | Medium | Push, In-App | "80% of dining budget used" |
| Transfer Complete | Low | Push, In-App | "$500 sent to John Doe" |
| Dispute Update | Medium | Email, In-App | "Your dispute has been resolved" |
| Market Alert | Low | Push | "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
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 Category | Features |
|---|---|
| Transaction | Amount, merchant category, time of day, day of week, distance from last transaction |
| Account | Account age, average balance, transaction frequency, historical fraud flags |
| Device | Device type, OS, browser, IP geolocation, device age, new device flag |
| Behavioral | Session duration, typing patterns, navigation behavior, login frequency |
| Network | IP reputation, proxy detection, VPN usage, Tor exit node |
| Merchant | Merchant 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
Key Banking Metrics
| Metric | Target | Alert 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;
}
}
}
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
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
| Regulation | Scope | Key Requirements |
|---|---|---|
| PCI DSS Level 1 | Card data handling | Encryption, access controls, network segmentation, quarterly scans, annual audit |
| Sarbanes-Oxley (SOX) | Financial reporting | Internal controls, audit trails, management assessment, external auditor attestation |
| Regulation E | Electronic fund transfers | Error resolution (10-day provisional credit), unauthorized transfer liability limits, disclosures |
| BSA/AML | Anti-money laundering | KYC, CTR filing ($10K+), SAR filing, transaction monitoring, training |
| GDPR | Data privacy (EU) | Consent, right to erasure, data portability, breach notification (72 hours) |
| PSD2 | Open banking (EU) | Strong Customer Authentication (SCA), TPP access, consent management |
| Reg CC | Funds availability | Hold policies, availability schedules, disclosure requirements |
| Reg Z (TILA) | Credit card billing | Truth 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);
}
}
}
21. API Design
The platform exposes RESTful APIs for external partners and mobile/web clients, with gRPC for internal service communication.
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
/api/v1/accounts | GET | List user accounts |
/api/v1/accounts/{id} | GET | Get account details |
/api/v1/accounts/{id}/balance | GET | Get account balance |
/api/v1/transactions | GET | List transactions (paginated) |
/api/v1/transactions | POST | Create transaction |
/api/v1/transfers/internal | POST | Internal transfer |
/api/v1/transfers/ach | POST | ACH transfer |
/api/v1/transfers/wire | POST | Wire transfer |
/api/v1/p2p/send | POST | Send P2P payment |
/api/v1/cards | GET/POST | List/issue cards |
/api/v1/cards/{id}/freeze | POST | Freeze card |
/api/v1/budgets | GET/POST | Manage budgets |
/api/v1/analytics/spending | GET | Spending insights |
/api/v1/investments/portfolio | GET | Portfolio summary |
/api/v1/investments/orders | POST | Place order |
/api/v1/disputes | POST | File dispute |
/api/v1/kyc/application | POST | Submit KYC |
/api/v1/documents | POST | Upload 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 Category | Rate Limit | Burst |
|---|---|---|
| Account reads | 100 req/min | 20 req/sec |
| Transaction reads | 100 req/min | 20 req/sec |
| Transfers (ACH, Wire) | 10 req/min | 2 req/sec |
| P2P payments | 20 req/min | 5 req/sec |
| Card operations | 30 req/min | 5 req/sec |
| Investment orders | 15 req/min | 3 req/sec |
| Document uploads | 10 req/min | 2 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
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 Type | Focus Area | Tools |
|---|---|---|
| Unit Tests | Ledger math, validation rules, categorization | xUnit, FluentAssertions, AutoFixture |
| Integration Tests | Database transactions, Kafka events, API contracts | Testcontainers, WireMock, Embeddable Kafka |
| Contract Tests | API compatibility between services | Pact, Spring Cloud Contract |
| Chaos Tests | Failure scenarios, network partitions | Chaos Monkey, Toxiproxy, Litmus |
| Security Tests | Penetration testing, SQL injection, XSS | OWASP ZAP, Burp Suite, Snyk |
| Performance Tests | Load testing, stress testing, soak testing | k6, Gatling, Locust |
| Compliance Tests | PCI DSS controls, SOX audit requirements | Custom 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)
| Component | Spec | Quantity | Unit Cost | Monthly Cost | |
|---|---|---|---|---|---|
| Application Servers (EKS) | c6g.2xlarge (8 vCPU, 16 GB) | 12 | $0.34/hr | $2,938 | |
| PostgreSQL (RDS) | db.r6g.2xlarge Multi-AZ | 3 (primary + 2 replicas) | $1.34/hr | $29,016 | |
| Redis Cluster (ElastiCache) | r6g.xlarge | 6 nodes | $0.26/hr | $11,232 | |
| Kafka (MSK) | kafka.m5.2xlarge | 6 brokers | $0.47/hr | $20,304 | |
| Elasticsearch | r6g.xlarge.elasticsearch | 6 nodes | $0.26/hr | $11,232 | |
| S3 Storage | Standard + IA | 5 TB | $0.023/GB | $115 | |
| Data Transfer | Inter-AZ + Internet | 5 TB outbound | $0.09/GB | $450 | |
| CloudFront CDN | Global edge locations | 10 TB transfer | $0.085/GB | $850 | |
| WAF | Web ACL + Rules | 1 | Fixed | $350 | |
| Snowflake (Analytics) | Medium warehouse | 1 | $4/credit | $2,000 | |
| Total Infrastructure | $78,487 | ||||
Third-Party Service Costs (Monthly)
| Service | Provider | Monthly 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 Feed | IEX/Polygon | $2,000 - $10,000 |
| Exchange Rates | Frankfurter/Open Exchange | $200 - $1,000 |
| KYC/Identity Verification | Jumio/Onfido | $3,000 - $15,000 |
| Sanctions Screening | Dow Jones/Refinitiv | $5,000 - $20,000 |
| HSM (CloudHSM) | AWS | $1,500 |
| PCI DSS Compliance | QSA Audit (annualized) | $4,000 |
| Total Third-Party | $24,200 - $86,500 |
Personnel Costs (Annual)
| Role | Headcount | Average Salary | Annual Cost |
|---|---|---|---|
| Backend Engineers | 8 | $180,000 | $1,440,000 |
| Frontend Engineers | 4 | $160,000 | $640,000 |
| DevOps/SRE | 3 | $170,000 | $510,000 |
| Data Engineers | 2 | $165,000 | $330,000 |
| Security Engineers | 2 | $175,000 | $350,000 |
| QA Engineers | 3 | $130,000 | $390,000 |
| Product Manager | 1 | $155,000 | $155,000 |
| Compliance Officer | 1 | $140,000 | $140,000 |
| Engineering Manager | 2 | $190,000 | $380,000 |
| Total Personnel | 26 | $4,335,000 |
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.