diff --git a/README.md b/README.md index 34a8019..f52854f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Demo fallbacks deliberately include failure states: the data-entry fallback cont - **`/officer/rules`** — every trip-wire with its outcome, including the ones that were *skipped* and why. This is the screen that shows the system refusing to fire on stale data. - **`/officer/feeds`** — provenance. Mandi rows carry a source: `Agmarknet`, or an amber **Hand** chip for a price somebody typed. By the time a price reaches a feature the two are indistinguishable, so the distinction has to be visible here. - **`/officer/data-entry`** — upload, preview, commit. Accepted, rejected and overwriting rows stay in three separate groups. +- **`/officer/integration`** — core banking / LMS. The mapping table is the argument: each lender's core names the same eleven fields differently, and a per-bank mapping is what absorbs that. Simulated adapters are badged as simulated on every screen they appear on, and unmapped vendor columns are shown being dropped at the boundary. - **`/nabard`** — the India choropleth and the zone → state → district drill-down. States with no portfolio are drawn in a neutral grey that is nowhere on the stress ramp, so absence is never read as health. --- diff --git a/src/App.tsx b/src/App.tsx index 59b3e9e..41f72aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { VisitsPage } from './features/officer/VisitsPage' import { RulesPage } from './features/officer/RulesPage' import { DataFeedsPage } from './features/officer/DataFeedsPage' import { DataEntryPage } from './features/officer/DataEntryPage' +import { IntegrationPage } from './features/officer/IntegrationPage' import { ReportsPage } from './features/officer/ReportsPage' import { SettingsPage } from './features/officer/SettingsPage' @@ -100,6 +101,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/features/officer/IntegrationPage.tsx b/src/features/officer/IntegrationPage.tsx new file mode 100644 index 0000000..6985bd8 --- /dev/null +++ b/src/features/officer/IntegrationPage.tsx @@ -0,0 +1,772 @@ +import { useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { errMsg, get, post, put } from '../../lib/api' +import type { + CanonicalColumns, + ConnectorCatalogEntry, + ConnectorProfileRow, + ConnectorSyncResponse, + ConnectorSyncRun, + ConnectorTestResult, +} from '../../lib/api' +import { + Card, + Empty, + Field, + GhostBtn, + KpiTile, + Modal, + PrimaryBtn, + StatusPill, + inputCls, +} from '../../components/ui/bits' +import { CANONICAL, demoCatalog, demoProfiles, demoRuns } from '../../lib/demo/connectors' + +/** + * Core banking / LMS integration. + * + *

What this screen is claiming. Not that UdyamLinc is integrated with + * anyone's core — it is not, and saying so on a slide would be the kind of claim + * that ends a pilot conversation badly. What it shows is the seam: an adapter per + * transport, a mapping per bank, and one validator that a nightly extract and a + * hand-made spreadsheet both go through. Onboarding a lender is then a + * configuration exercise, and this screen is where it happens. + * + *

