Wallet Architecture
System Overview
Nav.Wallet is built as a modular service layer on top of ID.Banking’s core infrastructure. The architecture separates concerns into distinct layers: channels, orchestration, domain services, integration adapters, and the underlying banking platform.
flowchart TB
subgraph channels["Channel Layer"]
direction LR
style channels fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
MobileApp["Mobile App
(Angular + Capacitor)"]
USSD["USSD Gateway
(Feature Phones)"]
WhatsApp["WhatsApp
(Business API)"]
AgentPortal["Agent Portal
(Web)"]
end
subgraph api["Wallet API Layer"]
direction LR
style api fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
CustomerCtrl["WalletCustomerController"]
AgentCtrl["WalletAgentController"]
AdminCtrl["WalletAdminController"]
UssdCtrl["UssdChannelService"]
WACtrl["WhatsAppChannelService"]
end
subgraph domain["Domain Services"]
direction LR
style domain fill:#e0f7fa,stroke:#0097a7,stroke-width:2px
Transfer["TransferService"]
VAS["VasEngine"]
KYC["KycEngine"]
TxGate["TransactionGateService"]
Agent["AgentManager"]
Pin["PinService"]
Cash["CashOperationService"]
Proxy["ProxyResolver"]
end
subgraph adapters["Integration Adapters"]
direction LR
style adapters fill:#fce4ec,stroke:#c62828,stroke-width:2px
AirtimeClient["IAirtimeOperatorClient"]
BillerClient["IBillerClient"]
FraudAdapter["IFraudDetection"]
SanctionsAdapter["ISanctionsScreening"]
NotifyAdapter["INotificationService"]
SIMSwap["ISIMSwapDetection"]
end
subgraph platform["ID.Banking Core"]
direction LR
style platform fill:#fff3e0,stroke:#e65100,stroke-width:2px
CILedger["CILedger
(Balance Authority)"]
Party["Party Module
(Identity & KYC Docs)"]
Auth["AuthenticationService
(JWT)"]
Notify["NotificationService
(SMS, Push, WhatsApp)"]
end
MobileApp --> CustomerCtrl
USSD --> UssdCtrl
WhatsApp --> WACtrl
AgentPortal --> AgentCtrl
CustomerCtrl --> Transfer
CustomerCtrl --> VAS
CustomerCtrl --> KYC
AgentCtrl --> Cash
AgentCtrl --> Agent
UssdCtrl --> Transfer
UssdCtrl --> VAS
WACtrl --> Transfer
Transfer --> TxGate
Transfer --> Proxy
Transfer --> Pin
VAS --> AirtimeClient
VAS --> BillerClient
Cash --> Agent
TxGate --> FraudAdapter
TxGate --> SanctionsAdapter
TxGate --> KYC
Transfer --> CILedger
Cash --> CILedger
VAS --> CILedger
Agent --> CILedger
KYC --> Party
Pin --> Auth
TxGate --> NotifyAdapter
NotifyAdapter --> Notify
Integration Landscape
Nav.Wallet integrates with external service providers through well-defined adapter interfaces. Each integration follows the same pattern: a platform-owned interface, a mock implementation for testing, and one or more production adapters.
flowchart LR
subgraph wallet["Nav.Wallet Services"]
direction TB
style wallet fill:#e0f7fa,stroke:#0097a7,stroke-width:2px
VAS["VasEngine"]
TxGate["TransactionGateService"]
Account["WalletAccountService"]
Notify["NotificationService"]
end
subgraph adapters["Adapter Interfaces (Platform-Owned)"]
direction TB
style adapters fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
IAirtime["IAirtimeOperatorClient"]
IBiller["IBillerClient"]
IFraud["IFraudDetection"]
ISanctions["ISanctionsScreening"]
ISIM["ISIMSwapDetection"]
INotify["INotificationService"]
IGeo["IGeocodingAdapter"]
end
subgraph external["External Providers"]
direction TB
style external fill:#fff3e0,stroke:#e65100,stroke-width:2px
Vodacom["Vodacom
MTN · Cell C · Telkom"]
Eskom["Eskom · City Power
DSTV · Municipal"]
FraudProv["TransUnion
Internal ML Model"]
SanctProv["UN · OFAC
EU · SARB Lists"]
SIMProv["MNO SIM Swap API"]
NotifyProv["SMS Gateway
Push (FCM/APNs)
WhatsApp Business API"]
GeoProv["Google Maps
Geocoding API"]
end
VAS --> IAirtime --> Vodacom
VAS --> IBiller --> Eskom
TxGate --> IFraud --> FraudProv
TxGate --> ISanctions --> SanctProv
Account --> ISIM --> SIMProv
Notify --> INotify --> NotifyProv
Account --> IGeo --> GeoProv
Integration Contracts
| Interface | Purpose | Failure Strategy |
|---|---|---|
IAirtimeOperatorClient | Vend prepaid airtime to mobile numbers | Circuit breaker (5 failures → 30s cooldown) + auto-reversal |
IBillerClient | Validate bill references and submit payments | Circuit breaker + auto-reversal within 30s |
IFraudDetection | Real-time transaction risk scoring | Fail-closed (decline on unavailability) |
ISanctionsScreening | Name/entity matching against watchlists | Fail-closed (block on unavailability) |
ISIMSwapDetection | Detect recent SIM swaps for fraud prevention | Fail-closed (suspend account on detection) |
INotificationService | Multi-channel customer alerts | Retry with exponential backoff |
IGeocodingAdapter | Agent location verification | Graceful degradation (skip if unavailable) |
Airtime Purchase Flow
The airtime integration follows a strict debit-first, vend-second pattern with automatic reversal on failure:
sequenceDiagram
participant Customer
participant WalletAPI as Wallet API
participant VAS as VasEngine
participant Gate as TransactionGate
participant Ledger as CILedger
participant CB as CircuitBreaker
participant Operator as MNO Operator API
Customer->>WalletAPI: POST /api/wallet/airtime/purchase
WalletAPI->>VAS: purchaseAirtime(phoneNumber, amount, operator)
VAS->>Gate: validateTransaction(customerId, amount)
Gate->>Gate: Check KYC tier limits
Gate->>Gate: Check fraud score
Gate-->>VAS: Approved
VAS->>Ledger: Debit customer wallet
Ledger-->>VAS: Debit confirmed (txRef)
VAS->>CB: Check circuit state
CB-->>VAS: Circuit CLOSED (healthy)
VAS->>Operator: vendAirtime(phoneNumber, amount)
alt Success
Operator-->>VAS: Vend confirmed (operatorRef)
VAS-->>WalletAPI: Success + receipt
WalletAPI-->>Customer: 200 OK + confirmation
else Failure
Operator-->>VAS: Vend failed / timeout
VAS->>CB: Record failure
VAS->>Ledger: Reverse debit (auto-reversal)
Ledger-->>VAS: Reversal confirmed
VAS-->>WalletAPI: Failure + reversal receipt
WalletAPI-->>Customer: 200 OK + failure notification
end
Resilience Patterns
- Circuit Breaker — After 5 consecutive failures to a specific operator, the circuit opens for 30 seconds. During this time, requests to that operator fail fast without network calls.
- Idempotency — Each purchase request carries an idempotency key. Duplicate requests return the original result without re-debiting.
- Auto-Reversal — If the operator vend fails, the wallet debit is reversed within 30 seconds. The customer receives a notification confirming the reversal.
Bill Payment Flow
Bill payments follow a validate-debit-pay pattern with biller reference validation before any money moves:
sequenceDiagram
participant Customer
participant WalletAPI as Wallet API
participant VAS as VasEngine
participant Gate as TransactionGate
participant Biller as IBillerClient
participant Ledger as CILedger
Customer->>WalletAPI: POST /api/wallet/bills/pay
WalletAPI->>VAS: payBill(billerId, reference, amount)
VAS->>Biller: validateReference(billerId, reference)
alt Invalid Reference
Biller-->>VAS: Invalid account/meter number
VAS-->>WalletAPI: 400 Bad Request
WalletAPI-->>Customer: Error - invalid reference
else Valid Reference
Biller-->>VAS: Reference valid + account holder name
VAS->>Gate: validateTransaction(customerId, amount)
Gate-->>VAS: Approved
VAS->>Ledger: Debit customer wallet
Ledger-->>VAS: Debit confirmed
VAS->>Biller: submitPayment(billerId, reference, amount)
alt Payment Success
Biller-->>VAS: Payment confirmed (billerRef)
VAS-->>WalletAPI: Success + receipt
WalletAPI-->>Customer: 200 OK + confirmation
else Payment Failed
Biller-->>VAS: Payment rejected
VAS->>Ledger: Reverse debit
Ledger-->>VAS: Reversal confirmed
VAS-->>WalletAPI: Failure + reversal notification
WalletAPI-->>Customer: Payment failed - funds returned
end
end
Biller Registry
The biller registry is a database-driven configuration table (wallet.biller_registry) that controls which billers are available:
| Field | Purpose |
|---|---|
biller_id | Unique identifier |
display_name | Customer-facing name |
category | Electricity, Water, Television, Municipal |
validation_endpoint | URL for account/meter validation |
payment_endpoint | URL for payment submission |
is_active | Enable/disable without code deployment |
reference_format | Regex pattern for reference validation |
New billers are onboarded by adding a row to the registry and configuring the adapter endpoint — no code changes required.
P2P Transfer & Proxy Resolution
P2P transfers use phone numbers as identifiers. The ProxyResolver maps phone numbers to internal wallet accounts:
sequenceDiagram
participant Sender
participant API as Wallet API
participant Transfer as TransferService
participant Proxy as ProxyResolver
participant Gate as TransactionGate
participant Pin as PinService
participant Ledger as CILedger
participant Notify as NotificationService
Sender->>API: POST /api/wallet/transfer {recipientPhone, amount, pin}
API->>Transfer: transfer(senderId, recipientPhone, amount, pin)
Transfer->>Pin: verifyPin(senderId, pin)
Pin-->>Transfer: PIN valid
Transfer->>Proxy: resolveByPhone(recipientPhone)
alt Registered Recipient
Proxy-->>Transfer: recipientAccountId
Transfer->>Gate: validateTransaction(senderId, amount)
Gate-->>Transfer: Approved
Transfer->>Ledger: Transfer (sender → recipient)
Ledger-->>Transfer: Transfer confirmed
Transfer->>Notify: Notify sender (debit) + recipient (credit)
Transfer-->>API: Success
else Unregistered Recipient
Proxy-->>Transfer: Not found
Transfer->>Gate: validateTransaction(senderId, amount)
Gate-->>Transfer: Approved
Transfer->>Ledger: Debit sender → Held transfer pool
Ledger-->>Transfer: Held transfer created
Transfer->>Notify: SMS to recipient with claim instructions
Transfer-->>API: Success (held for 72h)
end
Held Transfers
When the recipient phone number is not registered:
- Funds are debited from the sender and held in a suspense account
- Recipient receives an SMS invitation to register and claim the funds
- On registration, the
ProxyResolvertriggers automatic release of held transfers - If unclaimed after 72 hours, funds are automatically reversed to the sender
Data Architecture
Nav.Wallet maintains its own PostgreSQL schema (wallet) alongside the core banking schemas, with CILedger as the single source of truth for all financial state:
flowchart TB
subgraph walletSchema["wallet schema (Nav.Wallet owned)"]
direction TB
style walletSchema fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
Customers["wallet_customers
phone, tier, status"]
Agents["agents
hierarchy, float, status"]
Proxy["proxy_mappings
phone → accountId"]
Sessions["ussd_sessions
state machine"]
Commissions["commission_configs
rates and tiers"]
Billers["biller_registry
active billers"]
HeldTransfers["held_transfers
pending claims"]
end
subgraph coreSchemas["ID.Banking Core (shared)"]
direction TB
style coreSchemas fill:#fff3e0,stroke:#e65100,stroke-width:2px
CILedger["CILedger
accounts, transactions,
balances (financial truth)"]
Party["Party
identity, KYC docs,
authentication"]
Notifications["Notifications
templates, delivery log"]
end
Customers -->|"customer_id FK"| Party
Customers -->|"wallet_account_id"| CILedger
Agents -->|"float_account_id"| CILedger
Proxy -->|"wallet_account_id"| CILedger
HeldTransfers -->|"held_transaction_id"| CILedger
Commissions -->|"commission_account_id"| CILedger
Data Ownership Principles
| Data | Owner | Rationale |
|---|---|---|
| Account balances & transactions | CILedger | Single source of financial truth; double-entry integrity |
| Customer identity & KYC docs | Party module | Shared across banking and wallet |
| Phone-to-account mappings | Wallet schema | Wallet-specific proxy routing |
| Agent hierarchy & commissions | Wallet schema | Agent network is wallet-specific |
| USSD session state | Wallet schema | Channel-specific session management |
| Biller configuration | Wallet schema | VAS-specific integration config |
Deployment Architecture
Nav.Wallet deploys as part of the idbanking container — it is not a separate service. This simplifies operations while maintaining logical separation through module boundaries:
flowchart LR
subgraph k8s["Kubernetes Cluster"]
direction TB
style k8s fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px
subgraph pods["Application Pods"]
API["idbanking
(API + Wallet module)"]
SPA["idbanking-spa
(Admin UI)"]
WalletApp["navwallet-app
(Mobile web)"]
Investor["investor-site
(Investor portal)"]
end
subgraph data["Data Layer"]
PG["PostgreSQL
(primary + replica)"]
Redis["Redis
(session cache)"]
end
end
subgraph external["External"]
MNO["MNO APIs"]
Billers["Biller APIs"]
SMS["SMS Gateway"]
WhatsApp["WhatsApp Business API"]
end
WalletApp -->|"HTTPS"| API
SPA -->|"HTTPS"| API
API --> PG
API --> Redis
API --> MNO
API --> Billers
API --> SMS
API --> WhatsApp
Scaling Considerations
- Horizontal scaling — The
idbankingpod scales horizontally behind a load balancer; wallet state is in PostgreSQL, not in-memory - Database read replicas — Transaction history and reporting queries route to read replicas
- USSD session affinity — USSD sessions use Redis for cross-pod session state
- Circuit breakers per-pod — Each pod maintains independent circuit breaker state to avoid fleet-wide cascading failures
Feature Flagging
Nav.Wallet implements an environment-driven feature flag system that allows operators to enable or disable individual capabilities per deployment without code changes. This supports phased market rollouts where not all integrations (e.g., bill payments, airtime) are available from day one.
flowchart TB
subgraph config["Environment Configuration"]
direction TB
style config fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
EnvFile["environment.ts / environment.prod.ts"]
Flags["features: {
sendMoney: true
buyAirtime: true
payBills: false
cashOut: true
kycUpgrade: true
cards: false
}"]
EnvFile --> Flags
end
subgraph service["Feature Flags Service"]
direction TB
style service fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
FFS["FeatureFlagsService
isEnabled(feature)"]
Flags --> FFS
end
subgraph enforcement["Enforcement Points"]
direction TB
style enforcement fill:#fce4ec,stroke:#c62828,stroke-width:2px
RouteGuard["featureGuard
(route-level block)"]
Dashboard["Dashboard
(hide quick actions)"]
USSD["USSD Menus
(hide menu items)"]
end
FFS --> RouteGuard
FFS --> Dashboard
FFS --> USSD
subgraph result["User Experience"]
direction LR
style result fill:#fff3e0,stroke:#e65100,stroke-width:2px
Visible["Enabled features
shown and accessible"]
Hidden["Disabled features
hidden from UI + routes blocked"]
end
RouteGuard --> Visible
RouteGuard --> Hidden
Dashboard --> Visible
Dashboard --> Hidden
How It Works
Feature flags are defined in the Angular environment configuration file. At build time, the enabled set is baked into the application bundle. The FeatureFlagsService exposes a simple isEnabled(feature) API consumed at two enforcement layers:
- Route guards — A
featureGuardprevents navigation to disabled feature pages. If a user attempts to access a disabled route (e.g., via bookmarked URL), they are redirected to the dashboard. - UI rendering — The dashboard component filters quick action buttons based on flags. Disabled features do not appear in the navigation or action menus.
Configuration
// environment.prod.ts
export const environment = {
production: true,
apiBaseUrl: '/api',
// ...
features: {
sendMoney: true, // P2P transfers
buyAirtime: false, // Disabled until MNO integration live
payBills: false, // Disabled until biller APIs onboarded
cashOut: true, // Agent cash-out operations
kycUpgrade: true, // Document upload for tier upgrade
cards: false, // Virtual card issuance
},
};
Deployment Scenarios
| Scenario | Flags Enabled | Use Case |
|---|---|---|
| MVP launch | sendMoney, cashOut, kycUpgrade | Core P2P + agent network only |
| Post MNO integration | + buyAirtime | Airtime operator API live |
| Post biller onboarding | + payBills | Bill payment APIs configured |
| Card pilot | + cards | Virtual card processor connected |
| Full feature set | All enabled | Mature market with all integrations |
Architectural Benefits
- Zero-downtime feature rollout — Enable a feature by changing a single boolean and redeploying the frontend; no backend changes required
- Per-market configuration — Different jurisdictions can have different feature sets based on available integrations
- Safe rollback — If a newly enabled feature causes issues, set the flag to
falseand redeploy to instantly hide it - Clear build-time guarantees — Disabled features are excluded from the rendered UI at build time, not hidden via CSS; they produce no network requests or DOM elements
Guard Implementation
// feature.guard.ts
export function featureGuard(feature: FeatureKey): CanActivateFn {
return () => {
const featureFlags = inject(FeatureFlagsService);
const router = inject(Router);
if (featureFlags.isEnabled(feature)) {
return true;
}
return router.createUrlTree(['/dashboard']);
};
}
// Usage in routes
{
path: 'airtime',
canActivate: [featureGuard('buyAirtime')],
loadComponent: () => import('./buy-airtime.component')
}
This approach keeps feature management at the configuration layer — no feature branches, no runtime API calls, and no conditional compilation. Each deployment environment declares exactly which capabilities it exposes.