Most e-wallet demos look identical: a balance, a "send money" button, a transaction list. What separates a wallet that survives a PCI DSS audit and a Series A due-diligence review from one that gets rebuilt six months post-launch is almost never visible in the UI. It's the architecture underneath — the ledger design, the KYC state machine, the tokenization boundary, the idempotency guarantees on every money-moving endpoint.
This is a practitioner-level walkthrough of the four architectural decisions that matter most in e-wallet app development: the service architecture, the ledger, the compliance boundary, and the reliability guarantees that keep a wallet's numbers trustworthy under concurrent load.
The core service architecture
A production e-wallet, even a modest one, tends to converge on the same handful of services regardless of stack:
Identity & Auth — authentication, session management, device binding
KYC/Onboarding — identity verification workflow and risk tiering
Ledger — the source of truth for every balance and movement
Payments/Rails — integration with card networks, BaaS providers, or bank rails
Risk & Fraud — real-time transaction scoring
Notifications — transactional messaging, webhooks to merchants
The temptation early on is to build this as a monolith — and for an MVP, that's often the right call. But the ledger service in particular benefits from being isolated early, even inside a monolith, because it's the one component where a schema mistake is expensive to unwind once real money and real audit trails depend on it. Treat the ledger as a bounded context from day one, even if you don't split it into a separate deployable service yet.
The ledger: the actual heart of e-wallet app development
The single most common mistake in early-stage e-wallet app development is storing a "balance" as a mutable integer on a user row. It works in a demo. It falls apart the moment you need to explain to an auditor, or to yourself at 2am during an incident, exactly why a balance is what it is.
The fix is a double-entry ledger, borrowed directly from accounting practice: every transaction creates at least two immutable entries — a debit and a credit — that must always net to zero across the system. Balances are never stored directly; they're derived by summing entries.
typescript// Simplified double-entry ledger schema
interface LedgerEntry {
id: string;
transactionId: string; // groups debit + credit together
accountId: string; // wallet, fee account, merchant account, etc.
direction: 'debit' | 'credit';
amountMinorUnits: number; // always store money as integer cents, never float
currency: string;
createdAt: Date;
}
// A transfer between two wallets always produces a balanced pair
function recordTransfer(fromWallet: string, toWallet: string, amount: number, txnId: string) {
return [
{ id: uuid(), transactionId: txnId, accountId: fromWallet, direction: 'debit', amountMinorUnits: amount },
{ id: uuid(), transactionId: txnId, accountId: toWallet, direction: 'credit', amountMinorUnits: amount },
];
}
// Balance is a query, never a stored field
function getBalance(accountId: string): number {
const credits = sumEntries(accountId, 'credit');
const debits = sumEntries(accountId, 'debit');
return credits - debits;
}
This does two things that matter for a real wallet. First, it makes reconciliation mechanical: if every transaction is balanced and every entry is immutable, "where did the money go" is always answerable by querying entries, never by trusting a cached number. Second, it gives you a natural audit trail for free — which is exactly what PCI DSS and most banking-partner due diligence processes will ask to see.
One consistency detail trips up teams that are otherwise doing this correctly: the debit-and-credit pair for a single transaction must be written atomically, inside a single database transaction, or you risk a partial write leaving the ledger unbalanced during a crash or network blip. Most teams enforce this with a database-level transaction wrapping both entry inserts, plus a periodic reconciliation job that sums all entries system-wide and alerts if the total doesn't net to zero — a cheap, high-value safety net that catches bugs before an auditor or a user does.
PCI DSS: reducing scope before you build
Most first-time fintech teams treat PCI DSS as a checklist to satisfy after the app is built. The more effective approach is architectural: design the system so that as little of it as possible is ever "in scope" for PCI DSS to begin with.
The core technique is tokenization. Raw card data (the PAN — primary account number) should never touch your application servers or database. Instead, card capture happens directly between the user's device and your payment processor or BaaS provider (Stripe, Marqeta, Galileo, and similar all support this), which returns an opaque token your system stores and uses for all future transactions.
typescript// What your servers should NEVER see
interface RawCardData {
pan: string; // full card number — never touches your backend
cvv: string; // never stored, ever, by anyone
expiry: string;
}
// What your servers actually store and operate on
interface TokenizedPaymentMethod {
userId: string;
processorToken: string; // opaque reference from Stripe/Marqeta/etc.
last4: string; // safe to store for display purposes
cardBrand: string;
}
// Every charge references the token, never raw card data
async function chargeWallet(userId: string, amountMinorUnits: number) {
const method = await getTokenizedMethod(userId);
return processor.charge({ token: method.processorToken, amount: amountMinorUnits });
}
Done correctly, this reduces your PCI DSS assessment from a full Level 1 audit down to a much narrower SAQ (Self-Assessment Questionnaire) scope — typically SAQ A or SAQ A-EP for a properly tokenized, redirect-based card capture flow — because your infrastructure never stores, processes, or transmits raw cardholder data; the processor's PCI-certified infrastructure does. This single architectural decision, made before writing a line of production code, is usually the highest-leverage compliance decision in the entire project. Teams that skip it and store card data "just for the MVP" almost always regret it, since migrating off raw card storage later means re-architecting the payment flow while simultaneously serving live users.
KYC: model it as a state machine, not a boolean
A second common mistake: modeling KYC status as verified: boolean. Real KYC has more states than that, and treating it as binary makes tiered access, re-verification, and regulatory reporting far harder to implement correctly later.
typescripttype KYCState =
| 'unverified'
| 'pending_review'
| 'basic_verified' // tier 1: email/phone, low transaction limits
| 'enhanced_verified' // tier 2: government ID + liveness check
| 'rejected'
| 'requires_reverification'; // triggered by risk signals or regulatory refresh
interface KYCTransition {
from: KYCState;
to: KYCState;
trigger: string;
timestamp: Date;
}
// Transaction limits gate off the KYC tier, not a single boolean
function getTransactionLimit(state: KYCState): number {
switch (state) {
case 'basic_verified': return 100_00; // $100 in minor units
case 'enhanced_verified': return 10_000_00; // $10,000 in minor units
default: return 0;
}
}
Modeling KYC explicitly as a state machine, with logged transitions, gives you two things a boolean never can: a defensible audit trail for regulators asking "when and why was this user allowed to transact at this limit," and a clean hook for tiered onboarding — letting users start with basic verification and low limits, then upgrade to enhanced verification as they need higher transaction ceilings, without re-architecting anything.
Idempotency and webhook ordering: the reliability layer
The last piece that separates production-grade e-wallet app development from a fragile prototype is handling the fact that networks retry, clients double-tap buttons, and webhooks arrive out of order.
Every endpoint that moves money needs an idempotency key, supplied by the client, that guarantees a retried request doesn't create a duplicate transaction:
typescriptasync function transferFunds(request: TransferRequest, idempotencyKey: string) {
const existing = await findByIdempotencyKey(idempotencyKey);
if (existing) return existing; // safe to retry — returns the original result
const result = await executeTransfer(request);
await storeIdempotencyRecord(idempotencyKey, result);
return result;
}
Webhooks from BaaS or payment providers need the same discipline, plus explicit handling for out-of-order delivery — a "payment failed" event arriving after a "payment succeeded" event for the same transaction ID should never simply overwrite state; it should be reconciled against the ledger's current entries, with the ledger as the tie-breaking source of truth. A practical pattern here is to store every webhook payload with its provider-supplied event ID before processing it, check that ID against previously seen events, and process events in a queue keyed by transaction ID so that two events for the same transaction can never be handled concurrently by different workers.
Closing thought
None of this is exotic engineering — a double-entry ledger, tokenized card handling, a KYC state machine, and idempotent endpoints are well-understood patterns individually. What makes e-wallet app development hard is that all four have to be right simultaneously, from the first sprint, because retrofitting any of them after real transaction volume and real audit scrutiny arrive is dramatically more expensive than building them in from the start. Teams that have shipped this before — including fintech-focused shops like Dev Technosys — tend to treat these four decisions as the actual scope of the project, with the UI as the easy part that comes after.
Top comments (0)