Files
CBS-Mobile-App/ARCHITECTURE.md
T

25 KiB
Raw Blame History

BankCBS — Mobile Application Architecture

A React Native mobile banking client for a Core Banking System (CBS) backend. It serves two distinct applications from one binary — a retail customer app and a field-agent app — selected at runtime by the authenticated user's role, with every navigable route, tab and control individually switchable through a hierarchical feature-flag engine.

Package bankcbs v1.0.0 (com.bankcbs.mobile)
Runtime React Native 0.83.4, React 19.2, TypeScript 5.9 (strict, noUncheckedIndexedAccess)
State Redux Toolkit 2.11 + RTK Query, react-redux 9.2
Navigation React Navigation 7 (native-stack + bottom-tabs)
Transport Axios 1.14 wrapped by a custom RTK Query baseQuery
Crypto / secrets node-forge (RSA-OAEP), react-native-keychain, react-native-biometrics
Scale 381 source files · ~58,300 LOC · 103 screens · 17 modules · 182 feature keys

1. Layering

Dependencies flow strictly downward. Nothing in a lower layer imports from a higher one.

┌───────────────────────────────────────────────────────────────────┐
│  App.tsx / index.js         composition root, provider order      │
├───────────────────────────────────────────────────────────────────┤
│  src/navigation/            role routing + feature-gated routes   │
├───────────────────────────────────────────────────────────────────┤
│  src/modules/<domain>/      17 self-contained domain modules      │
│    screens · components · hooks · store · services · types · utils│
├───────────────────────────────────────────────────────────────────┤
│  src/features/              feature-flag engine (cross-cutting)   │
│  src/components/            16 shared presentational primitives   │
│  src/theme/                 design tokens + ThemeProvider         │
│  src/hooks/                 app-wide hooks (bootstrap, session…)   │
├───────────────────────────────────────────────────────────────────┤
│  src/services/              api · security · storage · device …   │
│  src/app/                   store, rootReducer, typed hooks       │
│  src/config/ constants/ types/ utils/                             │
└───────────────────────────────────────────────────────────────────┘

Directory map

Path Responsibility
src/app/ Store configuration, rootReducer, typed useAppDispatch/useAppSelector
src/navigation/ Root/tab/stack navigators, param lists, route→feature maps
src/modules/ Domain modules (see §7)
src/features/ Feature-flag service, provider, gates, hooks
src/services/api/ Axios client, interceptors, endpoint registry, error catalogue
src/services/security/ Session manager, device identity, MPIN crypto, biometrics
src/services/storage/ Keychain/AsyncStorage-backed secure storage
src/components/ Shared UI primitives, barrel-exported
src/theme/ FintechTheme token contract + context
src/constants/ Feature keys, route names, RTK Query tags, translations, fixtures
src/types/ api.ts (client errors), app.ts (domain), cbs.ts (wire types)

2. Composition root

App.tsx fixes the provider order, and the order is load-bearing:

GestureHandlerRootView
└── SafeAreaProvider
    └── redux Provider              ← store must exist before features read session
        └── FeatureProvider         ← flags must resolve before navigators register routes
            └── ThemeProvider
                └── AppShell
                    ├── useAppBootstrap()
                    └── ActivityTracker   (touch capture → session keep-alive)
                        ├── RootNavigator
                        └── ToastHost

index.js handles three pre-React concerns:

  1. react-native-get-random-values is imported first — uuid binds crypto.getRandomValues at module load, and both the device ID and the MPIN nonce import uuid, so a later import throws at startup.
  2. notifee.onBackgroundEvent is registered (required by Notifee even as a no-op).
  3. Text / TextInput defaultProps are patched to the Nunito-Regular font family.

Bootstrap sequence

src/hooks/useAppBootstrap.ts runs once on mount:

  1. bootstrapAuthSession() — mints the device ID and validates any stored session against the server before trusting it.
  2. seedAllData() — populates the fixture-backed modules (profile, dashboard, beneficiaries, payees, deposits, service requests, loans, insurance, cards).
  3. markAppReady(), then registerForPushNotifications().
  4. Registers a global unauthorized handler that dispatches logoutSession() and raises a toast.

