system-design77 min read

Stable Money App — Digital Banking & Investment Platform System Design | Article 169 | Ayodhyya

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

Article #169 Senior+ Guide System Design 12,000+ Words Ayodhyya Jul 14, 2026 48 min read

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

RequirementTargetRationale
Availability99.95% (4.4 hours/year downtime)Financial platform handling real money; high trust requirement
Latency (p99)< 300ms for reads, < 800ms for writesDashboard loads and transaction initiations must feel instant
DurabilityZero data loss (RPO = 0)Every rupee of user investment must be durably recorded
ConsistencyStrong consistency for balancesPortfolio values must always be accurate; no double-counting
Throughput5,000+ TPS sustained, 25,000+ TPS peakScale for millions of users across partner banks
SecurityPCI DSS Level 1, SOC 2 Type II, RBI Data LocalizationRegulatory compliance for Indian financial data
AuditComplete, immutable audit trailPMLA, RBI KYC norms, and internal audit requirements
Data ResidencyAll user data stored within IndiaRBI 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
Key Insight: The fundamental architectural challenge for Stable Money is the dual-consistency problem: the platform's internal ledger must be perfectly consistent with partner bank records, and any discrepancy can result in financial loss or regulatory violations. This drives the design of our reconciliation engine and double-entry ledger as the most critical subsystems in the entire platform.

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.

graph TB subgraph ClientLayer["Client Layer"] A["React Native Mobile App"] B["Next.js Web App"] C["Partner Bank Portals"] end subgraph APIGateway["API Gateway Layer"] D["AWS API Gateway"] E["JWT Auth + Rate Limiter"] F["WAF + DDoS Protection"] end subgraph CoreServices["Core Microservices"] G["User Service"] H["KYC Service"] I["FD Placement Service"] J["G-Sec Trading Service"] K["Payment Service"] L["Portfolio Service"] M["Rate Aggregator"] N["Notification Service"] O["Compliance Service"] P["Reconciliation Service"] Q["Analytics Service"] R["Support Service"] end subgraph DataLayer["Data Layer"] S[("Aurora PostgreSQL")] T[("Redis Cluster")] U["Apache Kafka"] V["Amazon S3"] W[("Elasticsearch")] end subgraph ExternalAPIs["External Integrations"] X["RBI Retail Direct API"] Y["NPCI UPI Gateway"] Z["Partner Bank APIs"] AA["DigiLocker / NSDL"] BB["SEBI KRA"] end A --> D B --> D C --> D D --> E E --> F F --> G F --> H F --> I F --> J F --> K F --> L F --> M G --> S G --> T H --> S H --> AA H --> BB I --> S I --> Z I --> U J --> S J --> X K --> S K --> Y K --> U L --> S L --> T M --> Z M --> T N --> U N --> W O --> S O --> U P --> S P --> Z P --> U Q --> W Q --> S R --> S R --> W

Service Responsibility Matrix

ServiceBounded ContextDatabaseKey Dependencies
User ServiceRegistration, profiles, authentication, sessionsaurora_usersRedis (sessions), Cognito (MFA)
KYC ServiceIdentity verification, document management, re-KYCaurora_kycDigiLocker, NSDL PAN API, S3 (docs)
FD Placement ServiceOrder management, bank partner routing, maturity trackingaurora_fdPartner bank APIs, Kafka (events)
G-Sec Trading ServiceT-Bill/Govt bond orders, auction participationaurora_gsecRBI Retail Direct, Kafka
Payment ServiceUPI/NEFT/RTGS/IMPS processing, refundsaurora_paymentsNPCI, Razorpay, Kafka
Portfolio ServiceHoldings aggregation, returns calculation, projectionsaurora_portfolioFD + G-Sec services (async), Redis (cache)
Rate AggregatorBank rate crawling, G-Sec yield scraping, cachingaurora_ratesPartner APIs, web scrapers, Redis
Compliance ServicePMLA screening, CTR/STR, RBI returnsaurora_complianceWorld-Check (sanctions), Kafka
Reconciliation ServiceEnd-of-day reconciliation, discrepancy resolutionaurora_reconPartner bank files, S3, Kafka
Notification ServiceSMS, email, push, WhatsApp, in-appaurora_notificationsTwilio, SendGrid, FCM, Kafka
Analytics ServiceUser behavior, portfolio analytics, personalizationRedshiftKafka (events), S3 (exports)
Support ServiceTickets, chat, feedback, escalationaurora_supportZendesk 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.

