- 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>
31 KiB
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 (tenant packs, feature layers, /features/config) |
| Audience | Mobile team, backend team, product |
Contents
- The problem
- What the code does today
- The variation ladder
- Rung 1 — Bank settings
- Rung 2 — Server-driven challenges
- Rung 3 — Journey variants
- Rung 4 — Strategies
- Rung 5 — Slot overrides
- Not allowed — branching on the bank
- One config response
- Worked example: one transfer, three banks
- Folder layout
- Testing
- Migration plan
- 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 |
| TPIN create | 'mpin' | 'otp' | 'tpin' | 'exists' |
TpinCreateFlow.tsx:30 |
| TPIN reset | 'start' | 'otp' | 'tpin' | 'notSet' | 'stopped' |
TpinResetFlow.tsx:36 |
| MPIN reset | 'mobile' | 'otp' | 'newMpin' |
MpinResetFlow.tsx:39 |
| Registration | navigate('VerifyOtp') → CreateMpin |
RegisterScreen.tsx:142, AuthNavigator.tsx |
| Stop payment | 'find' | 'confirm' | 'stopped' |
StopPaymentScreen.tsx:56 |
Hard-coded values a bank might need to change:
| Value | Where |
|---|---|
OTP_LENGTH = 6, copied 3 times, plus length={6} |
PaymentConfirmationScreen.tsx:37, TpinCreateFlow.tsx:35, TpinResetFlow.tsx:41, VerifyOtpScreen.tsx:210 |
TPIN_LENGTH = 4 |
tpinValidation.ts:4 |
MPIN_LENGTH = 4 |
validation.ts:10 |
RTGS_MINIMUM = 200000 |
TransferToAccountScreen.tsx:40 |
requireMpinOnLaunch, lockAfterBackgroundMs |
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 | — | — |
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
// 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
// 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):
# 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
// 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
// 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);
},
};
}
// 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 |
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:
// 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_ERRORandREQUEST_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 withattemptsRemaining - 1;OTP_EXPIREDandOTP_ATTEMPTS_EXCEEDED→ended.
- Never retry
verify-otpafter 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:
// 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:
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
// 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
// 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'.
// 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 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
// 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 };
// 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
// 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])} />;
}
// shared code
<Slot name="home.hero" fallback={SummaryCard} {...summaryProps} />
// 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:
// .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:
{
"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 |
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 FlowSteps. 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":
- Is it only a value? → Add or change a setting (§4).
- 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.
- Is it different steps in a journey? → Pick an existing variant, or add a new one to core (§6).
- Same screens, different rule or backend? → Strategy in the bank's pack (§7).
- 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.