It also re-seeds on every sign-in transition, because logout wipes the entire Redux tree (§4), and on sign-out re-reads only the sign-in context — a full bootstrap would raise isHydrating and blank the app behind a loader on the way out.


3. Navigation architecture

RootNavigator                       ← isHydrating? Loader : isAuthenticated?
├── AuthNavigator (unauthenticated)
│     Login · Register · VerifyOtp · CreateMpin · MpinLocked
├── MainTabNavigator  (role: 'User')
│   ├── Home      → HomeNavigator      (+ nested LoanFlow, InsuranceFlow)
│   ├── Payments  → PaymentsNavigator
│   ├── Services  → ServicesNavigator  (Hub; tabPress resets the stack)
│   ├── Cards     → CardsNavigator
│   └── Profile   → ProfileNavigator   (lives in src/modules/profile/navigation/)
└── AgentNavigator    (role: 'Agent')
    ├── AgentDashboard · AgentCustomers · AgentCollections
    └── AgentLoans · AgentProfile        (each a nested native stack)

Three deliberate decisions are documented in the code:

  • Role switch, not a route. RootNavigator.tsx picks the app stack from auth.role. Disabling FeatureKey.AGENT retires the whole field-agent app; an already-signed-in agent falls back to the customer app rather than landing on an empty navigator.
  • Initial route computed, never navigated to. AuthNavigator.tsx derives initialRouteName from a stored profile instead of replace()-ing in an effect — an effect-driven replace races navigation-container readiness and is silently dropped, and the warning that would reveal it is stripped from release builds. signInContextLoaded gates this so a null profile means "unknown" rather than "never registered" before the Keystore has been read.
  • CreateMpin and MpinLocked disable the back gesture. The setup token is single-use; leaving halfway means restarting registration.

Route-level feature gating

src/navigation/featureRoutes.ts maps every route name to the FeatureKey that owns it, and each navigator wraps its Screen in canRoute(...):

const canRoute = useRouteGuard(CARDS_ROUTE_FEATURES);
{canRoute('CardDetails') && <Stack.Screen name="CardDetails" component={CardDetailsScreen} />}

A disabled route is never registered, so it cannot be reached by navigate(), a deep link, or restored navigation state. Hiding the menu entry alone would not achieve that. FeatureRoute.tsx is a secondary screen-level guard for defence in depth.


4. State management

src/app/store.ts · src/app/rootReducer.ts

16 domain slices plus two RTK Query reducers (baseApi, schemesApi). serializableCheck is disabled.

Logout wipes everything. rootReducer intercepts auth/logoutSession/fulfilled and sets state = undefined, so no slice can leak data across sessions. Re-seeding on sign-in (§2) is the counterpart to this.

Three coexisting data-access patterns:

Pattern Where Used by
baseApi.injectEndpoints (RTK Query over Axios) modules/*/services/*Api.ts agent, cards, deposit, home ×2, insurance, loan, payments, profile, services ×2, transfer
createSlice + createAsyncThunk modules/*/store/ auth, cards, deposit, home, insurance, loan, payments, profile, services, transfer, ui
Standalone createApi modules/schemes/services/schemesApi.ts schemes only — its own reducer path, fixture queryFns

Cache invalidation runs on a central tag list — src/constants/queryTags.ts, 25 tags — registered on baseApi so any module can invalidate another's cache.


5. Data & API layer

The envelope

Every CBS response shares one wrapper (src/types/cbs.ts):

interface CbsEnvelope<T> {
  success: boolean; message: string;
  data?: T;            // present on success; VALIDATION_ERROR carries a field map here
  errorCode?: string;  // only on failure — this is what the app switches on
  timestamp: string;
}

Interceptors

