Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+509
View File
@@ -0,0 +1,509 @@
# 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/](src/app/) | Store configuration, `rootReducer`, typed `useAppDispatch`/`useAppSelector` |
| [src/navigation/](src/navigation/) | Root/tab/stack navigators, param lists, route→feature maps |
| [src/modules/](src/modules/) | Domain modules (see §7) |
| [src/features/](src/features/) | Feature-flag service, provider, gates, hooks |
| [src/services/api/](src/services/api/) | Axios client, interceptors, endpoint registry, error catalogue |
| [src/services/security/](src/services/security/) | Session manager, device identity, MPIN crypto, biometrics |
| [src/services/storage/](src/services/storage/) | Keychain/AsyncStorage-backed secure storage |
| [src/components/](src/components/) | Shared UI primitives, barrel-exported |
| [src/theme/](src/theme/) | `FintechTheme` token contract + context |
| [src/constants/](src/constants/) | Feature keys, route names, RTK Query tags, translations, fixtures |
| [src/types/](src/types/) | `api.ts` (client errors), `app.ts` (domain), `cbs.ts` (wire types) |
---
## 2. Composition root
[App.tsx](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](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](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](src/navigation/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](src/navigation/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](src/navigation/featureRoutes.ts) maps every
route name to the `FeatureKey` that owns it, and each navigator wraps its
`Screen` in `canRoute(...)`:
```tsx
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](src/navigation/FeatureRoute.tsx) is a secondary screen-level
guard for defence in depth.
---
## 4. State management
[src/app/store.ts](src/app/store.ts) · [src/app/rootReducer.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 `queryFn`s |
Cache invalidation runs on a central tag list —
[src/constants/queryTags.ts](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](src/types/cbs.ts)):
```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](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](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](src/services/api/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](src/services/api/endpoints.ts), with
parameterised routes as functions.
`resolveWithMock` in
[serviceHelpers.ts](src/services/api/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](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](src/features/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](src/features/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/](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](src/services/security/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](src/services/security/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](src/services/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](src/services/security/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](src/hooks/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](src/components/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/](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/](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](src/hooks/useTranslation.ts) resolves keys against
[translations.ts](src/constants/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](src/services/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](src/services/device/simCard.ts)).
- **Metro** ([metro.config.js](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](src/features/featureService.test.ts) | flag resolution, hierarchy, layer precedence, fail-closed behaviour |
| [cbsErrors.test.ts](src/services/api/cbsErrors.test.ts) | all 31 codes present and unique, copy for every code, retryability, `CbsApiError` identity |
| [validation.test.ts](src/utils/validation.test.ts) | shared input validators |
| [emi.test.ts](src/modules/loan/utils/emi.test.ts) | loan EMI maths |
| [depositCalculators.test.ts](src/modules/deposit/utils/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](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](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 `queryFn`s, 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'`.