Stable Money App — Digital Banking & Investment Platform
A complete system design deep-dive into building India's trusted platform for guaranteed returns, curated FDs, government securities, and secure digital banking
1. Stable Money Platform Overview & Requirements
Stable Money is a digital banking and investment platform that enables Indian retail investors to earn guaranteed, stable returns by investing in fixed deposits across partner banks, government securities (G-Secs), and curated fixed-income instruments. Unlike traditional FD platforms offered by individual banks, Stable Money aggregates the best rates from multiple partner banks, provides a unified dashboard, handles KYC verification end-to-end, and ensures that every deposit is backed by either direct bank guarantees or government sovereign backing. The platform has grown to serve millions of registered users who collectively manage tens of thousands of crores in deposits.
The core value proposition is straightforward: users invest their money once, and the platform automatically finds the highest-yielding, safest fixed-income instruments across partner banks and government securities. There is no risk of capital loss for insured deposits because every fixed deposit placed through Stable Money is covered under the Deposit Insurance and Credit Guarantee Corporation (DICGC) insurance up to the RBI-mandated limit of five lakh rupees per depositor per bank. For government securities, the backing is sovereign, meaning the Government of India itself guarantees the principal and interest payments. This combination of guaranteed returns and capital safety is what differentiates Stable Money from equity-linked investment platforms and mutual fund apps.
The platform operates in a highly regulated environment governed by the Reserve Bank of India (RBI), the Securities and Exchange Board of India (SEBI) for government securities, and the Prevention of Money Laundering Act (PMLA) for anti-money laundering compliance. Every feature, every data flow, and every storage decision must be evaluated against these regulatory frameworks. This makes the system design fundamentally different from a typical fintech application — regulatory compliance is not an afterthought but a core architectural constraint that shapes every design decision.
Functional Requirements
- Multi-Bank FD Aggregation: Place fixed deposits across 30+ partner banks from a single app; users compare rates, tenure options, and select the best combination based on yield and DICGC coverage
- Government Securities Investment: Direct investment in Treasury Bills, Government Securities, and State Development Loans via RBI Retail Direct platform integration
- Guaranteed Returns Dashboard: Real-time portfolio view showing current holdings, maturity dates, interest earned, projected returns, and reinvestment suggestions
- Automated KYC/AML: Aadhaar-based eKYC (DigiLocker integration), PAN verification via NSDL, video KYC for high-value accounts, and continuous transaction monitoring per PMLA requirements
- Multi-Mode Payments: UPI (NPCI integration), NEFT, RTGS, IMPS, net banking, and linked bank account debits for investment and withdrawal
- Smart Maturity Management: Automatic alerts before maturity, one-click renewal or reinvestment, auto-sweep to highest-yielding options
- Tax Reporting: TDS certificate generation (Form 16A), interest statement downloads, capital gains computation for G-Sec trades
- Regulatory Reporting: Automated CTR and STR filing, RBI returns generation, DICGC claim facilitation
- Customer Support: In-app chat, ticketing, callback requests, and integration with partner bank support teams
- Referral and Rewards: Referral tracking, bonus interest campaigns, loyalty tiers
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.95% (4.4 hours/year downtime) | Financial platform handling real money; high trust requirement |
| Latency (p99) | < 300ms for reads, < 800ms for writes | Dashboard loads and transaction initiations must feel instant |
| Durability | Zero data loss (RPO = 0) | Every rupee of user investment must be durably recorded |
| Consistency | Strong consistency for balances | Portfolio values must always be accurate; no double-counting |
| Throughput | 5,000+ TPS sustained, 25,000+ TPS peak | Scale for millions of users across partner banks |
| Security | PCI DSS Level 1, SOC 2 Type II, RBI Data Localization | Regulatory compliance for Indian financial data |
| Audit | Complete, immutable audit trail | PMLA, RBI KYC norms, and internal audit requirements |
| Data Residency | All user data stored within India | RBI mandate for payment system data localization |
Capacity Estimation
Assume 5 million active users, 2 million FDs placed per month, 500,000 daily active users:
- Dashboard reads per second: 500K DAU / 86,400 seconds * 20 page loads = ~120 TPS average, 1,200 TPS peak
- FD placement writes per second: 2M / 30 / 86,400 = ~0.8 TPS average, 50 TPS peak during business hours
- Payment processing TPS: Combined FD + withdrawal + renewal = ~5 TPS average, 200 TPS peak during month-end salary cycles
- Storage per user profile: ~2 KB including KYC metadata, preferences, and session data
- Storage per FD record: ~1.5 KB including metadata, audit fields, and reconciliation status
- Monthly storage growth: ~50 GB (user data + transactions + logs), ~600 GB/year before compression
- Partner bank API calls: 500K dashboard checks + 2M placements + 1M maturity events = ~3.5M/day
2. System Architecture Overview
The Stable Money platform follows a domain-driven microservices architecture with event-driven communication via Apache Kafka as the backbone. Each bounded context — user management, bank partner integration, fixed deposit engine, government securities, payment processing, compliance, notifications, analytics, and reconciliation — owns its data store and communicates through well-defined asynchronous events. Synchronous REST/gRPC calls are limited to operations requiring immediate consistency, such as balance checks before payment initiation or KYC status verification before FD placement.
The architecture is deployed on Amazon Web Services (AWS) in the ap-south-1 (Mumbai) region to satisfy RBI data localization requirements. Each microservice runs in its own ECS Fargate cluster with auto-scaling policies. PostgreSQL (Amazon Aurora) serves as the primary OLTP database for all transactional services, with multi-AZ replication for high availability. Redis clusters handle caching, rate limiting, and distributed locking. Amazon SQS and SNS provide dead-letter queues and fan-out notifications, while Kafka handles the high-throughput event streaming backbone. All infrastructure is defined as code using Terraform, enabling reproducible deployments and disaster recovery in alternate AWS regions within the same geographic zone.
Service Responsibility Matrix
| Service | Bounded Context | Database | Key Dependencies |
|---|---|---|---|
| User Service | Registration, profiles, authentication, sessions | aurora_users | Redis (sessions), Cognito (MFA) |
| KYC Service | Identity verification, document management, re-KYC | aurora_kyc | DigiLocker, NSDL PAN API, S3 (docs) |
| FD Placement Service | Order management, bank partner routing, maturity tracking | aurora_fd | Partner bank APIs, Kafka (events) |
| G-Sec Trading Service | T-Bill/Govt bond orders, auction participation | aurora_gsec | RBI Retail Direct, Kafka |
| Payment Service | UPI/NEFT/RTGS/IMPS processing, refunds | aurora_payments | NPCI, Razorpay, Kafka |
| Portfolio Service | Holdings aggregation, returns calculation, projections | aurora_portfolio | FD + G-Sec services (async), Redis (cache) |
| Rate Aggregator | Bank rate crawling, G-Sec yield scraping, caching | aurora_rates | Partner APIs, web scrapers, Redis |
| Compliance Service | PMLA screening, CTR/STR, RBI returns | aurora_compliance | World-Check (sanctions), Kafka |
| Reconciliation Service | End-of-day reconciliation, discrepancy resolution | aurora_recon | Partner bank files, S3, Kafka |
| Notification Service | SMS, email, push, WhatsApp, in-app | aurora_notifications | Twilio, SendGrid, FCM, Kafka |
| Analytics Service | User behavior, portfolio analytics, personalization | Redshift | Kafka (events), S3 (exports) |
| Support Service | Tickets, chat, feedback, escalation | aurora_support | Zendesk integration, Elasticsearch |
Architecture Decisions and Trade-offs
Why microservices over a modular monolith? The regulatory landscape demands strict isolation between data stores — RBI data localization rules mean payment data must be segregated from investment data, and partner bank credentials must be isolated. Each service has its own database, preventing cross-service SQL joins but enabling independent scaling, deployment, and compliance certification. The FD Placement service experiences 10x load spikes during quarter-end rate revisions while the Analytics service runs heavy batch jobs; microservices allow independent scaling without wasting resources.
Why Kafka over SQS for the event backbone? The platform requires event replay capabilities for reconciliation (replaying the last 24 hours of events to rebuild state), consumer groups for parallel processing, and ordered delivery for transaction sequencing. Kafka provides all three with durability guarantees. SQS is used for dead-letter queues and retry logic where replay is not needed.
Why Aurora PostgreSQL over DynamoDB? Financial ledger systems demand ACID transactions with row-level locking for balance updates, complex joins for portfolio aggregation queries, and strong consistency for regulatory reporting. Aurora provides these with multi-AZ failover in under 30 seconds, point-in-time recovery, and logical replication for analytics. While DynamoDB offers superior horizontal scalability, the access patterns for financial ledgers are primarily relational (joins between entries, accounts, and transactions), making PostgreSQL the natural fit.
Why React Native for mobile? The platform must ship on both Android and iOS simultaneously. React Native allows sharing 80% of the codebase while still accessing native modules for critical features like biometric authentication, secure enclave access, and push notification handling. The 20% native code covers device-specific banking SDKs and security modules. Performance profiling shows that the React Native bridge adds less than 5ms of overhead per frame, which is acceptable for a financial dashboard application where 60fps rendering is not a requirement.
3. User Onboarding and KYC/AML Pipeline
User onboarding on a financial platform like Stable Money is not just about account creation — it is a multi-step verification pipeline that must satisfy regulatory mandates while minimizing drop-off. The RBI's KYC Master Direction requires every reporting entity to perform Customer Due Diligence (CDD) before establishing a business relationship. For digital onboarding, RBI allows eKYC using Aadhaar OTP verification, video KYC for higher-risk customers, and simplified due diligence for low-value accounts under certain thresholds. The platform must implement all three tiers while maintaining a seamless user experience that converts registrations into verified accounts.
The onboarding flow is designed as a state machine with defined transitions: PENDING_REGISTRATION, MOBILE_VERIFIED, PAN_VERIFIED, AADHAAR_EKYC_INITIATED, AADHAAR_EKYC_COMPLETED, BANK_ACCOUNT_LINKED, and FULLY_VERIFIED. Each state transition is recorded with timestamps, audit metadata, and the specific verification provider response. Users can place FDs up to certain limits at intermediate states (e.g., simplified due diligence allows FDs up to one lakh rupees after just PAN verification), but full verification unlocks unlimited investment capacity and government securities access. The state machine approach ensures that users can always resume from where they left off, and the system can detect and prevent attempts to skip required verification steps.
KYC Verification Pipeline Implementation
The KYC service is implemented as a pipeline of independent verification steps, each calling an external provider API. The pipeline is designed to be fault-tolerant — if the NSDL PAN verification API is temporarily down, the system queues the request and retries with exponential backoff without blocking other verification steps that do not depend on PAN verification. This pipeline architecture allows adding new verification steps (e.g., a new government ID verification provider) without modifying the core onboarding flow.
C#
public class KycVerificationPipeline
{
private readonly IKycStep[] _steps;
private readonly IKycStateStore _stateStore;
private readonly IEventPublisher _eventPublisher;
private readonly ILogger<KycVerificationPipeline> _logger;
public KycVerificationPipeline(
IKycStep[] steps,
IKycStateStore stateStore,
IEventPublisher eventPublisher,
ILogger<KycVerificationPipeline> logger)
{
_steps = steps.OrderBy(s => s.ExecutionOrder).ToArray();
_stateStore = stateStore;
_eventPublisher = eventPublisher;
_logger = logger;
}
public async Task<KycResult> ExecuteAsync(KycContext context)
{
var currentState = await _stateStore.GetStateAsync(context.UserId);
var result = new KycResult { UserId = context.UserId };
foreach (var step in _steps)
{
if (!step.ShouldExecute(currentState, context))
{
_logger.LogDebug("Skipping step {Step} for user {UserId}",
step.StepName, context.UserId);
continue;
}
_logger.LogInformation("Executing KYC step {Step} for user {UserId}",
step.StepName, context.UserId);
var stepResult = await ExecuteWithRetryAsync(step, context, maxRetries: 3);
await _stateStore.RecordStepResultAsync(context.UserId, step.StepName,
stepResult.Status, stepResult.ProviderResponse, DateTime.UtcNow);
if (stepResult.Status == KycStepStatus.Failed)
{
result.Status = KycStatus.Failed;
result.FailedStep = step.StepName;
result.FailureReason = stepResult.FailureReason;
await _eventPublisher.PublishAsync(new KycFailedEvent
{
UserId = context.UserId,
FailedStep = step.StepName,
Reason = stepResult.FailureReason,
Timestamp = DateTime.UtcNow
});
break;
}
if (stepResult.Status == KycStepStatus.RequiresManualReview)
{
result.Status = KycStatus.PendingManualReview;
result.PendingStep = step.StepName;
break;
}
currentState = AdvanceState(currentState, step.StepName);
}
if (result.Status == default)
{
result.Status = KycStatus.Completed;
result.FullyVerified = true;
await _eventPublisher.PublishAsync(new KycCompletedEvent
{
UserId = context.UserId,
CompletedAt = DateTime.UtcNow,
VerificationLevel = VerificationLevel.Full
});
}
await _stateStore.SaveFinalStateAsync(context.UserId, currentState, result);
return result;
}
private async Task<KycStepResult> ExecuteWithRetryAsync(
IKycStep step, KycContext context, int maxRetries)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return await step.ExecuteAsync(context);
}
catch (ProviderUnavailableException ex) when (attempt < maxRetries)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt) * 5);
_logger.LogWarning(ex,
"Provider {Provider} unavailable, retrying in {Delay}s",
step.StepName, delay.TotalSeconds);
await Task.Delay(delay);
}
}
throw new KycPipelineException(
$"Step {step.StepName} failed after {maxRetries} retries");
}
private KycState AdvanceState(KycState current, string stepName)
{
return stepName switch
{
"MobileVerification" => KycState.MobileVerified,
"PanVerification" => KycState.PanVerified,
"AadhaarEkc" => KycState.AadhaarCompleted,
"BankAccountLink" => KycState.BankLinked,
"FinalReview" => KycState.FullyVerified,
_ => current
};
}
}
PAN Verification via NSDL
PAN verification is a critical first step because it establishes the user's tax identity and is required for TDS deduction on interest income. The platform integrates with NSDL's e-PAN API to verify the name on the PAN card against the name provided during registration. This is a critical step — any mismatch must trigger manual review because PAN name mismatches are a common indicator of identity fraud. The verification returns the name as printed on the PAN card, the PAN status (active/inactive/deactivated), and the date of issuance. The system stores the verified name hash for future audit purposes. The PAN verification also checks the PAN category — only individual PANs are allowed for personal FD investments; HUF and corporate PANs trigger a separate onboarding flow.
Aadhaar eKYC via DigiLocker
Aadhaar eKYC leverages UIDAI's OTP-based verification through the DigiLocker platform. When a user initiates eKYC, the system generates a request to DigiLocker, which in turn sends an OTP to the mobile number registered with Aadhaar. Upon successful OTP verification, the platform receives the user's demographic data (name, date of birth, gender, address) and photograph directly from UIDAI. This data is stored encrypted at rest using AES-256 with keys managed through AWS KMS. The Aadhaar number itself is masked in the database — only the last four digits are stored for reference, with a SHA-256 hash for deduplication. This approach satisfies the UIDAI's data minimization requirements while still allowing the platform to detect duplicate accounts.
Bank Account Verification — Penny Drop
Bank account verification uses the penny drop method: the platform initiates a one-rupee NEFT transfer to the user's provided bank account with a specific remark code. If the transfer succeeds, the beneficiary name returned by the bank is matched against the user's verified PAN name. A fuzzy match score above 85% is considered a pass; scores between 70% and 85% trigger manual review (account for common transliteration differences in Indian names); below 70% is an automatic rejection. This process typically completes within 2-4 hours for most banks, though some cooperative banks may take up to 24 hours. The penny drop also validates that the account is active and can receive electronic credits, which is essential for maturity payouts.
Video KYC for High-Value Accounts
For users whose investment portfolio exceeds ₹2,00,000 or who require enhanced due diligence under PMLA, the platform initiates a Video KYC (V-CIP) process. This involves a live video call with a trained KYC agent who verifies the user's identity documents in real-time, captures a live photograph for liveness detection, and records the user's verbal confirmation of key details. The video is stored encrypted on S3 with a retention period of 5 years as per RBI requirements. The V-CIP process must complete within 5 minutes of initiation to prevent session hijacking, and the agent must be a full-time employee of Stable Money (RBI requirement — cannot be outsourced).
PMLA Compliance — Transaction Monitoring
The Prevention of Money Laundering Act (PMLA) requires continuous monitoring of user transactions. The compliance service maintains a real-time rule engine that evaluates every transaction against a set of configurable thresholds and patterns. Suspicious transactions are flagged and reported to the Financial Intelligence Unit (FIU-IND) as Suspicious Transaction Reports (STRs) within the mandated timeline. Cash transaction reports (CTRs) are generated for any single transaction or series of linked transactions exceeding fifty thousand rupees in a single day. The monitoring system processes over 10 million events daily, with a false positive rate of approximately 3% that feeds into a manual review queue staffed by certified compliance analysts.
4. Bank Partner Integration Layer
The bank partner integration layer is the nervous system of the Stable Money platform. It manages communication with 30+ partner banks, each with different API specifications, data formats, authentication mechanisms, rate limits, and SLA characteristics. Some partner banks expose modern REST APIs with OAuth 2.0 authentication, while others still require SFTP file exchange with PGP encryption, and a few mandate direct database connectivity through VPN tunnels. The integration layer abstracts these differences behind a unified interface that the FD Placement service and Payment service interact with uniformly.
Each bank partner is modeled as a BankPartnerAdapter that implements a common interface. The adapter encapsulates the bank-specific authentication flow, request transformation, response parsing, error mapping, and retry logic. A BankPartnerRouter selects the appropriate adapter based on the user's selected bank, the deposit amount (some banks have minimum/maximum thresholds), the desired tenure (not all banks offer all tenures), and real-time rate comparisons. The router also considers the bank's current system health — if a bank's API is experiencing latency above the configured threshold, the router may deprioritize it in favor of alternatives. This dynamic routing ensures that users always get the best available rate from a healthy bank, even when individual partner systems are experiencing issues.
The adapter pattern also handles the significant heterogeneity in banking API response formats. Some banks return JSON responses, others return XML (SOAP), and a few return custom delimited text formats. The adapter normalizes all responses to a common internal model before passing them upstream. Error handling is equally diverse — some banks return HTTP 200 with an error code in the response body, others return proper HTTP error codes, and a few return HTML error pages when their systems are under stress. Each adapter implements bank-specific error classification that maps to common error categories (insufficient funds, account frozen, system unavailable, rate expired) used by the rest of the platform.
C#
public interface IBankPartnerAdapter
{
string BankId { get; }
string BankName { get; }
Task<BankRateResponse> GetRatesAsync(RateQuery query);
Task<FdPlacementResponse> PlaceFixedDepositAsync(FdPlacementRequest request);
Task<FdStatusResponse> GetFdStatusAsync(string partnerRefId);
Task<FdMaturityResponse> GetMaturityDetailsAsync(string partnerRefId);
Task<WithdrawalResponse> InitiateWithdrawalAsync(WithdrawalRequest request);
Task<bool> HealthCheckAsync();
Task<ReconciliationFile> GetReconciliationFileAsync(DateOnly date);
}
public class BankPartnerRouter
{
private readonly Dictionary<string, IBankPartnerAdapter> _adapters;
private readonly IBankHealthMonitor _healthMonitor;
private readonly IRateCache _rateCache;
private readonly ILogger<BankPartnerRouter> _logger;
public BankPartnerRouter(
IEnumerable<IBankPartnerAdapter> adapters,
IBankHealthMonitor healthMonitor,
IRateCache rateCache,
ILogger<BankPartnerRouter> logger)
{
_adapters = adapters.ToDictionary(a => a.BankId);
_healthMonitor = healthMonitor;
_rateCache = rateCache;
_logger = logger;
}
public async Task<IReadOnlyList<BankCandidate>> GetEligibleBanksAsync(
FdSearchCriteria criteria)
{
var candidates = new List<BankCandidate>();
foreach (var adapter in _adapters.Values)
{
var health = await _healthMonitor.GetHealthAsync(adapter.BankId);
if (health.Status == HealthStatus.Down)
{
_logger.LogWarning(
"Bank {Bank} is down, excluding from candidates",
adapter.BankName);
continue;
}
var rates = await _rateCache.GetLatestRatesAsync(adapter.BankId);
if (rates == null)
{
_logger.LogWarning(
"No cached rates for {Bank}, attempting live fetch",
adapter.BankName);
try
{
rates = await adapter.GetRatesAsync(new RateQuery
{
Amount = criteria.Amount,
TenureDays = criteria.TenureDays
});
await _rateCache.SetRatesAsync(adapter.BankId, rates,
TimeSpan.FromMinutes(15));
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to fetch rates from {Bank}", adapter.BankName);
continue;
}
}
if (!IsRateEligible(rates, criteria)) continue;
var dicgcEligible = criteria.Amount <= 500_000
|| criteria.SplitAcrossBanks;
candidates.Add(new BankCandidate
{
BankId = adapter.BankId,
BankName = adapter.BankName,
AnnualizedRate = rates.ApplicableRate,
EffectiveYield = CalculateEffectiveYield(rates, criteria),
DicgcCovered = dicgcEligible,
MaxTenureDays = rates.AvailableTenures.Max(),
MinAmount = rates.MinimumDeposit,
HealthScore = health.LatencyScore,
ProcessingFee = rates.ProcessingFee
});
}
return candidates
.OrderByDescending(c => c.EffectiveYield)
.ThenByDescending(c => c.HealthScore)
.ToList();
}
public async Task<FdPlacementResult> PlaceFdAsync(
string bankId, FdPlacementRequest request)
{
if (!_adapters.TryGetValue(bankId, out var adapter))
throw new BankNotSupportedException(bankId);
var health = await _healthMonitor.GetHealthAsync(bankId);
if (health.Status == HealthStatus.Down)
throw new BankUnavailableException(bankId,
"Bank is currently unavailable for FD placement");
using var timeout = new CancellationTokenSource(
TimeSpan.FromSeconds(30));
return await adapter.PlaceFixedDepositAsync(request);
}
private double CalculateEffectiveYield(
BankRateResponse rates, FdSearchCriteria criteria)
{
var annualRate = rates.ApplicableRate / 100.0;
var tenureYears = criteria.TenureDays / 365.0;
var effectiveYield = Math.Pow(1 + annualRate / 4, 4 * tenureYears) - 1;
return effectiveYield * 100;
}
}
Bank API Authentication Patterns
| Bank Category | Auth Method | Credential Rotation | Typical SLA |
|---|---|---|---|
| Modern Private Banks | OAuth 2.0 + JWT | Every 90 days, auto-rotated | 99.9%, p99 < 500ms |
| Large PSBs | API Key + HMAC Signature | Every 180 days, manual | 99.5%, p99 < 2s |
| Small Finance Banks | mTLS + Client Certificates | Annual, manual rotation | 99.0%, p99 < 5s |
| Cooperative Banks | SFTP + PGP Encrypted Files | Annual, manual | 95.0%, batch only |
| RBI Retail Direct (G-Sec) | OAuth 2.0 + Digital Signature | Per RBI guidelines | 99.9%, p99 < 1s |
Circuit Breaker and Bulkhead Patterns
Each bank adapter runs behind a circuit breaker that tracks the success/failure rate over a sliding window of 60 seconds. If the failure rate exceeds 50% or if there are more than 10 consecutive failures, the circuit opens and subsequent requests are immediately rejected without hitting the bank's API. The circuit transitions to a half-open state after 30 seconds and allows a single probe request. If the probe succeeds, the circuit closes; if it fails, the circuit reopens with an exponential backoff up to 5 minutes. A bulkhead pattern isolates each bank adapter in its own thread pool so that a slow bank cannot consume all available threads and starve requests to other banks. This is implemented using Polly's BulkheadIsolation policy with a maximum concurrency of 20 per bank adapter and a queue of 50 pending requests.
The integration layer also implements a canary deployment strategy for bank partner changes. When upgrading a bank adapter (e.g., to support a new API version), the system routes 10% of requests to the new version while monitoring success rates, latency, and error patterns. If the canary shows no degradation over a 4-hour window, the rollout proceeds to 50% and then 100%. If issues are detected, the system automatically rolls back to the previous version within 60 seconds. This approach is critical because bank partner integration bugs can directly result in financial loss — a failed FD placement that still debits user funds, or a successful placement recorded at the wrong interest rate.
Partner Bank SLA Monitoring
The health monitoring system tracks several metrics per bank partner: API response time (p50, p95, p99), success rate, timeout rate, and error distribution. These metrics are collected in real-time and published to a dedicated Grafana dashboard visible to the operations team. When a bank's p99 latency exceeds 5 seconds for more than 10 minutes, an automatic alert is raised and the bank's weight in the routing algorithm is reduced by 50%. When the success rate drops below 90%, the bank is temporarily removed from the routing pool and the operations team is paged. The system also tracks scheduled maintenance windows announced by bank partners and preemptively reduces traffic during those periods.
5. Fixed Deposit and Government Securities Engine
The FD and Government Securities engine is the core revenue-generating component of Stable Money. It manages the complete lifecycle of fixed deposits — from order placement through partner bank APIs, through accrual of interest during the deposit tenure, to maturity processing and payout. For government securities, the engine handles bidding in RBI auctions, secondary market trades, and coupon payments. The engine must handle a wide variety of product types: standard fixed deposits, tax-saver FDs (5-year lock-in under Section 80C), flexi deposits, recurring deposits, Treasury Bills (91-day, 182-day, 364-day), Government Securities (medium to long-term), and State Development Loans.
The FD lifecycle is modeled as a state machine with well-defined transitions and events at each boundary. When a user initiates an FD placement, the system first validates the user's KYC status, available balance, and compliance clearance. It then creates a pending order in the fd_orders table, initiates the payment, and upon successful payment confirmation, sends the placement request to the partner bank. The partner bank responds with a confirmation or rejection. On confirmation, the FD is moved to ACTIVE status and interest accrual begins. The system calculates daily accrued interest using the compound interest formula (quarterly compounding for most Indian banks) and records it in the ledger. This state machine approach ensures that every FD goes through a well-defined lifecycle with proper auditing at each stage.
C#
public class FixedDepositEngine
{
private readonly IFdOrderRepository _orderRepo;
private readonly IFdAccrualService _accrualService;
private readonly IMaturityService _maturityService;
private readonly IEventPublisher _events;
private readonly ILogger<FixedDepositEngine> _logger;
public async Task<FdOrderResult> PlaceFixedDepositAsync(
PlaceFdCommand command)
{
await ValidatePreconditionsAsync(command);
var existingOrder = await _orderRepo.GetByIdempotencyKeyAsync(
command.IdempotencyKey);
if (existingOrder != null)
{
_logger.LogInformation(
"Duplicate FD request detected, returning existing order {OrderId}",
existingOrder.Id);
return MapToResult(existingOrder);
}
var order = new FdOrder
{
Id = Guid.NewGuid(),
UserId = command.UserId,
BankId = command.BankId,
Amount = command.Amount,
TenureDays = command.TenureDays,
AnnualizedRate = command.AnnualizedRate,
ProductType = command.ProductType,
IdempotencyKey = command.IdempotencyKey,
Status = FdOrderStatus.PendingPayment,
CreatedAt = DateTime.UtcNow,
MaturityDate = CalculateMaturityDate(
DateTime.UtcNow, command.TenureDays),
ExpectedMaturityAmount = CalculateMaturityAmount(
command.Amount, command.AnnualizedRate, command.TenureDays),
TaxSaver = command.ProductType == FdProductType.TaxSaver
};
await _orderRepo.SaveAsync(order);
var paymentResult = await ProcessPaymentAsync(order);
if (!paymentResult.Success)
{
order.Status = FdOrderStatus.PaymentFailed;
await _orderRepo.UpdateAsync(order);
return new FdOrderResult
{
Success = false,
OrderId = order.Id,
Error = paymentResult.Error
};
}
order.Status = FdOrderStatus.PendingBankConfirmation;
await _orderRepo.UpdateAsync(order);
var bankResult = await PlaceWithBankAsync(order);
if (!bankResult.Success)
{
order.Status = FdOrderStatus.BankRejected;
order.BankRejectionReason = bankResult.Reason;
await _orderRepo.UpdateAsync(order);
await RefundPaymentAsync(order);
return new FdOrderResult
{
Success = false,
OrderId = order.Id,
Error = $"Bank rejected: {bankResult.Reason}"
};
}
order.Status = FdOrderStatus.Active;
order.PartnerRefId = bankResult.PartnerRefId;
order.ActivatedAt = DateTime.UtcNow;
await _orderRepo.UpdateAsync(order);
await _accrualService.ScheduleDailyAccrualAsync(order.Id);
await _maturityService.ScheduleMaturityProcessingAsync(
order.Id, order.MaturityDate);
await _events.PublishAsync(new FdActivatedEvent
{
OrderId = order.Id,
UserId = order.UserId,
BankId = order.BankId,
Amount = order.Amount,
AnnualizedRate = order.AnnualizedRate,
MaturityDate = order.MaturityDate,
ExpectedMaturityAmount = order.ExpectedMaturityAmount
});
return new FdOrderResult
{
Success = true,
OrderId = order.Id,
PartnerRefId = bankResult.PartnerRefId,
MaturityDate = order.MaturityDate,
ExpectedMaturityAmount = order.ExpectedMaturityAmount
};
}
private decimal CalculateMaturityAmount(
decimal principal, decimal annualRate, int tenureDays)
{
var quarterlyRate = annualRate / 4 / 100;
var quarters = tenureDays / 90.0;
return principal * (decimal)Math.Pow(
(double)(1 + quarterlyRate), quarters);
}
private DateTime CalculateMaturityDate(
DateTime startDate, int tenureDays)
{
var maturity = startDate.AddDays(tenureDays);
if (maturity.DayOfWeek == DayOfWeek.Saturday)
maturity = maturity.AddDays(2);
else if (maturity.DayOfWeek == DayOfWeek.Sunday)
maturity = maturity.AddDays(1);
return maturity;
}
}
Government Securities — RBI Retail Direct Integration
For government securities, Stable Money integrates with the RBI Retail Direct (RIRD) platform, which enables retail investors to directly invest in G-Secs. The RIRD integration handles account opening on the RIRD platform (linked to the user's Stable Money account), bidding in primary auctions, placing orders in the secondary market, receiving coupon payments, and handling maturity proceeds. G-Sec investments are settled on a T+1 basis for primary auctions and T+1 for secondary market trades through the NDS-OM platform. The system must maintain digital signature certificates (DSC) for each user who opts into G-Sec investments, as RBI mandates DSC-based authentication for all G-Sec transactions. The DSC is stored in a hardware security module (HSM) operated by a licensed certifying authority, and signing operations are performed server-side without ever exposing the private key.
Interest Accrual and Maturity Processing
Interest accrual runs as a daily batch job that processes all active FDs. For each FD, the system calculates the accrued interest from the last accrual date to today using the applicable compounding formula. Indian banks typically compound interest quarterly, so the daily accrual is calculated as: daily_accrual = principal * (annual_rate/100) / 365, but the compounded amount posted to the user's account is calculated at quarterly intervals. The system maintains both the daily accrual (for display purposes) and the quarterly compounded posting (for actual interest credit) separately in the ledger. This dual tracking allows users to see their interest growing daily on the dashboard while the actual interest credit follows the bank's compounding schedule.
Maturity processing runs as a scheduled job that triggers 3 days before the actual maturity date. The system sends the user a notification with renewal options: (1) full principal plus interest payout to linked bank account, (2) auto-renewal for the same tenure at the prevailing rate, (3) split reinvestment across multiple banks for DICGC optimization, or (4) partial withdrawal with the remainder renewed. If the user does not respond, the default action is configurable per user preference (default is full payout). The maturity proceeds are credited to the linked bank account via NEFT/RTGS, with UPI available for amounts under two lakh rupees. For tax-saver FDs (5-year lock-in), the system enforces the premature withdrawal restriction and only allows maturity payout at the end of the 5-year term.
Product Comparison Table
| Product | Min Investment | Max Investment | Tenure Range | Rate Range | DICGC Cover |
|---|---|---|---|---|---|
| Standard FD | ₹1,000 | ₹2,00,00,000 | 7 days - 10 years | 3.5% - 8.5% | Up to ₹5L/bank |
| Tax Saver FD | ₹10,000 | ₹1,50,000 | 5 years (fixed) | 6.5% - 7.5% | Up to ₹5L/bank |
| Flexi Deposit | ₹5,000 | ₹50,00,000 | 1-3 years | 5.0% - 7.0% | Up to ₹5L/bank |
| Recurring Deposit | ₹500/month | ₹50,000/month | 6 months - 10 years | 5.5% - 7.5% | Up to ₹5L/bank |
| 91-Day T-Bill | ₹10,000 | No limit | 91 days | 6.5% - 7.5% | Sovereign |
| Government Security | ₹10,000 | No limit | 4-40 years | 7.0% - 8.5% | Sovereign |
| State Development Loan | ₹10,000 | No limit | 3-30 years | 7.5% - 8.8% | Sovereign (State) |
DICGC Coverage Optimization Algorithm
The DICGC optimization algorithm is a constraint-satisfaction solver that recommends how users should distribute their deposits across banks to maximize insurance coverage. Given a total investment amount and a set of eligible banks with their current rates, the algorithm solves: maximize total coverage subject to per-bank limit of ₹5,00,000 (including projected interest) and total investment constraint. This is modeled as a variant of the bounded knapsack problem and solved using dynamic programming for small instances (fewer than 20 banks) and a greedy heuristic for larger instances. The algorithm runs whenever a user's portfolio crosses the ₹4,00,000 threshold in any single bank and generates a recommendation to redistribute the excess to other banks.
6. Real-Time Interest Rate Aggregation
The rate aggregation service is a critical differentiator for Stable Money — it continuously monitors and aggregates interest rates from all partner banks and government securities markets, enabling users to find the best available rates across the entire ecosystem. Interest rates on fixed deposits are not static; they change frequently based on RBI monetary policy decisions, interbank liquidity conditions, bank-specific deposit mobilization needs, and competitive pressure. The rate aggregation service must capture these changes in near-real-time and reflect them in the user-facing dashboard without requiring a full page refresh.
The service uses a multi-source rate ingestion pipeline. For partner banks that expose APIs, the service polls rates every 15 minutes (configurable per bank) and stores the latest rates in a Redis cache with a TTL matching the polling interval. For banks that publish rates on their websites, the service uses headless browser scrapers (Puppeteer-based) running on a scheduled basis every 4 hours. For government securities, the service subscribes to the RBI's real-time data feed for G-Sec yields and T-Bill auction results. All rate data flows through a Kafka topic (raw-rates), gets processed by a stream processor that normalizes rates across different compounding conventions, and lands in a normalized-rates topic consumed by the aggregation service. This multi-source approach ensures that rate data remains fresh even when individual bank APIs are temporarily unavailable.
Rate Normalization
One of the most complex aspects of rate aggregation is normalization. Different banks quote rates differently — some use annual percentage rate (APR), some use annualized percentage yield (APY), and some use simple interest for short-tenure deposits. The normalization service converts all rates to a standard APY metric using the formula: APY = (1 + r/n)^n - 1, where r is the nominal annual rate and n is the number of compounding periods per year. This allows users to compare rates across banks on an apples-to-apples basis. The normalization also accounts for special schemes like senior citizen premium rates (typically 0.25% to 0.50% additional), NRI-specific rates, and promotional rates with limited validity.
C#
public class RateNormalizer
{
private static readonly Dictionary<CompoundingFrequency, int> CompoundingMap = new()
{
{ CompoundingFrequency.Annually, 1 },
{ CompoundingFrequency.SemiAnnually, 2 },
{ CompoundingFrequency.Quarterly, 4 },
{ CompoundingFrequency.Monthly, 12 },
{ CompoundingFrequency.Daily, 365 },
{ CompoundingFrequency.SimpleInterest, 0 }
};
public NormalizedRate Normalize(RawBankRate rawRate)
{
var annualRate = rawRate.NominalAnnualRate / 100m;
int compoundingN = CompoundingMap[rawRate.CompoundingFrequency];
decimal apy;
if (compoundingN == 0)
{
apy = annualRate;
}
else
{
apy = (decimal)Math.Pow(
(double)(1 + annualRate / compoundingN), compoundingN) - 1m;
}
var tenureYears = rawRate.TenureDays / 365m;
var effectiveYield = compoundingN == 0
? annualRate * tenureYears
: (decimal)Math.Pow(
(double)(1 + annualRate / compoundingN),
(double)(compoundingN * tenureYears)) - 1m;
return new NormalizedRate
{
BankId = rawRate.BankId,
BankName = rawRate.BankName,
ProductType = rawRate.ProductType,
TenureDays = rawRate.TenureDays,
NominalAnnualRate = rawRate.NominalAnnualRate,
NormalizedAPY = apy * 100,
EffectiveYield = effectiveYield * 100,
CompoundingFrequency = rawRate.CompoundingFrequency,
LastUpdated = rawRate.Timestamp,
IsStale = DateTime.UtcNow - rawRate.Timestamp >
TimeSpan.FromHours(4),
Source = rawRate.Source
};
}
public IReadOnlyList<NormalizedRate> FindBestRates(
IReadOnlyList<NormalizedRate> allRates,
decimal amount, int tenureDays, int maxResults = 10)
{
return allRates
.Where(r =>
r.TenureDays == tenureDays &&
!r.IsStale &&
r.EffectiveYield > 0)
.OrderByDescending(r => r.EffectiveYield)
.Take(maxResults)
.ToList();
}
}
Rate Caching Strategy
| Data Type | Cache Layer | TTL | Refresh Strategy |
|---|---|---|---|
| Partner bank rates | Redis (hot) + PostgreSQL (cold) | 15 min (Redis) | Background poller every 15 min |
| G-Sec yields | Redis (hot) + PostgreSQL (cold) | 5 min (Redis) | RBI data feed, near-real-time |
| T-Bill auction results | PostgreSQL | Until next auction | RBI auction calendar events |
| Rate comparison results | Redis | 10 min | On-demand + scheduled refresh |
| Historical rate data | S3 (Parquet) + Redshift | Permanent | Nightly ETL job |
Rate Alert System
Users can set rate alerts that trigger when a specific bank's rate crosses a threshold or when the best available rate for a given tenure exceeds a target. The alert engine runs as a Kafka Streams application that evaluates incoming rate updates against stored alert conditions in real-time. When a condition is met, the system publishes a RateAlertTriggered event to the notification service for delivery via push notification, SMS, or email depending on user preference. The alert is deduplicated to prevent spam — once an alert fires for a given bank and tenure combination, it enters a 24-hour cooldown period before it can fire again for the same conditions. The alert system processes approximately 50,000 alert evaluations per minute during peak rate-change periods (typically following RBI policy announcements).
7. Portfolio Management and Allocation Service
The portfolio management service aggregates all of a user's investments across partner banks and government securities into a unified dashboard. It calculates real-time portfolio value, projected maturity amounts, interest earned to date, current yield, and DICGC coverage across banks. The service also provides smart allocation recommendations — for example, if a user has ₹8 lakh in a single bank's FD, the system recommends splitting it into two banks to maximize DICGC coverage (which covers up to ₹5 lakh per bank). These recommendations are generated by a rule-based engine that continuously evaluates portfolio state against optimization criteria.
The portfolio view is built using the CQRS pattern. Write operations (FD placement, maturity, interest accrual) update the primary database. Read operations (portfolio dashboard, analytics, reports) query a materialized view that is updated via Change Data Capture (CDC) from the primary database to a read-optimized Elasticsearch cluster. This separation ensures that heavy dashboard queries do not impact transaction processing performance. The materialized view is eventually consistent with a typical lag of 2-5 seconds, which is acceptable for portfolio display purposes. For operations requiring strong consistency (e.g., displaying the balance for a payment initiation), the system queries the primary database directly.
C#
public class PortfolioService
{
private readonly IPortfolioReadStore _readStore;
private readonly IFdOrderRepository _fdRepo;
private readonly IGSecHoldingRepository _gsecRepo;
private readonly IRateCache _rateCache;
private readonly IDicgcAnalyzer _dicgcAnalyzer;
public async Task<PortfolioSummary> GetPortfolioSummaryAsync(
string userId)
{
var holdings = await _readStore.GetUserHoldingsAsync(userId);
var fdHoldings = holdings.Where(h =>
h.Type == HoldingType.FixedDeposit).ToList();
var gsecHoldings = holdings.Where(h =>
h.Type == HoldingType.GovernmentSecurity).ToList();
var summary = new PortfolioSummary
{
UserId = userId,
TotalInvested = holdings.Sum(h => h.PrincipalAmount),
CurrentValue = holdings.Sum(h => h.CurrentValue),
TotalInterestEarned = holdings.Sum(h =>
h.AccruedInterest),
ProjectedMaturityValue = holdings.Sum(h =>
h.ExpectedMaturityAmount),
TotalInterestPending = holdings.Sum(h =>
h.ExpectedMaturityAmount - h.PrincipalAmount),
AnnualizedPortfolioYield = CalculatePortfolioYield(holdings),
ActiveFdCount = fdHoldings.Count,
ActiveGSecCount = gsecHoldings.Count,
UpcomingMaturities = holdings
.Where(h => h.MaturityDate <= DateTime.UtcNow.AddDays(30))
.OrderBy(h => h.MaturityDate)
.Select(MapToMaturityInfo)
.ToList(),
BankDistribution = CalculateBankDistribution(fdHoldings),
ProductDistribution = CalculateProductDistribution(holdings),
TaxImpact = await CalculateTaxImpactAsync(userId, holdings),
DicgcCoverage = await _dicgcAnalyzer.AnalyzeCoverageAsync(
fdHoldings)
};
return summary;
}
public async Task<IReadOnlyList<AllocationRecommendation>>
GetAllocationRecommendationsAsync(string userId)
{
var holdings = await _readStore.GetUserHoldingsAsync(userId);
var recommendations = new List<AllocationRecommendation>();
var bankDistribution = holdings
.Where(h => h.Type == HoldingType.FixedDeposit)
.GroupBy(h => h.BankId);
foreach (var bankGroup in bankDistribution)
{
var totalInBank = bankGroup.Sum(h => h.PrincipalAmount);
if (totalInBank > 450_000m)
{
var excess = totalInBank - 500_000m;
recommendations.Add(new AllocationRecommendation
{
Type = RecommendationType.DicgcOptimization,
Priority = Priority.High,
Title = "DICGC Coverage Gap Detected",
Description = $"You have {totalInBank:N0} in " +
$"{bankGroup.Key}. DICGC covers up to " +
"₹5,00,000 per bank. Consider splitting " +
$"₹{excess:N0} to another bank.",
PotentialBenefit = $"Up to ₹{excess:N0} " +
"additional DICGC coverage",
ActionUrl = "/portfolio/rebalance"
});
}
}
var currentRates = holdings
.GroupBy(h => h.BankId)
.ToDictionary(g => g.Key, g =>
g.First().AnnualizedRate);
var bestRates = await _rateCache.GetBestRatesAsync(
holdings.Select(h => h.TenureDays).Distinct());
foreach (var holding in holdings.Where(h =>
h.Type == HoldingType.FixedDeposit &&
h.MaturityDate <= DateTime.UtcNow.AddDays(60)))
{
var bestRate = bestRates
.Where(r => r.TenureDays == holding.TenureDays)
.OrderByDescending(r => r.Rate)
.FirstOrDefault();
if (bestRate != null &&
bestRate.Rate > holding.AnnualizedRate + 0.25m)
{
recommendations.Add(new AllocationRecommendation
{
Type = RecommendationType.RateOptimization,
Priority = Priority.Medium,
Title = $"Higher Rate Available at {bestRate.BankName}",
Description = $"Your current FD at {holding.BankName} " +
$"offers {holding.AnnualizedRate}%. {bestRate.BankName} " +
$"now offers {bestRate.Rate}% for the same tenure.",
ActionUrl = $"/reinvest/{holding.Id}"
});
}
}
return recommendations.OrderByDescending(r => r.Priority).ToList();
}
}
Portfolio Analytics Metrics
| Metric | Calculation Method | Update Frequency | Use Case |
|---|---|---|---|
| Weighted Avg Yield | Sum(rate * principal) / total principal | On every rate change | Portfolio performance comparison |
| Portfolio Duration | Sum(tenure * principal) / total principal | Daily | Interest rate risk assessment |
| Liquidity Score | % of portfolio maturing within 30 days | Daily | Emergency fund planning |
| HHI Concentration | Sum(market_share_i^2) across banks | On every FD change | DICGC risk detection |
| Tax Efficiency | After-tax yield optimization model | Quarterly | Tax saving recommendations |
| Real Return | Nominal yield - inflation rate | Monthly | Purchasing power tracking |
Tax Impact Calculator
The tax impact calculator is a critical feature for Indian investors, as interest income from FDs is fully taxable at the investor's income tax slab rate. The calculator estimates the user's annual interest income across all FDs, applies the applicable TDS rate (10% if PAN is provided, 20% if PAN is not provided), and projects the post-tax return for each FD. For users in higher tax slabs (30%+), the system recommends tax-saver FDs under Section 80C (up to ₹1.5 lakh deduction) and government securities where capital gains may be taxed more favorably. The calculator also factors in the surcharge and cess applicable to high-income investors and provides a comprehensive tax summary at the end of each financial year.
8. Payment Processing — UPI, NEFT, IMPS, Net Banking
Payment processing on Stable Money handles two primary flows: (1) inbound payments — users transferring money from their linked bank accounts to invest in FDs or G-Secs, and (2) outbound payments — maturity proceeds, interest credits, and withdrawal refunds being credited back to users' bank accounts. The platform supports UPI (via NPCI integration), NEFT, RTGS, IMPS, and net banking. UPI is the primary payment method for amounts under two lakh rupees due to its instant settlement, zero transaction fees for users, and high success rates. NEFT and RTGS are used for larger amounts and bulk maturity payouts. The payment service processes approximately ₹500 crore in transaction volume monthly, with UPI accounting for 70% of inbound transactions by volume.
The payment service is built on the principle of idempotent, exactly-once processing. Every payment request carries a unique idempotency key generated from the order ID and a monotonic sequence number. The service checks this key against a distributed idempotency store (Redis with 24-hour TTL backed by PostgreSQL for persistence) before processing any payment. If a duplicate key is detected, the existing payment status is returned without initiating a new transaction. This prevents double-charging in case of network retries, user double-clicks, or system restarts during payment processing. The idempotency check is performed atomically using Redis SETNX with a TTL, ensuring that even concurrent requests for the same payment are handled correctly.
The payment flow for an FD placement is: (1) User selects an FD and clicks "Invest Now", (2) The system creates a pending FD order and a pending payment record, (3) The user selects a payment method (UPI, NEFT, etc.), (4) For UPI, the system sends a collect request to the user's VPA; for NEFT/IMPS, it provides the platform's bank account details, (5) The user completes the payment on their banking app, (6) The payment gateway sends a callback to the platform, (7) The platform verifies the callback signature, updates the payment status, and if successful, proceeds to place the FD with the partner bank, (8) The user sees a confirmation screen with the FD details. The entire flow from payment initiation to confirmation typically completes within 30 seconds for UPI and 2-4 hours for NEFT.
C#
public class PaymentService
{
private readonly IPaymentRepository _paymentRepo;
private readonly IIdempotencyStore _idempotencyStore;
private readonly IUpiGateway _upiGateway;
private readonly INeftRtgsGateway _neftGateway;
private readonly IImpsGateway _impsGateway;
private readonly IEventPublisher _events;
private readonly ILogger<PaymentService> _logger;
public async Task<PaymentResult> ProcessInvestmentPaymentAsync(
InvestmentPaymentCommand command)
{
var existingPayment = await _idempotencyStore
.GetPaymentAsync(command.IdempotencyKey);
if (existingPayment != null)
{
_logger.LogInformation(
"Duplicate payment detected for key {Key}, returning status {Status}",
command.IdempotencyKey, existingPayment.Status);
return MapToResult(existingPayment);
}
var payment = new PaymentRecord
{
Id = Guid.NewGuid(),
UserId = command.UserId,
OrderId = command.OrderId,
Amount = command.Amount,
PaymentMethod = command.PaymentMethod,
IdempotencyKey = command.IdempotencyKey,
Status = PaymentStatus.Initiated,
CreatedAt = DateTime.UtcNow
};
await _paymentRepo.SaveAsync(payment);
await _idempotencyStore.StoreAsync(
command.IdempotencyKey, payment);
try
{
PaymentGatewayResponse gatewayResponse;
switch (command.PaymentMethod)
{
case PaymentMethod.UPI:
gatewayResponse = await ProcessUpiPaymentAsync(
payment, command);
break;
case PaymentMethod.NEFT:
gatewayResponse = await ProcessNeftPaymentAsync(
payment, command);
break;
case PaymentMethod.IMPS:
gatewayResponse = await ProcessImpsPaymentAsync(
payment, command);
break;
case PaymentMethod.NetBanking:
gatewayResponse = await ProcessNetBankingAsync(
payment, command);
break;
default:
throw new NotSupportedException(
$"Payment method {command.PaymentMethod} not supported");
}
payment.Status = gatewayResponse.Success
? PaymentStatus.Completed
: PaymentStatus.Failed;
payment.GatewayRefId = gatewayResponse.GatewayReferenceId;
payment.FailureReason = gatewayResponse.FailureReason;
payment.CompletedAt = DateTime.UtcNow;
await _paymentRepo.UpdateAsync(payment);
if (payment.Status == PaymentStatus.Completed)
{
await _events.PublishAsync(new PaymentCompletedEvent
{
PaymentId = payment.Id,
UserId = payment.UserId,
OrderId = payment.OrderId,
Amount = payment.Amount,
PaymentMethod = payment.PaymentMethod,
CompletedAt = payment.CompletedAt
});
}
return MapToResult(payment);
}
catch (Exception ex)
{
payment.Status = PaymentStatus.Error;
payment.FailureReason = ex.Message;
await _paymentRepo.UpdateAsync(payment);
_logger.LogError(ex,
"Payment {PaymentId} encountered error", payment.Id);
throw;
}
}
private async Task<PaymentGatewayResponse> ProcessUpiPaymentAsync(
PaymentRecord payment, InvestmentPaymentCommand command)
{
var upiRequest = new UpiCollectRequest
{
Amount = payment.Amount,
PayeeVpa = GetPlatformVpa(),
PayerVpa = command.UpiVpa,
TransactionNote = $"Stable Money FD #{payment.OrderId}",
IdempotencyKey = payment.IdempotencyKey,
ExpiryMinutes = 10
};
return await _upiGateway.CollectPaymentAsync(upiRequest);
}
public async Task<PaymentResult> ProcessMaturityPayoutAsync(
MaturityPayoutCommand command)
{
var idempotencyKey = $"MAT-{command.FdOrderId}-{command.PayoutSequence}";
var existing = await _idempotencyStore.GetPaymentAsync(idempotencyKey);
if (existing != null) return MapToResult(existing);
var payment = new PaymentRecord
{
Id = Guid.NewGuid(),
UserId = command.UserId,
Amount = command.MaturityAmount,
PaymentMethod = command.Amount <= 200_000m
? PaymentMethod.UPI
: PaymentMethod.NEFT,
IdempotencyKey = idempotencyKey,
Status = PaymentStatus.Initiated,
CreatedAt = DateTime.UtcNow,
IsMaturityPayout = true,
SourceFdOrderId = command.FdOrderId
};
await _paymentRepo.SaveAsync(payment);
await _idempotencyStore.StoreAsync(idempotencyKey, payment);
var payoutRequest = new PayoutRequest
{
Amount = payment.Amount,
BeneficiaryAccount = command.BankAccountNumber,
BeneficiaryIfsc = command.IfscCode,
BeneficiaryName = command.AccountHolderName,
Reference = $"SM-MAT-{command.FdOrderId}",
PaymentMode = payment.PaymentMethod == PaymentMethod.UPI
? PayoutMode.UPI
: PayoutMode.NEFT
};
var response = await _neftGateway.InitiatePayoutAsync(payoutRequest);
payment.Status = response.Success
? PaymentStatus.Completed
: PaymentStatus.Failed;
payment.GatewayRefId = response.GatewayRefId;
await _paymentRepo.UpdateAsync(payment);
return MapToResult(payment);
}
}
Payment Gateway Selection Matrix
| Method | Settlement Time | Min Amount | Max Amount | Platform Fee | Use Case |
|---|---|---|---|---|---|
| UPI (VPA Collect) | Instant | ₹1 | ₹2,00,000 | Free | FD placement, withdrawals |
| UPI (Intent) | Instant | ₹1 | ₹1,00,000 | Free | Quick top-ups |
| NEFT | 30 min batches | ₹1 | No limit | Free | Large FD placements, maturity payouts |
| RTGS | Instant | ₹2,00,000 | No limit | Free | Very large transactions |
| IMPS | Instant | ₹1 | ₹5,00,000 | Free | Urgent payouts outside NEFT hours |
| Net Banking | Instant - 30 min | ₹1 | Bank limit | Free | Users without UPI |
Payment Reconciliation Flow
Every payment must be reconciled against the partner bank's records. The reconciliation service runs every 4 hours (and once as a daily full reconciliation at 2 AM IST). For UPI, NPCI sends a settlement file that contains all transactions for the day. The service matches each transaction in the settlement file against the corresponding payment record in the platform's database using the UPI Transaction Reference (UTR) number. For NEFT/RTGS, the service processes the bank's NEFT return file. Any unmatched transaction is flagged for manual investigation. A discrepancy alert is raised if the mismatch amount exceeds ₹10,000 or if more than 5 transactions are unmatched in a single reconciliation cycle.
9. Risk Assessment and Compliance Engine
The compliance engine is the regulatory backbone of the Stable Money platform. It enforces RBI KYC Master Direction requirements, implements PMLA-mandated transaction monitoring, generates Currency Transaction Reports (CTRs) and Suspicious Transaction Reports (STRs) for filing with the Financial Intelligence Unit (FIU-IND), and ensures adherence to RBI's digital lending guidelines, data localization mandates, and fair practices code. The engine runs real-time rules for every transaction and periodic batch scans for pattern detection. The compliance team consists of certified AML analysts who review flagged transactions, and the engine is designed to minimize false positives while catching genuine suspicious activity.
The compliance engine is structured as a layered system. The real-time layer evaluates every transaction against a set of deterministic rules (amount thresholds, velocity checks, blacklisted entities) and can block a transaction within 50 milliseconds. The near-real-time layer processes transactions in micro-batches (every 5 minutes) and applies more complex pattern detection (structuring detection, round-tripping, unusual transaction patterns). The batch layer runs nightly and performs comprehensive scans across the entire transaction history to detect slow-moving fraud patterns, generate regulatory reports, and update risk scores. This layered approach ensures that critical checks are never delayed while complex analysis runs in the background without impacting transaction throughput.
C#
public class ComplianceEngine
{
private readonly ITransactionMonitor _transactionMonitor;
private readonly ISanctionsChecker _sanctionsChecker;
private readonly IVelocityChecker _velocityChecker;
private readonly ICtrGenerator _ctrGenerator;
private readonly IStrGenerator _strGenerator;
private readonly IRbiReturnGenerator _rbiReturns;
private readonly IEventPublisher _events;
private readonly ILogger<ComplianceEngine> _logger;
public async Task<ComplianceResult> EvaluateTransactionAsync(
FinancialTransaction transaction)
{
var result = new ComplianceResult
{
TransactionId = transaction.Id,
UserId = transaction.UserId,
EvaluatedAt = DateTime.UtcNow
};
var sanctionsCheck = await _sanctionsChecker
.CheckEntityAsync(transaction.CounterpartyInfo);
if (sanctionsCheck.IsSanctioned)
{
result.Action = ComplianceAction.Block;
result.Reason = "Counterparty matches sanctions list";
result.RequiresStr = true;
await PublishBlockedEventAsync(transaction, result);
return result;
}
var velocityCheck = await _velocityChecker
.CheckVelocityAsync(transaction.UserId, transaction);
if (velocityCheck.IsExceeded)
{
result.Action = ComplianceAction.HoldForReview;
result.Reason = velocityCheck.ExceededRule;
result.RequiresStr = true;
return result;
}
if (transaction.Amount >= 50_000m)
{
await _ctrGenerator.QueueCtrAsync(transaction);
}
var strIndicators = await DetectStrIndicatorsAsync(transaction);
if (strIndicators.Any())
{
result.RequiresStr = true;
result.StrIndicators = strIndicators;
await _strGenerator.QueueStrAsync(transaction, strIndicators);
}
var riskScore = await CalculateRiskScoreAsync(transaction);
result.RiskScore = riskScore;
if (riskScore > 0.8m)
{
result.Action = ComplianceAction.Block;
result.Reason = $"High risk score: {riskScore:F2}";
}
else if (riskScore > 0.5m)
{
result.Action = ComplianceAction.HoldForReview;
result.Reason = $"Elevated risk score: {riskScore:F2}";
}
else
{
result.Action = ComplianceAction.Allow;
}
return result;
}
private async Task<List<string>> DetectStrIndicatorsAsync(
FinancialTransaction transaction)
{
var indicators = new List<string>();
var recentTxns = await _transactionMonitor
.GetRecentTransactionsAsync(
transaction.UserId,
TimeSpan.FromHours(24));
var belowThresholdTxns = recentTxns
.Where(t => t.Amount >= 40_000m && t.Amount < 50_000m)
.ToList();
if (belowThresholdTxns.Count >= 3)
{
indicators.Add("STRUCTURING: Multiple transactions " +
"just below CTR threshold within 24 hours");
}
var totalIn = recentTxns
.Where(t => t.Direction == TransactionDirection.Inbound)
.Sum(t => t.Amount);
var totalOut = recentTxns
.Where(t => t.Direction == TransactionDirection.Outbound)
.Sum(t => t.Amount);
if (totalIn > 200_000m && totalOut > totalIn * 0.8m)
{
indicators.Add("RAPID_MOVEMENT: Large inflows followed by " +
"similar outflows within 24 hours");
}
return indicators;
}
public async Task<RbiReportBundle> GenerateDailyRbiReportsAsync(
DateOnly reportDate)
{
var bundle = new RbiReportBundle
{
ReportDate = reportDate,
GeneratedAt = DateTime.UtcNow
};
bundle.CtrReport = await _ctrGenerator
.GenerateDailyCtrAsync(reportDate);
bundle.StrReport = await _strGenerator
.GenerateDailyStrAsync(reportDate);
bundle.LvtrReport = await _rbiReturns
.GenerateLargeValueTransactionsAsync(reportDate);
bundle.DigitalLendingReport = await _rbiReturns
.GenerateDigitalLendingComplianceAsync(reportDate);
bundle.DataLocalizationCert = await _rbiReturns
.GenerateDataLocalizationCertificateAsync(reportDate);
return bundle;
}
}
RBI Regulatory Requirements Matrix
| Regulation | Requirement | Implementation | Deadline |
|---|---|---|---|
| PMLA 2002 | CTR for transactions ≥ ₹50K | Real-time detection + daily batch filing to FIU-IND | Daily (within 15 days for STR) |
| PMLA 2002 | STR for suspicious transactions | Pattern detection engine + manual review queue | Within 7 days of detection |
| RBI KYC Master Direction | CDD for all customers | EKYC pipeline with Aadhaar + PAN + Bank verification | Before establishing relationship |
| RBI Data Localization | Payment data stored in India only | All data in ap-south-1 region, no cross-region replication | Ongoing compliance |
| RBI Digital Lending Guidelines | Grievance redressal officer in India | Dedicated compliance team + in-app complaint system | Ongoing |
| DICGC Act | Deposit insurance up to ₹5L | Per-bank tracking + user notifications + claim facilitation | Ongoing |
| IT Act Sec 43A | Reasonable security practices | ISO 27001 certification + annual VAPT audits | Ongoing |
| RBI NBFC Guidelines | Capital adequacy and provisioning | Automated capital adequacy monitoring + reporting | Quarterly |
10. Notification System
The notification system on Stable Money is responsible for delivering timely, accurate, and actionable communications across multiple channels — push notifications, SMS, email, WhatsApp, and in-app notifications. Given the financial nature of the platform, notifications serve not just marketing purposes but critical operational and compliance functions: OTP for transaction authentication, payment confirmation alerts, FD maturity reminders, interest credit notifications, rate change alerts, regulatory compliance communications, and security alerts for suspicious account activity. Every notification must be delivered reliably, and delivery must be auditable for regulatory examination. The system processes approximately 2 million notifications daily across all channels.
The notification architecture is built around a template-driven, channel-agnostic event processor. When a notification event arrives on Kafka (e.g., FdActivatedEvent, MaturityReminderEvent, PaymentCompletedEvent), a notification orchestrator resolves the event type to a set of templates (one per enabled channel), personalizes the content with user data and transaction details, and routes each message to the appropriate channel adapter (FCM for push, Twilio for SMS, SendGrid for email, WhatsApp Business API for WhatsApp). Delivery status is tracked back to the event, and failed deliveries are retried with exponential backoff up to 3 attempts before entering a dead-letter queue for manual intervention. The orchestration layer ensures that even if one channel fails, other channels are still attempted — for example, if SMS delivery fails due to a carrier issue, the push notification and email are still sent.
C#
public class NotificationOrchestrator
{
private readonly ITemplateEngine _templateEngine;
private readonly IChannelRouter _channelRouter;
private readonly IUserPreferenceStore _preferenceStore;
private readonly INotificationLogStore _logStore;
private readonly ILogger<NotificationOrchestrator> _logger;
public async Task<NotificationResult> ProcessEventAsync(
NotificationEvent notificationEvent)
{
var userPrefs = await _preferenceStore
.GetPreferencesAsync(notificationEvent.UserId);
if (!IsChannelEnabled(userPrefs, notificationEvent.Category,
notificationEvent.PreferredChannel))
{
return NotificationResult.Skipped("User preference disabled");
}
var templates = await _templateEngine.GetTemplatesAsync(
notificationEvent.Category);
var channels = DetermineChannels(
notificationEvent, userPrefs);
var results = new List<ChannelDeliveryResult>();
foreach (var channel in channels)
{
var template = templates.FirstOrDefault(t =>
t.Channel == channel);
if (template == null) continue;
var message = await _templateEngine.RenderAsync(
template, notificationEvent.Variables);
var deliveryResult = await _channelRouter.SendAsync(
channel, message, notificationEvent);
results.Add(deliveryResult);
await _logStore.LogDeliveryAsync(new NotificationLog
{
EventId = notificationEvent.Id,
UserId = notificationEvent.UserId,
Channel = channel,
Category = notificationEvent.Category,
Subject = message.Subject,
ContentHash = ComputeHash(message.Body),
Status = deliveryResult.Status,
ExternalId = deliveryResult.ExternalMessageId,
SentAt = DateTime.UtcNow,
DeliveredAt = deliveryResult.DeliveredAt,
RetryCount = deliveryResult.RetryCount
});
}
return new NotificationResult
{
Success = results.All(r =>
r.Status == DeliveryStatus.Delivered),
ChannelResults = results
};
}
private List<NotificationChannel> DetermineChannels(
NotificationEvent evt, UserNotificationPrefs prefs)
{
return evt.Priority switch
{
NotificationPriority.Critical =>
new List<NotificationChannel>
{
NotificationChannel.Push,
NotificationChannel.SMS,
NotificationChannel.Email
},
NotificationPriority.High =>
new List<NotificationChannel>
{
NotificationChannel.Push,
NotificationChannel.SMS
},
NotificationPriority.Normal =>
new List<NotificationChannel>
{ evt.PreferredChannel },
NotificationPriority.Low =>
new List<NotificationChannel>
{ NotificationChannel.InApp },
_ => new List<NotificationChannel>
{ evt.PreferredChannel }
};
}
}
Notification Categories and SLAs
| Category | Priority | Channels | Delivery SLA | Examples |
|---|---|---|---|---|
| Security Alert | Critical | Push + SMS + Email | < 30 seconds | Login from new device, password change |
| Transaction OTP | Critical | SMS + Push | < 10 seconds | UPI PIN entry, FD placement confirmation |
| Payment Confirmation | High | Push + SMS | < 60 seconds | FD placed, maturity payout initiated |
| Maturity Reminder | High | Push + Email | < 5 minutes | FD maturing in 3 days, renewal options |
| Rate Alert | Normal | Push (configurable) | < 15 minutes | Bank rate increased, best rate available |
| Interest Credit | Normal | Push + In-App | < 30 minutes | Quarterly interest credited to FD |
| Marketing | Low | In-App + Email | Best effort | New bank partner, promotional rates |
| Regulatory | High | Email + In-App | < 1 hour | KYC re-verification due, tax certificate |
OTP Service Architecture
The OTP service is a latency-critical subsystem that must deliver 6-digit OTPs to users within 10 seconds of request. OTPs are generated using a cryptographically secure random number generator, stored in Redis with a 5-minute TTL, and delivered via both SMS (primary) and push notification (backup). The service uses a dual-provider SMS strategy — Twilio as primary and MSG91 as fallback — to ensure delivery even if one provider experiences downtime. Rate limiting is enforced per phone number (max 3 OTPs per 10 minutes, max 10 per hour) to prevent OTP bombing attacks. The OTP verification endpoint uses a constant-time comparison to prevent timing attacks and supports a maximum of 3 verification attempts before the OTP expires.
WhatsApp Business Integration
WhatsApp Business API is increasingly important for financial notifications in India. The platform uses WhatsApp for high-priority notifications like FD maturity alerts, large transaction confirmations, and KYC expiry reminders. The WhatsApp channel supports rich message templates with interactive buttons (e.g., "Renew FD", "View Details", "Contact Support") that drive higher engagement than plain SMS. The integration uses the WhatsApp Business Platform's Cloud API with webhook-based delivery confirmations. Message templates are pre-approved by WhatsApp and must be updated whenever notification content changes. The system tracks WhatsApp delivery rates (typically 95%+ in India) and automatically falls back to SMS if WhatsApp delivery fails within 30 seconds.
11. Transaction Ledger and Reconciliation
The transaction ledger is the single source of truth for every financial movement in the Stable Money platform. It follows a double-entry bookkeeping model where every transaction generates at least two entries — a debit and a credit — ensuring the fundamental accounting equation (assets = liabilities + equity) is always satisfied. The ledger is append-only; entries are never modified or deleted, only reversed through explicit reversal entries. This design ensures complete auditability and makes it impossible to silently corrupt financial data. The ledger currently processes approximately 50,000 entries daily and retains data for 10 years as per RBI requirements.
The reconciliation engine is arguably the most critical operational system in the platform. It continuously compares the platform's internal ledger against partner bank records to detect discrepancies. Discrepancies can arise from several sources: network failures causing one-sided transactions (money debited from user but FD not placed at bank), bank-side errors, timing differences in settlement, and system bugs. The reconciliation engine categorizes discrepancies by severity, initiates automatic resolution for common patterns, and escalates complex cases to the operations team. The system achieves a 99.7% automatic resolution rate for discrepancies, with the remaining 0.3% requiring manual intervention typically resolved within 48 hours.
C#
public class DoubleEntryLedger
{
private readonly ILedgerEntryRepository _entryRepo;
private readonly IDbContextFactory<LedgerDbContext> _dbContextFactory;
public async Task<LedgerTransactionResult> RecordTransactionAsync(
LedgerTransactionRequest request)
{
using var transaction = await _dbContextFactory
.CreateDbContextAsync();
var totalDebits = request.Entries
.Where(e => e.Direction == EntryDirection.Debit)
.Sum(e => e.Amount);
var totalCredits = request.Entries
.Where(e => e.Direction == EntryDirection.Credit)
.Sum(e => e.Amount);
if (totalDebits != totalCredits)
throw new LedgerImbalanceException(
$"Debits ({totalDebits}) != Credits ({totalCredits})");
var ledgerEntries = new List<LedgerEntry>();
var sequenceNo = await GetNextSequenceNumberAsync(
request.LedgerType);
foreach (var entry in request.Entries)
{
var ledgerEntry = new LedgerEntry
{
Id = Guid.NewGuid(),
LedgerType = request.LedgerType,
SequenceNumber = sequenceNo++,
AccountId = entry.AccountId,
AccountType = entry.AccountType,
Direction = entry.Direction,
Amount = entry.Amount,
Currency = entry.Currency,
TransactionId = request.TransactionId,
TransactionType = request.TransactionType,
Description = entry.Description,
Metadata = entry.Metadata,
CreatedAt = DateTime.UtcNow,
CreatedBy = request.InitiatedBy,
IdempotencyKey = $"{request.TransactionId}-{entry.Direction}-{entry.AccountId}"
};
ledgerEntries.Add(ledgerEntry);
}
await _entryRepo.BulkInsertAsync(ledgerEntries);
var lastSequence = await _entryRepo
.GetLastSequenceAsync(request.LedgerType);
if (lastSequence != sequenceNo - 1)
{
throw new SequenceGapDetectedException(
$"Expected {sequenceNo - 1}, found {lastSequence}");
}
return new LedgerTransactionResult
{
TransactionId = request.TransactionId,
EntriesRecorded = ledgerEntries.Count,
FirstSequence = ledgerEntries.First().SequenceNumber,
LastSequence = ledgerEntries.Last().SequenceNumber
};
}
public async Task<ReconciliationResult> ReconcileAsync(
DateOnly date, string bankId)
{
var platformEntries = await _entryRepo
.GetEntriesForDateAsync(date, bankId);
var bankFile = await LoadBankReconciliationFileAsync(
date, bankId);
var platformLookup = platformEntries.ToLookup(
e => e.Metadata["PartnerRefId"]?.ToString());
var bankLookup = bankFile.Transactions.ToLookup(
t => t.BankRefId);
var matched = new List<ReconciledPair>();
var platformOnly = new List<LedgerEntry>();
var bankOnly = new List<BankTransaction>();
foreach (var entry in platformEntries)
{
var refId = entry.Metadata["PartnerRefId"]?.ToString();
if (refId != null && bankLookup.Contains(refId))
{
var bankTxn = bankLookup[refId].First();
matched.Add(new ReconciledPair
{
PlatformEntry = entry,
BankTransaction = bankTxn,
AmountMatch = entry.Amount == bankTxn.Amount,
Status = ReconciliationStatus.Matched
});
}
else
{
platformOnly.Add(entry);
}
}
foreach (var bankTxn in bankFile.Transactions)
{
if (!platformLookup.Contains(bankTxn.BankRefId))
{
bankOnly.Add(bankTxn);
}
}
foreach (var unmatched in platformOnly)
{
var resolution = await TryAutoResolveAsync(unmatched, bankOnly);
if (resolution != null)
{
matched.Add(resolution);
bankOnly.Remove(resolution.BankTransaction);
}
}
var result = new ReconciliationResult
{
Date = date,
BankId = bankId,
TotalPlatformEntries = platformEntries.Count,
TotalBankTransactions = bankFile.Transactions.Count,
MatchedCount = matched.Count,
PlatformOnlyCount = platformOnly.Count,
BankOnlyCount = bankOnly.Count,
DiscrepancyAmount = CalculateDiscrepancyAmount(
platformOnly, bankOnly),
NeedsManualReview = platformOnly.Any() || bankOnly.Any()
};
if (result.DiscrepancyAmount > 0)
{
await RaiseDiscrepancyAlertAsync(result);
}
return result;
}
}
Reconciliation Schedule and SLAs
| Reconciliation Type | Frequency | SLA | Escalation |
|---|---|---|---|
| UPI Settlement | Every 4 hours | Resolved within 24 hours | Operations team - Finance head |
| NEFT/RTGS | Daily (11 PM IST) | Resolved within 48 hours | Operations team - Finance head |
| FD Placement | Every 2 hours | Resolved within 6 hours | Auto-escalation to bank team |
| Maturity Payout | Daily (8 AM IST) | Re-initiated within 4 hours | Urgent escalation to ops head |
| Interest Accrual | Weekly (Monday) | Resolved within 7 days | Finance team - Compliance |
| G-Sec Settlement | Daily (T+1) | Confirmed within T+2 | Operations - RBI liaison |
Idempotent Ledger Design Principles
The ledger follows six fundamental design principles: (1) Append-Only — entries are never updated or deleted; corrections are made via explicit reversal entries that preserve the full history; (2) Double-Entry — every transaction has equal debits and credits; (3) Monotonically Increasing Sequence Numbers — each ledger type maintains a gapless sequence; any gap triggers an immediate alert; (4) Idempotent Writes — the same idempotency key can be submitted multiple times with the same result; (5) Durability Before Acknowledgement — a transaction is only acknowledged to the user after the ledger entry is durably committed to disk with fsync; and (6) Time-Travel Queries — the ledger supports reconstructing the state at any point in time by replaying entries up to that timestamp. These principles ensure that the ledger remains trustworthy even in the face of system failures, network partitions, and concurrent modifications.
12. Customer Support and Ticketing Integration
Customer support for a financial platform requires specialized capabilities beyond traditional helpdesk systems. Support agents must have visibility into a user's complete investment portfolio, transaction history, KYC status, compliance flags, and communication history — all within a single pane of glass. The support system integrates with Zendesk for ticketing, Elasticsearch for full-text search across user interactions, and a custom-built agent workspace that provides deep financial context. Every support interaction is logged and auditable because RBI regulations require financial institutions to maintain records of all customer complaints for a minimum of five years.
The support taxonomy is structured around Stable Money's specific product types and issues. Categories include: FD placement failures, payment processing errors, maturity payout delays, KYC verification issues, rate discrepancies, account access problems, tax document requests, regulatory inquiries, and general product questions. Each category has associated SLAs — for example, payment processing errors must be acknowledged within 15 minutes and resolved within 4 hours, while tax document requests have a 48-hour resolution SLA. Escalation paths are defined for each category and priority level, with automatic escalation if the SLA is approaching breach. The escalation chain typically flows from L1 (general support) to L2 (product specialist) to L3 (engineering on-call) to L4 (compliance officer for regulatory issues).
AI-Powered Support Features
The support system incorporates an AI-powered triage engine that automatically classifies incoming tickets by category, priority, and sentiment. The engine uses a fine-tuned language model trained on historical Stable Money support tickets to predict the appropriate category and priority with 89% accuracy. For common issues (e.g., "FD not reflecting in portfolio" or "interest not credited"), the AI generates suggested responses that agents can customize and send, reducing average handling time by 35%. The AI also detects high-frustration messages and automatically escalates them to senior agents before the user explicitly requests escalation. The triage model is retrained monthly on new ticket data to maintain accuracy as the product evolves.
Support Metrics and KPIs
| Metric | Target | Current | Measurement |
|---|---|---|---|
| First Response Time | < 15 minutes | 12 minutes | Time from ticket creation to first agent response |
| Average Resolution Time | < 4 hours | 3.2 hours | Time from ticket creation to resolution |
| First Contact Resolution | > 70% | 73% | % tickets resolved in first interaction |
| Customer Satisfaction | > 4.5/5 | 4.6/5 | Post-resolution survey score |
| SLA Compliance | > 95% | 97% | % tickets resolved within SLA |
| AI Triage Accuracy | > 85% | 89% | Correct category/priority prediction rate |
| Ticket Volume per 1000 Users | < 15 | 11 | Monthly tickets per 1000 active users |
| Escalation Rate | < 10% | 8% | % tickets requiring escalation beyond L1 |
Agent Workspace Dashboard
The agent workspace is a custom React application that aggregates data from multiple services into a single view. When an agent receives a ticket, the workspace loads the user's complete profile: personal information (masked for security), KYC status and history, all active FDs with current values and maturity dates, recent transactions (last 30 days), previous support tickets and resolutions, compliance risk flags (visible only to compliance-certified agents), and device/session information for security-related tickets. This 360-degree view enables agents to resolve issues without switching between multiple systems. The workspace also includes canned responses for common scenarios, a knowledge base search integration, and a direct line to bank partner support teams for issues requiring bank-side investigation.
13. Analytics and Personalization Engine
The analytics and personalization engine transforms raw user behavior data and financial transaction records into actionable insights that drive both business decisions and personalized user experiences. The engine collects events from every microservice via Kafka, processes them through a real-time stream processing pipeline (Apache Flink) for immediate insights, and stores historical data in Amazon Redshift for batch analytics and model training. The dual architecture enables real-time personalization (showing a user a relevant notification based on their current session behavior) and deep historical analytics (understanding which FD products perform best for users in different income brackets and geographic regions).
Personalization on a financial platform must balance relevance with regulatory constraints. Unlike an e-commerce platform that can aggressively personalize product recommendations, a financial platform must ensure that recommendations are suitable for the user's risk profile and financial situation. RBI's fair practices code requires that financial products be recommended based on the user's needs and risk tolerance, not just the platform's commercial interests. The personalization engine incorporates a suitability filter that evaluates every recommendation against the user's stated risk profile, investment horizon, income level, and existing portfolio before surfacing it. Recommendations that fail the suitability filter are logged for compliance review and never shown to the user.
Event Taxonomy and Data Pipeline
Every user interaction on the platform generates a structured event with a consistent schema: event_id, user_id, session_id, event_type, event_category, properties (JSON), timestamp, device_info, and geo_location. Events flow through a Kafka topic (platform-events) partitioned by user_id to ensure ordering per user. The Flink consumer processes events in 10-second windows, computes real-time features (session duration, pages viewed, products compared, time since last login), and writes them to a feature store (Redis) for immediate consumption by the personalization service. Simultaneously, a Kafka Connect connector streams events to S3 in Parquet format for batch processing by Spark jobs that run nightly. The nightly jobs compute cohort-level analytics, update machine learning model training datasets, and generate regulatory reporting aggregations.
Key Analytics Models
- FD Maturity Prediction: Predicts the likelihood of a user reinvesting at maturity based on historical behavior, current rate environment, and user engagement signals. Enables proactive outreach with personalized renewal offers. The model achieves 78% accuracy at 30 days before maturity.
- Churn Prediction: Identifies users likely to withdraw their investments and leave the platform within the next 30 days based on declining engagement, support ticket patterns, and rate comparison behavior. Early intervention (personalized rate offers, portfolio review callbacks) reduces churn by 15%.
- Product Recommendation: Suggests optimal FD/G-Sec allocations based on the user's existing portfolio, risk profile, tax situation, and market conditions. Ensures DICGC compliance in recommendations. The recommendation engine uses a multi-armed bandit approach that balances exploration (suggesting new products) with exploitation (recommending proven products).
- Dynamic Notification Timing: Learns the optimal time to send notifications to each user based on their historical open and click patterns, maximizing engagement without being spammy. Users who receive notifications at their preferred time have 2.3x higher engagement rates.
- Fraud Scoring: Real-time risk scoring of every transaction using a gradient-boosted model trained on historical fraud patterns, device fingerprints, and behavioral biometrics. The model processes 500 features per transaction and delivers a risk score within 20ms.
Analytics Data Architecture
| Layer | Technology | Latency | Use Cases |
|---|---|---|---|
| Real-time Feature Store | Redis Cluster | < 5ms | Personalization, fraud scoring, session analytics |
| Stream Processing | Apache Flink | 10-second windows | Real-time dashboards, alerting, session tracking |
| Operational Analytics | PostgreSQL (materialized views) | 2-5 seconds | Admin dashboards, agent workspace |
| Batch Analytics | Amazon Redshift + Spark | Nightly batch | Model training, regulatory reports, BI dashboards |
| Long-term Storage | S3 (Parquet) + Glue Catalog | Nightly ETL | Audit trail, historical analysis, compliance |
| ML Feature Pipeline | SageMaker Feature Store | Hourly refresh | Model serving, A/B testing, cohort analysis |
14. Multi-device Synchronization
Users of Stable Money access their accounts from multiple devices — mobile phones (both Android and iOS), tablets, and web browsers. The platform must provide a seamless, consistent experience across all devices while maintaining security guarantees. Portfolio data must be consistent across all devices, and actions taken on one device (e.g., initiating an FD placement) must be immediately visible on other devices. The platform achieves this through a combination of server-side state management (the canonical state lives on the server), event-driven push updates (WebSocket connections for real-time portfolio changes), and optimistic UI updates with eventual consistency for non-critical state.
The synchronization architecture uses a client-state versioning model. The server maintains a monotonically increasing version number for each user's state. When any state-changing operation occurs (FD placement, payment, maturity), the server increments the version number and includes it in the response. The client stores the last-known version number and includes it in subsequent API requests. If the server detects that the client's version is behind, it includes a delta patch in the response that brings the client up to date. This approach minimizes bandwidth while ensuring consistency, and it degrades gracefully in poor network conditions. The version counter is stored in Redis with a 24-hour TTL and backed by PostgreSQL for persistence, ensuring that it survives cache evictions without losing sync state.
Conflict Resolution Strategy
For financial operations, there are no true "conflicts" in the traditional sense — a user cannot simultaneously place and withdraw the same FD. However, UI-level conflicts can arise when a user views stale data and attempts an action based on it. The platform handles this through optimistic concurrency control: every write operation includes the expected version number, and if the server's current version differs, the operation is rejected with a STALE_STATE error. The client then refreshes its state and prompts the user to retry. For non-financial state (preferences, notification settings), last-write-wins with server-side timestamps is used. This approach eliminates the complexity of operational transformation or CRDT-based conflict resolution while being perfectly suited for financial use cases where strict ordering is required.
Real-time Push Architecture
The platform uses WebSocket connections (via AWS API Gateway WebSocket API) to push real-time updates to connected clients. When a reconciliation event occurs (FD activated, interest credited, maturity processed), the notification service publishes an event that the WebSocket gateway consumes and routes to the affected user's connected devices. The WebSocket connection is established on app launch and maintained with a heartbeat interval of 30 seconds. If the connection drops, the client automatically reconnects with exponential backoff. For critical financial events (payment confirmation), the platform falls back to push notifications (FCM/APNs) even if the WebSocket is connected, ensuring delivery redundancy. The WebSocket infrastructure handles approximately 100,000 concurrent connections during peak hours.
Device Session Management
| Feature | Implementation | Security Control |
|---|---|---|
| Max concurrent sessions | 3 devices per user | Oldest session invalidated on 4th login |
| Session token lifetime | 24 hours (sliding window) | Refresh token rotated on every use |
| Biometric unlock | Device-native (Face ID / fingerprint) | Never sends biometric data to server |
| Device binding | Device fingerprint + secure enclave key | New device requires re-authentication |
| Remote logout | Server-side session invalidation | All sessions revoked on security alert |
| Offline mode | Encrypted local cache for portfolio view | No write operations allowed offline |
15. Security — Encryption, Tokenization, Fraud Detection
Security on a financial platform is not a feature — it is the foundational requirement upon which every other capability is built. Stable Money handles sensitive financial data including PAN numbers, Aadhaar numbers, bank account details, investment portfolios, and transaction histories. A breach of this data would not only cause direct financial harm to users but would also expose the platform to severe regulatory penalties under RBI's data protection framework, the IT Act, and the Digital Personal Data Protection Act. The security architecture follows a defense-in-depth model with multiple layers of protection, ensuring that no single point of failure can compromise the entire system.
Data encryption is implemented at three levels: at rest, in transit, and in use. For data at rest, all PostgreSQL databases use Transparent Data Encryption (TDE) with AES-256 keys managed through AWS KMS with automatic key rotation every 90 days. Sensitive fields (PAN, Aadhaar, bank account numbers) are additionally encrypted at the application layer using envelope encryption — the data is encrypted with a data encryption key (DEK), and the DEK is encrypted with a key encryption key (KEK) stored in KMS. This two-layer approach ensures that even if the database is compromised, the sensitive fields remain encrypted without the KMS keys. All API communication uses TLS 1.3 with certificate pinning on mobile clients to prevent man-in-the-middle attacks.
C#
public class SensitiveDataEncryptionService
{
private readonly IKeyManagementService _kms;
private readonly IEncryptionKeyCache _keyCache;
private readonly ILogger<SensitiveDataEncryptionService> _logger;
public SensitiveDataEncryptionService(
IKeyManagementService kms,
IEncryptionKeyCache keyCache,
ILogger<SensitiveDataEncryptionService> logger)
{
_kms = kms;
_keyCache = keyCache;
_logger = logger;
}
public async Task<EncryptedPayload> EncryptAsync(
string plaintext, SensitiveDataClassification classification)
{
var kekId = classification switch
{
SensitiveDataClassification.PanNumber => "pan-kek-v2",
SensitiveDataClassification.AadhaarNumber => "aadhaar-kek-v2",
SensitiveDataClassification.BankAccount => "bank-kek-v2",
SensitiveDataClassification.KycDocument => "kyc-kek-v1",
_ => "general-kek-v1"
};
var dek = GenerateRandomKey(32);
var iv = GenerateRandomIv(12);
var encrypted = AesGcmEncrypt(
Encoding.UTF8.GetBytes(plaintext), dek, iv);
var encryptedDek = await _kms.EncryptKeyAsync(kekId, dek);
return new EncryptedPayload
{
Ciphertext = Convert.ToBase64String(encrypted),
EncryptedDek = Convert.ToBase64String(encryptedDek),
KekId = kekId,
Iv = Convert.ToBase64String(iv),
Algorithm = "AES-256-GCM",
Classification = classification,
EncryptedAt = DateTime.UtcNow
};
}
public async Task<string> DecryptAsync(EncryptedPayload payload)
{
var dek = await _kms.DecryptKeyAsync(
payload.KekId,
Convert.FromBase64String(payload.EncryptedDek));
var iv = Convert.FromBase64String(payload.Iv);
var ciphertext = Convert.FromBase64String(payload.Ciphertext);
var plaintext = AesGcmDecrypt(ciphertext, dek, iv);
return Encoding.UTF8.GetString(plaintext);
}
public string MaskSensitiveData(
string data, SensitiveDataClassification classification)
{
return classification switch
{
SensitiveDataClassification.PanNumber =>
$"XXXXX{data[^4..]}",
SensitiveDataClassification.AadhaarNumber =>
$"XXXX XXXX {data[^4..]}",
SensitiveDataClassification.BankAccount =>
$"XXXXXX{data[^4..]}",
_ => $"***{data[^4..]}"
};
}
private byte[] GenerateRandomKey(int length)
{
var key = new byte[length];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(key);
return key;
}
}
Fraud Detection Engine
The fraud detection engine uses a multi-model ensemble approach to score every transaction and account action in real-time. The first model is a rules engine that catches known fraud patterns (velocity violations, blacklisted entities, impossible travel scenarios). The second is a gradient-boosted decision tree (XGBoost) trained on historical fraud data, which captures complex non-linear patterns in transaction features (amount, time, device, location, merchant category). The third is a behavioral biometrics model that analyzes typing patterns, swipe gestures, and navigation behavior to detect account takeover attacks where a fraudster has obtained the user's credentials. The three models are combined using a weighted ensemble that achieves a 99.2% fraud detection rate with only a 0.3% false positive rate.
Tokenization for Payment Data
All payment instrument data (bank account numbers, IFSC codes, UPI VPA addresses) are tokenized at the point of ingestion. The actual sensitive data is stored in a PCI DSS-compliant vault operated by the payment gateway partner (Razorpay), and the platform only retains tokens that map back to the vault. This significantly reduces the platform's PCI DSS scope — since the platform never stores, processes, or transmits card data or bank account numbers directly, it can operate under PCI DSS SAQ-A rather than the more burdensome SAQ-D. Tokenization also simplifies compliance audits and reduces the blast radius of any potential breach.
Security Architecture Summary
| Layer | Technology | Standard | Scope |
|---|---|---|---|
| Network Security | AWS WAF, Shield, VPC isolation | OWASP Top 10 | DDoS protection, injection prevention |
| Transport Security | TLS 1.3, certificate pinning | NIST SP 800-52 | All API communication |
| Application Security | Input validation, parameterized queries | OWASP ASVS Level 2 | All user inputs |
| Data at Rest | AES-256-GCM + envelope encryption | FIPS 140-2 Level 2 | All databases, S3 buckets |
| Key Management | AWS KMS with automatic rotation | NIST SP 800-57 | All encryption keys |
| Authentication | JWT + refresh tokens + device binding | NIST SP 800-63B AAL2 | User sessions |
| Authorization | RBAC + ABAC policies | ISO 27001 | All API endpoints |
| Fraud Detection | XGBoost + rules + behavioral biometrics | RBI PMLA | All transactions |
| Audit Logging | Immutable append-only logs | SOX, RBI KYC norms | All system actions |
| Vulnerability Mgmt | OWASP ZAP, Snyk, annual VAPT | OWASP, NIST | All code and dependencies |
16. Monitoring, Alerting, and SLA Management
Monitoring on a financial platform serves a dual purpose: ensuring operational reliability (the platform is up and performing within SLAs) and detecting financial anomalies (unexpected transaction patterns, reconciliation discrepancies, balance mismatches). The monitoring stack is built on Prometheus for metrics collection, Grafana for visualization, Alertmanager for routing alerts, PagerDuty for on-call management, and a custom financial monitoring dashboard that tracks key business metrics in real-time. Every microservice exposes a /metrics endpoint with standard RED (Rate, Errors, Duration) metrics plus domain-specific metrics (FD placement success rate, payment success rate, reconciliation discrepancy count, etc.). The monitoring infrastructure processes approximately 500,000 metrics per minute across all services.
The alerting strategy follows a tiered approach. Critical alerts (P1) page the on-call engineer immediately via PagerDuty with SMS and phone call escalation if not acknowledged within 5 minutes. These include: service unavailability, reconciliation discrepancy above ₹1 lakh, payment gateway failure rate above 5%, and database replication lag above 30 seconds. Warning alerts (P2) send Slack notifications and require acknowledgment within 30 minutes. These include: elevated error rates, slow API responses, approaching rate limits, and unusual transaction volume. Info alerts (P3) are logged and reviewed in daily standup. These include: certificate expiry warnings, high memory usage, and unusual but non-critical patterns. Every alert has an associated runbook with remediation steps, ensuring that even junior engineers can handle incidents correctly.
SLA Definitions and Error Budgets
| Service | Availability SLA | Latency SLA (p99) | Error Budget (Monthly) |
|---|---|---|---|
| User Service | 99.95% | < 200ms | 21.6 minutes downtime |
| FD Placement Service | 99.99% | < 800ms | 4.3 minutes downtime |
| Payment Service | 99.99% | < 1s | 4.3 minutes downtime |
| Portfolio Service | 99.95% | < 500ms | 21.6 minutes downtime |
| Notification Service | 99.9% | < 2s | 43.2 minutes downtime |
| KYC Service | 99.9% | < 3s | 43.2 minutes downtime |
| Analytics Service | 99.5% | < 10s | 3.6 hours downtime |
| Reconciliation Service | 99.95% | N/A (batch) | 21.6 minutes downtime |
Key Business Metrics Dashboard
The executive dashboard tracks real-time business metrics that indicate the health of the Stable Money platform from both an operational and commercial perspective. Key metrics include: (1) Total AUM (Assets Under Management) — aggregate value of all FDs and G-Secs on the platform, updated every 5 minutes; (2) Daily FD Placement Volume — number and value of FDs placed today compared to same day last week; (3) Maturity Pipeline — FDs maturing in the next 7/30/90 days, enabling the operations team to prepare payout infrastructure; (4) Payment Success Rate — real-time success/failure rate across all payment methods; (5) Reconciliation Discrepancy Count — number and value of unresolved discrepancies; and (6) User Funnel Conversion — registration to KYC completion to first FD placement rates. These metrics are displayed on a large screen in the operations center and updated every 30 seconds.
Distributed Tracing Architecture
Every request that enters the platform is assigned a unique trace ID at the API Gateway level. This trace ID propagates through all downstream service calls via HTTP headers and Kafka message headers, enabling end-to-end request tracing across the microservice boundary. The tracing infrastructure uses OpenTelemetry SDK for instrumentation, Jaeger for trace collection and storage, and Grafana Tempo for trace visualization. For financial operations, the trace includes additional context: the user's KYC status, compliance risk score, and payment gateway response times, enabling rapid root-cause analysis when issues arise. The tracing data is sampled at 100% for financial operations (FD placement, payments, maturity) and 5% for read operations (dashboard loads, rate checks) to balance observability with storage costs.
17. Database Schema Design
The database schema design for Stable Money reflects the complex domain model of a multi-product financial platform. The schema is partitioned across multiple Aurora PostgreSQL clusters, each serving a specific bounded context. The core schema design follows the double-entry ledger pattern with a central ledger_entries table that records every financial movement. The schema uses PostgreSQL-specific features extensively: partitioning by date for the ledger tables, JSONB columns for flexible metadata storage, materialized views for precomputed portfolio aggregations, and row-level security policies for multi-tenant data isolation. The total database footprint across all services is approximately 2 TB, with the ledger tables accounting for 60% of total storage.
Core Schema — Ledger and Accounts
SQL
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ledger_type VARCHAR(32) NOT NULL,
sequence_number BIGINT NOT NULL,
account_id UUID NOT NULL,
account_type VARCHAR(32) NOT NULL,
direction VARCHAR(6) NOT NULL CHECK (direction IN ('debit', 'credit')),
amount NUMERIC(18,2) NOT NULL CHECK (amount > 0),
currency VARCHAR(3) NOT NULL DEFAULT 'INR',
transaction_id UUID NOT NULL,
transaction_type VARCHAR(64) NOT NULL,
description TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) UNIQUE NOT NULL,
reversed_by UUID REFERENCES ledger_entries(id),
is_reversal BOOLEAN DEFAULT FALSE
) PARTITION BY RANGE (created_at);
CREATE TABLE ledger_entries_2026_07 PARTITION OF ledger_entries
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE UNIQUE INDEX idx_ledger_sequence
ON ledger_entries(ledger_type, sequence_number);
CREATE INDEX idx_ledger_account
ON ledger_entries(account_id, created_at DESC);
CREATE INDEX idx_ledger_transaction
ON ledger_entries(transaction_id);
-- User accounts table
CREATE TABLE user_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
account_type VARCHAR(32) NOT NULL,
bank_id VARCHAR(32) NOT NULL,
account_number BYTEA NOT NULL, -- encrypted
ifsc_code BYTEA NOT NULL, -- encrypted
account_holder VARCHAR(128) NOT NULL,
is_primary BOOLEAN DEFAULT FALSE,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Fixed deposit orders
CREATE TABLE fd_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
bank_id VARCHAR(32) NOT NULL,
partner_ref_id VARCHAR(128),
amount NUMERIC(18,2) NOT NULL,
tenure_days INT NOT NULL,
annualized_rate NUMERIC(6,3) NOT NULL,
product_type VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
expected_maturity_amount NUMERIC(18,2) NOT NULL,
maturity_date DATE NOT NULL,
activated_at TIMESTAMPTZ,
idempotency_key VARCHAR(128) UNIQUE NOT NULL,
bank_rejection_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_fd_orders_user ON fd_orders(user_id, status);
CREATE INDEX idx_fd_orders_maturity ON fd_orders(maturity_date)
WHERE status = 'active';
CREATE INDEX idx_fd_orders_bank ON fd_orders(bank_id, status);
-- Payment records
CREATE TABLE payment_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
order_id UUID NOT NULL,
amount NUMERIC(18,2) NOT NULL,
payment_method VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) UNIQUE NOT NULL,
gateway_ref_id VARCHAR(128),
failure_reason TEXT,
is_maturity_payout BOOLEAN DEFAULT FALSE,
source_fd_order_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_payments_user ON payment_records(user_id, created_at DESC);
CREATE INDEX idx_payments_status ON payment_records(status)
WHERE status IN ('initiated', 'pending');
CREATE INDEX idx_payments_idempotency ON payment_records(idempotency_key);
KYC and Compliance Schema
SQL
-- KYC verification state
CREATE TABLE kyc_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
status VARCHAR(32) NOT NULL,
current_step VARCHAR(32),
risk_category VARCHAR(16) NOT NULL DEFAULT 'medium',
pan_number_hash VARCHAR(64), -- SHA-256 hash
aadhaar_last4 VARCHAR(4),
verified_name BYTEA, -- encrypted
verification_data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
CONSTRAINT uq_kyc_user UNIQUE (user_id)
);
-- KYC step audit trail
CREATE TABLE kyc_step_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
step_name VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
provider VARCHAR(32),
provider_response JSONB,
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (executed_at);
-- Compliance flags and alerts
CREATE TABLE compliance_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
alert_type VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
transaction_id UUID,
indicators JSONB NOT NULL,
risk_score NUMERIC(5,4),
status VARCHAR(16) NOT NULL DEFAULT 'open',
assigned_to VARCHAR(64),
resolution TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX idx_compliance_open ON compliance_alerts(status, created_at)
WHERE status = 'open';
CREATE INDEX idx_compliance_user ON compliance_alerts(user_id, created_at DESC);
Schema Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| UUID vs BigInt PK | UUID (v7) | Distributed generation without coordination, temporal ordering in v7 |
| Encrypted columns | AES-256-GCM at app layer | Defense-in-depth; TDE protects disk, app-layer protects against DB dump |
| Partitioning strategy | Monthly range on created_at | Ledger tables grow unbounded; partition enables efficient archival |
| Index strategy | Partial indexes with WHERE clauses | Only index active records; 80% of records are historical |
| JSONB for metadata | Flexible schema for bank-specific data | Each bank returns different metadata fields |
| Soft deletes | status column instead of DELETE | Regulatory requirement: never delete financial records |
18. Interview Q&A
Common system design interview questions for a digital banking and investment platform, with detailed answers covering trade-offs and design decisions.