src/services/api/interceptors.ts is where the contract is enforced:

  • Request: attaches Authorization: Bearer <token> from secureStorage.
  • Response: branches on envelope.success, not the HTTP status — a failure can arrive with a 2xx, and treating it as a payload hands callers undefined. Failures are thrown as a CbsApiError; successes are unwrapped so callers read response.data as the payload.
  • Session mirroring: any call carrying an Authorization header calls sessionManager.touch(), because that same call reset the server's idle timer.
  • Session loss: only SESSION_INVALID clears storage and fires the global unauthorized handlers. INVALID_CREDENTIALS shares the 401 but means a wrong MPIN on the sign-in form — bouncing the user out of a screen they aren't in yet would be nonsense.

Error catalogue

src/services/api/cbsErrors.ts enumerates the 31 backend error codes as a const tuple, plus 3 client-synthesised codes (NETWORK_ERROR, REQUEST_TIMEOUT, UNKNOWN_ERROR). Each has authored { title, description, retryable } copy, so no failure can render as a blank toast — asserted by cbsErrors.test.ts. errorCode is treated as the stable contract; message is assumed re-wordable server-side, so no branch reads the message or the status.

Endpoint registry & mock strategy

All paths live in one object, src/services/api/endpoints.ts, with parameterised routes as functions.

resolveWithMock in serviceHelpers.ts returns fixtures when appConfig.features.useMockServer is set. Per its own contract, endpoints the CBS API actually implements must not go through it — they call apiClient directly so failures surface rather than being papered over with demo data. Live today: registration, sign-in, accounts/MMID, payees. Fixture-backed: dashboard, cards, loans, insurance, deposits, service requests, the agent app.


6. Feature-flag engine

The most distinctive part of the architecture: 182 keys covering every module, screen, tab, menu row, button and action.

Keys encode the hierarchy

src/constants/featureKeys.ts declares a dot-notation enum; the service derives parent/child from the path itself, so the tree can never drift from the naming.

loans  →  loans.apply  →  loans.apply.submit

featureConfig.ts is a flat Record<FeatureKey, FeatureDefinition> on purpose — the Record makes TypeScript reject the file if any declared key lacks config, which a nested object literal cannot guarantee.

Resolution

featureService.ts — one instance, holding a registry, a layer stack, a resolution cache, and a listener set:

default → environment → remote → tenant → user      (lowest → highest priority)

A feature is enabled only if it exists, its own flag is on, and every ancestor resolves enabled. Resolution is fail-closed: an unknown key, or a key with missing config, is false — in a banking app an unrecognised flag must hide the feature, never reveal it. validate() runs in __DEV__ and reports missing-config, unknown-key and missing-parent issues, catching a typo like loans.aply.submit that would otherwise become a root feature no parent could switch off.

Consumption surface

API Use
useFeature(key) / useFeatures([...]) reactive boolean(s); subscribes via useSyncExternalStore on a version counter
<FeatureGate feature={...} mode="all|any"> renders children or nothing — deliberately no fallback prop, so a disabled feature leaves no trace: no greyed control, no "coming soon", no hidden-but-present node
<DynamicFeatureGate feature="string"> JSON-driven UI where the key arrives as data
useFeatureAction / guardFeature guards a handler some other path may still hold
useRouteGuard(map) navigator-level route registration (§3)
FeatureProvider resolves async remote/tenant layers at startup, exposes ready

FeatureProvider swallows loader failures so the app always falls back to compiled-in defaults rather than blocking startup. Remote and tenant loaders are optional props — nothing is wired to Firebase Remote Config or a config API yet, but the seam exists without touching a single UI component.


7. Module anatomy

Every module under src/modules/ follows the same internal shape, so any module is navigable once one of them is:

