Files
udyamlinc-api/API-SPEC.md
T
LincPayandClaude Opus 5 b79b92fe8d feat(connector): core banking / LMS seam, one real adapter and four dialects
The loan book is the training label and the one input no public feed
supplies. Integrating with a lender's core is not a technical unknown —
it is an onboarding process per bank, with a schema agreed in a room.
What can be built before that room is the seam, and this is it.

CoreBankingConnector fetches and returns rows; it never writes. Mapped
rows go to LoanBookEntryService.stage — the same validator an uploaded
spreadsheet goes through, landing in the same manual_entry_batch with
the same preview. A parallel validator for the automated path would be
the obvious shape and would eventually enforce the no-PII rule
differently.

FileDropConnector is real: it reads the newest matching file from a
directory a bank delivers to, and leaves it there so a failed run is
repeatable. Finacle, FLEXCUBE, BaNCS and an in-house REST LMS are
simulated, each emitting the same portfolio in its own column dialect —
DPD_DAYS, OVERDUE_DAYS, DAYS_OVERDUE, daysPastDue — which is what makes
the case for a mapping layer rather than asserting it.

The mapping is {canonical: vendor}, keyed by canonical name, so an
unmapped vendor column has no route through and is dropped at the
boundary. CUST_NAME and MOBILE_NO stay out structurally rather than by
anyone remembering. Auto-commit is off on every profile: a sync stages
and stops, keeping the rule that nothing is written until someone has
seen what will be. simulated is stamped per run, not read from the
profile, so switching a profile live cannot make old runs look real.

Fixes a real defect this surfaced: FORBIDDEN_COLUMNS matched on the
exact normalised name, so CUST_NAME — what Finacle actually calls it —
was missed, and a directly uploaded bank file would have kept the
borrower's name in manual_entry_line.raw_row. Matching now splits
separators and camelCase, catching borrowerMobile too, while
loan_account_ref still splits clean.

Verified against a running instance: Finacle 44/44 accepted with 4
columns dropped, the file drop 2 accepted and 1 UNKNOWN_ENTERPRISE
rejected from a real CSV, and both CUST_NAME and borrowerMobile refused
at mapping save.

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

233 lines
14 KiB
Markdown

