feat(cic): switch CIC module to enquiry-alert service
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run

Remove CIBIL enquiry/consent flows; add alert fetch (manual + scheduled),
customer matching, fetch-run log, dashboard and ENQUIRY_ALERT mapping
(Liquibase 0605).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:14:18 +05:30
co-authored by Claude Sonnet 5.5
parent a8f8aa39b7
commit e4a2c00d75
11 changed files with 1684 additions and 1307 deletions
@@ -24,7 +24,6 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
@@ -34,7 +33,6 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -42,29 +40,28 @@ import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cic.service.CicAlertService;
import org.apache.fineract.portfolio.cic.service.CicCatalog;
import org.apache.fineract.portfolio.cic.service.CicConfigService;
import org.apache.fineract.portfolio.cic.service.CicConsentService;
import org.apache.fineract.portfolio.cic.service.CicEnquiryService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* CIC / credit bureau enquiry: bureau whitelist and configuration, API mapping, borrower consent and enquiries.
* CIC / credit bureau: bureau whitelist and configuration, API mapping, and the enquiry alerts the CICs send when one of
* the bank's customers is enquired by another lender.
*
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICENQUIRY / CREATE_CICENQUIRY,
* READ_CICCONSENT / CREATE_CICCONSENT / REVOKE_CICCONSENT.
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICALERT (dashboard and register),
* UPDATE_CICALERT (work an alert), FETCH_CICALERT (fetch now / import a file).
*/
@Path("/v1/cic")
@Component
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping, consent and enquiries.")
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping and enquiry alerts.")
@RequiredArgsConstructor
public class CicApiResource {
private final PlatformSecurityContext context;
private final CicConfigService configService;
private final CicConsentService consentService;
private final CicEnquiryService enquiryService;
private final CicAlertService alertService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── Catalog ───────────────────────────────────────────────────────────────
@@ -79,12 +76,12 @@ public class CicApiResource {
c.put("authTypes", CicCatalog.AUTH_TYPES);
c.put("endpointTypes", CicCatalog.ENDPOINT_TYPES);
c.put("httpMethods", CicCatalog.HTTP_METHODS);
c.put("consentModes", CicCatalog.CONSENT_MODES);
c.put("purposes", CicCatalog.PURPOSES);
c.put("cbsFields", CicCatalog.CBS_FIELDS);
c.put("resultFields", CicCatalog.RESULT_FIELDS);
c.put("rootFields", CicCatalog.ROOT_FIELDS);
c.put("transforms", CicCatalog.TRANSFORMS);
c.put("defaultConsentText", CicConsentService.DEFAULT_TEXT);
c.put("alertStatuses", CicCatalog.ALERT_STATUSES);
c.put("matchTypes", CicCatalog.MATCH_TYPES);
return gson.toJson(c);
}
@@ -94,7 +91,7 @@ public class CicApiResource {
@Path("bureaus")
@Produces(MediaType.APPLICATION_JSON)
public String bureaus(@QueryParam("whitelistedOnly") @DefaultValue("false") final boolean whitelistedOnly) {
requireAny("READ_CICBUREAU", "READ_CICENQUIRY", "CREATE_CICENQUIRY", "CREATE_CICCONSENT");
requireAny("READ_CICBUREAU", "READ_CICALERT");
return gson.toJson(configService.listBureaus(whitelistedOnly));
}
@@ -159,87 +156,133 @@ public class CicApiResource {
@Produces(MediaType.APPLICATION_JSON)
public String preview(@PathParam("id") final Long id, final String body) {
require("READ_CICBUREAU");
return gson.toJson(enquiryService.preview(id, json(body)));
}
// ── Client profile ────────────────────────────────────────────────────────
@GET
@Path("clients/{clientId}/profile")
@Produces(MediaType.APPLICATION_JSON)
public String clientProfile(@PathParam("clientId") final Long clientId, @QueryParam("bureauId") final Long bureauId) {
requireAny("CREATE_CICENQUIRY", "READ_CICENQUIRY", "CREATE_CICCONSENT");
return gson.toJson(enquiryService.clientProfile(clientId, bureauId));
}
// ── Consents ──────────────────────────────────────────────────────────────
@GET
@Path("consents")
@Produces(MediaType.APPLICATION_JSON)
public String consents(@QueryParam("clientId") final Long clientId, @QueryParam("status") final String status,
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
require("READ_CICCONSENT");
return gson.toJson(consentService.list(clientId, status, search, Math.max(0, offset), clamp(limit)));
return gson.toJson(alertService.preview(id, json(body)));
}
@POST
@Path("consents")
@Path("bureaus/{id}/parse-test")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String createConsent(final String body, @Context final HttpServletRequest request) {
final AppUser user = require("CREATE_CICCONSENT");
return gson.toJson(consentService.create(json(body), user, clientIp(request)));
public String parseTest(@PathParam("id") final Long id, final String body) {
require("READ_CICBUREAU");
return gson.toJson(alertService.parseTest(id, json(body)));
}
// ── Fetch / import ────────────────────────────────────────────────────────
@POST
@Path("consents/{id}/revoke")
@Path("bureaus/{id}/fetch")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String revokeConsent(@PathParam("id") final Long id, final String body) {
final AppUser user = require("REVOKE_CICCONSENT");
public String fetch(@PathParam("id") final Long id, final String body) {
final AppUser user = require("FETCH_CICALERT");
final JsonObject in = json(body);
return gson.toJson(consentService.revoke(id, in.has("reason") && !in.get("reason").isJsonNull() ? in.get("reason").getAsString() : null,
user));
}
// ── Enquiries ─────────────────────────────────────────────────────────────
@GET
@Path("enquiries")
@Produces(MediaType.APPLICATION_JSON)
public String enquiries(@QueryParam("clientId") final Long clientId, @QueryParam("bureauId") final Long bureauId,
@QueryParam("status") final String status, @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
require("READ_CICENQUIRY");
return gson.toJson(enquiryService.list(clientId, bureauId, status, fromDate, toDate, search, Math.max(0, offset), clamp(limit)));
}
@GET
@Path("enquiries/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String enquiry(@PathParam("id") final Long id) {
require("READ_CICENQUIRY");
return gson.toJson(enquiryService.get(id));
return gson.toJson(alertService.fetch(id, text(in, "fromDate"), text(in, "toDate"), "MANUAL", user));
}
@POST
@Path("enquiries")
@Path("bureaus/{id}/import")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String enquire(final String body) {
final AppUser user = require("CREATE_CICENQUIRY");
return gson.toJson(enquiryService.enquire(json(body), user));
public String importFile(@PathParam("id") final Long id, final String body) {
final AppUser user = require("FETCH_CICALERT");
return gson.toJson(alertService.importFile(id, json(body), user));
}
@GET
@Path("fetch-runs")
@Produces(MediaType.APPLICATION_JSON)
public String fetchRuns(@QueryParam("bureauId") final Long bureauId, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
requireAny("READ_CICALERT", "READ_CICBUREAU");
return gson.toJson(alertService.fetchRuns(bureauId, Math.max(0, offset), clamp(limit)));
}
@GET
@Path("fetch-runs/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String fetchRun(@PathParam("id") final Long id) {
requireAny("READ_CICALERT", "READ_CICBUREAU");
return gson.toJson(alertService.fetchRun(id, true));
}
// ── Alerts ────────────────────────────────────────────────────────────────
@GET
@Path("dashboard")
@Produces(MediaType.APPLICATION_JSON)
public String dashboard() {
require("READ_CICENQUIRY");
return gson.toJson(enquiryService.dashboard());
public String dashboard(@QueryParam("days") @DefaultValue("30") final int days) {
require("READ_CICALERT");
return gson.toJson(alertService.dashboard(Math.max(1, Math.min(365, days))));
}
@GET
@Path("alerts")
@Produces(MediaType.APPLICATION_JSON)
public String alerts(@QueryParam("status") final String status, @QueryParam("match") final String match,
@QueryParam("clientId") final String clientId, @QueryParam("bureauId") final String bureauId,
@QueryParam("officeId") final String officeId, @QueryParam("loanType") final String loanType,
@QueryParam("institutionType") final String institutionType, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate, @QueryParam("minAmount") final String minAmount,
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
require("READ_CICALERT");
final JsonObject f = new JsonObject();
f.addProperty("status", status);
f.addProperty("match", match);
f.addProperty("clientId", clientId);
f.addProperty("bureauId", bureauId);
f.addProperty("officeId", officeId);
f.addProperty("loanType", loanType);
f.addProperty("institutionType", institutionType);
f.addProperty("fromDate", fromDate);
f.addProperty("toDate", toDate);
f.addProperty("minAmount", minAmount);
f.addProperty("search", search);
return gson.toJson(alertService.list(f, Math.max(0, offset), Math.max(1, Math.min(5000, limit))));
}
@GET
@Path("alerts/options")
@Produces(MediaType.APPLICATION_JSON)
public String alertOptions() {
require("READ_CICALERT");
return gson.toJson(alertService.filterOptions());
}
@GET
@Path("alerts/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String alert(@PathParam("id") final Long id) {
require("READ_CICALERT");
return gson.toJson(alertService.get(id));
}
@PUT
@Path("alerts/{id}/status")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String updateStatus(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.updateStatus(id, json(body), user));
}
@PUT
@Path("alerts/{id}/client")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String linkClient(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.linkClient(id, json(body), user));
}
@POST
@Path("alerts/mark-seen")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String markSeen(final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.markSeen(json(body), user));
}
// ── helpers ──────────────────────────────────────────────────────────────
@@ -266,18 +309,11 @@ public class CicApiResource {
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
}
private static String text(final JsonObject in, final String key) {
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsString() : null;
}
private static int clamp(final int limit) {
return Math.max(1, Math.min(200, limit));
}
private static String clientIp(final HttpServletRequest request) {
if (request == null) {
return null;
}
final String forwarded = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotBlank(forwarded)) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
@@ -0,0 +1,89 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Fetches enquiry alerts automatically: every few minutes, for every tenant, each whitelisted bureau with automatic
* fetch on is fetched once its interval has passed since the last fetch. Runs only on the batch-manager node.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CicAlertScheduler {
private final FineractProperties fineractProperties;
private final TenantDetailsService tenantDetailsService;
private final BusinessDateReadPlatformService businessDateReadPlatformService;
private final JdbcTemplate jdbcTemplate;
private final CicAlertService alertService;
@Scheduled(fixedDelayString = "${cbs.cic.alert-scheduler-delay-ms:300000}", initialDelayString = "${cbs.cic.alert-scheduler-initial-delay-ms:180000}")
public void fetchDueAlerts() {
if (!fineractProperties.getMode().isBatchManagerEnabled()) {
return;
}
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
try {
ThreadLocalContextUtil.setTenant(tenant);
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT id, code, alert_fetch_interval_min, last_fetch_on FROM m_cic_bureau
WHERE whitelisted = TRUE AND alert_auto_fetch = TRUE
""");
final long now = Instant.now().toEpochMilli();
for (final Map<String, Object> b : due) {
final long intervalMs = ((Number) b.get("alert_fetch_interval_min")).longValue() * 60_000L;
final Object last = b.get("last_fetch_on");
if (last instanceof Timestamp ts && now - ts.getTime() < intervalMs) {
continue;
}
try {
alertService.fetch(((Number) b.get("id")).longValue(), null, null, "SCHEDULED", null);
} catch (final RuntimeException e) {
// A bureau that is not ready (no URL, no credential ...) is reported on the run / bureau, not retried here.
log.warn("CIC scheduled alert fetch of {} for tenant {} failed: {}", b.get("code"), tenant.getTenantIdentifier(),
e.getMessage());
}
}
} catch (final RuntimeException e) {
// A tenant whose database does not have the CIC tables yet.
log.debug("CIC alert scheduler skipped tenant {}: {}", tenant.getTenantIdentifier(), e.getMessage());
} finally {
ThreadLocalContextUtil.reset();
}
}
}
}
@@ -31,8 +31,8 @@ import java.util.Map;
import java.util.Set;
/**
* Everything the API Mapping screen can choose from, and the transforms applied when a CBS value is written into a
* bureau request or a bureau value is read back.
* Everything the API Mapping screen can choose from for the ENQUIRY_ALERT call, and the transforms applied when a
* value is written into the bureau request or read back from the bureau response.
*/
public final class CicCatalog {
@@ -40,75 +40,51 @@ public final class CicCatalog {
public static final List<String> ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION");
public static final List<String> AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS");
public static final List<String> ENDPOINT_TYPES = List.of("CONSUMER_ENQUIRY", "COMMERCIAL_ENQUIRY");
public static final String ALERT_ENDPOINT = "ENQUIRY_ALERT";
public static final List<String> ENDPOINT_TYPES = List.of(ALERT_ENDPOINT);
public static final List<String> HTTP_METHODS = List.of("POST", "PUT", "GET");
public static final List<String> CONSENT_MODES = List.of("SIGNED_FORM", "OTP", "DIGITAL_ACCEPTANCE");
public static final List<String> ALERT_STATUSES = List.of("NEW", "SEEN", "ACTIONED", "DISMISSED");
public static final List<String> MATCH_TYPES = List.of("PAN", "AADHAAR", "CUSTOMER_REF", "MANUAL", "NONE");
/** Enquiry purposes with the code sent to the bureau (enquiry.purposeCode). */
public static final Map<String, String> PURPOSES = new LinkedHashMap<>();
static {
PURPOSES.put("NEW_LOAN", "New loan application");
PURPOSES.put("CREDIT_CARD", "Credit card / overdraft");
PURPOSES.put("LOAN_RENEWAL", "Renewal / enhancement");
PURPOSES.put("ACCOUNT_REVIEW", "Account review / monitoring");
PURPOSES.put("GUARANTOR_CHECK", "Guarantor / co-applicant check");
PURPOSES.put("COLLECTION", "Collection / recovery");
PURPOSES.put("OTHER", "Other");
}
/** CBS fields a REQUEST mapping can take its value from. */
/** Values a REQUEST mapping can take: the fetch window and the bank's identity at the bureau. */
public static final Map<String, String> CBS_FIELDS = new LinkedHashMap<>();
static {
CBS_FIELDS.put("client.firstName", "Client first name");
CBS_FIELDS.put("client.middleName", "Client middle name");
CBS_FIELDS.put("client.lastName", "Client last name");
CBS_FIELDS.put("client.fullName", "Client full / display name");
CBS_FIELDS.put("client.dob", "Date of birth");
CBS_FIELDS.put("client.gender", "Gender");
CBS_FIELDS.put("client.pan", "PAN");
CBS_FIELDS.put("client.aadhaarMasked", "Aadhaar (masked)");
CBS_FIELDS.put("client.ckycNumber", "CKYC number");
CBS_FIELDS.put("client.mobile", "Mobile number");
CBS_FIELDS.put("client.email", "Email");
CBS_FIELDS.put("client.addressLine1", "Address line 1");
CBS_FIELDS.put("client.addressLine2", "Address line 2");
CBS_FIELDS.put("client.city", "City / town");
CBS_FIELDS.put("client.state", "State");
CBS_FIELDS.put("client.pincode", "PIN code");
CBS_FIELDS.put("client.cif", "CIF / client account number");
CBS_FIELDS.put("client.id", "Client id");
CBS_FIELDS.put("client.branch", "Branch name");
CBS_FIELDS.put("enquiry.reference", "Enquiry reference number");
CBS_FIELDS.put("enquiry.date", "Enquiry date (business date)");
CBS_FIELDS.put("enquiry.amount", "Loan amount applied for");
CBS_FIELDS.put("enquiry.purposeCode", "Enquiry purpose code");
CBS_FIELDS.put("enquiry.purpose", "Enquiry purpose description");
CBS_FIELDS.put("enquiry.applicationNo", "Loan application number");
CBS_FIELDS.put("fetch.reference", "Fetch reference number");
CBS_FIELDS.put("fetch.fromDate", "Alerts from date");
CBS_FIELDS.put("fetch.toDate", "Alerts to date");
CBS_FIELDS.put("fetch.date", "Request date (business date)");
CBS_FIELDS.put("bureau.memberCode", "Bank's member code at the bureau");
CBS_FIELDS.put("bureau.username", "Bureau user id");
}
/** Normalised result fields a RESPONSE mapping can fill. */
/**
* Result fields a RESPONSE mapping can fill. errorCode / errorMessage / alerts are read from the root of the
* response; "alerts" points at the list, and every other field is read from each item of that list.
*/
public static final Map<String, String> RESULT_FIELDS = new LinkedHashMap<>();
static {
RESULT_FIELDS.put("hit", "Record found (true/false)");
RESULT_FIELDS.put("reportId", "Bureau report / control number");
RESULT_FIELDS.put("score", "Credit score");
RESULT_FIELDS.put("scoreVersion", "Score model / version");
RESULT_FIELDS.put("totalAccounts", "Total accounts");
RESULT_FIELDS.put("activeAccounts", "Active accounts");
RESULT_FIELDS.put("overdueAccounts", "Overdue accounts");
RESULT_FIELDS.put("writtenOffAccounts", "Written-off / settled accounts");
RESULT_FIELDS.put("totalOutstanding", "Total outstanding");
RESULT_FIELDS.put("totalOverdue", "Total overdue");
RESULT_FIELDS.put("enquiriesLast6Months", "Enquiries in last 6 months");
RESULT_FIELDS.put("maxDpd", "Highest days past due");
RESULT_FIELDS.put("accounts", "Account list (array)");
RESULT_FIELDS.put("enquiries", "Enquiry list (array)");
RESULT_FIELDS.put("errorCode", "Bureau error code");
RESULT_FIELDS.put("errorMessage", "Bureau error message");
RESULT_FIELDS.put("alerts", "List of alerts (array) - root");
RESULT_FIELDS.put("errorCode", "Bureau error code - root");
RESULT_FIELDS.put("errorMessage", "Bureau error message - root");
RESULT_FIELDS.put("alertRef", "Alert / enquiry id at the bureau");
RESULT_FIELDS.put("pan", "Customer PAN");
RESULT_FIELDS.put("aadhaar", "Customer Aadhaar (masked or last 4)");
RESULT_FIELDS.put("customerName", "Customer name");
RESULT_FIELDS.put("dateOfBirth", "Customer date of birth");
RESULT_FIELDS.put("customerRef", "Our customer reference (CIF / account no) reported to the bureau");
RESULT_FIELDS.put("institutionCode", "Enquiring lender's member code");
RESULT_FIELDS.put("institutionName", "Enquiring lender (other bank / NBFC)");
RESULT_FIELDS.put("institutionType", "Lender type (Bank / NBFC / MFI ...)");
RESULT_FIELDS.put("enquiryDate", "Enquiry date");
RESULT_FIELDS.put("loanType", "Loan type applied for");
RESULT_FIELDS.put("amount", "Loan amount applied for");
RESULT_FIELDS.put("purpose", "Enquiry purpose");
RESULT_FIELDS.put("location", "Branch / city of the enquiry");
}
/** Result fields read from the root of the response, not from each alert. */
public static final Set<String> ROOT_FIELDS = Set.of("alerts", "errorCode", "errorMessage");
public static final Map<String, String> TRANSFORMS = new LinkedHashMap<>();
static {
TRANSFORMS.put("NONE", "As is");
@@ -118,37 +94,19 @@ public final class CicCatalog {
TRANSFORMS.put("DIGITS_ONLY", "Digits only");
TRANSFORMS.put("DATE_DDMMYYYY", "Date ddMMyyyy");
TRANSFORMS.put("DATE_DD_MM_YYYY", "Date dd-MM-yyyy");
TRANSFORMS.put("DATE_DD_SLASH_MM_YYYY", "Date dd/MM/yyyy");
TRANSFORMS.put("DATE_YYYY_MM_DD", "Date yyyy-MM-dd");
TRANSFORMS.put("GENDER_MF", "Gender M / F / T");
TRANSFORMS.put("GENDER_CODE", "Gender code 1=F 2=M 3=T");
TRANSFORMS.put("STATE_CODE", "State to 2-digit code");
TRANSFORMS.put("AMOUNT_INTEGER", "Amount, whole rupees");
TRANSFORMS.put("NUMBER", "Number");
TRANSFORMS.put("BOOLEAN", "true / false");
}
/** Fields masked wherever a request is stored or shown. */
public static final Set<String> SENSITIVE_FIELDS = Set.of("client.pan", "client.mobile", "client.email", "client.aadhaarMasked");
private static final Map<String, String> STATE_CODES = new LinkedHashMap<>();
static {
String[][] states = { { "JAMMU AND KASHMIR", "01" }, { "HIMACHAL PRADESH", "02" }, { "PUNJAB", "03" }, { "CHANDIGARH", "04" },
{ "UTTARAKHAND", "05" }, { "UTTARANCHAL", "05" }, { "HARYANA", "06" }, { "DELHI", "07" }, { "RAJASTHAN", "08" },
{ "UTTAR PRADESH", "09" }, { "BIHAR", "10" }, { "SIKKIM", "11" }, { "ARUNACHAL PRADESH", "12" }, { "NAGALAND", "13" },
{ "MANIPUR", "14" }, { "MIZORAM", "15" }, { "TRIPURA", "16" }, { "MEGHALAYA", "17" }, { "ASSAM", "18" },
{ "WEST BENGAL", "19" }, { "JHARKHAND", "20" }, { "ODISHA", "21" }, { "ORISSA", "21" }, { "CHHATTISGARH", "22" },
{ "MADHYA PRADESH", "23" }, { "GUJARAT", "24" }, { "DAMAN AND DIU", "25" }, { "DADRA AND NAGAR HAVELI", "26" },
{ "MAHARASHTRA", "27" }, { "KARNATAKA", "29" }, { "GOA", "30" }, { "LAKSHADWEEP", "31" }, { "KERALA", "32" },
{ "TAMIL NADU", "33" }, { "PUDUCHERRY", "34" }, { "PONDICHERRY", "34" }, { "ANDAMAN AND NICOBAR ISLANDS", "35" },
{ "TELANGANA", "36" }, { "ANDHRA PRADESH", "37" }, { "LADAKH", "38" } };
for (final String[] s : states) {
STATE_CODES.put(s[0], s[1]);
}
}
private static final Map<String, String> DATE_PATTERNS = Map.of("DATE_DDMMYYYY", "ddMMyyyy", "DATE_DD_MM_YYYY", "dd-MM-yyyy",
"DATE_DD_SLASH_MM_YYYY", "dd/MM/yyyy", "DATE_YYYY_MM_DD", "yyyy-MM-dd");
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
/** Applies a REQUEST transform to a CBS value; null stays null. */
/** Applies a REQUEST transform to a CBS value; null stays null. Dates come in as ISO yyyy-MM-dd. */
public static JsonElement applyRequestTransform(final String transform, final Object raw) {
if (raw == null) {
return null;
@@ -158,6 +116,14 @@ public final class CicCatalog {
if (s.isEmpty()) {
return null;
}
if (DATE_PATTERNS.containsKey(t)) {
try {
return new JsonPrimitive(LocalDate.parse(s.length() > 10 ? s.substring(0, 10) : s, ISO)
.format(DateTimeFormatter.ofPattern(DATE_PATTERNS.get(t))));
} catch (final RuntimeException e) {
return new JsonPrimitive(s);
}
}
switch (t) {
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
@@ -169,25 +135,6 @@ public final class CicCatalog {
final String digits = s.replaceAll("\\D", "");
return digits.isEmpty() ? null : new JsonPrimitive(digits);
}
case "DATE_DDMMYYYY":
return date(s, "ddMMyyyy");
case "DATE_DD_MM_YYYY":
return date(s, "dd-MM-yyyy");
case "DATE_YYYY_MM_DD":
return date(s, "yyyy-MM-dd");
case "GENDER_MF": {
final String g = s.toUpperCase(Locale.ROOT);
return new JsonPrimitive(g.startsWith("F") ? "F" : g.startsWith("M") ? "M" : "T");
}
case "GENDER_CODE": {
final String g = s.toUpperCase(Locale.ROOT);
return new JsonPrimitive(g.startsWith("F") ? "1" : g.startsWith("M") ? "2" : "3");
}
case "STATE_CODE": {
final String key = s.toUpperCase(Locale.ROOT).replace("&", "AND").replaceAll("\\s+", " ").trim();
final String code = STATE_CODES.get(key);
return new JsonPrimitive(code != null ? code : s);
}
case "AMOUNT_INTEGER":
return new JsonPrimitive(new BigDecimal(s).setScale(0, RoundingMode.HALF_UP).toBigInteger());
case "NUMBER":
@@ -199,15 +146,10 @@ public final class CicCatalog {
}
}
private static JsonElement date(final String iso, final String pattern) {
try {
return new JsonPrimitive(LocalDate.parse(iso.length() > 10 ? iso.substring(0, 10) : iso, ISO).format(DateTimeFormatter.ofPattern(pattern)));
} catch (final RuntimeException e) {
return new JsonPrimitive(iso);
}
}
/** Converts a value read from a bureau response for a RESPONSE mapping; arrays and objects pass through. */
/**
* Converts a value read from a bureau response. A DATE_* transform names the format the bureau sends, and the
* value comes out as ISO yyyy-MM-dd. Arrays and objects pass through.
*/
public static JsonElement applyResponseTransform(final String transform, final JsonElement value) {
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
return value;
@@ -215,18 +157,25 @@ public final class CicCatalog {
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
final String s = value.getAsString().trim();
try {
if (DATE_PATTERNS.containsKey(t)) {
return new JsonPrimitive(parseDate(s, DATE_PATTERNS.get(t)).toString());
}
switch (t) {
case "NUMBER":
case "AMOUNT_INTEGER":
case "AMOUNT_INTEGER": {
final String numeric = s.replaceAll("[^0-9.\\-]", "");
return numeric.isEmpty() ? null : new JsonPrimitive(new BigDecimal(numeric));
}
case "BOOLEAN":
return new JsonPrimitive("true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s)
|| "HIT".equalsIgnoreCase(s));
return new JsonPrimitive("true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s));
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
case "LOWER":
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
case "TRIM":
return new JsonPrimitive(s.replaceAll("\\s+", " "));
case "DIGITS_ONLY":
return new JsonPrimitive(s.replaceAll("\\D", ""));
default:
return value;
}
@@ -235,7 +184,17 @@ public final class CicCatalog {
}
}
/** ABCDE1234F -> ABxxxx234F style masking for logs and previews. */
/** Parses a bureau date in the given pattern, falling back to ISO. */
public static LocalDate parseDate(final String s, final String pattern) {
final String v = s.trim();
try {
return LocalDate.parse(v, DateTimeFormatter.ofPattern(pattern));
} catch (final RuntimeException e) {
return LocalDate.parse(v.length() > 10 ? v.substring(0, 10) : v, ISO);
}
}
/** ABCDE1234F -> AB****234F style masking for screens and logs. */
public static String mask(final String value) {
if (value == null || value.length() <= 4) {
return value == null ? null : "****";
@@ -244,4 +203,13 @@ public final class CicCatalog {
final int keepEnd = Math.min(4, value.length() / 3);
return value.substring(0, keepStart) + "*".repeat(value.length() - keepStart - keepEnd) + value.substring(value.length() - keepEnd);
}
/** Aadhaar shown as XXXX-XXXX-1234 whatever form the bureau sends it in. */
public static String maskAadhaar(final String value) {
if (value == null) {
return null;
}
final String digits = value.replaceAll("\\D", "");
return digits.length() < 4 ? null : "XXXX-XXXX-" + digits.substring(digits.length() - 4);
}
}
@@ -1,125 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* The client data a bureau enquiry is built from, as a flat map keyed by the CBS field names in
* {@link CicCatalog#CBS_FIELDS} (client.pan, client.dob ...).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CicClientProfileService {
private final JdbcTemplate jdbcTemplate;
public Map<String, Object> load(final Long clientId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT c.id, c.firstname, c.middlename, c.lastname, c.fullname, c.display_name, c.date_of_birth,
g.code_value AS gender, c.mobile_no, c.email_address, c.account_no, c.status_enum, c.office_id,
o.name AS office_name,
(SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL
ORDER BY k.id DESC LIMIT 1) AS pan,
(SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id
AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar_masked,
(SELECT k.ckyc_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.ckyc_number IS NOT NULL
ORDER BY k.id DESC LIMIT 1) AS ckyc_number
FROM m_client c
LEFT JOIN m_code_value g ON g.id = c.gender_cv_id
LEFT JOIN m_office o ON o.id = c.office_id
WHERE c.id = ?
""", clientId);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.client.not.found", "Client " + clientId + " was not found.");
}
final Map<String, Object> c = rows.get(0);
final Map<String, Object> ctx = new LinkedHashMap<>();
ctx.put("client.id", c.get("id"));
String first = str(c.get("firstname"));
String last = str(c.get("lastname"));
final String display = str(c.get("display_name"));
if (first == null && last == null && display != null) {
// An entity or a client captured with a single full name: split it the way bureaus expect.
final String[] parts = display.trim().split("\\s+");
first = parts[0];
last = parts.length > 1 ? parts[parts.length - 1] : null;
}
ctx.put("client.firstName", first);
ctx.put("client.middleName", str(c.get("middlename")));
ctx.put("client.lastName", last);
ctx.put("client.fullName", display != null ? display : str(c.get("fullname")));
ctx.put("client.dob", c.get("date_of_birth") == null ? null : c.get("date_of_birth").toString());
ctx.put("client.gender", str(c.get("gender")));
ctx.put("client.pan", str(c.get("pan")));
ctx.put("client.aadhaarMasked", str(c.get("aadhaar_masked")));
ctx.put("client.ckycNumber", str(c.get("ckyc_number")));
ctx.put("client.mobile", str(c.get("mobile_no")));
ctx.put("client.email", str(c.get("email_address")));
ctx.put("client.cif", str(c.get("account_no")));
ctx.put("client.branch", str(c.get("office_name")));
ctx.put("client.officeId", c.get("office_id"));
ctx.put("client.status", c.get("status_enum"));
try {
final List<Map<String, Object>> addresses = jdbcTemplate.queryForList("""
SELECT a.address_line_1, a.address_line_2, a.street, a.town_village, a.city, a.postal_code,
sp.code_value AS state_name
FROM m_client_address ca
JOIN m_address a ON a.id = ca.address_id
LEFT JOIN m_code_value sp ON sp.id = a.state_province_id
WHERE ca.client_id = ? AND (ca.is_active IS NULL OR ca.is_active = TRUE)
ORDER BY ca.id
LIMIT 1
""", clientId);
if (!addresses.isEmpty()) {
final Map<String, Object> a = addresses.get(0);
ctx.put("client.addressLine1", first(str(a.get("address_line_1")), str(a.get("street"))));
ctx.put("client.addressLine2", str(a.get("address_line_2")));
ctx.put("client.city", first(str(a.get("city")), str(a.get("town_village"))));
ctx.put("client.state", str(a.get("state_name")));
ctx.put("client.pincode", str(a.get("postal_code")));
}
} catch (final RuntimeException e) {
// A tenant whose address tables differ simply sends no address; the enquiry still works on PAN + DOB.
log.warn("CIC: could not read the address of client {}: {}", clientId, e.getMessage());
}
return ctx;
}
private static String str(final Object value) {
if (value == null) {
return null;
}
final String s = value.toString().trim();
return s.isEmpty() ? null : s;
}
private static String first(final String a, final String b) {
return a != null ? a : b;
}
}
@@ -23,7 +23,6 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
@@ -96,17 +95,22 @@ public class CicConfigService {
v.put("tokenUrl", row.get("token_url"));
v.put("clientId", row.get("client_id"));
v.put("timeoutMs", row.get("timeout_ms"));
v.put("reportValidityDays", row.get("report_validity_days"));
v.put("enquiryCharge", row.get("enquiry_charge"));
v.put("alertAutoFetch", row.get("alert_auto_fetch"));
v.put("alertFetchIntervalMin", row.get("alert_fetch_interval_min"));
v.put("alertLookbackDays", row.get("alert_lookback_days"));
v.put("lastFetchOn", row.get("last_fetch_on") == null ? null : row.get("last_fetch_on").toString());
v.put("lastFetchStatus", row.get("last_fetch_status"));
v.put("lastFetchMessage", row.get("last_fetch_message"));
v.put("lastTestStatus", row.get("last_test_status"));
v.put("lastTestMessage", row.get("last_test_message"));
v.put("lastTestedOn", row.get("last_tested_on") == null ? null : row.get("last_tested_on").toString());
v.put("updatedOn", row.get("updated_on") == null ? null : row.get("updated_on").toString());
v.put("activeEndpoints", jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, id));
v.put("enquiriesLast30Days", jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_enquiry WHERE bureau_id = ? AND requested_on >= ?", Integer.class, id,
Timestamp.from(Instant.now().minusSeconds(30L * 86400))));
v.put("alertsLast30Days", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND received_on >= ?",
Integer.class, id, Timestamp.from(Instant.now().minusSeconds(30L * 86400))));
v.put("newAlerts", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND status = 'NEW'",
Integer.class, id));
return v;
}
@@ -133,15 +137,15 @@ public class CicConfigService {
if (timeoutMs < 1000 || timeoutMs > 300000) {
throw fail("error.msg.cic.timeout.invalid", "Timeout must be between 1,000 and 300,000 milliseconds.");
}
final int validity = intOf(in, "reportValidityDays", ((Number) row.get("report_validity_days")).intValue());
if (validity < 0 || validity > 365) {
throw fail("error.msg.cic.validity.invalid", "Report reuse window must be between 0 and 365 days (0 = always pull fresh).");
final boolean autoFetch = in.has("alertAutoFetch") && !in.get("alertAutoFetch").isJsonNull() ? in.get("alertAutoFetch").getAsBoolean()
: Boolean.TRUE.equals(row.get("alert_auto_fetch"));
final int interval = intOf(in, "alertFetchIntervalMin", ((Number) row.get("alert_fetch_interval_min")).intValue());
if (interval < 15 || interval > 1440) {
throw fail("error.msg.cic.interval.invalid", "Automatic fetch interval must be between 15 minutes and 24 hours (1440).");
}
final BigDecimal charge = in.has("enquiryCharge") && !in.get("enquiryCharge").isJsonNull()
? in.get("enquiryCharge").getAsBigDecimal()
: (BigDecimal) row.get("enquiry_charge");
if (charge != null && charge.signum() < 0) {
throw fail("error.msg.cic.charge.invalid", "Enquiry charge cannot be negative.");
final int lookback = intOf(in, "alertLookbackDays", ((Number) row.get("alert_lookback_days")).intValue());
if (lookback < 1 || lookback > 90) {
throw fail("error.msg.cic.lookback.invalid", "Look-back window must be between 1 and 90 days.");
}
String secretEnc = (String) row.get("secret_enc");
@@ -159,18 +163,19 @@ public class CicConfigService {
jdbcTemplate.update("""
UPDATE m_cic_bureau SET name = ?, description = ?, environment = ?, base_url = ?, member_code = ?, auth_type = ?,
username = ?, api_key_header = ?, secret_enc = ?, token_url = ?, client_id = ?, timeout_ms = ?,
report_validity_days = ?, enquiry_charge = ?, is_default = ?, updated_by = ?, updated_on = ?
alert_auto_fetch = ?, alert_fetch_interval_min = ?, alert_lookback_days = ?, is_default = ?, updated_by = ?,
updated_on = ?
WHERE id = ?
""", StringUtils.defaultIfBlank(text(in, "name", (String) row.get("name")), (String) row.get("name")),
trimToNull(text(in, "description", (String) row.get("description"))), environment, baseUrl,
trimToNull(text(in, "memberCode", (String) row.get("member_code"))), authType,
trimToNull(text(in, "username", (String) row.get("username"))),
StringUtils.defaultIfBlank(text(in, "apiKeyHeader", (String) row.get("api_key_header")), "X-API-KEY"), secretEnc, tokenUrl,
trimToNull(text(in, "clientId", (String) row.get("client_id"))), timeoutMs, validity, charge,
trimToNull(text(in, "clientId", (String) row.get("client_id"))), timeoutMs, autoFetch, interval, lookback,
makeDefault || Boolean.TRUE.equals(row.get("is_default")), user.getId(), Timestamp.from(Instant.now()), id);
gateway.evictToken(id);
// A whitelisted bureau must stay usable after the edit; otherwise it would fail every enquiry silently.
// A whitelisted bureau must stay usable after the edit; otherwise every alert fetch would fail silently.
if (Boolean.TRUE.equals(row.get("whitelisted"))) {
assertReadyForLive(bureauRow(id));
}
@@ -188,7 +193,7 @@ public class CicConfigService {
return getBureau(id);
}
/** What a bureau needs before enquiries may run against it. */
/** What a bureau needs before alerts may be fetched from it. */
public void assertReadyForLive(final Map<String, Object> row) {
final String code = (String) row.get("code");
final Integer endpoints = jdbcTemplate.queryForObject(
@@ -223,7 +228,7 @@ public class CicConfigService {
String message;
if ("SIMULATOR".equals(row.get("environment"))) {
status = "OK";
message = "Simulator mode - no network call is made; enquiries are answered by the built-in simulator.";
message = "Simulator mode - no network call is made; alerts are answered by the built-in simulator.";
} else if (StringUtils.isBlank((String) row.get("base_url"))) {
status = "FAILED";
message = "No base URL configured.";
@@ -339,6 +344,10 @@ public class CicConfigService {
if (active && rows.stream().noneMatch(r -> "REQUEST".equals(r[0]))) {
throw fail("error.msg.cic.no.request.mapping", "An active endpoint needs at least one REQUEST mapping.");
}
if (active && rows.stream().noneMatch(r -> "RESPONSE".equals(r[0]) && "alerts".equals(r[2]))) {
throw fail("error.msg.cic.no.alert.list",
"Map the response path of the alert list to the result field 'alerts' (e.g. data.alerts), or the alerts cannot be read.");
}
Map<String, Object> existing = endpointRow(bureauId, t);
final Timestamp now = Timestamp.from(Instant.now());
@@ -1,245 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonObject;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The borrower's consent to a credit bureau pull. No enquiry runs without an active, unexpired consent for the client
* (for that bureau or for any bureau), and the consent used is recorded on the enquiry.
*/
@Service
@RequiredArgsConstructor
public class CicConsentService {
public static final String DEFAULT_TEXT = "I/We hereby authorise the Bank to obtain my/our credit information report from any Credit"
+ " Information Company (CIBIL, Experian, Equifax, CRIF High Mark) for the purpose stated, to share my/our details with"
+ " them for this purpose, and to use the report to assess my/our application. This consent is given voluntarily.";
private final JdbcTemplate jdbcTemplate;
private final CicClientProfileService clientProfileService;
@Transactional
public Map<String, Object> create(final JsonObject in, final AppUser user, final String ipAddress) {
final Long clientId = in.has("clientId") && !in.get("clientId").isJsonNull() ? in.get("clientId").getAsLong() : null;
if (clientId == null) {
throw CicConfigService.fail("error.msg.cic.consent.client.required", "Client is required.");
}
final Map<String, Object> client = clientProfileService.load(clientId);
final Long bureauId = in.has("bureauId") && !in.get("bureauId").isJsonNull() ? in.get("bureauId").getAsLong() : null;
if (bureauId != null && jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_bureau WHERE id = ?", Integer.class, bureauId) == 0) {
throw CicConfigService.fail("error.msg.cic.bureau.not.found", "Credit bureau " + bureauId + " was not found.");
}
final String purpose = StringUtils.defaultIfBlank(CicConfigService.text(in, "purpose", "NEW_LOAN"), "NEW_LOAN").toUpperCase();
if (!CicCatalog.PURPOSES.containsKey(purpose)) {
throw CicConfigService.fail("error.msg.cic.purpose.invalid", "Unknown purpose '" + purpose + "'.");
}
final String mode = StringUtils.defaultIfBlank(CicConfigService.text(in, "consentMode", "SIGNED_FORM"), "SIGNED_FORM").toUpperCase();
if (!CicCatalog.CONSENT_MODES.contains(mode)) {
throw CicConfigService.fail("error.msg.cic.consent.mode.invalid", "Consent mode must be one of " + CicCatalog.CONSENT_MODES + ".");
}
final String documentRef = StringUtils.trimToNull(CicConfigService.text(in, "documentRef", null));
final String otpReference = StringUtils.trimToNull(CicConfigService.text(in, "otpReference", null));
if ("SIGNED_FORM".equals(mode) && documentRef == null) {
throw CicConfigService.fail("error.msg.cic.consent.document.required",
"A signed consent form needs its document reference (form number or uploaded document name).");
}
if ("OTP".equals(mode) && otpReference == null) {
throw CicConfigService.fail("error.msg.cic.consent.otp.required", "An OTP consent needs the OTP / transaction reference.");
}
final boolean declared = in.has("customerDeclarationAccepted") && in.get("customerDeclarationAccepted").getAsBoolean();
if (!declared) {
throw CicConfigService.fail("error.msg.cic.consent.declaration.required",
"Confirm that the customer has read and accepted the consent declaration.");
}
final int validityDays = in.has("validityDays") && !in.get("validityDays").isJsonNull() ? in.get("validityDays").getAsInt() : 30;
if (validityDays < 1 || validityDays > 365) {
throw CicConfigService.fail("error.msg.cic.consent.validity.invalid", "Consent validity must be between 1 and 365 days.");
}
final LocalDate from = DateUtils.getBusinessLocalDate();
final LocalDate until = from.plusDays(validityDays - 1L);
final String ref = "CNS-" + from.format(DateTimeFormatter.BASIC_ISO_DATE) + "-"
+ String.format("%06d", ThreadLocalRandom.current().nextInt(1_000_000));
final String consentText = StringUtils.defaultIfBlank(CicConfigService.text(in, "consentText", null), DEFAULT_TEXT);
jdbcTemplate.update("""
INSERT INTO m_cic_consent (consent_ref, client_id, client_name, application_no, bureau_id, purpose, consent_mode, consent_text,
document_ref, otp_reference, valid_from, valid_until, status, captured_by, captured_by_name,
captured_on, ip_address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?)
""", ref, clientId, client.get("client.fullName"), StringUtils.trimToNull(CicConfigService.text(in, "applicationNo", null)),
bureauId, purpose, mode, consentText, documentRef, otpReference, Date.valueOf(from), Date.valueOf(until), user.getId(),
user.getDisplayName(), Timestamp.from(Instant.now()), StringUtils.abbreviate(ipAddress, 64));
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_cic_consent WHERE consent_ref = ?", Long.class, ref);
return get(id);
}
@Transactional
public Map<String, Object> revoke(final Long id, final String reason, final AppUser user) {
final Map<String, Object> c = get(id);
if (!"ACTIVE".equals(c.get("status"))) {
throw CicConfigService.fail("error.msg.cic.consent.not.active", "Only an active consent can be revoked (this one is " + c.get("status") + ").");
}
if (StringUtils.isBlank(reason)) {
throw CicConfigService.fail("error.msg.cic.consent.reason.required", "Give the reason for revoking the consent.");
}
jdbcTemplate.update("UPDATE m_cic_consent SET status = 'REVOKED', revoked_reason = ?, revoked_by = ?, revoked_on = ? WHERE id = ?",
StringUtils.abbreviate(reason.trim(), 500), user.getId(), Timestamp.from(Instant.now()), id);
return get(id);
}
public Map<String, Object> get(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT c.*, b.code AS bureau_code FROM m_cic_consent c LEFT JOIN m_cic_bureau b ON b.id = c.bureau_id WHERE c.id = ?", id);
if (rows.isEmpty()) {
throw CicConfigService.fail("error.msg.cic.consent.not.found", "Consent " + id + " was not found.");
}
return view(rows.get(0));
}
public Map<String, Object> list(final Long clientId, final String status, final String search, final int offset, final int limit) {
final StringBuilder where = new StringBuilder(" WHERE 1 = 1");
final List<Object> args = new ArrayList<>();
if (clientId != null) {
where.append(" AND c.client_id = ?");
args.add(clientId);
}
final LocalDate today = DateUtils.getBusinessLocalDate();
if (StringUtils.isNotBlank(status)) {
switch (status.toUpperCase()) {
case "ACTIVE" -> {
where.append(" AND c.status = 'ACTIVE' AND c.valid_until >= ?");
args.add(Date.valueOf(today));
}
case "EXPIRED" -> {
where.append(" AND c.status = 'ACTIVE' AND c.valid_until < ?");
args.add(Date.valueOf(today));
}
default -> {
where.append(" AND c.status = ?");
args.add(status.toUpperCase());
}
}
}
if (StringUtils.isNotBlank(search)) {
where.append(" AND (LOWER(c.client_name) LIKE ? OR LOWER(c.consent_ref) LIKE ? OR LOWER(c.application_no) LIKE ?)");
final String like = "%" + search.trim().toLowerCase() + "%";
args.add(like);
args.add(like);
args.add(like);
}
final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_consent c" + where, Integer.class, args.toArray());
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(limit);
pageArgs.add(offset);
final List<Map<String, Object>> items = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(
"SELECT c.*, b.code AS bureau_code FROM m_cic_consent c LEFT JOIN m_cic_bureau b ON b.id = c.bureau_id" + where
+ " ORDER BY c.id DESC LIMIT ? OFFSET ?",
pageArgs.toArray())) {
items.add(view(row));
}
final Map<String, Object> res = new LinkedHashMap<>();
res.put("totalFilteredRecords", total);
res.put("pageItems", items);
return res;
}
/**
* The consent an enquiry runs under: the one named, or the latest active, unexpired consent of this client that
* covers this bureau (a consent with no bureau covers every bureau).
*/
public Map<String, Object> requireValid(final Long clientId, final Long bureauId, final Long consentId) {
final LocalDate today = DateUtils.getBusinessLocalDate();
final List<Map<String, Object>> rows;
if (consentId != null) {
rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_consent WHERE id = ?", consentId);
if (rows.isEmpty()) {
throw CicConfigService.fail("error.msg.cic.consent.not.found", "Consent " + consentId + " was not found.");
}
final Map<String, Object> c = rows.get(0);
if (((Number) c.get("client_id")).longValue() != clientId) {
throw CicConfigService.fail("error.msg.cic.consent.other.client", "Consent " + c.get("consent_ref") + " belongs to another client.");
}
if (c.get("bureau_id") != null && ((Number) c.get("bureau_id")).longValue() != bureauId) {
throw CicConfigService.fail("error.msg.cic.consent.other.bureau", "Consent " + c.get("consent_ref") + " was given for another bureau.");
}
if (!"ACTIVE".equals(c.get("status")) || ((Date) c.get("valid_until")).toLocalDate().isBefore(today)) {
throw CicConfigService.fail("error.msg.cic.consent.not.valid", "Consent " + c.get("consent_ref") + " is revoked or expired.");
}
return c;
}
rows = jdbcTemplate.queryForList("""
SELECT * FROM m_cic_consent
WHERE client_id = ? AND status = 'ACTIVE' AND valid_until >= ? AND (bureau_id IS NULL OR bureau_id = ?)
ORDER BY id DESC LIMIT 1
""", clientId, Date.valueOf(today), bureauId);
if (rows.isEmpty()) {
throw CicConfigService.fail("error.msg.cic.consent.missing",
"No valid credit bureau consent is on record for this customer. Capture the customer's consent first.");
}
return rows.get(0);
}
private static Map<String, Object> view(final Map<String, Object> row) {
final Map<String, Object> v = new LinkedHashMap<>();
v.put("id", row.get("id"));
v.put("consentRef", row.get("consent_ref"));
v.put("clientId", row.get("client_id"));
v.put("clientName", row.get("client_name"));
v.put("applicationNo", row.get("application_no"));
v.put("bureauId", row.get("bureau_id"));
v.put("bureauCode", row.get("bureau_code") == null ? "ANY" : row.get("bureau_code"));
v.put("purpose", row.get("purpose"));
v.put("consentMode", row.get("consent_mode"));
v.put("consentText", row.get("consent_text"));
v.put("documentRef", row.get("document_ref"));
v.put("otpReference", row.get("otp_reference"));
v.put("validFrom", row.get("valid_from") == null ? null : row.get("valid_from").toString());
v.put("validUntil", row.get("valid_until") == null ? null : row.get("valid_until").toString());
String status = (String) row.get("status");
if ("ACTIVE".equals(status) && row.get("valid_until") instanceof Date d && d.toLocalDate().isBefore(DateUtils.getBusinessLocalDate())) {
status = "EXPIRED";
}
v.put("status", status);
v.put("revokedReason", row.get("revoked_reason"));
v.put("revokedOn", row.get("revoked_on") == null ? null : row.get("revoked_on").toString());
v.put("capturedByName", row.get("captured_by_name"));
v.put("capturedOn", row.get("captured_on") == null ? null : row.get("captured_on").toString());
v.put("ipAddress", row.get("ip_address"));
return v;
}
}
@@ -1,615 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Runs credit bureau enquiries: builds the bureau request from the client's data through the bureau's REQUEST
* mapping, enforces whitelist and consent, reuses a recent report when allowed, calls the bureau (or the simulator),
* reads the result through the RESPONSE mapping, and logs every enquiry with its masked request and raw response.
*
* Deliberately not @Transactional: the enquiry row is written once, at the end, whatever the outcome, so a failed
* call is on record as FAILED rather than rolled away.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CicEnquiryService {
private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{\\s*([A-Za-z0-9_.]+)\\s*}}");
private final JdbcTemplate jdbcTemplate;
private final CicConfigService configService;
private final CicConsentService consentService;
private final CicClientProfileService clientProfileService;
private final CicHttpGateway gateway;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/** A bureau request as it would be sent. */
public record BuiltRequest(String url, String method, String contentType, Map<String, String> headers, JsonObject body,
JsonObject maskedBody, List<String> missingMandatory) {}
// ── Request building ──────────────────────────────────────────────────────
private Map<String, Object> context(final Map<String, Object> bureau, final Long clientId, final BigDecimal amount, final String purpose,
final String applicationNo, final String reference) {
final Map<String, Object> ctx = new LinkedHashMap<>(clientProfileService.load(clientId));
ctx.put("enquiry.reference", reference);
ctx.put("enquiry.date", DateUtils.getBusinessLocalDate().toString());
ctx.put("enquiry.amount", amount);
ctx.put("enquiry.purposeCode", purpose);
ctx.put("enquiry.purpose", CicCatalog.PURPOSES.get(purpose));
ctx.put("enquiry.applicationNo", applicationNo);
ctx.put("bureau.memberCode", bureau.get("member_code"));
ctx.put("bureau.username", bureau.get("username"));
return ctx;
}
public BuiltRequest build(final Map<String, Object> bureau, final Map<String, Object> endpoint, final Map<String, Object> ctx) {
final Long endpointId = ((Number) endpoint.get("id")).longValue();
final JsonElement base = JsonParser.parseString(StringUtils.defaultIfBlank((String) endpoint.get("base_body"), "{}"));
final JsonObject body = base.isJsonObject() ? base.getAsJsonObject() : new JsonObject();
final JsonObject masked = body.deepCopy();
final List<String> missing = new ArrayList<>();
for (final Map<String, Object> m : configService.mappingRows(endpointId, "REQUEST")) {
final String source = (String) m.get("source_field");
final String target = (String) m.get("target_field");
Object raw = source == null ? null : ctx.get(source);
if ((raw == null || raw.toString().isBlank()) && m.get("default_value") != null) {
raw = m.get("default_value");
}
JsonElement value;
try {
value = CicCatalog.applyRequestTransform((String) m.get("transform"), raw);
} catch (final RuntimeException e) {
value = null;
}
if (value == null) {
if (Boolean.TRUE.equals(m.get("is_mandatory"))) {
missing.add(CicCatalog.CBS_FIELDS.getOrDefault(source, String.valueOf(source)) + " (" + target + ")");
}
continue;
}
CicJsonPath.set(body, target, value);
final boolean sensitive = source != null && CicCatalog.SENSITIVE_FIELDS.contains(source);
CicJsonPath.set(masked, target, sensitive && value.isJsonPrimitive() ? new JsonPrimitive(CicCatalog.mask(value.getAsString())) : value);
}
final Map<String, String> headers = new LinkedHashMap<>();
final JsonElement hj = JsonParser.parseString(StringUtils.defaultIfBlank((String) endpoint.get("headers_json"), "{}"));
if (hj.isJsonObject()) {
for (final Map.Entry<String, JsonElement> h : hj.getAsJsonObject().entrySet()) {
headers.put(h.getKey(), resolvePlaceholders(h.getValue().getAsString(), ctx));
}
}
final String url = CicHttpGateway.joinUrl((String) bureau.get("base_url"), (String) endpoint.get("path"));
return new BuiltRequest(url, (String) endpoint.get("http_method"), (String) endpoint.get("content_type"), headers, body, masked,
missing);
}
private static String resolvePlaceholders(final String template, final Map<String, Object> ctx) {
final Matcher m = PLACEHOLDER.matcher(template);
final StringBuilder sb = new StringBuilder();
while (m.find()) {
final Object v = ctx.get(m.group(1));
m.appendReplacement(sb, Matcher.quoteReplacement(v == null ? "" : v.toString()));
}
m.appendTail(sb);
return sb.toString();
}
private Map<String, Object> activeEndpoint(final Map<String, Object> bureau, final String enquiryType) {
final Map<String, Object> endpoint = configService.endpointRow(((Number) bureau.get("id")).longValue(), enquiryType);
if (endpoint == null || !Boolean.TRUE.equals(endpoint.get("is_active"))) {
throw CicConfigService.fail("error.msg.cic.endpoint.inactive",
bureau.get("code") + " has no active " + enquiryType + " endpoint. Configure it under API Mapping.");
}
return endpoint;
}
/** Dry run: the request exactly as it would go to the bureau (sensitive values masked), with what is missing. */
public Map<String, Object> preview(final Long bureauId, final JsonObject in) {
final Map<String, Object> bureau = configService.bureauRow(bureauId);
final Long clientId = longOf(in, "clientId");
if (clientId == null) {
throw CicConfigService.fail("error.msg.cic.client.required", "Choose a client for the preview.");
}
final String enquiryType = StringUtils.defaultIfBlank(CicConfigService.text(in, "enquiryType", "CONSUMER_ENQUIRY"), "CONSUMER_ENQUIRY")
.toUpperCase();
final String purpose = StringUtils.defaultIfBlank(CicConfigService.text(in, "purpose", "NEW_LOAN"), "NEW_LOAN").toUpperCase();
final BigDecimal amount = in.has("loanAmount") && !in.get("loanAmount").isJsonNull() ? in.get("loanAmount").getAsBigDecimal()
: new BigDecimal("100000");
final Map<String, Object> endpoint = configService.endpointRow(bureauId, enquiryType);
if (endpoint == null) {
throw CicConfigService.fail("error.msg.cic.endpoint.missing", bureau.get("code") + " has no " + enquiryType + " endpoint yet.");
}
final Map<String, Object> ctx = context(bureau, clientId, amount, purpose, CicConfigService.text(in, "applicationNo", null),
"PREVIEW-" + System.currentTimeMillis());
final BuiltRequest req = build(bureau, endpoint, ctx);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("environment", bureau.get("environment"));
res.put("url", "SIMULATOR".equals(bureau.get("environment")) ? "(simulator - no network call)" : req.url());
res.put("method", req.method());
res.put("headers", req.headers());
res.put("authentication", bureau.get("auth_type"));
res.put("body", gson.toJson(req.maskedBody()));
res.put("missingMandatory", req.missingMandatory());
res.put("clientData", maskedContext(ctx));
return res;
}
private static Map<String, Object> maskedContext(final Map<String, Object> ctx) {
final Map<String, Object> out = new LinkedHashMap<>();
ctx.forEach((k, v) -> {
if (k.startsWith("client.") && !"client.officeId".equals(k) && !"client.status".equals(k)) {
out.put(k, v != null && CicCatalog.SENSITIVE_FIELDS.contains(k) ? CicCatalog.mask(v.toString()) : v);
}
});
return out;
}
/** What the screens show about a client before an enquiry: the data that will go out, and the consent on file. */
public Map<String, Object> clientProfile(final Long clientId, final Long bureauId) {
final Map<String, Object> ctx = clientProfileService.load(clientId);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("clientId", clientId);
res.put("data", maskedContext(ctx));
Map<String, Object> consent = null;
if (bureauId != null) {
try {
consent = consentService.requireValid(clientId, bureauId, null);
} catch (final RuntimeException e) {
consent = null;
}
} else {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT * FROM m_cic_consent WHERE client_id = ? AND status = 'ACTIVE' AND valid_until >= ? ORDER BY id DESC LIMIT 1
""", clientId, Date.valueOf(DateUtils.getBusinessLocalDate()));
consent = rows.isEmpty() ? null : rows.get(0);
}
Map<String, Object> consentView = null;
if (consent != null) {
consentView = new LinkedHashMap<>();
consentView.put("id", consent.get("id"));
consentView.put("consentRef", consent.get("consent_ref"));
consentView.put("validUntil", String.valueOf(consent.get("valid_until")));
consentView.put("consentMode", consent.get("consent_mode"));
consentView.put("bureauId", consent.get("bureau_id"));
}
res.put("validConsent", consentView);
final List<Map<String, Object>> last = jdbcTemplate.queryForList("""
SELECT e.id, e.enquiry_ref, e.bureau_code, e.status, e.score, e.requested_on FROM m_cic_enquiry e
WHERE e.client_id = ? ORDER BY e.id DESC LIMIT 5
""", clientId);
final List<Map<String, Object>> recent = new ArrayList<>();
for (final Map<String, Object> r : last) {
final Map<String, Object> v = new LinkedHashMap<>();
v.put("id", r.get("id"));
v.put("enquiryRef", r.get("enquiry_ref"));
v.put("bureauCode", r.get("bureau_code"));
v.put("status", r.get("status"));
v.put("score", r.get("score"));
v.put("requestedOn", String.valueOf(r.get("requested_on")));
recent.add(v);
}
res.put("recentEnquiries", recent);
return res;
}
// ── Enquiry ───────────────────────────────────────────────────────────────
public Map<String, Object> enquire(final JsonObject in, final AppUser user) {
final Long clientId = longOf(in, "clientId");
if (clientId == null) {
throw CicConfigService.fail("error.msg.cic.client.required", "Choose the customer to enquire on.");
}
Long bureauId = longOf(in, "bureauId");
if (bureauId == null) {
final List<Long> def = jdbcTemplate.queryForList(
"SELECT id FROM m_cic_bureau WHERE whitelisted = TRUE ORDER BY is_default DESC, id LIMIT 1", Long.class);
if (def.isEmpty()) {
throw CicConfigService.fail("error.msg.cic.none.whitelisted",
"No credit bureau is whitelisted for this bank. Whitelist one under CIC Configuration.");
}
bureauId = def.get(0);
}
final Map<String, Object> bureau = configService.bureauRow(bureauId);
if (!Boolean.TRUE.equals(bureau.get("whitelisted"))) {
throw CicConfigService.fail("error.msg.cic.bureau.not.whitelisted",
bureau.get("code") + " is not whitelisted for this bank. Only whitelisted bureaus can be enquired.");
}
configService.assertReadyForLive(bureau);
final String enquiryType = StringUtils.defaultIfBlank(CicConfigService.text(in, "enquiryType", "CONSUMER_ENQUIRY"), "CONSUMER_ENQUIRY")
.toUpperCase();
if (!CicCatalog.ENDPOINT_TYPES.contains(enquiryType)) {
throw CicConfigService.fail("error.msg.cic.endpoint.type.invalid", "Enquiry type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final String purpose = StringUtils.defaultIfBlank(CicConfigService.text(in, "purpose", "NEW_LOAN"), "NEW_LOAN").toUpperCase();
if (!CicCatalog.PURPOSES.containsKey(purpose)) {
throw CicConfigService.fail("error.msg.cic.purpose.invalid", "Unknown enquiry purpose '" + purpose + "'.");
}
final BigDecimal amount = in.has("loanAmount") && !in.get("loanAmount").isJsonNull() ? in.get("loanAmount").getAsBigDecimal() : null;
if (amount == null || amount.signum() <= 0) {
throw CicConfigService.fail("error.msg.cic.amount.required", "Enter the loan / credit amount the enquiry is for.");
}
final String applicationNo = StringUtils.trimToNull(CicConfigService.text(in, "applicationNo", null));
final boolean forceFresh = in.has("forceFresh") && !in.get("forceFresh").isJsonNull() && in.get("forceFresh").getAsBoolean();
final Map<String, Object> consent = consentService.requireValid(clientId, bureauId, longOf(in, "consentId"));
final Long consentId = ((Number) consent.get("id")).longValue();
final Map<String, Object> endpoint = activeEndpoint(bureau, enquiryType);
final LocalDate today = DateUtils.getBusinessLocalDate();
final String reference = "CIC" + today.format(DateTimeFormatter.ofPattern("yyMMdd"))
+ String.format("%08d", ThreadLocalRandom.current().nextInt(100_000_000));
final Map<String, Object> ctx = context(bureau, clientId, amount, purpose, applicationNo, reference);
final Row row = new Row();
row.reference = reference;
row.bureau = bureau;
row.clientId = clientId;
row.clientName = (String) ctx.get("client.fullName");
row.officeId = ctx.get("client.officeId");
row.applicationNo = applicationNo;
row.consentId = consentId;
row.enquiryType = enquiryType;
row.purpose = purpose;
row.amount = amount;
row.user = user;
// Reuse a recent successful report of the same bureau instead of paying for, and leaving, another hard enquiry.
final int validityDays = ((Number) bureau.get("report_validity_days")).intValue();
if (!forceFresh && validityDays > 0) {
final List<Map<String, Object>> recent = jdbcTemplate.queryForList("""
SELECT * FROM m_cic_enquiry
WHERE client_id = ? AND bureau_id = ? AND enquiry_type = ? AND status = 'SUCCESS' AND requested_on >= ?
ORDER BY id DESC LIMIT 1
""", clientId, bureauId, enquiryType, Timestamp.from(Instant.now().minusSeconds(validityDays * 86400L)));
if (!recent.isEmpty()) {
final Map<String, Object> src = recent.get(0);
row.status = "REUSED";
row.score = src.get("score") == null ? null : ((Number) src.get("score")).intValue();
row.resultJson = (String) src.get("result_json");
row.requestMasked = (String) src.get("request_masked");
row.responseRaw = (String) src.get("response_raw");
row.reusedFromId = ((Number) src.get("id")).longValue();
row.charge = BigDecimal.ZERO;
row.errorMessage = "Report " + src.get("enquiry_ref") + " of " + src.get("requested_on") + " reused (within " + validityDays
+ " days). Pull fresh to override.";
row.durationMs = 0;
return insert(row);
}
}
final BuiltRequest req = build(bureau, endpoint, ctx);
row.requestMasked = gson.toJson(Map.of("method", req.method(), "url", req.url(), "headers", req.headers(), "body", req.maskedBody()));
row.charge = (BigDecimal) bureau.get("enquiry_charge");
if (!req.missingMandatory().isEmpty()) {
row.status = "FAILED";
row.errorMessage = "Customer data missing for mandatory bureau fields: " + String.join(", ", req.missingMandatory());
row.charge = BigDecimal.ZERO;
row.durationMs = 0;
return insert(row);
}
// Call the bureau, or the simulator.
final long started = System.currentTimeMillis();
JsonElement response;
if ("SIMULATOR".equals(bureau.get("environment"))) {
response = CicSimulator.respond((String) bureau.get("code"), ctx, today, reference);
row.httpStatus = 200;
row.responseRaw = gson.toJson(response);
} else {
final CicHttpGateway.HttpResult r = gateway.send(bureau, req.method(), req.url(), req.headers(), req.contentType(),
"GET".equalsIgnoreCase(req.method()) ? null : gson.toJson(req.body()));
row.httpStatus = r.status();
row.responseRaw = r.body();
if (!r.ok()) {
row.status = "FAILED";
row.errorMessage = r.status() == 0 ? "Bureau not reachable: " + r.error()
: "Bureau returned HTTP " + r.status() + ": " + StringUtils.abbreviate(StringUtils.defaultString(r.body()), 500);
row.durationMs = (int) (System.currentTimeMillis() - started);
return insert(row);
}
try {
response = JsonParser.parseString(StringUtils.defaultIfBlank(r.body(), "{}"));
} catch (final RuntimeException e) {
row.status = "FAILED";
row.errorMessage = "Bureau response is not valid JSON: " + e.getMessage();
row.durationMs = (int) (System.currentTimeMillis() - started);
return insert(row);
}
}
row.durationMs = (int) (System.currentTimeMillis() - started);
final JsonObject result = parse(((Number) endpoint.get("id")).longValue(), response);
row.resultJson = gson.toJson(result);
row.score = result.has("score") && result.get("score").isJsonPrimitive() ? scoreOf(result.get("score")) : null;
final boolean noHit = result.has("hit") && result.get("hit").isJsonPrimitive() && !truthy(result.get("hit"));
if (result.has("errorCode") && !result.get("errorCode").isJsonNull() && StringUtils.isNotBlank(result.get("errorCode").getAsString())) {
row.status = "FAILED";
row.errorMessage = "Bureau error " + result.get("errorCode").getAsString()
+ (result.has("errorMessage") && !result.get("errorMessage").isJsonNull() ? ": " + result.get("errorMessage").getAsString() : "");
} else if (noHit) {
row.status = "NO_HIT";
row.errorMessage = "No record found at " + bureau.get("code") + " (new to credit).";
} else {
row.status = "SUCCESS";
}
return insert(row);
}
private JsonObject parse(final Long endpointId, final JsonElement response) {
final JsonObject result = new JsonObject();
for (final Map<String, Object> m : configService.mappingRows(endpointId, "RESPONSE")) {
JsonElement value = null;
try {
value = CicJsonPath.get(response, (String) m.get("source_field"));
} catch (final RuntimeException e) {
log.debug("CIC: response path {} unreadable: {}", m.get("source_field"), e.getMessage());
}
if (value == null && m.get("default_value") != null) {
value = new JsonPrimitive((String) m.get("default_value"));
}
if (value != null) {
result.add((String) m.get("target_field"), CicCatalog.applyResponseTransform((String) m.get("transform"), value));
}
}
return result;
}
private static Integer scoreOf(final JsonElement e) {
try {
final String digits = e.getAsString().replaceAll("[^0-9\\-]", "");
return digits.isEmpty() ? null : Integer.valueOf(new BigDecimal(e.getAsString().replaceAll("[^0-9.\\-]", "")).intValue());
} catch (final RuntimeException ex) {
return null;
}
}
private static boolean truthy(final JsonElement e) {
final String s = e.getAsString();
return "true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s) || "HIT".equalsIgnoreCase(s);
}
/** Everything one enquiry row carries; written once, at the end. */
private static final class Row {
private String reference;
private Map<String, Object> bureau;
private Long clientId;
private String clientName;
private Object officeId;
private String applicationNo;
private Long consentId;
private String enquiryType;
private String purpose;
private BigDecimal amount;
private String status;
private Integer score;
private Integer httpStatus;
private String errorMessage;
private String requestMasked;
private String responseRaw;
private String resultJson;
private Long reusedFromId;
private BigDecimal charge;
private int durationMs;
private AppUser user;
}
private Map<String, Object> insert(final Row r) {
jdbcTemplate.update("""
INSERT INTO m_cic_enquiry (enquiry_ref, bureau_id, bureau_code, client_id, client_name, application_no, consent_id, enquiry_type,
purpose, loan_amount, status, score, http_status, error_message, request_masked, response_raw,
result_json, reused_from_id, charge_amount, environment, office_id, requested_by, requested_by_name,
requested_on, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", r.reference, r.bureau.get("id"), r.bureau.get("code"), r.clientId, r.clientName, r.applicationNo, r.consentId,
r.enquiryType, r.purpose, r.amount, r.status, r.score, r.httpStatus, StringUtils.abbreviate(r.errorMessage, 2000),
r.requestMasked, r.responseRaw, r.resultJson, r.reusedFromId, r.charge, r.bureau.get("environment"),
r.officeId instanceof Number n ? n.longValue() : null, r.user.getId(), r.user.getDisplayName(), Timestamp.from(Instant.now()),
r.durationMs);
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_cic_enquiry WHERE enquiry_ref = ?", Long.class, r.reference);
log.info("CIC enquiry {} on client {} at {}: {} (score {})", r.reference, r.clientId, r.bureau.get("code"), r.status, r.score);
return get(id);
}
// ── Reads ────────────────────────────────────────────────────────────────
public Map<String, Object> get(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT e.*, c.consent_ref, b.name AS bureau_name, src.enquiry_ref AS reused_from_ref
FROM m_cic_enquiry e
LEFT JOIN m_cic_consent c ON c.id = e.consent_id
LEFT JOIN m_cic_bureau b ON b.id = e.bureau_id
LEFT JOIN m_cic_enquiry src ON src.id = e.reused_from_id
WHERE e.id = ?
""", id);
if (rows.isEmpty()) {
throw CicConfigService.fail("error.msg.cic.enquiry.not.found", "Enquiry " + id + " was not found.");
}
final Map<String, Object> v = summaryView(rows.get(0));
final Map<String, Object> row = rows.get(0);
v.put("consentRef", row.get("consent_ref"));
v.put("bureauName", row.get("bureau_name"));
v.put("reusedFromRef", row.get("reused_from_ref"));
v.put("httpStatus", row.get("http_status"));
v.put("requestMasked", row.get("request_masked"));
v.put("responseRaw", row.get("response_raw"));
v.put("result", row.get("result_json") == null ? null : JsonParser.parseString((String) row.get("result_json")));
return v;
}
private static Map<String, Object> summaryView(final Map<String, Object> row) {
final Map<String, Object> v = new LinkedHashMap<>();
v.put("id", row.get("id"));
v.put("enquiryRef", row.get("enquiry_ref"));
v.put("bureauId", row.get("bureau_id"));
v.put("bureauCode", row.get("bureau_code"));
v.put("clientId", row.get("client_id"));
v.put("clientName", row.get("client_name"));
v.put("applicationNo", row.get("application_no"));
v.put("enquiryType", row.get("enquiry_type"));
v.put("purpose", row.get("purpose"));
v.put("loanAmount", row.get("loan_amount"));
v.put("status", row.get("status"));
v.put("score", row.get("score"));
v.put("errorMessage", row.get("error_message"));
v.put("chargeAmount", row.get("charge_amount"));
v.put("environment", row.get("environment"));
v.put("requestedByName", row.get("requested_by_name"));
v.put("requestedOn", row.get("requested_on") == null ? null : row.get("requested_on").toString());
v.put("durationMs", row.get("duration_ms"));
return v;
}
public Map<String, Object> list(final Long clientId, final Long bureauId, final String status, final String fromDate, final String toDate,
final String search, final int offset, final int limit) {
final StringBuilder where = new StringBuilder(" WHERE 1 = 1");
final List<Object> args = new ArrayList<>();
if (clientId != null) {
where.append(" AND e.client_id = ?");
args.add(clientId);
}
if (bureauId != null) {
where.append(" AND e.bureau_id = ?");
args.add(bureauId);
}
if (StringUtils.isNotBlank(status)) {
where.append(" AND e.status = ?");
args.add(status.trim().toUpperCase());
}
if (StringUtils.isNotBlank(fromDate)) {
where.append(" AND e.requested_on >= ?");
args.add(Timestamp.valueOf(LocalDate.parse(fromDate).atStartOfDay()));
}
if (StringUtils.isNotBlank(toDate)) {
where.append(" AND e.requested_on < ?");
args.add(Timestamp.valueOf(LocalDate.parse(toDate).plusDays(1).atStartOfDay()));
}
if (StringUtils.isNotBlank(search)) {
where.append(" AND (LOWER(e.client_name) LIKE ? OR LOWER(e.enquiry_ref) LIKE ? OR LOWER(e.application_no) LIKE ?)");
final String like = "%" + search.trim().toLowerCase() + "%";
args.add(like);
args.add(like);
args.add(like);
}
final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_enquiry e" + where, Integer.class, args.toArray());
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(limit);
pageArgs.add(offset);
final List<Map<String, Object>> items = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate
.queryForList("SELECT e.* FROM m_cic_enquiry e" + where + " ORDER BY e.id DESC LIMIT ? OFFSET ?", pageArgs.toArray())) {
items.add(summaryView(row));
}
final Map<String, Object> res = new LinkedHashMap<>();
res.put("totalFilteredRecords", total);
res.put("pageItems", items);
return res;
}
public Map<String, Object> dashboard() {
final Timestamp since30 = Timestamp.from(Instant.now().minusSeconds(30L * 86400));
final Timestamp todayStart = Timestamp.valueOf(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).atStartOfDay());
final Map<String, Object> res = new LinkedHashMap<>();
res.put("enquiriesToday", count("SELECT COUNT(*) FROM m_cic_enquiry WHERE requested_on >= ?", todayStart));
res.put("enquiriesLast30Days", count("SELECT COUNT(*) FROM m_cic_enquiry WHERE requested_on >= ?", since30));
final Map<String, Object> byStatus = new LinkedHashMap<>();
for (final Map<String, Object> r : jdbcTemplate
.queryForList("SELECT status, COUNT(*) AS n FROM m_cic_enquiry WHERE requested_on >= ? GROUP BY status", since30)) {
byStatus.put((String) r.get("status"), r.get("n"));
}
res.put("byStatusLast30Days", byStatus);
res.put("averageScoreLast30Days", jdbcTemplate.queryForObject(
"SELECT AVG(score) FROM m_cic_enquiry WHERE status IN ('SUCCESS','REUSED') AND score IS NOT NULL AND requested_on >= ?",
BigDecimal.class, since30));
res.put("chargesLast30Days", jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(charge_amount), 0) FROM m_cic_enquiry WHERE requested_on >= ?", BigDecimal.class, since30));
final List<Map<String, Object>> byBureau = new ArrayList<>();
for (final Map<String, Object> r : jdbcTemplate.queryForList("""
SELECT b.id, b.code, b.name, b.whitelisted, b.environment, b.is_default,
(SELECT COUNT(*) FROM m_cic_enquiry e WHERE e.bureau_id = b.id AND e.requested_on >= ?) AS enquiries,
(SELECT COUNT(*) FROM m_cic_enquiry e WHERE e.bureau_id = b.id AND e.requested_on >= ? AND e.status = 'FAILED') AS failed
FROM m_cic_bureau b ORDER BY b.is_default DESC, b.id
""", since30, since30)) {
final Map<String, Object> v = new LinkedHashMap<>();
v.put("id", r.get("id"));
v.put("code", r.get("code"));
v.put("name", r.get("name"));
v.put("whitelisted", r.get("whitelisted"));
v.put("environment", r.get("environment"));
v.put("isDefault", r.get("is_default"));
v.put("enquiries", r.get("enquiries"));
v.put("failed", r.get("failed"));
byBureau.add(v);
}
res.put("bureaus", byBureau);
res.put("activeConsents", count("SELECT COUNT(*) FROM m_cic_consent WHERE status = 'ACTIVE' AND valid_until >= ?",
Date.valueOf(DateUtils.getBusinessLocalDate())));
final List<Map<String, Object>> bands = new ArrayList<>();
final int[][] ranges = { { 300, 549 }, { 550, 649 }, { 650, 699 }, { 700, 749 }, { 750, 900 } };
final String[] labels = { "Poor (300-549)", "Fair (550-649)", "Good (650-699)", "Very good (700-749)", "Excellent (750+)" };
for (int i = 0; i < ranges.length; i++) {
final Map<String, Object> band = new LinkedHashMap<>();
band.put("label", labels[i]);
band.put("count", count("SELECT COUNT(*) FROM m_cic_enquiry WHERE status = 'SUCCESS' AND score BETWEEN ? AND ? AND requested_on >= ?",
ranges[i][0], ranges[i][1], since30));
bands.add(band);
}
res.put("scoreBandsLast30Days", bands);
res.put("recent", list(null, null, null, null, null, null, 0, 10).get("pageItems"));
return res;
}
private Integer count(final String sql, final Object... args) {
return jdbcTemplate.queryForObject(sql, Integer.class, args);
}
private static Long longOf(final JsonObject in, final String key) {
return in != null && in.has(key) && !in.get(key).isJsonNull() && !in.get(key).getAsString().isBlank() ? in.get(key).getAsLong() : null;
}
}
@@ -21,122 +21,133 @@ package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Random;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Stand-in bureau for the SIMULATOR environment: answers in the shape of the seeded generic mapping, deterministically
* per PAN (the same customer always gets the same report), so the whole enquiry flow can be exercised and trained on
* before a bureau's real credentials are in place. Never used once the bureau is set to UAT or PRODUCTION.
* Stand-in CIC for the SIMULATOR environment. Plays the bureau's enquiry-alert service: for every day of the asked
* window it reports a few of this bank's own customers as enquired by other lenders, in the shape of the seeded generic
* mapping. Deterministic per bureau and day, so fetching the same window again yields the same alerts (and they are
* de-duplicated). Now and then it also sends an alert for an unknown customer and one raised by this bank itself, so
* the unmatched and own-bank paths can be seen too. Never used once the bureau is set to UAT or PRODUCTION.
*/
public final class CicSimulator {
@Component
@RequiredArgsConstructor
public class CicSimulator {
private static final String[] LENDERS = { "STATE BANK OF INDIA", "HDFC BANK", "ICICI BANK", "AXIS BANK", "BAJAJ FINANCE",
"PUNJAB NATIONAL BANK", "KOTAK MAHINDRA BANK", "MUTHOOT FINANCE" };
private static final String[] TYPES = { "PERSONAL LOAN", "CREDIT CARD", "HOUSING LOAN", "AUTO LOAN", "GOLD LOAN", "CONSUMER LOAN",
"KISAN CREDIT CARD", "TWO-WHEELER LOAN" };
private static final String[] PURPOSES = { "PERSONAL LOAN", "CREDIT CARD", "AUTO LOAN", "BUSINESS LOAN", "GOLD LOAN" };
private static final String[][] LENDERS = { { "HDFC BANK LTD", "BANK" }, { "STATE BANK OF INDIA", "BANK" }, { "ICICI BANK LTD", "BANK" },
{ "BAJAJ FINANCE LTD", "NBFC" }, { "MUTHOOT FINANCE LTD", "NBFC" }, { "UJJIVAN SMALL FINANCE BANK", "SMALL FINANCE BANK" },
{ "BANDHAN BANK LTD", "BANK" }, { "SATIN CREDITCARE NETWORK", "MFI" }, { "MADHYA PRADESH GRAMIN BANK", "REGIONAL RURAL BANK" },
{ "SARASWAT CO-OPERATIVE BANK", "CO-OPERATIVE BANK" }, { "TATA CAPITAL LTD", "NBFC" }, { "PUNJAB NATIONAL BANK", "BANK" } };
private static final Object[][] LOAN_TYPES = { { "PERSONAL LOAN", 50_000, 1_000_000 }, { "HOUSING LOAN", 800_000, 6_000_000 },
{ "AUTO LOAN", 300_000, 1_500_000 }, { "TWO-WHEELER LOAN", 50_000, 200_000 }, { "GOLD LOAN", 20_000, 500_000 },
{ "BUSINESS LOAN", 200_000, 5_000_000 }, { "CREDIT CARD", 25_000, 300_000 }, { "KISAN CREDIT CARD", 50_000, 300_000 },
{ "EDUCATION LOAN", 200_000, 2_000_000 }, { "CONSUMER DURABLE LOAN", 10_000, 150_000 },
{ "LOAN AGAINST PROPERTY", 500_000, 5_000_000 }, { "MICROFINANCE LOAN", 20_000, 100_000 } };
private static final String[] CITIES = { "BHOPAL", "INDORE", "VIDISHA", "SAGAR", "JABALPUR", "GWALIOR", "BASODA", "RAISEN" };
private static final DateTimeFormatter DDMMYYYY = DateTimeFormatter.ofPattern("ddMMyyyy");
private CicSimulator() {}
private final JdbcTemplate jdbcTemplate;
public static JsonObject respond(final String bureauCode, final Map<String, Object> ctx, final LocalDate businessDate,
final String reference) {
final String key = String.valueOf(ctx.get("client.pan") != null ? ctx.get("client.pan") : ctx.get("client.id"));
final long seed = Math.abs((long) (key + "|" + bureauCode).hashCode());
final Random r = new Random(seed);
public JsonObject respond(final Map<String, Object> bureau, final LocalDate from, final LocalDate to, final String reference) {
final String code = String.valueOf(bureau.get("code"));
final String ownMemberCode = bureau.get("member_code") == null ? "OWNBANK" : bureau.get("member_code").toString();
final List<Map<String, Object>> clients = jdbcTemplate.queryForList("""
SELECT c.id, c.display_name, c.account_no, c.date_of_birth,
(SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL
ORDER BY k.id DESC LIMIT 1) AS pan,
(SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id
AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar
FROM m_client c
WHERE c.status_enum = 300
ORDER BY c.id
LIMIT 300
""");
final JsonObject report = new JsonObject();
report.addProperty("reportId", bureauCode + "-SIM-" + reference);
report.addProperty("bureau", bureauCode);
report.addProperty("generatedOn", businessDate.toString());
final JsonArray alerts = new JsonArray();
final long days = Math.min(90, ChronoUnit.DAYS.between(from, to));
for (int d = 0; d <= days; d++) {
final LocalDate day = from.plusDays(d);
final Random r = new Random(Math.abs((long) (code + "|" + day).hashCode()));
final int count = clients.isEmpty() ? 0 : r.nextInt(4);
int seq = 0;
for (int i = 0; i < count; i++) {
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, null));
}
if (r.nextInt(8) == 0) {
// A consumer the bureau has on file for this bank but whom the bank no longer has (or never had) under that id.
alerts.add(alert(code, day, ++seq, Map.of("display_name", "UNKNOWN CUSTOMER", "pan", "ZZZPZ" + (1000 + r.nextInt(8999)) + "Z"),
r, null));
}
if (!clients.isEmpty() && r.nextInt(10) == 0) {
// An enquiry this bank made itself - the bureau reports it, the CBS must skip it.
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, ownMemberCode));
}
}
final JsonObject header = new JsonObject();
header.addProperty("status", "SUCCESS");
header.addProperty("referenceNumber", reference);
header.addProperty("bureau", code);
header.addProperty("fromDate", from.format(DDMMYYYY));
header.addProperty("toDate", to.format(DDMMYYYY));
final JsonObject data = new JsonObject();
data.addProperty("totalAlerts", alerts.size());
data.add("alerts", alerts);
final JsonObject root = new JsonObject();
root.add("report", report);
// Roughly one customer in fifteen is new to credit.
if (seed % 15 == 0) {
report.addProperty("hit", false);
report.addProperty("message", "No record found for the given identifiers (new to credit)");
return root;
}
report.addProperty("hit", true);
final int accountsCount = 1 + r.nextInt(6);
final JsonArray accounts = new JsonArray();
int active = 0;
int overdueCount = 0;
int writtenOff = 0;
long outstanding = 0;
long overdue = 0;
int maxDpd = 0;
final boolean stressed = seed % 7 == 0;
for (int i = 0; i < accountsCount; i++) {
final JsonObject a = new JsonObject();
final long sanctioned = (5 + r.nextInt(95)) * 10_000L;
final boolean closed = r.nextInt(4) == 0;
final long balance = closed ? 0 : (long) (sanctioned * (0.1 + r.nextDouble() * 0.8));
int dpd = 0;
if (!closed && (stressed || r.nextInt(6) == 0)) {
dpd = stressed ? 30 + r.nextInt(150) : r.nextInt(45);
}
final long due = dpd > 0 ? Math.max(1000, balance / 20) : 0;
final boolean wo = stressed && dpd > 120 && r.nextBoolean();
a.addProperty("memberName", LENDERS[r.nextInt(LENDERS.length)]);
a.addProperty("accountType", TYPES[r.nextInt(TYPES.length)]);
a.addProperty("ownership", r.nextInt(5) == 0 ? "JOINT" : "INDIVIDUAL");
a.addProperty("openedOn", businessDate.minusMonths(6 + r.nextInt(84)).toString());
a.addProperty("sanctionedAmount", sanctioned);
a.addProperty("currentBalance", balance);
a.addProperty("amountOverdue", due);
a.addProperty("dpd", dpd);
a.addProperty("status", wo ? "WRITTEN_OFF" : closed ? "CLOSED" : "ACTIVE");
accounts.add(a);
if (!closed) {
active++;
outstanding += balance;
}
if (due > 0) {
overdueCount++;
overdue += due;
}
if (wo) {
writtenOff++;
}
maxDpd = Math.max(maxDpd, dpd);
}
final int enquiriesCount = r.nextInt(stressed ? 9 : 5);
final JsonArray enquiries = new JsonArray();
for (int i = 0; i < enquiriesCount; i++) {
final JsonObject e = new JsonObject();
e.addProperty("memberName", LENDERS[r.nextInt(LENDERS.length)]);
e.addProperty("enquiryDate", businessDate.minusDays(5 + r.nextInt(170)).toString());
e.addProperty("purpose", PURPOSES[r.nextInt(PURPOSES.length)]);
e.addProperty("amount", (1 + r.nextInt(50)) * 20_000L);
enquiries.add(e);
}
int score = 650 + r.nextInt(200);
score -= Math.min(260, maxDpd * 2) + writtenOff * 60 + Math.max(0, enquiriesCount - 3) * 12;
score = Math.max(300, Math.min(900, score));
final JsonObject scoreObj = new JsonObject();
scoreObj.addProperty("value", score);
scoreObj.addProperty("version", bureauCode + " SIMULATED v1");
report.add("score", scoreObj);
final JsonObject summary = new JsonObject();
summary.addProperty("totalAccounts", accountsCount);
summary.addProperty("activeAccounts", active);
summary.addProperty("overdueAccounts", overdueCount);
summary.addProperty("writtenOffAccounts", writtenOff);
summary.addProperty("totalOutstanding", outstanding);
summary.addProperty("totalOverdue", overdue);
summary.addProperty("enquiriesLast6Months", enquiriesCount);
summary.addProperty("maxDpd", maxDpd);
report.add("summary", summary);
report.add("accounts", accounts);
report.add("enquiries", enquiries);
root.add("responseHeader", header);
root.add("data", data);
return root;
}
private static JsonObject alert(final String code, final LocalDate day, final int seq, final Map<String, Object> client, final Random r,
final String forcedMemberCode) {
final JsonObject consumer = new JsonObject();
consumer.addProperty("name", String.valueOf(client.get("display_name")).toUpperCase());
final Object pan = client.get("pan");
final Object aadhaar = client.get("aadhaar");
if (pan != null && !pan.toString().isBlank()) {
consumer.addProperty("pan", pan.toString().trim().toUpperCase());
} else if (aadhaar != null && !aadhaar.toString().isBlank()) {
consumer.addProperty("aadhaar", "XXXXXXXX" + lastFour(aadhaar.toString()));
} else if (client.get("account_no") != null) {
consumer.addProperty("memberReference", client.get("account_no").toString());
}
if (client.get("date_of_birth") != null) {
consumer.addProperty("dateOfBirth", LocalDate.parse(client.get("date_of_birth").toString().substring(0, 10)).format(DDMMYYYY));
}
final int lenderIdx = r.nextInt(LENDERS.length);
final Object[] type = LOAN_TYPES[r.nextInt(LOAN_TYPES.length)];
final int min = (Integer) type[1];
final int max = (Integer) type[2];
final long amount = Math.round((min + r.nextDouble() * (max - min)) / 5000.0) * 5000L;
final JsonObject enquiry = new JsonObject();
enquiry.addProperty("memberCode", forcedMemberCode != null ? forcedMemberCode : "SIM" + String.format("%04d", 101 + lenderIdx));
enquiry.addProperty("memberName", forcedMemberCode != null ? "THIS BANK" : LENDERS[lenderIdx][0]);
enquiry.addProperty("memberType", forcedMemberCode != null ? "BANK" : LENDERS[lenderIdx][1]);
enquiry.addProperty("date", day.format(DDMMYYYY));
enquiry.addProperty("productType", (String) type[0]);
enquiry.addProperty("amount", amount);
enquiry.addProperty("purpose", r.nextInt(5) == 0 ? "LOAN RENEWAL / ENHANCEMENT" : "NEW LOAN APPLICATION");
enquiry.addProperty("location", CITIES[r.nextInt(CITIES.length)]);
final JsonObject a = new JsonObject();
a.addProperty("alertId", code + "-" + day.format(DDMMYYYY) + "-" + seq);
a.add("consumer", consumer);
a.add("enquiry", enquiry);
return a;
}
private static String lastFour(final String value) {
final String digits = value.replaceAll("\\D", "");
return digits.length() >= 4 ? digits.substring(digits.length() - 4) : "0000";
}
}
@@ -179,4 +179,5 @@
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0604_cic_credit_bureau_module.xml" relativeToChangelogFile="true"/>
<include file="parts/0605_cic_enquiry_alerts.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
CIC module, second shape: the bank no longer pulls credit reports. Instead the CIC tells the bank when one of
ITS customers was enquired by ANOTHER bank / NBFC - i.e. the customer applied for a loan elsewhere - with the
lender, loan type and amount. These "enquiry alerts" are fetched through the same bureau configuration and
UI-driven API mapping (endpoint type ENQUIRY_ALERT) and shown on the Credit Bureau dashboard.
Removed: borrower consent (m_cic_consent), report enquiries (m_cic_enquiry), their permissions, and the
CONSUMER_ENQUIRY endpoints / mappings.
Added: m_cic_alert (one row per enquiry made on our customer at another lender),
m_cic_alert_fetch_run (every fetch / file import with its counts and raw response),
alert fetch settings on m_cic_bureau, the ENQUIRY_ALERT endpoint and its generic mapping.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0605-1-drop-cic-enquiry-consent" author="cbsnexora">
<sql>
DROP TABLE IF EXISTS m_cic_enquiry;
DROP TABLE IF EXISTS m_cic_consent;
DELETE FROM m_cic_field_mapping
WHERE endpoint_id IN (SELECT id FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY'));
DELETE FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY');
DELETE FROM m_role_permission
WHERE permission_id IN (SELECT id FROM m_permission
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT',
'REVOKE_CICCONSENT'));
DELETE FROM m_permission
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT', 'REVOKE_CICCONSENT');
</sql>
</changeSet>
<changeSet id="0605-2-cic-bureau-alert-settings" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_cic_bureau" columnName="report_validity_days"/>
</preConditions>
<dropColumn tableName="m_cic_bureau" columnName="report_validity_days"/>
<dropColumn tableName="m_cic_bureau" columnName="enquiry_charge"/>
<addColumn tableName="m_cic_bureau">
<column name="alert_auto_fetch" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
<column name="alert_fetch_interval_min" type="INT" defaultValueNumeric="60"><constraints nullable="false"/></column>
<column name="alert_lookback_days" type="INT" defaultValueNumeric="7"><constraints nullable="false"/></column>
<column name="last_fetch_on" type="TIMESTAMP"/>
<column name="last_fetch_status" type="VARCHAR(20)"/>
<column name="last_fetch_message" type="VARCHAR(1000)"/>
</addColumn>
</changeSet>
<changeSet id="0605-3-cic-alert-tables" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cic_alert"/></not>
</preConditions>
<createTable tableName="m_cic_alert_fetch_run">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="run_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_fetch_run_ref"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="trigger_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="environment" type="VARCHAR(20)"/>
<column name="from_date" type="DATE"/>
<column name="to_date" type="DATE"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="http_status" type="INT"/>
<column name="received" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="new_alerts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="matched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="unmatched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="duplicates" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="own_bank" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="error_message" type="VARCHAR(2000)"/>
<column name="request_masked" type="TEXT"/>
<column name="response_raw" type="TEXT"/>
<column name="duration_ms" type="INT"/>
<column name="triggered_by" type="BIGINT"/>
<column name="triggered_by_name" type="VARCHAR(200)"/>
<column name="started_on" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_cic_alert_fetch_run" indexName="idx_cic_fetch_run_bureau"><column name="bureau_id"/><column name="started_on"/></createIndex>
<createTable tableName="m_cic_alert">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="alert_key" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="client_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="client_name" type="VARCHAR(200)"/>
<column name="client_account_no" type="VARCHAR(50)"/>
<column name="match_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="customer_name" type="VARCHAR(200)"/>
<column name="pan_masked" type="VARCHAR(20)"/>
<column name="aadhaar_masked" type="VARCHAR(20)"/>
<column name="customer_ref" type="VARCHAR(100)"/>
<column name="date_of_birth" type="DATE"/>
<column name="institution_code" type="VARCHAR(100)"/>
<column name="institution_name" type="VARCHAR(200)"/>
<column name="institution_type" type="VARCHAR(60)"/>
<column name="enquiry_date" type="DATE"/>
<column name="loan_type" type="VARCHAR(100)"/>
<column name="amount" type="DECIMAL(19,6)"/>
<column name="purpose" type="VARCHAR(200)"/>
<column name="location" type="VARCHAR(200)"/>
<column name="status" type="VARCHAR(20)" defaultValue="NEW"><constraints nullable="false"/></column>
<column name="remarks" type="VARCHAR(1000)"/>
<column name="actioned_by" type="BIGINT"/>
<column name="actioned_by_name" type="VARCHAR(200)"/>
<column name="actioned_on" type="TIMESTAMP"/>
<column name="received_via" type="VARCHAR(20)"/>
<column name="fetch_run_id" type="BIGINT"/>
<column name="received_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="raw_json" type="TEXT"/>
</createTable>
<addUniqueConstraint tableName="m_cic_alert" columnNames="bureau_id, alert_key" constraintName="uq_cic_alert_key"/>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_client"><column name="client_id"/></createIndex>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_status"><column name="status"/></createIndex>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_enquiry_date"><column name="enquiry_date"/></createIndex>
</changeSet>
<!-- An ENQUIRY_ALERT endpoint per bureau, with a generic body the built-in simulator answers to. -->
<changeSet id="0605-4-cic-alert-endpoints" author="cbsnexora">
<sql>
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
SELECT b.id, 'ENQUIRY_ALERT', 'POST', '/monitoring/enquiry-alerts', 'application/json',
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{fetch.reference}}"}',
'{"requestHeader":{"product":"ENQUIRY_ALERT","version":"1.0"},"criteria":{}}',
TRUE
FROM m_cic_bureau b
WHERE NOT EXISTS (SELECT 1 FROM m_cic_bureau_endpoint e WHERE e.bureau_id = b.id AND e.endpoint_type = 'ENQUIRY_ALERT');
</sql>
<sql>
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
FROM m_cic_bureau_endpoint e
CROSS JOIN (
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
UNION ALL SELECT 'REQUEST', 'fetch.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
UNION ALL SELECT 'REQUEST', 'fetch.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', NULL, TRUE, 30
UNION ALL SELECT 'REQUEST', 'fetch.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', NULL, TRUE, 40
UNION ALL SELECT 'REQUEST', NULL, 'criteria.scope', 'NONE', 'OTHER_MEMBERS', FALSE, 50
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorCode', 'errorCode', 'NONE', NULL, FALSE, 10
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorMessage', 'errorMessage', 'NONE', NULL, FALSE, 20
UNION ALL SELECT 'RESPONSE', 'data.alerts', 'alerts', 'NONE', NULL, FALSE, 30
UNION ALL SELECT 'RESPONSE', 'alertId', 'alertRef', 'NONE', NULL, FALSE, 40
UNION ALL SELECT 'RESPONSE', 'consumer.pan', 'pan', 'UPPER', NULL, FALSE, 50
UNION ALL SELECT 'RESPONSE', 'consumer.aadhaar', 'aadhaar', 'NONE', NULL, FALSE, 60
UNION ALL SELECT 'RESPONSE', 'consumer.name', 'customerName', 'UPPER', NULL, FALSE, 70
UNION ALL SELECT 'RESPONSE', 'consumer.dateOfBirth', 'dateOfBirth', 'DATE_DDMMYYYY', NULL, FALSE, 80
UNION ALL SELECT 'RESPONSE', 'consumer.memberReference', 'customerRef', 'NONE', NULL, FALSE, 90
UNION ALL SELECT 'RESPONSE', 'enquiry.memberCode', 'institutionCode', 'NONE', NULL, FALSE, 100
UNION ALL SELECT 'RESPONSE', 'enquiry.memberName', 'institutionName', 'NONE', NULL, FALSE, 110
UNION ALL SELECT 'RESPONSE', 'enquiry.memberType', 'institutionType', 'NONE', NULL, FALSE, 120
UNION ALL SELECT 'RESPONSE', 'enquiry.date', 'enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 130
UNION ALL SELECT 'RESPONSE', 'enquiry.productType', 'loanType', 'NONE', NULL, FALSE, 140
UNION ALL SELECT 'RESPONSE', 'enquiry.amount', 'amount', 'NUMBER', NULL, FALSE, 150
UNION ALL SELECT 'RESPONSE', 'enquiry.purpose', 'purpose', 'NONE', NULL, FALSE, 160
UNION ALL SELECT 'RESPONSE', 'enquiry.location', 'location', 'NONE', NULL, FALSE, 170
) m
WHERE e.endpoint_type = 'ENQUIRY_ALERT'
AND NOT EXISTS (SELECT 1 FROM m_cic_field_mapping x WHERE x.endpoint_id = e.id);
</sql>
</changeSet>
<!-- Alert permissions; every role that has the Credit Bureau menu may read, work and fetch alerts. -->
<changeSet id="0605-5-cic-alert-permissions" author="cbsnexora">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICALERT', 'CICALERT', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICALERT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'UPDATE_CICALERT', 'CICALERT', 'UPDATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICALERT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'FETCH_CICALERT', 'CICALERT', 'FETCH', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'FETCH_CICALERT');
</sql>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT DISTINCT rp.role_id, p2.id
FROM m_role_permission rp
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_CREDIT_BUREAU'
JOIN m_permission p2 ON p2.code IN ('READ_CICALERT', 'UPDATE_CICALERT', 'FETCH_CICALERT')
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
</sql>
</changeSet>
</databaseChangeLog>