Files
CBS-Mobile-App/docs/BANK_FLOW_VARIANTS.md
T
Abin ToppoandClaude Opus 5.5 f960a119e8 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>
2026-10-14 18:18:40 +05:30

31 KiB
Raw Blame History

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

  1. The problem
  2. What the code does today
  3. The variation ladder
  4. Rung 1 — Bank settings
  5. Rung 2 — Server-driven challenges
  6. Rung 3 — Journey variants
  7. Rung 4 — Strategies
  8. Rung 5 — Slot overrides
  9. Not allowed — branching on the bank
  10. One config response
  11. Worked example: one transfer, three banks
  12. Folder layout
  13. Testing
  14. Migration plan
  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
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_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:

// 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":

  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.