modules/<domain>/
├── screens/       route components
├── components/    domain-specific presentational pieces
├── hooks/         form/flow logic extracted from screens
├── store/         <domain>Slice.ts · <domain>Thunk.ts · <domain>Selectors.ts
├── services/      <domain>Api.ts  (baseApi.injectEndpoints)
├── types/         domain types
├── utils/         calculators, validators, fixtures
├── constants/     domain constants (agent, awareness, schemes)
└── navigation/    only where the module owns its own navigator (agent, profile)
Module Screens Components Store Services Hooks Files
agent 18 9 2 1 5 41
services 13 6 3 2 3 30
profile 12 5 3 1 1 25
insurance 10 9 3 2 2 29
loan 10 11 3 1 4 33
home 7 7 3 2 1 22
payments 7 1 4 1 1 17
schemes 7 5 2 1 1 18
auth 5 7 3 1 1 19
deposit 5 4 3 1 2 19
cards 4 9 3 1 2 21
awareness 2 1 — — 1 6
notifications 1 — 1 — — 2
search 1 — — — — 2
employee 1 — — — — 1
transfer — 1 3 1 1 8
ui — — 3 — 1 4

transfer is state-and-service only (its screens live under payments); ui holds cross-cutting UI state (toasts, app-ready, session-expired).


8. Security & session architecture

MPIN never leaves the device in the clear

mpinCrypto.ts encrypts { mpin, nonce, ts } under the server's RSA public key (RSA-OAEP, SHA-256, MGF1 explicitly SHA-256) and sends it as encryptedMpin. Three integration hazards are called out in the source, all of which fail silently:

  • MGF1 must be passed explicitly. node-forge happens to default it to md, but Java's OAEPWithSHA-256AndMGF1Padding leaves MGF1 on SHA-1 — relying on a default produces ciphertext the server cannot decrypt, with a useless error.
  • ts is in seconds. Milliseconds read as ~55,000 years ahead and are rejected.
  • nonce must be fresh per call or it is refused as a replay.

The key is fetched from /crypto/public-key and cached; PINNED_PUBLIC_KEY is a fallback for when the fetch fails. invalidate() drops the cache after a MALFORMED_REQUEST, which is what a stale key looks like client-side (the server cannot say "your key is wrong" without leaking whether the profile exists).

Device binding

deviceIdentity.ts mints one UUID on first launch, persisted to the Keychain under ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY (not WHEN_UNLOCKED_* — the ID is not a secret and sign-in must read it before the user authenticates). It is memoised in memory, and concurrent cold-start callers share one in-flight promise — otherwise each would mint a UUID, last write wins, and the device silently unbinds. The registered profile is bound to this value; a mismatch returns DEVICE_NOT_BOUND, which reads like a server fault but is a client bug.

Secure storage

secureStorage.ts keeps three Keychain services — session, last profile, biometric MPIN — plus a biometric-enabled flag in AsyncStorage (the device ID is a fourth, owned by deviceIdentity). The session is cached in memory because the request interceptor needs the token on every call and an Android Keystore read costs tens of ms.

The MPIN is retained only when the customer opts into biometric sign-in: the API has no refresh token, so a session can only be opened by encrypting the MPIN, which makes biometric sign-in impossible otherwise. It sits behind the device's own biometric prompt, never in AsyncStorage. An unparseable session (written by an older build) is treated as absent rather than crashing bootstrap.

Session lifetime — three independent triggers

sessionManager.ts adopts the server's own limits at sign-in (idleTimeoutSeconds, expiresAt) so the app never guesses and never drifts from what the backend enforces. It tracks two clocks separately:

  • lastServerContactAt — the only thing the server's idle window is measured from, so the only thing expiry may be judged on.
  • lastInteractionAt — screen touches. Because most of the app reads from fixtures, a user can tap around for five minutes without one authenticated call; treating that as idle would sign them out mid-use. So interaction drives a keep-alive, never the expiry clock itself.

useSessionTimeout.ts polls every 5s and expires on whichever of three triggers fires first — idle, absolute, or background (returning to the foreground after lockAfterBackgroundMs, default 30s). Each has its own copy; background is toned as informational because a deliberate re-lock is not a failure, and dressing it up as one would train users to ignore the banner that matters.

ActivityTracker wraps the whole tree and reads touches via onStartShouldSetResponderCapture, returning false so it sees every touch on the way down but never becomes the responder — a wrapper that actually captured touches would break every pressable beneath it.

