# 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,
length or on/off?"} Q1 -- yes --> R1["Rung 1 — Setting"] Q1 -- no --> Q2{"Is it a security step?
PIN, OTP, biometric,
approval, cooling period"} Q2 -- yes --> R2["Rung 2 — Server challenge policy"] Q2 -- no --> Q3{"Different steps or order
in a journey?"} Q3 -- yes --> R3["Rung 3 — Journey variant"] Q3 -- no --> Q4{"Same screens,
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//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: "" } ← { 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 = | { 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 { start(input: TInput): Promise>; answer(challenge: Challenge, response: string): Promise>; reset(): void; } ``` ### 5.4 App side — runner and renderer ```ts // src/flows/challenges/useAuthorisation.ts export function useAuthorisation(flow: AuthorisationFlow) { const [step, setStep] = useState | null>(null); const [busy, setBusy] = useState(false); const run = async (pending: Promise>) => { 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>; export const ChallengeSheet = ({ auth }: { auth: ReturnType }) => { if (auth.step?.status !== 'challenge') return null; const { challenge, error } = auth.step; const View = CHALLENGE_VIEWS[challenge.type]; return ( ); }; ``` | 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 => { 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 ( {/* review card — unchanged */}