Design Principle: Every financial operation in the system follows the pattern: (1) Record intent in the ledger, (2) Execute with external system, (3) Record outcome, (4) Reconcile. This ensures that even if the system crashes between any two steps, the reconciliation engine can detect and resolve the inconsistency.

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.

stateDiagram-v2 [*] --> PENDING_REGISTRATION PENDING_REGISTRATION --> MOBILE_VERIFIED: OTP Verified MOBILE_VERIFIED --> PAN_VERIFIED: NSDL API Success PAN_VERIFIED --> AADHAAR_EKYC_INITIATED: User Consents AADHAAR_EKYC_INITIATED --> AADHAAR_EKYC_COMPLETED: UIDAI OTP Success AADHAAR_EKYC_COMPLETED --> BANK_ACCOUNT_LINKED: Penny Drop Verified BANK_ACCOUNT_LINKED --> FULLY_VERIFIED: All Checks Pass FULLY_VERIFIED --> VIDEO_KYC_PENDING: High Value Trigger VIDEO_KYC_PENDING --> FULLY_VERIFIED: Agent Approved MOBILE_VERIFIED --> KYC_FAILED: PAN Mismatch AADHAAR_EKYC_INITIATED --> KYC_FAILED: UIDAI Rejection KYC_FAILED --> PENDING_REGISTRATION: Retry

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.

Regulatory Note: RBI mandates that KYC records must be updated at least once every 10 years for low-risk customers, every 8 years for medium-risk customers, and every 2 years for high-risk customers. The platform automates re-KYC scheduling with configurable intervals per risk category and sends proactive reminders to users 60 days before their KYC expiry date.

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.

sequenceDiagram participant User participant FDService as FD Placement Service participant Router as Bank Partner Router participant Adapter as Bank Adapter participant Bank as Partner Bank API participant Ledger as Double-Entry Ledger User->>FDService: Place FD (amount, bank, tenure) FDService->>FDService: Validate KYC + Compliance FDService->>Ledger: Record intent (debit user account) FDService->>Router: Route to bank adapter Router->>Router: Check health + rate cache Router->>Adapter: Forward placement request Adapter->>Adapter: Transform to bank-specific format Adapter->>Bank: API call (with circuit breaker) Bank-->>Adapter: Response (confirm/reject) Adapter-->>Router: Normalized response Router-->>FDService: Bank result FDService->>Ledger: Record outcome (credit bank account) FDService->>FDService: Schedule accrual + maturity FDService-->>User: Confirmation with FD details
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 CategoryAuth MethodCredential RotationTypical SLA
Modern Private BanksOAuth 2.0 + JWTEvery 90 days, auto-rotated99.9%, p99 < 500ms
Large PSBsAPI Key + HMAC SignatureEvery 180 days, manual99.5%, p99 < 2s
Small Finance BanksmTLS + Client CertificatesAnnual, manual rotation99.0%, p99 < 5s
Cooperative BanksSFTP + PGP Encrypted FilesAnnual, manual95.0%, batch only
RBI Retail Direct (G-Sec)OAuth 2.0 + Digital SignaturePer RBI guidelines99.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.

Critical Consideration: Partner bank APIs are notoriously unreliable during peak hours (10 AM - 2 PM IST on weekdays) and quarter-end periods. The integration layer must gracefully degrade — if a bank's API is unreachable for rate fetching, the system falls back to the last cached rate (timestamped and marked as potentially stale). For FD placement, if the API is unreachable, the order is queued with a TTL and retried. If the bank does not respond within 4 hours, the user is notified and the amount is refunded.

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.

