Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on

- Positive Pay on the 12 Oct /pps contract (account number, not account ID):
  OTP enrollment, cheque submit, saved cheques, detail and stop payment.
  Full flow passed on the phone against mobile-banking-backend.lincpay.
- Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on
  Home and the Statement screen; dated PDF export with the password rule.
- Onboarding slides and splash screen, Inter font, shared Icon and
  TypewriterText components, nearby branches via OpenStreetMap.
- Every feature switched on; the 14 Oct switch set is kept in
  featureConfig.stage-2026-10-14.ts. Loans run on demo data until the
  backend has /loans routes.
- npm run android wraps run-android for shells spawned from VS Code.
- OpenProject work packages updated to 14-10-2026 status, with screenshots
  of Positive Pay, statement, loans and cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-10-14 18:18:40 +05:30
co-authored by Claude Opus 5.5
parent 5fbd13ba12
commit f960a119e8
510 changed files with 20268 additions and 6483 deletions
+2 -2
View File
@@ -84,7 +84,7 @@ GestureHandlerRootView
`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.
3. `Text` / `TextInput` `defaultProps` are patched to the `Inter-Regular` font family.
### Bootstrap sequence
@@ -488,7 +488,7 @@ Factual observations from the code, not recommendations:
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
2. **`API_ORIGIN` is a hard-coded LAN address** (`http://192.168.6.138:8079`) 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.
+22
View File
@@ -117,3 +117,25 @@ dependencies {
implementation jscFlavor
}
}
/**
* Makes `gradlew clean` (and Android Studio's Clean Project) work.
*
* The app's native clean re-runs CMake before cleaning, and the app's CMake
* build pulls in each library's codegen output from
* node_modules/<lib>/android/build/generated/. `clean` deletes those library
* build folders first, so the configure step fails with "add_subdirectory given
* source ... which is not an existing directory" and the whole clean fails.
*
* There is nothing for that step to do that deleting the CMake folder does not
* do better, so it is skipped and `.cxx` goes with `build/`. The next build
* regenerates the codegen output before CMake needs it.
*/
tasks.configureEach { task ->
if (task.name.startsWith("externalNativeBuildClean")) {
task.enabled = false
}
}
tasks.named("clean") {
delete file(".cxx")
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,10 @@
package com.bankcbs.mobile
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -19,4 +24,43 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
restoreAdjustResize()
}
/**
* Brings back `windowSoftInputMode="adjustResize"` on Android 15 and later.
*
* This app targets SDK 36, and from Android 15 a window targeting 35+ is forced edge-to-edge —
* the opt-out attribute is ignored at 36. An edge-to-edge window is never resized for the
* keyboard: the system only reports the keyboard as an IME inset and leaves the app to react.
* Every screen here was built on the window shrinking (that is what the manifest asks for, and
* what Android 14 and earlier still do), so on 15+ the keyboard simply covered the lower half of
* every form.
*
* Padding the content view by the IME inset is exactly the resize the system used to do: the
* React root ends at the top of the keyboard, scroll views shrink, and Android's ScrollView
* scrolls the focused field back into view on its own. Nothing in JS has to know.
*
* Gated on API 35 because below it the system still resizes the window itself, and padding on
* top of that would lift everything twice. It is also self-correcting above it: a window the
* system did resize reports no IME inset to its content, so the padding comes out as zero.
*/
private fun restoreAdjustResize() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
return
}
val content = findViewById<View>(android.R.id.content) ?: return
ViewCompat.setOnApplyWindowInsetsListener(content) { view, insets ->
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, ime.bottom)
// Passed on untouched, so safe-area-context and everything below still see the real
// status- and navigation-bar insets.
insets
}
}
}
@@ -22,6 +22,7 @@
First entry is the one `API_ORIGIN` currently points at; the rest
are addresses this build has been aimed at before. -->
<domain includeSubdomains="true">mobile-banking-backend.lincpay</domain>
<domain includeSubdomains="false">192.168.6.138</domain>
<domain includeSubdomains="false">10.209.40.168</domain>
<domain includeSubdomains="false">192.168.6.165</domain>
<domain includeSubdomains="false">192.168.1.11</domain>
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+12 -8
View File
@@ -2,20 +2,24 @@
"migIndex": 1,
"data": [
{
"path": "assets/fonts/Nunito-Bold.ttf",
"sha1": "a2d6a8de275b708042cfa663533194d18d2861c4"
"path": "assets/fonts/Inter-Bold.ttf",
"sha1": "9461e7c083137c2abe6d198f784fb0d341a527a1"
},
{
"path": "assets/fonts/Nunito-ExtraBold.ttf",
"sha1": "30b19033e24e4a79ca8a0ac3e780ab8e3d664185"
"path": "assets/fonts/Inter-ExtraBold.ttf",
"sha1": "872bc40675c084c451af1c3eb10e21c13e2008a3"
},
{
"path": "assets/fonts/Nunito-Regular.ttf",
"sha1": "6b19e7ffbd729c36ca7bbd0163a791d2716447a8"
"path": "assets/fonts/Inter-Medium.ttf",
"sha1": "c1d25d8c10adb45a970e71c387bbd6380f10992d"
},
{
"path": "assets/fonts/Nunito-SemiBold.ttf",
"sha1": "12eebee4d162f8f099235a42623369d5e57a6af9"
"path": "assets/fonts/Inter-Regular.ttf",
"sha1": "6e3430f906a4b42b4b63fd8f33e498a43d9f8d84"
},
{
"path": "assets/fonts/Inter-SemiBold.ttf",
"sha1": "b4bdaf6bdd6a479b46c452a7bbae13f5cb2079bf"
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+770
View File
@@ -0,0 +1,770 @@
# Per-Bank Flow Variations
Feature flags answer **"does this bank have the feature?"**
This document answers **"how does the feature work for this bank?"**
| | |
|---|---|
| **Status** | Proposal |
| **Extends** | [MULTI_BANK_ARCHITECTURE.md](MULTI_BANK_ARCHITECTURE.md) (tenant packs, feature layers, `/features/config`) |
| **Audience** | Mobile team, backend team, product |
---
## Contents
1. [The problem](#1-the-problem)
2. [What the code does today](#2-what-the-code-does-today)
3. [The variation ladder](#3-the-variation-ladder)
4. [Rung 1 — Bank settings](#4-rung-1--bank-settings)
5. [Rung 2 — Server-driven challenges](#5-rung-2--server-driven-challenges-security-flows)
6. [Rung 3 — Journey variants](#6-rung-3--journey-variants-non-security-flows)
7. [Rung 4 — Strategies](#7-rung-4--strategies-same-screens-different-logic)
8. [Rung 5 — Slot overrides](#8-rung-5--slot-overrides-last-resort)
9. [Not allowed — branching on the bank](#9-not-allowed--branching-on-the-bank)
10. [One config response](#10-one-config-response)
11. [Worked example: one transfer, three banks](#11-worked-example-one-transfer-three-banks)
12. [Folder layout](#12-folder-layout)
13. [Testing](#13-testing)
14. [Migration plan](#14-migration-plan)
15. [Checklist: a bank asks for a different flow](#15-checklist-a-bank-asks-for-a-different-flow)
---
## 1. The problem
Banks don't only buy different features. They often want **the same feature to work differently**:
| Feature | Bank A | Bank B | Bank C |
|---|---|---|---|
| Approve a fund transfer | TPIN → OTP | OTP only up to ₹5,000; TPIN → OTP above | Biometric → TPIN |
| Register a device | Mobile → SMS OTP → MPIN | Mobile → debit card + PIN → OTP → MPIN | Mobile → account no. + date of birth → OTP → MPIN |
| Add a payee | Verify name (penny drop) → save | Save → 24-hour cooling period | Save → OTP |
| PIN / OTP length | MPIN 4, OTP 6 | MPIN 6, OTP 6 | MPIN 4, OTP 4 |
| Loan application | Product → amount → employment → documents → review | Product → amount → documents → review | — (no loans) |
| Account number format | 15 digits | 14 digits | 12–16 digits |
**What must not happen:** `if (bank === 'B')` spread across screens. With N banks and M features that grows to N × M branches nobody can test. The same goes for per-bank Git branches or copied screens: every bug fix then has to be made N times.
---
## 2. What the code does today
Every multi-step flow runs its own hand-written step machine, with the order and the values hard-coded:
| Flow | Steps, hard-coded | Where |
|---|---|---|
| Transfer authorisation | `'review' \| 'tpin' \| 'otp'` — "IMPS needs the TPIN first" | [PaymentConfirmationScreen.tsx:40, :200](../src/modules/payments/screens/PaymentConfirmationScreen.tsx) |
| TPIN create | `'mpin' \| 'otp' \| 'tpin' \| 'exists'` | [TpinCreateFlow.tsx:30](../src/modules/payments/components/TpinCreateFlow.tsx) |
| TPIN reset | `'start' \| 'otp' \| 'tpin' \| 'notSet' \| 'stopped'` | [TpinResetFlow.tsx:36](../src/modules/payments/components/TpinResetFlow.tsx) |
| MPIN reset | `'mobile' \| 'otp' \| 'newMpin'` | [MpinResetFlow.tsx:39](../src/modules/auth/components/MpinResetFlow.tsx) |
| Registration | `navigate('VerifyOtp')` → `CreateMpin` | [RegisterScreen.tsx:142](../src/modules/auth/screens/RegisterScreen.tsx), [AuthNavigator.tsx](../src/navigation/AuthNavigator.tsx) |
| Stop payment | `'find' \| 'confirm' \| 'stopped'` | [StopPaymentScreen.tsx:56](../src/modules/services/screens/StopPaymentScreen.tsx) |
Hard-coded values a bank might need to change:
| Value | Where |
|---|---|
| `OTP_LENGTH = 6`, copied 3 times, plus `length={6}` | [PaymentConfirmationScreen.tsx:37](../src/modules/payments/screens/PaymentConfirmationScreen.tsx), [TpinCreateFlow.tsx:35](../src/modules/payments/components/TpinCreateFlow.tsx), [TpinResetFlow.tsx:41](../src/modules/payments/components/TpinResetFlow.tsx), [VerifyOtpScreen.tsx:210](../src/modules/auth/screens/VerifyOtpScreen.tsx) |
| `TPIN_LENGTH = 4` | [tpinValidation.ts:4](../src/modules/payments/utils/tpinValidation.ts) |
| `MPIN_LENGTH = 4` | [validation.ts:10](../src/utils/validation.ts) |
| `RTGS_MINIMUM = 200000` | [TransferToAccountScreen.tsx:40](../src/modules/payments/screens/TransferToAccountScreen.tsx) |
| `requireMpinOnLaunch`, `lockAfterBackgroundMs` | [appConfig.ts](../src/config/appConfig.ts) |
The flows themselves are well written: idempotency keys, careful error handling, and never retrying after a timeout. **The problem is only where the decisions live.** The refactor moves the decisions out of the screens and keeps all of that logic.
---
## 3. The variation ladder
There are five ways to let a bank differ. **Always use the lowest rung that fits.** Higher rungs are more powerful and cost more to maintain.
| Rung | What differs between banks | Mechanism | Lives in | Can change without OTA? | Example |
|---|---|---|---|---|---|
| **1. Settings** | A value | Typed `BankSettings` | Pack + server | ✅ Yes | RTGS minimum, lock timeout, PIN length |
| **2. Server-driven challenges** | **Security steps**: proving identity, approving money | Challenge chain: the server says what it needs next | Backend policy; the app only renders | ✅ Yes | TPIN → OTP vs OTP only |
| **3. Journey variants** | Which screens a **non-security** journey has, and in which order | Named step lists compiled into the app | Core registry; pack or server picks one | ✅ Picks among shipped variants | Loan application with or without the employment step |
| **4. Strategies** | Rules or integration, with the **same screens** | An interface with a default and per-bank implementations | Core default, pack override | ❌ OTA | Account-number validation, receipt format |
| **5. Slot overrides** | A **section or screen looks different** | Slot registry | Pack `overrides/` | ❌ OTA | Bank C's own Home header |
| ✗ | — | `if (tenant.id === …)`, forks, branches | **Never** | — | — |
```mermaid
flowchart TD
Q0["A bank wants feature X to work differently"] --> Q1{"Only a number,<br/>length or on/off?"}
Q1 -- yes --> R1["Rung 1 — Setting"]
Q1 -- no --> Q2{"Is it a security step?<br/>PIN, OTP, biometric,<br/>approval, cooling period"}
Q2 -- yes --> R2["Rung 2 — Server challenge policy"]
Q2 -- no --> Q3{"Different steps or order<br/>in a journey?"}
Q3 -- yes --> R3["Rung 3 — Journey variant"]
Q3 -- no --> Q4{"Same screens,<br/>different rule or backend?"}
Q4 -- yes --> R4["Rung 4 — Strategy"]
Q4 -- no --> R5["Rung 5 — Slot override"]
```
---
## 4. Rung 1 — Bank settings
### 4.1 Contract
```ts
// src/tenant/settings.ts
export interface BankSettings {
auth: {
mpinLength: 4 | 6;
requireMpinOnLaunch: boolean;
lockAfterBackgroundMs: number;
};
payments: {
tpinLength: 4 | 6;
rtgsMinimum: number;
payeeCoolingHours: number;
};
services: {
chequeLeafOptions: number[];
};
}
/** Today's values, so the first bank sees no change. */
export const DEFAULT_SETTINGS: BankSettings = {
auth: { mpinLength: 4, requireMpinOnLaunch: true, lockAfterBackgroundMs: 30_000 },
payments: { tpinLength: 4, rtgsMinimum: 200_000, payeeCoolingHours: 0 },
services: { chequeLeafOptions: [25, 50, 100] },
};
```
OTP length is deliberately **not** a setting. Under rung 2 every OTP challenge carries its own `length`, so the app never has to know it in advance.
### 4.2 Layers
The same layering as feature flags:
```
DEFAULT_SETTINGS → tenants/<id>/settings.ts → server "settings" (in /features/config)
```
| Rule | Why |
|---|---|
| Every server value is checked against a schema: type and range. Invalid values are ignored and the layer below stays | A typo on the server must not break the app |
| **Security timeouts: the stricter value wins.** `lockAfterBackgroundMs` takes the *smaller* of pack and server | The server can tighten security live, but a bad value can never loosen it |
| Values the backend enforces (PIN lengths) must match the backend | The app draws the boxes; the server decides what is valid |
### 4.3 Usage
```ts
// before — TransferToAccountScreen.tsx
const RTGS_MINIMUM = 200000;
// after
const { rtgsMinimum } = useBankSettings().payments;
```
`useBankSettings()` re-renders on a config change, the same way `useFeature` does.
---
## 5. Rung 2 — Server-driven challenges (security flows)
### 5.1 Why the server must drive security steps
Security steps already have to be enforced by the server. If the **app** decides "Bank B skips the TPIN", a modified app can skip it at any bank. So the server decides, and the app only knows **how to show** each kind of challenge:
- Bank A vs B vs C differences live in **one place**: the bank's policy on the server.
- "Above ₹5,000 also ask for the TPIN" (step-up by amount) is a policy rule, with no app change.
- "New device in its first 24 hours → extra OTP" (per customer) is also a policy rule.
- A new bank with a new order of existing challenge types needs **no app release at all**.
### 5.2 Protocol
```
POST /fund-transfers/initiate { requestId, mode, amount, payer, payee }
← { flowId, status: "CHALLENGE",
challenge: { type: "TPIN", length: 4, attemptsRemaining: 3 } }
POST /flows/{flowId}/answer { type: "TPIN", response: "<RSA-OAEP encrypted>" }
← { flowId, status: "CHALLENGE",
challenge: { type: "OTP", length: 6, expiresAt: "…", attemptsRemaining: 3, sentTo: "••••••3210" } }
POST /flows/{flowId}/answer { type: "OTP", response: "482913" }
← { flowId, status: "COMPLETED", result: { refId, custRefNo, txnStatus: "SUCCESS" } }
```
Possible `status` values:
| `status` | Meaning | App does |
|---|---|---|
| `CHALLENGE` | Needs one more proof | Shows that challenge |
| `CHALLENGE` + `errorCode` | Wrong answer, try again | Same challenge, with the error and attempts left |
| `ACTION_REQUIRED` | E.g. `TPIN_NOT_SET` | Leaves the flow (to Create TPIN), then the customer comes back and confirms again |
| `COMPLETED` | Done | Result screen |
| `FAILED` | Attempt over; nothing debited | Back to review, with the message |
Server-side policy per bank (illustrative):
```yaml
# Bank A
transfer: [{ challenges: [TPIN, OTP] }]
# Bank B — step-up by amount
transfer:
- when: amount <= 5000
challenges: [OTP]
- challenges: [TPIN, OTP]
# Bank C
transfer: [{ challenges: [BIOMETRIC, TPIN] }]
```
A `BIOMETRIC` challenge must be verifiable by the server, not just a local fingerprint prompt. The server sends a `nonce`; the app signs it with a key that only biometrics can unlock (`react-native-biometrics` `createSignature`); the server checks the signature against the public key registered for that device. Today the app uses biometrics only to unlock the stored MPIN, so this challenge type is new work.
### 5.3 App side — types
```ts
// src/flows/challenges/types.ts
export type Challenge =
| { type: 'TPIN' | 'MPIN'; length: number; attemptsRemaining?: number }
| { type: 'OTP'; length: number; expiresAt?: string; attemptsRemaining?: number; sentTo?: string }
| { type: 'SET_PIN'; pin: 'TPIN' | 'MPIN'; length: number } // choose a new PIN + confirm
| { type: 'BIOMETRIC'; nonce: string; prompt: string };
export type FlowStep<TResult> =
| { status: 'challenge'; challenge: Challenge; error?: string }
| { status: 'action'; action: 'CREATE_TPIN' | 'RESET_TPIN' }
| { status: 'done'; result: TResult }
| { status: 'ended'; message: string };
/** One security flow — transfer, add payee, TPIN create/reset, MPIN reset, registration. */
export interface AuthorisationFlow<TInput, TResult> {
start(input: TInput): Promise<FlowStep<TResult>>;
answer(challenge: Challenge, response: string): Promise<FlowStep<TResult>>;
reset(): void;
}
```
### 5.4 App side — runner and renderer
```ts
// src/flows/challenges/useAuthorisation.ts
export function useAuthorisation<TInput, TResult>(flow: AuthorisationFlow<TInput, TResult>) {
const [step, setStep] = useState<FlowStep<TResult> | null>(null);
const [busy, setBusy] = useState(false);
const run = async (pending: Promise<FlowStep<TResult>>) => {
setBusy(true);
try {
setStep(await pending);
} finally {
setBusy(false);
}
};
return {
step,
busy,
start: (input: TInput) => void run(flow.start(input)),
answer: (response: string) => {
if (step?.status === 'challenge') void run(flow.answer(step.challenge, response));
},
cancel: () => {
if (busy) return; // a request in flight cannot be called back
flow.reset();
setStep(null);
},
};
}
```
```tsx
// src/flows/challenges/ChallengeSheet.tsx
const CHALLENGE_VIEWS = {
TPIN: PinChallenge,
MPIN: PinChallenge,
OTP: OtpChallenge,
SET_PIN: SetPinChallenge,
BIOMETRIC: BiometricChallenge,
} satisfies Record<Challenge['type'], ComponentType<ChallengeViewProps>>;
export const ChallengeSheet = ({ auth }: { auth: ReturnType<typeof useAuthorisation> }) => {
if (auth.step?.status !== 'challenge') return null;
const { challenge, error } = auth.step;
const View = CHALLENGE_VIEWS[challenge.type];
return (
<AppModal visible onClose={auth.cancel} title={challengeTitle(challenge)}>
<View challenge={challenge} error={error} busy={auth.busy} onSubmit={auth.answer} />
</AppModal>
);
};
```
| Rule | Why |
|---|---|
| An **unknown** challenge type (from a newer server) ends the flow: "Update the app to continue" | Fail closed — never skip a step the app cannot show |
| `PinChallenge` and `OtpChallenge` wrap the existing [CodeInput](../src/modules/auth/components/CodeInput.tsx) | The same look in every flow, built once |
| Pre-login flows (registration, MPIN reset) use a full-screen `ChallengeScreen` instead of the sheet; same flow object | Presentation is separate from the steps |
### 5.5 Adapters — refactor the app now, without waiting for the backend
The protocol in §5.2 is the target. **Today's API is different**: `initiate` takes the TPIN, returns `PENDING_OTP`, then `verify-otp` follows. An adapter makes today's API look like a challenge chain, so the app can move to the new structure immediately:
```ts
// src/modules/payments/flows/impsTpinOtpTransfer.ts
export const impsTpinOtpTransfer = (
api: FundTransferApi,
settings: BankSettings,
): AuthorisationFlow<TransferInput, FundTransferVerifyResult> => {
let input: TransferInput | null = null;
let transfer: FundTransferInitiateResult | null = null;
let requestId: string | null = null;
return {
async start(next) {
input = next;
// Today's initiate needs the TPIN up front, so it is the first challenge.
return { status: 'challenge', challenge: { type: 'TPIN', length: settings.payments.tpinLength } };
},
async answer(challenge, response) {
if (challenge.type === 'TPIN') {
requestId = requestId ?? uuidv4(); // kept across network retries — see below
try {
transfer = await api.initiate({ ...input!, requestId, tpin: response });
} catch (error) {
return mapInitiateError(error as ApiError, challenge, () => (requestId = null));
}
return transfer.status === 'PENDING_OTP'
? {
status: 'challenge',
challenge: {
type: 'OTP',
length: 6,
expiresAt: transfer.otpExpiresAt,
attemptsRemaining: transfer.attemptsRemaining,
},
}
: { status: 'ended', message: 'IMPS could not verify this payee. Check the details and try again.' };
}
try {
return { status: 'done', result: await api.verifyOtp(transfer!.transferId, response) };
} catch (error) {
return mapVerifyError(error as ApiError, challenge);
}
},
reset() {
input = null;
transfer = null;
requestId = null;
},
};
};
```
All of today's rules move into the adapter **unchanged**:
- **Idempotency key:** kept across `NETWORK_ERROR` and `REQUEST_TIMEOUT`, dropped on any definite answer.
- **Error mapping:**
- `INVALID_TPIN` → same challenge with an error;
- `TPIN_NOT_SET` → `action: CREATE_TPIN`;
- `OTP_INVALID` → same challenge with `attemptsRemaining - 1`;
- `OTP_EXPIRED` and `OTP_ATTEMPTS_EXCEEDED` → `ended`.
- **Never retry `verify-otp` after a timeout:** report it as pending.
Each bank picks its adapter in its pack. When the backend ships the generic protocol, banks move to `server-driven` one at a time:
```ts
// src/modules/payments/flows/index.ts
export const TRANSFER_AUTHORISATION = {
'imps-tpin-otp': impsTpinOtpTransfer, // today's API
'server-driven': serverDrivenTransfer, // §5.2 protocol
} as const;
// tenants/bank-a/index.ts
flows: { transferAuthorisation: 'imps-tpin-otp' },
```
### 5.6 What the screen becomes
`PaymentConfirmationScreen` keeps the review, the receipt mapping (`finishImps`) and "save payee if asked". The TPIN modal, the OTP modal, the step state, the countdown and the error switch all leave the screen:
```tsx
const auth = useAuthorisation(useTransferAuthorisation()); // adapter picked by the bank's pack
useEffect(() => {
const step = auth.step;
if (step?.status === 'done') finishImps(step.result);
if (step?.status === 'action') navigation.navigate(step.action === 'CREATE_TPIN' ? 'CreateTpin' : 'ResetTpin');
if (step?.status === 'ended') setNotice(step.message);
}, [auth.step]);
return (
<ScreenShell …>
{/* review card — unchanged */}
<Button title="Confirm" onPress={guardFeature(FeatureKey.PAYMENTS_TRANSFER_SUBMIT, () => auth.start(transferInput))} />
<ChallengeSheet auth={auth} />
</ScreenShell>
);
```
### 5.7 Flows that move onto the same framework
| Flow | Challenges today | Becomes |
|---|---|---|
| Transfer authorisation | TPIN → OTP | `transferAuthorisation` |
| TPIN create | MPIN → OTP → new TPIN | `tpinCreate` (`MPIN`, `OTP`, `SET_PIN`) |
| TPIN reset | (session) → OTP → new TPIN | `tpinReset` |
| MPIN reset | mobile → OTP → new MPIN | `mpinReset` (pre-login, full screen) |
| Registration | mobile → OTP → MPIN | `registration` — Bank B's card + PIN step is just another challenge type |
| Add payee | validate → save | `payeeAdd` — cooling period / OTP per bank policy |
Five hand-written step machines become one runner, one sheet and a set of small adapters.
---
## 6. Rung 3 — Journey variants (non-security flows)
### 6.1 When
Use this when a bank wants **different steps in a journey that collects information**, and the server does not need to enforce the order. Examples: loan application, agent account opening, cheque book request, onboarding slides, KYC update.
### 6.2 Definition
```ts
// src/flows/journeys/types.ts
export interface JourneyStep<TDraft> {
id: string;
title: string;
component: ComponentType<JourneyStepProps<TDraft>>;
/** Skip for this draft — e.g. no employment step for a pensioner. */
when?: (draft: TDraft) => boolean;
/** Dropped when this feature is off — flags still narrow journeys per customer. */
feature?: FeatureKey;
}
export interface JourneyDefinition<TDraft> {
steps: JourneyStep<TDraft>[];
submit: (draft: TDraft) => Promise<void>;
}
```
### 6.3 Variants live in core, banks choose
```ts
// src/modules/loan/journeys.ts
export const LOAN_APPLICATION_JOURNEYS = {
standard: {
steps: [productStep, amountStep, employmentStep, documentsStep, reviewStep],
submit: submitLoanApplication,
},
'no-employment': {
steps: [productStep, amountStep, documentsStep, reviewStep],
submit: submitLoanApplication,
},
} satisfies Record<string, JourneyDefinition<LoanDraft>>;
```
The bank's choice resolves through the same layers as flags: **server `variants` → pack `variants` → `'standard'`**.
```ts
// tenants/bank-b/index.ts
variants: { 'loans.apply': 'no-employment' },
```
| Rule | Why |
|---|---|
| The server can only **pick** a variant that is compiled into the app; an unknown name falls back to the pack's choice | The server cannot invent a flow the app has never been tested with |
| A new variant is a code change in core (reviewed, tested), then shipped by OTA | Variants stay shared — Bank D can reuse Bank B's |
| Steps are built from shared step components | A bug fixed in `documentsStep` is fixed for every bank |
### 6.4 Runner
`<JourneyRunner journey={…} />` holds the draft, renders the current step, and handles Back / Next and the stepper. [TransferStepper](../src/modules/transfer/components/TransferStepper.tsx) generalises into this: today it has five fixed labels; it would take them from `journey.steps`.
---
## 7. Rung 4 — Strategies (same screens, different logic)
### 7.1 When
Use this when the screens are the same but a **rule or an integration** differs: account-number format, own-bank detection, receipt layout, how a bank's backend shapes a response.
### 7.2 Contract
```ts
// src/tenant/strategies.ts
export interface BankStrategies {
accountNumber: {
validate(value: string): string | undefined; // error message, or undefined when valid
mask(value: string): string;
};
/** Is this IFSC one of our own branches? Today: "Within BankCBS" is hard-coded. */
isOwnBank(ifsc: string): boolean;
receipt: {
lines(result: TransferReceipt): ReceiptLine[];
};
}
export const DEFAULT_STRATEGIES: BankStrategies = { /* today's behaviour */ };
// merged once, at startup
export const strategies: BankStrategies = { ...DEFAULT_STRATEGIES, ...tenant.strategies };
```
```ts
// tenants/bank-b/strategies.ts
export const strategies: Partial<BankStrategies> = {
accountNumber: {
validate: (v) => (/^\d{14}$/.test(v) ? undefined : 'Bank B account numbers have 14 digits.'),
mask: (v) => `••••${v.slice(-4)}`,
},
isOwnBank: (ifsc) => ifsc.startsWith('BNKB'),
};
```
| Rule | Why |
|---|---|
| Small interfaces, one per concern | Easy to override one thing without copying the rest |
| Strategies never render UI | UI differences are rung 5 |
| Each override has unit tests in the pack | Bank-specific logic is tested where it lives |
| A strategy is **not** a security control | The server still validates; a strategy only improves the message |
---
## 8. Rung 5 — Slot overrides (last resort)
### 8.1 When
Use this when a bank wants a **section or a screen to look and behave differently**, and nothing below fits.
### 8.2 Slots
```ts
// src/tenant/slots.tsx
export interface SlotProps {
'home.hero': SummaryCardProps;
'login.footer': LoginFooterProps;
'transfer.reviewSummary': ReviewSummaryProps;
}
export function Slot<K extends keyof SlotProps>({
name,
fallback: Default,
...props
}: { name: K; fallback: ComponentType<SlotProps[K]> } & SlotProps[K]) {
const Override = tenant.overrides?.[name] as ComponentType<SlotProps[K]> | undefined;
const Component = Override ?? Default;
return <Component {...(props as SlotProps[K])} />;
}
```
```tsx
// shared code
<Slot name="home.hero" fallback={SummaryCard} {...summaryProps} />
```
```ts
// tenants/bank-c/index.ts
overrides: { 'home.hero': BankCHeroCard }, // tenants/bank-c/overrides/BankCHeroCard.tsx
```
Whole screens work the same way: navigators register `screenFor('PaymentConfirmation', PaymentConfirmationScreen)`.
| Rule | Why |
|---|---|
| Prefer a **section** slot over a whole screen | Smaller override, less to keep in sync |
| Same typed props as the default | Shared code does not know or care which one renders |
| Overrides still use `FeatureGate`, `useFeatureAction` and `guardFeature` | Flags and kill switches keep working |
| Each override has an owner and is reviewed every release | Overrides drift; someone must notice |
| A bank with more than 3 overrides is a warning sign | The difference probably belongs in a setting, variant or strategy |
The pack is bundled only into its own bank's app (Metro `@tenant`), so Bank C's override code never ships to Bank A.
---
## 9. Not allowed — branching on the bank
Not allowed in `src/`:
- `if (tenant.id === 'bank-b')`
- `switch (tenant.orgId)`
- per-bank Git branches
- copies of screens
ESLint enforces the first two:
```js
// .eslintrc.js
module.exports = {
root: true,
extends: '@react-native',
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"BinaryExpression[operator=/^[!=]==?$/] > MemberExpression[object.name='tenant'][property.name=/^(id|orgId)$/]",
message: 'Do not branch on the bank. Use a setting, challenge policy, journey variant, strategy or slot — see docs/BANK_FLOW_VARIANTS.md.',
},
{
selector:
"SwitchStatement[discriminant.object.name='tenant'][discriminant.property.name=/^(id|orgId)$/]",
message: 'Do not branch on the bank. See docs/BANK_FLOW_VARIANTS.md.',
},
],
},
};
```
`src/tenant/` itself may read `tenant.id`, for the tenant guard and channel names. Allow that with an `overrides` entry for that folder.
---
## 10. One config response
Settings and variants ride on the same `/features/config` response as flags. They get the same caching, the same offline behaviour, the same org scoping and the same "drop what you don't recognise" rule:
```json
{
"version": 44,
"overrides": { "loans": true, "cards": false },
"roleOverrides": { "loans.apply": false },
"settings": { "auth.lockAfterBackgroundMs": 20000, "payments.rtgsMinimum": 200000 },
"variants": { "loans.apply": "no-employment" }
}
```
Challenge policy is **not** in this response. It lives only on the server and shows up step by step inside each flow (§5). The app never needs to know a bank's security policy in advance.
**Who answers which question:**
| Question | Answered by | Rung |
|---|---|---|
| Does this bank / customer have it? | Feature flags | — |
| How much, how long, how many? | Settings | 1 |
| Which security steps, in which order? | Server challenge policy | 2 |
| Which screens in this journey? | Journey variant | 3 |
| Which rule or integration? | Strategy | 4 |
| Which look? | Slot override | 5 |
---
## 11. Worked example: one transfer, three banks
**The app code is identical for all three banks.** Only data differs:
| | Bank A | Bank B | Bank C |
|---|---|---|---|
| Pack `flows.transferAuthorisation` | `server-driven` | `server-driven` | `server-driven` |
| Server policy | `[TPIN, OTP]` | ≤ ₹5,000: `[OTP]`; above: `[TPIN, OTP]` | `[BIOMETRIC, TPIN]` |
| Pack `settings.payments.tpinLength` | 4 | 6 | 4 |
```mermaid
sequenceDiagram
autonumber
actor C as Customer
participant S as PaymentConfirmationScreen
participant R as useAuthorisation + ChallengeSheet
participant API as Backend (bank policy)
C->>S: Confirm ₹3,000
S->>R: start(transferInput)
R->>API: POST /fund-transfers/initiate
alt Bank B and amount ≤ 5,000
API-->>R: CHALLENGE OTP (length 6)
else Bank A, or Bank B above 5,000
API-->>R: CHALLENGE TPIN (length 4 or 6)
R->>C: PIN sheet
C->>R: TPIN
R->>API: answer TPIN (encrypted)
API-->>R: CHALLENGE OTP
else Bank C
API-->>R: CHALLENGE BIOMETRIC (nonce)
R->>C: biometric prompt → signs nonce
R->>API: answer BIOMETRIC (signature)
API-->>R: CHALLENGE TPIN
end
R->>C: next sheet (OTP or TPIN)
C->>R: code
R->>API: answer
API-->>R: COMPLETED
R-->>S: done(result) → TransactionSuccess
```
Bank B can change its threshold from ₹5,000 to ₹10,000 in its policy with no app release.
---
## 12. Folder layout
```
src/
├── flows/ NEW — shared flow machinery
│ ├── challenges/
│ │ ├── types.ts Challenge, FlowStep, AuthorisationFlow
│ │ ├── useAuthorisation.ts runner
│ │ ├── ChallengeSheet.tsx in-app presentation
│ │ ├── ChallengeScreen.tsx pre-login presentation
│ │ └── views/ PinChallenge, OtpChallenge, SetPinChallenge, BiometricChallenge
│ └── journeys/
│ ├── types.ts JourneyStep, JourneyDefinition
│ └── JourneyRunner.tsx generalised TransferStepper
├── modules/payments/flows/ impsTpinOtpTransfer, serverDrivenTransfer, payeeAdd, tpinCreate, tpinReset
├── modules/auth/flows/ registration, mpinReset
├── modules/loan/journeys.ts loan application variants
└── tenant/
├── settings.ts BankSettings, DEFAULT_SETTINGS, useBankSettings
├── strategies.ts BankStrategies, DEFAULT_STRATEGIES
└── slots.tsx Slot, screenFor
tenants/bank-b/
├── index.ts + flows, variants, settings, strategies, overrides
├── settings.ts
├── strategies.ts
└── overrides/ only if rung 5 is really needed
```
---
## 13. Testing
| What | How |
|---|---|
| Each `AuthorisationFlow` adapter | Table-driven: given these server answers → expect these `FlowStep`s. Port the current behaviour first (idempotency, every error code) so the refactor is proven safe |
| `ChallengeSheet` | Renders every challenge type; an unknown type ends the flow safely |
| Settings | Every pack's settings pass the schema; the stricter-wins merge for security timeouts |
| Journey registry | Every variant's steps have components; every bank's chosen variant exists |
| Strategies | Unit tests inside each pack |
| No branching on the bank | ESLint rule in CI |
| Per bank | UAT build per bank, smoke-test the flows that bank customises |
---
## 14. Migration plan
**Build rungs 1–3 now; add rungs 4–5 only when the first real case shows up.**
| Phase | Work | Size | Behaviour change |
|---|---|---|---|
| F1. Settings | `BankSettings`, `useBankSettings`; replace `TPIN_LENGTH`, `MPIN_LENGTH`, `RTGS_MINIMUM`, the `appConfig.security` values | 1 day | None |
| F2. Challenge framework | Types, `useAuthorisation`, `ChallengeSheet`, `PinChallenge` and `OtpChallenge` (from `CodeInput`) | 2 days | None |
| F3. Transfer on the framework | `impsTpinOtpTransfer` adapter built from `PaymentConfirmationScreen`'s logic; screen keeps review + receipt; adapter tests | 1–2 days | None — same API, same steps |
| F4. Other security flows | TPIN create/reset, MPIN reset, registration onto the framework; the duplicate `OTP_LENGTH` constants go away | 3–4 days | None |
| F5. Backend protocol | Generic challenge endpoints + per-bank policy; banks switch to `server-driven` one by one | Backend team | Per-bank flows, live |
| F6. Journeys, strategies, slots | Only when a bank needs one | As needed | Per bank |
F1–F4 change no behaviour for the current bank. They are pure refactors, provable by the adapter tests in F3.
---
## 15. Checklist: a bank asks for a different flow
Ask these in order and stop at the first "yes":
1. **Is it only a value?** → Add or change a setting (§4).
2. **Is it a security step** — PIN, OTP, biometric, approval, cooling period? → Change that bank's server policy (§5). Add a challenge type to the app only if the step is new to every bank.
3. **Is it different steps in a journey?** → Pick an existing variant, or add a new one to core (§6).
4. **Same screens, different rule or backend?** → Strategy in the bank's pack (§7).
5. **A section looks different?** → Slot override in the bank's pack (§8).
Write the decision and the reason in the bank's pack `README.md`, so the next developer knows why that bank differs.
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
# OpenProject sprint: Positive Pay development
Create the SPRINT work package first, then each TASK below as its child. In the work package table, right-click the sprint row and choose **Create new child**.
For every work package, set:
- **Assignee:** Abin Toppo
- **Client bank name:** Apna Sahakari Bank Ltd.
- **Status:** Developed
Copy the **Subject** into the subject field and the text under **Description** into the description field.
---
# SPRINT: Positive Pay development
- **Subject:** Sprint: Positive Pay development
- **Parent:** FEATURE 6: Cheque services (add a "Relates to" relation to Task 6.3 Positive Pay)
- **Start date:** 09-10-2026 · **Finish date:** 13-10-2026
- **Status:** Developed · **Priority:** High
**Description**
Positive Pay built in the mobile app on the backend's new Positive Pay API (/pps). The customer turns Positive Pay on for an account with an OTP, registers each cheque with the bank before handing it over, sees their saved cheques, and can stop a submitted cheque.
The app follows the backend team's Positive Pay integration guide (12-10-2026, re-checked against the 13-10-2026 version). Accounts are identified by account number; the app never sends an account ID. The live API at mobile-banking-backend.lincpay was checked against the guide on 13-10-2026 and matches it.
Endpoints: /pps/enrollment/accounts, /pps/enrollment/initiate, /pps/enrollment/verify-otp, /pps/enrollment/resend-otp, /pps/cheques (GET, POST), /pps/cheques/{chequeId}, /pps/cheques/{chequeId}/stop-payment
Tasks in this sprint:
1. Positive Pay API connection
2. Turn on Positive Pay with OTP
3. Register a cheque
4. Saved cheques list
5. Cheque detail and stop payment
6. Error handling
7. Stop payment in the Services hub
---
## TASK 1: Positive Pay API connection
- **Subject:** Positive Pay API connection
**Description**
Connected the app to all 8 Positive Pay endpoints.
- Every request carries the existing session token. There is no separate Positive Pay login.
- Accounts are sent by account number, as text, so leading zeros are kept. Moved from account ID to account number on 12-10-2026, when the backend changed its API.
- Every reply is checked before it is used. A reply the app cannot read is treated as an unknown outcome, never as success.
- Submitting a cheque and stopping one wait up to 60 seconds, because both go through to the bank.
- The cheque list and detail always read fresh from the backend when a screen opens.
---
## TASK 2: Turn on Positive Pay with OTP
- **Subject:** Turn on Positive Pay with OTP
**Description**
The customer picks an account and the app checks whether Positive Pay is already on. If it is off, the customer turns it on with an OTP.
- The OTP goes to the registered mobile number from the customer's profile, shown masked on screen.
- The expiry and resend countdowns use the seconds the backend returns, not fixed values.
- Resend is disabled until its countdown ends. The app stops offering resend when the backend's limit is reached.
- A wrong code can be corrected. An expired code, or too many wrong ones, ends the OTP session, and the customer can start again.
- If the answer to verify is lost, the app first checks whether Positive Pay turned on, before asking the customer to do anything again.
- The OTP and its request ID stay on the screen only. They are not stored and not written to the logs.
- Switching accounts during the OTP drops that OTP session.
Endpoints: /pps/enrollment/accounts, /pps/enrollment/initiate, /pps/enrollment/verify-otp, /pps/enrollment/resend-otp
---
## TASK 3: Register a cheque
- **Subject:** Register a cheque under Positive Pay
**Description**
Form for one cheque on an account with Positive Pay on: cheque number, cheque date, amount and payee name. The customer checks the details on a confirmation screen before they are sent.
- Cheque number: exactly 6 digits, kept as text so 000123 stays 000123.
- Cheque date: from a date picker. Post-dated cheques are allowed, up to one year ahead.
- Amount: at least 0.01, up to 2 decimal places. It is sent exactly as entered, with no rounding.
- Payee name: up to 200 characters. Spaces at the start and end are removed.
- Each submission gets a request ID. The ID is kept if the customer tries again with the same details, even after leaving the screen, so a cheque cannot be registered twice.
- Submitted is shown only when the bank confirms. If the outcome is unknown, the app says so, looks for the cheque in the saved list, and links to it.
- A double tap sends only one request.
Endpoint: /pps/cheques (POST)
---
## TASK 4: Saved cheques list
- **Subject:** Positive Pay saved cheques list
**Description**
List of every cheque the customer has registered, across all their accounts, newest first.
- 20 cheques per page. The next page loads on scroll, and pull to refresh reloads the list.
- Each row shows the cheque number, the masked account, the payee, the amount and the status: Submitted, Canceled, Failed or Processing.
- A status the app does not know is still shown, in neutral styling.
- The empty state links to registering a cheque.
Endpoint: /pps/cheques (GET)
---
## TASK 5: Cheque detail and stop payment
- **Subject:** Positive Pay cheque detail and stop payment
**Description**
The detail screen shows one saved cheque: amount, status, cheque number and date, payee, masked account, bank reference, and the registered, submitted and canceled times.
- Stop payment is offered only on a Submitted cheque, and the customer must confirm it first.
- The stop has its own request ID, separate from the submission's.
- The cheque changes to Canceled only after the bank confirms the stop. A failed or unanswered stop leaves it Submitted.
- After a failed stop, the customer can try again only after reading why it failed.
- Failed and Processing cheques show what the status means and that the cheque must not be registered again.
Endpoints: /pps/cheques/{chequeId}, /pps/cheques/{chequeId}/stop-payment
---
## TASK 6: Error handling
- **Subject:** Positive Pay error handling
**Description**
Every error code in the backend's Positive Pay guide has its own message and its own action in the app. The app decides from the error code, never from the message text.
- An unknown code, a timeout or a lost connection on a submit or a stop is treated as "outcome unknown", never as a failure the customer could fix by entering the cheque again.
- The app never resends a submission or a stop on its own.
- If the session has ended, the customer is signed out.
- Unit tests cover the error rules, the amount format and the cheque rules (51 tests passing).
---
## TASK 7: Stop payment in the Services hub
- **Subject:** Stop payment through Positive Pay in the Services hub
**Description**
Stop payment in the Services hub now lists the customer's Positive Pay cheques and stops them through the Positive Pay API. The customer picks a cheque instead of typing an account and cheque number. This replaces /check/verify and /check/stop-payment (Task 6.2).
+731
View File
@@ -0,0 +1,731 @@
# OpenProject work packages: CBS Mobile App (Apna Sahakari Bank)
Create these in OpenProject in this order: the EPIC first, then each FEATURE as a child of the EPIC, then each TASK or BUG as a child of its FEATURE. In the work package table, right-click a row and choose **Create new child**.
For every work package, set:
- **Assignee:** Abin Toppo (unless noted)
- **Client bank name:** Apna Sahakari Bank Ltd.
Copy the **Subject** into the subject field and the text under **Description** into the description field.
**Status meanings**
| Status | Meaning |
|---|---|
| Developed | Works on the live CBS API, or needs no backend. Move to Closed after QA. |
| In progress | The endpoint exists but the app is not fully connected to it yet. |
| On hold | Screen is built, but the backend endpoint does not exist yet. |
| New | Screen exists, but its action is a placeholder. Not built yet. |
---
# EPIC: Mobile App – Apna Sahakari Bank (Customer App)
- **Parent:** #537819 Core Banking System (CBS) Regulatory & Operational Enhancements
- **Start date:** 26-05-2026
- **Status:** In progress
- **Attachment:** docs/Application Features_API_Status.pdf
**Description**
React Native mobile banking app for Apna Sahakari Bank Ltd. (org 107295), running on the CBS mobile banking backend (http://mobile-banking-backend.lincpay/api/v1).
The app has 63 features. Up to 13-10-2026, 41 were switched on for Apna Bank customers. On 14-10-2026 every feature was switched on, including the field-agent app, Cards and Loans. The earlier set of switches is saved in src/features/featureConfig.stage-2026-10-14.ts, so the app can go back to it. The features that call the live CBS API now include Positive Pay and the mini statement. The rest use demo data, run only on the phone, or wait for backend endpoints.
Repository: git.lincpay.tech/CBSNexora/CBS-Mobile-App, branch Apnabank.
Full feature list with API status: see the attached "Application Features_API_Status.pdf".
---
## FEATURE 1: App foundation
- **Status:** Developed
**Description**
The base of the app that every screen uses: project setup, feature switches, API client, security and branding. All items here are complete.
### TASK 1.1: Project setup and app structure
- **Status:** Developed · **Finish date:** 26-05-2026
**Description**
React Native 0.83 app with TypeScript (strict mode), Redux Toolkit / RTK Query for data, and React Navigation 7 for screens. The code is split into 17 modules (auth, home, payments, services, profile, and so on) with about 100 screens. One app holds both the customer app and the agent app; the user's role at sign-in decides which one opens.
### TASK 1.2: Feature switch engine
- **Status:** Developed
**Description**
Every module, screen, tab and button can be switched on or off, per bank. The switches are nested: turning off a parent turns off everything under it. An unknown or misconfigured switch counts as off, so a mistake hides a feature rather than showing it. A switched-off screen is not registered at all, so it cannot be opened by a link either. The app is ready to read the bank's live switches from /features/config (see Task 10.5).
### TASK 1.3: API client and CBS error handling
- **Status:** Developed
**Description**
A shared API client for all CBS calls. It adds the session token to each request, reads the CBS response format (success, data, errorCode), and turns every failure into a clear message for the customer. All 31 backend error codes plus network and timeout errors have their own title and text, covered by unit tests. If the server says the session is no longer valid, the customer is signed out.
### TASK 1.4: Device binding, PIN encryption and session timeout
- **Status:** Developed · **Finish date:** 25-09-2026
**Description**
- The phone gets a device ID on first launch, stored in the secure Keychain. The customer's profile is bound to this device.
- MPIN and TPIN are never sent in plain text. They are encrypted with the server's RSA public key (RSA-OAEP, SHA-256) from /crypto/public-key. The key is fetched fresh before every encryption.
- Session timeout follows the server's rules: sign-out after 5 minutes idle (with a warning 1 minute before), at the absolute session expiry, and a re-lock when the app comes back after 30 seconds in the background.
- Logging out clears all customer data from the app.
### TASK 1.5: Apna Bank branding and configuration
- **Status:** Developed · **Finish date:** 29-09-2026
**Description**
The app is set to org 107295 (Apna Sahakari Bank Ltd.). Added the bank logo on the sign-in screen and updated the colour palette. The agent app is switched off for this bank.
---
## FEATURE 2: Sign-in & registration
- **Status:** Developed
**Description**
How a customer registers the phone, signs in with MPIN or biometrics, resets a forgotten MPIN, and logs out. All 5 items are live on the CBS API.
### TASK 2.1: Device registration
- **Status:** Developed
**Description**
A new customer registers this phone for mobile banking. The app looks up the customer with the CBS, verifies the OTP sent to the registered mobile number, and asks the customer to create a 4-digit MPIN. The MPIN cannot be a run of digits (like 1234 or 4321) or the last 4 digits of the mobile number. The customer cannot go back from the Create MPIN screen, because the setup token works only once.
Endpoints: /registration, /registration/verify-otp, /registration/mpin
### TASK 2.2: MPIN sign-in
- **Status:** Developed · **Finish date:** 25-09-2026
**Description**
The customer signs in with their MPIN, which is sent encrypted. The app asks for the MPIN on every launch and checks the session with the server. Too many wrong attempts lock the MPIN, and the customer is told to visit the branch. A SIM icon is shown beside the mobile number on the sign-in screen.
Endpoints: /auth/login, /auth/me, /crypto/public-key
### TASK 2.3: Biometric sign-in
- **Status:** Developed
**Description**
The customer can turn on fingerprint or face sign-in from the sign-in screen or from Profile. The MPIN is then kept in the phone's secure Keychain, behind the phone's own biometric prompt. A successful fingerprint or face check unlocks it and signs in through /auth/login.
### TASK 2.4: Forgot MPIN
- **Status:** Developed · **Finish date:** 29-09-2026
**Description**
Reset from the sign-in screen when the customer has forgotten the MPIN. Step 1: the CBS checks the customer and sends an OTP. Step 2: the OTP is exchanged for a one-time reset token. Step 3: the customer sets a new MPIN, which is sent encrypted. This signs out every other open session. There is no separate resend-OTP endpoint; resending starts step 1 again.
Endpoints: /mpin-reset/initiate, /mpin-reset/verify-otp, /mpin-reset/confirm
### TASK 2.5: Log out
- **Status:** Developed
**Description**
Log out button in Profile. Ends the session on the server and clears all customer data from the app.
Endpoint: /auth/logout
---
## FEATURE 3: Accounts
- **Status:** In progress
**Description**
The customer's accounts, balances, statements, transfer history and KYC. 4 items are live, MMID is partly live, and 3 screens wait for backend endpoints.
### TASK 3.1: Accounts on Home
- **Status:** Developed
**Description**
The Home screen shows every account on the customer's profile as a carousel, with a button to show or hide balances.
Endpoint: /accounts
### TASK 3.2: Account details and live balance
- **Status:** Developed · **Finish date:** 21-09-2026
**Description**
Account details screen with the live balance, read from the bank on every visit and never cached. Works for savings and current accounts. Fixed and recurring deposits are hidden here because they have their own screen (Task 5.1).
Endpoints: /accounts, /accounts/balance
### TASK 3.3: Account statement and mini statement
- **Status:** In progress
**Description**
Built on the backend's Statement & Mini Statement guide v1.0 (14-10-2026).
- Mini statement: the latest 10 transactions of the selected account, each marked debit or credit. The Home screen's recent transactions read from it too.
- Statement PDF: the customer picks a From and To date and downloads the statement as a PDF. The PDF is password-protected: the first 4 letters of the name in capitals, then the date of birth as DDMM. The screen tells the customer this.
Opens from Home, Services and Profile.
Endpoints: /accounts/mini-statement, /accounts/statement/export
**Comment (add under Activity)**
In progress, 14-10-2026.
Done:
- App moved to the 14-10-2026 guide. The old page-by-page /accounts/statement list was removed by the backend, so the app no longer uses it.
- Mini statement checked on the phone against mobile-banking-backend.lincpay: account …0019 shows 10 transactions, account …0021 shows 7.
Remaining:
- The PDF download returns STATEMENT_PROFILE_INCOMPLETE (422) for the test user 6267095464, because the backend has no verified date of birth for them. The backend team must add it (guide section 13). Then test the download and the PDF password.
- Ask the backend team whether the password uses the title. The name on record is "Mr. Sanjay Mehta", so it starts with either MRSA or SANJ.
### TASK 3.4: Fund transfer history
- **Status:** Developed · **Finish date:** 29-09-2026
**Description**
A list of the customer's IMPS transfers, opened from the clock button on Home. Filters by status, transfer mode and date range; loads 10 at a time. Tapping a transfer opens its detail. A transfer with status "pending" can be refreshed to get its final result.
Endpoints: /fund-transfer-history, /fund-transfer-history/{id}
### TASK 3.5: MMID
- **Status:** In progress
**Description**
Viewing the customer's MMIDs works on the live API (from Home and Payments). Requesting a new MMID and cancelling one only show a message on screen, because the backend has no endpoints for them yet.
Endpoint (live): /accounts/mmid
Needed from backend: generate MMID, cancel MMID
### TASK 3.6: e-Passbook
- **Status:** On hold
**Description**
The passbook screen is built but shows demo rows, not the customer's real transactions. Next step: decide whether to feed it from /accounts/statement or from a dedicated passbook endpoint.
### TASK 3.7: Re-KYC
- **Status:** On hold
**Description**
The Update KYC form is built. Submitting it is simulated, because there is no KYC update endpoint yet.
Needed from backend: KYC update endpoint
### TASK 3.8: Video KYC
- **Status:** On hold
**Description**
The screen for booking a video KYC slot is built. Booking is simulated, because there is no backend or video-KYC service connected yet.
Needed from backend: video KYC slot booking
---
## FEATURE 4: Payments (IMPS)
- **Status:** Developed
**Description**
Beneficiaries, IMPS transfers to an account or a mobile number, and the Transaction PIN (TPIN) that authorises them. 4 items are live. Transfer between own accounts waits for a backend endpoint.
### TASK 4.1: Beneficiaries
- **Status:** Developed
**Description**
List of saved beneficiaries, and adding a new one. Before saving, the app runs an IMPS name check so the customer can see the account holder's name. Also opens from Profile. The backend has no endpoint to delete a beneficiary.
Endpoints: /beneficiaries, /beneficiaries/validate
### TASK 4.2: IMPS transfer to account (P2A)
- **Status:** Developed · **Finish date:** 25-09-2026
**Description**
Transfer to any bank account using account number and IFSC. Step 1 (initiate) checks the payee's name and sends an OTP; no money moves yet. Step 2 (verify OTP) sends the payment. The customer authorises the transfer with their TPIN. The result screen shows success, failure, or "pending" when the bank has not confirmed the result yet.
Endpoints: /fund-transfers/initiate, /fund-transfers/{id}/verify-otp
### TASK 4.3: IMPS transfer to mobile (P2P)
- **Status:** Developed · **Finish date:** 25-09-2026
**Description**
Transfer using the payee's mobile number and MMID. Same two steps and OTP as Task 4.2. The form currently does not block on the mobile number or MMID format.
Endpoints: /fund-transfers/initiate, /fund-transfers/{id}/verify-otp
### TASK 4.4: TPIN creation and TPIN-authorised transfers
- **Status:** Developed · **Finish date:** 29-09-2026
**Description**
The customer creates a 4-digit Transaction PIN (TPIN) that authorises every fund transfer. The TPIN cannot be a run of digits or the last 4 digits of the mobile number. It is created from Services, or automatically from the transfer screen when the server says no TPIN is set.
Flow: enter current MPIN, then enter the OTP, then set the new TPIN (sent encrypted). Transfers now send the encrypted TPIN, and a wrong TPIN shows a clear error. Changing an existing TPIN is not built yet.
Endpoints: /tpin/create/initiate, /tpin/create/verify-otp, /tpin/create/confirm
Note: the attached PDF (dated 28-09-2026) still lists TPIN as "Not connected". It went live on 29-09-2026.
### TASK 4.5: Transfer between own accounts
- **Status:** On hold
**Description**
The screen for moving money between the customer's own accounts is built. The transfer is simulated, because the CBS has no endpoint for it yet.
Needed from backend: own-account / internal transfer endpoint
---
## FEATURE 5: Deposits
- **Status:** Developed
**Description**
Viewing the customer's fixed and recurring deposits, and a maturity calculator. Opening a new FD or RD is not offered yet.
### TASK 5.1: View fixed and recurring deposits
- **Status:** Developed
**Description**
Overview of the customer's FDs and RDs, and a details screen for each deposit with its terms.
Endpoints: /accounts, /accounts/deposits/detail
### TASK 5.2: Deposit calculator
- **Status:** Developed
**Description**
FD and RD maturity calculator. Works on the phone with no backend call. The calculation is covered by unit tests.
---
## FEATURE 6: Cheque services
- **Status:** In progress
**Description**
Cheque book request, stop payment, Positive Pay, cheque book tracking and fate of cheque. 3 items are live, tracking still needs to be connected, and fate of cheque waits for a backend endpoint.
### TASK 6.1: Cheque book request
- **Status:** Developed
**Description**
The customer sees the charges first, then requests a cheque book. The serial number range comes back straight away.
Endpoints: /cheque-book/charges, /cheque-book (POST)
### TASK 6.2: Stop cheque payment
- **Status:** Developed
**Description**
The customer enters the cheque, the app shows its details from the bank to confirm, and then stops it. Works only for cheques registered under Positive Pay.
Endpoints: /check/verify, /check/stop-payment
### TASK 6.3: Positive Pay
- **Status:** Developed · **Finish date:** 14-10-2026
**Description**
The customer registers a cheque with the bank before handing it over, so the bank can match it when it is presented. The app was moved to the new Positive Pay API on 12-10-2026. This API identifies accounts by account number.
- Turn on Positive Pay: the app checks whether the account already has it. If not, the customer turns it on with an OTP sent to the registered mobile number. The OTP can be resent.
- Register a cheque: account, 6-digit cheque number, cheque date, amount and payee name. The backend sends it to the bank. A submission that fails is saved too, with status Failed.
- Saved cheques: every cheque the customer has registered, across all their accounts, newest first.
- Cheque detail, with a Stop payment button while the cheque's status is Submitted.
- Stop payment in Services now lists Positive Pay cheques and stops them through this API. It replaces /check/verify and /check/stop-payment (Task 6.2).
Endpoints: /pps/enrollment/accounts, /pps/enrollment/initiate, /pps/enrollment/verify-otp, /pps/enrollment/resend-otp, /pps/cheques (GET, POST), /pps/cheques/{chequeId}, /pps/cheques/{chequeId}/stop-payment
**Comment (add under Activity)**
Developed, 14-10-2026.
Done:
- App moved to the 12-10-2026 Positive Pay API. Accounts are identified by account number; accountId is no longer used. Re-checked against the 13-10-2026 guide: no change needed.
- Screens built and connected to the API: turn on Positive Pay (OTP and resend), register a cheque, saved cheques, cheque detail and stop payment.
- Full flow passed on the phone against mobile-banking-backend.lincpay with the guide's test user (6267095464, account …0019): Positive Pay turned on with OTP, cheque 000157 submitted and then stopped (status Canceled, ref CBS-PPS-000001), cheque 000158 submitted and left as Submitted (ref CBS-PPS-000002). Screenshots of every step: docs/screenshots/positive-pay.
Notes for QA:
- Cheque numbers 000157 and 000158 are already used on …0019. Use other numbers from the simulator range 000001–000200.
- Account …0021 does not have Positive Pay turned on yet.
- The second backend (192.168.6.138:8079) has not been tested. Check which version it runs before testing against it.
- If the backend returns ACCOUNT_NOT_FOUND for an account the customer owns, the backend's stored account number hash for it is wrong. Report this to the backend team; it is not an app bug.
### TASK 6.4: Cheque book tracking
- **Status:** In progress
**Description**
The delivery status screen is built but still shows a fixed sample list. The live cheque book list endpoint already exists; the screen needs to be connected to it.
Endpoint to connect: /cheque-book (GET)
### TASK 6.5: Fate of cheque
- **Status:** On hold
**Description**
The screen to check the status of an issued cheque is built. The lookup is simulated, because the CBS has no endpoint for it yet.
Needed from backend: fate of cheque endpoint
---
## FEATURE 7: Services hub
- **Status:** In progress
**Description**
The Services tab: branch locator, security awareness, financial protection schemes, and support tiles. TPIN and cheque services also open from here; they are tracked under Feature 4 (Payments) and Feature 6 (Cheque services).
### TASK 7.1: Branch locator
- **Status:** On hold
**Description**
Map of nearby branches using the phone's location, with directions. The screen works, but it shows a sample branch list.
Needed from backend: branch list endpoint
### TASK 7.2: Security awareness
- **Status:** Developed
**Description**
Safe-banking articles the customer can read and bookmark. The articles come with the app; no backend is needed.
### TASK 7.3: Financial protection schemes
- **Status:** On hold
**Description**
Screens for government protection schemes, enrolment, DEAF claims and DICGC information. All of them show sample deposits and enrolments. This module uses its own demo service with a fixed test OTP (123456), which must be replaced when real endpoints are available.
Needed from backend: schemes, enrolment, DEAF claim endpoints
### TASK 7.4: Support chat and Help center
- **Status:** New
**Description**
The Support chat and Help center tiles on the Services hub only show a "coming soon" message. Not built yet.
---
## FEATURE 8: Search & notifications
- **Status:** Developed
**Description**
Search across the app and the in-app notification inbox. Both work on the phone; notifications will need a backend endpoint for server-sent alerts.
### TASK 8.1: Global search
- **Status:** Developed · **Finish date:** 02-06-2026
**Description**
Search from the Home search icon across app features and the customer's accounts. The feature list is on the phone; accounts come from /accounts.
### TASK 8.2: In-app and push notifications
- **Status:** Developed · **Finish date:** 02-06-2026
**Description**
Notification list with unread badge, mark as read and swipe to delete, plus phone notifications in the foreground and background. Notifications are created on the phone only. Transaction alerts from the server need a notifications endpoint.
---
## FEATURE 9: Profile & security
- **Status:** In progress
**Description**
The Profile tab and Security Center. Reset MPIN, settings and legal pages work. Profile data and linked accounts use sample data, and several security actions are placeholders.
### TASK 9.1: Reset MPIN from Security Center
- **Status:** Developed · **Finish date:** 29-09-2026
**Description**
Profile > Security Center > Reset MPIN. Same three-step OTP flow as Forgot MPIN (Task 2.4). The customer is signed out afterwards.
Endpoints: /mpin-reset/initiate, /mpin-reset/verify-otp, /mpin-reset/confirm
### TASK 9.2: App settings
- **Status:** Developed
**Description**
Language, notification preferences and privacy settings. Saved on the phone.
### TASK 9.3: Legal and About
- **Status:** Developed
**Description**
Terms and conditions, privacy policy and app version. The text comes with the app.
### TASK 9.4: View and edit profile
- **Status:** On hold
**Description**
The profile header and edit screen are built but show sample data, not the customer's real profile.
Needed from backend: customer profile endpoint
### TASK 9.5: Linked bank accounts
- **Status:** On hold
**Description**
The screen to see, add and remove linked accounts is built but uses sample data.
Needed from backend: linked accounts endpoint
### TASK 9.6: Change MPIN
- **Status:** New
**Description**
Change PIN in Profile, also opened from the MPIN tile in Services. It is only a placeholder now: it accepts only 1234 and calls no API. Needs a change-MPIN endpoint and a real flow (see Bug 10.2).
### TASK 9.7: Change password
- **Status:** New
**Description**
Change the web login password. The screen shows "success" without calling any API. Needs a change-password endpoint.
### TASK 9.8: Two-factor and Hide balance switches
- **Status:** New
**Description**
The two switches in the Security Center are on screen but do nothing yet.
### TASK 9.9: Devices and log out everywhere
- **Status:** New
**Description**
The Security Center section for signed-in devices shows fixed messages and makes no backend call. Needs endpoints to list devices and to end all sessions.
### TASK 9.10: Help and support
- **Status:** New
**Description**
Call, email and complaint form. The phone number and email are sample values, and the complaint reference number is made up on the phone. Needs the bank's real contact details and a complaints endpoint.
---
## FEATURE 10: Release readiness
- **Status:** New
**Description**
Items that must be fixed before the app goes to Apna Bank customers: test values left in the code, placeholder screens, live feature switches, HTTPS, and the list of backend endpoints still needed.
### BUG 10.1: Payer account hardcoded to a test account
- **Priority:** High · **Status:** New
**Description**
Every IMPS transfer is sent from the test account 002002000100019 instead of the account the customer picks (src/modules/payments/services/paymentsApi.ts, line 34). This was added for testing and must be removed before release.
### BUG 10.2: Change MPIN placeholder accepts only 1234
- **Priority:** High · **Status:** New
**Description**
The Change MPIN screen accepts only "1234" as the current MPIN and calls no API, but it is switched on for customers. Hide it until the real change-MPIN flow is built (Task 9.6).
### BUG 10.3: Change password and complaint form show fake results
- **Priority:** Normal · **Status:** New
**Description**
Change password shows success without calling an API. The complaint form in Help and support shows a made-up reference number and sample phone and email. Customers could believe these actions worked. Hide them or connect them before release.
### BUG 10.4: Beneficiary delete switch on without a screen or endpoint
- **Priority:** Low · **Status:** New
**Description**
The beneficiary delete switch is on, but no screen offers delete and the backend has no delete endpoint. Turn the switch off, or build it once the endpoint exists.
### TASK 10.5: Read the bank's live feature switches
- **Status:** New
**Description**
useMockServer is still true in src/config/env.ts, so the app uses its built-in on/off defaults instead of the bank's live settings from /features/config. Turn it off once every live feature is confirmed to work without demo data, and check which screens still depend on it.
### TASK 10.6: Move the API connection to HTTPS
- **Status:** New
**Description**
The app calls http://mobile-banking-backend.lincpay over plain HTTP. This name resolves only on the office network or VPN. A release build needs a public HTTPS address, and the Android network security config and iOS Info.plist need updating to match.
### TASK 10.7: Backend endpoints needed by the mobile app
- **Assignee:** backend lead · **Status:** New
- **Blocks:** Task 4.5, Task 3.5, Task 3.7, Task 3.8, Task 3.6, Task 6.5, Task 7.1, Task 7.3, Task 8.2, Task 9.4, Task 9.5, Task 9.6, Task 9.7, Task 9.9, Task 9.10, Bug 10.4
**Description**
These screens are built in the app but wait for backend endpoints:
- Own-account / internal transfer (Task 4.5)
- MMID generate and cancel (Task 3.5)
- Re-KYC update (Task 3.7)
- Video KYC slot booking (Task 3.8)
- e-Passbook ledger, or confirm /accounts/statement can be used (Task 3.6)
- Fate of cheque (Task 6.5)
- Branch list (Task 7.1)
- Financial protection schemes, enrolment and DEAF claim (Task 7.3)
- Notifications (Task 8.2)
- Customer profile (Task 9.4)
- Linked accounts (Task 9.5)
- Change MPIN (Task 9.6)
- Change password (Task 9.7)
- Devices and log out everywhere (Task 9.9)
- Complaints (Task 9.10)
- Beneficiary delete (Bug 10.4)
- Loans: products, eligibility, application, tracking, EMI payment, foreclosure (Feature 13)
- Verified date of birth for the test user 6267095464, so the statement PDF can be tested (Task 3.3)
After creating this task, open its Relations tab and add a "Blocks" relation to each task listed above.
---
## Optional: built but switched off
Create these only if the team wants to track them. Put each one directly under the EPIC as a FEATURE with status **On hold**. They need no child tasks for now.
### FEATURE 11: Agent app (parked)
- **Status:** On hold
**Description**
Field-agent app in the same code: dashboard and route plan, customers (list, create, edit, instalment history), daily collections with receipts, loan requests and account opening, commission, and agent profile. 18 screens, all on demo data. Switched on 14-10-2026, with the Agent login tab.
### FEATURE 12: Cards
- **Status:** On hold
**Description**
Card list and details, card controls (freeze, online, international, contactless, ATM, limits, block), credit card application and physical card request. Built on demo data. Switched on 14-10-2026; screenshots of every screen: docs/screenshots/cards.
### FEATURE 13: Loans
- **Status:** On hold
**Description**
Loan products, eligibility check, EMI calculator, application and tracking, documents, active loan details, EMI payment, foreclosure and history.
Switched on 14-10-2026. The backend has no /loans endpoints yet, so every loan screen shows demo data (LOANS_USE_DEMO_DATA in src/modules/loan/services/loanApi.ts). Set that switch to false once the endpoints exist. Screenshots of every screen: docs/screenshots/loans.
### FEATURE 14: Insurance
- **Status:** On hold
**Description**
Insurance plans, buying, plan comparison, premium calculator, policies, renewals, documents and claims. Built on demo data; the whole module is switched off.
### FEATURE 15: Other payment, deposit and service options
- **Status:** On hold
**Description**
Built but switched off because they are not in Apna Bank's target screen set: NEFT and RTGS transfers, UPI and scan to pay, opening a new FD or RD, certificates (balance, interest, tax), and service requests (standing instructions, Form 15G/15H, nomination).
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
+929
View File
@@ -0,0 +1,929 @@
<title>Multi-Bank Platform Blueprint</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap">
<style>
/* Layout: a reading column beside a sticky contents rail; diagrams sit in framed, horizontally scrollable panels. */
:root {
--bg: #F3F5F7;
--surface: #FFFFFF;
--ink: #15202B;
--muted: #556170;
--line: #D7DEE5;
--line-strong: #98A4B0;
--accent: #0B57D0;
--accent-soft: #E4EDFC;
--accent-ink: #0A3F96;
--kill: #B42318;
--kill-soft: #FCEBE9;
--code-bg: #E9EEF3;
--font-display: "IBM Plex Sans Condensed", "Arial Narrow", "Segoe UI", sans-serif;
--font-body: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0E141A; --surface: #151D25; --ink: #E5EAF0; --muted: #9AA7B5;
--line: #25313C; --line-strong: #4C5A68;
--accent: #7FA8F5; --accent-soft: #14284A; --accent-ink: #BCD1FA;
--kill: #F8786D; --kill-soft: #3A1815; --code-bg: #1C2630;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0E141A; --surface: #151D25; --ink: #E5EAF0; --muted: #9AA7B5;
--line: #25313C; --line-strong: #4C5A68;
--accent: #7FA8F5; --accent-soft: #14284A; --accent-ink: #BCD1FA;
--kill: #F8786D; --kill-soft: #3A1815; --code-bg: #1C2630;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
background: var(--bg);
color: var(--ink);
font: 16px/1.62 var(--font-body);
padding-inline: 20px;
padding-block: 0 72px;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
code { font-family: var(--font-mono); font-size: 0.86em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
.wrap { max-width: 1160px; margin: 0 auto; display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 56px; }
@media (max-width: 1020px) { .wrap { grid-template-columns: minmax(0, 1fr); gap: 0; } .toc { display: none; } }
.toc { position: sticky; top: calc(env(safe-area-inset-top, 0px) + 32px); align-self: start; padding-top: 48px; font-size: 14px; }
.toc p { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
.toc a { display: grid; grid-template-columns: 26px 1fr; color: var(--muted); text-decoration: none; padding: 5px 0; line-height: 1.35; }
.toc a span { font-family: var(--font-mono); font-size: 12px; color: var(--line-strong); }
.toc a:hover { color: var(--ink); }
main { min-width: 0; }
header.page { padding-block: 48px 8px; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
.eyebrow { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); margin: 0 0 14px; }
h1 { font-family: var(--font-display); font-weight: 600; font-size: clamp(36px, 5.4vw, 56px); line-height: 1.02; letter-spacing: -0.01em; margin: 0 0 18px; text-wrap: balance; }
.lede { font-size: 19px; line-height: 1.55; max-width: 700px; margin: 0 0 22px; color: var(--ink); }
.meta { display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 13.5px; color: var(--muted); margin: 0 0 26px; }
.meta b { color: var(--ink); font-weight: 600; }
.toc-inline { display: none; flex-wrap: wrap; gap: 6px 14px; font-size: 14px; margin: 0 0 26px; }
@media (max-width: 1020px) { .toc-inline { display: flex; } }
.qa { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin: 6px 0 34px; }
@media (max-width: 860px) { .qa { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .qa { grid-template-columns: minmax(0, 1fr); } }
.qa div { background: var(--surface); padding: 16px 16px 18px; min-width: 0; }
.qa .q { font-family: var(--font-display); font-weight: 600; font-size: 17px; line-height: 1.25; margin: 0 0 6px; }
.qa .a { font-size: 14px; color: var(--muted); margin: 0 0 8px; line-height: 1.45; }
.qa a { font-size: 13px; text-decoration: none; font-weight: 500; }
section { padding-block: 40px 8px; border-top: 1px solid var(--line); }
section:first-of-type { border-top: 0; }
h2 { font-family: var(--font-display); font-weight: 600; font-size: clamp(26px, 3.4vw, 32px); line-height: 1.12; margin: 0 0 12px; text-wrap: balance; display: flex; gap: 12px; align-items: baseline; }
h2 .num { font-family: var(--font-mono); font-weight: 400; font-size: 15px; color: var(--accent); }
h3 { font-size: 18px; font-weight: 600; margin: 32px 0 8px; text-wrap: balance; }
.prose { max-width: 720px; }
.prose p, .prose li { margin: 0 0 12px; }
.prose ul, .prose ol { padding-left: 22px; margin: 0 0 14px; }
figure { margin: 26px 0 30px; }
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 18px; overflow-x: auto; }
figcaption { font-size: 14px; color: var(--muted); margin-top: 10px; max-width: 720px; line-height: 1.5; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 20px; font-size: 13px; color: var(--muted); margin-top: 12px; }
.sw { display: inline-block; width: 13px; height: 13px; border-radius: 3px; border: 1.2px solid var(--line-strong); background: var(--surface); vertical-align: -2px; margin-right: 6px; }
.sw.a { background: var(--accent-soft); border-color: var(--accent); }
.sw.k { background: var(--kill-soft); border-color: var(--kill); }
.sw.n { border-style: dashed; }
/* Diagrams: page CSS styles the inline SVG through classes, so both themes follow the tokens. */
.dg { display: block; width: 100%; min-width: 760px; height: auto; font-family: var(--font-body); }
.dg text { fill: var(--ink); font-size: 12.5px; }
.dg .tt { font-weight: 600; font-size: 13px; }
.dg .ts { fill: var(--muted); font-size: 11.5px; }
.dg .tm { fill: var(--muted); font-family: var(--font-mono); font-size: 11px; }
.dg .th { fill: var(--muted); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.08em; }
.dg .ta { fill: var(--accent-ink); }
.dg .tk { fill: var(--kill); }
.dg .box { fill: var(--surface); stroke: var(--line-strong); stroke-width: 1; }
.dg .box-a { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 1.2; }
.dg .box-k { fill: var(--kill-soft); stroke: var(--kill); stroke-width: 1.2; }
.dg .grp { fill: none; stroke: var(--line-strong); stroke-width: 1; stroke-dasharray: 4 4; }
.dg .new { stroke-dasharray: 5 3; }
.dg .edge { fill: none; stroke: var(--muted); stroke-width: 1.4; }
.dg .edge-a { fill: none; stroke: var(--accent); stroke-width: 1.4; }
.dg .edge-k { fill: none; stroke: var(--kill); stroke-width: 1.4; }
.dg .ah { fill: var(--muted); }
.dg .ah-a { fill: var(--accent); }
.dg .ah-k { fill: var(--kill); }
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin: 20px 0 10px; }
@media (max-width: 640px) { .cards { grid-template-columns: minmax(0, 1fr); } }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px 12px; min-width: 0; }
.card h4 { margin: 0 0 2px; font-family: var(--font-display); font-weight: 600; font-size: 19px; }
.card .where { font-family: var(--font-mono); font-size: 12px; color: var(--muted); margin: 0 0 10px; }
.card ul { margin: 0; padding-left: 18px; font-size: 14.5px; }
.card li { margin: 0 0 6px; }
.tbl { overflow-x: auto; margin: 16px 0 24px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
table { border-collapse: collapse; width: 100%; font-size: 14.5px; line-height: 1.45; }
th, td { text-align: left; vertical-align: top; padding: 10px 14px; border-bottom: 1px solid var(--line); }
tr:last-child td { border-bottom: 0; }
th { font-family: var(--font-mono); font-weight: 500; font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
td:first-child { font-weight: 500; }
.pill { display: inline-block; font-family: var(--font-mono); font-size: 11.5px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line-strong); color: var(--muted); white-space: nowrap; }
.pill.a { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); }
.pill.k { border-color: var(--kill); color: var(--kill); background: var(--kill-soft); }
.rule { border-left: 3px solid var(--kill); background: var(--kill-soft); padding: 12px 16px; margin: 18px 0; max-width: 720px; font-size: 15px; }
.rule b { color: var(--kill); }
.note { border-left: 3px solid var(--accent); background: var(--accent-soft); padding: 12px 16px; margin: 18px 0; max-width: 720px; font-size: 15px; }
ol.steps { counter-reset: s; list-style: none; padding: 0; margin: 14px 0 20px; max-width: 720px; }
ol.steps li { counter-increment: s; display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 15px; }
ol.steps li::before { content: counter(s, decimal-leading-zero); font-family: var(--font-mono); font-size: 12.5px; color: var(--accent); padding-top: 2px; }
footer.page { margin-top: 48px; padding-top: 22px; border-top: 1px solid var(--line); font-size: 14px; color: var(--muted); max-width: 720px; }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
html { scroll-behavior: smooth; }
</style>
<div class="wrap">
<nav class="toc" aria-label="Contents">
<p>Contents</p>
<a href="#glance"><span>01</span>The platform at a glance</a>
<a href="#bank"><span>02</span>What a bank is made of</a>
<a href="#app"><span>03</span>Inside the app</a>
<a href="#features"><span>04</span>Who gets which feature</a>
<a href="#flows"><span>05</span>When a bank's flow differs</a>
<a href="#manage"><span>06</span>Managing the banks</a>
<a href="#updates"><span>07</span>How every change ships</a>
<a href="#security"><span>08</span>Security rules</a>
<a href="#roadmap"><span>09</span>Build order</a>
</nav>
<main>
<header class="page">
<p class="eyebrow">Architecture · BankCBS mobile platform</p>
<h1>Multi-Bank Platform Blueprint</h1>
<p class="lede">One React Native codebase ships a separate app for every bank. This page shows how the pieces fit together, how we run each bank, and how every kind of change reaches customers' phones.</p>
<div class="meta">
<span><b>Status</b> Proposal</span>
<span><b>Covers</b> mobile app, backend contract, delivery, operations</span>
<span><b>Detail</b> <code>docs/MULTI_BANK_ARCHITECTURE.md</code>, <code>docs/BANK_FLOW_VARIANTS.md</code></span>
</div>
<nav class="toc-inline" aria-label="Contents">
<a href="#glance">At a glance</a><a href="#bank">A bank</a><a href="#app">The app</a><a href="#features">Features</a><a href="#flows">Flows</a><a href="#manage">Managing banks</a><a href="#updates">Updates</a><a href="#security">Security</a><a href="#roadmap">Build order</a>
</nav>
<div class="qa">
<div><p class="q">Which bank is this app?</p><p class="a">A tenant pack, chosen when the app is built.</p><a href="#bank">What a bank is made of</a></div>
<div><p class="q">Which features does it show?</p><p class="a">Five layers: product, bank, live bank switches, the customer's session, the device.</p><a href="#features">Who gets which feature</a></div>
<div><p class="q">How does a feature work here?</p><p class="a">Settings, server-driven security steps, journey variants. Never <code>if (bank)</code>.</p><a href="#flows">When a bank's flow differs</a></div>
<div><p class="q">How does a change reach phones?</p><p class="a">Four lanes, from minutes (switches) to days (store builds).</p><a href="#updates">How every change ships</a></div>
</div>
</header>
<!-- 01 -->
<section id="glance">
<h2><span class="num">01</span>The platform at a glance</h2>
<div class="prose">
<p>Code lives in one repository. A release script builds it once per bank and ships it in two ways: store builds for native changes, OTA bundles for JavaScript. On the phone, the app asks the backend what this bank and this customer may use, and the backend checks every action again.</p>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 420" role="img" aria-label="Platform overview: one repository with shared core and tenant packs feeds a release script that produces a store build and an OTA update per bank; installed bank apps fetch config, answer security challenges and call business APIs on the platform backend, which the platform ops console and the bank admin portal configure.">
<defs>
<marker id="d1-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d1-ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-a" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="grp" x="20" y="130" width="230" height="176" rx="8"/>
<text class="tt" x="36" y="152">One repository</text>
<rect class="box" x="36" y="166" width="198" height="52" rx="6"/>
<text class="tt" x="50" y="188">Shared core</text>
<text class="ts" x="50" y="206">screens, flows, engines</text>
<rect class="box-a" x="36" y="232" width="198" height="56" rx="6"/>
<text class="tt ta" x="50" y="255">Tenant packs</text>
<text class="ts" x="50" y="274">one folder per bank</text>
<line class="edge" x1="135" y1="306" x2="135" y2="327" marker-end="url(#d1-m)"/>
<text class="ts" x="143" y="321">merge</text>
<rect class="box" x="36" y="330" width="198" height="70" rx="6"/>
<text class="tt" x="50" y="352">Release script / CI</text>
<text class="ts" x="50" y="370">checks, then builds</text>
<text class="ts" x="50" y="387">every bank in a loop</text>
<path class="edge" d="M234 342 H296 V190 H327" marker-end="url(#d1-m)"/>
<text class="ts" x="302" y="268">native change</text>
<line class="edge" x1="234" y1="372" x2="327" y2="372" marker-end="url(#d1-m)"/>
<text class="ts" x="281" y="390" text-anchor="middle">JS change</text>
<rect class="box-a" x="330" y="160" width="210" height="60" rx="6"/>
<text class="tt ta" x="344" y="185">Store build per bank</text>
<text class="ts" x="344" y="204">in the bank's own store account</text>
<rect class="box-a" x="330" y="342" width="210" height="60" rx="6"/>
<text class="tt ta" x="344" y="367">OTA update per bank</text>
<text class="ts" x="344" y="386">channel per bank: uat, prod</text>
<line class="edge" x1="540" y1="190" x2="617" y2="190" marker-end="url(#d1-m)"/>
<text class="ts" x="579" y="182" text-anchor="middle">installs</text>
<line class="edge" x1="540" y1="372" x2="617" y2="372" marker-end="url(#d1-m)"/>
<text class="ts" x="579" y="364" text-anchor="middle">updates JS</text>
<rect class="grp" x="620" y="150" width="160" height="250" rx="8"/>
<text class="tt" x="636" y="172">Customer phones</text>
<rect class="box-a" x="636" y="186" width="128" height="44" rx="6"/>
<text class="tt ta" x="700" y="213" text-anchor="middle">Bank A app</text>
<rect class="box-a" x="636" y="244" width="128" height="44" rx="6"/>
<text class="tt ta" x="700" y="271" text-anchor="middle">Bank B app</text>
<rect class="box-a" x="636" y="302" width="128" height="44" rx="6"/>
<text class="tt ta" x="700" y="329" text-anchor="middle">Bank C app</text>
<text class="ts" x="636" y="378">one listing per bank</text>
<line class="edge" x1="780" y1="211" x2="849" y2="211" marker-end="url(#d1-m)"/>
<text class="ts" x="815" y="203" text-anchor="middle">config</text>
<line class="edge" x1="780" y1="273" x2="849" y2="273" marker-end="url(#d1-m)"/>
<text class="ts" x="815" y="265" text-anchor="middle">challenge</text>
<line class="edge" x1="780" y1="335" x2="849" y2="335" marker-end="url(#d1-m)"/>
<text class="ts" x="815" y="327" text-anchor="middle">API calls</text>
<rect class="grp" x="840" y="150" width="150" height="250" rx="8"/>
<text class="tt" x="852" y="172">Platform backend</text>
<rect class="box" x="852" y="186" width="126" height="50" rx="6"/>
<text class="tt" x="864" y="207">Config service</text>
<text class="ts" x="864" y="225">flags, settings</text>
<rect class="box" x="852" y="248" width="126" height="50" rx="6"/>
<text class="tt" x="864" y="269">Flow engine</text>
<text class="ts" x="864" y="287">security steps</text>
<rect class="box" x="852" y="310" width="126" height="50" rx="6"/>
<text class="tt" x="864" y="331">Business APIs</text>
<text class="ts" x="864" y="349">enforces access</text>
<rect class="box" x="620" y="20" width="180" height="56" rx="6"/>
<text class="tt" x="636" y="43">Platform ops console</text>
<text class="ts" x="636" y="62">our team · onboarding</text>
<rect class="box-a" x="820" y="20" width="170" height="56" rx="6"/>
<text class="tt ta" x="836" y="43">Bank admin portal</text>
<text class="ts" x="836" y="62">bank staff · approvals</text>
<path class="edge" d="M710 76 V112 H870 V147" marker-end="url(#d1-m)"/>
<text class="ts" x="790" y="104" text-anchor="middle">sets up org, policy</text>
<line class="edge-a" x1="905" y1="76" x2="905" y2="147" marker-end="url(#d1-ma)"/>
<text class="ts" x="912" y="118">toggles</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Exists once per bank</span><span><i class="sw"></i>Shared by every bank</span></div>
</div>
<figcaption>Everything blue exists once per bank. Everything else is shared, so a fix in the shared core reaches every bank at once.</figcaption>
</figure>
<div class="prose">
<ul>
<li><b>Bank differences are data, not code branches.</b> They live in tenant packs and in server config.</li>
<li><b>The server decides security.</b> Which checks a transfer needs, and whether a customer may use a feature at all.</li>
<li><b>Every change has a lane.</b> A switch flips in minutes, a JS fix ships in hours, a native change takes days. Pick the fastest lane that is allowed (<a href="#updates">section 07</a>).</li>
</ul>
</div>
</section>
<!-- 02 -->
<section id="bank">
<h2><span class="num">02</span>What a bank is made of</h2>
<div class="prose">
<p>"Bank A" is not one thing. It exists in four places, and onboarding a bank means filling in all four. The bank's <code>id</code> (for example <code>bank-a</code>) ties them together.</p>
</div>
<div class="cards">
<div class="card">
<h4>Code</h4>
<p class="where">tenants/bank-a/</p>
<ul>
<li>Identity: <code>orgId</code>, name, logo, brand colours, wording</li>
<li>Feature defaults for the first launch and offline</li>
<li>Settings, flow choices, and rare strategies or slot overrides</li>
</ul>
</div>
<div class="card">
<h4>Native shell</h4>
<p class="where">Android flavor bankA · iOS scheme BankA</p>
<ul>
<li>App ID, launcher icon, home-screen name</li>
<li>Upload signing key, kept in CI secrets</li>
<li>OTA channel baked in: <code>bank-a-prod</code></li>
</ul>
</div>
<div class="card">
<h4>Server</h4>
<p class="where">org bank-a in the platform backend</p>
<ul>
<li>Live feature switches, settings, journey variants</li>
<li>Security challenge policy and customer rules</li>
<li>Admin users, approvals, audit log</li>
</ul>
</div>
<div class="card">
<h4>Delivery</h4>
<p class="where">tenants/registry.json · stores · Hot Updater</p>
<ul>
<li>Store listing in Bank A's own developer account</li>
<li>OTA channels <code>bank-a-uat</code> and <code>bank-a-prod</code></li>
<li>One registry entry: flavor, app IDs, version</li>
</ul>
</div>
</div>
<div class="note"><b>Two lists must agree.</b> <code>tenants/registry.json</code> (mobile) and the org table (backend) list the same banks with the same <code>id</code>. CI checks that every registry entry has a pack, and the onboarding checklist creates both.</div>
</section>
<!-- 03 -->
<section id="app">
<h2><span class="num">03</span>Inside the app</h2>
<div class="prose">
<p>The app keeps its current layering. Two things are added: a tenant layer that supplies the bank's identity at build time, and a flows layer that runs security steps and journeys. Bank-specific input enters in only two places: the tenant pack when building, and server config when running.</p>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 380" role="img" aria-label="App layers from top to bottom: app shell, navigation, modules, flows, a decision layer of feature engine, bank settings and strategies, the tenant pack, and services. The tenant pack enters at build time; feature config and the flow engine feed the decision and flows layers at runtime.">
<defs>
<marker id="d3-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d3-ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-a" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="220" y="20" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="45">App shell</text>
<text class="ts" x="410" y="45">providers · bootstrap · tenant guard</text>
<rect class="box" x="220" y="70" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="95">Navigation</text>
<text class="ts" x="410" y="95">a screen exists only if its feature is on</text>
<rect class="box" x="220" y="120" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="145">Modules</text>
<text class="ts" x="410" y="145">home · payments · services · loans · cards · agent</text>
<rect class="box new" x="220" y="170" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="195">Flows</text>
<text class="ts" x="410" y="195">challenge runner · journey runner</text>
<rect class="box" x="220" y="220" width="196" height="40" rx="6"/>
<text class="tt" x="318" y="245" text-anchor="middle">Feature engine</text>
<rect class="box new" x="432" y="220" width="196" height="40" rx="6"/>
<text class="tt" x="530" y="245" text-anchor="middle">Bank settings</text>
<rect class="box new" x="644" y="220" width="196" height="40" rx="6"/>
<text class="tt" x="742" y="245" text-anchor="middle">Strategies &amp; slots</text>
<rect class="box-a new" x="220" y="270" width="620" height="40" rx="6"/>
<text class="tt ta" x="236" y="295">Tenant pack (@tenant)</text>
<text class="ts" x="410" y="295">this bank's identity and defaults</text>
<rect class="box" x="220" y="320" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="345">Services</text>
<text class="ts" x="410" y="345">API client · security · storage</text>
<rect class="box-a" x="20" y="270" width="150" height="40" rx="6"/>
<text class="tm ta" x="95" y="294" text-anchor="middle">tenants/bank-a</text>
<line class="edge-a" x1="170" y1="290" x2="217" y2="290" marker-end="url(#d3-ma)"/>
<text class="ts" x="194" y="282" text-anchor="middle">build</text>
<rect class="box" x="870" y="170" width="120" height="40" rx="6"/>
<text class="tt" x="930" y="195" text-anchor="middle">Flow engine</text>
<line class="edge" x1="870" y1="190" x2="843" y2="190" marker-end="url(#d3-m)"/>
<rect class="box" x="870" y="220" width="120" height="40" rx="6"/>
<text class="tm" x="930" y="244" text-anchor="middle">/features/config</text>
<line class="edge" x1="870" y1="240" x2="843" y2="240" marker-end="url(#d3-m)"/>
<text class="ts" x="930" y="282" text-anchor="middle">runtime, from</text>
<text class="ts" x="930" y="298" text-anchor="middle">the server</text>
</svg>
<div class="legend"><span><i class="sw n"></i>New in this design</span><span><i class="sw a"></i>Differs per bank</span></div>
</div>
<figcaption>Screens never read the tenant pack or the server directly. They ask the decision layer ("is it on?", "what value?", "which rule?"), so a screen is the same for every bank.</figcaption>
</figure>
</section>
<!-- 04 -->
<section id="features">
<h2><span class="num">04</span>Who gets which feature</h2>
<div class="prose">
<p>Every screen, tab, button and action has a feature key such as <code>loans.apply</code>. The app resolves each key through five layers. A later layer overrides an earlier one, with one exception that protects the bank: a <b>false</b> from the bank or from the session is final.</p>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 222" role="img" aria-label="Five feature layers in priority order: product default, bank pack, bank live, session, local device. A false from bank live or session is final; no later layer can switch it back on.">
<defs>
<marker id="d4-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d4-mk" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-k" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="ts" x="20" y="16">Later layers override earlier ones</text>
<line class="edge" x1="20" y1="26" x2="928" y2="26" marker-end="url(#d4-m)"/>
<rect class="box" x="20" y="44" width="170" height="84" rx="6"/>
<text class="tt" x="34" y="68">Product default</text>
<text class="ts" x="34" y="88">set by developers</text>
<text class="ts" x="34" y="106">compiled in</text>
<rect class="box-a" x="205" y="44" width="170" height="84" rx="6"/>
<text class="tt ta" x="219" y="68">Bank pack</text>
<text class="ts" x="219" y="88">set by mobile team</text>
<text class="ts" x="219" y="106">compiled per bank</text>
<rect class="box-k" x="390" y="44" width="170" height="84" rx="6"/>
<text class="tt tk" x="404" y="68">Bank live</text>
<text class="ts" x="404" y="88">set by bank admin</text>
<text class="ts" x="404" y="106">fetched, cached</text>
<rect class="box-k" x="575" y="44" width="170" height="84" rx="6"/>
<text class="tt tk" x="589" y="68">Session</text>
<text class="ts" x="589" y="88">set by server rules</text>
<text class="ts" x="589" y="106">role + this customer</text>
<rect class="box" x="760" y="44" width="170" height="84" rx="6"/>
<text class="tt" x="774" y="68">Local device</text>
<text class="ts" x="774" y="88">set by QA or staff</text>
<text class="ts" x="774" y="106">release: off only</text>
<path class="edge-k" d="M475 128 V150 M660 128 V150 M475 150 H660"/>
<line class="edge-k" x1="567" y1="150" x2="567" y2="164" marker-end="url(#d4-mk)"/>
<rect class="box-k" x="330" y="166" width="475" height="36" rx="6"/>
<text class="tk" x="567" y="189" text-anchor="middle">A false here is final. No layer to its right can switch it back on.</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Differs per bank</span><span><i class="sw k"></i>Its "off" is final (kill switch)</span></div>
</div>
<figcaption>Two more rules on top: a feature is on only if every parent is on (<code>loans</code> off hides all of <code>loans.*</code>), and an unknown key is always off.</figcaption>
</figure>
<h3>Worked example</h3>
<div class="prose"><p>Bank A has loans and cards, but has switched cards off live during an outage. Bank B has no loans. Customer X at Bank A has KYC pending.</p></div>
<div class="tbl">
<table>
<thead><tr><th>Feature</th><th>Bank A · customer X</th><th>Bank A · customer Y</th><th>Bank B · anyone</th></tr></thead>
<tbody>
<tr><td><code>loans</code></td><td><span class="pill a">on</span> bank pack</td><td><span class="pill a">on</span> bank pack</td><td><span class="pill">off</span> bank pack</td></tr>
<tr><td><code>loans.apply</code></td><td><span class="pill k">off</span> session (KYC pending)</td><td><span class="pill a">on</span></td><td><span class="pill">off</span> parent is off</td></tr>
<tr><td><code>cards</code></td><td><span class="pill k">off</span> bank live</td><td><span class="pill k">off</span> bank live</td><td><span class="pill">off</span> default</td></tr>
<tr><td>Staff phone turns <code>cards</code> on</td><td><span class="pill k">off</span> still killed</td><td><span class="pill k">off</span> still killed</td><td><span class="pill">off</span> release builds ignore it</td></tr>
</tbody>
</table>
</div>
<div class="prose">
<ul>
<li><b>The root key decides a module.</b> Turning <code>loans</code> on for a bank turns on the whole module; children are switched off only to remove one capability.</li>
<li><b>New keys ship off.</b> An OTA update never shows a new feature to a bank that did not ask for it.</li>
<li><b>The server checks again.</b> Hiding a button is for the customer's convenience. The API refuses the call with <code>FEATURE_NOT_AVAILABLE</code> either way.</li>
</ul>
</div>
</section>
<!-- 05 -->
<section id="flows">
<h2><span class="num">05</span>When a bank's flow differs</h2>
<div class="prose">
<p>Some banks want the same feature to work differently: other security checks, other steps, other limits. There are five ways to allow that. Use the lowest rung that fits; each step up adds power and upkeep.</p>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 318" role="img" aria-label="The variation ladder, five rungs from lowest to highest: setting, server challenge, journey variant, strategy, slot override. The first three can change without an app update; strategies and slot overrides need an OTA update.">
<defs>
<marker id="d5-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="ts" x="20" y="28">Higher rung: more power, more upkeep. Use the lowest rung that fits.</text>
<line class="edge" x1="20" y1="40" x2="975" y2="40" marker-end="url(#d5-m)"/>
<rect class="box-a" x="20" y="200" width="180" height="100" rx="6"/>
<text class="tt ta" x="34" y="224">1 · Setting</text>
<text class="ts" x="34" y="244">a value differs</text>
<text class="tm" x="34" y="262">RTGS minimum</text>
<text class="ta" x="34" y="282">changes live</text>
<rect class="box-a" x="215" y="175" width="180" height="125" rx="6"/>
<text class="tt ta" x="229" y="199">2 · Server challenge</text>
<text class="ts" x="229" y="219">security steps differ</text>
<text class="tm" x="229" y="237">TPIN → OTP</text>
<text class="ta" x="229" y="257">changes live</text>
<rect class="box-a" x="410" y="150" width="180" height="150" rx="6"/>
<text class="tt ta" x="424" y="174">3 · Journey variant</text>
<text class="ts" x="424" y="194">journey steps differ</text>
<text class="tm" x="424" y="212">loan form steps</text>
<text class="ta" x="424" y="232">switch live, add by OTA</text>
<rect class="box" x="605" y="125" width="180" height="175" rx="6"/>
<text class="tt" x="619" y="149">4 · Strategy</text>
<text class="ts" x="619" y="169">a rule differs</text>
<text class="tm" x="619" y="187">account number format</text>
<text x="619" y="207">needs OTA</text>
<rect class="box" x="800" y="100" width="180" height="200" rx="6"/>
<text class="tt" x="814" y="124">5 · Slot override</text>
<text class="ts" x="814" y="144">a look differs</text>
<text class="tm" x="814" y="162">Home header</text>
<text x="814" y="182">needs OTA</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Can change without an app update</span><span><i class="sw"></i>Needs an OTA update</span></div>
</div>
<figcaption>Most bank requests stop at rung 1 or 2. Rungs 4 and 5 are added only when a real bank needs them.</figcaption>
</figure>
<h3>Security steps come from the server</h3>
<div class="prose">
<p>If the app decided "Bank B skips the TPIN", a modified app could skip it anywhere. So the server holds each bank's policy and replies one step at a time: "now the TPIN", "now the OTP", "done". The app only knows how to show each kind of check.</p>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 250" role="img" aria-label="The same transfer in three banks. Bank A: confirm, TPIN, OTP, done. Bank B for 3,000 rupees: confirm, OTP, done. Bank C: confirm, biometric, TPIN, done. The app code is identical; only the server policy differs.">
<defs>
<marker id="d6-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="ts" x="180" y="22">Each arrow is one server reply that names the next check.</text>
<text class="tt" x="20" y="58">Bank A</text>
<text class="ts" x="20" y="76">policy: TPIN, then OTP</text>
<rect class="box" x="180" y="40" width="130" height="44" rx="6"/><text class="tt" x="245" y="67" text-anchor="middle">Confirm</text>
<line class="edge" x1="310" y1="62" x2="348" y2="62" marker-end="url(#d6-m)"/>
<rect class="box-a" x="350" y="40" width="130" height="44" rx="6"/><text class="tt ta" x="415" y="67" text-anchor="middle">TPIN</text>
<line class="edge" x1="480" y1="62" x2="518" y2="62" marker-end="url(#d6-m)"/>
<rect class="box-a" x="520" y="40" width="130" height="44" rx="6"/><text class="tt ta" x="585" y="67" text-anchor="middle">OTP</text>
<line class="edge" x1="650" y1="62" x2="688" y2="62" marker-end="url(#d6-m)"/>
<rect class="box" x="690" y="40" width="130" height="44" rx="6"/><text class="tt" x="755" y="67" text-anchor="middle">Done</text>
<text class="tt" x="20" y="128">Bank B · ₹3,000</text>
<text class="ts" x="20" y="146">OTP only up to ₹5,000</text>
<rect class="box" x="180" y="110" width="130" height="44" rx="6"/><text class="tt" x="245" y="137" text-anchor="middle">Confirm</text>
<line class="edge" x1="310" y1="132" x2="348" y2="132" marker-end="url(#d6-m)"/>
<rect class="box-a" x="350" y="110" width="130" height="44" rx="6"/><text class="tt ta" x="415" y="137" text-anchor="middle">OTP</text>
<line class="edge" x1="480" y1="132" x2="518" y2="132" marker-end="url(#d6-m)"/>
<rect class="box" x="520" y="110" width="130" height="44" rx="6"/><text class="tt" x="585" y="137" text-anchor="middle">Done</text>
<text class="tt" x="20" y="198">Bank C</text>
<text class="ts" x="20" y="216">biometric, then TPIN</text>
<rect class="box" x="180" y="180" width="130" height="44" rx="6"/><text class="tt" x="245" y="207" text-anchor="middle">Confirm</text>
<line class="edge" x1="310" y1="202" x2="348" y2="202" marker-end="url(#d6-m)"/>
<rect class="box-a" x="350" y="180" width="130" height="44" rx="6"/><text class="tt ta" x="415" y="207" text-anchor="middle">Biometric</text>
<line class="edge" x1="480" y1="202" x2="518" y2="202" marker-end="url(#d6-m)"/>
<rect class="box-a" x="520" y="180" width="130" height="44" rx="6"/><text class="tt ta" x="585" y="207" text-anchor="middle">TPIN</text>
<line class="edge" x1="650" y1="202" x2="688" y2="202" marker-end="url(#d6-m)"/>
<rect class="box" x="690" y="180" width="130" height="44" rx="6"/><text class="tt" x="755" y="207" text-anchor="middle">Done</text>
<rect class="grp" x="850" y="40" width="140" height="184" rx="8"/>
<text class="tt" x="866" y="70">Same app code</text>
<text class="ts" x="866" y="92">for all three banks.</text>
<text class="ts" x="866" y="112">Only the server</text>
<text class="ts" x="866" y="130">policy differs.</text>
<text class="ts" x="866" y="160">Bank B can move</text>
<text class="ts" x="866" y="178">₹5,000 to ₹10,000</text>
<text class="ts" x="866" y="196">with no app release.</text>
</svg>
<div class="legend"><span><i class="sw a"></i>A check the server asked for</span></div>
</div>
<figcaption>Today's API already works this way for IMPS: <code>initiate</code> takes the TPIN, and <code>verify-otp</code> takes the OTP. An adapter maps it onto the same runner, so the app can move to this structure before the backend changes.</figcaption>
</figure>
<div class="rule"><b>Not allowed:</b> <code>if (tenant.id === 'bank-b')</code> or <code>switch (tenant.orgId)</code> in shared code, per-bank Git branches, or copied screens. An ESLint rule fails the build on the first two.</div>
</section>
<!-- 06 -->
<section id="manage">
<h2><span class="num">06</span>Managing the banks</h2>
<div class="prose">
<p>Each bank moves through the same lifecycle. After go-live, every change, large or small, goes back through that bank's UAT before production.</p>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 200" role="img" aria-label="Bank lifecycle: agree scope, set up, UAT, go live, operate, change. Every change loops back to UAT.">
<defs>
<marker id="d7-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="20" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="34" y="54">1 · Agree scope</text>
<text class="ts" x="34" y="74">contract, features</text>
<text class="tm" x="34" y="94">bank + us</text>
<line class="edge" x1="160" y1="72" x2="180" y2="72" marker-end="url(#d7-m)"/>
<rect class="box" x="182" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="196" y="54">2 · Set up</text>
<text class="ts" x="196" y="74">pack, flavor, org</text>
<text class="tm" x="196" y="94">us + DevOps</text>
<line class="edge" x1="322" y1="72" x2="342" y2="72" marker-end="url(#d7-m)"/>
<rect class="box-a" x="344" y="30" width="140" height="84" rx="6"/>
<text class="tt ta" x="358" y="54">3 · UAT</text>
<text class="ts" x="358" y="74">bank tests UAT</text>
<text class="tm" x="358" y="94">bank</text>
<line class="edge" x1="484" y1="72" x2="504" y2="72" marker-end="url(#d7-m)"/>
<rect class="box" x="506" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="520" y="54">4 · Go live</text>
<text class="ts" x="520" y="74">store + prod OTA</text>
<text class="tm" x="520" y="94">bank + us</text>
<line class="edge" x1="646" y1="72" x2="666" y2="72" marker-end="url(#d7-m)"/>
<rect class="box" x="668" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="682" y="54">5 · Operate</text>
<text class="ts" x="682" y="74">monitor, toggle</text>
<text class="tm" x="682" y="94">bank + us</text>
<line class="edge" x1="808" y1="72" x2="828" y2="72" marker-end="url(#d7-m)"/>
<rect class="box" x="830" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="844" y="54">6 · Change</text>
<text class="ts" x="844" y="74">features, flows</text>
<text class="tm" x="844" y="94">bank + us</text>
<path class="edge-a" d="M900 114 V160 H414 V118" marker-end="url(#d7-m)"/>
<text class="ts" x="657" y="180" text-anchor="middle">every change goes back through UAT</text>
</svg>
</div>
<figcaption>Offboarding runs the lifecycle in reverse: switch the bank's features off live, unpublish the store listing, retire the channels, archive the org.</figcaption>
</figure>
<h3>Who owns what</h3>
<div class="tbl">
<table>
<thead><tr><th>Task</th><th>Platform team (us)</th><th>Bank</th><th>DevOps</th></tr></thead>
<tbody>
<tr><td>Feature scope</td><td>advises</td><td><b>decides</b></td><td>—</td></tr>
<tr><td>Tenant pack, branding</td><td><b>builds</b></td><td>approves</td><td>—</td></tr>
<tr><td>Store listing and developer account</td><td>helps</td><td><b>owns</b></td><td>—</td></tr>
<tr><td>Signing keys, CI secrets</td><td>—</td><td>provides account access</td><td><b>owns</b></td></tr>
<tr><td>Live feature switches</td><td>emergency kill only</td><td><b>owns</b></td><td>—</td></tr>
<tr><td>Customer rules (KYC, account type, new device)</td><td>configures</td><td><b>decides</b></td><td>—</td></tr>
<tr><td>Security challenge policy</td><td>configures, reviews</td><td><b>decides</b>, signs off</td><td>—</td></tr>
<tr><td>UAT testing</td><td>supports</td><td><b>owns</b></td><td>—</td></tr>
<tr><td>OTA releases</td><td><b>owns</b></td><td>signs off</td><td>runs the pipeline</td></tr>
<tr><td>Store releases</td><td>builds</td><td><b>submits</b></td><td>—</td></tr>
<tr><td>Monitoring</td><td><b>owns</b> app health</td><td>sees its own dashboard</td><td><b>owns</b> infrastructure</td></tr>
</tbody>
</table>
</div>
<h3>Who can change what</h3>
<div class="tbl">
<table>
<thead><tr><th>Tool</th><th>Used by</th><th>Can change</th><th>Cannot change</th></tr></thead>
<tbody>
<tr><td>Bank admin portal</td><td>Bank staff, with two-person approval</td><td>Its own bank's switches, settings, journey variants, customer blocks</td><td>Other banks, security policy, app code</td></tr>
<tr><td>Platform ops console</td><td>Our operations team</td><td>Onboarding, security policies, emergency kill for any bank</td><td>App code</td></tr>
<tr><td>Git and CI</td><td>Our developers</td><td>Shared code, tenant packs, flow adapters</td><td>Live switches</td></tr>
<tr><td>Hot Updater console</td><td>Release managers</td><td>Rollout percentage, disable or roll back a bundle</td><td>Native code</td></tr>
<tr><td>Play Console, App Store Connect</td><td>Bank, with our help</td><td>Store listing, staged rollout, halt</td><td>—</td></tr>
</tbody>
</table>
</div>
<h3>Watching each bank</h3>
<div class="prose">
<p>Every crash report, API log and analytics event carries five tags: <code>tenant</code>, <code>appVersion</code>, <code>bundleId</code>, <code>configVersion</code> and <code>channel</code>. With those, one dashboard filters to any bank and support can see exactly what a customer was running.</p>
<ul>
<li>Crash-free sessions per bank and per OTA bundle, to decide when to widen a rollout</li>
<li>OTA adoption per channel, and how many phones are still on old native versions</li>
<li><code>FEATURE_NOT_AVAILABLE</code> rate: if it rises, the app is showing something the server refuses</li>
<li>Challenge failure rates by type, for example OTP not delivered at one bank</li>
</ul>
</div>
<h3>Adding a bank</h3>
<ol class="steps">
<li>Copy <code>tenants/_template</code> to <code>tenants/bank-c</code>; add the identity, brand colours, logo, features and settings.</li>
<li>Run the contrast check with Bank C's colours and adjust until it passes.</li>
<li>Add Bank C to <code>tenants/registry.json</code>; the Android flavor and CI job appear automatically.</li>
<li>Add launcher icons and iOS configuration for Bank C.</li>
<li>DevOps adds Bank C's signing key to CI secrets.</li>
<li>Ops creates org <code>bank-c</code>: feature switches, customer rules, security policy, admin users.</li>
<li>The bank creates its store listing and adds our team.</li>
<li>Build to <code>bank-c-uat</code>, the bank tests and signs off, then release to production.</li>
</ol>
</section>
<!-- 07 -->
<section id="updates">
<h2><span class="num">07</span>How every change ships</h2>
<div class="prose">
<p>There are four lanes. Choose by what changed, not by urgency: a native change can never take the OTA lane, and a switch never needs a release.</p>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 366" role="img" aria-label="Four update lanes. Switches and settings: made in the bank admin portal, approved by two people, delivered by config fetch, in minutes. Security policy: made in the flow engine, reviewed by ops and the bank, delivered in the next server reply, from the next transfer. JS, branding and pack changes: a pull request, CI and bank UAT, Hot Updater OTA, in hours. Native changes: a pull request, CI and store review, a store update with forced update, in days.">
<defs>
<marker id="d8-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="th" x="20" y="30">CHANGE</text>
<text class="th" x="230" y="30">MADE IN</text>
<text class="th" x="440" y="30">CHECKED BY</text>
<text class="th" x="650" y="30">TRAVELS VIA</text>
<text class="th" x="860" y="30">REACHES PHONES</text>
<rect class="box-a" x="20" y="46" width="180" height="60" rx="6"/><text class="tt ta" x="34" y="71">Switch or setting</text><text class="ts" x="34" y="89">bank level, live</text>
<line class="edge" x1="200" y1="76" x2="228" y2="76" marker-end="url(#d8-m)"/>
<rect class="box-a" x="230" y="46" width="180" height="60" rx="6"/><text class="tt ta" x="244" y="71">Bank admin portal</text><text class="ts" x="244" y="89">or platform ops</text>
<line class="edge" x1="410" y1="76" x2="438" y2="76" marker-end="url(#d8-m)"/>
<rect class="box-a" x="440" y="46" width="180" height="60" rx="6"/><text class="tt ta" x="454" y="71">Maker-checker</text><text class="ts" x="454" y="89">two people approve</text>
<line class="edge" x1="620" y1="76" x2="648" y2="76" marker-end="url(#d8-m)"/>
<rect class="box-a" x="650" y="46" width="180" height="60" rx="6"/><text class="tt ta" x="664" y="71">Config fetch</text><text class="ts" x="664" y="89">start · resume · sign-in</text>
<line class="edge" x1="830" y1="76" x2="858" y2="76" marker-end="url(#d8-m)"/>
<rect class="box-a" x="860" y="46" width="130" height="60" rx="6"/><text class="tt ta" x="874" y="71">Minutes</text><text class="ts" x="874" y="89">no app release</text>
<rect class="box-a" x="20" y="126" width="180" height="60" rx="6"/><text class="tt ta" x="34" y="151">Security policy</text><text class="ts" x="34" y="169">TPIN, OTP, step-up</text>
<line class="edge" x1="200" y1="156" x2="228" y2="156" marker-end="url(#d8-m)"/>
<rect class="box-a" x="230" y="126" width="180" height="60" rx="6"/><text class="tt ta" x="244" y="151">Flow engine policy</text><text class="ts" x="244" y="169">one per bank</text>
<line class="edge" x1="410" y1="156" x2="438" y2="156" marker-end="url(#d8-m)"/>
<rect class="box-a" x="440" y="126" width="180" height="60" rx="6"/><text class="tt ta" x="454" y="151">Ops review</text><text class="ts" x="454" y="169">+ bank sign-off</text>
<line class="edge" x1="620" y1="156" x2="648" y2="156" marker-end="url(#d8-m)"/>
<rect class="box-a" x="650" y="126" width="180" height="60" rx="6"/><text class="tt ta" x="664" y="151">Next server reply</text><text class="ts" x="664" y="169">inside the flow</text>
<line class="edge" x1="830" y1="156" x2="858" y2="156" marker-end="url(#d8-m)"/>
<rect class="box-a" x="860" y="126" width="130" height="60" rx="6"/><text class="tt ta" x="874" y="151">Next transfer</text><text class="ts" x="874" y="169">no app release</text>
<rect class="box" x="20" y="206" width="180" height="60" rx="6"/><text class="tt" x="34" y="231">JS, branding, pack</text><text class="ts" x="34" y="249">screens, fixes, logos</text>
<line class="edge" x1="200" y1="236" x2="228" y2="236" marker-end="url(#d8-m)"/>
<rect class="box" x="230" y="206" width="180" height="60" rx="6"/><text class="tt" x="244" y="231">Pull request</text><text class="ts" x="244" y="249">one codebase</text>
<line class="edge" x1="410" y1="236" x2="438" y2="236" marker-end="url(#d8-m)"/>
<rect class="box" x="440" y="206" width="180" height="60" rx="6"/><text class="tt" x="454" y="231">CI + bank UAT</text><text class="ts" x="454" y="249">per-bank uat channel</text>
<line class="edge" x1="620" y1="236" x2="648" y2="236" marker-end="url(#d8-m)"/>
<rect class="box" x="650" y="206" width="180" height="60" rx="6"/><text class="tt" x="664" y="231">Hot Updater OTA</text><text class="ts" x="664" y="249">staged 10% → 100%</text>
<line class="edge" x1="830" y1="236" x2="858" y2="236" marker-end="url(#d8-m)"/>
<rect class="box" x="860" y="206" width="130" height="60" rx="6"/><text class="tt" x="874" y="231">Hours</text><text class="ts" x="874" y="249">no store review</text>
<rect class="box" x="20" y="286" width="180" height="60" rx="6"/><text class="tt" x="34" y="311">Native change</text><text class="ts" x="34" y="329">SDK, icon, app ID</text>
<line class="edge" x1="200" y1="316" x2="228" y2="316" marker-end="url(#d8-m)"/>
<rect class="box" x="230" y="286" width="180" height="60" rx="6"/><text class="tt" x="244" y="311">Pull request</text><text class="ts" x="244" y="329">+ registry version</text>
<line class="edge" x1="410" y1="316" x2="438" y2="316" marker-end="url(#d8-m)"/>
<rect class="box" x="440" y="286" width="180" height="60" rx="6"/><text class="tt" x="454" y="311">CI + store review</text><text class="ts" x="454" y="329">Google, Apple</text>
<line class="edge" x1="620" y1="316" x2="648" y2="316" marker-end="url(#d8-m)"/>
<rect class="box" x="650" y="286" width="180" height="60" rx="6"/><text class="tt" x="664" y="311">Store update</text><text class="ts" x="664" y="329">+ forced update</text>
<line class="edge" x1="830" y1="316" x2="858" y2="316" marker-end="url(#d8-m)"/>
<rect class="box" x="860" y="286" width="130" height="60" rx="6"/><text class="tt" x="874" y="311">Days</text><text class="ts" x="874" y="329">store release</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Server-side: no app release at all</span><span><i class="sw"></i>Needs a new app build</span></div>
</div>
<figcaption>The two blue lanes change only server data, so they can be undone as quickly as they were made. The other two ship code and go through UAT.</figcaption>
</figure>
<h3>Which change goes where</h3>
<div class="tbl">
<table>
<thead><tr><th>Example</th><th>Lane</th><th>Scope</th></tr></thead>
<tbody>
<tr><td>Turn loans off for Bank B during an incident</td><td><span class="pill a">switch</span></td><td>One bank</td></tr>
<tr><td>Change Bank A's RTGS minimum or lock timeout</td><td><span class="pill a">setting</span></td><td>One bank</td></tr>
<tr><td>Block one customer from adding payees for 24 hours</td><td><span class="pill a">switch</span> session rule</td><td>One customer</td></tr>
<tr><td>Raise Bank B's OTP-only limit from ₹5,000 to ₹10,000</td><td><span class="pill a">policy</span></td><td>One bank</td></tr>
<tr><td>Fix a bug on the transfer screen</td><td><span class="pill">OTA</span></td><td>Every bank, channel by channel</td></tr>
<tr><td>New logo or colours for Bank C</td><td><span class="pill">OTA</span></td><td>One bank</td></tr>
<tr><td>New screen behind a new feature key (ships off)</td><td><span class="pill">OTA</span>, then <span class="pill a">switch</span> per bank</td><td>Banks that opt in</td></tr>
<tr><td>New native SDK, permission, app icon or app ID</td><td><span class="pill">store</span></td><td>Every bank that needs it</td></tr>
</tbody>
</table>
</div>
<h3>The OTA pipeline</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 226" role="img" aria-label="OTA pipeline: pull request, CI checks, build per bank, UAT channels, bank sign-off, production rollout from 10 to 100 percent. On a problem, disable the bundle so phones revert, then fix forward with a new pull request.">
<defs>
<marker id="d9-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d9-mk" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-k" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="20" y="50" width="100" height="64" rx="6"/>
<text class="tt" x="34" y="76">Pull request</text>
<text class="ts" x="34" y="95">one change</text>
<line class="edge" x1="120" y1="82" x2="143" y2="82" marker-end="url(#d9-m)"/>
<rect class="box" x="145" y="50" width="160" height="64" rx="6"/>
<text class="tt" x="159" y="76">CI checks</text>
<text class="ts" x="159" y="95">lint · types · tests</text>
<line class="edge" x1="305" y1="82" x2="328" y2="82" marker-end="url(#d9-m)"/>
<rect class="box" x="330" y="50" width="150" height="64" rx="6"/>
<text class="tt" x="344" y="76">Build per bank</text>
<text class="tm" x="344" y="95">TENANT=bank-x</text>
<line class="edge" x1="480" y1="82" x2="503" y2="82" marker-end="url(#d9-m)"/>
<rect class="box-a" x="505" y="50" width="140" height="64" rx="6"/>
<text class="tt ta" x="519" y="76">UAT channels</text>
<text class="tm" x="519" y="95">bank-x-uat</text>
<line class="edge" x1="645" y1="82" x2="668" y2="82" marker-end="url(#d9-m)"/>
<rect class="box-a" x="670" y="50" width="110" height="64" rx="6"/>
<text class="tt ta" x="684" y="76">Bank sign-off</text>
<text class="ts" x="684" y="95">per bank</text>
<line class="edge" x1="780" y1="82" x2="803" y2="82" marker-end="url(#d9-m)"/>
<rect class="box-a" x="805" y="50" width="185" height="64" rx="6"/>
<text class="tt ta" x="819" y="76">Production</text>
<text class="tm" x="819" y="95">10% → 50% → 100%</text>
<line class="edge-k" x1="897" y1="114" x2="897" y2="155" marker-end="url(#d9-mk)"/>
<text class="ts" x="905" y="140">problem</text>
<rect class="box-k" x="760" y="158" width="230" height="48" rx="6"/>
<text class="tt tk" x="774" y="178">Disable the bundle</text>
<text class="ts" x="774" y="196">phones revert to the last bundle</text>
<path class="edge" d="M760 182 H70 V117" marker-end="url(#d9-m)"/>
<text class="ts" x="415" y="200" text-anchor="middle">fix forward with a new pull request</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Runs once per bank</span><span><i class="sw k"></i>Rollback</span></div>
</div>
<figcaption>CI also runs the feature audit, the tenant matrix (every pack is valid) and the no-bank-branching lint rule. If a bundle crashes before the first screen, Hot Updater rolls that phone back by itself.</figcaption>
</figure>
<div class="note"><b>Suggested rhythm.</b> A weekly JS train: cut on Monday, bank UAT Tuesday to Wednesday, staged production from Thursday. A monthly native train. Hotfixes any day, on the same path with a faster sign-off.</div>
<h3>Keeping versions compatible</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 240" role="img" aria-label="Versions over time: store release 1.4.0 receives OTA fixes targeted with -t 1.4.0; store release 1.5.0 receives its own fixes with -t 1.5.0; raising the minimum version to 1.5.0 forces phones still on 1.4 to update from the store.">
<defs>
<marker id="d10-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d10-ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-a" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="tt" x="20" y="64">Store (native)</text>
<text class="tt" x="20" y="126">OTA (JS)</text>
<text class="tt" x="20" y="184">Forced update</text>
<rect class="box" x="160" y="40" width="120" height="40" rx="6"/>
<text class="tt" x="220" y="65" text-anchor="middle">v1.4.0</text>
<rect class="box" x="600" y="40" width="120" height="40" rx="6"/>
<text class="tt" x="660" y="65" text-anchor="middle">v1.5.0</text>
<path class="edge-a" d="M220 80 V122 H308" marker-end="url(#d10-ma)"/>
<text class="tm" x="264" y="114" text-anchor="middle">-t 1.4.0</text>
<rect class="box-a" x="310" y="104" width="130" height="36" rx="6"/>
<text class="ta" x="375" y="127" text-anchor="middle">fix 1 for 1.4.0</text>
<line class="edge-a" x1="440" y1="122" x2="458" y2="122" marker-end="url(#d10-ma)"/>
<rect class="box-a" x="460" y="104" width="130" height="36" rx="6"/>
<text class="ta" x="525" y="127" text-anchor="middle">fix 2 for 1.4.0</text>
<path class="edge-a" d="M660 80 V122 H748" marker-end="url(#d10-ma)"/>
<text class="tm" x="704" y="114" text-anchor="middle">-t 1.5.0</text>
<rect class="box-a" x="750" y="104" width="130" height="36" rx="6"/>
<text class="ta" x="815" y="127" text-anchor="middle">fix 1 for 1.5.0</text>
<rect class="box-k" x="600" y="162" width="390" height="40" rx="6"/>
<text class="tk" x="614" y="187">Min version → 1.5.0: phones on 1.4.x must update from the store</text>
<line class="edge" x1="160" y1="224" x2="980" y2="224" marker-end="url(#d10-m)"/>
<text class="ts" x="150" y="228" text-anchor="end">time</text>
</svg>
</div>
<figcaption>An OTA bundle only reaches the native version it was built for, so JavaScript never runs on a binary that lacks its native code.</figcaption>
</figure>
<div class="prose">
<ul>
<li>The app ignores config keys it does not know, so the server can publish ahead of the apps.</li>
<li>The backend keeps every API that the oldest supported app version uses. Remove a field only after the minimum version has moved past it.</li>
<li>The minimum supported version lives in server config. Below it, the app sends the customer to the store.</li>
</ul>
</div>
<h3>When something goes wrong</h3>
<div class="tbl">
<table>
<thead><tr><th>Problem</th><th>Fastest fix</th><th>Takes effect</th><th>Who</th></tr></thead>
<tbody>
<tr><td>A feature misbehaves at one bank</td><td>Switch it off in the admin portal</td><td>Minutes</td><td>Bank admin or ops</td></tr>
<tr><td>A new JS bundle crashes on start</td><td>Nothing: Hot Updater rolls that phone back</td><td>Next launch</td><td>Automatic</td></tr>
<tr><td>A new JS bundle has a bug</td><td>Disable the bundle in the Hot Updater console</td><td>Minutes</td><td>Release manager</td></tr>
<tr><td>A security step must change now</td><td>Change that bank's challenge policy</td><td>Next transfer</td><td>Ops, with the bank</td></tr>
<tr><td>A native bug in an old version</td><td>Ship a fixed store build, then raise the minimum version</td><td>Hours to days</td><td>Platform team, bank</td></tr>
<tr><td>A bad store build is rolling out</td><td>Halt the staged rollout in the store console</td><td>Hours</td><td>Bank's store account</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 08 -->
<section id="security">
<h2><span class="num">08</span>Security rules</h2>
<div class="prose">
<ol>
<li><b>The server is the boundary.</b> Every business API checks the bank, the role and the customer again. The app's gating is for the customer's convenience.</li>
<li><b>Fail closed.</b> An unknown feature key, an unknown challenge type or an unreadable config means "off" or "stop", never "allow".</li>
<li><b>The bank's off is final.</b> Session and device layers cannot re-enable it, and release builds let the device layer switch features off only.</li>
<li><b>Kill switches survive offline.</b> The last bank config is cached and applied on a cold start without network.</li>
<li><b>No leaks between customers or banks.</b> The session layer is wiped on sign-out, each bank's bundle contains only its own pack, and a tenant guard refuses to run a bundle in the wrong bank's app.</li>
<li><b>HTTPS before production.</b> Config and challenge replies must not be changeable in transit.</li>
<li><b>Signed code.</b> Separate store signing keys per bank, and signed OTA bundles.</li>
<li><b>Every change is recorded.</b> Two-person approval and an audit log for switches and policies.</li>
</ol>
</div>
</section>
<!-- 09 -->
<section id="roadmap">
<h2><span class="num">09</span>Build order</h2>
<div class="prose"><p>The early phases change nothing customers can see. They make the current app ready for a second bank. Sizes are rough, for one developer.</p></div>
<div class="tbl">
<table>
<thead><tr><th>Phase</th><th>Work</th><th>Size</th></tr></thead>
<tbody>
<tr><td>0 · Prepare</td><td>Branch, commit the work in progress, real release signing</td><td>½ day</td></tr>
<tr><td>1 · Harden the feature engine</td><td>Send <code>orgId</code> with config, 4-second fetch limit, device layer off-only, wait for session flags, refresh on resume</td><td>1–2 days</td></tr>
<tr><td>2 · Tenant packs</td><td>Move today's identity into the first pack, Metro <code>@tenant</code>, settings</td><td>2–3 days</td></tr>
<tr><td>3 · Build and delivery</td><td>Registry, Android flavors, iOS schemes, release script, CI matrix</td><td>2–3 days</td></tr>
<tr><td>F · Flow framework</td><td>Challenge runner, then transfer, TPIN, MPIN and registration onto it</td><td>6–8 days</td></tr>
<tr><td>4 · Backend</td><td>Config with entitlements, challenge protocol, admin portal, API enforcement</td><td>Backend team</td></tr>
<tr><td>5 · OTA per bank</td><td>Hot Updater channels, tenant guard, bundle signing</td><td>1 day</td></tr>
<tr><td>6 · Second bank</td><td>Follow the checklist in section 06</td><td>½ day</td></tr>
</tbody>
</table>
</div>
</section>
<footer class="page">
<p>Full specifications, with code and file-by-file changes, are in the repository: <code>docs/MULTI_BANK_ARCHITECTURE.md</code> (banks, features, build, backend) and <code>docs/BANK_FLOW_VARIANTS.md</code> (per-bank flows). This page is <code>docs/platform-blueprint.html</code>.</p>
</footer>
</main>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Some files were not shown because too many files have changed in this diff Show More