Three things are deliberately hard to miss. Simulated adapters carry a badge + * everywhere they appear, including in the run history, because a demo mistaken + * for a live feed is the failure mode that matters here. Unmapped vendor columns + * are counted and named as dropped at the boundary, which is how the + * no-PII promise is kept structurally rather than by anyone remembering. And + * auto-commit is off, so a sync stages into the same preview an upload does and + * waits for a person. + */ + +type Tab = 'connections' | 'mapping' | 'history' + +/** + * Whether a column name carries borrower identity — the same test the backend + * applies, mirrored so the reason appears next to the field instead of arriving + * as an error after a round trip. + * + *

Matched on the whole name and on each part, splitting separators and + * camelCase alike: a core's real column is `CUST_NAME` and an in-house LMS ships + * `borrowerMobile`, neither of which an exact-match list catches. + */ +function isIdentifying(column: string, neverStored: string[]): boolean { + const name = column ?? '' + if (neverStored.includes(name.replace(/[_\s\-.]/g, '').toLowerCase())) return true + return name + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase() + .split(/[_\s\-.]+/) + .some((part) => part && neverStored.includes(part)) +} + +export function IntegrationPage() { + const queryClient = useQueryClient() + const [tab, setTab] = useState('connections') + const [selectedCode, setSelectedCode] = useState(null) + const [testResult, setTestResult] = useState(null) + const [syncResult, setSyncResult] = useState(null) + const [notice, setNotice] = useState(null) + const [error, setError] = useState(null) + + const profilesQ = useQuery({ + queryKey: ['connector-profiles'], + queryFn: () => get('/admin/connectors/profiles'), + }) + const catalogQ = useQuery({ + queryKey: ['connector-catalog'], + queryFn: () => get('/admin/connectors/catalog'), + }) + const runsQ = useQuery({ + queryKey: ['connector-runs'], + queryFn: () => get('/admin/connectors/runs', { limit: 30 }), + }) + const canonicalQ = useQuery({ + queryKey: ['connector-canonical'], + queryFn: () => get('/admin/connectors/canonical-columns'), + }) + + const demo = profilesQ.isError || catalogQ.isError + const profiles = profilesQ.data ?? demoProfiles() + const catalog = catalogQ.data ?? demoCatalog() + const runs = runsQ.data ?? demoRuns() + const canonical = canonicalQ.data ?? CANONICAL + + const selected = useMemo( + () => profiles.find((p) => p.code === selectedCode) ?? profiles[0] ?? null, + [profiles, selectedCode], + ) + + const testM = useMutation({ + mutationFn: (code: string) => post(`/admin/connectors/profiles/${code}/test`), + onSuccess: (data) => { + setTestResult(data) + setError(null) + }, + onError: (e) => setError(errMsg(e)), + }) + + const syncM = useMutation({ + mutationFn: (code: string) => post(`/admin/connectors/profiles/${code}/sync`), + onSuccess: async (data) => { + setSyncResult(data) + setError(null) + setNotice(data.run.message) + await queryClient.invalidateQueries({ queryKey: ['connector-runs'] }) + await queryClient.invalidateQueries({ queryKey: ['connector-profiles'] }) + }, + onError: (e) => setError(errMsg(e)), + }) + + const saveM = useMutation({ + mutationFn: (vars: { code: string; body: Record }) => + put(`/admin/connectors/profiles/${vars.code}`, vars.body), + onSuccess: async () => { + setNotice('Mapping saved') + setError(null) + await queryClient.invalidateQueries({ queryKey: ['connector-profiles'] }) + }, + onError: (e) => setError(errMsg(e)), + }) + + const connected = profiles.filter((p) => p.active).length + const live = profiles.filter((p) => p.active && !p.simulated).length + const failing = profiles.filter((p) => p.lastStatus === 'FAILED').length + const neverRun = profiles.filter((p) => p.lastStatus === 'NEVER_RUN').length + + return ( +

+
+
+

Core banking integration

+

+ The loan book is the one input no public feed can supply, and no two lenders expose it the + same way. What is built here is the seam — an adapter per transport, a mapping per bank — + and every route lands in the same validation an uploaded spreadsheet goes through. +

+
+ {demo && } +
+ +
+ + + + +
+ + {/* The honest sentence, stated by the product rather than left to a slide. */} +
+

+ What is real and what is not. The file-drop adapter reads an actual extract from a + directory a bank delivers to, and its rows are validated, staged and written like any other. + The Finacle, FLEXCUBE, BaNCS and in-house-LMS adapters are simulated: they generate an + extract in each core's own column dialect so the mapping layer can be exercised end to end. + A live link to any of them is agreed per bank during onboarding — the schema conversation is + the work, and this screen is what that conversation configures. +

+
+ +
+ {( + [ + ['connections', 'Connections'], + ['mapping', 'Field mapping'], + ['history', 'Sync history'], + ] as [Tab, string][] + ).map(([key, label]) => ( + + ))} +
+ + {notice && !error &&

{notice}

} + {error &&

{error}

} + + {tab === 'connections' && ( + { + setSelectedCode(code) + setSyncResult(null) + testM.mutate(code) + }} + onSync={(code) => { + setSelectedCode(code) + setTestResult(null) + syncM.mutate(code) + }} + onConfigure={(code) => { + setSelectedCode(code) + setTab('mapping') + }} + /> + )} + + {tab === 'mapping' && ( + selected && saveM.mutate({ code: selected.code, body: { mapping } })} + /> + )} + + {tab === 'history' && } + + {testResult && setTestResult(null)} />} + {syncResult && setSyncResult(null)} />} +
+ ) +} + +// ── connections ─────────────────────────────────────────────────── + +function ConnectionsTab({ + profiles, + catalog, + busyCode, + onTest, + onSync, + onConfigure, +}: { + profiles: ConnectorProfileRow[] + catalog: ConnectorCatalogEntry[] + busyCode: string | null | undefined + onTest: (code: string) => void + onSync: (code: string) => void + onConfigure: (code: string) => void +}) { + return ( +
+ + {profiles.length === 0 ? ( + + ) : ( +
+ {profiles.map((p) => ( +
+
+
+ {p.displayName} + +
+
+ {p.code} · {p.vendorName} · {p.transport} +
+
+ {p.endpoint ?? 'no endpoint set'} +
+ {p.activationNote && p.simulated && ( +
+ To make live: {p.activationNote} +
+ )} +
+ +
+ +
+ {p.scheduleCron ? `cron ${p.scheduleCron}` : 'not scheduled'} +
+
+ {p.autoCommit ? 'writes automatically' : 'stages for review'} +
+
+ +
+ onConfigure(p.code)}>Mapping + onTest(p.code)} disabled={busyCode === p.code}> + Test + + onSync(p.code)} disabled={busyCode === p.code}> + {busyCode === p.code ? 'Working…' : 'Run sync'} + +
+
+ ))} +
+ )} +
+ + +
+ {catalog.map((c) => ( +
+
+ {c.displayName} + +
+
+ {c.vendor} · {c.transport} +
+

{c.activationNote}

+ {c.nativeColumns.length > 0 && ( +

+ {c.nativeColumns.slice(0, 6).join(', ')} + {c.nativeColumns.length > 6 && ` +${c.nativeColumns.length - 6} more`} +

+ )} +
+ ))} +
+
+
+ ) +} + +function SimulatedBadge({ simulated }: { simulated: boolean }) { + return simulated ? ( + SIMULATED + ) : ( + READS REAL SOURCE + ) +} + +function LastStatus({ profile }: { profile: ConnectorProfileRow }) { + const status = profile.lastStatus ?? 'NEVER_RUN' + const cls = + status === 'SUCCESS' + ? 'pill-ok' + : status === 'FAILED' + ? 'pill-bad' + : status === 'PARTIAL' + ? 'pill-pend' + : 'pill-mut' + return ( +
+ {status.replace(/_/g, ' ')} +
+ {profile.lastSyncAt ? `last ${profile.lastSyncAt.replace('T', ' ').slice(0, 16)}` : 'never run'} +
+
+ ) +} + +// ── field mapping ───────────────────────────────────────────────── + +/** + * The screen that absorbs "every bank is different". + * + *