stateDiagram-v2 [*] --> PendingPayment: User Initiates FD PendingPayment --> PaymentFailed: Payment Error PendingPayment --> PendingBankConfirmation: Payment Success PaymentFailed --> PendingPayment: User Retries PendingBankConfirmation --> Active: Bank Confirms PendingBankConfirmation --> BankRejected: Bank Rejects BankRejected --> PaymentFailed: Auto Refund Active --> AccruingInterest: Daily Job AccruingInterest --> Active: Interest Posted Active --> MaturityPending: 3 Days Before Maturity MaturityPending --> RenewalOffered: User Notified RenewalOffered --> Active: Auto-Renewal RenewalOffered --> PayoutPending: User Chooses Payout RenewalOffered --> PartialWithdrawal: User Partial Withdraw PayoutPending --> Completed: Payout Success PayoutPending --> PayoutFailed: Bank Rejects Payout PayoutFailed --> PayoutPending: Retry Completed --> [*]
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

ProductMin InvestmentMax InvestmentTenure RangeRate RangeDICGC Cover
Standard FD₹1,000₹2,00,00,0007 days - 10 years3.5% - 8.5%Up to ₹5L/bank
Tax Saver FD₹10,000₹1,50,0005 years (fixed)6.5% - 7.5%Up to ₹5L/bank
Flexi Deposit₹5,000₹50,00,0001-3 years5.0% - 7.0%Up to ₹5L/bank
Recurring Deposit₹500/month₹50,000/month6 months - 10 years5.5% - 7.5%Up to ₹5L/bank
91-Day T-Bill₹10,000No limit91 days6.5% - 7.5%Sovereign
Government Security₹10,000No limit4-40 years7.0% - 8.5%Sovereign
State Development Loan₹10,000No limit3-30 years7.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.

Key Insight: The interest accrual and maturity processing systems must handle edge cases specific to Indian banking: maturity dates falling on bank holidays (typically moved to the next working day), quarterly compounding calculation differences between banks (some use 365-day year, others use 360-day year), and TDS deduction at source for interest exceeding ₹40,000 per year per bank (recently increased to ₹50,000 for senior citizens). These nuances require per-bank configuration in the engine.

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 TypeCache LayerTTLRefresh Strategy
Partner bank ratesRedis (hot) + PostgreSQL (cold)15 min (Redis)Background poller every 15 min
G-Sec yieldsRedis (hot) + PostgreSQL (cold)5 min (Redis)RBI data feed, near-real-time
T-Bill auction resultsPostgreSQLUntil next auctionRBI auction calendar events
Rate comparison resultsRedis10 minOn-demand + scheduled refresh
Historical rate dataS3 (Parquet) + RedshiftPermanentNightly 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).

Key Insight: Rate staleness is the single biggest trust risk for an aggregator platform. If a user sees a rate of 7.5% on the dashboard but the actual rate at the time of FD placement is 7.2%, it creates immediate distrust. The system addresses this by (1) displaying a "last updated" timestamp on every rate, (2) re-fetching the rate at the time of FD placement as a final confirmation, and (3) if the rate has changed by more than 0.1%, pausing the transaction and asking the user to confirm the new rate before proceeding.

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

MetricCalculation MethodUpdate FrequencyUse Case
Weighted Avg YieldSum(rate * principal) / total principalOn every rate changePortfolio performance comparison
Portfolio DurationSum(tenure * principal) / total principalDailyInterest rate risk assessment
Liquidity Score% of portfolio maturing within 30 daysDailyEmergency fund planning
HHI ConcentrationSum(market_share_i^2) across banksOn every FD changeDICGC risk detection
Tax EfficiencyAfter-tax yield optimization modelQuarterlyTax saving recommendations
Real ReturnNominal yield - inflation rateMonthlyPurchasing 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.

Portfolio Optimization: The portfolio service runs a monthly optimization job that considers all active users' portfolios and generates personalized recommendations. These recommendations are scored by estimated value (how much additional return the user would earn by following the recommendation) and surfaced in the app's notification feed. Users who follow DICGC optimization recommendations see an average improvement of 0.15% in their portfolio yield while gaining ₹2.5 lakh in additional insurance coverage per user.

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.

flowchart LR subgraph UserAction["User Action"] A[Click Invest Now] B[Select Payment Method] C[Complete Payment on Bank App] end subgraph Platform["Stable Money Platform"] D[Create Pending FD Order] E[Create Pending Payment] F[Idempotency Check] G[Route to Gateway] H[Verify Callback] I[Update Payment Status] J[Place FD with Partner Bank] K[Show Confirmation] end subgraph Gateways["Payment Gateways"] L[NPCI UPI Gateway] M[NEFT/RTGS Gateway] N[Net Banking Gateway] end A --> D D --> E E --> B B --> F F --> G G --> L G --> M G --> N C --> L C --> M C --> N L --> H M --> H N --> H H --> I I --> J J --> K
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