appConfig.security.requireMpinOnLaunch defaults to true: every launch asks for the MPIN rather than resuming a stored session, because there is no local MPIN verifier — the only one is POST /auth/login, so "ask again" necessarily means "open a fresh session".


9. Presentation layer

Theme. src/theme/ defines a FintechTheme token contract — colours (including a "Cyber Nexus" accent set), spacing, typography scale, radii, shadows — consumed via useTheme(). RootNavigator maps these onto React Navigation's DarkTheme.

Shared primitives. 16 barrel-exported components in src/components/: Button, Input, Card, Modal, BottomSheet, DatePicker, Loader, EmptyState, BalanceCard, TransactionItem, AppHeader, ToastHost, ActivityTracker, plus three keyboard/layout containers.

ScreenShell is the standard screen wrapper — gradient background, safe area, AppHeader, optional scroll, per-platform KeyboardAvoidingView behaviour (padding on iOS, height on Android), withTabBar padding so content never hides behind the absolutely-positioned tab bar, and a fixedFooter slot.

i18n. useTranslation.ts resolves keys against translations.ts using the language in the profile slice, falling back to en then to the key itself. Currently a small dictionary rather than a full i18n library.

Notifications. pushNotifications.ts (Notifee) for delivery; the notifications slice holds the in-app inbox.


10. Build & platform

  • Android permissions: INTERNET, fine/coarse location (branch locator, agent routes), POST_NOTIFICATIONS, READ_PHONE_STATE / READ_PHONE_NUMBERS (SIM-card checks in simCard.ts).
  • Metro (metro.config.js) block-lists native build output — android/app/build alone exceeds 2 GB after a debug build, and without watchman Metro walks it on startup and never becomes responsive.
  • TypeScript is strict with noUncheckedIndexedAccess and bundler module resolution.
  • Scripts: start, android, ios, lint, test, typecheck, clean:android.
  • Notable native deps: MapLibre (branch locator / agent routes), Reanimated 4
    • Worklets, gesture-handler, @gorhom/bottom-sheet, SVG, linear-gradient, haptic feedback, date-picker.

11. Testing

Jest, with 5 unit suites concentrated on the logic where a silent error is costly:

Suite Covers
featureService.test.ts flag resolution, hierarchy, layer precedence, fail-closed behaviour
cbsErrors.test.ts all 31 codes present and unique, copy for every code, retryability, CbsApiError identity
validation.test.ts shared input validators
emi.test.ts loan EMI maths
depositCalculators.test.ts FD/RD maturity maths

There is no component, navigation or integration test layer.


12. Current state & known gaps

Factual observations from the code, not recommendations:

  1. useMockServer is true in src/config/env.ts. Registration, sign-in, accounts/MMID and payees hit the live API; the other modules resolve from ~1,390 lines of fixtures in src/constants/mockData.ts.
  2. API_ORIGIN is a hard-coded LAN address (http://10.209.40.168:8089) over plain HTTP. The file documents that debug builds permit cleartext and release builds will need HTTPS or a network-security-config. There is no per-environment config mechanism.
  3. PINNED_PUBLIC_KEY is committed in source as the fallback when the key fetch fails.
  4. schemesApi bypasses the shared stack — its own createApi with fetchBaseQuery({ baseUrl: '/' }) and fixture queryFns, including a hard-coded otp !== '123456' check. It does not use baseApi, the endpoint registry, or the CBS error catalogue.
  5. Remote and tenant flag layers are unwired. ENVIRONMENT_FEATURE_OVERRIDES is {} and FeatureProvider is mounted without loader props, so all 182 keys resolve from the compiled-in defaults. The layering exists as a seam.
  6. Three data-access patterns coexist (RTK Query, slice+thunk, standalone createApi), several modules using two of them at once.
  7. serializableCheck is disabled store-wide.
  8. The employee module is a single placeholder screen; EmployeeNavigator is defined but not reachable from RootNavigator, which routes only 'Agent' and 'User'.