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>
This commit is contained in:
LincPay
2026-08-07 18:20:00 +05:30
co-authored by Claude Opus 5
parent 22870eb480
commit b79b92fe8d
19 changed files with 1756 additions and 14 deletions
@@ -0,0 +1,150 @@
package com.lincfusion.udyamlinc.connector;
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
import com.lincfusion.udyamlinc.enterprise.LoanRecordRepository;
import com.lincfusion.udyamlinc.institution.InstitutionRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.*;
/**
* Every adapter the platform can run, and the dialect each one speaks.
*
* <p>The four simulated dialects are not decoration. Read the {@code columns}
* maps side by side and the case for a mapping layer makes itself: the days-past
* due field is {@code DPD_DAYS}, {@code OVERDUE_DAYS}, {@code DAYS_OVERDUE} and
* {@code daysPastDue} depending on whose core it is, and a bank will not be
* renaming it for us. Column names are drawn from the public documentation and
* common extract conventions for each core; a given bank's actual export will
* differ again, which is exactly why the mapping is per-profile data rather than
* per-vendor code.
*
* <p>{@code extraColumns} on each dialect carries the fields a real export ships
* that this system has promised never to store — the borrower's name, a mobile
* number. They are emitted so that the mapping screen can show them arriving and
* being dropped unmapped at the boundary. An extract that were already clean
* would let a reviewer assume the promise is kept by luck.
*/
@Component
@RequiredArgsConstructor
public class ConnectorCatalog {
private final FileDropConnector fileDropConnector;
private final EnterpriseRepository enterpriseRepository;
private final LoanRecordRepository loanRepository;
private final InstitutionRepository institutionRepository;
private final Map<String, CoreBankingConnector> byVendor = new LinkedHashMap<>();
@PostConstruct
void register() {
add(fileDropConnector);
add(simulated(finacle()));
add(simulated(flexcube()));
add(simulated(bancs()));
add(simulated(customApi()));
}
private void add(CoreBankingConnector connector) {
byVendor.put(connector.vendor(), connector);
}
private SimulatedCoreConnector simulated(SimulatedCoreConnector.Dialect dialect) {
return new SimulatedCoreConnector(dialect, enterpriseRepository, loanRepository, institutionRepository);
}
public List<CoreBankingConnector> all() {
return List.copyOf(byVendor.values());
}
public Optional<CoreBankingConnector> find(String vendor) {
return Optional.ofNullable(byVendor.get(vendor == null ? "" : vendor.trim().toUpperCase(Locale.ROOT)));
}
// ── dialects ────────────────────────────────────────────
private SimulatedCoreConnector.Dialect finacle() {
LinkedHashMap<String, String> columns = new LinkedHashMap<>();
columns.put("enterprise_ref", "ENTITY_REF");
columns.put("loan_account_ref", "CUST_ACCT_ID");
columns.put("product", "SCHM_CODE");
columns.put("sanctioned_amount", "SANCT_AMT");
columns.put("outstanding", "PRIN_OS");
columns.put("emi_amount", "INSTL_AMT");
columns.put("due_date", "NEXT_DUE_DT");
columns.put("last_paid_date", "LAST_PAID_DT");
columns.put("dpd", "DPD_DAYS");
columns.put("bucket", "ASSET_CLASS");
columns.put("closed", "ACCT_CLS_FLG");
columns.put("as_of_date", "EXTRACT_DT");
return new SimulatedCoreConnector.Dialect("FINACLE", "Infosys Finacle (simulated)", "FILE",
"Finacle exports through a scheduled CRM/Reports extract. A bank enables it, "
+ "agrees the column list, and the file lands on the drop path — at which point "
+ "this profile switches to the file-drop adapter with the mapping already made.",
columns, List.of("CUST_NAME", "MOBILE_NO", "BRANCH_CODE", "CCY"));
}
private SimulatedCoreConnector.Dialect flexcube() {
LinkedHashMap<String, String> columns = new LinkedHashMap<>();
columns.put("enterprise_ref", "CUSTOMER_REF");
columns.put("loan_account_ref", "ACCOUNT_NUMBER");
columns.put("product", "PRODUCT_CODE");
columns.put("sanctioned_amount", "AMOUNT_FINANCED");
columns.put("outstanding", "PRINCIPAL_OUTSTANDING");
columns.put("emi_amount", "INSTALMENT_AMOUNT");
columns.put("due_date", "NEXT_DUE_DATE");
columns.put("last_paid_date", "LAST_PAYMENT_DATE");
columns.put("dpd", "OVERDUE_DAYS");
columns.put("bucket", "NPA_STAGE");
columns.put("closed", "ACCOUNT_STATUS");
columns.put("as_of_date", "VALUE_DATE");
return new SimulatedCoreConnector.Dialect("FLEXCUBE", "Oracle FLEXCUBE (simulated)", "JDBC",
"FLEXCUBE is usually read through a reporting schema or an extract view rather than "
+ "the transaction tables. Needs a read-only account scoped to the loan module, "
+ "and the bank's DBA in the room.",
columns, List.of("CUSTOMER_NAME", "CONTACT_MOBILE", "BRANCH_CODE"));
}
private SimulatedCoreConnector.Dialect bancs() {
LinkedHashMap<String, String> columns = new LinkedHashMap<>();
columns.put("enterprise_ref", "PARTY_REF");
columns.put("loan_account_ref", "ARRANGEMENT_ID");
columns.put("product", "PRODUCT_GROUP");
columns.put("sanctioned_amount", "LIMIT_AMT");
columns.put("outstanding", "OUTSTANDING_BAL");
columns.put("emi_amount", "EMI");
columns.put("due_date", "DUE_DATE");
columns.put("last_paid_date", "PAID_DATE");
columns.put("dpd", "DAYS_OVERDUE");
columns.put("bucket", "CLASSIFICATION");
columns.put("closed", "STATUS");
columns.put("as_of_date", "POSITION_DATE");
return new SimulatedCoreConnector.Dialect("BANCS", "TCS BaNCS (simulated)", "FILE",
"BaNCS deployments here are typically cooperative-bank hosted. A nightly position "
+ "file is the realistic ask; a live API rarely is in year one.",
columns, List.of("PARTY_NAME", "MOBILE", "BRANCH"));
}
private SimulatedCoreConnector.Dialect customApi() {
LinkedHashMap<String, String> columns = new LinkedHashMap<>();
columns.put("enterprise_ref", "borrowerRef");
columns.put("loan_account_ref", "loanId");
columns.put("product", "productCode");
columns.put("sanctioned_amount", "sanctionedAmount");
columns.put("outstanding", "outstandingPrincipal");
columns.put("emi_amount", "instalment");
columns.put("due_date", "nextDueDate");
columns.put("last_paid_date", "lastRepaymentDate");
columns.put("dpd", "daysPastDue");
columns.put("bucket", "assetClassification");
columns.put("closed", "isClosed");
columns.put("as_of_date", "asOfDate");
return new SimulatedCoreConnector.Dialect("CUSTOM_API", "In-house LMS over REST (simulated)", "REST",
"For a lender whose loan management system is their own. They expose one paged "
+ "read-only endpoint returning these fields; the mapping absorbs whatever they "
+ "happen to call them.",
columns, List.of("borrowerName", "borrowerMobile", "branchCode"));
}
}
@@ -0,0 +1,254 @@
package com.lincfusion.udyamlinc.connector;
import com.lincfusion.udyamlinc.common.ApiResponse;
import com.lincfusion.udyamlinc.manualentry.LoanBookEntryService;
import com.lincfusion.udyamlinc.manualentry.ManualEntryPreview;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.*;
/**
* The core-banking integration surface.
*
* <p>Mounted under {@code /admin}, so the existing split holds without a new
* rule: any bank user may read the catalogue, the profiles and the run history;
* changing a mapping or triggering a sync needs a role carrying CONFIG:MANAGE.
* Reading who is connected is exactly the sort of thing a field officer should be
* able to check before trusting a DPD number.
*/
@RestController
@RequestMapping("/admin/connectors")
@RequiredArgsConstructor
public class ConnectorController {
private final ConnectorCatalog catalog;
private final ConnectorProfileRepository profileRepository;
private final ConnectorSyncService syncService;
private final FieldMapper fieldMapper;
// ── catalogue ───────────────────────────────────────────
/**
* Every adapter the platform can run, with its dialect and what it would take
* to make it live. The console renders {@code simulated} as a badge.
*/
@GetMapping("/catalog")
public ApiResponse<List<Map<String, Object>>> catalog() {
List<Map<String, Object>> out = new ArrayList<>();
for (CoreBankingConnector connector : catalog.all()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("vendor", connector.vendor());
row.put("displayName", connector.displayName());
row.put("transport", connector.transport());
row.put("simulated", connector.simulated());
row.put("nativeColumns", connector.nativeColumns());
row.put("activationNote", connector.activationNote());
row.put("suggestedMapping", suggestedMapping(connector));
out.add(row);
}
return ApiResponse.ok(out);
}
/** The canonical template a mapping targets, so the editor need not hardcode it. */
@GetMapping("/canonical-columns")
public ApiResponse<Map<String, Object>> canonicalColumns() {
return ApiResponse.ok(Map.of(
"columns", LoanBookEntryService.CANONICAL_COLUMNS,
"required", LoanBookEntryService.REQUIRED_COLUMNS,
"neverStored", LoanBookEntryService.FORBIDDEN_COLUMNS.stream().sorted().toList()));
}
/**
* A vendor's own columns, in canonical order — the draft a bank starts from.
* Empty for the file drop, which cannot know until a file arrives.
*/
private Map<String, String> suggestedMapping(CoreBankingConnector connector) {
if (!(connector instanceof SimulatedCoreConnector simulated)) {
Map<String, String> identity = new LinkedHashMap<>();
LoanBookEntryService.CANONICAL_COLUMNS.forEach(c -> identity.put(c, c));
return identity;
}
return simulated.dialectColumns();
}
// ── profiles ────────────────────────────────────────────
@GetMapping("/profiles")
public ApiResponse<List<Map<String, Object>>> profiles(@RequestParam(required = false) String institutionCode) {
List<ConnectorProfile> profiles = institutionCode == null
? profileRepository.findAllByOrderByDisplayNameAsc()
: profileRepository.findByInstitutionCodeOrderByDisplayNameAsc(institutionCode);
return ApiResponse.ok(profiles.stream().map(this::describe).toList());
}
@GetMapping("/profiles/{code}")
public ApiResponse<Map<String, Object>> profile(@PathVariable String code) {
return ApiResponse.ok(describe(profileRepository.findByCode(code)
.orElseThrow(() -> new IllegalArgumentException("No connector profile '" + code + "'"))));
}
/**
* Saves a profile's mapping and settings.
*
* <p>The mapping is validated before it is stored rather than at the next
* nightly run — see {@link FieldMapper#validate}. A mapping that would reject
* every row is refused here, where someone is looking at the screen.
*/
@PutMapping("/profiles/{code}")
public ApiResponse<Map<String, Object>> save(@PathVariable String code,
@RequestBody SaveProfileRequest request) {
ConnectorProfile profile = profileRepository.findByCode(code)
.orElseThrow(() -> new IllegalArgumentException("No connector profile '" + code + "'"));
if (request.mapping() != null) {
CoreBankingConnector connector = catalog.find(profile.getVendor()).orElse(null);
Collection<String> available = connector == null ? List.of() : connector.nativeColumns();
FieldMapper.Validation validation = fieldMapper.validate(request.mapping(), available);
if (!validation.valid()) {
return ApiResponse.fail("Mapping not saved — " + String.join("; ", validation.problems()));
}
profile.setMappingJson(fieldMapper.write(request.mapping()));
}
if (request.displayName() != null) profile.setDisplayName(request.displayName());
if (request.endpoint() != null) profile.setEndpoint(request.endpoint());
if (request.scheduleCron() != null) profile.setScheduleCron(request.scheduleCron());
if (request.autoCommit() != null) profile.setAutoCommit(request.autoCommit());
if (request.active() != null) profile.setActive(request.active());
return ApiResponse.ok("Profile saved", describe(profileRepository.save(profile)));
}
/** Creates a profile for a bank being onboarded. */
@PostMapping("/profiles")
public ApiResponse<Map<String, Object>> create(@RequestBody CreateProfileRequest request) {
if (request.code() == null || request.code().isBlank()) {
return ApiResponse.fail("A profile needs a code");
}
if (profileRepository.findByCode(request.code()).isPresent()) {
return ApiResponse.fail("A profile called '" + request.code() + "' already exists");
}
if (catalog.find(request.vendor()).isEmpty()) {
return ApiResponse.fail("No adapter for vendor '" + request.vendor() + "'");
}
CoreBankingConnector connector = catalog.find(request.vendor()).orElseThrow();
ConnectorProfile profile = ConnectorProfile.builder()
.code(request.code().trim().toUpperCase(Locale.ROOT))
.displayName(request.displayName() == null ? connector.displayName() : request.displayName())
.vendor(connector.vendor())
.institutionCode(request.institutionCode())
.endpoint(request.endpoint())
.scheduleCron(request.scheduleCron() == null ? "0 30 1 * * *" : request.scheduleCron())
.mappingJson(fieldMapper.write(suggestedMapping(connector)))
.autoCommit(false)
.active(true)
.build();
return ApiResponse.ok("Profile created", describe(profileRepository.save(profile)));
}
// ── test, sync, history ─────────────────────────────────
/** Reaches the source and shows what came back. Stages nothing, writes nothing. */
@PostMapping("/profiles/{code}/test")
public ApiResponse<Map<String, Object>> test(
@PathVariable String code,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate asOf) {
Map<String, Object> result = syncService.test(code, asOf);
return Boolean.TRUE.equals(result.get("reachable"))
? ApiResponse.ok("Reached the source", result)
: ApiResponse.ok("Could not reach the source", result);
}
/**
* Pulls the loan book and stages it. Writes only when the profile has
* auto-commit on, which is off by default.
*/
@PostMapping("/profiles/{code}/sync")
public ApiResponse<Map<String, Object>> sync(
@PathVariable String code,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate asOf,
Authentication authentication) {
ConnectorSyncService.SyncResult result =
syncService.sync(code, asOf, "MANUAL", username(authentication));
Map<String, Object> body = new LinkedHashMap<>();
body.put("run", result.run());
body.put("droppedColumns", result.droppedColumns());
body.put("extractLabel", result.extractLabel());
if (result.preview() != null) body.put("preview", describe(result.preview()));
return ApiResponse.ok(result.run().getMessage(), body);
}
@GetMapping("/runs")
public ApiResponse<List<ConnectorSyncRun>> runs(@RequestParam(required = false) String profileCode,
@RequestParam(defaultValue = "30") int limit) {
return ApiResponse.ok(syncService.runs(profileCode, limit));
}
/** The staged batch behind a run — the same preview shape the upload screen renders. */
@GetMapping("/runs/{runId}/preview")
public ApiResponse<Map<String, Object>> runPreview(@PathVariable Long runId) {
ConnectorSyncRun run = syncService.runs(null, 200).stream()
.filter(r -> r.getId().equals(runId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No such run: " + runId));
if (run.getBatchId() == null) {
return ApiResponse.fail("Run " + runId + " staged nothing — " + run.getMessage());
}
return ApiResponse.ok(describe(syncService.preview(run.getBatchId())));
}
// ── shaping ─────────────────────────────────────────────
private Map<String, Object> describe(ConnectorProfile profile) {
CoreBankingConnector connector = catalog.find(profile.getVendor()).orElse(null);
Map<String, Object> row = new LinkedHashMap<>();
row.put("code", profile.getCode());
row.put("displayName", profile.getDisplayName());
row.put("vendor", profile.getVendor());
row.put("vendorName", connector == null ? profile.getVendor() : connector.displayName());
row.put("transport", connector == null ? null : connector.transport());
row.put("simulated", connector != null && connector.simulated());
row.put("activationNote", connector == null ? null : connector.activationNote());
row.put("institutionCode", profile.getInstitutionCode());
row.put("endpoint", profile.getEndpoint());
row.put("scheduleCron", profile.getScheduleCron());
row.put("autoCommit", profile.getAutoCommit());
row.put("active", profile.getActive());
row.put("lastSyncAt", profile.getLastSyncAt());
row.put("lastStatus", profile.getLastStatus());
row.put("mapping", fieldMapper.read(profile));
row.put("sourceColumns", connector == null ? List.of() : connector.nativeColumns());
return row;
}
private Map<String, Object> describe(ManualEntryPreview preview) {
return Map.of(
"batch", preview.batch(),
"accepted", preview.accepted(),
"rejected", preview.rejected(),
"overwriting", preview.overwriting(),
"warnings", preview.warnings());
}
private String username(Authentication authentication) {
return authentication == null ? "unknown" : authentication.getName();
}
// ── request bodies ──────────────────────────────────────
public record SaveProfileRequest(String displayName, String endpoint, String scheduleCron,
Boolean autoCommit, Boolean active, Map<String, String> mapping) {
}
public record CreateProfileRequest(String code, String displayName, String vendor,
String institutionCode, String endpoint, String scheduleCron) {
}
}
@@ -0,0 +1,108 @@
package com.lincfusion.udyamlinc.connector;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* One bank's configured link to its own core — which adapter, pointed where,
* and whose column means what.
*
* <p><b>The mapping is the product.</b> The adapter for a given core is written
* once; what changes bank to bank is that this one calls the days-past-due column
* {@code DPD_DAYS} and that one calls it {@code OVERDUE_DAYS}, and that a
* cooperative bank's export has no instalment column at all. Holding that as
* per-bank data rather than per-bank code is what makes onboarding a new lender a
* configuration exercise instead of a release.
*
* <p>{@code mappingJson} is a flat {@code {canonical_column: vendor_column}}
* object. Direction matters: keyed by the canonical name, so a column the bank
* does not have is simply absent, and a vendor column nobody mapped is dropped at
* the boundary rather than carried into the store. That default — drop unless
* mapped — is what keeps a bank's borrower names out of a system that promised
* not to hold them.
*
* <p>No credential is stored here. A live deployment needs one, and where it
* goes is a decision for the pilot; a field on this entity would be the wrong
* answer written down too early.
*/
@Entity
@Table(name = "connector_profile", indexes = {
@Index(name = "ix_connector_profile_code", columnList = "code", unique = true),
@Index(name = "ix_connector_profile_institution", columnList = "institutionCode"),
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ConnectorProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Unique per deployment — "MPGB-FINACLE". */
@Column(nullable = false, unique = true, length = 40)
private String code;
@Column(nullable = false, length = 120)
private String displayName;
/** Which {@link CoreBankingConnector} runs this profile. */
@Column(nullable = false, length = 32)
private String vendor;
/** The partner bank this reads from. Matches {@code institution.code}. */
@Column(length = 32)
private String institutionCode;
/**
* Where the extract is: a drop directory glob for FILE, a URL for REST.
* Free text because the adapter is the only thing that interprets it.
*/
@Column(length = 300)
private String endpoint;
/** {@code {canonical_column: vendor_column}}. Null means "use the canonical names". */
@Column(length = 4000)
private String mappingJson;
/**
* The nightly window a real deployment would run in. Recorded even though
* nothing schedules it yet — the value of showing it is that the bank sees
* when we would be reading, and can say no.
*/
@Column(length = 40)
private String scheduleCron;
/**
* Whether a clean sync writes without a person pressing Commit.
*
* <p>Off by default, and deliberately so: the manual path's rule is that
* nothing lands until someone has seen what will, and automating a feed is
* not a reason to drop that — it is the reason to keep it.
*/
@Column(nullable = false)
private Boolean autoCommit;
@Column(nullable = false)
private Boolean active;
private LocalDateTime lastSyncAt;
/** SUCCESS | PARTIAL | FAILED | NEVER_RUN — denormalised for the console list. */
@Column(length = 16)
private String lastStatus;
private LocalDateTime createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
if (active == null) active = Boolean.TRUE;
if (autoCommit == null) autoCommit = Boolean.FALSE;
if (lastStatus == null) lastStatus = "NEVER_RUN";
}
}
@@ -0,0 +1,15 @@
package com.lincfusion.udyamlinc.connector;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ConnectorProfileRepository extends JpaRepository<ConnectorProfile, Long> {
Optional<ConnectorProfile> findByCode(String code);
List<ConnectorProfile> findAllByOrderByDisplayNameAsc();
List<ConnectorProfile> findByInstitutionCodeOrderByDisplayNameAsc(String institutionCode);
}
@@ -0,0 +1,79 @@
package com.lincfusion.udyamlinc.connector;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* One configured link per partner bank, so the integration screen has something
* to be about on a fresh database.
*
* <p>Guarded per profile code rather than by {@code count() > 0}. That pattern
* has bitten this codebase twice: anything added to a seeder after a database was
* first populated never reaches it, and the symptom is a screen that is empty for
* everyone except whoever last dropped their schema.
*
* <p>The three lenders are on three different cores on purpose. It is also true
* of the sector — a regional rural bank, a district cooperative bank and a small
* finance bank will not be on one core — and a demo where every bank is on
* Finacle would quietly suggest that the mapping layer is optional.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ConnectorProfileSeeder {
private final ConnectorProfileRepository profileRepository;
private final ConnectorCatalog catalog;
private final FieldMapper fieldMapper;
private record Seed(String code, String displayName, String vendor, String institutionCode,
String endpoint, String cron) {
}
public void seed() {
List<Seed> seeds = List.of(
new Seed("MPGB-FINACLE", "MP Gramin Bank — Finacle nightly extract",
"FINACLE", "MPGB", "loanbook-*.csv", "0 30 1 * * *"),
new Seed("SEHDCCB-DROP", "Sehore DCCB — SFTP file drop",
FileDropConnector.VENDOR, "SEHDCCB", "sehdccb-loanbook-*.{csv,xlsx}", "0 0 2 * * *"),
new Seed("VSFB-LMS", "Vindhya SFB — in-house LMS over REST",
"CUSTOM_API", "VSFB", "https://lms.vindhyasfb.example/api/loans", "0 15 1 * * *"));
int written = 0;
for (Seed seed : seeds) {
if (profileRepository.findByCode(seed.code()).isPresent()) continue;
CoreBankingConnector connector = catalog.find(seed.vendor()).orElse(null);
if (connector == null) continue;
profileRepository.save(ConnectorProfile.builder()
.code(seed.code())
.displayName(seed.displayName())
.vendor(connector.vendor())
.institutionCode(seed.institutionCode())
.endpoint(seed.endpoint())
.scheduleCron(seed.cron())
.mappingJson(fieldMapper.write(mappingFor(connector)))
// Off, on every seeded profile. A demo that writes to the loan
// book the first time someone presses Sync would be a demo that
// taught the wrong thing about how this is meant to run.
.autoCommit(false)
.active(true)
.build());
written++;
}
if (written > 0) log.info("Seeded {} core-banking connector profiles", written);
}
private java.util.Map<String, String> mappingFor(CoreBankingConnector connector) {
if (connector instanceof SimulatedCoreConnector simulated) return simulated.dialectColumns();
// A file drop has no declared dialect — start from the canonical template,
// which is what the bank is sent when it asks what to put in the file.
java.util.LinkedHashMap<String, String> identity = new java.util.LinkedHashMap<>();
com.lincfusion.udyamlinc.manualentry.LoanBookEntryService.CANONICAL_COLUMNS
.forEach(c -> identity.put(c, c));
return identity;
}
}
@@ -0,0 +1,96 @@
package com.lincfusion.udyamlinc.connector;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* One attempt to pull a loan book, kept whether it worked or not.
*
* <p>Modelled on {@code IngestionRun} for the same reason: a feed that fails
* silently is worse than one that is absent, because the scores keep being
* computed and nothing about them says the loan book stopped moving three weeks
* ago. A failed run is a row here, and the console reads it.
*
* <p>{@code batchId} points at the {@code manual_entry_batch} the rows were
* staged into, so a connector sync is inspectable through exactly the same
* preview screen an upload is — accepted, rejected, and what it would overwrite.
*
* <p>{@code simulated} is stamped per run rather than read from the profile. A
* profile that is switched from a simulated adapter to a live one must not
* retroactively make its old runs look real.
*/
@Entity
@Table(name = "connector_sync_run", indexes = {
@Index(name = "ix_sync_run_profile", columnList = "profileCode"),
@Index(name = "ix_sync_run_started", columnList = "startedAt"),
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ConnectorSyncRun {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 40)
private String profileCode;
@Column(length = 32)
private String vendor;
@Column(length = 32)
private String institutionCode;
/** MANUAL | SCHEDULED — who asked for this run. */
@Column(nullable = false, length = 16)
private String trigger;
@Column(nullable = false, length = 64)
private String triggeredBy;
/** SUCCESS | PARTIAL | FAILED. PARTIAL means rows were rejected but some landed. */
@Column(nullable = false, length = 16)
private String status;
/** True when the rows were generated by a stub adapter rather than read from a core. */
@Column(nullable = false)
private Boolean simulated;
/** The date the extract describes, not the date it was pulled. */
private java.time.LocalDate asOfDate;
private Integer rowsFetched;
private Integer rowsAccepted;
private Integer rowsRejected;
private Integer rowsOverwriting;
/** Vendor columns nobody mapped, and therefore never crossed the boundary. */
private Integer columnsDropped;
/** The staged batch, so the run opens in the same preview an upload does. */
private Long batchId;
/** Whether this run's rows were written, or are still sitting in preview. */
@Column(nullable = false)
private Boolean committed;
private Integer durationMs;
@Column(nullable = false)
private LocalDateTime startedAt;
@Column(length = 600)
private String message;
@PrePersist
void onCreate() {
if (startedAt == null) startedAt = LocalDateTime.now();
if (committed == null) committed = Boolean.FALSE;
if (simulated == null) simulated = Boolean.TRUE;
}
}
@@ -0,0 +1,13 @@
package com.lincfusion.udyamlinc.connector;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ConnectorSyncRunRepository extends JpaRepository<ConnectorSyncRun, Long> {
List<ConnectorSyncRun> findAllByOrderByStartedAtDesc(Pageable pageable);
List<ConnectorSyncRun> findByProfileCodeOrderByStartedAtDesc(String profileCode, Pageable pageable);
}
@@ -0,0 +1,245 @@
package com.lincfusion.udyamlinc.connector;
import com.lincfusion.udyamlinc.manualentry.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
/**
* Runs one sync: fetch, map, validate, stage, and record what happened.
*
* <p><b>The validation is not this class's.</b> Mapped rows are handed to
* {@code LoanBookEntryService.stage}, the same method a spreadsheet upload goes
* through, and land in the same {@code manual_entry_batch} with the same preview.
* Writing a second validator for the automated path would be the obvious shape
* and the wrong one — the no-PII rule, the unknown-enterprise reject and the
* point-in-time as-of date would then be enforced in one path and re-implemented,
* eventually differently, in the other.
*
* <p>Auto-commit is off by default on every profile, so a sync stages and stops.
* That preserves the manual path's rule — nothing is written until someone has
* seen what will be — and a bank that wants the nightly feed unattended turns it
* on per profile, knowingly.
*
* <p>A failed run is still a run. The {@link ConnectorSyncRun} row is written
* whether the fetch threw, returned nothing, or worked, because a feed that
* stopped three weeks ago must be visible as something other than silence.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ConnectorSyncService {
private final ConnectorCatalog catalog;
private final ConnectorProfileRepository profileRepository;
private final ConnectorSyncRunRepository runRepository;
private final FieldMapper fieldMapper;
private final LoanBookEntryService loanBookEntryService;
private final ManualEntryBatchRepository batchRepository;
private final ManualEntryLineRepository lineRepository;
/** A sync and everything the console needs to explain it. */
public record SyncResult(ConnectorSyncRun run, ManualEntryPreview preview,
List<String> droppedColumns, String extractLabel) {
}
// ── running a sync ──────────────────────────────────────
public SyncResult sync(String profileCode, LocalDate asOf, String trigger, String username) {
ConnectorProfile profile = profileRepository.findByCode(profileCode)
.orElseThrow(() -> new IllegalArgumentException("No connector profile '" + profileCode + "'"));
CoreBankingConnector connector = catalog.find(profile.getVendor())
.orElseThrow(() -> new IllegalStateException(
"Profile " + profileCode + " names vendor '" + profile.getVendor()
+ "', which no adapter implements"));
LocalDate effectiveAsOf = asOf == null ? LocalDate.now() : asOf;
LocalDateTime startedAt = LocalDateTime.now();
ConnectorSyncRun run = ConnectorSyncRun.builder()
.profileCode(profile.getCode())
.vendor(profile.getVendor())
.institutionCode(profile.getInstitutionCode())
.trigger(trigger)
.triggeredBy(username)
.simulated(connector.simulated())
.asOfDate(effectiveAsOf)
.startedAt(startedAt)
.committed(false)
.build();
try {
CoreBankingConnector.Extract extract = connector.fetch(profile, effectiveAsOf);
return complete(profile, connector, run, extract, effectiveAsOf, startedAt, username);
} catch (Exception e) {
log.warn("Connector sync {} failed: {}", profileCode, e.toString());
run.setStatus("FAILED");
run.setRowsFetched(0);
run.setDurationMs(elapsedMs(startedAt));
run.setMessage(e.getMessage() == null ? e.toString() : e.getMessage());
runRepository.save(run);
stampProfile(profile, run);
return new SyncResult(run, null, List.of(), null);
}
}
private SyncResult complete(ConnectorProfile profile, CoreBankingConnector connector, ConnectorSyncRun run,
CoreBankingConnector.Extract extract, LocalDate asOf,
LocalDateTime startedAt, String username) {
run.setRowsFetched(extract.rows().size());
if (extract.rows().isEmpty()) {
// Nothing to read is a normal night, not a fault. Saying FAILED here
// would train everyone to ignore the status.
run.setStatus("SUCCESS");
run.setRowsAccepted(0);
run.setRowsRejected(0);
run.setColumnsDropped(0);
run.setDurationMs(elapsedMs(startedAt));
run.setMessage("Nothing to read — " + extract.label());
runRepository.save(run);
stampProfile(profile, run);
return new SyncResult(run, null, List.of(), extract.label());
}
Map<String, String> mapping = fieldMapper.read(profile);
FieldMapper.Mapped mapped = fieldMapper.apply(extract, mapping);
if (!mapped.missingRequired().isEmpty()) {
run.setStatus("FAILED");
run.setRowsAccepted(0);
run.setRowsRejected(extract.rows().size());
run.setColumnsDropped(mapped.droppedColumns().size());
run.setDurationMs(elapsedMs(startedAt));
run.setMessage("Mapping is incomplete — " + String.join(", ", mapped.missingRequired())
+ " not mapped, so no row can be validated");
runRepository.save(run);
stampProfile(profile, run);
return new SyncResult(run, null, mapped.droppedColumns(), extract.label());
}
String filename = "%s · %s".formatted(profile.getCode(), extract.label());
ManualEntryPreview preview = loanBookEntryService.stage(
new TabularParser.Sheet(mapped.headers(), mapped.rows()),
filename, username, profile.getCode());
boolean committed = false;
if (Boolean.TRUE.equals(profile.getAutoCommit()) && !preview.accepted().isEmpty()) {
preview = loanBookEntryService.commit(preview.batch().getId(), username);
committed = true;
}
run.setRowsAccepted(preview.accepted().size());
run.setRowsRejected(preview.rejected().size());
run.setRowsOverwriting(preview.overwriting().size());
run.setColumnsDropped(mapped.droppedColumns().size());
run.setBatchId(preview.batch().getId());
run.setCommitted(committed);
run.setDurationMs(elapsedMs(startedAt));
run.setStatus(preview.rejected().isEmpty() ? "SUCCESS" : "PARTIAL");
run.setMessage(describe(connector, preview, mapped, committed));
runRepository.save(run);
stampProfile(profile, run);
return new SyncResult(run, preview, mapped.droppedColumns(), extract.label());
}
private String describe(CoreBankingConnector connector, ManualEntryPreview preview,
FieldMapper.Mapped mapped, boolean committed) {
StringBuilder sb = new StringBuilder();
sb.append(preview.accepted().size()).append(" accepted · ")
.append(preview.rejected().size()).append(" rejected");
if (!mapped.droppedColumns().isEmpty()) {
sb.append(" · ").append(mapped.droppedColumns().size())
.append(" unmapped columns dropped at the boundary");
}
sb.append(committed ? " · written" : " · staged, awaiting commit");
if (connector.simulated()) {
sb.append(" · SIMULATED extract, not a live core");
}
return sb.length() > 590 ? sb.substring(0, 590) : sb.toString();
}
private void stampProfile(ConnectorProfile profile, ConnectorSyncRun run) {
profile.setLastSyncAt(run.getStartedAt());
profile.setLastStatus(run.getStatus());
profileRepository.save(profile);
}
private int elapsedMs(LocalDateTime startedAt) {
return (int) Duration.between(startedAt, LocalDateTime.now()).toMillis();
}
// ── connection test ─────────────────────────────────────
/**
* Fetches without staging anything, so a bank operations user can see the
* bank's own columns and a sample row before drafting a mapping.
*
* <p>Separate from a sync on purpose: the first thing anyone does with a new
* connector is press the button to see whether it can reach anything, and
* that must not be a thing that writes.
*/
public Map<String, Object> test(String profileCode, LocalDate asOf) {
ConnectorProfile profile = profileRepository.findByCode(profileCode)
.orElseThrow(() -> new IllegalArgumentException("No connector profile '" + profileCode + "'"));
CoreBankingConnector connector = catalog.find(profile.getVendor())
.orElseThrow(() -> new IllegalStateException("No adapter for vendor " + profile.getVendor()));
Map<String, Object> result = new LinkedHashMap<>();
result.put("profileCode", profile.getCode());
result.put("vendor", connector.vendor());
result.put("simulated", connector.simulated());
result.put("transport", connector.transport());
try {
CoreBankingConnector.Extract extract =
connector.fetch(profile, asOf == null ? LocalDate.now() : asOf);
Map<String, String> mapping = fieldMapper.read(profile);
FieldMapper.Mapped mapped = fieldMapper.apply(extract, mapping);
result.put("reachable", true);
result.put("label", extract.label());
result.put("rowsAvailable", extract.rows().size());
result.put("sourceColumns", extract.headers().isEmpty()
? sourceColumnsFrom(extract) : extract.headers());
result.put("sampleRow", extract.rows().isEmpty() ? Map.of() : extract.rows().get(0));
result.put("mappedSampleRow", mapped.rows().isEmpty() ? Map.of() : mapped.rows().get(0));
result.put("droppedColumns", mapped.droppedColumns());
result.put("missingRequired", mapped.missingRequired());
} catch (Exception e) {
result.put("reachable", false);
result.put("message", e.getMessage() == null ? e.toString() : e.getMessage());
result.put("sourceColumns", connector.nativeColumns());
}
return result;
}
/** A file drop declares no columns up front — read them off the extract itself. */
private List<String> sourceColumnsFrom(CoreBankingConnector.Extract extract) {
return extract.rows().isEmpty() ? List.of() : List.copyOf(extract.rows().get(0).keySet());
}
// ── history ─────────────────────────────────────────────
public List<ConnectorSyncRun> runs(String profileCode, int limit) {
PageRequest page = PageRequest.of(0, Math.min(limit, 200));
return profileCode == null
? runRepository.findAllByOrderByStartedAtDesc(page)
: runRepository.findByProfileCodeOrderByStartedAtDesc(profileCode, page);
}
/** The staged batch behind a run, so the console can open the same preview an upload shows. */
public ManualEntryPreview preview(Long batchId) {
ManualEntryBatch batch = batchRepository.findById(batchId)
.orElseThrow(() -> new IllegalArgumentException("No such batch: " + batchId));
return new ManualEntryPreview(batch, lineRepository.findByBatchIdOrderByRowNumber(batchId));
}
}
@@ -0,0 +1,78 @@
package com.lincfusion.udyamlinc.connector;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* One way of getting a loan book out of a lender's core banking or loan
* management system.
*
* <p><b>Why an interface and not an integration.</b> Every bank's core is
* different — Finacle, Flexcube, BaNCS, a cooperative bank's in-house system —
* and each names the same eleven fields differently, exposes them over a
* different transport, and will only agree a schema during its own onboarding.
* There is no single integration to build ahead of that conversation. What can
* be built ahead of it is the shape of the conversation: a connector produces a
* table of the bank's own columns, a per-bank mapping profile says which of its
* columns is the DPD, and everything downstream is identical regardless of which
* core it came from.
*
* <p><b>A connector never writes.</b> It fetches and returns rows. Mapping,
* validation, staging and commit belong to {@link ConnectorSyncService}, which
* hands them to the same {@code LoanBookEntryService} a manual upload goes
* through. An adapter that could write would be an adapter that could bypass the
* no-PII rule, and the point of routing both paths through one validator is that
* neither can.
*
* <p>{@link #simulated()} is not a debug flag. It is rendered in the console and
* stamped on every sync run, because a demo that cannot be told apart from a
* live feed is a demo that will eventually be mistaken for one.
*/
public interface CoreBankingConnector {
/** Stable code — {@code FINACLE}, {@code FILE_DROP}. Matches a profile's vendor. */
String vendor();
/** What a bank operations user would call it. */
String displayName();
/** How the extract physically arrives: {@code FILE}, {@code REST}, {@code JDBC}. */
String transport();
/**
* True when the rows are generated rather than read from a lender's system.
* Travels all the way to the console badge and the sync-run record.
*/
boolean simulated();
/**
* The column names this core exports, for the mapping editor to offer.
*
* <p>Declared rather than discovered so a mapping can be drafted before the
* bank has opened anything — which is the order these conversations actually
* happen in.
*/
List<String> nativeColumns();
/**
* One sentence on what a bank would have to do to make this real. Shown in
* the console beside a simulated connector so the gap is stated by the
* product rather than discovered in a meeting.
*/
String activationNote();
/** Reads the extract for {@code asOf}. Returns the bank's own column names, unmapped. */
Extract fetch(ConnectorProfile profile, LocalDate asOf) throws Exception;
/**
* What a connector returns: the vendor's headers and rows, plus a label
* naming where it came from for the audit trail.
*/
record Extract(String label, List<String> headers, List<Map<String, String>> rows) {
public static Extract empty(String label) {
return new Extract(label, List.of(), List.of());
}
}
}
@@ -0,0 +1,185 @@
package com.lincfusion.udyamlinc.connector;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lincfusion.udyamlinc.manualentry.LoanBookEntryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.*;
/**
* Turns one bank's column names into the canonical loan-book template.
*
* <p>This is the whole answer to "every bank's core is different". The adapters
* differ by transport; the banks differ by vocabulary; and everything downstream
* of this class sees one template. Adding a lender is editing a JSON object,
* which is a thing a bank operations user can do in a screen — not a release.
*
* <p><b>Unmapped columns are dropped, not carried.</b> The direction of the map
* makes that structural rather than a rule someone has to remember: it is keyed
* by canonical name, so a vendor column nobody asked for has no way through. That
* is what keeps {@code CUST_NAME} and {@code MOBILE_NO} out of a system that
* promised not to hold them, and it is why {@link #validate} refuses a mapping
* that points a canonical column at an identifying one rather than accepting it
* and failing later at the parse boundary.
*/
@Component
@Slf4j
public class FieldMapper {
/** As elsewhere in this codebase, constructed rather than injected — see the Boot 4 note. */
private static final ObjectMapper JSON = new ObjectMapper();
/** What a mapping did to one extract. */
public record Mapped(List<String> headers, List<Map<String, String>> rows,
List<String> droppedColumns, List<String> missingRequired) {
}
/** Whether a mapping can be saved, and what is wrong with it if not. */
public record Validation(boolean valid, List<String> problems, List<String> missingRequired,
List<String> identifyingColumns) {
}
/**
* Reads a stored mapping. A null or unreadable one means "the extract already
* uses the canonical names" — which is true of the loan-book template itself,
* so a bank that adopts our template needs no mapping at all.
*/
public Map<String, String> read(ConnectorProfile profile) {
String json = profile == null ? null : profile.getMappingJson();
if (json == null || json.isBlank()) return identity();
try {
Map<?, ?> raw = JSON.readValue(json, Map.class);
Map<String, String> mapping = new LinkedHashMap<>();
raw.forEach((k, v) -> {
if (k != null && v != null && !String.valueOf(v).isBlank()) {
mapping.put(String.valueOf(k), String.valueOf(v));
}
});
return mapping;
} catch (Exception e) {
log.warn("Connector {} has an unreadable mapping, falling back to canonical names: {}",
profile.getCode(), e.toString());
return identity();
}
}
public String write(Map<String, String> mapping) {
try {
return JSON.writeValueAsString(mapping == null ? Map.of() : mapping);
} catch (Exception e) {
throw new IllegalArgumentException("Could not store that mapping: " + e.getMessage());
}
}
private Map<String, String> identity() {
Map<String, String> mapping = new LinkedHashMap<>();
LoanBookEntryService.CANONICAL_COLUMNS.forEach(c -> mapping.put(c, c));
return mapping;
}
// ── validation ──────────────────────────────────────────
/**
* Checks a mapping before it is saved, against the columns the extract
* actually carries when those are known.
*
* <p>Refusing early matters: the alternative is a mapping that saves cleanly,
* runs nightly, and rejects every row for the next fortnight because the
* as-of-date column was never pointed anywhere.
*/
public Validation validate(Map<String, String> mapping, Collection<String> availableColumns) {
List<String> problems = new ArrayList<>();
List<String> missing = new ArrayList<>();
for (String required : LoanBookEntryService.REQUIRED_COLUMNS) {
String source = mapping.get(required);
if (source == null || source.isBlank()) {
missing.add(required);
}
}
if (!missing.isEmpty()) {
problems.add("not mapped, and every row will be rejected without them: " + String.join(", ", missing));
}
for (Map.Entry<String, String> entry : mapping.entrySet()) {
if (!LoanBookEntryService.CANONICAL_COLUMNS.contains(entry.getKey())) {
problems.add("'" + entry.getKey() + "' is not a loan-book column");
}
if (availableColumns != null && !availableColumns.isEmpty()
&& entry.getValue() != null && !entry.getValue().isBlank()
&& availableColumns.stream().noneMatch(c -> c.equalsIgnoreCase(entry.getValue()))) {
problems.add("the extract has no column called '" + entry.getValue() + "'");
}
}
// A mapping that renames CUST_NAME to 'product' would smuggle an
// identifying field past a parse-time check that only looks at headers.
List<String> identifying = LoanBookEntryService.forbiddenHeaders(
mapping.values().stream().filter(Objects::nonNull).toList());
if (!identifying.isEmpty()) {
problems.add("these carry borrower identity and cannot be mapped in: "
+ String.join(", ", identifying));
}
return new Validation(problems.isEmpty(), problems, missing, identifying);
}
// ── application ─────────────────────────────────────────
/**
* Rewrites an extract into the canonical template.
*
* <p>Reads case-, space- and underscore-insensitively for the same reason
* {@code TabularParser} does — a bank that ships {@code Next_Due_Dt} where its
* documentation said {@code NEXT_DUE_DT} has not made a mistake worth failing
* a nightly run over.
*/
public Mapped apply(CoreBankingConnector.Extract extract, Map<String, String> mapping) {
Set<String> used = new HashSet<>();
List<String> headers = new ArrayList<>();
for (String canonical : LoanBookEntryService.CANONICAL_COLUMNS) {
String source = mapping.get(canonical);
if (source != null && !source.isBlank()) {
headers.add(canonical);
used.add(normalise(source));
}
}
List<Map<String, String>> rows = new ArrayList<>();
for (Map<String, String> row : extract.rows()) {
Map<String, String> mapped = new LinkedHashMap<>();
for (String canonical : headers) {
mapped.put(canonical, valueOf(row, mapping.get(canonical)));
}
rows.add(mapped);
}
List<String> dropped = extract.headers().stream()
.filter(Objects::nonNull)
.filter(h -> !used.contains(normalise(h)))
.toList();
List<String> missingRequired = LoanBookEntryService.REQUIRED_COLUMNS.stream()
.filter(c -> !headers.contains(c))
.toList();
return new Mapped(headers, rows, dropped, missingRequired);
}
private String valueOf(Map<String, String> row, String column) {
if (column == null) return null;
for (Map.Entry<String, String> entry : row.entrySet()) {
if (entry.getKey() != null && normalise(entry.getKey()).equals(normalise(column))) {
String value = entry.getValue();
return value == null || value.isBlank() ? null : value.trim();
}
}
return null;
}
private String normalise(String s) {
return s.replace("_", "").replace(" ", "").replace("-", "").toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,119 @@
package com.lincfusion.udyamlinc.connector;
import com.lincfusion.udyamlinc.manualentry.TabularParser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/**
* The one connector that actually reads a lender's data.
*
* <p>A bank drops its nightly extract on a path — an SFTP landing directory, a
* mounted share — and this picks up the newest file matching the profile's
* pattern. It is deliberately the least clever option available, and it is also
* the one most likely to be what a district cooperative bank can actually do in
* month one: no API to expose, no core vendor change request, no firewall
* conversation beyond one they have already had for their regulatory returns.
*
* <p>SFTP polling itself is not implemented — this reads a local directory, on
* the assumption that the drop is mounted or synced there by something that
* already exists at the bank. That is a real deployment pattern rather than a
* shortcut, but it is worth being plain that no credential is held and no remote
* host is contacted by this class.
*
* <p>The file is left where it was found. Moving or deleting it would make a
* failed sync unrepeatable, and the first thing anyone wants after a bad run is
* to run it again against the same bytes.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class FileDropConnector implements CoreBankingConnector {
public static final String VENDOR = "FILE_DROP";
private final TabularParser parser;
@Value("${udyam.connector.file-drop.dir:./data/cbs-drop}")
private String dropDir;
@Override
public String vendor() {
return VENDOR;
}
@Override
public String displayName() {
return "Nightly file drop (SFTP / mounted share)";
}
@Override
public String transport() {
return "FILE";
}
@Override
public boolean simulated() {
return false;
}
/**
* Unknown until a file arrives — a drop carries whatever the bank's export
* job produces. The mapping editor discovers these from the last extract
* rather than being told them up front.
*/
@Override
public List<String> nativeColumns() {
return List.of();
}
@Override
public String activationNote() {
return "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.";
}
@Override
public Extract fetch(ConnectorProfile profile, LocalDate asOf) throws Exception {
Path dir = Paths.get(dropDir).toAbsolutePath().normalize();
if (!Files.isDirectory(dir)) {
throw new IllegalStateException("No drop directory at " + dir
+ " — create it, or point udyam.connector.file-drop.dir somewhere else");
}
String pattern = (profile.getEndpoint() == null || profile.getEndpoint().isBlank())
? "*.{csv,xlsx}"
: profile.getEndpoint().trim();
PathMatcher matcher = dir.getFileSystem().getPathMatcher("glob:" + pattern);
Optional<Path> newest;
try (var paths = Files.list(dir)) {
newest = paths
.filter(Files::isRegularFile)
.filter(p -> matcher.matches(p.getFileName()))
.max(Comparator.comparingLong(p -> p.toFile().lastModified()));
}
if (newest.isEmpty()) {
// Not an error. A bank that has not dropped tonight's file yet is a
// normal state, and it must read differently from a broken connector.
return Extract.empty("no file matching '" + pattern + "' in " + dir);
}
Path file = newest.get();
try (InputStream in = Files.newInputStream(file)) {
TabularParser.Sheet sheet = parser.parse(in, file.getFileName().toString());
log.info("File-drop connector {} read {} rows from {}",
profile.getCode(), sheet.rows().size(), file.getFileName());
return new Extract(file.getFileName().toString(), sheet.headers(), sheet.rows());
}
}
}
@@ -0,0 +1,242 @@
package com.lincfusion.udyamlinc.connector;
import com.lincfusion.udyamlinc.enterprise.Enterprise;
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
import com.lincfusion.udyamlinc.enterprise.LoanRecord;
import com.lincfusion.udyamlinc.enterprise.LoanRecordRepository;
import com.lincfusion.udyamlinc.institution.InstitutionRepository;
import lombok.RequiredArgsConstructor;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
/**
* A core banking system that does not exist, exporting in the dialect of one
* that does.
*
* <p><b>What this is for, and what it is not.</b> Integrating with Finacle,
* Flexcube or BaNCS is not a technical unknown — it is an onboarding process per
* bank, with a schema agreed in a room. Until that room, the thing worth
* demonstrating is not a fake feed but the <i>shape of the seam</i>: that each
* core names the same eleven fields differently, that a mapping profile is what
* reconciles them, and that nothing after the mapping cares which core it was.
* Running the same portfolio out through four dialects makes that visible in a
* way a paragraph of intent does not.
*
* <p>Every row it produces is stamped simulated, all the way to the console badge
* and the sync-run record. That matters more than it sounds: the failure mode
* here is not a wrong number, it is a demo mistaken for a pilot.
*
* <p>The rows are derived from the enterprises and loan records already in the
* database rather than invented, so a mapped sync validates against real
* enterprise ids and exercises the real reject paths. The drift applied to DPD is
* seeded from the account reference, so the same extract is reproducible — a
* connector whose output changed every call would make a preview meaningless.
*/
@RequiredArgsConstructor
public class SimulatedCoreConnector implements CoreBankingConnector {
/**
* A core's column names for the eleven fields that matter, keyed by canonical
* name. This is the entire per-vendor difference, which is rather the point.
*/
public record Dialect(String vendor, String displayName, String transport, String activationNote,
LinkedHashMap<String, String> columns, List<String> extraColumns) {
}
private final Dialect dialect;
private final EnterpriseRepository enterpriseRepository;
private final LoanRecordRepository loanRepository;
private final InstitutionRepository institutionRepository;
@Override
public String vendor() {
return dialect.vendor();
}
@Override
public String displayName() {
return dialect.displayName();
}
@Override
public String transport() {
return dialect.transport();
}
@Override
public boolean simulated() {
return true;
}
@Override
public List<String> nativeColumns() {
List<String> all = new ArrayList<>(dialect.columns().values());
all.addAll(dialect.extraColumns());
return all;
}
@Override
public String activationNote() {
return dialect.activationNote();
}
/**
* This core's {@code {canonical: vendor}} map — the draft mapping the console
* offers when a bank on this core is first onboarded.
*/
public LinkedHashMap<String, String> dialectColumns() {
return new LinkedHashMap<>(dialect.columns());
}
@Override
public Extract fetch(ConnectorProfile profile, LocalDate asOf) {
List<Enterprise> enterprises = enterprisesFor(profile);
if (enterprises.isEmpty()) {
return Extract.empty("no enterprises for " + profile.getInstitutionCode());
}
List<Map<String, String>> rows = new ArrayList<>();
for (Enterprise enterprise : enterprises) {
rows.addAll(rowsFor(enterprise, asOf));
}
return new Extract(
"%s simulated extract · %d accounts as at %s".formatted(dialect.vendor(), rows.size(), asOf),
nativeColumns(), rows);
}
/** The bank's own book, when the profile names one; otherwise everything. */
private List<Enterprise> enterprisesFor(ConnectorProfile profile) {
if (profile.getInstitutionCode() == null || profile.getInstitutionCode().isBlank()) {
return enterpriseRepository.findAll();
}
return institutionRepository.findByCode(profile.getInstitutionCode())
.map(institution -> enterpriseRepository.findAll().stream()
.filter(e -> Objects.equals(e.getInstitutionId(), institution.getId()))
.toList())
.orElseGet(List::of);
}
/**
* One row per open loan, carried forward to {@code asOf}.
*
* <p>An enterprise with no loan record yet still produces a row, built from
* the outstanding and instalment the enterprise itself carries. Skipping them
* would make the simulated extract cover only the six enterprises that already
* have a loan book, which is precisely the gap this feature exists to discuss.
*/
private List<Map<String, String>> rowsFor(Enterprise enterprise, LocalDate asOf) {
List<LoanRecord> open = loanRepository.findByEnterpriseIdAndClosedFalse(enterprise.getEnterpriseId());
if (open.isEmpty()) {
return List.of(syntheticRow(enterprise, asOf));
}
// Latest extract per account, carried forward to the requested date.
Map<String, LoanRecord> latest = new LinkedHashMap<>();
for (LoanRecord loan : open) {
latest.merge(loan.getAccountRef(), loan,
(a, b) -> a.getAsOfDate().isAfter(b.getAsOfDate()) ? a : b);
}
List<Map<String, String>> rows = new ArrayList<>();
for (LoanRecord loan : latest.values()) {
rows.add(rowFrom(enterprise, loan, asOf));
}
return rows;
}
private Map<String, String> syntheticRow(Enterprise enterprise, LocalDate asOf) {
BigDecimal outstanding = enterprise.getLoanOutstanding() == null
? BigDecimal.ZERO : enterprise.getLoanOutstanding();
BigDecimal instalment = enterprise.getMonthlyInstalment() == null
? BigDecimal.ZERO : enterprise.getMonthlyInstalment();
int dpd = drift(enterprise.getEnterpriseId(), enterprise.getBand());
return emit(enterprise, enterprise.getEnterpriseId() + "-LN1", "TERM_LOAN",
outstanding.multiply(BigDecimal.valueOf(1.25)).setScale(0, RoundingMode.HALF_UP),
outstanding, instalment,
asOf.plusDays(12), asOf.minusDays(Math.max(1, dpd)), dpd, asOf);
}
private Map<String, String> rowFrom(Enterprise enterprise, LoanRecord loan, LocalDate asOf) {
int base = loan.getDpd() == null ? 0 : loan.getDpd();
int dpd = Math.max(0, base + drift(loan.getAccountRef(), enterprise.getBand()) - 2);
return emit(enterprise, loan.getAccountRef(), loan.getProduct(),
loan.getSanctionedAmount(), loan.getOutstandingAmount(), loan.getInstalmentAmount(),
loan.getNextDueDate() == null ? asOf.plusDays(10) : loan.getNextDueDate(),
loan.getLastPaidDate() == null ? asOf.minusDays(Math.max(1, dpd)) : loan.getLastPaidDate(),
dpd, asOf);
}
/** Writes the values out under this core's own column names. */
private Map<String, String> emit(Enterprise enterprise, String accountRef, String product,
BigDecimal sanctioned, BigDecimal outstanding, BigDecimal instalment,
LocalDate dueDate, LocalDate lastPaid, int dpd, LocalDate asOf) {
Map<String, String> canonical = new LinkedHashMap<>();
canonical.put("enterprise_ref", enterprise.getEnterpriseId());
canonical.put("loan_account_ref", accountRef);
canonical.put("product", product == null ? "TERM_LOAN" : product);
canonical.put("sanctioned_amount", plain(sanctioned));
canonical.put("outstanding", plain(outstanding));
canonical.put("emi_amount", plain(instalment));
canonical.put("due_date", dueDate.toString());
canonical.put("last_paid_date", lastPaid.toString());
canonical.put("dpd", String.valueOf(dpd));
canonical.put("bucket", bucket(dpd));
canonical.put("closed", "false");
canonical.put("as_of_date", asOf.toString());
Map<String, String> row = new LinkedHashMap<>();
dialect.columns().forEach((canonicalName, vendorColumn) ->
row.put(vendorColumn, canonical.get(canonicalName)));
// The columns a real export carries and this system will not store. Emitted
// deliberately: the mapping screen's job is to show them being dropped at
// the boundary, and a tidy extract would never exercise that.
for (String extra : dialect.extraColumns()) {
row.put(extra, extraValue(extra, enterprise, accountRef));
}
return row;
}
private String extraValue(String column, Enterprise enterprise, String accountRef) {
String normalised = column.replace("_", "").toLowerCase(Locale.ROOT);
if (normalised.contains("name")) return enterprise.getName();
if (normalised.contains("mobile") || normalised.contains("phone")) return "9###" + hash(accountRef, 900000);
if (normalised.contains("branch")) return "BR-" + (100 + hash(accountRef, 40));
if (normalised.contains("ccy") || normalised.contains("currency")) return "INR";
return String.valueOf(hash(accountRef, 9999));
}
/**
* Deterministic per-account drift, so the same pull twice gives the same rows.
* Stressed books move more, which is what makes a simulated extract worth
* looking at rather than a flat line.
*/
private int drift(String key, String band) {
int spread = "RED".equals(band) ? 55 : "WATCH".equals(band) ? 18 : 4;
return hash(key, spread + 1);
}
private int hash(String key, int bound) {
if (bound <= 0) return 0;
int h = key == null ? 7 : key.hashCode();
return Math.abs(h % bound);
}
private String bucket(int dpd) {
if (dpd == 0) return "0";
if (dpd <= 30) return "1-30";
if (dpd <= 60) return "31-60";
if (dpd <= 90) return "61-90";
return "90+";
}
private String plain(BigDecimal value) {
return value == null ? null : value.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
}
@@ -45,6 +45,22 @@ public class LoanBookEntryService {
public static final String ENTRY_TYPE = "LOAN_RECORD";
/** A batch that came from a person with a spreadsheet, rather than a connector. */
public static final String SOURCE_UPLOAD = "UPLOAD";
/**
* The canonical loan-book columns, in template order. A connector's mapping
* profile maps a vendor's own column names onto these, and nothing outside
* this list is carried across the boundary.
*/
public static final List<String> CANONICAL_COLUMNS = List.of(
"enterprise_ref", "loan_account_ref", "product", "sanctioned_amount", "outstanding",
"emi_amount", "due_date", "last_paid_date", "dpd", "bucket", "closed", "as_of_date");
/** The three a row cannot be validated without. */
public static final List<String> REQUIRED_COLUMNS = List.of(
"enterprise_ref", "loan_account_ref", "as_of_date");
private static final List<DateTimeFormatter> DATE_FORMATS = List.of(
DateTimeFormatter.ISO_LOCAL_DATE,
DateTimeFormatter.ofPattern("dd/MM/yyyy"),
@@ -63,7 +79,7 @@ public class LoanBookEntryService {
* store. Matching is on the normalised header, so {@code Customer Name},
* {@code customer_name} and {@code CUSTOMERNAME} are all caught.
*/
private static final Set<String> FORBIDDEN_COLUMNS = Set.of(
public static final Set<String> FORBIDDEN_COLUMNS = Set.of(
"name", "customername", "borrowername", "applicantname", "fathername",
"phone", "mobile", "mobileno", "contact", "email",
"aadhaar", "aadhar", "uid", "pan", "voterid",
@@ -80,13 +96,27 @@ public class LoanBookEntryService {
@Transactional
public ManualEntryPreview upload(MultipartFile file, String username) throws IOException {
TabularParser.Sheet sheet = parser.parse(file);
return stage(parser.parse(file), file.getOriginalFilename(), username, SOURCE_UPLOAD);
}
/**
* Validates an already-parsed sheet and stages it, writing nothing.
*
* <p>Split out from {@link #upload} so a core-banking connector can reach the
* same validation. That reuse is the point: a nightly CBS extract and a
* hand-made spreadsheet must be judged by one set of rules, or the automated
* path quietly becomes the one where the no-PII promise is not enforced.
*
* @param source {@code UPLOAD}, or the connector code the rows arrived through
*/
@Transactional
public ManualEntryPreview stage(TabularParser.Sheet sheet, String filename, String username, String source) {
ManualEntryBatch batch = batchRepository.save(ManualEntryBatch.builder()
.entryType(ENTRY_TYPE)
.filename(file.getOriginalFilename())
.filename(filename)
.uploadedBy(username)
.status("PREVIEW")
.source(source == null ? SOURCE_UPLOAD : source)
.rowsParsed(sheet.rows().size())
.build());
@@ -120,15 +150,45 @@ public class LoanBookEntryService {
return new ManualEntryPreview(batch, lines);
}
/** Header names in the file that this system has promised never to hold. */
private List<String> forbiddenHeaders(List<String> headers) {
/**
* Header names in the file that this system has promised never to hold.
*
* <p>Static and public so a connector can run the same test against a vendor's
* column list <i>before</i> a sync is configured, rather than discovering it on
* the first nightly run.
*/
public static List<String> forbiddenHeaders(Collection<String> headers) {
return headers.stream()
.filter(Objects::nonNull)
.filter(header -> FORBIDDEN_COLUMNS.contains(
header.replace("_", "").replace(" ", "").replace("-", "").toLowerCase(Locale.ROOT)))
.filter(LoanBookEntryService::isIdentifying)
.toList();
}
/**
* Whether a column name carries borrower identity.
*
* <p>Matched on the whole normalised name <b>and</b> on each separator-delimited
* part, because a core's real column is {@code CUST_NAME}, not
* {@code customer_name} — an exact-match list quietly missed it, and the row
* would then have been kept verbatim in {@code manual_entry_line.raw_row}.
*
* <p>Part-matching rather than substring-matching on purpose:
* {@code loan_account_ref} splits to {@code loan/account/ref} and stays clean,
* where a substring test would have to special-case it. camelCase counts as a
* separator too — an in-house LMS ships {@code borrowerMobile}, which has no
* underscore to split on and is no less a phone number for it.
*/
private static boolean isIdentifying(String header) {
String normalised = header.replace("_", "").replace(" ", "").replace("-", "").toLowerCase(Locale.ROOT);
if (FORBIDDEN_COLUMNS.contains(normalised)) return true;
String delimited = header.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
for (String part : delimited.split("[_\\s\\-.]+")) {
if (!part.isBlank() && FORBIDDEN_COLUMNS.contains(part)) return true;
}
return false;
}
// ── validation ──────────────────────────────────────────
private ManualEntryLine validate(Map<String, String> row, int rowNumber, Long batchId,
@@ -38,6 +38,17 @@ public class ManualEntryBatch {
@Column(length = 260)
private String filename;
/**
* {@code UPLOAD}, or the connector code the rows arrived through.
*
* <p>Kept on the batch rather than inferred from the filename, because once a
* CBS connector runs nightly, "who put this number here" stops being answerable
* by "someone uploaded a file" — and that question is the reason this table
* exists.
*/
@Column(length = 40)
private String source;
/** The username from the JWT. Never a display name — this is an audit field. */
@Column(nullable = false, length = 64)
private String uploadedBy;
@@ -68,5 +79,6 @@ public class ManualEntryBatch {
void onCreate() {
if (uploadedAt == null) uploadedAt = LocalDateTime.now();
if (status == null) status = "PREVIEW";
if (source == null) source = "UPLOAD";
}
}
@@ -7,6 +7,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
@@ -34,14 +35,26 @@ public class TabularParser {
}
public Sheet parse(MultipartFile file) throws IOException {
String name = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase(Locale.ROOT);
return name.endsWith(".csv") ? parseCsv(file) : parseWorkbook(file);
return parse(file.getInputStream(), file.getOriginalFilename());
}
/**
* The same parse, for bytes that never came through a browser.
*
* <p>A core-banking extract arrives on a drop path rather than a form post,
* and it must land in exactly the shape an upload does — the whole value of
* the connector layer is that it feeds the validation the manual path already
* has, rather than a second, laxer one beside it.
*/
public Sheet parse(InputStream stream, String filename) throws IOException {
String name = filename == null ? "" : filename.toLowerCase(Locale.ROOT);
return name.endsWith(".csv") ? parseCsv(stream) : parseWorkbook(stream);
}
// ── xlsx ────────────────────────────────────────────────
private Sheet parseWorkbook(MultipartFile file) throws IOException {
try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) {
private Sheet parseWorkbook(InputStream stream) throws IOException {
try (Workbook workbook = WorkbookFactory.create(stream)) {
org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
if (sheet == null) return new Sheet(List.of(), List.of());
@@ -102,9 +115,9 @@ public class TabularParser {
// ── csv ─────────────────────────────────────────────────
private Sheet parseCsv(MultipartFile file) throws IOException {
private Sheet parseCsv(InputStream stream) throws IOException {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
String headerLine = reader.readLine();
if (headerLine == null) return new Sheet(List.of(), List.of());
@@ -81,6 +81,7 @@ public class UdyamDataSeeder implements CommandLineRunner {
private final UdyamUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final InstitutionSeeder institutionSeeder;
private final com.lincfusion.udyamlinc.connector.ConnectorProfileSeeder connectorProfileSeeder;
@Value("${udyam.seed.enabled:true}")
private boolean seedEnabled;
@@ -114,6 +115,9 @@ public class UdyamDataSeeder implements CommandLineRunner {
// The institutional tier last: it assigns the portfolio the steps above
// created, so it has to see a finished book.
institutionSeeder.seed();
// Connector profiles after the institutions they read from, since each
// names a partner bank by code.
connectorProfileSeeder.seed();
log.info("UdyamLinc seed complete — {} enterprises on book", enterpriseRepository.count());
}