MethodSettlement TimeMin AmountMax AmountPlatform FeeUse Case
UPI (VPA Collect)Instant₹1₹2,00,000FreeFD placement, withdrawals
UPI (Intent)Instant₹1₹1,00,000FreeQuick top-ups
NEFT30 min batches₹1No limitFreeLarge FD placements, maturity payouts
RTGSInstant₹2,00,000No limitFreeVery large transactions
IMPSInstant₹1₹5,00,000FreeUrgent payouts outside NEFT hours
Net BankingInstant - 30 min₹1Bank limitFreeUsers 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.

Payment Failure Handling: Payment failures are categorized into three types: (1) User-initiated cancellations (user clicked "cancel" on UPI app) — no action needed, FD order is cancelled; (2) Bank-side failures (insufficient funds, account frozen, daily limit exceeded) — user is notified with specific error message and suggested action; (3) System failures (gateway timeout, callback not received) — the system enters a pending state and polls for payment status every 5 minutes for up to 2 hours. If the payment cannot be confirmed within 2 hours, it is marked as failed and the user is notified to retry.

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.

flowchart LR subgraph RealTime["Real-Time Layer (50ms)"] A[Transaction Event] --> B[Sanctions Check] B --> C[Velocity Check] C --> D[Amount Threshold] D --> E[CTR Generation] end subgraph NearRealTime["Near-Real-Time (5min batches)"] F[Kafka Consumer] --> G[Structuring Detection] G --> H[Round-Trip Detection] H --> I[Pattern Analysis] end subgraph BatchLayer["Batch Layer (Nightly)"] J[Full Transaction Scan] --> K[Fraud Pattern ML] K --> L[Risk Score Update] L --> M[Regulatory Reports] end subgraph Outputs["Outputs"] N[Allow Transaction] O[Hold for Review] P[Block Transaction] Q[CTR/STR Filing] end E --> N E --> O E --> P I --> O M --> Q
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

RegulationRequirementImplementationDeadline
PMLA 2002CTR for transactions ≥ ₹50KReal-time detection + daily batch filing to FIU-INDDaily (within 15 days for STR)
PMLA 2002STR for suspicious transactionsPattern detection engine + manual review queueWithin 7 days of detection
RBI KYC Master DirectionCDD for all customersEKYC pipeline with Aadhaar + PAN + Bank verificationBefore establishing relationship
RBI Data LocalizationPayment data stored in India onlyAll data in ap-south-1 region, no cross-region replicationOngoing compliance
RBI Digital Lending GuidelinesGrievance redressal officer in IndiaDedicated compliance team + in-app complaint systemOngoing
DICGC ActDeposit insurance up to ₹5LPer-bank tracking + user notifications + claim facilitationOngoing
IT Act Sec 43AReasonable security practicesISO 27001 certification + annual VAPT auditsOngoing
RBI NBFC GuidelinesCapital adequacy and provisioningAutomated capital adequacy monitoring + reportingQuarterly
Regulatory Risk: RBI has been increasingly strict about fintech compliance. Non-compliance can result in penalties up to ₹5 crore or three times the amount involved, whichever is higher. The platform must maintain a dedicated compliance team that monitors regulatory changes (RBI circulars, SEBI notifications, FEMA amendments) and translates them into system changes within the mandated timelines. A regulatory change management workflow with SLA tracking is essential.

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

CategoryPriorityChannelsDelivery SLAExamples
Security AlertCriticalPush + SMS + Email< 30 secondsLogin from new device, password change
Transaction OTPCriticalSMS + Push< 10 secondsUPI PIN entry, FD placement confirmation
Payment ConfirmationHighPush + SMS< 60 secondsFD placed, maturity payout initiated
Maturity ReminderHighPush + Email< 5 minutesFD maturing in 3 days, renewal options
Rate AlertNormalPush (configurable)< 15 minutesBank rate increased, best rate available
Interest CreditNormalPush + In-App< 30 minutesQuarterly interest credited to FD
MarketingLowIn-App + EmailBest effortNew bank partner, promotional rates
RegulatoryHighEmail + In-App< 1 hourKYC 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.

