fix(tenancy): borrower rows come from the scoped API or they do not render

The console fell back to lib/demo/* whenever a request failed, which reads
well until you notice the demo portfolio has no owner on it. The ids are
generated from a formula and ops.ts does not mention institutions at all, so
the fallback listed all 142 fabricated borrowers to whoever was signed in — a
Madhya Pradesh Gramin officer was shown a Vindhya Small Finance Bank
borrower. Clicking it produced a 404 from the API, which was the only correct
part of the exchange. Risk alerts and Field visits never called the API at
all.

Scoping the demo data was attempted and cannot be done honestly: the session
carries blocks, but blocks do not map to banks. Sehore holds both MPGB and
Sehore DCCB, Raisen holds both Sehore DCCB and VSFB, and MPGB's own block list
includes Bhopal, which is entirely VSFB.

So the rule is now the one the central claim requires: borrower rows come
from the tenant-scoped API or the screen says it cannot reach the server. A
console that invents borrowers while the server is down is worse than one
that admits it cannot reach the server — particularly this console, whose
whole argument is that a bank's book is unreachable from another bank's
screen.

useTenantData.ts is the only sanctioned source. Each hook returns
`unavailable` rather than substituting data. scopedEnterprises() now takes the
book as a required argument with no default, so reintroducing the leak is a
compile error rather than an oversight.

Feeds, data sources, connectors and the rule catalogue keep their demo
fallbacks. Nothing in them is a borrower.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-25 13:45:19 +05:30
co-authored by Claude Opus 5
parent 39ca34be68
commit 41c49113c2
10 changed files with 556 additions and 107 deletions
+317 -45
View File
@@ -1,7 +1,16 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { officerSession } from '../../lib/api'
import type { Enterprise } from '../../lib/api'
import { enterpriseDetail, isNotFound, officerSession } from '../../lib/api'
import type {
Enterprise,
FieldVisit,
LedgerEntry,
EnterpriseDetail,
ForecastPointRow,
ReasonCodeRow,
StressScoreRow,
} from '../../lib/api'
import {
ActionChip,
Card,
@@ -22,8 +31,6 @@ import {
breachPoint,
findEnterprise,
forecastFor,
reasonCodesFor,
stressScoreFor,
subScoresFor,
tripWiresFor,
} from '../../lib/demo/enterprises'
@@ -43,18 +50,110 @@ const TABS: { key: Tab; label: string }[] = [
{ key: 'VISITS', label: 'Visits' },
]
/** Everything the officer needs about one enterprise, in five tabs. */
/**
* Sector label that cannot throw.
*
* <p>`SECTORS[e.sector].label` reads fine until the day a sector is added to the
* database and not to the front end, and then it takes the whole page down
* rather than showing one unfamiliar word. The codes match today; this is so
* they may stop matching without costing an officer their screen.
*/
function sectorLabel(sector: string | undefined): string {
if (!sector) return "Unknown sector"
return SECTORS[sector as keyof typeof SECTORS]?.label ?? sector.replace(/_/g, " ").toLowerCase()
}
/**
* Reconcile the wire record with the shape this screen is written against.
*
* <p>They are not the same object and were never meant to be. The API stores an
* enterprise with foreign keys — {@code sectorId}, {@code districtId} — and
* returns the resolved {@code sector} and {@code district} as siblings, because
* a row in a table should not carry its own joins. The screen wants one flat
* record. Reading the API object directly gave `SECTORS[undefined]`, which threw
* on every enterprise.
*
* <p>`trend` has no server equivalent yet — the six-month score history would
* need a query across scoring runs. Until that exists it comes from the demo
* enterprise where there is one, and otherwise flat-lines at the current score
* rather than inventing a shape.
*/
function mergeEnterprise(
detail: EnterpriseDetail | undefined,
demo: Enterprise | undefined,
): Enterprise | undefined {
if (!detail) return demo
const api = detail.enterprise as unknown as Record<string, unknown>
const score = Number(api.score ?? demo?.score ?? 0)
return {
...(demo ?? ({} as Enterprise)),
id: Number(api.id ?? demo?.id ?? 0),
enterpriseId: String(api.enterpriseId ?? demo?.enterpriseId ?? ''),
name: String(api.name ?? demo?.name ?? ''),
nameHi: String(api.nameHi ?? demo?.nameHi ?? ''),
// Resolved names travel beside the record, not inside it.
sector: (detail.sector ?? demo?.sector) as Enterprise['sector'],
district: detail.district ?? demo?.district ?? '',
block: String(api.block ?? demo?.block ?? ''),
ownerCount: Number(api.ownerCount ?? demo?.ownerCount ?? 0),
vintageMonths: Number(api.vintageMonths ?? demo?.vintageMonths ?? 0),
loanOutstanding: Number(api.loanOutstanding ?? demo?.loanOutstanding ?? 0),
monthlyInstalment: Number(api.monthlyInstalment ?? demo?.monthlyInstalment ?? 0),
score,
band: (api.band ?? demo?.band ?? 'STANDARD') as Enterprise['band'],
trend: demo?.trend ?? Array(6).fill(score),
lastUpdated: String(api.lastScoredAt ?? demo?.lastUpdated ?? ''),
}
}
/**
* Everything the officer needs about one enterprise, in five tabs.
*
* <p>Reads the live record. This screen used to compute the score, the
* sub-scores and the drivers in the browser from a JavaScript copy of the rule
* weights — which meant the "SHAP" badge sat over numbers no model had produced,
* while the real SHAP values sat unread in `reason_code`. Everything on this page
* now comes from the scoring run that actually happened, and the engine that
* produced it is named on the score card.
*/
export function EnterpriseDetailPage() {
const { enterpriseId = '' } = useParams()
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('OVERVIEW')
const [scheduling, setScheduling] = useState(false)
const e = findEnterprise(enterpriseId)
const { data, error, isError, isLoading } = useQuery({
queryKey: ['enterprise-detail', enterpriseId],
queryFn: () => enterpriseDetail(enterpriseId),
enabled: Boolean(enterpriseId),
})
// A 404 is an answer, not a failure. The tenancy model returns one for a
// borrower on another bank's book, so falling back to demo data here would
// show exactly the row the backend refused.
const refused = isNotFound(error)
const demoEnterprise = refused ? undefined : findEnterprise(enterpriseId)
const e = mergeEnterprise(data, demoEnterprise)
const demo = !refused && (isError || !data)
if (isLoading) return <Empty text="Loading…" />
if (refused) return <Empty text={`No enterprise ${enterpriseId} on your book.`} />
if (!e) return <Empty text={`No enterprise found for ${enterpriseId}.`} />
const points = forecastFor(e)
const breach = breachPoint(points)
// Live forecast rows where we have them; the in-file generator only where the
// backend gave us nothing, so an unreachable API degrades rather than blanks.
const points = data?.forecast?.length
? data.forecast.map((p) => ({
month: p.month,
horizon: p.horizon,
p10: p.p10,
p50: p.p50,
p90: p.p90,
obligation: p.obligation,
}))
: forecastFor(demoEnterprise ?? e)
const breach = breachPoint(points as never)
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
return (
@@ -69,12 +168,13 @@ export function EnterpriseDetailPage() {
<h1 className="text-[19px] font-bold text-forest">{e.name}</h1>
<RiskBadge band={e.band} />
</div>
<div className="text-[11.5px] text-slate-500">
{e.enterpriseId} · {SECTORS[e.sector].label} · {e.block} block, {e.district} district ·{' '}
<div className="text-[11.5px] text-ink-3">
{e.enterpriseId} · {sectorLabel(e.sector)} · {e.block} block, {e.district} district ·{' '}
{e.vintageMonths} months on book
</div>
</div>
<div className="ml-auto flex gap-2">
<div className="ml-auto flex items-center gap-2">
{demo && <span className="pill pill-pend">Demo data</span>}
<PrimaryBtn onClick={() => setScheduling(true)}>Schedule visit</PrimaryBtn>
<GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn>
</div>
@@ -100,28 +200,77 @@ export function EnterpriseDetailPage() {
))}
</div>
{tab === 'OVERVIEW' && <OverviewTab e={e} />}
{tab === 'FORECAST' && <ForecastTab e={e} />}
{tab === 'DRIVERS' && <DriversTab e={e} />}
{tab === 'LEDGER' && <LedgerTab e={e} />}
{tab === 'VISITS' && <VisitsTab e={e} />}
{tab === 'OVERVIEW' && <OverviewTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
{tab === 'FORECAST' && <ForecastTab e={e} live={data?.forecast ?? []} />}
{tab === 'DRIVERS' && <DriversTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
{tab === 'LEDGER' && <LedgerTab e={e} live={data?.ledger ?? []} />}
{tab === 'VISITS' && <VisitsTab e={e} live={data?.visits ?? []} />}
{scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />}
</div>
)
}
function OverviewTab({ e }: { e: Enterprise }) {
const score = stressScoreFor(e)
const reasons = reasonCodesFor(e)
/** How the engine that produced a score should be described, and coloured. */
const ENGINE_LABEL: Record<string, { text: string; pill: string; note: string }> = {
MODEL: {
text: 'Model',
pill: 'pill-ok',
note: 'Gradient-boosted stress head. Drivers below are SHAP contributions.',
},
RULE: {
text: 'Rules',
pill: 'pill-info',
note: 'Sector-weighted rule engine. Drivers are each family’s weighted share.',
},
RULE_FALLBACK: {
text: 'Rules — model unreachable',
pill: 'pill-pend',
note: 'The run was configured for the model; the inference service could not be reached, so this score is rule-derived.',
},
}
function OverviewTab({
e,
score,
reasons,
}: {
e: Enterprise
score: StressScoreRow | null
reasons: ReasonCodeRow[]
}) {
const engine = ENGINE_LABEL[score?.engine ?? ''] ?? {
text: score?.engine ?? 'Unknown',
pill: 'pill-mut',
note: 'Engine not recorded on this score.',
}
const families: { key: string; label: string; value: number | null }[] = [
{ key: 'liquidity', label: 'Liquidity', value: score?.liquidity ?? null },
{ key: 'behavioural', label: 'Repayment behaviour', value: score?.behavioural ?? null },
{ key: 'trendVolatility', label: 'Market volatility', value: score?.trendVolatility ?? null },
{ key: 'obligationLoad', label: 'Obligation load', value: score?.obligationLoad ?? null },
{ key: 'externalShock', label: 'External shock', value: score?.externalShock ?? null },
]
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Composite Stress Score" badge={score.modelVersion}>
<Card title="Composite Stress Score" badge={score ? `run ${score.runId ?? '—'}` : 'not scored'}>
<div className="p-4 grid place-items-center">
<ScoreDial score={e.score} band={e.band} size={132} />
<ScoreDial score={score?.score ?? e.score} band={(score?.band ?? e.band) as never} size={132} />
{/* The engine is stated on the face of the score, not in a tooltip. A
number produced by a fallback must not look like one produced by
the model it was configured to use. */}
<div className="flex items-center gap-2 mt-3">
<span className={`pill ${engine.pill}`}>{engine.text}</span>
{score && <span className="pill pill-mut">{score.confidence}% confidence</span>}
</div>
<p className="text-[10.5px] text-ink-3 text-center leading-snug mt-2">{engine.note}</p>
<div className="mt-3 w-full">
<Sparkline values={e.trend} band={e.band} width={240} height={44} />
<div className="text-[10px] text-slate-400 text-center mt-1">6-month score history</div>
<Sparkline values={e.trend} band={(score?.band ?? e.band) as never} width={240} height={44} />
<div className="text-[10px] text-ink-3 text-center mt-1">6-month score history</div>
</div>
</div>
</Card>
@@ -131,28 +280,64 @@ function OverviewTab({ e }: { e: Enterprise }) {
{[
['Loan outstanding', inr(e.loanOutstanding)],
['Monthly instalment', inr(e.monthlyInstalment)],
['Sector pack', SECTORS[e.sector].label],
['Sector pack', sectorLabel(e.sector)],
['Block / district', `${e.block} / ${e.district}`],
['Owners on record', String(e.ownerCount)],
['Vintage', `${e.vintageMonths} months`],
['Last scored', score?.computedAt ? score.computedAt.slice(0, 16).replace('T', ' ') : '—'],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-3">
<dt className="text-slate-500">{k}</dt>
<dd className="font-semibold text-slate-700">{v}</dd>
<dt className="text-ink-3">{k}</dt>
<dd className="font-semibold text-ink num">{v}</dd>
</div>
))}
</dl>
<div className="px-4 pb-4">
<div className="text-[10px] uppercase tracking-[0.07em] text-ink-3 font-bold mb-2">
Five risk families
</div>
<div className="space-y-1.5">
{families.map((f) => (
<div key={f.key} className="flex items-center gap-2">
<span className="text-[11px] text-ink-2 w-[124px] shrink-0">{f.label}</span>
<span className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden">
<span
className={`block h-full rounded-full ${
(f.value ?? 0) >= 65 ? 'bg-risk-red' : (f.value ?? 0) >= 35 ? 'bg-amber' : 'bg-green'
}`}
style={{ width: `${Math.min(100, f.value ?? 0)}%` }}
/>
</span>
<span className="font-mono text-[10.5px] text-ink-3 w-7 text-right">
{f.value ?? '—'}
</span>
</div>
))}
</div>
</div>
</Card>
<Card title="Why this score" badge="SHAP">
<Card
title="Why this score"
badge={score?.engine === 'MODEL' ? 'SHAP' : 'rule weights'}
actions={<span className="text-[10.5px] text-ink-3">Top drivers, ranked</span>}
>
<div className="p-4 space-y-3">
{reasons.map((r) => (
<div key={r.driver} className="border-l-[3px] border-amber pl-2.5">
<div className="text-[11px] font-semibold text-slate-700">
{r.driver} <span className="font-mono text-slate-400">· {r.weight}</span>
{reasons.length === 0 && <Empty text="No reason codes stored for this enterprise." />}
{reasons.slice(0, 5).map((r) => (
<div key={r.id} className="flex gap-2.5">
<span className="w-6 h-6 rounded-[7px] bg-lp-blue-soft text-lp-blue grid place-items-center text-[10.5px] font-bold shrink-0">
{r.rank}
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11.5px] font-semibold text-ink">{r.driverHi || r.driver}</span>
<span className="font-mono text-[10px] text-ink-3">+{r.weight}</span>
</div>
<p className="text-[11.5px] text-ink-2 leading-snug">{r.sentenceEn}</p>
<p className="text-[11px] text-ink-3 leading-snug">{r.sentenceHi}</p>
</div>
<p className="text-[11.5px] text-slate-600 leading-snug">{r.sentenceEn}</p>
<p className="text-[11px] text-slate-400 leading-snug">{r.sentenceHi}</p>
</div>
))}
</div>
@@ -161,11 +346,29 @@ function OverviewTab({ e }: { e: Enterprise }) {
)
}
function ForecastTab({ e }: { e: Enterprise }) {
const points = forecastFor(e)
function ForecastTab({ e, live }: { e: Enterprise; live: ForecastPointRow[] }) {
// Live rows carry the engine that produced them; the in-file generator does
// not, so the badge can always say which one the reader is looking at.
const engine = live.length ? live[0].engine : null
const points = live.length
? live.map((p) => ({
month: p.month,
label: monthRef(new Date(`${p.month}-01T00:00:00`)).label,
labelHi: monthRef(new Date(`${p.month}-01T00:00:00`)).labelHi,
p10: p.p10,
p50: p.p50,
p90: p.p90,
obligation: p.obligation,
}))
: forecastFor(e)
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Net cash position — 6-month quantile forecast" className="lg:col-span-2">
<Card
title="Net cash position — 6-month quantile forecast"
badge={engine ? engine.toLowerCase() : "demo"}
actions={<span className="text-[10.5px] text-ink-3">Missing inputs widen the band; they never lower its centre.</span>}
className="lg:col-span-2"
>
<div className="p-4">
<ForecastFan points={points} height={280} />
</div>
@@ -207,14 +410,47 @@ function ForecastTab({ e }: { e: Enterprise }) {
)
}
function DriversTab({ e }: { e: Enterprise }) {
const sub = subScoresFor(e)
function DriversTab({
e,
score,
reasons,
}: {
e: Enterprise
score: StressScoreRow | null
reasons: ReasonCodeRow[]
}) {
// The five families as the run stored them. Only where the backend gave us
// nothing do we fall back to the in-browser approximation — and the badge says
// which of the two you are looking at.
const live = score != null
const sub = live
? {
liquidity: score.liquidity ?? 0,
behavioural: score.behavioural ?? 0,
trendVolatility: score.trendVolatility ?? 0,
obligationLoad: score.obligationLoad ?? 0,
externalShock: score.externalShock ?? 0,
}
: subScoresFor(e)
const wires = tripWiresFor(e)
const isModel = score?.engine === 'MODEL'
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Five risk families" badge={`${SECTORS[e.sector].label} weights`}>
<Card
title="Five risk families"
badge={live ? (isModel ? 'SHAP · run ' + (score?.runId ?? '—') : 'rule weights') : 'demo'}
actions={
<span className="text-[10.5px] text-ink-3">
{isModel
? 'Contribution to the model’s score'
: 'Each family’s weighted share of the composite'}
</span>
}
>
<div className="p-4">
<SubScoreRadar sub={sub} band={e.band} />
<SubScoreRadar sub={sub as never} band={(score?.band ?? e.band) as never} />
<div className="space-y-2 mt-4">
{DRIVER_META.map((d) => (
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
@@ -223,7 +459,38 @@ function DriversTab({ e }: { e: Enterprise }) {
</div>
</Card>
<Card title="Deterministic trip-wires" badge="rule engine">
<Card title="Reason codes" badge={isModel ? 'SHAP sentences' : 'rule sentences'}>
<div className="p-4 space-y-3">
{reasons.length === 0 && <Empty text="No reason codes stored for this enterprise." />}
{reasons.map((r) => (
<div key={r.id} className="flex items-start gap-2.5">
<span className="w-6 h-6 rounded-[7px] bg-lp-blue-soft text-lp-blue grid place-items-center text-[10.5px] font-bold shrink-0">
{r.rank}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[11.5px] font-semibold text-ink capitalize">{r.driver}</span>
<span className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden">
<span
className="block h-full rounded-full bg-lp-blue"
style={{ width: `${Math.min(100, r.weight)}%` }}
/>
</span>
<span className="font-mono text-[10.5px] text-ink-3 w-7 text-right">+{r.weight}</span>
</div>
<p className="text-[11.5px] text-ink-2 leading-snug mt-0.5">{r.sentenceEn}</p>
<p className="text-[11px] text-ink-3 leading-snug">{r.sentenceHi}</p>
</div>
</div>
))}
</div>
<p className="px-4 py-2 text-[10.5px] text-ink-3 border-t border-line leading-snug">
Sentences are rendered once and stored, so the wording an officer acted on survives a later
change to the templates.
</p>
</Card>
<Card title="Deterministic trip-wires" badge="rule engine" className="lg:col-span-2">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
@@ -259,8 +526,11 @@ function DriversTab({ e }: { e: Enterprise }) {
)
}
function LedgerTab({ e }: { e: Enterprise }) {
const entries = loadLedger(e.enterpriseId).slice(0, 40)
function LedgerTab({ e, live }: { e: Enterprise; live: LedgerEntry[] }) {
// Server entries first — the payload carries this borrower's last sixty,
// tenant-checked. loadLedger reads whatever this browser happens to hold from
// the PWA demo, which is this device's data, not the enterprise's.
const entries = (live.length ? live : loadLedger(e.enterpriseId)).slice(0, 40)
const label = (kind: string, code: string) =>
CATEGORIES[kind as keyof typeof CATEGORIES]?.find((c) => c.code === code)?.en ?? code
@@ -301,8 +571,10 @@ function LedgerTab({ e }: { e: Enterprise }) {
)
}
function VisitsTab({ e }: { e: Enterprise }) {
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer')
function VisitsTab({ e, live }: { e: Enterprise; live: FieldVisit[] }) {
// The detail payload already carries this enterprise's visits, tenant-checked
// on the way out. Locally scheduled ones merge over them.
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer', live)
const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId)
return (