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

InterfacePurposeFailure Strategy
IAirtimeOperatorClientVend prepaid airtime to mobile numbersCircuit breaker (5 failures → 30s cooldown) + auto-reversal
IBillerClientValidate bill references and submit paymentsCircuit breaker + auto-reversal within 30s
IFraudDetectionReal-time transaction risk scoringFail-closed (decline on unavailability)
ISanctionsScreeningName/entity matching against watchlistsFail-closed (block on unavailability)
ISIMSwapDetectionDetect recent SIM swaps for fraud preventionFail-closed (suspend account on detection)
INotificationServiceMulti-channel customer alertsRetry with exponential backoff
IGeocodingAdapterAgent location verificationGraceful 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:

FieldPurpose
biller_idUnique identifier
display_nameCustomer-facing name
categoryElectricity, Water, Television, Municipal
validation_endpointURL for account/meter validation
payment_endpointURL for payment submission
is_activeEnable/disable without code deployment
reference_formatRegex 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:

  1. Funds are debited from the sender and held in a suspense account
  2. Recipient receives an SMS invitation to register and claim the funds
  3. On registration, the ProxyResolver triggers automatic release of held transfers
  4. 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

DataOwnerRationale
Account balances & transactionsCILedgerSingle source of financial truth; double-entry integrity
Customer identity & KYC docsParty moduleShared across banking and wallet
Phone-to-account mappingsWallet schemaWallet-specific proxy routing
Agent hierarchy & commissionsWallet schemaAgent network is wallet-specific
USSD session stateWallet schemaChannel-specific session management
Biller configurationWallet schemaVAS-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 idbanking pod 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:

  1. Route guards — A featureGuard prevents 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.
  2. 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

ScenarioFlags EnabledUse Case
MVP launchsendMoney, cashOut, kycUpgradeCore P2P + agent network only
Post MNO integration+ buyAirtimeAirtime operator API live
Post biller onboarding+ payBillsBill payment APIs configured
Card pilot+ cardsVirtual card processor connected
Full feature setAll enabledMature 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 false and 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.