Delivery Tracking: Every notification sent by the platform is logged with its delivery status, channel, timestamps, and external provider IDs. This audit trail is essential for regulatory examination — RBI may request proof that specific compliance notifications were sent to users (e.g., KYC expiry reminders, regulatory change disclosures). The notification logs are retained for 8 years as per RBI record retention requirements.

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.

flowchart TB subgraph Sources["Data Sources"] A[Platform Ledger] B[Partner Bank File] C[NPCI Settlement File] end subgraph ReconEngine["Reconciliation Engine"] D[Load Ledger Entries] E[Load Bank Records] F[Match by Reference ID] G{Match Found?} H[Verify Amount Match] I[Auto-Resolve] J[Flag for Manual Review] end subgraph Outcomes["Resolution"] K[Matched - Reconciled] L[Amount Mismatch - Investigate] M[Platform Only - Bank Rejected] N[Bank Only - Missing Entry] end A --> D B --> E C --> E D --> F E --> F F --> G G -->|Yes| H G -->|No| J H -->|Amount Match| K H -->|Amount Mismatch| L I --> K J --> M J --> N L --> O[Operations Team] M --> O N --> O
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 TypeFrequencySLAEscalation
UPI SettlementEvery 4 hoursResolved within 24 hoursOperations team - Finance head
NEFT/RTGSDaily (11 PM IST)Resolved within 48 hoursOperations team - Finance head
FD PlacementEvery 2 hoursResolved within 6 hoursAuto-escalation to bank team
Maturity PayoutDaily (8 AM IST)Re-initiated within 4 hoursUrgent escalation to ops head
Interest AccrualWeekly (Monday)Resolved within 7 daysFinance team - Compliance
G-Sec SettlementDaily (T+1)Confirmed within T+2Operations - 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.

Key Insight: The reconciliation engine typically discovers discrepancies before users notice them. On average, the platform detects and resolves 95% of discrepancies within 6 hours without any user impact. The remaining 5% that require manual intervention are typically bank-side errors or network-partition scenarios that take 24-72 hours to resolve. The platform's financial loss from unreconcilable discrepancies is maintained below ₹10,000 per month through rigorous automation.

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

MetricTargetCurrentMeasurement
First Response Time< 15 minutes12 minutesTime from ticket creation to first agent response
Average Resolution Time< 4 hours3.2 hoursTime from ticket creation to resolution
First Contact Resolution> 70%73%% tickets resolved in first interaction
Customer Satisfaction> 4.5/54.6/5Post-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< 1511Monthly 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.

Support Efficiency: The combination of AI triage and the comprehensive agent workspace has reduced the average ticket resolution time from 6.5 hours to 3.2 hours over the past year. The most significant improvement came from automating the KYC verification status lookup — agents previously had to manually check three separate systems to determine KYC status, which now loads automatically in the agent workspace.

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

LayerTechnologyLatencyUse Cases
Real-time Feature StoreRedis Cluster< 5msPersonalization, fraud scoring, session analytics
Stream ProcessingApache Flink10-second windowsReal-time dashboards, alerting, session tracking
Operational AnalyticsPostgreSQL (materialized views)2-5 secondsAdmin dashboards, agent workspace
Batch AnalyticsAmazon Redshift + SparkNightly batchModel training, regulatory reports, BI dashboards
Long-term StorageS3 (Parquet) + Glue CatalogNightly ETLAudit trail, historical analysis, compliance
ML Feature PipelineSageMaker Feature StoreHourly refreshModel 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

FeatureImplementationSecurity Control
Max concurrent sessions3 devices per userOldest session invalidated on 4th login
Session token lifetime24 hours (sliding window)Refresh token rotated on every use
Biometric unlockDevice-native (Face ID / fingerprint)Never sends biometric data to server
Device bindingDevice fingerprint + secure enclave keyNew device requires re-authentication
Remote logoutServer-side session invalidationAll sessions revoked on security alert
Offline modeEncrypted local cache for portfolio viewNo 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