Editing is local until Save, and Save is refused by the backend if a + * required column is unmapped or an identifying one is mapped in — checked here + * too, so the reason appears next to the field rather than as a toast after a + * round trip. + */ +function MappingTab({ + profiles, + selected, + canonical, + onSelect, + saving, + onSave, +}: { + profiles: ConnectorProfileRow[] + selected: ConnectorProfileRow | null + canonical: CanonicalColumns + onSelect: (code: string) => void + saving: boolean + onSave: (mapping: Record) => void +}) { + const [draft, setDraft] = useState | null>(null) + const [draftFor, setDraftFor] = useState(null) + + if (!selected) return + + // Reset the draft when the selected profile changes, without an effect. + if (draftFor !== selected.code) { + setDraftFor(selected.code) + setDraft({ ...selected.mapping }) + return null + } + + const mapping = draft ?? selected.mapping + const missing = canonical.required.filter((c) => !mapping[c] || !mapping[c].trim()) + const identifying = Object.values(mapping).filter((v) => v && isIdentifying(v, canonical.neverStored)) + const mappedSources = new Set( + Object.values(mapping) + .filter(Boolean) + .map((v) => v.replace(/[_\s-]/g, '').toLowerCase()), + ) + const dropped = selected.sourceColumns.filter( + (c) => !mappedSources.has(c.replace(/[_\s-]/g, '').toLowerCase()), + ) + + return ( +

+ +
+
+ + + +
+ setDraft({ ...selected.mapping })} disabled={saving}> + Reset + + onSave(mapping)} + disabled={saving || missing.length > 0 || identifying.length > 0} + > + {saving ? 'Saving…' : 'Save mapping'} + +
+
+ +

