diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1629eae..52a85da 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/android/app/build.gradle b/android/app/build.gradle index 6c0dd6e..7634dbf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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//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") +} diff --git a/android/app/src/main/assets/fonts/IBMPlexMono-SmBld.ttf b/android/app/src/main/assets/fonts/IBMPlexMono-SmBld.ttf deleted file mode 100644 index a48e40a..0000000 Binary files a/android/app/src/main/assets/fonts/IBMPlexMono-SmBld.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/IBMPlexMono.ttf b/android/app/src/main/assets/fonts/IBMPlexMono.ttf deleted file mode 100644 index 4254c37..0000000 Binary files a/android/app/src/main/assets/fonts/IBMPlexMono.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/IBMPlexSans-Bold.ttf b/android/app/src/main/assets/fonts/IBMPlexSans-Bold.ttf deleted file mode 100644 index 1d66b1a..0000000 Binary files a/android/app/src/main/assets/fonts/IBMPlexSans-Bold.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/IBMPlexSans-Medm.ttf b/android/app/src/main/assets/fonts/IBMPlexSans-Medm.ttf deleted file mode 100644 index a3826c4..0000000 Binary files a/android/app/src/main/assets/fonts/IBMPlexSans-Medm.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/IBMPlexSans-SmBld.ttf b/android/app/src/main/assets/fonts/IBMPlexSans-SmBld.ttf deleted file mode 100644 index 09ec8cd..0000000 Binary files a/android/app/src/main/assets/fonts/IBMPlexSans-SmBld.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/IBMPlexSans.ttf b/android/app/src/main/assets/fonts/IBMPlexSans.ttf deleted file mode 100644 index bd6817d..0000000 Binary files a/android/app/src/main/assets/fonts/IBMPlexSans.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/Inter-Bold.ttf b/android/app/src/main/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/android/app/src/main/assets/fonts/Inter-Bold.ttf differ diff --git a/android/app/src/main/assets/fonts/Inter-ExtraBold.ttf b/android/app/src/main/assets/fonts/Inter-ExtraBold.ttf new file mode 100644 index 0000000..0fdf1e0 Binary files /dev/null and b/android/app/src/main/assets/fonts/Inter-ExtraBold.ttf differ diff --git a/android/app/src/main/assets/fonts/Inter-Medium.ttf b/android/app/src/main/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/android/app/src/main/assets/fonts/Inter-Medium.ttf differ diff --git a/android/app/src/main/assets/fonts/Inter-Regular.ttf b/android/app/src/main/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/android/app/src/main/assets/fonts/Inter-Regular.ttf differ diff --git a/android/app/src/main/assets/fonts/Inter-SemiBold.ttf b/android/app/src/main/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/android/app/src/main/assets/fonts/Inter-SemiBold.ttf differ diff --git a/android/app/src/main/java/com/bankcbs/mobile/MainActivity.kt b/android/app/src/main/java/com/bankcbs/mobile/MainActivity.kt index 281a614..644ae12 100644 --- a/android/app/src/main/java/com/bankcbs/mobile/MainActivity.kt +++ b/android/app/src/main/java/com/bankcbs/mobile/MainActivity.kt @@ -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(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 + } + } } diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index 61f8c56..7a17f7f 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -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. --> mobile-banking-backend.lincpay + 192.168.6.138 10.209.40.168 192.168.6.165 192.168.1.11 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 2a84e18..d4081da 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/link-assets-manifest.json b/android/link-assets-manifest.json index ac8eed6..48765a1 100644 --- a/android/link-assets-manifest.json +++ b/android/link-assets-manifest.json @@ -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" } ] } \ No newline at end of file diff --git a/assets/fonts/IBMPlexMono-SmBld.ttf b/assets/fonts/IBMPlexMono-SmBld.ttf deleted file mode 100644 index a48e40a..0000000 Binary files a/assets/fonts/IBMPlexMono-SmBld.ttf and /dev/null differ diff --git a/assets/fonts/IBMPlexMono.ttf b/assets/fonts/IBMPlexMono.ttf deleted file mode 100644 index 4254c37..0000000 Binary files a/assets/fonts/IBMPlexMono.ttf and /dev/null differ diff --git a/assets/fonts/IBMPlexSans-Bold.ttf b/assets/fonts/IBMPlexSans-Bold.ttf deleted file mode 100644 index 1d66b1a..0000000 Binary files a/assets/fonts/IBMPlexSans-Bold.ttf and /dev/null differ diff --git a/assets/fonts/IBMPlexSans-Medm.ttf b/assets/fonts/IBMPlexSans-Medm.ttf deleted file mode 100644 index a3826c4..0000000 Binary files a/assets/fonts/IBMPlexSans-Medm.ttf and /dev/null differ diff --git a/assets/fonts/IBMPlexSans-SmBld.ttf b/assets/fonts/IBMPlexSans-SmBld.ttf deleted file mode 100644 index 09ec8cd..0000000 Binary files a/assets/fonts/IBMPlexSans-SmBld.ttf and /dev/null differ diff --git a/assets/fonts/IBMPlexSans.ttf b/assets/fonts/IBMPlexSans.ttf deleted file mode 100644 index bd6817d..0000000 Binary files a/assets/fonts/IBMPlexSans.ttf and /dev/null differ diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-ExtraBold.ttf b/assets/fonts/Inter-ExtraBold.ttf new file mode 100644 index 0000000..0fdf1e0 Binary files /dev/null and b/assets/fonts/Inter-ExtraBold.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/docs/Application Features_API_Status.pdf b/docs/Application Features_API_Status.pdf new file mode 100644 index 0000000..cfc8ac9 Binary files /dev/null and b/docs/Application Features_API_Status.pdf differ diff --git a/docs/BANK_FLOW_VARIANTS.md b/docs/BANK_FLOW_VARIANTS.md new file mode 100644 index 0000000..8786b8d --- /dev/null +++ b/docs/BANK_FLOW_VARIANTS.md @@ -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,
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 */} +