LayerTechnologyStandardScope
Network SecurityAWS WAF, Shield, VPC isolationOWASP Top 10DDoS protection, injection prevention
Transport SecurityTLS 1.3, certificate pinningNIST SP 800-52All API communication
Application SecurityInput validation, parameterized queriesOWASP ASVS Level 2All user inputs
Data at RestAES-256-GCM + envelope encryptionFIPS 140-2 Level 2All databases, S3 buckets
Key ManagementAWS KMS with automatic rotationNIST SP 800-57All encryption keys
AuthenticationJWT + refresh tokens + device bindingNIST SP 800-63B AAL2User sessions
AuthorizationRBAC + ABAC policiesISO 27001All API endpoints
Fraud DetectionXGBoost + rules + behavioral biometricsRBI PMLAAll transactions
Audit LoggingImmutable append-only logsSOX, RBI KYC normsAll system actions
Vulnerability MgmtOWASP ZAP, Snyk, annual VAPTOWASP, NISTAll code and dependencies
Incident Response: The platform maintains a documented incident response plan with defined roles, communication templates, and escalation procedures. Security incidents are classified into P1 (active breach, user data exposed), P2 (potential vulnerability, no confirmed breach), P3 (minor security event, no user impact). P1 incidents trigger an immediate war room, notification to CERT-In (as required by the IT Act), and user communication within 6 hours. The incident response team conducts quarterly tabletop exercises simulating various breach scenarios.

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.

flowchart TB subgraph Services["Microservices"] A[User Service] B[FD Placement] C[Payment Service] D[Portfolio Service] E[Compliance Engine] end subgraph Collection["Metrics Collection"] F[Prometheus] G[Jaeger Traces] H[CloudWatch Logs] end subgraph Processing["Alert Processing"] I[Alertmanager] J[Grafana Dashboards] K[PagerDuty] end subgraph Response["Response Actions"] L[P1: Page On-Call] M[P2: Slack Alert] N[P3: Log Only] end A --> F B --> F C --> F D --> F E --> F A --> G B --> G C --> G F --> I G --> J H --> J I --> K K --> L I --> M I --> N

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

ServiceAvailability SLALatency SLA (p99)Error Budget (Monthly)
User Service99.95%< 200ms21.6 minutes downtime
FD Placement Service99.99%< 800ms4.3 minutes downtime
Payment Service99.99%< 1s4.3 minutes downtime
Portfolio Service99.95%< 500ms21.6 minutes downtime
Notification Service99.9%< 2s43.2 minutes downtime
KYC Service99.9%< 3s43.2 minutes downtime
Analytics Service99.5%< 10s3.6 hours downtime
Reconciliation Service99.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.

Operational Runbook: Every critical alert has an associated runbook in the PagerDuty incident response system. The runbook includes: (1) symptoms and impact assessment, (2) immediate remediation steps, (3) rollback procedures, (4) escalation contacts, and (5) post-incident review template. Runbooks are reviewed quarterly and updated whenever the system architecture changes. New engineers complete a runbook shadow exercise during their onboarding process.

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.

