Files
udyamlinc/src/lib/api.ts
T
LincPayandClaude Opus 5 e923daf825 feat(integration): DPD history tab — coverage, generation and backfill
A fourth tab on /officer/integration reading the loan book as a training
set. The verdict banner is the point of the screen: DPD history is the
model's label, and three thousand generated snapshots look identical on
a chart to three thousand real ones, so the split is stated in words at
the top rather than left to be inferred from a percentage.

Four tiles, and "unknown provenance" is one of them — rows written
before stamping existed are shown as their own bucket rather than
counted as real. Generation and purge sit beside a backfill-from-core
control, with the honest note that a backfill from a simulated adapter
produces simulated rows and only a bank's own core counts toward the
real total.

The demo fixture deliberately reports a book that is NOT trainable. A
fixture claiming a healthy training set would defeat the entire purpose
of the panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:58:14 +05:30

727 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
/** udyamlinc-backend — Spring Boot skeleton, context path /api/v1/udyam. */
export const API_BASE =
import.meta.env.VITE_UDYAM_API_BASE_URL ?? 'http://udyamlinc-backend.lincpay/api/v1/udyam'
/** Short timeout: field devices are on patchy 2G and every screen has a local fallback. */
export const api = axios.create({ baseURL: API_BASE, timeout: 6_000 })
// ── Auth sessions ────────────────────────────────────────────────
/** Field officer console session — sessionStorage 'ul-officer-auth'. */
export interface OfficerSession {
token: string
username: string
fullName: string
role: string // FIELD_OFFICER | SUPERVISOR
blocks: string[]
branch: string
}
/** Enterprise PWA session — localStorage 'ul-app-auth' (must survive an offline relaunch). */
export interface EnterpriseSession {
token: string
enterpriseId: string
name: string
nameHi: string
sector: Sector
block: string
lang: Lang
}
/**
* Which portal a console session belongs to.
*
* <p>Derived from the role rather than stored as its own field, so a tampered
* session cannot claim a tier its token does not carry. This only picks the
* landing page — the backend re-derives the tier from the signed JWT and refuses
* the other surface outright, so getting this wrong is a routing annoyance
* rather than an access-control hole.
*/
export function isApexRole(role: string | undefined): boolean {
return !!role && role.startsWith('NABARD_')
}
export function homeFor(session: OfficerSession | null): string {
if (!session?.token) return '/login'
return isApexRole(session.role) ? '/nabard' : '/officer'
}
export function officerSession(): OfficerSession | null {
const raw = sessionStorage.getItem('ul-officer-auth')
if (!raw) return null
try {
const s = JSON.parse(raw)
return s?.token ? s : null
} catch {
return null
}
}
export function enterpriseSession(): EnterpriseSession | null {
const raw = localStorage.getItem('ul-app-auth')
if (!raw) return null
try {
const s = JSON.parse(raw)
return s?.token ? s : null
} catch {
return null
}
}
export function clearSessions() {
sessionStorage.removeItem('ul-officer-auth')
localStorage.removeItem('ul-app-auth')
}
// Attach the JWT of whichever surface the user is on (officer console vs enterprise app)
api.interceptors.request.use((config) => {
const path = window.location.pathname
const token =
(path.startsWith('/app') ? enterpriseSession()?.token : officerSession()?.token) ??
officerSession()?.token ??
enterpriseSession()?.token
if (token && !config.url?.includes('/auth/')) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Expired/invalid token → drop the session and return to sign-in
api.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401 && !window.location.pathname.startsWith('/login')) {
clearSessions()
window.location.assign('/login')
}
return Promise.reject(error)
},
)
/** Standard backend envelope: { success, message, data } */
export async function get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
const res = await api.get(path, { params })
return res.data.data as T
}
export async function post<T>(path: string, body?: unknown): Promise<T> {
const res = await api.post(path, body)
return res.data.data as T
}
export async function put<T>(path: string, body?: unknown): Promise<T> {
const res = await api.put(path, body)
return res.data.data as T
}
export async function del<T>(path: string): Promise<T> {
const res = await api.delete(path)
return res.data.data as T
}
/** Extract the backend's error message from an axios error. */
export function errMsg(e: unknown): string {
const anyE = e as { response?: { data?: { message?: string } }; message?: string }
return anyE.response?.data?.message ?? anyE.message ?? 'Request failed'
}
// ── Domain types (mirror UdyamLinc-Backend/API-SPEC.md) ──────────
export type Lang = 'hi' | 'en'
/** The five sector risk packs from the deck. */
export type Sector = 'DAIRY' | 'POULTRY' | 'FOOD_PROC' | 'HANDICRAFTS' | 'RURAL_RETAIL'
/** Composite Stress Score bands. Red escalates, Watch is monitored, Standard is healthy. */
export type Band = 'RED' | 'WATCH' | 'STANDARD'
export interface Enterprise {
id: number
enterpriseId: string // MSME-4417
name: string
nameHi: string
sector: Sector
block: string
district: string
ownerCount: number
vintageMonths: number
loanOutstanding: number
monthlyInstalment: number
score: number // 0–100 Composite Stress Score
band: Band
trend: number[] // 6-month score history, oldest first
lastUpdated: string
}
/** One month of the quantile forecast. Amounts are net cash position in ₹. */
export interface ForecastPoint {
month: string // '2026-09'
label: string // 'Sep'
labelHi: string // 'सित'
p10: number
p50: number
p90: number
obligation: number // scheduled instalment + fixed costs due that month
}
/** Five independent risk families, each 0–100, combined with sector weights. */
export interface SubScores {
liquidity: number
behavioural: number
trendVolatility: number
obligationLoad: number
externalShock: number
}
export interface StressScore {
enterpriseId: string
score: number
band: Band
subScores: SubScores
computedAt: string
modelVersion: string
}
/** A SHAP driver rendered as a sentence a field officer can read aloud. */
export interface ReasonCode {
driver: string
driverHi: string
weight: number // contribution, 0–100
sentenceEn: string
sentenceHi: string
}
/** Deterministic rule that fires independently of the model, for auditability. */
export interface TripWire {
code: string
label: string
labelHi: string
fired: boolean
detail: string
threshold: string
}
export type AlertOrigin = 'MODEL' | 'TRIPWIRE'
export type AlertState = 'OPEN' | 'ACKNOWLEDGED' | 'ESCALATED' | 'SNOOZED' | 'CLOSED'
export interface RiskAlert {
id: number
alertId: string
enterpriseId: string
enterpriseName: string
sector: Sector
block: string
band: Band
score: number
origin: AlertOrigin
title: string
titleHi: string
reason: ReasonCode
shortfall: number // projected ₹ gap at P10
breachMonth: string
state: AlertState
raisedAt: string
}
export type VisitState = 'PLANNED' | 'DONE' | 'OVERDUE'
export interface FieldVisit {
id: number
visitId: string
enterpriseId: string
enterpriseName: string
block: string
action: string
actionHi: string
priority: number // 1 = act first
dueDate: string
state: VisitState
outcome: string | null
officer: string
loggedAt: string | null
}
/** One item of the ranked playbook the officer works down. */
export interface PlaybookAction {
code: string
label: string
labelHi: string
appliesTo: Band
detail: string
}
export type LedgerKind = 'IN' | 'OUT' | 'STOCK'
export interface LedgerEntry {
id: string // client-generated so offline entries have identity before sync
enterpriseId: string
kind: LedgerKind
amount: number
category: string
note: string | null
entryDate: string // 'YYYY-MM-DD'
syncState: 'PENDING' | 'SYNCED'
createdAt: string
}
export interface PortfolioSummary {
block: string
month: string
total: number
totalDelta: number
red: number
redDelta: number
watch: number
watchDelta: number
standard: number
standardDelta: number
updatedMinutesAgo: number
rows: Enterprise[]
}
// ── Ingestion, features and rules (the /admin surface) ───────────
/**
* A week of mandi prices for one district and commodity. Weekly rather than
* daily because a mandi does not report every day, and every feature built on it
* is defined against a trailing weekly mean anyway.
*/
export interface MarketSignal {
id: number
districtName: string
commodityName: string
weekStart: string // Monday of the ISO week
modalPrice: number | null
minPrice: number | null
maxPrice: number | null
arrivalCount: number | null
unit: string | null
source: string
validForDate: string // the date the fact describes
observedAt: string // when we learned it
}
export interface ClimateSignal {
id: number
districtName: string
weekStart: string
isoWeek: number
rainfallMm: number | null
tmaxC: number | null
rainfallNormalMm: number | null
rainfallAnomalyPct: number | null
heatAnomalyC: number | null
validForDate: string
observedAt: string
}
/** Per-district data age — what a trip-wire consults before it is allowed to fire. */
export interface Freshness {
districtCode: string
districtName: string
marketValidFor: string | null
marketStalenessDays: number | null
climateValidFor: string | null
climateStalenessDays: number | null
}
export type IngestionStatus = 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'SKIPPED'
export interface IngestionRun {
id: number
source: string // AGMARKNET | OPEN_METEO
runDate: string
trigger: string // SCHEDULED | MANUAL
status: IngestionStatus
rowsFetched: number | null
rowsWritten: number | null
callsFailed: number | null
durationMs: number | null
startedAt: string
message: string | null
}
/** A catalogue entry. `stalenessToleranceDays` is what gates the rules. */
export interface FeatureDefinition {
code: string
label: string
family: string
unit: string | null
sourceKind: string | null
stalenessToleranceDays: number | null
direction: string | null
derivation: string | null
}
/**
* One tall-store row. A null `value` always carries a `missingReason` — that is
* the difference between a hole we can explain and one we cannot.
*/
export interface FeatureValueRow {
featureCode: string
value: number | null
validForDate: string
stalenessDays: number | null
missingReason: string | null
}
export interface TripWireRule {
id: number
code: string
label: string
labelHi: string
expression: string
thresholdText: string
severity: 'RED' | 'WATCH'
sectorCode: string | null
requiredFeatures: string
maxStalenessDays: number
active: boolean
version: number
}
export type RuleStatus =
| 'FIRED'
| 'NOT_FIRED'
| 'SKIPPED_STALE'
| 'SKIPPED_MISSING'
| 'SKIPPED_SECTOR'
| 'ERROR'
/**
* What one rule did against one snapshot. A rule that came back false and one
* that was never allowed to run are different events, and the console has to
* show which — "no alert" because the enterprise is fine reads very differently
* from "no alert" because the feed has been down a fortnight.
*/
export interface RuleOutcome {
ruleCode: string
ruleId: number
ruleVersion: number
severity: 'RED' | 'WATCH'
status: RuleStatus
detail: string | null
}
/** Enterprise app home payload — everything the Home tab needs in one call. */
export interface AppHome {
enterprise: Enterprise
alert: RiskAlert | null
forecast: ForecastPoint[]
todayIn: number
todayOut: number
advice: { textEn: string; textHi: string }
lastSyncedAt: string | null
}
// ── Manual & bulk data entry ─────────────────────────────────────
// The feeds no API serves: NECC egg rates, dairy-federation milk, fodder — and
// the loan book, until a lender builds a nightly extract.
export interface ManualEntryBatch {
id: number
entryType: 'MARKET_SIGNAL' | 'LOAN_RECORD'
filename: string | null
uploadedBy: string
uploadedAt: string
status: 'PREVIEW' | 'COMMITTED' | 'DISCARDED'
committedAt: string | null
committedBy: string | null
rowsParsed: number | null
rowsAccepted: number | null
rowsRejected: number | null
rowsOverwriting: number | null
note: string | null
}
export interface ManualEntryLine {
id: number
rowNumber: number
rawDistrict: string | null
rawCommodity: string | null
rawEffectiveDate: string | null
rawModalPrice: string | null
effectiveDate: string | null
weekStart: string | null
modalPrice: number | null
minPrice: number | null
maxPrice: number | null
unit: string | null
accepted: boolean
rejectReason: string | null
rejectDetail: string | null
warning: string | null
overwriting: boolean
/** What an accepted row would replace — a correction is only auditable if this survives. */
priorModalPrice: number | null
}
/** Accepted, rejected and overwriting stay apart all the way from the backend. */
export interface ManualEntryPreview {
batch: ManualEntryBatch
accepted: ManualEntryLine[]
rejected: ManualEntryLine[]
overwriting: ManualEntryLine[]
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<string, string>
}
/** 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<string, string>
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<string, string>
mappedSampleRow?: Record<string, string>
droppedColumns?: string[]
missingRequired?: string[]
}
export interface ConnectorSyncResponse {
run: ConnectorSyncRun
droppedColumns: string[]
extractLabel: string | null
preview?: ManualEntryPreview
}
/**
* The loan book read as a training set.
*
* <p>`verdict` is a sentence, not a number, because "is this trainable" is the
* only question that matters here and it must not be left to whoever is reading
* the percentages to work out.
*/
export interface LoanHistoryCoverage {
totalRecords: number
/** Rows this system generated. Never counts toward a real-data claim. */
syntheticRecords: number
realRecords: number
/** Rows written before provenance was stamped. Never counted as real. */
unknownProvenance: number
enterprisesTotal: number
enterprisesWithAnyLoan: number
enterprisesWithHistory: number
distinctAsOfDates: number
earliest: string | null
latest: string | null
historyCoveragePct: number
trainable: boolean
verdict: string
}
export interface LoanHistoryGenerateResult {
enterprises: number
loans: number
snapshotsWritten: number
monthsRequested: number
from: string | null
to: string | null
note: string
}
/** One month of one loan, as an extract reported it. */
export interface LoanSnapshot {
id: number
accountRef: string
enterpriseId: string
product: string | null
sanctionedAmount: number | null
outstandingAmount: number | null
instalmentAmount: number | null
nextDueDate: string | null
lastPaidDate: string | null
dpd: number | null
bucket: string | null
closed: boolean
asOfDate: string
observedAt: string | null
/** SEEDED | SEEDED_HISTORY | UPLOAD | a connector profile code. */
source: string | null
}
/** 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()
form.append('file', file)
const res = await api.post(`/admin/manual/upload?type=${type}`, form)
return res.data as { success: boolean; message: string; data: ManualEntryPreview }
}
// ── NABARD (apex) surface ────────────────────────────────────────
// Aggregates only. Nothing here carries an enterprise id, by design — see
// ApexService on the backend. A cell with fewer borrowers than the platform's
// minimum is returned with suppressed=true rather than omitted, because a
// missing district reads as "no activity" and that is a different claim.
export interface ApexDashboard {
partnerBanks: number
branches: number
enterprises: number
red: number
watch: number
standard: number
stressRatePct: number
portfolioValue: number
openAlerts: number
minCellSize: number
dataAgeDays: number
}
export interface BankScorecardRow {
institutionCode: string
institutionName: string
shortName: string
type: string
branches: number
enterprises: number
suppressed: boolean
red?: number
watch?: number
standard?: number
stressRatePct?: number
avgScore?: number
alertsRaised?: number
alertsActioned?: number
/** Share of raised alerts a bank has acted on — the number a bank never self-reports. */
responseRatePct?: number
outstanding?: number
}
export interface ApexCell {
districtName?: string
sectorName?: string
enterprises: number
suppressed: boolean
red?: number
watch?: number
standard?: number
stressRatePct?: number
avgScore?: number
}
export interface ApexDigest {
asOf: string
districtsUnderStress: ApexCell[]
sectorsUnderStress: ApexCell[]
slowestResponders: BankScorecardRow[]
}
export interface InstitutionRow {
code: string
name: string
shortName: string
type: string
state: string
branches: { code: string; name: string; region: string; blocks: string }[]
}
/** One row of the India → zone → state → district drill-down. */
export interface GeoCell {
key: string
name: string
level: 'ZONE' | 'STATE' | 'DISTRICT'
zone?: string
zoneLabel?: string
state?: string
enterprises: number
suppressed: boolean
red?: number
watch?: number
standard?: number
stressRatePct?: number
avgScore?: number
}
/** A partner bank's branch, as the apex tier sees it — aggregates, never borrowers. */
export interface BranchCell {
name: string
branchCode: string
region: string
blocks: string
district: string
institutionCode: string
institutionName: string
enterprises: number
suppressed: boolean
red?: number
watch?: number
standard?: number
stressRatePct?: number
avgScore?: number
}