From 0a76a53ceb6cf0cd097b580cf2aadb678ab50ec9 Mon Sep 17 00:00:00 2001 From: LincPay Date: Fri, 7 Aug 2026 18:20:15 +0530 Subject: [PATCH] feat(integration): core banking console at /officer/integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tabs. Connections lists each bank's configured link with its last status, and lets an operations user test or run a sync. Field mapping is the screen that carries the argument: one row per loan-book field, one column per lender's name for it, which is what "every bank's core is different" actually reduces to. Sync history reads as an audit — a failed run is a row, because a feed that stopped three weeks ago must be visible as something other than silence. Simulated adapters are badged everywhere they appear, including in the run history, because the failure mode that matters here is a demo mistaken for a live feed. Unmapped vendor columns are shown being dropped at the boundary, with identifying ones marked never stored. The client-side identifying-column check mirrors the backend's, matching on the whole name and on each part with separators and camelCase split alike — so CUST_NAME and borrowerMobile are refused next to the field rather than after a round trip. Follows the house pattern: useQuery against the real endpoint, in-file demo fallback, "Demo data" pill when the backend is unreachable. The demo fixtures deliberately include one connection that has never run and one that last failed. Build and oxlint clean. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + src/App.tsx | 2 + src/features/officer/IntegrationPage.tsx | 772 +++++++++++++++++++++++ src/features/officer/OfficerShell.tsx | 1 + src/lib/api.ts | 92 +++ src/lib/demo/connectors.ts | 265 ++++++++ 6 files changed, 1133 insertions(+) create mode 100644 src/features/officer/IntegrationPage.tsx create mode 100644 src/lib/demo/connectors.ts 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', + }, + ] +}