Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+7
View File
@@ -1,6 +1,13 @@
export interface ApiError {
status: number;
message: string;
/**
* The backend's `errorCode`, or a client-synthesised one when the request
* never got an answer. Stable across wording changes — branch on this.
*/
code?: string;
/** Field-to-message map that `VALIDATION_ERROR` returns. */
fieldErrors?: Record<string, string>;
data?: unknown;
}
+43 -5
View File
@@ -29,11 +29,27 @@ export type CardDeliveryStage =
export type LoginRole = 'User' | 'Agent';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
/**
* What the app holds after a successful sign-in. There is no refresh token in
* the CBS API — a session is opened by MPIN and ends at whichever of its two
* timeouts bites first.
*/
export interface AuthSession {
/** Sent as `Authorization: Bearer <token>` on every authenticated call. */
sessionToken: string;
/** Absolute expiry from the server. Never extended. */
expiresAt: string;
role?: LoginRole;
/** Idle window in seconds; any authenticated call resets it. */
idleTimeoutSeconds: number;
orgId: string;
mobileNumber: string;
fullName: string;
role: LoginRole;
/**
* False for the agent app, which has no CBS backend behind it yet and runs
* on fixtures. Keeps mock sessions from being treated as live ones.
*/
live: boolean;
}
export interface NotificationPreference {
@@ -50,6 +66,8 @@ export interface UserProfile {
phone: string;
customerId: string;
kycStatus: 'verified' | 'pending';
/** ISO date of the last completed KYC / re-KYC. */
lastKycDate?: string;
ckycNumber?: string;
language: LanguageCode;
avatarUrl?: string;
@@ -79,6 +97,10 @@ export interface AccountSummary {
type: AccountType;
accountHolder: string;
branch: string;
/** IFSC code of the home branch. */
ifscCode?: string;
/** Operating status of the account. */
status?: 'Active' | 'Dormant' | 'Frozen';
maskedNumber: string;
availableBalance: number;
currentBalance: number;
@@ -114,12 +136,24 @@ export interface TransactionRecord {
export interface Beneficiary {
id: string;
/** The bank's answer, never what the customer typed. */
name: string;
bankName: string;
accountMasked: string;
upiId?: string;
/** The one field that is the customer's own. */
nickname?: string;
/**
* Held on this device only. The API has no favourite flag, so starring a
* payee is a local preference rather than something the bank knows about.
*/
favorite: boolean;
/** P2A routes by account number and IFSC; P2P by mobile number and MMID. */
mode?: 'P2A' | 'P2P';
ifsc?: string | null;
mobileNumber?: string | null;
mmid?: string | null;
status?: string | null;
}
export interface TransferDraft {
@@ -192,6 +226,10 @@ export interface DepositAccount {
interestEarned: number;
payoutLabel: string;
interestPayoutType?: DepositInterestPayoutType;
/** The bank's name for the product, when it supplies one. */
productName?: string;
/** Recurring deposits only: installments credited so far, of `tenureMonths`. */
installmentsPaid?: number;
status: DepositStatus;
settlementAccountMasked: string;
nominee?: DepositNominee;
@@ -229,7 +267,7 @@ export interface LoanProduct {
features: string[];
}
export type LoanCategory = 'Personal Loan' | 'Home Loan' | 'Car Loan' | 'Gold Loan' | 'Education Loan';
export type LoanCategory = 'Personal Loan' | 'Home Loan' | 'Car Loan' | 'Gold Loan' | 'Education Loan' | 'Loan Against DDS Deposit';
export type LoanStatus = 'Active' | 'Closed' | 'Overdue';
export type LoanApplicationStage = 'Submitted' | 'Under Review' | 'Approved' | 'Rejected' | 'Disbursed';
export type LoanDocumentType = 'Aadhaar' | 'PAN' | 'Salary Slip' | 'Bank Statement' | 'Address Proof' | 'Other';
+594
View File
@@ -0,0 +1,594 @@
/**
* Wire types for the CBS mobile banking API. These mirror the documented
* request and response bodies exactly — mapping into the app's own shapes
* happens at the module boundary, never here.
*/
/** Success and failure share one wrapper, so the client parses a single shape. */
export interface CbsEnvelope<T> {
success: boolean;
message: string;
/**
* Present on success. `VALIDATION_ERROR` is the one failure that also
* carries it — as a field-to-message map rather than a payload.
*/
data?: T;
/** Only on failure. This is what the app switches on. */
errorCode?: string;
timestamp: string;
}
/* ── Registration ─────────────────────────────────────────────────────────── */
export interface StartRegistrationRequest {
orgId: string;
mobileNumber: string;
}
export interface StartRegistrationResult {
otpStatus: string;
customerExists: boolean;
accountsLinked: number;
}
export interface VerifyOtpRequest {
orgId: string;
mobileNumber: string;
otp: string;
deviceId: string;
}
export interface VerifyOtpResult {
verified: boolean;
mpinSetupRequired: boolean;
mmidsAssigned: number;
/** Single use, valid five minutes. Authorises MPIN creation. */
setupToken: string;
}
/* ── Statement ────────────────────────────────────────────────────────────── */
/**
* Query for `GET /accounts/statement`.
*
* `fromDate` and `toDate` go together or not at all — the bank answers
* `INVALID_DATE_RANGE` for exactly one of the two. With neither, it applies
* its own recent-activity window, whose span it does not publish.
*/
export interface CbsStatementQuery {
/** The customer's own account, unmasked, as `GET /accounts` returned it. */
accountNumber: string;
/** `yyyy-MM-dd`. Only ever sent alongside `toDate`. */
fromDate?: string;
/** `yyyy-MM-dd`. Only ever sent alongside `fromDate`. */
toDate?: string;
/** Zero-based. Defaults to 0 server-side. */
page?: number;
/** Defaults to 10 server-side and is capped there at 100. */
size?: number;
}
/**
* One ledger entry.
*
* Note what is still *not* here, because the screen rendering these used to
* show both from local fixtures: no settlement status (nothing is "pending")
* and no spending category.
*
* The counterparty *is* here now — the bank added the five `counterparty*`
* fields alongside a `transactionDate` that carries a time. Both are
* best-effort and all five can be null together, so nothing below may be read
* without a null check. See `hasCounterparty` underneath.
*/
export interface CbsStatementTransaction {
/**
* `yyyy-MM-ddTHH:mm:ss`, local — a date *and* a time.
*
* This used to be a bare `yyyy-MM-dd`, which is why anything grouping or
* comparing these by string equality has to take the date part off first:
* whole-value equality now buckets every entry on its own. `statementDay`
* in `utils/statement.ts` is the one place that split lives.
*/
transactionDate: string;
/** The bank's own description of the entry. */
narration: string;
/** Unique per entry, and the only stable key these have. */
referenceNumber: string;
/**
* NEFT, IMPS, UPI, ATM_WDL, POS, CASH_DEPOSIT, CLEARING_INWARD,
* CLEARING_OUTWARD and others — deliberately typed as `string`, because the
* bank does not treat this as a closed set and a union here would turn a new
* channel into a client bug.
*/
transactionType: string;
/** Direction. The amount itself is always positive. */
drCr: 'DR' | 'CR';
/** Always positive; `drCr` carries the sign. */
amount: number;
/** The account's balance immediately after this entry. */
balanceAfter: number;
/**
* Who the money moved to or from.
*
* All four are null together for `ATM_WDL`, `POS` and `CASH_DEPOSIT` — those
* have no other party's account on the far end, so an absent counterparty is
* the correct answer for them rather than missing data.
*/
counterpartyName: string | null;
counterpartyAccountNumber: string | null;
counterpartyIfsc: string | null;
counterpartyBankName: string | null;
/**
* Set only when `transactionType` is `UPI`. Every other channel moved money
* by account number, so there is no VPA to report.
*/
counterpartyVpa: string | null;
}
/** One page of `GET /accounts/statement`. */
export interface CbsStatementPage {
content: CbsStatementTransaction[];
page: number;
size: number;
totalElements: number;
totalPages: number;
hasMore: boolean;
}
/**
* Query for `GET /accounts/statement/export`.
*
* The same range `CbsStatementQuery` describes, minus paging: the whole range
* becomes one document, so `page` and `size` are not accepted. The bank reuses
* the cached fetch behind `GET /accounts/statement`, which is why exporting a
* range just viewed on screen costs nothing extra upstream.
*/
export type CbsStatementExportQuery = Omit<CbsStatementQuery, 'page' | 'size'>;
/**
* A PDF the bank returned.
*
* These two endpoints are the only ones that do not answer the JSON envelope,
* so nothing about them flows through `apiClient` — see
* `services/files/pdfDownload.ts`. The file is written to the app's own
* storage, which needs no runtime permission on either platform and is cleared
* with the app.
*/
export interface CbsPdfDocument {
/** Absolute path on the device. Not a URL, and not readable by other apps. */
path: string;
/** From `Content-Disposition`, falling back to one built from the account. */
fileName: string;
}
export interface PublicKeyResult {
algorithm: string;
/** Always `RSA/ECB/OAEPWithSHA-256AndMGF1Padding`. */
transformation: string;
/** Base64 DER, SPKI. */
publicKey: string;
}
export interface CreateMpinRequest {
setupToken: string;
deviceId: string;
encryptedMpin: string;
}
export interface CreateMpinResult {
mpinStatus: string;
registrationComplete: boolean;
}
/* ── Session ──────────────────────────────────────────────────────────────── */
export interface LoginRequest {
orgId: string;
mobileNumber: string;
deviceId: string;
encryptedMpin: string;
/**
* Only present on the sign-in that follows a device re-bind.
*
* verify-otp answers `mpinSetupRequired: false` for a customer who already
* has an MPIN and hands back a setup token anyway. There is no MPIN to
* create, so the token is spent here instead: it is what tells the server
* this sign-in is the far end of an OTP that was just verified, rather than
* an ordinary one. Omitted entirely on every other sign-in.
*/
setupToken?: string;
}
export interface LoginResult {
sessionToken: string;
/** Absolute expiry. Never extended, however active the user is. */
expiresAt: string;
idleTimeoutSeconds: number;
fullName: string;
}
export interface SessionIdentity {
orgId: string;
mobileNumber: string;
fullName: string;
deviceId: string;
expiresAt: string;
}
/* ── Accounts ─────────────────────────────────────────────────────────────── */
/**
* Account types `GET /accounts` returns.
*
* Deposit accounts are in here as well as operative ones: a profile commonly
* holds an FD or RD alongside its savings account, and they come back from the
* same call. They cannot take part in IMPS, which is why the bank allocates no
* MMID against them.
*/
export type CbsAccountType =
| 'SAVINGS'
| 'CURRENT'
| 'FIXED_DEPOSIT'
| 'RECURRING_DEPOSIT';
export type CbsAccountStatus = 'ACTIVE' | 'DORMANT' | 'BLOCKED' | 'CLOSED';
/**
* An account as `GET /accounts` returns it.
*
* Note what is *not* here: there is no balance field, because no endpoint in
* the CBS API returns one. Anything showing a balance is not sourced from here.
*
* The account number is unmasked — this is the only endpoint that returns one —
* so it must never be written to disk, logged, or rendered in full where a
* masked form would do.
*/
export interface CbsAccount {
accountNumber: string;
accountType: CbsAccountType;
status: CbsAccountStatus;
ifsc: string;
/** Null until the bank has allocated one. */
mmid: string | null;
}
/**
* One deposit as `GET /accounts/deposits/detail` returns it.
*
* Looked up by the deposit's own account number, which only `GET /accounts`
* hands out in full — a masked or truncated number is not a valid lookup, so
* this can only be asked for about an account the profile already listed.
*
* The nullable fields are the ones that apply to a single kind of deposit:
* `interestRate` and `tenureMonths` describe the fixed-deposit side,
* `installmentAmount` and `installmentsPaid` the recurring side. The bank
* guarantees neither set, so nothing here may be read without a null check.
*/
export interface CbsDepositDetail {
accountNumber: string;
productName: string;
depositType: Extract<CbsAccountType, 'FIXED_DEPOSIT' | 'RECURRING_DEPOSIT'>;
status: string;
balance: number;
interestRate: number | null;
tenureMonths: number | null;
installmentAmount: number | null;
installmentsPaid: number | null;
/** `yyyy-MM-dd`. */
maturityDate: string | null;
/** `yyyy-MM-dd`. */
openedDate: string | null;
}
export interface MmidAccount {
/** No endpoint ever returns a full account number. */
accountNumberMasked: string;
accountType: CbsAccountType;
status: CbsAccountStatus;
ifsc: string;
/** Null until the bank has allocated one. */
mmid: string | null;
}
export interface MmidAccountsResult {
mobileNumber: string;
accounts: MmidAccount[];
}
/* ── Payees ───────────────────────────────────────────────────────────────── */
/** P2A routes by account number and IFSC; P2P by mobile number and MMID. */
export type BeneficiaryMode = 'P2A' | 'P2P';
/**
* How a payee is addressed. Exactly one pair is valid per mode — account
* number + IFSC for P2A, mobile + MMID for P2P — and sending the wrong pair
* for the chosen mode is answered with `BENEFICIARY_DETAILS_INCOMPLETE`.
*
* `name` is required by both endpoints. It is what the customer believes the
* payee is called; the bank answers with its own resolution, which is the one
* that gets stored and shown.
*/
export interface BeneficiaryPayee {
accountNumber?: string;
ifsc?: string;
mobileNumber?: string;
mmid?: string;
/** Up to 120 characters. */
name: string;
}
/**
* `POST /beneficiaries/validate`.
*
* Payer and payee are separate objects by design: the two carry the same field
* names, and flattening them is how an account number ends up on the wrong
* side of a transfer. The payer must be one of the caller's own accounts —
* anything else answers `ACCOUNT_NOT_FOUND`, exactly as a typo would, so the
* endpoint cannot be used to discover whether someone else's account exists.
*/
export interface BeneficiaryValidateRequest {
mode: BeneficiaryMode;
payer: { accountNumber: string };
payee: BeneficiaryPayee;
}
/**
* IMPS's own answer to a validate call, passed through verbatim.
*
* Note this is *not* a beneficiary: nothing has been saved, and the name comes
* back masked. A 200 does not mean the payee was verified — that is what
* `result` says — so callers must read `result` rather than trusting the
* status code.
*/
export interface BeneficiaryValidateResult {
txnId: string;
refId: string;
/** IMPS's masked rendering of the account holder's name. */
maskedName: string;
/** `SUCCESS` when the payee was verified. */
result: string;
npciMessageId: string;
timestamp: string;
riskScore: string;
accountType: string;
accountHolderType: string;
responseCode: string;
errCode: string;
errorDiscrp: string;
}
/**
* `POST /beneficiaries`.
*
* Resolved through the bank's own CBS lookup, which is a different path from
* the IMPS call `validate` makes — so the two can disagree, and the name that
* gets stored is CBS's. The nickname lives here and not on validate, because
* until a payee is saved there is nothing to label.
*/
export interface BeneficiarySaveRequest {
mode: BeneficiaryMode;
beneficiary: BeneficiaryPayee;
/** Up to 60 characters. */
nickname?: string;
}
/** True when IMPS verified the payee. A 200 alone does not mean it did. */
export const isPayeeVerified = (result: BeneficiaryValidateResult) =>
result.result?.toUpperCase() === 'SUCCESS';
export interface CbsBeneficiary {
/** Null from `validate`, which resolves the payee without saving it. */
beneficiaryId: string | null;
mode: BeneficiaryMode;
/** The bank's answer. Sending a name has no effect. */
name: string;
bankName: string;
nickname: string | null;
accountNumberMasked: string;
ifsc: string | null;
mobileNumber: string | null;
mmid: string | null;
status: string | null;
}
/* ── Positive Pay ─────────────────────────────────────────────────────────── */
/**
* A cheque declared to the bank before it is presented, so a tampered one is
* refused at clearing.
*
* `accountNumber` is the full number, not the masked form every other payload
* carries — and no endpoint returns it, so it can only come from the customer
* typing it. The server checks it belongs to the caller and answers a
* mismatch with the same `ACCOUNT_NOT_FOUND` as a typo, deliberately, so this
* cannot be used to discover whether someone else's account exists.
*/
export interface PositivePayRequest {
/** 5–20 digits. Must be one of the caller's own accounts. */
accountNumber: string;
/** Exactly 6 digits, read from the cheque's MICR band. */
chequeNumber: string;
/** `yyyy-MM-dd`. */
chequeDate: string;
/** At least 1.00, at most two decimal places. */
amount: number;
/** Up to 100 characters, exactly as written on the cheque. */
payeeName: string;
}
export interface PositivePayResult {
/**
* The bank's handle for this registration, and the only record the customer
* keeps — nothing is stored on this side to look it up again.
*/
referenceId: string;
status: string;
accountNumberMasked: string;
chequeNumber: string;
chequeDate: string;
amount: number;
payeeName: string;
}
/* ── Cheques ──────────────────────────────────────────────────────────────── */
/**
* The lookup both `POST /positive-pay/verify` and `POST /check/verify` take.
*
* One request shape, one core-banking lookup behind it — the two endpoints
* differ only in which answer counts as good news, so there is nothing to
* distinguish here.
*/
export interface ChequeLookupRequest {
/** 5–20 digits. Must be one of the caller's own accounts. */
accountNumber: string;
/** Exactly 6 digits, read from the cheque's MICR band. */
chequeNumber: string;
}
/**
* A cheque as `POST /check/verify` reports it.
*
* Only a cheque declared under `POST /positive-pay` exists to this endpoint —
* the bank keeps no separate issuance ledger — so an ordinary unregistered
* cheque answers `CHEQUE_NOT_FOUND` rather than turning up with some other
* status. That is also why there is no way to stop one that was never
* registered.
*/
export interface ChequeDetail {
chequeNumber: string;
accountNumberMasked: string;
/** `REGISTERED` until a stop takes effect, then `STOPPED`. */
status: 'REGISTERED' | 'STOPPED' | string;
payeeName: string;
amount: number;
/** `yyyy-MM-dd`. */
chequeDate: string;
}
/** Why the customer is stopping the cheque. The bank accepts only these. */
export const STOP_PAYMENT_REASONS = [
'LOST',
'STOLEN',
'DISPUTE',
'SIGNATURE_MISMATCH',
'ACCOUNT_CLOSURE',
'DUPLICATE_ISSUED',
'OTHER',
] as const;
export type StopPaymentReason = (typeof STOP_PAYMENT_REASONS)[number];
export interface StopPaymentRequest extends ChequeLookupRequest {
reason: StopPaymentReason;
/** Up to 200 characters. Optional, and mainly there to qualify `OTHER`. */
remark?: string;
}
/**
* The outcome of a stop, and nothing more — the endpoint deliberately does not
* repeat the cheque's detail back, because `POST /check/verify` already showed
* the customer what they were confirming. As with Positive Pay, nothing is
* stored on this side: `referenceId` is the handle they quote at a branch.
*/
export interface StopPaymentResult {
referenceId: string;
status: string;
accountNumberMasked: string;
chequeNumber: string;
reason: StopPaymentReason | string;
}
/* ── Cheque books ─────────────────────────────────────────────────────────── */
/**
* The leaf counts the bank issues. A closed set sent as text, the same shape
* as `reason` on a stop payment — the server takes `"50"`, not `50`, and
* answers VALIDATION_ERROR for anything outside these three.
*/
export const CHEQUE_BOOK_LEAF_COUNTS = ['25', '50', '100'] as const;
export type ChequeBookLeafCount = (typeof CHEQUE_BOOK_LEAF_COUNTS)[number];
export interface ChequeBookRequest {
/** 5–20 digits. Must be one of the caller's own accounts. */
accountNumber: string;
/** Sent as text — see `CHEQUE_BOOK_LEAF_COUNTS`. */
leafCount: ChequeBookLeafCount;
}
/**
* A fee quote for one account and leaf count, from `POST /cheque-book/charges`.
*
* Note `leafCount` comes back as a number though it was sent as a string —
* the asymmetry is the server's, not a mistake here.
*/
export interface ChequeBookCharges {
accountNumberMasked: string;
leafCount: number;
/** In rupees. Zero is a real answer — a 25-leaf book is free. */
charge: number;
}
/**
* A cheque book as `POST /cheque-book` issues it.
*
* `charge` is what the bank actually applied, echoed back rather than
* re-derived, so it always agrees with the quote the customer accepted.
* Serial numbers are per-account and never reissued: a second book starts
* exactly where the previous one ended.
*/
export interface ChequeBookIssued {
/** The bank's handle, and what the customer quotes at a branch. */
referenceId: string;
/** `ISSUED` — the call is synchronous, so there is no pending state. */
status: 'ISSUED' | string;
accountNumberMasked: string;
leafCount: number;
charge: number;
/** 6 digits, as printed in the MICR band. */
startingChequeNumber: string;
endingChequeNumber: string;
/** `yyyy-MM-dd`. */
issueDate: string;
}
/**
* One row of `GET /cheque-book`. Deliberately not `ChequeBookIssued`: the
* history carries neither `status` nor `accountNumberMasked`, because the
* caller already named the account and every book listed is issued.
*/
export interface ChequeBookHistoryEntry {
referenceId: string;
leafCount: number;
charge: number;
startingChequeNumber: string;
endingChequeNumber: string;
issueDate: string;
}
/* ── Feature configuration ────────────────────────────────────────────────── */
/**
* Server-driven feature switches.
*
* `GET /features/config` — callable with or without a session. Unauthenticated
* it returns `overrides` only, which is what lets the sign-in screen itself be
* gated; with a session it also returns `roleOverrides` for the caller's role
* and tenant.
*
* Keys are feature paths as declared in `FeatureKey` (`'loans.apply.submit'`).
* Unknown keys are dropped by the client rather than trusted, so the server can
* ship a key ahead of the app release that understands it.
*/
export interface FeatureConfigResult {
/**
* Monotonic version of the published configuration. Logged on apply so a
* support case can be tied to the exact config a handset was running.
*/
version: number;
/** Org-wide switches. Applied to the `remote` layer. */
overrides: Record<string, boolean>;
/** Switches for this session's role and tenant. Applied to the `tenant` layer. */
roleOverrides?: Record<string, boolean>;
}