How to Design a Digital Wallet System — A Senior+ Guide
Article #181 — A deep-dive into building a production-grade digital wallet from first principles to deployment
1. Introduction: Digital Wallet Landscape
The digital wallet industry has undergone a seismic transformation over the past decade. What began as simple prepaid card platforms has evolved into a sophisticated financial ecosystem processing trillions of dollars annually. By 2026, global digital wallet transactions are projected to exceed $14 trillion, representing more than half of all e-commerce payments worldwide. This exponential growth has been fueled by smartphone proliferation, the decline of cash usage in both developed and emerging markets, and an insatiable consumer demand for seamless, instant financial experiences.
A digital wallet, at its core, is a software-based system that securely stores users' payment information, passwords, and financial credentials, enabling electronic transactions across a wide variety of channels. However, beneath this deceptively simple definition lies an extraordinarily complex distributed system that must balance speed, consistency, security, regulatory compliance, and user experience in real-time. For senior engineers and architects, designing such a system demands deep expertise in distributed systems, financial protocols, cryptography, and regulatory frameworks.
The landscape of digital wallets can be broadly segmented into several categories. Consumer-facing wallets like Apple Pay, Google Pay, and PayPal dominate in Western markets. In Asia, super-apps such as WeChat Pay and Alipay have created all-encompassing financial ecosystems. In Africa and South Asia, mobile money platforms like M-Pesa have brought financial services to billions of previously unbanked individuals. Each of these platforms, despite their different market positions, share core architectural patterns that we will dissect in this article.
When we examine what makes a digital wallet system truly production-grade, several non-negotiable requirements emerge. The system must handle millions of concurrent users with sub-second latency for payment authorization. It must maintain a perfectly accurate ledger that balances to the penny, always. It must comply with a patchwork of international regulations spanning KYC (Know Your Customer), AML (Anti-Money Laundering), PCI-DSS (Payment Card Industry Data Security Standard), and PSD2 (Payment Services Directive 2). It must detect and prevent fraud in real-time while minimizing false positives that frustrate legitimate users. And it must do all of this while maintaining 99.99% uptime.
This article is structured as a comprehensive senior-level guide. We will not merely discuss abstract concepts — we will dive into concrete C# implementations, architectural diagrams, database schemas, and design patterns that you can adapt for production systems. Each section builds upon the previous ones, creating a cohesive narrative from account creation through transaction processing, settlement, and reconciliation.
Whether you are preparing for a staff engineer interview, architecting a greenfield wallet platform, or looking to understand the inner workings of the payments infrastructure you use daily, this guide provides the depth and breadth you need. We will cover the happy paths and the edge cases, the elegant designs and the hard-won lessons from real-world payment systems.
Let us begin by understanding the different types of wallets and their architectural implications, because the type of wallet you are building fundamentally shapes every downstream design decision.
Why Digital Wallet Design Matters for Senior Engineers
For senior-plus engineers, digital wallet system design is one of the most intellectually demanding and professionally rewarding challenges in the industry. It requires synthesizing knowledge from distributed systems (eventual consistency, consensus protocols, conflict resolution), financial engineering (double-entry bookkeeping, reconciliation, settlement cycles), security (cryptography, key management, secure enclaves), and regulatory compliance (jurisdiction-specific requirements that change frequently). The systems you build in this domain directly handle people's money, which means the cost of bugs is measured not in user inconvenience but in real financial loss and potential legal liability.
The hiring market reflects this complexity. Questions about designing payment systems, wallets, and financial infrastructure appear regularly in staff-plus engineering interviews at companies like Stripe, Square, PayPal, and major banks. This article will equip you with the foundational knowledge and concrete patterns to tackle these challenges with confidence.
2. Wallet Types — Custodial, Non-Custodial, Hot, Cold
Understanding the taxonomy of digital wallets is essential because the wallet type determines your trust model, regulatory obligations, technical architecture, and user experience. Each type represents a different point on the spectrum between convenience and user sovereignty.
Custodial Wallets
A custodial wallet is one where a third-party provider (the custodian) holds the user's funds and private keys on their behalf. When you use PayPal, Venmo, or your bank's mobile app, you are using a custodial wallet. The custodian maintains a database that tracks balances, processes transactions, and ensures funds are available when users want to transact. The user trusts the custodian to safeguard their money and honor withdrawal requests.
The primary advantage of custodial wallets is simplicity. Users do not need to understand cryptography, manage seed phrases, or worry about losing access to their funds due to a lost device. The custodian handles all security, backup, and recovery. This makes custodial wallets ideal for mainstream consumer applications where ease of use is paramount.
However, custodial wallets come with significant responsibilities. The custodian must maintain adequate reserves (ensuring they always have enough funds to cover all user balances), comply with money transmission regulations, implement robust security measures, and provide clear audit trails. In many jurisdictions, custodial wallet providers must obtain money transmitter licenses, maintain minimum capital requirements, and submit to regular audits.
Non-Custodial Wallets
Non-custodial wallets (also called self-custody wallets) give users full control over their private keys and funds. The wallet software generates and stores keys locally on the user's device. No third party can access, freeze, or seize the user's funds. Examples include MetaMask, Trust Wallet, and hardware wallets like Ledger and Trezor.
The architectural implications of non-custodial wallets are profound. There is no central database of user balances — the balance is derived from the blockchain. Transaction signing happens locally on the user's device. The wallet provider's infrastructure is primarily focused on blockchain interaction (broadcasting transactions, fetching balances, indexing transaction history) rather than fund custody.
Hot Wallets vs. Cold Wallets
The hot/cold distinction refers to whether the wallet's private keys are connected to the internet. Hot wallets are internet-connected and enable fast, convenient transactions but are more vulnerable to online attacks. Cold wallets store keys offline (in hardware devices, air-gapped computers, or paper backups) and provide superior security at the cost of convenience.
| Characteristic | Custodial Hot | Custodial Cold | Non-Custodial Hot | Non-Custodial Cold |
|---|---|---|---|---|
| Key Storage | Server-side (HSM) | Offline vault | User device | Hardware device |
| Transaction Speed | Instant | Hours to days | Instant | Manual signing required |
| Security Model | Trust custodian | Trust custodian | Trust yourself | Trust yourself |
| Regulatory Burden | High | High | Low to moderate | Minimal |
| User Experience | Seamless | Slow | Moderate | Complex |
| Recovery | Custodian manages | Custodian manages | User manages seed | User manages seed |
| Ideal Use Case | Daily payments | Reserve storage | DeFi, Web3 | Long-term storage |
Hybrid Models
Many modern platforms adopt hybrid approaches. For example, a cryptocurrency exchange might keep 95% of user funds in cold storage (offline) and maintain a hot wallet with enough liquidity for daily withdrawals. This "95/5 split" is an industry standard that balances security with operational liquidity. The hot wallet is typically secured by multi-signature schemes requiring multiple hardware signers, and automated rebalancing mechanisms move funds between hot and cold storage based on configurable thresholds.
For our system design throughout this article, we will primarily focus on building a custodial hot wallet system, as this represents the most complex and common scenario in mainstream digital wallet applications. The patterns and principles we discuss can be extended to other wallet types as needed.
3. System Architecture Overview
Before diving into individual components, let us establish the high-level architecture of a digital wallet platform. The system must be decomposable into loosely coupled services that can be developed, deployed, and scaled independently. At the same time, these services must coordinate to ensure financial correctness — a transaction that debits one account must always credit another, and the ledger must always balance.
Service Decomposition Principles
The architecture follows several key principles. First, we apply the Single Responsibility Principle aggressively — each service owns one bounded context. The Account Service manages user profiles and KYC status. The Wallet Service manages wallet balances and top-up/withdrawal operations. The Payment Service handles card and bank payment processing. The Transfer Service manages P2P transfers. The Ledger Service is the single source of truth for all financial records.
Second, we embrace eventual consistency where appropriate but demand strong consistency where financial correctness requires it. For example, displaying a user's transaction history can tolerate brief delays, but the actual balance deduction in a payment must be atomic and consistent.
Third, we design for failure from the beginning. Every external call (to banks, card networks, KYC providers) must have circuit breakers, retries with exponential backoff, and idempotency mechanisms. The system must degrade gracefully — if the notification service is down, payments should still process.
Communication Patterns
Services communicate through a combination of synchronous REST/gRPC calls and asynchronous event-driven messaging. Critical path operations (like payment authorization) use synchronous calls for immediate response. Non-critical operations (like sending notifications, updating analytics, triggering rewards) are handled asynchronously through an event bus such as Apache Kafka or RabbitMQ.
4. Account Management and KYC
Account management in a digital wallet system extends far beyond simple user registration. It encompasses identity verification, tiered account classification, ongoing monitoring, and compliance with jurisdiction-specific regulations. The KYC (Know Your Customer) process is particularly critical — it is both a regulatory requirement and a risk management tool that protects the platform and its users.
User Registration Flow
The registration flow must balance friction against security. Too much friction at signup drives users away; too little creates opportunities for fraud and non-compliance. Most platforms implement a progressive KYC approach, allowing basic usage with minimal information and requiring additional verification as users request higher transaction limits or access to more features.
KYC Tiers and Transaction Limits
| Tier | Verification Level | Requirements | Daily Limit | Monthly Limit | Features |
|---|---|---|---|---|---|
| Tier 0 | Unverified | Email + Phone | $100 | $500 | Receive only |
| Tier 1 | Basic KYC | Full name, DOB, Address | $1,000 | $5,000 | Send, Receive, Top-up |
| Tier 2 | Document KYC | Government ID + Selfie | $10,000 | $50,000 | Bank withdrawal, P2P |
| Tier 3 | Enhanced KYC | Proof of address + Source of funds | $50,000 | $250,000 | Merchant payments, Multi-currency |
| Tier 4 | Full KYC | In-person or video verification | $100,000 | $500,000 | All features, API access |
The account management system must also handle edge cases such as account suspensions (triggered by fraud signals or regulatory requests), account closures (with proper fund return procedures), and account recovery (when users lose access to their devices or credentials).
C# Implementation: Account Service
C#
public class AccountService
{
private readonly IUserRepository _userRepository;
private readonly IKycProvider _kycProvider;
private readonly IEventBus _eventBus;
private readonly ILogger<AccountService> _logger;
public AccountService(
IUserRepository userRepository,
IKycProvider kycProvider,
IEventBus eventBus,
ILogger<AccountService> logger)
{
_userRepository = userRepository;
_kycProvider = kycProvider;
_eventBus = eventBus;
_logger = logger;
}
public async Task<AccountRegistrationResult> RegisterAsync(RegisterRequest request)
{
var existingUser = await _userRepository.FindByEmailAsync(request.Email);
if (existingUser != null)
return AccountRegistrationResult.DuplicateEmail();
var user = new User
{
Id = Guid.NewGuid(),
Email = request.Email,
PhoneNumber = request.PhoneNumber,
FullName = request.FullName,
CreatedAt = DateTime.UtcNow,
AccountTier = AccountTier.Tier0,
Status = AccountStatus.PendingVerification,
KycStatus = KycStatus.NotStarted
};
user.SetPasswordHash(PasswordHasher.Hash(request.Password));
await _userRepository.CreateAsync(user);
await _eventBus.PublishAsync(new AccountCreatedEvent
{
UserId = user.Id,
Email = user.Email,
PhoneNumber = user.PhoneNumber,
Timestamp = DateTime.UtcNow
});
_logger.LogInformation("Account created for {Email}", request.Email);
return AccountRegistrationResult.Success(user.Id);
}
public async Task<KycVerificationResult> SubmitKycAsync(
Guid userId, KycSubmission submission)
{
var user = await _userRepository.GetByIdAsync(userId)
?? throw new NotFoundException("User not found");
if (user.Status == AccountStatus.Suspended)
return KycVerificationResult.AccountSuspended();
var kycResult = await _kycProvider.VerifyAsync(new KycRequest
{
FullName = submission.FullName,
DateOfBirth = submission.DateOfBirth,
Address = submission.Address,
DocumentType = submission.DocumentType,
DocumentFrontImage = submission.DocumentFrontImage,
DocumentBackImage = submission.DocumentBackImage,
SelfieImage = submission.SelfieImage
});
user.KycStatus = kycResult.Status;
user.KycVerifiedAt = kycResult.IsVerified ? DateTime.UtcNow : null;
if (kycResult.IsVerified)
{
user.AccountTier = DetermineTier(submission.RequestedTier, kycResult);
user.Status = AccountStatus.Active;
}
await _userRepository.UpdateAsync(user);
await _eventBus.PublishAsync(new KycCompletedEvent
{
UserId = userId,
Status = kycResult.Status,
NewTier = user.AccountTier,
Timestamp = DateTime.UtcNow
});
return KycVerificationResult.FromKycResponse(kycResult);
}
private AccountTier DetermineTier(AccountTier requested, KycResponse kyc)
{
return requested switch
{
AccountTier.Tier1 when kyc.BasicChecksPassed => AccountTier.Tier1,
AccountTier.Tier2 when kyc.DocumentVerified => AccountTier.Tier2,
AccountTier.Tier3 when kyc.EnhancedDueDiligence => AccountTier.Tier3,
AccountTier.Tier4 when kyc.FullVerification => AccountTier.Tier4,
_ => AccountTier.Tier0
};
}
}
Ongoing Monitoring and Refresh
KYC is not a one-time event. Regulations require periodic re-verification, typically every 1-3 years depending on the jurisdiction and account tier. Additionally, ongoing monitoring must detect changes in user risk profile — for example, a sudden spike in transaction volume, transactions with sanctioned entities, or adverse media mentions. This continuous monitoring is sometimes called "perpetual KYC" or pKYC, and it represents the modern approach to compliance that moves beyond point-in-time checks.
The system must also handle PEP (Politically Exposed Persons) screening, sanctions list checks (OFAC, EU, UN), and adverse media screening. These checks must be performed at onboarding and periodically thereafter, with results stored in an auditable format.
5. Ledger Design — Double-Entry Bookkeeping
The ledger is the beating heart of any financial system. It is the immutable, authoritative record of every financial event in the system. A well-designed ledger ensures that money is never created or destroyed without a corresponding entry, that balances are always accurate, and that every transaction can be traced and audited. Getting the ledger right is arguably the single most important architectural decision in a digital wallet system.
Why Double-Entry Bookkeeping?
Double-entry bookkeeping has been the gold standard for financial record-keeping for over 500 years, and for good reason: it is mathematically self-checking. Every transaction involves at least two entries — a debit and a credit — and the sum of all debits must always equal the sum of all credits. This invariant makes it impossible for money to appear or disappear without a corresponding entry, and any discrepancy immediately signals an error or fraud.
In a digital wallet system, every operation — top-up, withdrawal, payment, P2P transfer, fee collection, rewards crediting — is recorded as a ledger entry with both a debit and a credit side. When a user tops up $100 from their bank account, the ledger records a debit to the bank settlement account and a credit to the user's wallet balance. When they make a $30 payment to a merchant, the ledger records a debit from the user's wallet and a credit to the merchant's wallet (minus fees).
Ledger Schema Design
| Column | Type | Description |
|---|---|---|
| entry_id | UUID (PK) | Unique identifier for this ledger entry |
| transaction_id | UUID (FK) | Links to the parent transaction |
| account_id | UUID (FK) | The account affected by this entry |
| entry_type | ENUM | DEBIT or CREDIT |
| amount | DECIMAL(19,4) | Positive amount (sign determined by entry_type) |
| currency | CHAR(3) | ISO 4217 currency code |
| balance_before | DECIMAL(19,4) | Account balance before this entry |
| balance_after | DECIMAL(19,4) | Account balance after this entry |
| description | VARCHAR(500) | Human-readable description |
| metadata | JSONB | Flexible metadata (fee breakdown, etc.) |
| created_at | TIMESTAMPTZ | When this entry was recorded |
| created_by | VARCHAR(100) | System or user that initiated |
| idempotency_key | VARCHAR(255) | Prevents duplicate entries |
C# Implementation: Ledger Service
C#
public class LedgerService : ILedgerService
{
private readonly ILedgerRepository _ledgerRepo;
private readonly IAccountRepository _accountRepo;
private readonly ILogger<LedgerService> _logger;
public LedgerService(
ILedgerRepository ledgerRepo,
IAccountRepository accountRepo,
ILogger<LedgerService> logger)
{
_ledgerRepo = ledgerRepo;
_accountRepo = accountRepo;
_logger = logger;
}
public async Task<LedgerResult> PostTransactionAsync(
TransactionRequest request, CancellationToken ct = default)
{
if (request.Entries.Count < 2)
throw new LedgerException("A transaction requires at least two entries");
var totalDebits = request.Entries
.Where(e => e.Type == EntryType.Debit)
.Sum(e => e.Amount);
var totalCredits = request.Entries
.Where(e => e.Type == EntryType.Credit)
.Sum(e => e.Amount);
if (Math.Abs(totalDebits - totalCredits) > 0.0001m)
throw new LedgerException(
$"Debits ({totalDebits}) must equal credits ({totalCredits})");
if (request.Entries.Any(e => e.Amount <= 0))
throw new LedgerException("Entry amounts must be positive");
var entries = new List<LedgerEntry>();
foreach (var req in request.Entries)
{
var account = await _accountRepo.GetByIdAsync(req.AccountId, ct)
?? throw new NotFoundException(
$"Account {req.AccountId} not found");
var currentBalance = await _ledgerRepo
.GetBalanceAsync(req.AccountId, req.Currency, ct);
if (req.Type == EntryType.Debit &&
currentBalance < req.Amount)
throw new InsufficientFundsException(
$"Account {req.AccountId} has insufficient funds");
var balanceAfter = req.Type == EntryType.Debit
? currentBalance - req.Amount
: currentBalance + req.Amount;
entries.Add(new LedgerEntry
{
Id = Guid.NewGuid(),
TransactionId = request.TransactionId,
AccountId = req.AccountId,
Type = req.Type,
Amount = req.Amount,
Currency = req.Currency,
BalanceBefore = currentBalance,
BalanceAfter = balanceAfter,
Description = request.Description,
Metadata = req.Metadata,
CreatedAt = DateTime.UtcNow,
CreatedBy = request.InitiatedBy,
IdempotencyKey = request.IdempotencyKey
});
}
await _ledgerRepo.PostEntriesAsync(entries, ct);
foreach (var entry in entries)
{
await _accountRepo.UpdateBalanceAsync(
entry.AccountId, entry.Currency,
entry.BalanceAfter, ct);
}
_logger.LogInformation(
"Posted transaction {TxId} with {Count} entries, " +
"total: {Amount} {Currency}",
request.TransactionId, entries.Count,
totalDebits, request.Entries.First().Currency);
return LedgerResult.Success(entries);
}
public async Task<decimal> GetBalanceAsync(
Guid accountId, string currency,
CancellationToken ct = default)
{
return await _ledgerRepo
.GetBalanceAsync(accountId, currency, ct);
}
public async Task<ReconciliationResult> ReconcileAsync(
DateTime date, CancellationToken ct = default)
{
var entries = await _ledgerRepo
.GetEntriesForDateAsync(date, ct);
var totalDebits = entries
.Where(e => e.Type == EntryType.Debit)
.Sum(e => e.Amount);
var totalCredits = entries
.Where(e => e.Type == EntryType.Credit)
.Sum(e => e.Amount);
var isBalanced = Math.Abs(totalDebits - totalCredits) < 0.01m;
if (!isBalanced)
{
_logger.LogCritical(
"LEDGER IMBALANCE on {Date}: debits={Debits}, " +
"credits={Credits}, difference={Diff}",
date, totalDebits, totalCredits,
totalDebits - totalCredits);
}
return new ReconciliationResult
{
Date = date,
TotalEntries = entries.Count,
TotalDebits = totalDebits,
TotalCredits = totalCredits,
IsBalanced = isBalanced
};
}
}
Idempotency and the Double-Submit Problem
In distributed systems, network failures can cause requests to be retried. Without idempotency guarantees, a single payment could result in multiple ledger entries, corrupting balances. Every ledger operation must include an idempotency key, and the system must guarantee that posting the same idempotency key twice produces only one set of entries. This is typically implemented using a unique constraint on the idempotency_key column combined with a database transaction that checks for duplicates before inserting.
Reconciliation
Reconciliation is the process of verifying that the ledger is internally consistent and matches external records. Internal reconciliation checks that debits equal credits for all periods. External reconciliation compares ledger records against bank statements, card network settlements, and payment processor reports. Discrepancies must be investigated and resolved, typically through an automated matching process with manual review for exceptions. Most financial regulations require daily reconciliation, and any unresolved discrepancies must be escalated within defined timeframes.
6. Payment Processing — Card, Bank Transfer, QR
Payment processing is the core revenue-generating capability of a digital wallet. It involves accepting payments from various sources (credit/debit cards, bank accounts, other wallets) and disbursing funds to merchants and recipients. Each payment method has its own technical protocol, settlement timeline, fee structure, and failure modes that the system must handle correctly.
Card Payment Processing
Card payments remain the most widely used digital payment method globally. Processing a card payment involves multiple entities: the cardholder's issuing bank, the card network (Visa, Mastercard, etc.), the acquiring bank, and the payment gateway. The authorization flow follows a well-defined protocol:
The key technical challenge in card processing is handling the various failure modes: do-not-honor (insufficient funds), expired card, wrong CVV, issuer decline, network timeout, and fraud hold. Each failure mode requires different retry logic, user messaging, and fallback behavior. The system must also handle 3D Secure authentication for European transactions (required by PSD2's Strong Customer Authentication mandate) and manage tokenized card credentials for returning users.
Bank Transfer Processing
Bank transfers (ACH in the US, SEPA in Europe, NEFT/IMPS/UPI in India, Faster Payments in the UK) offer lower fees than card payments but have different characteristics. ACH transfers typically settle in 1-3 business days (same-day ACH is available but more expensive). SEPA transfers settle within one business day. UPI in India offers real-time settlement. The system must account for these different settlement timelines when making funds available to users — a user who tops up via ACH should not be able to withdraw those funds before the ACH settles, otherwise the platform assumes settlement risk.
QR Code Payments
QR code payments have become enormously popular in Asia and are gaining traction globally. There are two main models: static QR codes (where the merchant's payment details are encoded in the QR code and the customer enters the amount) and dynamic QR codes (where both the merchant details and the exact amount are encoded, generated per-transaction). The wallet app scans the QR code, extracts the payment information, and initiates a transfer through the appropriate rail (often UPI, PromptPay, or a proprietary network).
C# Implementation: Payment Processing
C#
public class PaymentProcessor : IPaymentProcessor
{
private readonly ICardPaymentGateway _cardGateway;
private readonly IBankTransferService _bankService;
private readonly IQrPaymentService _qrService;
private readonly IFraudDetector _fraudDetector;
private readonly ILedgerService _ledgerService;
private readonly IPaymentRepository _paymentRepo;
private readonly ILogger<PaymentProcessor> _logger;
public async Task<PaymentResult> ProcessPaymentAsync(
PaymentRequest request, CancellationToken ct = default)
{
var fraudCheck = await _fraudDetector.EvaluateAsync(
new FraudContext
{
UserId = request.UserId,
Amount = request.Amount,
Currency = request.Currency,
PaymentMethod = request.PaymentMethod,
DeviceFingerprint = request.DeviceFingerprint,
IpAddress = request.IpAddress,
Timestamp = DateTime.UtcNow
}, ct);
if (fraudCheck.ShouldBlock)
{
_logger.LogWarning(
"Payment blocked for user {UserId}: {Reason}",
request.UserId, fraudCheck.Reason);
return PaymentResult.Blocked(fraudCheck.Reason);
}
if (fraudCheck.RequiresStepUp)
return PaymentResult.RequiresAdditionalAuth(
fraudCheck.StepUpMethods);
IPaymentChannel channel = request.PaymentMethod switch
{
PaymentMethodType.Card => _cardGateway,
PaymentMethodType.BankTransfer => _bankService,
PaymentMethodType.QrCode => _qrService,
_ => throw new NotSupportedException(
$"Payment method {request.PaymentMethod} not supported")
};
var authorization = await channel.AuthorizeAsync(
new ChannelAuthorizationRequest
{
Amount = request.Amount,
Currency = request.Currency,
PaymentToken = request.PaymentToken,
IdempotencyKey = request.IdempotencyKey,
Metadata = request.Metadata
}, ct);
if (!authorization.IsSuccessful)
{
return PaymentResult.Declined(
authorization.ResponseCode,
authorization.ResponseMessage);
}
var txId = Guid.NewGuid();
var ledgerResult = await _ledgerService.PostTransactionAsync(
new TransactionRequest
{
TransactionId = txId,
Description = $"Payment to {request.RecipientName}",
IdempotencyKey = request.IdempotencyKey,
InitiatedBy = request.UserId.ToString(),
Entries = new List<EntryRequest>
{
new EntryRequest
{
AccountId = request.UserWalletAccountId,
Type = EntryType.Debit,
Amount = request.Amount,
Currency = request.Currency
},
new EntryRequest
{
AccountId = request.MerchantAccountId,
Type = EntryType.Credit,
Amount = request.Amount - request.Fee,
Currency = request.Currency
},
new EntryRequest
{
AccountId = request.PlatformFeeAccountId,
Type = EntryType.Credit,
Amount = request.Fee,
Currency = request.Currency
}
}
}, ct);
var payment = new PaymentRecord
{
Id = txId,
UserId = request.UserId,
Amount = request.Amount,
Currency = request.Currency,
Status = PaymentStatus.Completed,
AuthorizationCode = authorization.AuthCode,
ProcessedAt = DateTime.UtcNow
};
await _paymentRepo.SaveAsync(payment, ct);
return PaymentResult.Success(txId, authorization.AuthCode);
}
}
Payment Method Comparison
| Attribute | Credit/Debit Card | Bank Transfer (ACH) | UPI / IMPS | QR Code |
|---|---|---|---|---|
| Settlement Time | T+1 to T+2 | T+2 to T+3 | Instant | Instant to T+1 |
| Typical Fee | 1.5% - 3.5% | $0.20 - $1.00 | Free to $0.05 | 0% - 1% |
| Chargeback Risk | High | Low | Minimal | Varies |
| User Friction | Low | Medium | Low | Low |
| Geographic Availability | Global | Regional | India-specific | Global |
| Refund Speed | 3-10 business days | 2-5 business days | Instant | Varies by rail |
Idempotency in Payment Processing
Payment idempotency is absolutely critical. If a network timeout causes a client to retry a payment request, the system must not charge the customer twice. Every payment request includes an idempotency key (typically a UUID generated by the client), and the system stores the result of the first processing attempt. Subsequent requests with the same idempotency key return the stored result without reprocessing. This applies to both the internal ledger operations and the external payment gateway calls.
The idempotency window is typically 24-48 hours, after which the key expires and a new request with a new key would be required. The idempotency key must be stored at both the application level (in the database) and at the payment gateway level (most gateways accept an idempotency key parameter).
7. P2P Transfer Engine
Peer-to-peer (P2P) transfers are one of the most popular features of digital wallets. They allow users to send money to other users instantly, typically using just a phone number, email address, or username. The P2P transfer engine must handle the complexities of instant settlement between accounts, daily and per-transaction limits, transfer fees (if any), and the various edge cases that arise when money moves between users within the same platform.
P2P Transfer Architecture
Within the same wallet platform, P2P transfers are essentially internal ledger operations — no external payment network is involved. The system debits the sender's wallet and credits the recipient's wallet in a single atomic transaction. This makes intra-platform P2P transfers extremely fast (sub-second) and essentially free from a processing cost perspective (though the platform may choose to charge fees).
The more complex case is cross-platform P2P transfers, where the sender and recipient use different wallet providers. In this case, the transfer must traverse an external rail such as a bank transfer, a card network push payment (Visa Direct, Mastercard Send), or an inter-wallet settlement network. Cross-platform transfers are slower, more expensive, and subject to the limitations of the underlying payment rail.
C# Implementation: P2P Transfer Service
C#
public class P2pTransferService : IP2pTransferService
{
private readonly IAccountRepository _accountRepo;
private readonly ILedgerService _ledgerService;
private readonly IUserLookupService _lookupService;
private readonly ILimitsService _limitsService;
private readonly IEventBus _eventBus;
private readonly ILogger<P2pTransferService> _logger;
public async Task<TransferResult> TransferAsync(
P2pTransferRequest request, CancellationToken ct = default)
{
var sender = await _accountRepo.GetByIdAsync(
request.SenderUserId, ct)
?? throw new NotFoundException("Sender not found");
var recipient = await _lookupService.LookupRecipientAsync(
request.RecipientIdentifier, ct)
?? throw new NotFoundException(
"Recipient not found for: " + request.RecipientIdentifier);
if (sender.Id == recipient.UserId)
return TransferResult.Error(
"Cannot transfer to yourself");
if (sender.Status != AccountStatus.Active)
return TransferResult.Error(
"Sender account is not active");
if (recipient.Status != AccountStatus.Active)
return TransferResult.Error(
"Recipient account is not active");
var limitsCheck = await _limitsService.CheckTransferLimitsAsync(
new TransferLimitsContext
{
SenderUserId = sender.Id,
Amount = request.Amount,
Currency = request.Currency,
TransferType = TransferType.P2p,
Period = DateTime.UtcNow.Date
}, ct);
if (!limitsCheck.IsWithinLimits)
return TransferResult.Error(
$"Transfer exceeds {limitsCheck.LimitType} limit. " +
$"Remaining: {limitsCheck.RemainingAmount}");
if (sender.IsSamePlatform(recipient))
{
return await ProcessIntraPlatformTransferAsync(
sender, recipient, request, ct);
}
else
{
return await ProcessCrossPlatformTransferAsync(
sender, recipient, request, ct);
}
}
private async Task<TransferResult> ProcessIntraPlatformTransferAsync(
UserAccount sender, UserAccount recipient,
P2pTransferRequest request, CancellationToken ct)
{
var txId = Guid.NewGuid();
var fee = CalculateFee(request.Amount, request.Currency);
var ledgerResult = await _ledgerService.PostTransactionAsync(
new TransactionRequest
{
TransactionId = txId,
Description = $"P2P transfer to {recipient.FullName}",
IdempotencyKey = request.IdempotencyKey,
InitiatedBy = sender.Id.ToString(),
Entries = new List<EntryRequest>
{
new EntryRequest
{
AccountId = sender.WalletAccountId,
Type = EntryType.Debit,
Amount = request.Amount + fee,
Currency = request.Currency
},
new EntryRequest
{
AccountId = recipient.WalletAccountId,
Type = EntryType.Credit,
Amount = request.Amount,
Currency = request.Currency
},
new EntryRequest
{
AccountId = request.FeeAccountPlatformId,
Type = EntryType.Credit,
Amount = fee,
Currency = request.Currency
}
}
}, ct);
await _limitsService.RecordTransferAsync(
sender.Id, request.Amount, request.Currency, ct);
await _eventBus.PublishAsync(new P2pTransferCompletedEvent
{
TransactionId = txId,
SenderUserId = sender.Id,
RecipientUserId = recipient.UserId,
Amount = request.Amount,
Currency = request.Currency,
Fee = fee,
Timestamp = DateTime.UtcNow
});
_logger.LogInformation(
"P2P transfer {TxId}: {Sender} -> {Recipient}, " +
"{Amount} {Currency}",
txId, sender.Id, recipient.UserId,
request.Amount, request.Currency);
return TransferResult.Success(txId, fee);
}
private decimal CalculateFee(decimal amount, string currency)
{
if (currency == "USD" && amount <= 100m) return 0m;
return Math.Round(amount * 0.01m, 2);
}
}
Transfer Limits and Velocity Checks
| Limit Type | Tier 1 | Tier 2 | Tier 3 | Tier 4 |
|---|---|---|---|---|
| Per Transaction | $500 | $2,500 | $10,000 | $50,000 |
| Daily | $1,000 | $5,000 | $25,000 | $100,000 |
| Weekly | $3,000 | $15,000 | $75,000 | $300,000 |
| Monthly | $5,000 | $25,000 | $100,000 | $500,000 |
| Max Recipients/Day | 5 | 20 | 50 | Unlimited |
| Max Amount to New Recipient | $100 | $500 | $2,000 | $10,000 |
Velocity checks are an essential fraud prevention measure. The system tracks how frequently a user transacts, how many unique recipients they send to, and whether their transaction patterns deviate from their historical baseline. Sudden spikes — such as a user who normally sends $50 per week suddenly attempting to send $5,000 to five new recipients — trigger additional verification steps (step-up authentication, manual review, or temporary holds).
Request-to-Pay and Payment Links
Beyond simple push transfers, many wallet platforms support request-to-pay (where the recipient sends a payment request that the sender can approve) and payment links (where a user generates a link that anyone can use to send them money). These features add convenience but require additional security measures — payment links can be phished if shared carelessly, and request-to-pay can be abused for social engineering attacks. Rate limiting on link generation and request sending is essential.
8. Top-Up and Withdrawal Flows
Top-up (adding funds to the wallet) and withdrawal (moving funds out of the wallet) are the primary on-ramps and off-ramps between the digital wallet and the traditional financial system. These flows involve interactions with external payment networks and must handle settlement risk, fraud, and regulatory compliance. The design of these flows directly impacts user experience, platform liquidity, and financial risk.
Top-Up Methods and Flows
Users can top up their wallets through several methods: bank account debit (ACH/SEPA), credit or debit card, another digital wallet, cash deposit (through retail partnerships), and cryptocurrency. Each method has different characteristics in terms of speed, cost, risk, and geographic availability. The system must present the user with the appropriate options based on their KYC tier, location, and the available payment methods in their region.
The critical risk in top-ups is settlement risk — the wallet platform credits the user's balance immediately (or near-immediately) based on an authorization from the payment network, but the actual settlement of funds may take 1-3 business days. If the underlying payment fails to settle (e.g., an ACH return, a card chargeback), the platform must be able to reverse the user's balance. This means the platform is effectively extending credit to the user during the settlement window, and must manage this risk through hold periods, velocity limits, and reserve requirements.
C# Implementation: Top-Up Service
C#
public class TopUpService : ITopUpService
{
private readonly ILedgerService _ledgerService;
private readonly ICardPaymentGateway _cardGateway;
private readonly IBankTransferService _bankService;
private readonly ITopUpRepository _topUpRepo;
private readonly IHoldService _holdService;
private readonly ILogger<TopUpService> _logger;
public async Task<TopUpResult> ProcessTopUpAsync(
TopUpRequest request, CancellationToken ct = default)
{
var user = await ValidateUserAsync(request.UserId, ct);
var limitsCheck = await CheckTopUpLimitsAsync(
request.UserId, request.Amount, request.Currency, ct);
if (!limitsCheck.IsWithinLimits)
return TopUpResult.LimitExceeded(limitsCheck);
TopUpRecord record = new TopUpRecord
{
Id = Guid.NewGuid(),
UserId = request.UserId,
Amount = request.Amount,
Currency = request.Currency,
Method = request.Method,
Status = TopUpStatus.Processing,
CreatedAt = DateTime.UtcNow,
IdempotencyKey = request.IdempotencyKey
};
try
{
switch (request.Method)
{
case TopUpMethod.Card:
return await ProcessCardTopUpAsync(
record, request, ct);
case TopUpMethod.BankTransfer:
return await ProcessBankTopUpAsync(
record, request, ct);
default:
throw new NotSupportedException(
$"Top-up method {request.Method} not supported");
}
}
catch (Exception ex)
{
record.Status = TopUpStatus.Failed;
record.FailureReason = ex.Message;
await _topUpRepo.SaveAsync(record, ct);
throw;
}
}
private async Task<TopUpResult> ProcessCardTopUpAsync(
TopUpRecord record, TopUpRequest request,
CancellationToken ct)
{
var authResult = await _cardGateway.ChargeAsync(
new CardChargeRequest
{
Amount = request.Amount,
Currency = request.Currency,
PaymentToken = request.PaymentToken,
IdempotencyKey = record.IdempotencyKey,
Capture = true
}, ct);
if (!authResult.IsSuccess)
{
record.Status = TopUpStatus.Failed;
record.FailureReason = authResult.ResponseMessage;
await _topUpRepo.SaveAsync(record, ct);
return TopUpResult.PaymentFailed(
authResult.ResponseCode,
authResult.ResponseMessage);
}
if (RequiresHold(request.Amount, request.Currency))
{
await _holdService.PlaceHoldAsync(
request.UserId, request.Amount,
request.Currency, record.Id, ct);
record.Status = TopUpStatus.OnHold;
}
else
{
await CreditWalletAsync(
request.UserId, request.Amount,
request.Currency, record.Id, ct);
record.Status = TopUpStatus.Completed;
}
record.AuthorizationCode = authResult.AuthCode;
record.ProcessedAt = DateTime.UtcNow;
await _topUpRepo.SaveAsync(record, ct);
return TopUpResult.Success(record.Id, record.Status);
}
private async Task CreditWalletAsync(
Guid userId, decimal amount, string currency,
Guid topUpId, CancellationToken ct)
{
await _ledgerService.PostTransactionAsync(
new TransactionRequest
{
TransactionId = Guid.NewGuid(),
Description = $"Top-up via card",
IdempotencyKey = $"topup-credit-{topUpId}",
InitiatedBy = "system",
Entries = new List<EntryRequest>
{
new EntryRequest
{
AccountId = await GetSettlementAccountAsync(
currency, ct),
Type = EntryType.Debit,
Amount = amount,
Currency = currency
},
new EntryRequest
{
AccountId = await GetUserWalletAccountAsync(
userId, currency, ct),
Type = EntryType.Credit,
Amount = amount,
Currency = currency
}
}
}, ct);
}
private bool RequiresHold(decimal amount, string currency)
{
return (currency == "USD" && amount > 2500m) ||
(currency == "EUR" && amount > 2000m);
}
}
Withdrawal Processing
Withdrawals move funds from the wallet to an external bank account or card. The withdrawal flow must verify the destination account (ensuring it belongs to the user), check for sufficient balance and available (non-held) funds, comply with daily and monthly withdrawal limits, and handle the asynchronous nature of bank transfers. Withdrawals to bank accounts typically settle in 1-3 business days, during which the funds are debited from the user's wallet but not yet credited to their bank account.
| Top-Up Method | Availability | Speed | Fee Range | Risk Level |
|---|---|---|---|---|
| Debit Card | Instant | Instant | 1.5% - 2.5% | Medium |
| Credit Card | Instant | Instant | 2.5% - 3.5% | High (chargebacks) |
| ACH Pull | 1-3 days | 1-3 days | $0.20 - $1.00 | Low-Medium |
| SEPA Transfer | 1 business day | 1 business day | €0.00 - €1.00 | Low |
| UPI (India) | Instant | Instant | Free | Low |
| Cash (Retail) | Same day | Instant to 24h | $1.00 - $5.00 | Low |
Settlement Reconciliation
After top-ups and withdrawals settle, the platform must reconcile the expected settlement amounts (based on its internal records) against the actual amounts received from (or sent to) external banks and payment networks. Discrepancies must be identified, investigated, and resolved within regulatory timeframes. Automated reconciliation systems match transactions by amount, reference number, and date, flagging unmatched items for manual review. Daily reconciliation is a regulatory requirement in most jurisdictions.
9. Tokenization and Card-on-File
Tokenization is the process of replacing sensitive payment credentials (such as a full card number, or PAN — Primary Account Number) with a non-sensitive equivalent called a token. This token has no extrinsic or exploitable meaning or value and can be stored, transmitted, and processed without exposing the underlying card details. Tokenization is a cornerstone of PCI-DSS compliance and is essential for any digital wallet that stores card-on-file credentials for recurring or one-click payments.
How Tokenization Works
When a user adds a card to their wallet for the first time, the full card details are sent to the payment gateway or tokenization provider, which returns a token (typically a string like tok_abc123xyz or a vault ID like vault_456def). The wallet platform stores this token — not the actual card number — in its database. When the user subsequently makes a payment, the platform sends the token to the payment gateway, which detokenizes it internally and processes the payment using the actual card details stored in its secure vault.
Network Tokenization vs. Gateway Tokenization
There are two primary tokenization approaches. Network tokenization (provided by Visa Token Service, Mastercard MDES, etc.) replaces the card number with a network-level token that can be used across different acquirers and gateways. Gateway tokenization (provided by Stripe, Braintree, Adyen, etc.) creates a token that is specific to a particular gateway. Network tokens generally offer better acceptance rates and lower fees, but require more integration effort. Gateway tokens are simpler to implement but lock you to a specific provider.
C# Implementation: Tokenization Service
C#
public class TokenizationService : ITokenizationService
{
private readonly ITokenVaultRepository _tokenVault;
private readonly IPaymentGatewayFactory _gatewayFactory;
private readonly IHsmClient _hsmClient;
private readonly ILogger<TokenizationService> _logger;
public async Task<TokenizationResult>_tokenizeCardAsync(
CardTokenizationRequest request, CancellationToken ct = default)
{
ValidateCardInput(request);
var gateway = _gatewayFactory.GetGateway(request.GatewayType);
var tokenResponse = await gateway.TokenizeAsync(
new GatewayTokenizationRequest
{
CardNumber = request.CardNumber,
ExpiryMonth = request.ExpiryMonth,
ExpiryYear = request.ExpiryYear,
CardholderName = request.CardholderName,
Cvv = request.Cvv,
BillingAddress = request.BillingAddress
}, ct);
if (!tokenResponse.IsSuccess)
return TokenizationResult.Failed(tokenResponse.ErrorMessage);
var fingerprint = await GenerateCardFingerprintAsync(
request.CardNumber, ct);
var existingToken = await _tokenVault
.FindByFingerprintAsync(fingerprint, ct);
if (existingToken != null)
{
_logger.LogInformation(
"Card already tokenized as {TokenId}", existingToken.Id);
return TokenizationResult.AlreadyTokenized(existingToken);
}
var storedToken = new StoredToken
{
Id = Guid.NewGuid(),
UserId = request.UserId,
GatewayToken = tokenResponse.Token,
GatewayType = request.GatewayType,
CardFingerprint = fingerprint,
CardLastFour = request.CardNumber[^4..],
CardBrand = DetectCardBrand(request.CardNumber),
CardExpiryMonth = request.ExpiryMonth,
CardExpiryYear = request.ExpiryYear,
CardholderName = request.CardholderName,
IsDefault = request.MakeDefault,
IsActive = true,
CreatedAt = DateTime.UtcNow,
NetworkToken = tokenResponse.NetworkToken
};
if (storedToken.IsDefault)
await _tokenVault
.ClearDefaultForUserAsync(request.UserId, ct);
await _tokenVault.SaveAsync(storedToken, ct);
_logger.LogInformation(
"Card tokenized for user {UserId}: {Brand} ****{LastFour}",
request.UserId, storedToken.CardBrand,
storedToken.CardLastFour);
return TokenizationResult.Success(storedToken);
}
public async Task<PaymentResult> ChargeTokenAsync(
TokenPaymentRequest request, CancellationToken ct = default)
{
var token = await _tokenVault.GetByIdAsync(
request.TokenId, ct)
?? throw new NotFoundException("Payment token not found");
if (!token.IsActive)
throw new PaymentException("Payment token is inactive");
if (token.CardExpiryYear < DateTime.UtcNow.Year ||
(token.CardExpiryYear == DateTime.UtcNow.Year &&
token.CardExpiryMonth < DateTime.UtcNow.Month))
{
token.IsActive = false;
await _tokenVault.UpdateAsync(token, ct);
throw new PaymentException("Card has expired");
}
var gateway = _gatewayFactory.GetGateway(token.GatewayType);
var chargeResult = await gateway.ChargeWithTokenAsync(
new GatewayChargeRequest
{
Token = token.GatewayToken,
Amount = request.Amount,
Currency = request.Currency,
IdempotencyKey = request.IdempotencyKey,
Metadata = request.Metadata
}, ct);
return chargeResult.IsSuccess
? PaymentResult.Success(chargeResult.TransactionId)
: PaymentResult.Declined(
chargeResult.ResponseCode,
chargeResult.ErrorMessage);
}
private async Task<string> GenerateCardFingerprintAsync(
string cardNumber, CancellationToken ct)
{
var normalized = cardNumber.Replace(" ", "").Replace("-", "");
var hash = await _hsmClient.ComputeHmacAsync(
"CARD_FINGERPRINT_KEY",
System.Text.Encoding.UTF8.GetBytes(normalized), ct);
return Convert.ToBase64String(hash);
}
private string DetectCardBrand(string cardNumber)
{
var normalized = cardNumber.Replace(" ", "");
if (normalized.StartsWith("4")) return "VISA";
if (normalized.StartsWith("5") || normalized.StartsWith("2"))
return "MASTERCARD";
if (normalized.StartsWith("34") || normalized.StartsWith("37"))
return "AMEX";
if (normalized.StartsWith("6011") || normalized.StartsWith("65"))
return "DISCOVER";
return "UNKNOWN";
}
}
Token Lifecycle Management
| Event | Action | Responsible Party |
|---|---|---|
| Card added | Create token, validate with issuer | Tokenization provider |
| Card expired | Attempt network token refresh | Wallet platform |
| Card replaced (issuer) | Auto-update with new token | Network token service |
| Card lost/stolen | Deactivate token | User or issuer |
| User removes card | Deactivate and archive token | Wallet platform |
| Gateway migration | Re-tokenize with new gateway | Wallet platform |
| Suspected fraud | Temporarily freeze token | Fraud system |
Token lifecycle management is a critical operational concern. When a card expires, the issuer may issue a new card with a different PAN. With network tokenization, this update can be automatically propagated to the token (a feature called "card-on-file updater"). Without network tokens, the platform must proactively prompt users to update their card details when a charge fails due to an expired card. Failed payment recovery flows — where the system detects an expired card and sends the user a notification to update their details — are an important retention mechanism that directly impacts revenue.
10. Fraud Detection and Prevention
Fraud is an existential threat to any digital wallet platform. Unlike credit card fraud (where the issuer bears most of the liability), wallet fraud often results in direct losses to the platform or to innocent users whose accounts are compromised. A robust fraud detection and prevention system must operate in real-time (blocking fraudulent transactions before they complete), adapt to evolving attack vectors, and minimize false positives that degrade the experience for legitimate users.
Fraud Taxonomy
Digital wallet fraud can be categorized into several types. Account takeover (ATO) occurs when an attacker gains access to a legitimate user's account, typically through phishing, credential stuffing, or SIM swapping. New account fraud involves creating fake accounts to exploit promotional bonuses, launder money, or conduct unauthorized transactions. Transaction fraud involves using stolen card credentials to top up a wallet and then quickly withdrawing the funds. Synthetic identity fraud combines real and fake information to create convincing but fraudulent identities that pass KYC checks.
Fraud Detection Architecture
Feature Engineering for Fraud Detection
Effective fraud detection relies on comprehensive feature engineering. The system must extract and evaluate hundreds of signals from each transaction in real-time, including device fingerprinting (device ID, OS, browser, screen resolution), geolocation (IP geolocation, GPS, comparison with usual locations), behavioral biometrics (typing speed, tap patterns, swipe gestures), transaction velocity (number and amount of transactions in various time windows), recipient analysis (is this a new recipient? have they received funds from multiple other accounts?), and cross-account linkages (are multiple accounts using the same device, IP, or bank account?).
C# Implementation: Fraud Detection Engine
C#
public class FraudDetectionEngine : IFraudDetector
{
private readonly IFeatureStore _featureStore;
private readonly IFraudRuleEngine _ruleEngine;
private readonly IFraudMlModel _mlModel;
private readonly IVelocityTracker _velocityTracker;
private readonly IDeviceRepository _deviceRepo;
private readonly ILogger<FraudDetectionEngine> _logger;
public async Task<FraudAssessment> EvaluateAsync(
FraudContext context, CancellationToken ct = default)
{
var features = await ExtractFeaturesAsync(context, ct);
var ruleResult = await _ruleEngine.EvaluateAsync(features, ct);
var mlScore = await _mlModel.PredictAsync(features, ct);
var velocityResult = await _velocityTracker
.CheckVelocityAsync(context, ct);
var compositeScore = CalculateCompositeScore(
ruleResult, mlScore, velocityResult);
_logger.LogInformation(
"Fraud assessment for user {UserId}: " +
"rules={RuleScore}, ml={MlScore}, velocity={VelScore}, " +
"composite={Composite}",
context.UserId, ruleResult.RiskScore,
mlScore.Probability, velocityResult.RiskScore,
compositeScore);
return new FraudAssessment
{
CompositeScore = compositeScore,
ShouldBlock = compositeScore >= 0.85m,
RequiresStepUp = compositeScore >= 0.40m &&
compositeScore < 0.85m,
StepUpMethods = DetermineStepUpMethods(
compositeScore, context),
Reason = compositeScore >= 0.85m
? $"High fraud score: {compositeScore}"
: null,
RuleResults = ruleResult,
MlPrediction = mlScore,
VelocityResults = velocityResult
};
}
private async Task<FraudFeatures> ExtractFeaturesAsync(
FraudContext context, CancellationToken ct)
{
var userHistory = await _featureStore
.GetUserHistoryAsync(context.UserId, ct);
var deviceHistory = await _deviceRepo
.GetDeviceHistoryAsync(context.DeviceFingerprint, ct);
return new FraudFeatures
{
Amount = context.Amount,
AmountDeviation = CalculateDeviation(
context.Amount, userHistory.AverageTransactionAmount),
IsNewDevice = !deviceHistory.Any(d =>
d.Fingerprint == context.DeviceFingerprint),
DeviceAge = deviceHistory.Any(d =>
d.Fingerprint == context.DeviceFingerprint)
? (DateTime.UtcNow - deviceHistory.First(d =>
d.Fingerprint == context.DeviceFingerprint)
.FirstSeen).Days
: 0,
GeoDistance = CalculateGeoDistance(
context.IpAddress,
userHistory.LastKnownLocation),
TransactionCount24h = userHistory
.TransactionCountInWindow(
TimeSpan.FromHours(24)),
AmountSum24h = userHistory
.TransactionSumInWindow(
TimeSpan.FromHours(24)),
UniqueRecipients7d = userHistory
.UniqueRecipientsInWindow(
TimeSpan.FromDays(7)),
TimeSinceLastTx = userHistory.LastTransactionTime.HasValue
? (DateTime.UtcNow - userHistory.LastTransactionTime.Value)
.TotalMinutes
: double.MaxValue,
IsWeekend = DateTime.UtcNow.DayOfWeek ==
DayOfWeek.Saturday ||
DateTime.UtcNow.DayOfWeek == DayOfWeek.Sunday,
IsNightTime = DateTime.UtcNow.Hour < 6 ||
DateTime.UtcNow.Hour > 22
};
}
private decimal CalculateCompositeScore(
RuleEvaluation rules,
MlPrediction ml,
VelocityCheck velocity)
{
return (decimal)(
0.30 * rules.RiskScore +
0.45 * ml.Probability +
0.25 * velocity.RiskScore);
}
private List<StepUpMethod> DetermineStepUpMethods(
decimal score, FraudContext context)
{
var methods = new List<StepUpMethod>();
if (score >= 0.60m)
methods.Add(StepUpMethod.Biometric);
if (score >= 0.50m)
methods.Add(StepUpMethod.OneTimePassword);
if (context.Amount > 1000m)
methods.Add(StepUpMethod.PinEntry);
return methods;
}
}
Velocity Rules Configuration
| Rule | Window | Threshold | Action |
|---|---|---|---|
| Transaction count per user | 1 hour | > 10 transactions | Step-up auth |
| Transaction count per user | 24 hours | > 50 transactions | Block + review |
| Total amount per user | 24 hours | > $5,000 | Step-up auth |
| Total amount per user | 7 days | > $25,000 | Block + review |
| Unique recipients per user | 24 hours | > 20 | Block + review |
| Failed auth attempts per user | 1 hour | > 3 | Temporarily lock |
| New device + high amount | Session | > $500 | Step-up auth |
| Cross-border velocity | 24 hours | > 3 countries | Block + review |
The ML model component typically uses gradient-boosted trees (XGBoost, LightGBM) or neural networks trained on historical transaction data labeled with fraud outcomes. The model is retrained regularly (typically weekly or monthly) with fresh data to adapt to evolving fraud patterns. Real-time feature stores (like Redis or Apache Flink) enable sub-millisecond feature lookups during transaction processing. The combination of deterministic rules (for known patterns and regulatory requirements) and probabilistic ML models (for detecting novel patterns) provides the most robust fraud defense.
11. Regulatory Compliance — PCI-DSS, PSD2, AML
Regulatory compliance is not optional for digital wallet platforms — it is a fundamental requirement that shapes architecture, operations, and business processes. The regulatory landscape is complex, multi-jurisdictional, and constantly evolving. A single compliance failure can result in massive fines (up to €20 million or 4% of global annual turnover under GDPR, for example), loss of licenses, and irreparable reputational damage.
PCI-DSS Compliance
The Payment Card Industry Data Security Standard (PCI-DSS) applies to any organization that stores, processes, or transmits cardholder data. For a digital wallet that tokenizes and stores card credentials, PCI-DSS compliance is mandatory. The standard defines 12 requirements across six goals: build and maintain a secure network, protect cardholder data, maintain a vulnerability management program, implement strong access control measures, regularly monitor and test networks, and maintain an information security policy.
Key architectural implications of PCI-DSS include: card data must never be stored in plaintext (tokenization eliminates this risk), network segmentation must isolate cardholder data environments, encryption must protect data in transit (TLS 1.2+) and at rest (AES-256), access to cardholder data must be restricted on a need-to-know basis, and all access to cardholder data must be logged and monitored.
PSD2 and Strong Customer Authentication
The European Union's Payment Services Directive 2 (PSD2) introduces Strong Customer Authentication (SCA) requirements for electronic payments. SCA requires authentication using at least two of three factors: something the user knows (password, PIN), something the user has (phone, hardware token), and something the user is (biometric). This has significant implications for the wallet's authentication and payment flows.
AML and Transaction Monitoring
Anti-Money Laundering (AML) regulations require wallet platforms to monitor transactions for suspicious activity and file Suspicious Activity Reports (SARs) with relevant Financial Intelligence Units (FIUs). The system must implement rules-based and behavior-based transaction monitoring, maintain comprehensive audit trails, and ensure that all customer due diligence records are retained for the required period (typically 5 years after account closure).
| Regulation | Jurisdiction | Key Requirements | Penalty for Non-Compliance |
|---|---|---|---|
| PCI-DSS | Global (card industry) | 12 data security requirements | $5,000 - $100,000/month |
| PSD2 / SCA | European Economic Area | Two-factor authentication | Liability shift for fraud losses |
| AML / CFT | Global (FATF framework) | KYC, transaction monitoring, SAR filing | Fines, criminal charges, license revocation |
| GDPR | European Union | Data protection, right to erasure | Up to €20M or 4% of global turnover |
| BSA | United States | AML program, CTR and SAR filing | Criminal penalties up to $500,000 |
| EMI License | Various (e.g., FCA, BaFin) | Capital requirements, operational standards | License revocation |
| MiCA | European Union | Crypto-asset service provider rules | Up to €5M or 12.5% of turnover |
Compliance Architecture
The compliance architecture must be designed with auditability as a first-class concern. Every action taken by the system, every decision made by automated rules, and every action taken by compliance officers must be logged with timestamps, actor identification, and reasoning. These audit logs must be immutable (write-once storage or blockchain-anchored hashing), searchable (for regulatory examinations), and retained for the required period (typically 5-7 years depending on jurisdiction).
12. Notification System — SMS, Push, Email
A comprehensive notification system is essential for a digital wallet platform. Notifications serve multiple purposes: confirming transactions (building user trust), alerting users to suspicious activity (fraud prevention), delivering OTPs and verification codes (security), marketing promotions (engagement), and regulatory communications (compliance). The notification system must support multiple channels (push notifications, SMS, email, in-app messages) and must be reliable, scalable, and configurable.
Notification Architecture
The notification system should be built as an event-driven service that consumes events from the main transaction processing pipeline. When a transaction completes, the wallet service publishes an event (e.g., TransactionCompletedEvent) to the message bus. The notification service consumes these events, determines which notifications to send based on the event type and user preferences, and dispatches them to the appropriate channel.
This decoupled architecture ensures that notification failures do not block transaction processing. It also allows the notification system to be scaled independently (notification volumes can spike dramatically during promotional events) and to be updated without affecting the core payment logic.
C# Implementation: Notification Service
C#
public class NotificationService : INotificationService
{
private readonly IUserPreferencesRepository _prefsRepo;
private readonly ISmsProvider _smsProvider;
private readonly IEmailProvider _emailProvider;
private readonly IPushNotificationProvider _pushProvider;
private readonly ITemplateEngine _templateEngine;
private readonly INotificationLogRepo _logRepo;
private readonly ILogger<NotificationService> _logger;
public async Task<NotificationResult> SendAsync(
NotificationRequest request, CancellationToken ct = default)
{
var preferences = await _prefsRepo
.GetByUserIdAsync(request.UserId, ct);
if (!IsNotificationEnabled(preferences, request.Type))
return NotificationResult.Skipped("User preference");
var template = await _templateEngine
.GetTemplateAsync(request.Type, request.Language, ct);
var rendered = template.Render(request.Variables);
var results = new List<ChannelResult>();
if (preferences.PushEnabled &&
ShouldSendPush(request.Type))
{
var pushResult = await SendPushAsync(
request.UserId, rendered.PushTitle,
rendered.PushBody, request.Data, ct);
results.Add(pushResult);
}
if (preferences.SmsEnabled &&
ShouldSendSms(request.Type) &&
!string.IsNullOrEmpty(request.PhoneNumber))
{
var smsResult = await SendSmsAsync(
request.PhoneNumber, rendered.SmsBody, ct);
results.Add(smsResult);
}
if (preferences.EmailEnabled &&
ShouldSendEmail(request.Type) &&
!string.IsNullOrEmpty(request.Email))
{
var emailResult = await SendEmailAsync(
request.Email, rendered.EmailSubject,
rendered.EmailBody, ct);
results.Add(emailResult);
}
await _logRepo.SaveAsync(new NotificationLog
{
Id = Guid.NewGuid(),
UserId = request.UserId,
Type = request.Type,
Channels = results.Select(r => r.Channel).ToList(),
AllSuccessful = results.All(r => r.IsSuccess),
SentAt = DateTime.UtcNow
}, ct);
return NotificationResult.Completed(results);
}
private bool ShouldSendPush(NotificationType type)
{
return type == NotificationType.TransactionCompleted ||
type == NotificationType.FraudAlert ||
type == NotificationType.SecurityAlert ||
type == NotificationType.TopUpCompleted ||
type == NotificationType.WithdrawalCompleted;
}
private bool ShouldSendSms(NotificationType type)
{
return type == NotificationType.FraudAlert ||
type == NotificationType.SecurityAlert ||
type == NotificationType.OtpRequired;
}
private bool ShouldSendEmail(NotificationType type)
{
return type == NotificationType.TransactionCompleted ||
type == NotificationType.KycUpdate ||
type == NotificationType.AccountSecurity;
}
private async Task<ChannelResult> SendPushAsync(
Guid userId, string title, string body,
Dictionary<string, string> data,
CancellationToken ct)
{
try
{
var devices = await GetActiveDevicesAsync(userId, ct);
foreach (var device in devices)
{
await _pushProvider.SendAsync(
new PushMessage
{
DeviceToken = device.PushToken,
Platform = device.Platform,
Title = title,
Body = body,
Data = data,
Badge = await GetUnreadCountAsync(userId, ct)
}, ct);
}
return ChannelResult.Success(NotificationChannel.Push);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send push notification to user {UserId}",
userId);
return ChannelResult.Failure(
NotificationChannel.Push, ex.Message);
}
}
private async Task<ChannelResult> SendSmsAsync(
string phoneNumber, string body, CancellationToken ct)
{
try
{
await _smsProvider.SendAsync(phoneNumber, body, ct);
return ChannelResult.Success(NotificationChannel.Sms);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send SMS to {Phone}", phoneNumber);
return ChannelResult.Failure(
NotificationChannel.Sms, ex.Message);
}
}
private async Task<ChannelResult> SendEmailAsync(
string email, string subject, string body,
CancellationToken ct)
{
try
{
await _emailProvider.SendAsync(
new EmailMessage
{
To = email,
Subject = subject,
HtmlBody = body
}, ct);
return ChannelResult.Success(NotificationChannel.Email);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send email to {Email}", email);
return ChannelResult.Failure(
NotificationChannel.Email, ex.Message);
}
}
}
Notification Template Examples
| Event | Push | SMS | Priority | |
|---|---|---|---|---|
| Payment sent | You sent $X to {name} | $X sent to {name}. Ref: {ref} | Payment confirmation receipt | High |
| Payment received | You received $X from {name} | $X received from {name} | Credit notification | High |
| Top-up completed | $X added to your wallet | Wallet topped up: $X | Top-up receipt | High |
| Fraud alert | ALERT: Suspicious activity | ALERT: Suspicious activity detected | Security alert | Critical |
| Login from new device | New device login detected | New login from {device} | Security notification | High |
| Promo | Special offer available! | N/A | Monthly promotions | Low |
Rate Limiting and Quiet Hours
The notification system must respect rate limits to avoid overwhelming users. A common policy is to limit push notifications to a maximum of 3 per hour and 10 per day for non-critical notifications. SMS and email have their own rate limits (both platform-imposed and carrier-imposed). The system must also respect user-configured quiet hours, where non-critical notifications are batched and delivered during specified windows (e.g., 9 AM to 9 PM). Critical notifications (fraud alerts, security alerts) bypass quiet hours and rate limits.
13. Dispute and Chargeback Management
Disputes and chargebacks are an inevitable part of any payment system. A dispute occurs when a user contests a transaction on their wallet — they may claim they did not authorize the transaction, that the goods or services were not received, or that the amount was incorrect. A chargeback occurs when the issuing bank reverses a card transaction, typically initiated by the cardholder through their bank. Both scenarios require careful handling to protect all parties and comply with card network rules and regulations.
Dispute Lifecycle
The dispute lifecycle typically follows a structured process: the user files a dispute (with supporting evidence), the platform investigates the claim (reviewing transaction logs, device data, and communication records), the platform renders a decision (favoring the user, favoring the merchant, or finding insufficient evidence), and if the dispute involves an external card payment, the chargeback process with the card network may follow. The entire process must be completed within strict timeframes imposed by card networks (typically 30-45 days for the initial response and 45-75 days for resolution).
C# Implementation: Dispute Management
C#
public class DisputeService : IDisputeService
{
private readonly IDisputeRepository _disputeRepo;
private readonly ITransactionRepository _txRepo;
private readonly ICardChargebackService _chargebackService;
private readonly IEventBus _eventBus;
private readonly ILogger<DisputeService> _logger;
public async Task<DisputeResult> FileDisputeAsync(
DisputeRequest request, CancellationToken ct = default)
{
var transaction = await _txRepo.GetByIdAsync(
request.TransactionId, ct)
?? throw new NotFoundException("Transaction not found");
if (transaction.UserId != request.UserId)
throw new ForbiddenException(
"You can only dispute your own transactions");
var existingDispute = await _disputeRepo
.FindByTransactionAsync(request.TransactionId, ct);
if (existingDispute != null &&
existingDispute.Status != DisputeStatus.Dismissed)
return DisputeResult.AlreadyDisputed(
existingDispute.DisputeId);
var timeSinceTransaction = DateTime.UtcNow -
transaction.ProcessedAt;
if (timeSinceTransaction.TotalDays > 90)
return DisputeResult.TooLate(
"Disputes must be filed within 90 days");
var dispute = new DisputeRecord
{
Id = Guid.NewGuid(),
TransactionId = request.TransactionId,
UserId = request.UserId,
Reason = request.Reason,
Description = request.Description,
Evidence = request.EvidenceFiles,
Status = DisputeStatus.Opened,
FiledAt = DateTime.UtcNow,
ResponseDeadline = DateTime.UtcNow.AddDays(7),
EstimatedResolution = DateTime.UtcNow.AddDays(30)
};
await _disputeRepo.SaveAsync(dispute, ct);
if (transaction.PaymentMethod == PaymentMethodType.Card)
{
dispute.RequiresChargebackProcess = true;
dispute.CardNetworkDeadline = DateTime.UtcNow.AddDays(45);
}
await _eventBus.PublishAsync(new DisputeFiledEvent
{
DisputeId = dispute.Id,
TransactionId = request.TransactionId,
UserId = request.UserId,
Reason = request.Reason,
Amount = transaction.Amount,
Currency = transaction.Currency,
Timestamp = DateTime.UtcNow
});
_logger.LogInformation(
"Dispute {DisputeId} filed for transaction {TxId}",
dispute.Id, request.TransactionId);
return DisputeResult.Success(dispute);
}
public async Task<DisputeResolutionResult> ResolveDisputeAsync(
Guid disputeId, DisputeResolution resolution,
CancellationToken ct = default)
{
var dispute = await _disputeRepo.GetByIdAsync(disputeId, ct)
?? throw new NotFoundException("Dispute not found");
if (dispute.Status != DisputeStatus.Opened &&
dispute.Status != DisputeStatus.Investigating)
return DisputeResolutionResult.InvalidState();
dispute.Status = resolution.FavorUser
? DisputeStatus.ResolvedInUserFavor
: DisputeStatus.ResolvedInMerchantFavor;
dispute.Resolution = resolution.Reason;
dispute.ResolvedAt = DateTime.UtcNow;
dispute.ResolvedBy = resolution.InvestigatorId;
if (resolution.FavorUser)
{
await ProcessRefundAsync(dispute, ct);
}
await _disputeRepo.UpdateAsync(dispute, ct);
await _eventBus.PublishAsync(new DisputeResolvedEvent
{
DisputeId = disputeId,
Resolution = dispute.Status.ToString(),
AmountRefunded = resolution.FavorUser
? dispute.Amount
: 0m,
Timestamp = DateTime.UtcNow
});
return DisputeResolutionResult.Success(dispute);
}
private async Task ProcessRefundAsync(
DisputeRecord dispute, CancellationToken ct)
{
var transaction = await _txRepo.GetByIdAsync(
dispute.TransactionId, ct);
await _ledgerService.PostTransactionAsync(
new TransactionRequest
{
TransactionId = Guid.NewGuid(),
Description = $"Refund for dispute {dispute.Id}",
IdempotencyKey = $"dispute-refund-{dispute.Id}",
InitiatedBy = "dispute-system",
Entries = new List<EntryRequest>
{
new EntryRequest
{
AccountId = transaction.MerchantAccountId,
Type = EntryType.Debit,
Amount = dispute.Amount,
Currency = dispute.Currency
},
new EntryRequest
{
AccountId = transaction.UserWalletAccountId,
Type = EntryType.Credit,
Amount = dispute.Amount,
Currency = dispute.Currency
}
}
}, ct);
}
}
Chargeback Management and Representment
When a card issuer initiates a chargeback, the wallet platform has a limited window (typically 30-45 days) to respond with representment — providing evidence that the transaction was legitimate. Strong representment requires comprehensive documentation: proof of authentication (3D Secure records, device fingerprints, IP addresses), proof of delivery (for goods) or service completion (for digital services), the user's terms of service acceptance, and any communication records between the user and the platform.
| Chargeback Reason Code | Description | Representment Strategy |
|---|---|---|
| 10.1 - EMV Liability Shift | Counterfeit fraud | Provide chip transaction evidence |
| 10.2 - Non-EMV Liability Shift | Non-counterfeit fraud | Provide authentication logs |
| 10.4 - Other Fraud | Card-absent environment fraud | Provide 3DS, device, and IP evidence |
| 10.5 - Fraud (non-transaction) | Authorized but not received | Provide delivery confirmation |
| 13.1 - Merchandise Not Received | Product not delivered | Provide shipping/delivery proof |
| 13.3 - Not as Described | Product defective | Provide product documentation |
| 13.6 - Credit Not Processed | Refund not received | Provide refund confirmation |
| 13.7 - Cancelled Recurring | After cancellation | Provide cancellation records |
The dispute management system must maintain a complete evidence package for every transaction, including cryptographic hashes of the user's terms of service acceptance, device fingerprint records, 3D Secure authentication results, and delivery confirmations. This evidence must be stored in a tamper-proof manner and be retrievable quickly for representment deadlines.
14. Multi-Currency and Exchange Rates
Multi-currency support transforms a domestic wallet into a global financial tool. Users can hold balances in multiple currencies, send money internationally, and benefit from competitive exchange rates. However, multi-currency support introduces significant complexity in ledger management, exchange rate sourcing, FX transaction processing, and regulatory compliance (since cross-border payments are subject to additional regulations).
Multi-Currency Ledger Design
The ledger must support multi-currency accounting natively. Each ledger entry is denominated in a specific currency, and balance calculations are always performed within a single currency. The system must track the equivalent value in a base currency (typically USD) for reporting and regulatory purposes, but operational balances are always currency-specific.
C# Implementation: Multi-Currency Service
C#
public class MultiCurrencyService : IMultiCurrencyService
{
private readonly IExchangeRateProvider _rateProvider;
private readonly ILedgerService _ledgerService;
private readonly ICurrencyConfigRepository _configRepo;
private readonly ILogger<MultiCurrencyService> _logger;
public async Task<ConversionResult> ConvertCurrencyAsync(
CurrencyConversionRequest request, CancellationToken ct = default)
{
var rate = await _rateProvider.GetRateAsync(
request.SourceCurrency,
request.TargetCurrency, ct);
var spread = await _configRepo
.GetSpreadAsync(request.SourceCurrency, ct);
var adjustedRate = rate * (1 - spread);
var targetAmount = Math.Round(
request.SourceAmount * adjustedRate, 4);
var txId = Guid.NewGuid();
var ledgerResult = await _ledgerService.PostTransactionAsync(
new TransactionRequest
{
TransactionId = txId,
Description = $"FX: {request.SourceCurrency} to " +
$"{request.TargetCurrency}",
IdempotencyKey = request.IdempotencyKey,
InitiatedBy = request.UserId.ToString(),
Entries = new List<EntryRequest>
{
new EntryRequest
{
AccountId = request.SourceAccountId,
Type = EntryType.Debit,
Amount = request.SourceAmount,
Currency = request.SourceCurrency,
Metadata = new Dictionary<string, object>
{
["fx_rate"] = adjustedRate,
["fx_spread"] = spread
}
},
new EntryRequest
{
AccountId = request.TargetAccountId,
Type = EntryType.Credit,
Amount = targetAmount,
Currency = request.TargetCurrency,
Metadata = new Dictionary<string, object>
{
["fx_rate"] = adjustedRate,
["original_source_amount"] =
request.SourceAmount
}
}
}
}, ct);
return ConversionResult.Success(
txId, adjustedRate, targetAmount);
}
public async Task<FxQuoteResult> GetQuoteAsync(
FxQuoteRequest request, CancellationToken ct = default)
{
var rate = await _rateProvider.GetRateAsync(
request.SourceCurrency,
request.TargetCurrency, ct);
var spread = await _configRepo
.GetSpreadAsync(request.SourceCurrency, ct);
var displayRate = rate * (1 - spread);
var estimatedTarget = request.SourceAmount * displayRate;
return new FxQuoteResult
{
SourceAmount = request.SourceAmount,
SourceCurrency = request.SourceCurrency,
TargetAmount = Math.Round(estimatedTarget, 4),
TargetCurrency = request.TargetCurrency,
ExchangeRate = displayRate,
Spread = spread,
ValidUntil = DateTime.UtcNow.AddMinutes(30),
QuoteId = Guid.NewGuid()
};
}
}
Exchange Rate Management
| Rate Source | Update Frequency | Coverage | Latency | Use Case |
|---|---|---|---|---|
| ECB (European Central Bank) | Daily | 30+ currencies | ~1 second | Reference rates, reporting |
| Reuters / Refinitiv | Real-time | 100+ currencies | <100ms | Live trading, quotes |
| Open Exchange Rates | Hourly | 170+ currencies | ~200ms | General purpose |
| Bloomberg | Real-time | 180+ currencies | <50ms | Institutional, high-volume |
| Crypto exchanges | Real-time | Crypto pairs | <10ms | Cryptocurrency conversions |
The FX engine should aggregate rates from multiple sources to ensure reliability and competitive pricing. If the primary source fails, the system must seamlessly fall back to secondary sources. Rate staleness is a critical concern — rates that are even a few minutes old can represent significant value in volatile markets. The system must track rate freshness and reject quotes based on stale rates. For high-value transactions, real-time rate feeds from multiple providers are essential to minimize market risk.
Corridor Management and Route Optimization
For international transfers, the system must support corridor management — defining which currency pairs are supported, the applicable fees and spreads for each corridor, and the optimal payment route (direct bank transfer, card network push payment, or intermediary settlement through a correspondent bank). Different corridors have different characteristics: sending USD to EUR via SEPA might cost $1 with T+1 settlement, while sending USD to NGN (Nigerian Naira) via a local partner might cost 2% with T+2 settlement. The system must present users with clear pricing and estimated delivery times for each available route.
15. Rewards and Cashback Engine
A well-designed rewards and cashback engine is a powerful tool for driving user acquisition, increasing transaction volume, and building user loyalty. In the competitive digital wallet market, rewards programs can be the differentiating factor that tips users toward one platform over another. However, an improperly designed rewards engine can become a significant financial liability — if the rewards are too generous or poorly targeted, they can cost more than they generate in incremental revenue.
Rewards Model Types
Digital wallets typically implement one or more rewards models. Flat-rate cashback offers a fixed percentage back on every transaction (e.g., 1% cashback on all payments). Tiered cashback increases the percentage based on user status or spending level (e.g., 1% for standard users, 2% for gold, 3% for platinum). Category-based cashback offers higher rates for specific merchant categories (e.g., 5% on dining, 3% on groceries, 1% on everything else). Points-based systems accumulate reward points that can be redeemed for goods, services, or statement credits. Promotional campaigns offer limited-time enhanced rewards for specific behaviors (e.g., 10% cashback on the first three top-ups).
| Model | User Appeal | Cost Control | Complexity | Example |
|---|---|---|---|---|
| Flat Cashback | Simple to understand | Predictable | Low | 1% on everything |
| Tiered Cashback | Encourages loyalty | Scales with revenue | Medium | 1%/2%/3% by tier |
| Category-Based | High engagement | Can steer behavior | Medium | 5% dining, 3% groceries |
| Points System | Flexible redemption | Breakage revenue | High | 1 point per $1 spent |
| Referral Bonus | Viral growth | Pay per acquisition | Low | $10 for referrer + referee |
| Streak Rewards | Habit formation | Gamification cost | Medium | Bonus for 7-day streak |
C# Implementation: Cashback Engine
C#
public class CashbackEngine : ICashbackEngine
{
private readonly ICashbackRuleRepository _ruleRepo;
private readonly ICashbackLedgerRepo _cashbackLedger;
private readonly IPromotionRepository _promoRepo;
private readonly ILogger<CashbackEngine> _logger;
public async Task<CashbackResult> CalculateCashbackAsync(
CashbackContext context, CancellationToken ct = default)
{
var applicableRules = await _ruleRepo
.GetApplicableRulesAsync(
context.MerchantCategory,
context.UserTier,
context.TransactionAmount,
context.Currency, ct);
var promotions = await _promoRepo
.GetActivePromotionsAsync(context.UserId, ct);
var totalCashback = 0m;
var breakdown = new List<CashbackLineItem>();
foreach (var rule in applicableRules)
{
var cashback = CalculateRuleCashback(
rule, context.TransactionAmount);
if (cashback > 0)
{
totalCashback += cashback;
breakdown.Add(new CashbackLineItem
{
RuleId = rule.Id,
RuleName = rule.Name,
Rate = rule.Rate,
Amount = cashback
});
}
}
foreach (var promo in promotions.Where(p =>
p.AppliesTo(context)))
{
var promoCashback = CalculatePromoCashback(
promo, context.TransactionAmount);
if (promoCashback > 0)
{
totalCashback += promoCashback;
breakdown.Add(new CashbackLineItem
{
PromotionId = promo.Id,
PromotionName = promo.Name,
Rate = promo.BonusRate,
Amount = promoCashback,
IsPromotional = true
});
}
}
if (totalCashback > 0)
{
var cashbackRecord = new CashbackRecord
{
Id = Guid.NewGuid(),
UserId = context.UserId,
TransactionId = context.TransactionId,
TotalAmount = totalCashback,
Currency = context.Currency,
Breakdown = breakdown,
Status = CashbackStatus.Accrued,
AccruedAt = DateTime.UtcNow,
VestedAt = DateTime.UtcNow.AddDays(30)
};
await _cashbackLedger.SaveAsync(cashbackRecord, ct);
await CreditCashbackAsync(
context.UserId, totalCashback,
context.Currency, cashbackRecord.Id, ct);
}
return new CashbackResult
{
TotalCashback = totalCashback,
Breakdown = breakdown,
Message = totalCashback > 0
? $"You earned {totalCashback:F2} " +
$"{context.Currency} cashback!"
: null
};
}
private decimal CalculateRuleCashback(
CashbackRule rule, decimal transactionAmount)
{
var eligibleAmount = Math.Min(
transactionAmount, rule.MaxEligibleAmount);
var cashback = Math.Round(
eligibleAmount * rule.Rate, 2);
return Math.Min(cashback, rule.MaxCashbackPerTransaction);
}
}
Budget管理和防滥用
Rewards budgets must be carefully managed. The system must track cumulative rewards liability (accrued but not yet redeemed cashback), forecast future liability based on transaction volume trends, and enforce circuit breakers that reduce or suspend rewards if the budget is exceeded. Anti-abuse measures include velocity limits on cashback accrual, detection of circular transaction patterns (users transacting with themselves or coordinated groups to earn rewards), and merchant category manipulation detection.
16. Security — Encryption, HSM, Key Management
Security in a digital wallet system is not a feature — it is a foundational requirement that permeates every layer of the architecture. A single security breach can result in financial losses, regulatory penalties, loss of user trust, and potential business failure. The security architecture must address data protection (encryption at rest and in transit), access control (authentication and authorization), key management (secure generation, storage, rotation, and destruction of cryptographic keys), and physical security (protection of hardware security modules and data centers).
Encryption Architecture
All sensitive data must be encrypted both at rest and in transit. Data in transit is protected by TLS 1.3 (with TLS 1.2 as minimum acceptable). Data at rest is protected by AES-256 encryption. However, the choice of encryption strategy depends on the data sensitivity and access pattern. Payment card data uses tokenization (never stored as plaintext). User PII (Personally Identifiable Information) uses application-level encryption with envelope encryption. Ledger data uses database-level encryption (transparent data encryption). Secrets (API keys, database credentials) are stored in a dedicated secrets management system (HashiCorp Vault, AWS Secrets Manager).
Hardware Security Modules (HSMs)
HSMs are tamper-resistant hardware devices designed to securely generate, store, and manage cryptographic keys. They are essential for high-security financial systems because they never export private keys — all cryptographic operations (signing, decryption) happen inside the HSM. HSMs are classified by FIPS 140-2 levels (Level 1 through Level 4), with Level 3 and Level 4 providing physical tamper resistance (zeroization of keys if tampering is detected).
C# Implementation: Key Management Service
C#
public class KeyManagementService : IKeyManagementService
{
private readonly IHsmClient _hsmClient;
private readonly IKeyRepository _keyRepo;
private readonly ILogger<KeyManagementService> _logger;
public async Task<EncryptionResult> EncryptAsync(
EncryptionRequest request, CancellationToken ct = default)
{
var keyMetadata = await _keyRepo.GetActiveKeyAsync(
request.Purpose, ct)
?? throw new KeyNotFoundException(
$"No active key for purpose {request.Purpose}");
var plaintextBytes = Encoding.UTF8.GetBytes(request.Plaintext);
var encryptedData = await _hsmClient.EncryptAsync(
new HsmEncryptRequest
{
KeyId = keyMetadata.HsmKeyId,
Algorithm = EncryptionAlgorithm.Aes256Gcm,
Plaintext = plaintextBytes,
AssociatedData = request.AssociatedData
}, ct);
return new EncryptionResult
{
Ciphertext = Convert.ToBase64String(
encryptedData.Ciphertext),
Iv = Convert.ToBase64String(encryptedData.Iv),
AuthTag = Convert.ToBase64String(
encryptedData.AuthTag),
KeyVersion = keyMetadata.Version,
Algorithm = EncryptionAlgorithm.Aes256Gcm
};
}
public async Task<string> DecryptAsync(
DecryptionRequest request, CancellationToken ct = default)
{
var keyMetadata = await _keyRepo.GetKeyByVersionAsync(
request.Purpose, request.KeyVersion, ct)
?? throw new KeyNotFoundException(
$"Key version {request.KeyVersion} not found");
var plaintext = await _hsmClient.DecryptAsync(
new HsmDecryptRequest
{
KeyId = keyMetadata.HsmKeyId,
Algorithm = request.Algorithm,
Ciphertext = Convert.FromBase64String(
request.Ciphertext),
Iv = Convert.FromBase64String(request.Iv),
AuthTag = Convert.FromBase64String(
request.AuthTag),
AssociatedData = request.AssociatedData
}, ct);
return Encoding.UTF8.GetString(plaintext);
}
public async Task<KeyRotationResult> RotateKeyAsync(
KeyPurpose purpose, CancellationToken ct = default)
{
var currentKey = await _keyRepo
.GetActiveKeyAsync(purpose, ct);
var newHsmKey = await _hsmClient.GenerateKeyAsync(
new HsmKeyGenRequest
{
Algorithm = KeyAlgorithm.Aes256,
Extractable = false,
Usage = KeyUsage.Encrypt | KeyUsage.Decrypt
}, ct);
var newKeyMetadata = new KeyMetadata
{
Id = Guid.NewGuid(),
Purpose = purpose,
HsmKeyId = newHsmKey.KeyId,
Version = (currentKey?.Version ?? 0) + 1,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddYears(1),
Status = KeyStatus.Active
};
await _keyRepo.SaveAsync(newKeyMetadata, ct);
if (currentKey != null)
{
currentKey.Status = KeyStatus.Rotated;
currentKey.RotatedAt = DateTime.UtcNow;
await _keyRepo.UpdateAsync(currentKey, ct);
}
_logger.LogInformation(
"Key rotated for purpose {Purpose}: " +
"v{OldVersion} -> v{NewVersion}",
purpose, currentKey?.Version ?? 0,
newKeyMetadata.Version);
return KeyRotationResult.Success(newKeyMetadata);
}
}
Security Layers Summary
| Layer | Mechanism | Standard | Purpose |
|---|---|---|---|
| Transport | TLS 1.3 | NIST SP 800-52 | Protect data in transit |
| Application | AES-256-GCM | NIST SP 800-38D | Protect data at rest |
| Database | TDE + Column encryption | PCI-DSS Req 3 | Protect stored cardholder data |
| Key Storage | FIPS 140-2 Level 3+ HSM | FIPS 140-2 | Protect cryptographic keys |
| Authentication | OAuth 2.0 + JWT + MFA | PSD2 SCA | Verify user identity |
| Authorization | RBAC + ABAC | Least privilege | Control access to resources |
| Audit | Immutable audit logs | SOC 2 | Record all system activity |
| Network | VPC, WAF, DDoS protection | Defense in depth | Protect infrastructure |
Key rotation is a critical operational procedure that must be performed regularly (typically every 90-365 days depending on the key type and regulatory requirements). The rotation process must be non-disruptive — the system must continue to encrypt with the new key while still being able to decrypt data encrypted with the old key. This is achieved by maintaining multiple key versions and including the key version in every encrypted record.
Rate Limiting, DDoS Protection, and Bot Prevention
The API layer must implement comprehensive protection against abuse. Rate limiting prevents any single client from overwhelming the system. DDoS protection (via Cloudflare, AWS Shield, or similar) mitigates volumetric attacks. Bot detection prevents automated credential stuffing and account enumeration attacks. Web Application Firewalls (WAF) filter malicious requests based on known attack patterns. All of these defenses work in layers, providing defense in depth.
17. Interview Q&A
Q1: How do you ensure that the ledger always balances?
Double-entry bookkeeping enforces the fundamental invariant that total debits must equal total credits for every transaction. Before any ledger entry is posted, the system validates that the sum of all debit entries equals the sum of all credit entries. This validation happens within a database transaction that also holds appropriate locks on the affected accounts, preventing concurrent modifications from creating imbalances. Additionally, periodic reconciliation (daily, at minimum) compares total debits and credits across the entire system and alerts on any discrepancy. The ledger is append-only — entries are never modified or deleted, only new correcting entries are posted to reverse errors.
Q2: How do you handle idempotency in payment processing?
Every payment request includes a client-generated idempotency key (typically a UUID). Before processing, the system checks whether this key has been previously processed. If so, it returns the stored result without re-executing the payment. If not, it stores the key and begins processing. The idempotency check and the payment processing are performed within the same database transaction to prevent race conditions. The idempotency window is typically 24-48 hours. The key is stored at both the application level (for internal deduplication) and is passed to external payment gateways (which also support idempotency keys) to prevent duplicate charges.
Q3: What is the difference between authorization and settlement in card payments?
Authorization is the process of verifying with the issuing bank that the cardholder has sufficient credit or funds and that the transaction is legitimate. It results in a temporary hold on the cardholder's available balance. Settlement is the actual transfer of funds from the issuing bank to the acquiring bank, which typically occurs in a batch process at the end of the business day (T+1 or T+2). The wallet platform must account for this timing difference — the user sees the deduction immediately (based on authorization), but the actual funds do not move until settlement. This creates settlement risk if the authorization succeeds but settlement fails (e.g., due to an ACH return or a card chargeback).
Q4: How would you design the fraud detection system to scale to millions of transactions per day?
The fraud detection system uses a tiered approach. Fast, deterministic rules (velocity checks, blocklist lookups, amount thresholds) execute in under 5 milliseconds using pre-computed features stored in an in-memory cache (Redis). Machine learning model scoring runs as a separate microservice with GPU acceleration, targeting sub-50ms latency. The two tiers run in parallel, and their results are combined by a decision engine. Feature computation for ML models is performed asynchronously — a streaming pipeline (Apache Flink or Kafka Streams) pre-computes features like "transaction count in last 24 hours" and stores them in the feature store. At peak loads, the system can degrade gracefully by falling back to rules-only mode if the ML service is overwhelmed.
Q5: How do you handle a scenario where a P2P transfer succeeds on the sender's side but fails to credit the recipient?
This is handled by designing the P2P transfer as an atomic ledger operation — the debit to the sender and the credit to the recipient are posted within the same database transaction. If any part fails, the entire transaction rolls back. In the rare case where a system failure occurs between the debit and credit being committed (a partial failure), a compensating transaction mechanism automatically detects the inconsistency through reconciliation and reverses the debit. The user sees a failed transfer and their balance is restored. Idempotency ensures that the retried transfer does not result in a double debit.
Q6: Explain the concept of settlement risk and how you mitigate it.
Settlement risk is the risk that funds authorized in a transaction will not actually settle (transfer) from the counterparty. In card payments, this manifests as chargebacks. In ACH transfers, this manifests as returns. In cross-border payments, this can include correspondent bank failures or regulatory holds. Mitigation strategies include: holding top-up funds for a settlement period before making them withdrawable, maintaining reserves proportional to the volume of unsettled transactions, implementing velocity limits on fresh deposits, using real-time payment rails (UPI, Faster Payments) where available to minimize the settlement window, and carrying insurance against catastrophic settlement failures.
Q7: How do you design a multi-currency ledger that handles FX correctly?
Each ledger entry is denominated in a single currency. The system maintains separate balance pools for each currency within a user's wallet. When a conversion occurs, the system posts two simultaneous ledger entries: a debit in the source currency and a credit in the target currency, both within the same atomic transaction. The exchange rate used is captured at the time of the transaction and stored in the ledger metadata. The system must source rates from multiple providers, handle staleness checks (reject rates older than a configurable threshold), and apply a spread (the platform's FX markup) consistently. Reconciliation must verify that all FX entries across currencies balance when expressed in a common base currency.
Q8: How would you handle a situation where the notification service is down during a high-volume payment event?
Notifications are decoupled from the payment processing pipeline. Payments are committed to the ledger and confirmed to the user regardless of notification service status. Failed notifications are captured as events in a durable message queue (Kafka with sufficient retention). When the notification service recovers, it processes the backlog of queued notifications. The system tracks notification delivery status and can trigger alternative channels (e.g., if push notifications fail, fall back to SMS or email). For critical notifications (fraud alerts, security alerts), the system retries with exponential backoff and escalates to backup channels more aggressively.
Q9: What are the key metrics you would monitor for a digital wallet platform?
Key metrics span multiple domains: Transaction metrics (authorization rate, decline rate, average transaction amount, transactions per second), financial metrics (total volume, revenue, fraud loss rate, settlement reconciliation variance), operational metrics (API latency P50/P95/P99, error rates by service, queue depth), compliance metrics (KYC completion rate, SAR filing volume, dispute rate), and business metrics (daily active users, top-up volume, withdrawal volume, churn rate). Critical alerts include: ledger imbalance (any non-zero reconciliation difference), fraud rate spikes (above historical average + 3 standard deviations), authorization rate drops (below 95%), and settlement failures (any failed settlement).
Q10: How do you ensure PCI-DSS compliance in a microservices architecture?
The Cardholder Data Environment (CDE) must be isolated from the rest of the system through network segmentation. Only services that specifically need to handle card data (the tokenization service and payment gateway integration) are placed within the CDE. All other services interact with tokens, never with actual card numbers. The CDE runs on dedicated, hardened infrastructure with enhanced monitoring. API gateways enforce that no card data flows outside the CDE. Secrets management ensures that no card data appears in logs, error messages, or configuration files. Regular vulnerability scans, penetration testing, and PCI-DSS audits verify compliance. The scope of PCI-DSS is minimized by using third-party tokenization (reducing what the platform itself handles).