feat(app): complete the borrower PWA contract, and drop vendor core names
The offline promise was only half kept. Ledger entries were queued on the phone and marked PENDING, and the client flipped them to SYNCED without sending anything — so an owner could record a sale with no signal, which was the point, but it never reached the lender and the officer's screen never saw it. POST /app/ledger/sync now has a caller; it dedupes on the clientId the phone minted, so a retry after a half-failed sync writes nothing twice. Adds GET /app/reasons: the borrower's own scored reason codes, newest run only. A borrower told their limit is under review is entitled to know which measurement drove it, and the app had been generating sentences from a demo sector table rather than reading the ones the model produced. Both surfaces now render the identical string. PUT /app/consent was already here but nothing called it; the profile toggle was local state. Consent is the artefact that makes reading a borrower's inflows lawful, so it has to live on the server and be revocable from the owner's own device. Separately: the connector layer no longer names third-party core banking products. Finacle, FLEXCUBE and BaNCS are replaced by CBSNexora, LincPay's own core, with an in-place migration in ConnectorProfileSeeder.renameLegacy() that also rewrites existing sync runs and manual entry batch labels. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<List<com.lincfusion.udyamlinc.risk.ReasonCode>> reasons(
|
||||
@AuthenticationPrincipal String enterpriseId) {
|
||||
return ApiResponse.ok(appService.reasons(enterpriseId));
|
||||
}
|
||||
|
||||
@GetMapping("/alerts")
|
||||
public ApiResponse<List<RiskAlert>> alerts(@AuthenticationPrincipal String enterpriseId) {
|
||||
return ApiResponse.ok(appService.alerts(enterpriseId));
|
||||
|
||||
@@ -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<ForecastPoint> forecast = latestRun == null ? List.of()
|
||||
: pointRepository.findByEnterpriseIdAndRunIdOrderByHorizonAsc(enterpriseId, latestRun);
|
||||
|
||||
List<LedgerEntry> today = ledgerRepository.findByEnterpriseIdAndEntryDate(enterpriseId, LocalDate.now());
|
||||
Map<String, String> 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.
|
||||
*
|
||||
* <p>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<ReasonCode> reasons(String enterpriseId) {
|
||||
requireEnterprise(enterpriseId);
|
||||
List<ReasonCode> 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<RiskAlert> alerts(String enterpriseId) {
|
||||
return alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId);
|
||||
}
|
||||
|
||||
@@ -12,25 +12,25 @@ 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>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.
|
||||
*
|
||||
* <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.
|
||||
* <p><b>No third-party core banking product is named anywhere in this system.</b>
|
||||
* 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<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() {
|
||||
/**
|
||||
* CBSNexora — LincPay's own core banking platform, as the platform would read it.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>{@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<String, String> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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.
|
||||
* <p>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.
|
||||
*
|
||||
* <p><b>CBSNexora is the only core banking product named anywhere in this
|
||||
* system,</b> 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.
|
||||
*
|
||||
* <p>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> 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.
|
||||
*
|
||||
* <p>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<ConnectorSyncRun> 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<ManualEntryBatch> 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<Seed> 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) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import java.util.Map;
|
||||
* 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 —
|
||||
* 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. */
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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><b>What this is for, and what it is not.</b> 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 <i>shape of the seam</i>: 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.
|
||||
*
|
||||
* <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
|
||||
|
||||
Reference in New Issue
Block a user