# UdyamLinc — API Specification
Base URL: `http://localhost:8082/api/v1/udyam`
Every response uses one envelope. The UI's `api.ts` unwraps `.data`, so the shape is stable across every endpoint.
```json
{ "success": true, "message": "OK", "data": { } }
```
A failure keeps the envelope and nulls the payload:
```json
{ "success": false, "message": "No enterprise MSME-1409", "data": null }
```
---
## 1. Authentication and tiers
Three surfaces, three institutional tiers. **They are not ranks of one permission** — an apex user cannot reach a bank endpoint, and a bank user cannot reach an apex one. Both get `403`.
| Scope | Who | May read |
|---|---|---|
| `NABARD` | the apex institution | aggregates across every partner bank; **no borrower row at all** |
| `BANK` | a lending institution | its own book in full, optionally narrowed to a branch |
| `ENTERPRISE` | a borrower | itself |
### The tenancy rule
The institution is a **signed claim in the JWT**. It is never read from a path variable, query parameter or request body. A tenancy key the caller can type is not a tenancy key.
`TenantContext` is resolved once per request from the token and injected into handlers as an ordinary argument, so an endpoint that needs scoping cannot forget to ask for it. The filter **fails closed**: a caller with no institution sees nothing, not everything.
Narrowing parameters that select a *view* — `level`, `zone`, `state`, `block` — do come from the request, because they grant nothing. An apex user may read every zone, so naming one changes only what is displayed.
### Cross-tenant reads return 404, not 403
Requesting a borrower on another bank's book returns exactly what a fabricated id returns:
```
GET /officer/enterprises/MSME-1409 as dccb.admin (owner) → 200
GET /officer/enterprises/MSME-1409 as mpgb.admin (other) → 404
{"success":false,"message":"No enterprise MSME-1409","data":null}
```
A distinguishable `403` would confirm the id is real and on somebody's book — enough to enumerate a competitor's portfolio without reading a single field.
### Endpoints
| Method | Path | Notes |
|---|---|---|
| `POST` | `/auth/officer-login` | Both staff tiers. Issues an apex or bank token depending on the user's institution. |
| `POST` | `/auth/app-login` | Enterprise PWA. `{ enterpriseId, pin }`. |
| `GET` | `/auth/health` | `{ status: "UP", service: "udyamlinc-backend" }` |
> **Known defect.** `SecurityConfig` whitelists `/health`, but no handler is mapped there — it returns `500`. The working probe is `/auth/health`. Worth fixing before any container health check points at it.
---
## 2. `/nabard/**` — the apex surface
Requires `SCOPE_NABARD`. **Not one endpoint here returns an enterprise id, a borrower name, or any row joinable back to a person.** That is verified rather than asserted: a check across all six read endpoints returns zero `MSME-` identifiers.
**Small-cell suppression.** Any cell with fewer than `udyam.apex.min-cell-size` (default 5) borrowers is returned with `suppressed: true` and no figures. A district with two borrowers and one alert discloses that borrower as surely as naming them. Suppressed rows are **returned, not omitted** — a missing district reads as "no activity", a different and wrong claim.
| Method | Path | Returns |
|---|---|---|
| `GET` | `/nabard/dashboard` | national totals, stress rate, portfolio value, open alerts, `minCellSize`, `dataAgeDays` |
| `GET` | `/nabard/banks` | partner-bank scorecard |
| `GET` | `/nabard/banks/{code}/branches` | one bank by branch — **the deepest this tier goes** |
| `GET` | `/nabard/geography?level=&zone=&state=` | `level` = `ZONE` \| `STATE` \| `DISTRICT` |
| `GET` | `/nabard/districts` | stress by district |
| `GET` | `/nabard/sectors` | stress by sector |
| `GET` | `/nabard/digest` | districts, sectors and slowest-responding banks |
| `GET` | `/nabard/institutions` | the institution and branch register |
**Response rate** on the scorecard is the share of raised alerts a bank has acted on. Stress rate is largely a fact about a bank's districts and sectors; response rate is a fact about the bank, and it is the number no bank self-reports.
---
## 3. `/officer/**` — the bank console
Requires `SCOPE_BANK`. Every response is filtered to the caller's institution, and to their branch when they are branch-scoped.
| Method | Path | Notes |
|---|---|---|
| `GET` | `/officer/portfolio?block=&month=` | counts plus rows |
| `GET` | `/officer/enterprises?block=` | ranked by stress score |
| `GET` | `/officer/enterprises/{enterpriseId}` | score, reasons, forecast, alerts, visits, recent ledger |
| `GET` | `/officer/alerts?block=&state=` | scoped through visible borrowers |
| `PUT` | `/officer/alerts/{alertId}` | `OPEN` \| `ACKNOWLEDGED` \| `ESCALATED` \| `SNOOZED` \| `CLOSED`; re-checks tenancy |
| `GET` | `/officer/visits?block=` | |
| `POST` | `/officer/visits` | schedule from the playbook |
| `PUT` | `/officer/visits/{visitId}/outcome` | |
| `GET` | `/officer/playbook` | intervention catalogue |
| `GET` | `/officer/sectors?block=` | sector rollup |
| `GET` | `/forecast/{enterpriseId}` | stored quantile path, first breach, shortfall |
| `GET` | `/forecast/runs` | |
---
## 4. `/admin/**` — configuration and operations
`GET` is open to any console tier. Writes require `ROLE_SUPERVISOR`, `ROLE_BANK_ADMIN` or `ROLE_DATA_STEWARD`.
### Ingestion
| Method | Path | Notes |
|---|---|---|
| `POST` | `/admin/ingestion/run` | all sources |
| `POST` | `/admin/ingestion/run/{source}` | `AGMARKNET` \| `OPEN_METEO` |
| `GET` | `/admin/ingestion/runs?source=&limit=` | **every outcome, including a skip** |
| `GET` | `/admin/ingestion/calls` | outbound call log; the API key is redacted |
| `GET` | `/admin/ingestion/signals/market?weeks=` | carries `source`: `AGMARKNET` or `MANUAL` |
| `GET` | `/admin/ingestion/signals/climate?weeks=` | |
| `GET` | `/admin/ingestion/freshness` | per-district staleness — what a trip-wire checks first |
### Features
| Method | Path | Notes |
|---|---|---|
| `POST` | `/admin/features/compute` | whole book |
| `POST` | `/admin/features/compute/{enterpriseId}` | |
| `GET` | `/admin/features/definitions` | catalogue with staleness tolerances |
| `GET` | `/admin/features/snapshot/{enterpriseId}` | wide row |
| `GET` | `/admin/features/values/{enterpriseId}` | tall rows **including nulls and their reasons** |
| `GET` | `/admin/features/coverage` | populated share per feature |
A feature that cannot be computed is stored as null with a reason — `NO_SIGNAL`, `NO_LEDGER`, `NO_CBS`, `NO_CONSENT`, `NO_FORECAST`, `INSUFFICIENT_HISTORY`. "We did not know" and "we never looked" are different facts.
### Scoring and rules
| Method | Path | Notes |
|---|---|---|
| `POST` | `/admin/scoring/run` | whole book |
| `POST` | `/admin/scoring/run/{enterpriseId}` | |
| `GET` | `/admin/scoring/runs` | |
| `GET` | `/admin/scoring/explain/{enterpriseId}` | every rule's outcome, no writes |
| `GET` `POST` `PUT` `DELETE` | `/admin/rules`, `/admin/rules/{code}` | trip-wire CRUD; version bumps on edit |
| `PUT` | `/admin/sectors/{code}/weights` | sub-score weights in basis points |
| `GET` | `/admin/sectors`, `/admin/districts`, `/admin/commodities` | masters |
`explain` returns one row per rule with a status that distinguishes the cases that matter:
```
TW-01 FIRED DSCR(P10) < 1.00
TW-02 SKIPPED_MISSING upi_inflow_change (NO_CONSENT)
TW-03 NOT_FIRED no entry ≥ 14 days
TW-05 SKIPPED_MISSING price_z_score (INSUFFICIENT_HISTORY)
```
`NOT_FIRED` means the enterprise is fine. `SKIPPED_STALE` and `SKIPPED_MISSING` mean the rule could not run. These must never look the same.
### Manual and bulk entry
For the feeds no API serves — egg, milk, fodder — and the loan book.
| Method | Path | Notes |
|---|---|---|
| `GET` | `/admin/manual/template/market` | CSV template |
| `GET` | `/admin/manual/template/loans` | CSV template |
| `POST` | `/admin/manual/upload?type=` | multipart; `MARKET_SIGNAL` \| `LOAN_RECORD`. **Stages a preview and writes nothing.** |
| `POST` | `/admin/manual/{batchId}/commit` | only a `PREVIEW` batch may commit |
| `DELETE` | `/admin/manual/{batchId}` | discard |
| `POST` | `/admin/manual/entry` | one row, validated and committed |
| `GET` | `/admin/manual/batches`, `/admin/manual/batches/{id}` | audit trail |
An upload returns `accepted`, `rejected`, `overwriting` and `warnings` as **separate lists**. Merging them hides the only two questions the person clicking Commit has: what will not land, and what will land on top of something already there. An overwriting row carries `priorModalPrice`.
**Effective dating.** `effective_date` is the date the fact describes and comes from the file. `observedAt` is when we learned it and is stamped at commit — the uploader cannot reach it. A March rate uploaded in August must stay invisible to a March scoring run, or the model trains on data that did not exist yet.
**Rejection reasons.** `UNKNOWN_DISTRICT`, `UNKNOWN_COMMODITY`, `UNKNOWN_ENTERPRISE`, `FUTURE_DATE`, `BAD_DATE`, `BAD_NUMBER`, `UNIT_MISMATCH`, `PRICE_ORDER`, `DUPLICATE_IN_FILE`, `MISSING_REQUIRED`, `IDENTIFYING_COLUMN`.
A price far from last week's **warns and is accepted** — a genuine price shock is the event this system exists to catch, and rejecting it would be backwards.
**The loan book carries no borrower identity.** A file whose headers include a name, phone, Aadhaar, PAN, account number or address is **refused whole**, with the offending columns named. Dropping the column quietly would let a bank believe it had sent something we promised to keep and had not.
A header matches on the whole normalised name **and on each part**, splitting separators and camelCase alike — a real core ships `CUST_NAME` and an in-house LMS ships `borrowerMobile`, neither of which an exact-match list catches. `loan_account_ref` splits to `loan`/`account`/`ref` and stays clean, which is why this is part-matching and not substring-matching.
### Core banking / LMS integration
The loan book is the one input no public feed supplies, and no two lenders expose it the same way. What exists is the seam, not an integration: an adapter per transport, a mapping per bank, and one validator — mapped rows are handed to the same `LoanBookEntryService.stage` an uploaded spreadsheet goes through, and land in the same `manual_entry_batch` with the same preview.
| Method | Path | Notes |
|---|---|---|
| `GET` | `/admin/connectors/catalog` | every adapter, its dialect, and `simulated` |
| `GET` | `/admin/connectors/canonical-columns` | the template a mapping targets, plus what may never be mapped in |
| `GET` | `/admin/connectors/profiles`, `/admin/connectors/profiles/{code}` | configured links |
| `POST` | `/admin/connectors/profiles` | onboard a lender |
| `PUT` | `/admin/connectors/profiles/{code}` | mapping and settings; **an invalid mapping is refused here**, not at the next nightly run |
| `POST` | `/admin/connectors/profiles/{code}/test` | reaches the source and shows the columns. **Stages nothing.** |
| `POST` | `/admin/connectors/profiles/{code}/sync` | pulls and stages; writes only if the profile has `autoCommit` |
| `GET` | `/admin/connectors/runs` | every attempt, successful or not |
| `GET` | `/admin/connectors/runs/{runId}/preview` | the staged batch behind a run |
**One adapter is real.** `FILE_DROP` reads an actual extract from a directory a bank delivers to (`udyam.connector.file-drop.dir`, default `./data/cbs-drop`), newest file matching the profile's glob, left where it was found so a failed run is repeatable. `FINACLE`, `FLEXCUBE`, `BANCS` and `CUSTOM_API` are **simulated**: they generate an extract in each core's own column dialect so the mapping layer is exercisable end to end. `simulated` is stamped per run, not read from the profile — switching a profile to a live adapter must not retroactively make its old runs look real.
**The mapping is `{canonical_column: vendor_column}`, and the direction matters.** Keyed by the canonical name, so a vendor column nobody mapped has no route through: unmapped columns are **dropped at the boundary** and counted on the run. That is how `CUST_NAME` and `MOBILE_NO` stay out of a system that promised not to hold them — structurally, rather than by anyone remembering.
**Auto-commit is off by default.** A sync stages and stops, preserving the manual path's rule that nothing is written until someone has seen what will be. A bank that wants the nightly feed unattended turns it on per profile, knowingly.
A failed run is still a run. Nothing to read is `SUCCESS` with a message, not `FAILED` — a bank that has not delivered tonight's file is a normal state, and conflating it with a broken connector trains everyone to ignore the status.
---
## 5. `/app/**` — the enterprise PWA
Requires `SCOPE_ENTERPRISE`, pinned to one enterprise id.
| Method | Path | Notes |
|---|---|---|
| `GET` | `/app/home` | |
| `GET` | `/app/ledger` | |
| `POST` | `/app/ledger/sync` | offline queue; `clientId` is generated on the phone so an offline entry keeps identity through sync |
| `GET` | `/app/alerts` | |
| `PUT` | `/app/consent` | |
---
## 6. External feeds
Never called in a request path. A nightly job writes signals; the API serves only what is already in Postgres.
| Feed | Source | Notes |
|---|---|---|
| Mandi prices | data.gov.in resource `35985678-0d79-46b4-9ed6-6f13308a1d24` | filters are `State`/`District`/`Commodity`, capitalised and exact; `sort[Arrival_Date]=desc` is required |
| Rainfall, temperature | Open-Meteo archive | no key; meters by request cost, so a rate-limited call retries with backoff |
| Egg, milk, fodder | **no usable API** | manual entry |
| Loan book | CBS/LMS extract | connector seam built; one real adapter (file drop), four simulated dialects. Manual upload remains the fallback |
See `ARCHITECTURE.md` §4 for provenance and the degradation rules.