erDiagram USERS ||--o{ KYC_VERIFICATIONS : has USERS ||--o{ USER_ACCOUNTS : owns USERS ||--o{ FD_ORDERS : places USERS ||--o{ PAYMENT_RECORDS : makes USERS ||--o{ COMPLIANCE_ALERTS : triggers USER_ACCOUNTS ||--o{ FD_ORDERS : funds FD_ORDERS ||--|| PAYMENT_RECORDS : pays FD_ORDERS ||--o{ LEDGER_ENTRIES : generates FD_ORDERS ||--o{ FD_ACCRUALS : accrues USERS { uuid id PK string phone string email string name_hash jsonb preferences timestamp created_at } FD_ORDERS { uuid id PK uuid user_id FK string bank_id decimal amount int tenure_days decimal rate string status date maturity_date timestamp created_at } LEDGER_ENTRIES { uuid id PK uuid account_id FK string direction decimal amount uuid transaction_id string idempotency_key timestamp created_at } PAYMENT_RECORDS { uuid id PK uuid user_id FK uuid order_id FK decimal amount string method string status timestamp created_at }

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

DecisionChoiceRationale
UUID vs BigInt PKUUID (v7)Distributed generation without coordination, temporal ordering in v7
Encrypted columnsAES-256-GCM at app layerDefense-in-depth; TDE protects disk, app-layer protects against DB dump
Partitioning strategyMonthly range on created_atLedger tables grow unbounded; partition enables efficient archival
Index strategyPartial indexes with WHERE clausesOnly index active records; 80% of records are historical
JSONB for metadataFlexible schema for bank-specific dataEach bank returns different metadata fields
Soft deletesstatus column instead of DELETERegulatory 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.

Q1: How do you ensure that a user's money is never lost during an FD placement?

Answer: We use a two-phase commit pattern with the Transaction Outbox pattern. When a user initiates an FD placement: (1) The amount is debited from the user's linked bank account via UPI/NEFT, (2) The debit is recorded in the ledger with a PENDING status, (3) The FD placement request is sent to the partner bank, (4) If the bank confirms, the ledger entry is updated to COMPLETED, (5) If the bank rejects or the API times out, the ledger entry is updated to REVERSED and the amount is automatically refunded to the user's bank account within 24 hours. The reconciliation service runs every 2 hours to detect any PENDING entries that have not been resolved and triggers the refund flow. The idempotency key ensures that even if the system crashes between steps, the operation will be correctly resumed on restart.

Q2: How do you handle the DICGC insurance limit when a user has more than ₹5 lakh in a single bank?

Answer: The platform maintains a per-bank deposit tracker that includes both principal and projected interest at maturity. When a user's total deposits in any single bank exceed ₹4,00,000 (leaving a ₹1,00,000 buffer for interest accrual), the system generates a DICGC optimization recommendation. The recommendation suggests splitting the excess into FDs at other partner banks, showing the available rates at each bank and the net impact on the user's portfolio yield. The optimization algorithm solves a constraint-satisfaction problem: maximize total DICGC coverage while minimizing the yield loss from splitting across banks. For users who explicitly opt out of splitting, the system records their acknowledgment and continues monitoring.

Q3: How do you handle the scenario where a partner bank's API is down during a user's FD maturity payout?

Answer: Maturity payouts are handled with multiple fallback mechanisms. (1) Primary: The system initiates the payout via the bank's normal API, (2) If the API is unreachable after 3 retries over 30 minutes, the system queues the payout in a retry queue with exponential backoff, (3) If the payout cannot be processed within 24 hours of maturity, the system initiates the payout via an alternative channel (NEFT file upload to the bank's SFTP endpoint), (4) If all electronic channels fail, the system notifies the operations team to initiate a manual wire transfer. Throughout this process, the user's portfolio shows the FD as "Maturity Pending" with a clear timeline. The user's interest continues to accrue during the delay, and any interest lost due to bank-side delays is tracked and reported to the compliance team.

Q4: Design the idempotency mechanism for NEFT payments that take 2-4 hours to settle.

Answer: NEFT payments use a multi-stage idempotency approach: (1) On initiation, record the payment with an idempotency key and status INITIATED, (2) The NEFT reference number is assigned by NPCI and returned in the callback, (3) We store the NEFT UTR as the canonical identifier for deduplication, (4) For reconciliation, we match our outgoing NEFT entries against the bank's NEFT return file using the UTR, (5) The idempotency key is retained for 90 days (the NEFT return window), (6) If a user or system retries within this window, we return the existing payment status instead of creating a new one. For bulk NEFT (maturity payouts), we use batch-level idempotency with a batch_id that maps to multiple individual transactions.

Q5: How do you handle the case where interest rates change between the user viewing a rate and placing an FD?

Answer: We use a "quote and lock" pattern similar to foreign exchange: (1) When the user views rates, the system fetches the latest rate and caches it with a 60-second TTL, (2) When the user clicks "Invest", the system re-fetches the rate from the bank's API as a final confirmation, (3) If the rate has changed by more than 0.1% from the displayed rate, the system pauses the transaction and shows the user the new rate with a confirmation dialog, (4) If the user confirms the new rate, the transaction proceeds; if not, the user can cancel without any financial impact, (5) The quote is locked for 5 minutes — if the user does not complete the transaction within this window, the rate must be refreshed. This approach ensures that users always invest at the rate they expect, with full transparency about any changes.

Q6: How would you design the system to handle RBI's data localization requirement while still allowing analytics across regions?

Answer: RBI mandates that all payment system data must be stored and processed within India. Our architecture handles this by: (1) All production data resides in the ap-south-1 (Mumbai) AWS region, with no cross-region replication of user data, (2) For analytics, we use a data anonymization pipeline that strips personally identifiable information (PII) before any data leaves the Indian region, (3) Aggregated analytics (non-PII) can be processed in other regions for global model training, (4) The data localization compliance certificate is generated monthly by the compliance service, verifying that no user data has been transferred outside India, (5) For disaster recovery, we maintain a warm standby in the ap-south-2 (Hyderabad) region — both within India — with encrypted cross-region replication. The key insight is that data localization applies to identifiable data, not to aggregated, anonymized analytics.

Q7: How do you handle the reconciliation when a user's bank rejects an NEFT maturity payout due to account closure?

Answer: When a maturity payout is rejected by the beneficiary bank, the NEFT return file includes a reason code (e.g., "account closed", "account frozen", "IFSC mismatch"). The reconciliation service detects this mismatch and triggers an automated workflow: (1) The rejection is recorded in the ledger with a PAYOUT_FAILED status, (2) The user is immediately notified via SMS, push notification, and email with clear instructions, (3) The user is asked to update their bank account details in the app, (4) If the user does not respond within 7 days, a second notification is sent, (5) If no response within 30 days, the amount is held in an escrow account and the compliance team is notified, (6) The user can claim the maturity amount at any time by providing updated bank details and completing a verification step. The rejected amount is always tracked and never written off.

Q8: Design the system for handling a sudden spike in FD maturity on a quarter-end date (March 31st).

Answer: Quarter-end dates (March 31, June 30, September 30, December 31) see 5-10x normal maturity volumes due to tax-saving FD maturities. Our preparation: (1) The maturity pipeline runs a pre-scan 30 days before quarter-end to identify all FDs maturing in the next 60 days, (2) Maturity payout processing is pre-authorized with partner banks — we submit batch payout requests 2 days before maturity, (3) The system auto-scales payment processing capacity 3x during the week, (4) Maturity renewal offers are sent 15 days before maturity (not 3 days) to spread out user decision-making, (5) The reconciliation service runs every 2 hours (instead of daily) during the quarter-end week, (6) An on-call ops team is staffed with double capacity, (7) All non-critical deployments are frozen 3 days before and after quarter-end. Historical data shows that 70% of maturity payouts on March 31 are tax-saver FDs, so we pre-compute TDS certificates and send them along with the payout confirmation.

Q9: How do you ensure that the portfolio value displayed to the user is always accurate?

Answer: Portfolio accuracy is maintained through a multi-layer approach: (1) The portfolio service queries the ledger directly for balance-critical displays (e.g., "invested amount"), using the most recent committed ledger entries, (2) For accrued interest, the system calculates in real-time using the last accrual entry plus daily accrual from the formula, rather than relying on a cached value, (3) The portfolio dashboard shows both "as of last accrual" and "estimated as of today" values, with a clear distinction, (4) Every night, a batch job reconciles the portfolio's expected values against the actual ledger entries and partner bank confirmations, flagging any discrepancy above ₹1 as a potential issue, (5) The reconciliation results feed back into the morning dashboard so that the operations team starts each day with a verified portfolio state. For user-facing displays, we show a "Last verified: [timestamp]" indicator to build trust.

Q10: How do you handle the scenario where RBI突然 changes the TDS threshold mid-year?

Answer: Regulatory changes are managed through a configuration-driven compliance engine. When RBI changes the TDS threshold (e.g., from ₹40,000 to ₹50,000): (1) The compliance team receives the RBI circular notification, (2) They update the threshold in the compliance configuration database (not code), (3) The system immediately applies the new threshold to all subsequent TDS calculations, (4) For the transition period, the system handles both old and new thresholds for interest accrued before and after the change date, (5) The change is propagated to all partner banks via the reconciliation layer, ensuring that TDS deducted by banks matches our records, (6) A compliance report is generated showing the impact of the threshold change on all users, (7) Users affected by the change receive a notification explaining the new TDS implications. The key architectural decision is that compliance rules are data-driven (stored in the database) rather than code-driven (hardcoded), enabling rapid response to regulatory changes without requiring code deployments.

Stable Money App — Digital Banking & Investment Platform System Design | Article #169 | Ayodhyya

© 2026 Ayodhyya. All rights reserved. | www.ayodhyya.com