diff --git a/src/main/java/com/lincfusion/udyamlinc/app/EnterpriseAppController.java b/src/main/java/com/lincfusion/udyamlinc/app/EnterpriseAppController.java index afaef4c..08cc087 100644 --- a/src/main/java/com/lincfusion/udyamlinc/app/EnterpriseAppController.java +++ b/src/main/java/com/lincfusion/udyamlinc/app/EnterpriseAppController.java @@ -47,6 +47,13 @@ public class EnterpriseAppController { return ApiResponse.ok("Synced", appService.sync(enterpriseId, batch)); } + /** Why the owner's score is what it is — the newest run's sentences. */ + @GetMapping("/reasons") + public ApiResponse> reasons( + @AuthenticationPrincipal String enterpriseId) { + return ApiResponse.ok(appService.reasons(enterpriseId)); + } + @GetMapping("/alerts") public ApiResponse> alerts(@AuthenticationPrincipal String enterpriseId) { return ApiResponse.ok(appService.alerts(enterpriseId)); diff --git a/src/main/java/com/lincfusion/udyamlinc/app/EnterpriseAppService.java b/src/main/java/com/lincfusion/udyamlinc/app/EnterpriseAppService.java index ed30b53..45aa5d3 100644 --- a/src/main/java/com/lincfusion/udyamlinc/app/EnterpriseAppService.java +++ b/src/main/java/com/lincfusion/udyamlinc/app/EnterpriseAppService.java @@ -10,8 +10,11 @@ import com.lincfusion.udyamlinc.enterprise.LedgerEntry; import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository; import com.lincfusion.udyamlinc.master.Sector; import com.lincfusion.udyamlinc.master.SectorRepository; +import com.lincfusion.udyamlinc.risk.ForecastPoint; import com.lincfusion.udyamlinc.risk.ForecastPointRepository; import com.lincfusion.udyamlinc.risk.RiskAlert; +import com.lincfusion.udyamlinc.risk.ReasonCode; +import com.lincfusion.udyamlinc.risk.ReasonCodeRepository; import com.lincfusion.udyamlinc.risk.RiskAlertRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -38,6 +41,7 @@ public class EnterpriseAppService { private final LedgerEntryRepository ledgerRepository; private final ForecastPointRepository pointRepository; private final RiskAlertRepository alertRepository; + private final ReasonCodeRepository reasonRepository; private final SectorRepository sectorRepository; public AppHomeResponse home(String enterpriseId) { @@ -47,13 +51,22 @@ public class EnterpriseAppService { .findFirst() .orElse(null); + // Newest scoring run only. Unscoped, this returned every point ever forecast + // for the enterprise — seventeen runs deep on the demo book — and the owner's + // six-month strip drew a hundred overlapping months. + Long latestRun = pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId).stream() + .map(ForecastPoint::getRunId).filter(java.util.Objects::nonNull) + .max(Long::compareTo).orElse(null); + List forecast = latestRun == null ? List.of() + : pointRepository.findByEnterpriseIdAndRunIdOrderByHorizonAsc(enterpriseId, latestRun); + List today = ledgerRepository.findByEnterpriseIdAndEntryDate(enterpriseId, LocalDate.now()); Map advice = adviceFor(sectorCode(enterprise)); return new AppHomeResponse( enterprise, alert, - pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId), + forecast, sum(today, "IN"), sum(today, "OUT"), advice.get("en"), @@ -65,6 +78,22 @@ public class EnterpriseAppService { return ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId); } + /** + * The owner's own reasons, newest run only. + * + *

The same sentences the officer sees. A borrower who is told their limit + * is under review is entitled to know which measurement drove it, and reading + * across runs would pair today's score with last month's explanation. + */ + public List reasons(String enterpriseId) { + requireEnterprise(enterpriseId); + List all = reasonRepository.findByEnterpriseIdOrderByRankAsc(enterpriseId); + Long latestRun = all.stream().map(ReasonCode::getRunId) + .filter(java.util.Objects::nonNull).max(Long::compareTo).orElse(null); + if (latestRun == null) return List.of(); + return all.stream().filter(r -> latestRun.equals(r.getRunId())).toList(); + } + public List alerts(String enterpriseId) { return alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId); } diff --git a/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorCatalog.java b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorCatalog.java index 2485fda..e9bfffc 100644 --- a/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorCatalog.java +++ b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorCatalog.java @@ -12,25 +12,25 @@ import java.util.*; /** * Every adapter the platform can run, and the dialect each one speaks. * - *

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. + *

Two adapters, and the difference between them is the whole design. The file + * drop is real and reads whatever a bank chooses to deliver. {@link #cbsNexora()} + * is CBSNexora, LincPay's own core banking platform, simulated here because no + * instance feeds this deployment. * - *

{@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. + *

No third-party core banking product is named anywhere in this system. + * Where a lender runs someone else's core, the integration is described by its + * shape — a scheduled extract, a reporting view, a nightly position file — and + * the per-profile field mapping absorbs whatever that deployment calls its + * columns. Naming another vendor would imply a relationship with it that does not + * exist, and the mapping layer means the platform never needed the name. */ @Component @RequiredArgsConstructor public class ConnectorCatalog { + /** Vendor code for the CBSNexora adapter. Matches a profile's {@code vendor}. */ + public static final String VENDOR = "CBSNEXORA"; + private final FileDropConnector fileDropConnector; private final EnterpriseRepository enterpriseRepository; private final LoanRecordRepository loanRepository; @@ -41,10 +41,7 @@ public class ConnectorCatalog { @PostConstruct void register() { add(fileDropConnector); - add(simulated(finacle())); - add(simulated(flexcube())); - add(simulated(bancs())); - add(simulated(customApi())); + add(simulated(cbsNexora())); } private void add(CoreBankingConnector connector) { @@ -64,70 +61,20 @@ public class ConnectorCatalog { } // ── dialects ──────────────────────────────────────────── - - private SimulatedCoreConnector.Dialect finacle() { - LinkedHashMap 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 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 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() { + /** + * CBSNexora — LincPay's own core banking platform, as the platform would read it. + * + *

Simulated here: no CBSNexora instance feeds this deployment, and every row + * an adapter of this class produces is stamped {@code simulated} per sync run so + * a profile later pointed at a real instance cannot make its old runs look real. + * + *

{@code extraColumns} 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 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. + */ + private SimulatedCoreConnector.Dialect cbsNexora() { LinkedHashMap columns = new LinkedHashMap<>(); columns.put("enterprise_ref", "borrowerRef"); columns.put("loan_account_ref", "loanId"); @@ -141,10 +88,10 @@ public class ConnectorCatalog { 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.", + return new SimulatedCoreConnector.Dialect(VENDOR, "CBSNexora (simulated)", "REST", + "CBSNexora exposes one paged, read-only loan endpoint. A lender enables it for " + + "the platform's service account and agrees the field list; the mapping absorbs " + + "whatever their deployment happens to call them.", columns, List.of("borrowerName", "borrowerMobile", "branchCode")); } } diff --git a/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorProfile.java b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorProfile.java index a624af9..4fdffa1 100644 --- a/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorProfile.java +++ b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorProfile.java @@ -43,7 +43,7 @@ public class ConnectorProfile { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - /** Unique per deployment — "MPGB-FINACLE". */ + /** Unique per deployment — "MPGB-CBSNEXORA". */ @Column(nullable = false, unique = true, length = 40) private String code; diff --git a/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorProfileSeeder.java b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorProfileSeeder.java index e7951da..de438d5 100644 --- a/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorProfileSeeder.java +++ b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorProfileSeeder.java @@ -1,7 +1,10 @@ package com.lincfusion.udyamlinc.connector; +import com.lincfusion.udyamlinc.manualentry.ManualEntryBatch; +import com.lincfusion.udyamlinc.manualentry.ManualEntryBatchRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import java.util.List; @@ -15,10 +18,17 @@ import java.util.List; * first populated never reaches it, and the symptom is a screen that is empty for * everyone except whoever last dropped their schema. * - *

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. + *

The three lenders do not arrive the same way, on purpose. Two are on + * CBSNexora and read over its loan API; the cooperative bank delivers a file to a + * drop path. A demo where every bank arrived identically would quietly suggest + * that the mapping layer is optional, and it is the opposite of optional — it is + * what lets the platform onboard a lender whose core it has never seen. + * + *

CBSNexora is the only core banking product named anywhere in this + * system, and it is named because LincPay builds it. A lender on someone + * else's core is absorbed by the per-profile field mapping instead: the platform + * never needs that product's name, and printing one would imply a relationship + * with its vendor that does not exist. */ @Component @RequiredArgsConstructor @@ -26,6 +36,8 @@ import java.util.List; public class ConnectorProfileSeeder { private final ConnectorProfileRepository profileRepository; + private final ConnectorSyncRunRepository runRepository; + private final ManualEntryBatchRepository batchRepository; private final ConnectorCatalog catalog; private final FieldMapper fieldMapper; @@ -33,14 +45,90 @@ public class ConnectorProfileSeeder { String endpoint, String cron) { } + /** + * Codes and vendors this seeder used before the vendor names were removed. + * + *

Seeding is guarded per code, so without this a database populated under + * the old names would keep them for ever and quietly seed a second profile + * beside each one. Renaming in place also preserves every sync run already + * recorded against the profile, which deleting and reseeding would orphan. + */ + private record Legacy(String oldCode, String newCode, String oldVendor, String displayName) { + } + + private static final List LEGACY = List.of( + new Legacy("MPGB-FINACLE", "MPGB-CBSNEXORA", "FINACLE", + "MP Gramin Bank — CBSNexora loan API"), + new Legacy("VSFB-LMS", "VSFB-CBSNEXORA", "CUSTOM_API", + "Vindhya SFB — CBSNexora loan API")); + + /** + * Relabel, in three places, because the old name was denormalised into all + * three. A profile carries it; every sync run copies the code and vendor it + * ran under; and each staged upload batch carries the profile code plus a + * human label built from it at the time. + * + *

The one field deliberately left alone is {@code simulated} on a run. It + * records what the run actually was, and renaming an identity must never + * rewrite the provenance of work already done under it. + */ + private void renameLegacy() { + for (Legacy row : LEGACY) { + profileRepository.findByCode(row.oldCode()).ifPresent(profile -> { + profile.setCode(row.newCode()); + profile.setVendor(ConnectorCatalog.VENDOR); + profile.setDisplayName(row.displayName()); + profileRepository.save(profile); + log.info("Renamed legacy connector profile {} → {}", row.oldCode(), row.newCode()); + }); + + // Runs are looked up by profile code, so leaving these would both show + // the old name in sync history and strand the runs from their profile. + List runs = + runRepository.findByProfileCodeOrderByStartedAtDesc(row.oldCode(), Pageable.unpaged()); + if (!runs.isEmpty()) { + runs.forEach(run -> { + run.setProfileCode(row.newCode()); + run.setVendor(ConnectorCatalog.VENDOR); + }); + runRepository.saveAll(runs); + log.info("Repointed {} sync run(s) from {} to {}", runs.size(), row.oldCode(), row.newCode()); + } + + // The batch label is free text composed at sync time — "MPGB-FINACLE · + // FINACLE simulated extract · 44 accounts". Both halves have to go. + List batches = batchRepository.findAllByOrderByUploadedAtDesc(Pageable.unpaged()) + .stream() + .filter(b -> row.oldCode().equals(b.getSource()) + || (b.getFilename() != null && b.getFilename().contains(row.oldCode()))) + .toList(); + if (batches.isEmpty()) continue; + batches.forEach(b -> { + if (row.oldCode().equals(b.getSource())) b.setSource(row.newCode()); + if (b.getFilename() != null) { + b.setFilename(b.getFilename() + .replace(row.oldCode(), row.newCode()) + .replace(row.oldVendor(), ConnectorCatalog.VENDOR)); + } + }); + batchRepository.saveAll(batches); + log.info("Relabelled {} upload batch(es) from {} to {}", + batches.size(), row.oldCode(), row.newCode()); + } + } + public void seed() { + renameLegacy(); + List seeds = List.of( - new Seed("MPGB-FINACLE", "MP Gramin Bank — Finacle nightly extract", - "FINACLE", "MPGB", "loanbook-*.csv", "0 30 1 * * *"), + new Seed("MPGB-CBSNEXORA", "MP Gramin Bank — CBSNexora loan API", + ConnectorCatalog.VENDOR, "MPGB", "https://cbsnexora.mpgb.example/api/loans", + "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 * * *")); + new Seed("VSFB-CBSNEXORA", "Vindhya SFB — CBSNexora loan API", + ConnectorCatalog.VENDOR, "VSFB", "https://cbsnexora.vindhyasfb.example/api/loans", + "0 15 1 * * *")); int written = 0; for (Seed seed : seeds) { diff --git a/src/main/java/com/lincfusion/udyamlinc/connector/CoreBankingConnector.java b/src/main/java/com/lincfusion/udyamlinc/connector/CoreBankingConnector.java index e20715b..52d21d9 100644 --- a/src/main/java/com/lincfusion/udyamlinc/connector/CoreBankingConnector.java +++ b/src/main/java/com/lincfusion/udyamlinc/connector/CoreBankingConnector.java @@ -9,7 +9,8 @@ import java.util.Map; * management system. * *

Why an interface and not an integration. Every bank's core is - * different — Finacle, Flexcube, BaNCS, a cooperative bank's in-house system — + * different — a scheduled report extract, a reporting view, a nightly position + * file, a REST loan API such as CBSNexora — * 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 @@ -31,7 +32,7 @@ import java.util.Map; */ public interface CoreBankingConnector { - /** Stable code — {@code FINACLE}, {@code FILE_DROP}. Matches a profile's vendor. */ + /** Stable code — {@code CBSNEXORA}, {@code FILE_DROP}. Matches a profile's vendor. */ String vendor(); /** What a bank operations user would call it. */ diff --git a/src/main/java/com/lincfusion/udyamlinc/connector/SimulatedCoreConnector.java b/src/main/java/com/lincfusion/udyamlinc/connector/SimulatedCoreConnector.java index fa9a3f9..a010b61 100644 --- a/src/main/java/com/lincfusion/udyamlinc/connector/SimulatedCoreConnector.java +++ b/src/main/java/com/lincfusion/udyamlinc/connector/SimulatedCoreConnector.java @@ -15,17 +15,17 @@ import java.time.temporal.ChronoUnit; import java.util.*; /** - * A core banking system that does not exist, exporting in the dialect of one - * that does. + * A core banking instance that is not there, exporting in the dialect of one + * that would be. * - *

What this is for, and what it is not. 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 shape of the seam: 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. + *

What this is for, and what it is not. Connecting a lender's core 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 shape of the seam: that a core names the same eleven fields + * in its own way, that a mapping profile is what reconciles them, and that + * nothing after the mapping cares which core it was. Running the real portfolio + * out through a declared dialect makes that visible in a way a paragraph of + * intent does not. * *

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