+ Each row says which of {selected.vendorName}'s columns carries one field of the + loan-book template. A column left blank is simply absent from that bank's export. Anything + the bank sends that no row points at is dropped at the boundary and never stored — + which is how borrower names and mobile numbers stay out of a system that promised not to + hold them. +

+ + {missing.length > 0 && ( +

+ {missing.join(', ')} must be mapped — without them no row can be validated, and a nightly + run would reject the whole file. +

+ )} + {identifying.length > 0 && ( +

+ {identifying.join(', ')} carries borrower identity and cannot be mapped in. +

+ )} + +
+ + + + + + + + + + {canonical.columns.map((column) => { + const required = canonical.required.includes(column) + const value = mapping[column] ?? '' + const bad = required && !value.trim() + return ( + + + + + + ) + })} + +
Loan-book field{selected.vendorName} columnRequired
{column} + {selected.sourceColumns.length > 0 ? ( + + ) : ( + setDraft({ ...mapping, [column]: e.target.value })} + /> + )} + + {required ? required : 'optional'} +
+
+
+
+ + {selected.sourceColumns.length > 0 && ( + +
+ {dropped.length === 0 ? ( +

+ Every column this export carries is mapped. Nothing is being discarded. +

+ ) : ( + <> +

+ {selected.vendorName} sends these and nothing points at them, so they are discarded + before validation and never reach storage. A real bank export carries more of these + than it does useful columns. +

+
+ {dropped.map((c) => { + const identity = isIdentifying(c, canonical.neverStored) + return ( + + {c} + {identity && ' · never stored'} + + ) + })} +
+ + )} +
+
+ )} +
+ ) +} + +// ── history ─────────────────────────────────────────────────────── + +function HistoryTab({ runs }: { runs: ConnectorSyncRun[] }) { + return ( + + {runs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + + {runs.map((r) => ( + + + + + + + + + + + + ))} + +
StartedConnectionAs atFetchedAcceptedRejectedDropped colsStatusWhat happened
+ {r.startedAt.replace('T', ' ').slice(0, 16)} + +
{r.profileCode}
+
+ {r.trigger.toLowerCase()} · {r.triggeredBy} +
+
{r.asOfDate ?? '—'}{r.rowsFetched ?? '—'} + {r.rowsAccepted ?? '—'} + + {r.rowsRejected ?? '—'} + + {r.columnsDropped ?? '—'} + + + {r.status} + + {r.simulated && SIM} + {!r.committed && r.batchId != null && ( +
staged, not written
+ )} +
{r.message}
+
+ )} +
+ ) +} + +// ── modals ──────────────────────────────────────────────────────── + +function TestModal({ result, onClose }: { result: ConnectorTestResult; onClose: () => void }) { + return ( + +
+
+ + {result.reachable ? 'Reached the source' : 'Could not reach the source'} + + + + {result.vendor} · {result.transport} + +
+ + {!result.reachable &&

{result.message}

} + + {result.reachable && ( + <> +

+ {result.label} — {result.rowsAvailable} rows available. Nothing was staged or written. +

+ + {result.missingRequired && result.missingRequired.length > 0 && ( +

+ Mapping incomplete: {result.missingRequired.join(', ')}. Every row would be rejected. +

+ )} + +
+
+
+ As the bank sends it +
+ +
+
+
+ After mapping +
+ +
+
+ + {result.droppedColumns && result.droppedColumns.length > 0 && ( +

+ {result.droppedColumns.length} columns dropped at the boundary:{' '} + {result.droppedColumns.join(', ')} +

+ )} + + )} +
+
+ ) +} + +function RowTable({ row }: { row: Record }) { + const entries = Object.entries(row) + if (entries.length === 0) return

Nothing returned.

+ return ( +
+ {entries.map(([k, v]) => ( +
+ {k} + {v ?? '—'} +
+ ))} +
+ ) +} + +function SyncModal({ result, onClose }: { result: ConnectorSyncResponse; onClose: () => void }) { + const run = result.run + return ( + +
+
+ + {run.status} + + + + {run.durationMs}ms · as at {run.asOfDate} + +
+ +

{run.message}

+ +
+ + + + + +
+ + {run.batchId != null && !run.committed && ( +

+ The rows are staged, not written. This connection has auto-commit off, so the batch + is waiting in the same preview an uploaded file produces — open{' '} + Data entry → batch {run.batchId} to see every accepted and rejected row before + committing it. +

+ )} + + {result.droppedColumns.length > 0 && ( +

+ Dropped at the boundary:{' '} + {result.droppedColumns.join(', ')} +

+ )} + + {result.preview && result.preview.rejected.length > 0 && ( +
+
+ Rejected rows +
+
+ {result.preview.rejected.slice(0, 40).map((line) => ( +
+ row {line.rowNumber} + + {line.rejectReason} + + {line.rejectDetail} +
+ ))} +
+
+ )} +
+
+ ) +} + +function Count({ label, value, tone }: { label: string; value: number | null; tone: string }) { + return ( +
+
{value ?? '—'}
+
{label}
+
+ ) +} diff --git a/src/features/officer/OfficerShell.tsx b/src/features/officer/OfficerShell.tsx index a9e63ef..1bfcf64 100644 --- a/src/features/officer/OfficerShell.tsx +++ b/src/features/officer/OfficerShell.tsx @@ -26,6 +26,7 @@ const NAV: { label: string; to: string; end?: boolean }[] = [ { label: 'Trip-wires', to: '/officer/rules' }, { label: 'Data feeds', to: '/officer/feeds' }, { label: 'Data entry', to: '/officer/data-entry' }, + { label: 'Core banking', to: '/officer/integration' }, { label: 'Reports', to: '/officer/reports' }, { label: 'Settings', to: '/officer/settings' }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index 9a0ab2f..38f75e7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -467,6 +467,98 @@ export interface ManualEntryPreview { warnings: ManualEntryLine[] } +// ── Core banking / LMS integration ─────────────────────────────── +// The loan book is the training label, and no two lenders expose it the same +// way. What is built is the seam: an adapter per transport, a mapping per bank, +// and one validator — the same one an uploaded spreadsheet goes through. + +/** An adapter the platform can run. `simulated` is rendered, never hidden. */ +export interface ConnectorCatalogEntry { + vendor: string + displayName: string + transport: 'FILE' | 'REST' | 'JDBC' + simulated: boolean + nativeColumns: string[] + /** What a bank would have to do to make this one live. */ + activationNote: string + suggestedMapping: Record +} + +/** One bank's configured link — which adapter, pointed where, whose column means what. */ +export interface ConnectorProfileRow { + code: string + displayName: string + vendor: string + vendorName: string + transport: string | null + simulated: boolean + activationNote: string | null + institutionCode: string | null + endpoint: string | null + scheduleCron: string | null + autoCommit: boolean + active: boolean + lastSyncAt: string | null + lastStatus: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'NEVER_RUN' | null + /** { canonical_column: vendor_column }. Unmapped means dropped at the boundary. */ + mapping: Record + sourceColumns: string[] +} + +export interface ConnectorSyncRun { + id: number + profileCode: string + vendor: string | null + institutionCode: string | null + trigger: 'MANUAL' | 'SCHEDULED' + triggeredBy: string + status: 'SUCCESS' | 'PARTIAL' | 'FAILED' + /** Stamped per run, not read from the profile — old runs must not turn real. */ + simulated: boolean + asOfDate: string | null + rowsFetched: number | null + rowsAccepted: number | null + rowsRejected: number | null + rowsOverwriting: number | null + columnsDropped: number | null + batchId: number | null + committed: boolean + durationMs: number | null + startedAt: string + message: string | null +} + +/** What a connection test found, without staging anything. */ +export interface ConnectorTestResult { + profileCode: string + vendor: string + simulated: boolean + transport: string + reachable: boolean + label?: string + message?: string + rowsAvailable?: number + sourceColumns: string[] + sampleRow?: Record + mappedSampleRow?: Record + droppedColumns?: string[] + missingRequired?: string[] +} + +export interface ConnectorSyncResponse { + run: ConnectorSyncRun + droppedColumns: string[] + extractLabel: string | null + preview?: ManualEntryPreview +} + +/** The template a mapping targets, and the columns that may never be mapped in. */ +export interface CanonicalColumns { + columns: string[] + required: string[] + neverStored: string[] +} + /** Multipart, so it bypasses the JSON helpers. */ export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_RECORD') { const form = new FormData() diff --git a/src/lib/demo/connectors.ts b/src/lib/demo/connectors.ts new file mode 100644 index 0000000..3784c94 --- /dev/null +++ b/src/lib/demo/connectors.ts @@ -0,0 +1,265 @@ +import type { + CanonicalColumns, + ConnectorCatalogEntry, + ConnectorProfileRow, + ConnectorSyncRun, +} from '../api' + +/** + * Demo fallback for the integration screen. + * + *

Mirrors the backend's own catalogue rather than inventing a tidier one, and + * keeps the same two things visible that the live screen shows: which adapters + * are simulated, and that a real export ships borrower columns which are dropped + * at the boundary rather than stored. + * + *

One of the three profiles has never run and one last failed. A demo where + * every connector is green would make the run history look decorative, when + * reading it is the entire reason a field officer can trust a DPD number. + */ + +export const CANONICAL: CanonicalColumns = { + columns: [ + 'enterprise_ref', + 'loan_account_ref', + 'product', + 'sanctioned_amount', + 'outstanding', + 'emi_amount', + 'due_date', + 'last_paid_date', + 'dpd', + 'bucket', + 'closed', + 'as_of_date', + ], + required: ['enterprise_ref', 'loan_account_ref', 'as_of_date'], + neverStored: [ + 'aadhaar', 'accountnumber', 'address', 'borrowername', 'customername', 'dob', + 'email', 'ifsc', 'mobile', 'name', 'pan', 'phone', + ], +} + +const FINACLE: Record = { + enterprise_ref: 'ENTITY_REF', + loan_account_ref: 'CUST_ACCT_ID', + product: 'SCHM_CODE', + sanctioned_amount: 'SANCT_AMT', + outstanding: 'PRIN_OS', + emi_amount: 'INSTL_AMT', + due_date: 'NEXT_DUE_DT', + last_paid_date: 'LAST_PAID_DT', + dpd: 'DPD_DAYS', + bucket: 'ASSET_CLASS', + closed: 'ACCT_CLS_FLG', + as_of_date: 'EXTRACT_DT', +} + +const CUSTOM: Record = { + enterprise_ref: 'borrowerRef', + loan_account_ref: 'loanId', + product: 'productCode', + sanctioned_amount: 'sanctionedAmount', + outstanding: 'outstandingPrincipal', + emi_amount: 'instalment', + due_date: 'nextDueDate', + last_paid_date: 'lastRepaymentDate', + dpd: 'daysPastDue', + bucket: 'assetClassification', + closed: 'isClosed', + as_of_date: 'asOfDate', +} + +const IDENTITY: Record = Object.fromEntries(CANONICAL.columns.map((c) => [c, c])) + +export function demoCatalog(): ConnectorCatalogEntry[] { + return [ + { + vendor: 'FILE_DROP', + displayName: 'Nightly file drop (SFTP / mounted share)', + transport: 'FILE', + simulated: false, + nativeColumns: [], + activationNote: + 'Point the profile at the directory the bank’s extract lands in. No core-system change is needed — this reads the file the bank already produces.', + suggestedMapping: IDENTITY, + }, + { + vendor: 'FINACLE', + displayName: 'Infosys Finacle (simulated)', + transport: 'FILE', + simulated: true, + nativeColumns: [...Object.values(FINACLE), 'CUST_NAME', 'MOBILE_NO', 'BRANCH_CODE', 'CCY'], + activationNote: + 'Finacle exports through a scheduled CRM/Reports extract. A bank enables it, agrees the column list, and the file lands on the drop path.', + suggestedMapping: FINACLE, + }, + { + vendor: 'FLEXCUBE', + displayName: 'Oracle FLEXCUBE (simulated)', + transport: 'JDBC', + simulated: true, + nativeColumns: [ + 'CUSTOMER_REF', 'ACCOUNT_NUMBER', 'PRODUCT_CODE', 'AMOUNT_FINANCED', 'PRINCIPAL_OUTSTANDING', + 'INSTALMENT_AMOUNT', 'NEXT_DUE_DATE', 'LAST_PAYMENT_DATE', 'OVERDUE_DAYS', 'NPA_STAGE', + 'ACCOUNT_STATUS', 'VALUE_DATE', 'CUSTOMER_NAME', 'CONTACT_MOBILE', 'BRANCH_CODE', + ], + activationNote: + 'Read through a reporting schema or extract view rather than the transaction tables. Needs a read-only account scoped to the loan module.', + suggestedMapping: {}, + }, + { + vendor: 'BANCS', + displayName: 'TCS BaNCS (simulated)', + transport: 'FILE', + simulated: true, + nativeColumns: [ + 'PARTY_REF', 'ARRANGEMENT_ID', 'PRODUCT_GROUP', 'LIMIT_AMT', 'OUTSTANDING_BAL', 'EMI', + 'DUE_DATE', 'PAID_DATE', 'DAYS_OVERDUE', 'CLASSIFICATION', 'STATUS', 'POSITION_DATE', + 'PARTY_NAME', 'MOBILE', 'BRANCH', + ], + activationNote: + 'Cooperative-bank hosted here. A nightly position file is the realistic ask; a live API rarely is in year one.', + suggestedMapping: {}, + }, + { + vendor: 'CUSTOM_API', + displayName: 'In-house LMS over REST (simulated)', + transport: 'REST', + simulated: true, + nativeColumns: [...Object.values(CUSTOM), 'borrowerName', 'borrowerMobile', 'branchCode'], + activationNote: + 'For a lender whose loan management system is their own. One paged read-only endpoint; the mapping absorbs whatever they call the fields.', + suggestedMapping: CUSTOM, + }, + ] +} + +export function demoProfiles(): ConnectorProfileRow[] { + return [ + { + code: 'MPGB-FINACLE', + displayName: 'MP Gramin Bank — Finacle nightly extract', + vendor: 'FINACLE', + vendorName: 'Infosys Finacle (simulated)', + transport: 'FILE', + simulated: true, + activationNote: demoCatalog()[1].activationNote, + institutionCode: 'MPGB', + endpoint: 'loanbook-*.csv', + scheduleCron: '0 30 1 * * *', + autoCommit: false, + active: true, + lastSyncAt: '2026-08-07T01:30:00', + lastStatus: 'PARTIAL', + mapping: FINACLE, + sourceColumns: demoCatalog()[1].nativeColumns, + }, + { + code: 'SEHDCCB-DROP', + displayName: 'Sehore DCCB — SFTP file drop', + vendor: 'FILE_DROP', + vendorName: 'Nightly file drop (SFTP / mounted share)', + transport: 'FILE', + simulated: false, + activationNote: demoCatalog()[0].activationNote, + institutionCode: 'SEHDCCB', + endpoint: 'sehdccb-loanbook-*.{csv,xlsx}', + scheduleCron: '0 0 2 * * *', + autoCommit: false, + active: true, + lastSyncAt: '2026-08-07T02:00:00', + lastStatus: 'FAILED', + mapping: IDENTITY, + sourceColumns: [], + }, + { + code: 'VSFB-LMS', + displayName: 'Vindhya SFB — in-house LMS over REST', + vendor: 'CUSTOM_API', + vendorName: 'In-house LMS over REST (simulated)', + transport: 'REST', + simulated: true, + activationNote: demoCatalog()[4].activationNote, + institutionCode: 'VSFB', + endpoint: 'https://lms.vindhyasfb.example/api/loans', + scheduleCron: '0 15 1 * * *', + autoCommit: false, + active: true, + lastSyncAt: null, + lastStatus: 'NEVER_RUN', + mapping: CUSTOM, + sourceColumns: demoCatalog()[4].nativeColumns, + }, + ] +} + +export function demoRuns(): ConnectorSyncRun[] { + return [ + { + id: 14, + profileCode: 'SEHDCCB-DROP', + vendor: 'FILE_DROP', + institutionCode: 'SEHDCCB', + trigger: 'SCHEDULED', + triggeredBy: 'scheduler', + status: 'FAILED', + simulated: false, + asOfDate: '2026-08-06', + rowsFetched: 0, + rowsAccepted: null, + rowsRejected: null, + rowsOverwriting: null, + columnsDropped: null, + batchId: null, + committed: false, + durationMs: 41, + startedAt: '2026-08-07T02:00:00', + message: 'No drop directory at ./data/cbs-drop — the bank has not delivered tonight’s file', + }, + { + id: 13, + profileCode: 'MPGB-FINACLE', + vendor: 'FINACLE', + institutionCode: 'MPGB', + trigger: 'SCHEDULED', + triggeredBy: 'scheduler', + status: 'PARTIAL', + simulated: true, + asOfDate: '2026-08-06', + rowsFetched: 61, + rowsAccepted: 58, + rowsRejected: 3, + rowsOverwriting: 12, + columnsDropped: 4, + batchId: 22, + committed: false, + durationMs: 880, + startedAt: '2026-08-07T01:30:00', + message: + '58 accepted · 3 rejected · 4 unmapped columns dropped at the boundary · staged, awaiting commit · SIMULATED extract, not a live core', + }, + { + id: 12, + profileCode: 'MPGB-FINACLE', + vendor: 'FINACLE', + institutionCode: 'MPGB', + trigger: 'MANUAL', + triggeredBy: 'supervisor', + status: 'SUCCESS', + simulated: true, + asOfDate: '2026-07-31', + rowsFetched: 61, + rowsAccepted: 61, + rowsRejected: 0, + rowsOverwriting: 0, + columnsDropped: 4, + batchId: 19, + committed: true, + durationMs: 913, + startedAt: '2026-08-06T16:12:00', + message: + '61 accepted · 0 rejected · 4 unmapped columns dropped at the boundary · written · SIMULATED extract, not a live core', + }, + ] +}