diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/api/CicApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/api/CicApiResource.java index 4ad9a2bd3..5df66165e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/api/CicApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/api/CicApiResource.java @@ -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(); - } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicAlertScheduler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicAlertScheduler.java new file mode 100644 index 000000000..bf7621005 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicAlertScheduler.java @@ -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> 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 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(); + } + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicAlertService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicAlertService.java new file mode 100644 index 000000000..6591f5e79 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicAlertService.java @@ -0,0 +1,1041 @@ +/** + * 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.JsonArray; +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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +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.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +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.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Enquiry alerts from the CICs: when another bank / NBFC pulls the credit report of one of this bank's customers - i.e. + * the customer has applied for a loan elsewhere - the CIC reports it. This service fetches those alerts through the + * bureau's ENQUIRY_ALERT endpoint and its UI-driven mapping (or imports a file the bureau delivered), matches each one to + * the bank's customer by PAN, then Aadhaar, then the customer reference the bank reported, de-duplicates, and keeps them + * for the Credit Bureau dashboard and the alert register. + * + * Deliberately not @Transactional: the fetch run is on record whatever the outcome, and each alert stands alone. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CicAlertService { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{\\s*([A-Za-z0-9_.]+)\\s*}}"); + private static final Pattern PAN = Pattern.compile("^[A-Z]{5}[0-9]{4}[A-Z]$"); + private static final DateTimeFormatter REF_DATE = DateTimeFormatter.ofPattern("yyMMdd"); + private static final int MAX_WINDOW_DAYS = 90; + private static final int MAX_RAW = 200_000; + + private final JdbcTemplate jdbcTemplate; + private final CicConfigService configService; + private final CicHttpGateway gateway; + private final CicSimulator simulator; + 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 headers, JsonObject body, + List missingMandatory) {} + + /** One alert as read from the bureau, before it is stored. */ + private static final class Parsed { + + private final Map fields = new LinkedHashMap<>(); + private JsonElement raw; + + String text(final String key) { + final JsonElement e = fields.get(key); + if (e == null || e.isJsonNull()) { + return null; + } + final String s = e.isJsonPrimitive() ? e.getAsString() : e.toString(); + return StringUtils.trimToNull(s); + } + } + + private record Match(Long clientId, String type) {} + + /** Counts of one fetch / import. */ + private static final class Counts { + + private int received; + private int created; + private int matched; + private int unmatched; + private int duplicates; + private int ownBank; + } + + // ── Request building ────────────────────────────────────────────────────── + + private static Map context(final Map bureau, final LocalDate from, final LocalDate to, + final String reference) { + final Map ctx = new LinkedHashMap<>(); + ctx.put("fetch.reference", reference); + ctx.put("fetch.fromDate", from.toString()); + ctx.put("fetch.toDate", to.toString()); + ctx.put("fetch.date", DateUtils.getBusinessLocalDate().toString()); + ctx.put("bureau.memberCode", bureau.get("member_code")); + ctx.put("bureau.username", bureau.get("username")); + return ctx; + } + + private BuiltRequest build(final Map bureau, final Map endpoint, final Map 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 List missing = new ArrayList<>(); + for (final Map 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 Map headers = new LinkedHashMap<>(); + final JsonElement hj = JsonParser.parseString(StringUtils.defaultIfBlank((String) endpoint.get("headers_json"), "{}")); + if (hj.isJsonObject()) { + for (final Map.Entry 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, missing); + } + + private static String resolvePlaceholders(final String template, final Map 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 alertEndpoint(final Map bureau, final boolean requireActive) { + final Map endpoint = configService.endpointRow(((Number) bureau.get("id")).longValue(), CicCatalog.ALERT_ENDPOINT); + if (endpoint == null || (requireActive && !Boolean.TRUE.equals(endpoint.get("is_active")))) { + throw CicConfigService.fail("error.msg.cic.endpoint.inactive", + bureau.get("code") + " has no active enquiry-alert endpoint. Configure it under API Mapping."); + } + return endpoint; + } + + /** The fetch window: given dates, or the bureau's look-back up to the business date. */ + private static LocalDate[] window(final Map bureau, final String fromDate, final String toDate) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate to = StringUtils.isBlank(toDate) ? today : LocalDate.parse(toDate.trim()); + final int lookback = bureau.get("alert_lookback_days") == null ? 7 : ((Number) bureau.get("alert_lookback_days")).intValue(); + final LocalDate from = StringUtils.isBlank(fromDate) ? to.minusDays(lookback) : LocalDate.parse(fromDate.trim()); + if (from.isAfter(to)) { + throw CicConfigService.fail("error.msg.cic.window.invalid", "The from date must not be after the to date."); + } + if (from.plusDays(MAX_WINDOW_DAYS).isBefore(to)) { + throw CicConfigService.fail("error.msg.cic.window.too.long", "Fetch at most " + MAX_WINDOW_DAYS + " days of alerts at a time."); + } + return new LocalDate[] { from, to }; + } + + /** Dry run: the request exactly as it would go to the bureau for the window, with what is missing. */ + public Map preview(final Long bureauId, final JsonObject in) { + final Map bureau = configService.bureauRow(bureauId); + final Map endpoint = alertEndpoint(bureau, false); + final LocalDate[] w = window(bureau, CicConfigService.text(in, "fromDate", null), CicConfigService.text(in, "toDate", null)); + final BuiltRequest req = build(bureau, endpoint, context(bureau, w[0], w[1], "PREVIEW-" + System.currentTimeMillis())); + final Map 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.body())); + res.put("fromDate", w[0].toString()); + res.put("toDate", w[1].toString()); + res.put("missingMandatory", req.missingMandatory()); + return res; + } + + // ── Fetch / import ──────────────────────────────────────────────────────── + + /** + * Fetches the alerts of a window from the bureau (or the simulator) and stores the new ones. + * + * @param trigger + * MANUAL or SCHEDULED + * @param user + * null for a scheduled run + */ + public Map fetch(final Long bureauId, final String fromDate, final String toDate, final String trigger, + final AppUser user) { + final Map bureau = configService.bureauRow(bureauId); + requireWhitelisted(bureau); + configService.assertReadyForLive(bureau); + final Map endpoint = alertEndpoint(bureau, true); + final LocalDate[] w = window(bureau, fromDate, toDate); + final Long runId = startRun(bureau, trigger, w[0], w[1], user); + final String runRef = runRef(runId); + final long started = System.currentTimeMillis(); + try { + final BuiltRequest req = build(bureau, endpoint, context(bureau, w[0], w[1], runRef)); + final String requestLog = gson.toJson(Map.of("method", req.method(), "url", req.url(), "headers", req.headers(), "body", req.body())); + if (!req.missingMandatory().isEmpty()) { + return finishRun(runId, bureau, "FAILED", null, new Counts(), "Mandatory request fields have no value: " + + String.join(", ", req.missingMandatory()), requestLog, null, started); + } + final JsonElement response; + final Integer httpStatus; + final String rawText; + if ("SIMULATOR".equals(bureau.get("environment"))) { + response = simulator.respond(bureau, w[0], w[1], runRef); + httpStatus = 200; + rawText = 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())); + httpStatus = r.status(); + rawText = r.body(); + if (!r.ok()) { + final String msg = r.status() == 0 ? "Bureau not reachable: " + r.error() + : "Bureau returned HTTP " + r.status() + ": " + StringUtils.abbreviate(StringUtils.defaultString(r.body()), 500); + return finishRun(runId, bureau, "FAILED", httpStatus, new Counts(), msg, requestLog, rawText, started); + } + try { + response = JsonParser.parseString(StringUtils.defaultIfBlank(r.body(), "{}")); + } catch (final RuntimeException e) { + return finishRun(runId, bureau, "FAILED", httpStatus, new Counts(), "Bureau response is not valid JSON: " + e.getMessage(), + requestLog, rawText, started); + } + } + return process(runId, bureau, endpoint, response, "SIMULATOR".equals(bureau.get("environment")) ? "SIMULATOR" : "API", + httpStatus, requestLog, rawText, started); + } catch (final RuntimeException e) { + log.warn("CIC alert fetch {} at {} failed", runRef, bureau.get("code"), e); + return finishRun(runId, bureau, "FAILED", null, new Counts(), e.getMessage(), null, null, started); + } + } + + /** Imports an alert file the bureau delivered (JSON in the same shape as its API response). */ + public Map importFile(final Long bureauId, final JsonObject in, final AppUser user) { + final Map bureau = configService.bureauRow(bureauId); + requireWhitelisted(bureau); + final Map endpoint = alertEndpoint(bureau, false); + final String content = CicConfigService.text(in, "content", null); + if (StringUtils.isBlank(content)) { + throw CicConfigService.fail("error.msg.cic.import.empty", "The alert file is empty."); + } + final JsonElement response; + try { + response = JsonParser.parseString(content); + } catch (final RuntimeException e) { + throw CicConfigService.fail("error.msg.cic.import.invalid", "The alert file is not valid JSON: " + e.getMessage()); + } + final LocalDate today = DateUtils.getBusinessLocalDate(); + final Long runId = startRun(bureau, "FILE", null, today, user); + final String fileName = StringUtils.abbreviate(StringUtils.defaultIfBlank(CicConfigService.text(in, "fileName", null), "upload"), 200); + return process(runId, bureau, endpoint, response, "FILE", null, gson.toJson(Map.of("file", fileName)), content, + System.currentTimeMillis()); + } + + /** Reads a sample response through the mapping and shows what would be stored - nothing is saved. */ + public Map parseTest(final Long bureauId, final JsonObject in) { + final Map bureau = configService.bureauRow(bureauId); + final Map endpoint = alertEndpoint(bureau, false); + final String content = CicConfigService.text(in, "content", null); + JsonElement response; + if (StringUtils.isBlank(content)) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + response = simulator.respond(bureau, today.minusDays(6), today, "SAMPLE"); + } else { + try { + response = JsonParser.parseString(content); + } catch (final RuntimeException e) { + throw CicConfigService.fail("error.msg.cic.sample.invalid", "The sample response is not valid JSON: " + e.getMessage()); + } + } + final Long endpointId = ((Number) endpoint.get("id")).longValue(); + final Map root = new LinkedHashMap<>(); + final List parsed = parse(endpointId, response, root); + final List> items = new ArrayList<>(); + for (final Parsed p : parsed) { + final Map v = new LinkedHashMap<>(); + p.fields.forEach((k, e) -> v.put(k, e == null || e.isJsonNull() ? null : e.isJsonPrimitive() ? e.getAsString() : e.toString())); + if (v.get("pan") != null) { + v.put("pan", CicCatalog.mask(v.get("pan").toString())); + } + final boolean own = isOwnBank(bureau, p); + final Match match = own ? new Match(null, "NONE") : match(p); + v.put("ownBank", own); + v.put("matchType", match.type()); + v.put("clientId", match.clientId()); + v.put("clientName", match.clientId() == null ? null : clientName(match.clientId())); + items.add(v); + } + final Map res = new LinkedHashMap<>(); + res.put("usedSample", StringUtils.isBlank(content)); + res.put("errorCode", text(root.get("errorCode"))); + res.put("errorMessage", text(root.get("errorMessage"))); + res.put("alertListFound", root.get("alerts") != null && !root.get("alerts").isJsonNull()); + res.put("count", items.size()); + res.put("items", items); + if (StringUtils.isBlank(content)) { + res.put("sampleResponse", gson.toJson(response)); + } + return res; + } + + private static void requireWhitelisted(final Map bureau) { + 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. Whitelist it under CIC Configuration first."); + } + } + + private Map process(final Long runId, final Map bureau, final Map endpoint, + final JsonElement response, final String via, final Integer httpStatus, final String requestLog, final String rawText, + final long started) { + final Counts counts = new Counts(); + final Map root = new LinkedHashMap<>(); + final List parsed = parse(((Number) endpoint.get("id")).longValue(), response, root); + final String errorCode = text(root.get("errorCode")); + if (errorCode != null) { + final String errorMessage = text(root.get("errorMessage")); + return finishRun(runId, bureau, "FAILED", httpStatus, counts, "Bureau error " + errorCode + (errorMessage == null ? "" : ": " + errorMessage), + requestLog, rawText, started); + } + if (root.get("alerts") == null || root.get("alerts").isJsonNull()) { + // No list at the mapped path: an empty answer, or a mapping that does not fit the bureau's response. + log.info("CIC {}: no alert list at the mapped path in run {}", bureau.get("code"), runId); + } + final Timestamp now = Timestamp.from(Instant.now()); + for (final Parsed p : parsed) { + counts.received++; + if (isOwnBank(bureau, p)) { + counts.ownBank++; + continue; + } + final String key = alertKey(p); + final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND alert_key = ?", + Integer.class, bureau.get("id"), key); + if (exists != null && exists > 0) { + counts.duplicates++; + continue; + } + final Match match = match(p); + Map client = null; + if (match.clientId() != null) { + final List> rows = jdbcTemplate + .queryForList("SELECT id, display_name, account_no, office_id FROM m_client WHERE id = ?", match.clientId()); + client = rows.isEmpty() ? null : rows.get(0); + } + try { + jdbcTemplate.update(""" + INSERT INTO m_cic_alert (bureau_id, bureau_code, alert_key, client_id, office_id, client_name, client_account_no, match_type, + customer_name, pan_masked, aadhaar_masked, customer_ref, date_of_birth, institution_code, + institution_name, institution_type, enquiry_date, loan_type, amount, purpose, location, status, + received_via, fetch_run_id, received_on, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'NEW', ?, ?, ?, ?) + """, bureau.get("id"), bureau.get("code"), key, client == null ? null : client.get("id"), + client == null ? null : client.get("office_id"), client == null ? null : client.get("display_name"), + client == null ? null : client.get("account_no"), client == null ? "NONE" : match.type(), + cut(p.text("customerName"), 200), p.text("pan") == null ? null : CicCatalog.mask(p.text("pan").toUpperCase(Locale.ROOT)), + CicCatalog.maskAadhaar(p.text("aadhaar")), cut(p.text("customerRef"), 100), sqlDate(p.text("dateOfBirth")), + cut(p.text("institutionCode"), 100), cut(p.text("institutionName"), 200), cut(p.text("institutionType"), 60), + sqlDate(p.text("enquiryDate")), cut(p.text("loanType"), 100), amount(p.text("amount")), cut(p.text("purpose"), 200), + cut(p.text("location"), 200), via, runId, now, p.raw == null ? null : gson.toJson(p.raw)); + } catch (final DuplicateKeyException e) { + counts.duplicates++; + continue; + } + counts.created++; + if (client == null) { + counts.unmatched++; + } else { + counts.matched++; + } + } + final String message = counts.received + " received, " + counts.created + " new (" + counts.matched + " matched to customers, " + + counts.unmatched + " unmatched), " + counts.duplicates + " already on record, " + counts.ownBank + " by this bank itself."; + return finishRun(runId, bureau, "SUCCESS", httpStatus, counts, message, requestLog, rawText, started); + } + + /** Reads the response through the RESPONSE mapping: root fields into {@code root}, one {@link Parsed} per alert. */ + private List parse(final Long endpointId, final JsonElement response, final Map root) { + final List> rows = configService.mappingRows(endpointId, "RESPONSE"); + for (final Map m : rows) { + final String target = (String) m.get("target_field"); + if (CicCatalog.ROOT_FIELDS.contains(target)) { + JsonElement value = read(response, (String) m.get("source_field")); + if ("alerts".equals(target)) { + root.put(target, value); + } else { + if (value == null && m.get("default_value") != null) { + value = new JsonPrimitive((String) m.get("default_value")); + } + root.put(target, value == null ? null : CicCatalog.applyResponseTransform((String) m.get("transform"), value)); + } + } + } + final List items = new ArrayList<>(); + final JsonElement list = root.get("alerts"); + if (list != null && list.isJsonArray()) { + list.getAsJsonArray().forEach(items::add); + } else if (list != null && list.isJsonObject()) { + items.add(list); + } else if (list == null && response != null && response.isJsonArray()) { + // A bureau that answers with a bare array. + response.getAsJsonArray().forEach(items::add); + } + final List out = new ArrayList<>(); + for (final JsonElement item : items) { + final Parsed p = new Parsed(); + p.raw = item; + for (final Map m : rows) { + final String target = (String) m.get("target_field"); + if (CicCatalog.ROOT_FIELDS.contains(target)) { + continue; + } + JsonElement value = read(item, (String) m.get("source_field")); + if ((value == null || value.isJsonNull()) && m.get("default_value") != null) { + value = new JsonPrimitive((String) m.get("default_value")); + } + if (value != null && !value.isJsonNull()) { + p.fields.put(target, CicCatalog.applyResponseTransform((String) m.get("transform"), value)); + } + } + out.add(p); + } + return out; + } + + private static JsonElement read(final JsonElement source, final String path) { + if (source == null || StringUtils.isBlank(path)) { + return null; + } + try { + final JsonElement v = CicJsonPath.get(source, path); + return v == null || v.isJsonNull() ? null : v; + } catch (final RuntimeException e) { + return null; + } + } + + private static boolean isOwnBank(final Map bureau, final Parsed p) { + final String own = bureau.get("member_code") == null ? null : bureau.get("member_code").toString().trim(); + final String code = p.text("institutionCode"); + return own != null && !own.isEmpty() && code != null && own.equalsIgnoreCase(code); + } + + /** The bureau's alert id; failing that, a hash of what makes the alert unique. */ + private static String alertKey(final Parsed p) { + final String ref = p.text("alertRef"); + if (ref != null) { + return StringUtils.abbreviate(ref, 100); + } + final String basis = String.join("|", StringUtils.defaultString(p.text("pan")), StringUtils.defaultString(p.text("aadhaar")), + StringUtils.defaultString(p.text("customerRef")), StringUtils.defaultString(p.text("institutionCode")), + StringUtils.defaultString(p.text("institutionName")), StringUtils.defaultString(p.text("enquiryDate")), + StringUtils.defaultString(p.text("loanType")), StringUtils.defaultString(p.text("amount"))); + try { + return "H" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(basis.getBytes(StandardCharsets.UTF_8))) + .substring(0, 63); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + // ── Matching ───────────────────────────────────────────────────────────── + + /** PAN first; then Aadhaar last four confirmed by date of birth or name; then the customer reference we reported. */ + private Match match(final Parsed p) { + final String pan = p.text("pan") == null ? null : p.text("pan").toUpperCase(Locale.ROOT).replaceAll("\\s", ""); + if (pan != null && PAN.matcher(pan).matches()) { + final List ids = jdbcTemplate.queryForList(""" + SELECT DISTINCT k.client_id FROM m_client_kyc k WHERE UPPER(TRIM(k.pan_number)) = ? ORDER BY k.client_id + """, Long.class, pan); + if (!ids.isEmpty()) { + return new Match(ids.get(0), "PAN"); + } + } + final String aadhaar = p.text("aadhaar") == null ? null : p.text("aadhaar").replaceAll("\\D", ""); + if (aadhaar != null && aadhaar.length() >= 4) { + final String last4 = aadhaar.substring(aadhaar.length() - 4); + final String dob = p.text("dateOfBirth"); + final String name = normalise(p.text("customerName")); + final Set hits = new LinkedHashSet<>(); + for (final Map r : jdbcTemplate.queryForList(""" + SELECT k.client_id, k.aadhaar_number_masked, c.date_of_birth, c.display_name + FROM m_client_kyc k JOIN m_client c ON c.id = k.client_id + WHERE k.aadhaar_number_masked LIKE ? + """, "%" + last4)) { + final String stored = String.valueOf(r.get("aadhaar_number_masked")).replaceAll("\\D", ""); + if (!stored.endsWith(last4)) { + continue; + } + final boolean dobOk = dob != null && r.get("date_of_birth") != null && r.get("date_of_birth").toString().startsWith(dob); + final boolean nameOk = name != null && name.equals(normalise((String) r.get("display_name"))); + if (dobOk || nameOk) { + hits.add(((Number) r.get("client_id")).longValue()); + } + } + if (hits.size() == 1) { + return new Match(hits.iterator().next(), "AADHAAR"); + } + } + final String ref = p.text("customerRef"); + if (ref != null) { + final List ids = jdbcTemplate.queryForList("SELECT id FROM m_client WHERE account_no = ? OR external_id = ? ORDER BY id", + Long.class, ref, ref); + if (!ids.isEmpty()) { + return new Match(ids.get(0), "CUSTOMER_REF"); + } + } + return new Match(null, "NONE"); + } + + private static String normalise(final String name) { + return name == null ? null : StringUtils.trimToNull(name.toUpperCase(Locale.ROOT).replaceAll("[^A-Z]", "")); + } + + private String clientName(final Long clientId) { + final List names = jdbcTemplate.queryForList("SELECT display_name FROM m_client WHERE id = ?", String.class, clientId); + return names.isEmpty() ? null : names.get(0); + } + + // ── Fetch runs ─────────────────────────────────────────────────────────── + + private Long startRun(final Map bureau, final String trigger, final LocalDate from, final LocalDate to, + final AppUser user) { + final String tmpRef = "RUN-" + System.nanoTime() + "-" + ThreadLocalRandom.current().nextInt(1000); + jdbcTemplate.update(""" + INSERT INTO m_cic_alert_fetch_run (run_ref, bureau_id, bureau_code, trigger_type, environment, from_date, to_date, status, + triggered_by, triggered_by_name, started_on) + VALUES (?, ?, ?, ?, ?, ?, ?, 'RUNNING', ?, ?, ?) + """, tmpRef, bureau.get("id"), bureau.get("code"), trigger, bureau.get("environment"), from == null ? null : Date.valueOf(from), + to == null ? null : Date.valueOf(to), user == null ? null : user.getId(), + user == null ? "SYSTEM (scheduled)" : user.getDisplayName(), Timestamp.from(Instant.now())); + final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_cic_alert_fetch_run WHERE run_ref = ?", Long.class, tmpRef); + jdbcTemplate.update("UPDATE m_cic_alert_fetch_run SET run_ref = ? WHERE id = ?", runRef(id), id); + return id; + } + + private static String runRef(final Long id) { + return "CICF" + DateUtils.getBusinessLocalDate().format(REF_DATE) + String.format("%06d", id); + } + + private Map finishRun(final Long runId, final Map bureau, final String status, final Integer httpStatus, + final Counts c, final String message, final String requestLog, final String rawText, final long started) { + final int duration = (int) (System.currentTimeMillis() - started); + jdbcTemplate.update(""" + UPDATE m_cic_alert_fetch_run SET status = ?, http_status = ?, received = ?, new_alerts = ?, matched = ?, unmatched = ?, + duplicates = ?, own_bank = ?, error_message = ?, request_masked = ?, response_raw = ?, duration_ms = ? + WHERE id = ? + """, status, httpStatus, c.received, c.created, c.matched, c.unmatched, c.duplicates, c.ownBank, + StringUtils.abbreviate(message, 2000), requestLog, rawText == null ? null : StringUtils.abbreviate(rawText, MAX_RAW), duration, + runId); + jdbcTemplate.update("UPDATE m_cic_bureau SET last_fetch_on = ?, last_fetch_status = ?, last_fetch_message = ? WHERE id = ?", + Timestamp.from(Instant.now()), status, StringUtils.abbreviate(message, 1000), bureau.get("id")); + log.info("CIC alert fetch {} at {}: {} - {}", runRef(runId), bureau.get("code"), status, message); + return fetchRun(runId, false); + } + + public Map fetchRun(final Long id, final boolean withRaw) { + final List> rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_alert_fetch_run WHERE id = ?", id); + if (rows.isEmpty()) { + throw CicConfigService.fail("error.msg.cic.fetch.run.not.found", "Fetch run " + id + " was not found."); + } + final Map r = rows.get(0); + final Map v = new LinkedHashMap<>(); + v.put("id", r.get("id")); + v.put("runRef", r.get("run_ref")); + v.put("bureauId", r.get("bureau_id")); + v.put("bureauCode", r.get("bureau_code")); + v.put("trigger", r.get("trigger_type")); + v.put("environment", r.get("environment")); + v.put("fromDate", str(r.get("from_date"))); + v.put("toDate", str(r.get("to_date"))); + v.put("status", r.get("status")); + v.put("httpStatus", r.get("http_status")); + v.put("received", r.get("received")); + v.put("newAlerts", r.get("new_alerts")); + v.put("matched", r.get("matched")); + v.put("unmatched", r.get("unmatched")); + v.put("duplicates", r.get("duplicates")); + v.put("ownBank", r.get("own_bank")); + v.put("message", r.get("error_message")); + v.put("durationMs", r.get("duration_ms")); + v.put("triggeredByName", r.get("triggered_by_name")); + v.put("startedOn", str(r.get("started_on"))); + if (withRaw) { + v.put("request", r.get("request_masked")); + v.put("response", r.get("response_raw")); + } + return v; + } + + public Map fetchRuns(final Long bureauId, final int offset, final int limit) { + final String where = bureauId == null ? "" : " WHERE bureau_id = " + bureauId.longValue(); + final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert_fetch_run" + where, Integer.class); + final List> items = new ArrayList<>(); + for (final Long id : jdbcTemplate.queryForList("SELECT id FROM m_cic_alert_fetch_run" + where + " ORDER BY id DESC LIMIT ? OFFSET ?", + Long.class, limit, offset)) { + items.add(fetchRun(id, false)); + } + final Map res = new LinkedHashMap<>(); + res.put("totalFilteredRecords", total); + res.put("pageItems", items); + return res; + } + + // ── Alert register ─────────────────────────────────────────────────────── + + private static Map view(final Map r) { + final Map v = new LinkedHashMap<>(); + v.put("id", r.get("id")); + v.put("bureauId", r.get("bureau_id")); + v.put("bureauCode", r.get("bureau_code")); + v.put("alertKey", r.get("alert_key")); + v.put("clientId", r.get("client_id")); + v.put("clientName", r.get("client_name")); + v.put("clientAccountNo", r.get("client_account_no")); + v.put("officeId", r.get("office_id")); + v.put("officeName", r.get("office_name")); + v.put("matchType", r.get("match_type")); + v.put("customerName", r.get("customer_name")); + v.put("panMasked", r.get("pan_masked")); + v.put("aadhaarMasked", r.get("aadhaar_masked")); + v.put("customerRef", r.get("customer_ref")); + v.put("institutionCode", r.get("institution_code")); + v.put("institutionName", r.get("institution_name")); + v.put("institutionType", r.get("institution_type")); + v.put("enquiryDate", str(r.get("enquiry_date"))); + v.put("loanType", r.get("loan_type")); + v.put("amount", r.get("amount")); + v.put("purpose", r.get("purpose")); + v.put("location", r.get("location")); + v.put("status", r.get("status")); + v.put("remarks", r.get("remarks")); + v.put("actionedByName", r.get("actioned_by_name")); + v.put("actionedOn", str(r.get("actioned_on"))); + v.put("receivedVia", r.get("received_via")); + v.put("receivedOn", str(r.get("received_on"))); + return v; + } + + public Map list(final JsonObject f, final int offset, final int limit) { + final StringBuilder where = new StringBuilder(" WHERE 1 = 1"); + final List args = new ArrayList<>(); + final String status = CicConfigService.text(f, "status", null); + if (StringUtils.isNotBlank(status)) { + where.append(" AND a.status = ?"); + args.add(status.trim().toUpperCase(Locale.ROOT)); + } + final String match = CicConfigService.text(f, "match", null); + if ("MATCHED".equalsIgnoreCase(match)) { + where.append(" AND a.client_id IS NOT NULL"); + } else if ("UNMATCHED".equalsIgnoreCase(match)) { + where.append(" AND a.client_id IS NULL"); + } + addLong(where, args, f, "clientId", "a.client_id"); + addLong(where, args, f, "bureauId", "a.bureau_id"); + addLong(where, args, f, "officeId", "a.office_id"); + final String loanType = CicConfigService.text(f, "loanType", null); + if (StringUtils.isNotBlank(loanType)) { + where.append(" AND a.loan_type = ?"); + args.add(loanType.trim()); + } + final String institutionType = CicConfigService.text(f, "institutionType", null); + if (StringUtils.isNotBlank(institutionType)) { + where.append(" AND a.institution_type = ?"); + args.add(institutionType.trim()); + } + final String fromDate = CicConfigService.text(f, "fromDate", null); + if (StringUtils.isNotBlank(fromDate)) { + where.append(" AND a.enquiry_date >= ?"); + args.add(Date.valueOf(LocalDate.parse(fromDate.trim()))); + } + final String toDate = CicConfigService.text(f, "toDate", null); + if (StringUtils.isNotBlank(toDate)) { + where.append(" AND a.enquiry_date <= ?"); + args.add(Date.valueOf(LocalDate.parse(toDate.trim()))); + } + final String minAmount = CicConfigService.text(f, "minAmount", null); + if (StringUtils.isNotBlank(minAmount)) { + where.append(" AND a.amount >= ?"); + args.add(new BigDecimal(minAmount.trim())); + } + final String search = CicConfigService.text(f, "search", null); + if (StringUtils.isNotBlank(search)) { + where.append(" AND (LOWER(a.client_name) LIKE ? OR LOWER(a.customer_name) LIKE ? OR LOWER(a.client_account_no) LIKE ?" + + " OR LOWER(a.institution_type) LIKE ? OR LOWER(a.loan_type) LIKE ?)"); + final String like = "%" + search.trim().toLowerCase(Locale.ROOT) + "%"; + for (int i = 0; i < 5; i++) { + args.add(like); + } + } + final Map totals = jdbcTemplate.queryForMap( + "SELECT COUNT(*) AS n, COALESCE(SUM(a.amount), 0) AS amt, COUNT(DISTINCT a.client_id) AS customers FROM m_cic_alert a" + where, + args.toArray()); + final List pageArgs = new ArrayList<>(args); + pageArgs.add(limit); + pageArgs.add(offset); + final List> items = new ArrayList<>(); + for (final Map r : jdbcTemplate.queryForList(""" + SELECT a.*, o.name AS office_name FROM m_cic_alert a LEFT JOIN m_office o ON o.id = a.office_id + """ + where + " ORDER BY a.enquiry_date DESC NULLS LAST, a.id DESC LIMIT ? OFFSET ?", pageArgs.toArray())) { + items.add(view(r)); + } + final Map res = new LinkedHashMap<>(); + res.put("totalFilteredRecords", ((Number) totals.get("n")).intValue()); + res.put("totalAmount", totals.get("amt")); + res.put("customers", totals.get("customers")); + res.put("pageItems", items); + return res; + } + + private static void addLong(final StringBuilder where, final List args, final JsonObject f, final String key, final String column) { + final String v = CicConfigService.text(f, key, null); + if (StringUtils.isNotBlank(v)) { + where.append(" AND ").append(column).append(" = ?"); + args.add(Long.valueOf(v.trim())); + } + } + + public Map get(final Long id) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT a.*, o.name AS office_name FROM m_cic_alert a LEFT JOIN m_office o ON o.id = a.office_id WHERE a.id = ? + """, id); + if (rows.isEmpty()) { + throw CicConfigService.fail("error.msg.cic.alert.not.found", "Alert " + id + " was not found."); + } + final Map r = rows.get(0); + final Map v = view(r); + v.put("raw", r.get("raw_json") == null ? null : JsonParser.parseString((String) r.get("raw_json"))); + v.put("fetchRunId", r.get("fetch_run_id")); + if (r.get("client_id") != null) { + final Long clientId = ((Number) r.get("client_id")).longValue(); + final List> others = new ArrayList<>(); + for (final Map o : jdbcTemplate.queryForList(""" + SELECT a.*, NULL AS office_name FROM m_cic_alert a WHERE a.client_id = ? AND a.id <> ? + ORDER BY a.enquiry_date DESC NULLS LAST, a.id DESC LIMIT 20 + """, clientId, id)) { + others.add(view(o)); + } + v.put("otherAlertsOfCustomer", others); + v.put("clientLoans", clientLoans(clientId)); + } + return v; + } + + /** The customer's loans with this bank, for judging the alert (retention offer, exposure, over-leverage). */ + private List> clientLoans(final Long clientId) { + try { + final List> out = new ArrayList<>(); + for (final Map r : jdbcTemplate.queryForList(""" + SELECT l.id, l.account_no, p.name AS product_name, l.loan_status_id, l.principal_amount, + l.total_outstanding_derived, l.disbursedon_date + FROM m_loan l LEFT JOIN m_product_loan p ON p.id = l.product_id + WHERE l.client_id = ? AND l.loan_status_id IN (100, 200, 300) + ORDER BY l.id DESC LIMIT 20 + """, clientId)) { + final Map v = new LinkedHashMap<>(); + v.put("id", r.get("id")); + v.put("accountNo", r.get("account_no")); + v.put("productName", r.get("product_name")); + final int st = ((Number) r.get("loan_status_id")).intValue(); + v.put("status", st == 300 ? "ACTIVE" : st == 200 ? "APPROVED" : "SUBMITTED"); + v.put("principal", r.get("principal_amount")); + v.put("outstanding", r.get("total_outstanding_derived")); + v.put("disbursedOn", str(r.get("disbursedon_date"))); + out.add(v); + } + return out; + } catch (final RuntimeException e) { + log.debug("CIC: loans of client {} unreadable: {}", clientId, e.getMessage()); + return List.of(); + } + } + + public Map updateStatus(final Long id, final JsonObject in, final AppUser user) { + get(id); + final String status = StringUtils.upperCase(StringUtils.trimToNull(CicConfigService.text(in, "status", null))); + if (status == null || !CicCatalog.ALERT_STATUSES.contains(status)) { + throw CicConfigService.fail("error.msg.cic.alert.status.invalid", "Status must be one of " + CicCatalog.ALERT_STATUSES + "."); + } + final String remarks = StringUtils.trimToNull(CicConfigService.text(in, "remarks", null)); + if (("ACTIONED".equals(status) || "DISMISSED".equals(status)) && remarks == null) { + throw CicConfigService.fail("error.msg.cic.alert.remarks.required", + "Write what was done (e.g. customer contacted, retention offer made) or why it is dismissed."); + } + jdbcTemplate.update(""" + UPDATE m_cic_alert SET status = ?, remarks = COALESCE(?, remarks), actioned_by = ?, actioned_by_name = ?, actioned_on = ? + WHERE id = ? + """, status, StringUtils.abbreviate(remarks, 1000), user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), id); + return get(id); + } + + public Map markSeen(final JsonObject in, final AppUser user) { + final JsonArray ids = in.has("ids") && in.get("ids").isJsonArray() ? in.getAsJsonArray("ids") : new JsonArray(); + int updated = 0; + for (final JsonElement e : ids) { + updated += jdbcTemplate.update(""" + UPDATE m_cic_alert SET status = 'SEEN', actioned_by = ?, actioned_by_name = ?, actioned_on = ? WHERE id = ? AND status = 'NEW' + """, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), e.getAsLong()); + } + return Map.of("updated", updated); + } + + /** Links an unmatched alert to the customer it belongs to (e.g. the PAN was missing from our KYC). */ + public Map linkClient(final Long id, final JsonObject in, final AppUser user) { + get(id); + final String clientIdText = CicConfigService.text(in, "clientId", null); + if (StringUtils.isBlank(clientIdText)) { + throw CicConfigService.fail("error.msg.cic.client.required", "Choose the customer this alert belongs to."); + } + final List> rows = jdbcTemplate.queryForList("SELECT id, display_name, account_no, office_id FROM m_client WHERE id = ?", + Long.valueOf(clientIdText.trim())); + if (rows.isEmpty()) { + throw CicConfigService.fail("error.msg.cic.client.not.found", "Customer " + clientIdText + " was not found."); + } + final Map c = rows.get(0); + jdbcTemplate.update(""" + UPDATE m_cic_alert SET client_id = ?, client_name = ?, client_account_no = ?, office_id = ?, match_type = 'MANUAL', + actioned_by = ?, actioned_by_name = ?, actioned_on = ? + WHERE id = ? + """, c.get("id"), c.get("display_name"), c.get("account_no"), c.get("office_id"), user.getId(), user.getDisplayName(), + Timestamp.from(Instant.now()), id); + return get(id); + } + + // ── Dashboard ───────────────────────────────────────────────────────────── + + public Map dashboard(final int days) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final Date since = Date.valueOf(today.minusDays(days - 1L)); + final Map res = new LinkedHashMap<>(); + res.put("days", days); + res.put("businessDate", today.toString()); + res.put("newAlerts", count("SELECT COUNT(*) FROM m_cic_alert WHERE status = 'NEW' AND client_id IS NOT NULL")); + res.put("unmatchedOpen", count("SELECT COUNT(*) FROM m_cic_alert WHERE client_id IS NULL AND status IN ('NEW', 'SEEN')")); + res.put("receivedToday", + count("SELECT COUNT(*) FROM m_cic_alert WHERE received_on >= ?", Timestamp.valueOf(today.atStartOfDay()))); + final Map totals = jdbcTemplate.queryForMap(""" + SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0) AS amt, COUNT(DISTINCT client_id) AS customers, + COUNT(DISTINCT institution_type) AS lender_types + FROM m_cic_alert WHERE enquiry_date >= ? AND client_id IS NOT NULL + """, since); + res.put("alertsInPeriod", totals.get("n")); + res.put("amountInPeriod", totals.get("amt")); + res.put("customersInPeriod", totals.get("customers")); + res.put("lenderTypesInPeriod", totals.get("lender_types")); + res.put("byLoanType", group("loan_type", since, 12)); + res.put("byInstitutionType", group("institution_type", since, 10)); + + final List> trend = new ArrayList<>(); + final Map perDay = new LinkedHashMap<>(); + for (final Map r : jdbcTemplate.queryForList(""" + SELECT enquiry_date AS d, COUNT(*) AS n, COALESCE(SUM(amount), 0) AS amt FROM m_cic_alert + WHERE enquiry_date >= ? AND client_id IS NOT NULL GROUP BY enquiry_date + """, Date.valueOf(today.minusDays(13)))) { + perDay.put(str(r.get("d")), new Object[] { r.get("n"), r.get("amt") }); + } + for (int i = 13; i >= 0; i--) { + final String d = today.minusDays(i).toString(); + final Object[] v = perDay.get(d); + final Map point = new LinkedHashMap<>(); + point.put("date", d); + point.put("count", v == null ? 0 : v[0]); + point.put("amount", v == null ? BigDecimal.ZERO : v[1]); + trend.add(point); + } + res.put("trend14Days", trend); + + // Customers shopping for credit at several lenders: the strongest signal for retention or over-leverage review. + final List> repeat = new ArrayList<>(); + for (final Map r : jdbcTemplate.queryForList(""" + SELECT a.client_id, MAX(a.client_name) AS client_name, MAX(a.client_account_no) AS account_no, COUNT(*) AS n, + STRING_AGG(DISTINCT COALESCE(a.institution_type, 'Lender'), ', ') AS lender_types, COALESCE(SUM(a.amount), 0) AS amt, MAX(a.enquiry_date) AS last_date + FROM m_cic_alert a + WHERE a.enquiry_date >= ? AND a.client_id IS NOT NULL + GROUP BY a.client_id + HAVING COUNT(*) >= 2 + ORDER BY COUNT(*) DESC, SUM(a.amount) DESC + LIMIT 10 + """, since)) { + final Map v = new LinkedHashMap<>(); + v.put("clientId", r.get("client_id")); + v.put("clientName", r.get("client_name")); + v.put("accountNo", r.get("account_no")); + v.put("alerts", r.get("n")); + v.put("lenderTypes", r.get("lender_types")); + v.put("amount", r.get("amt")); + v.put("lastEnquiryDate", str(r.get("last_date"))); + repeat.add(v); + } + res.put("repeatCustomers", repeat); + + final List> recent = new ArrayList<>(); + for (final Map r : jdbcTemplate.queryForList(""" + SELECT a.*, o.name AS office_name FROM m_cic_alert a LEFT JOIN m_office o ON o.id = a.office_id + WHERE a.client_id IS NOT NULL ORDER BY (CASE WHEN a.status = 'NEW' THEN 0 ELSE 1 END), a.enquiry_date DESC NULLS LAST, a.id DESC + LIMIT 15 + """)) { + recent.add(view(r)); + } + res.put("recent", recent); + + final List> bureaus = new ArrayList<>(); + for (final Map b : jdbcTemplate.queryForList(""" + SELECT id, code, name, whitelisted, environment, is_default, alert_auto_fetch, alert_fetch_interval_min, last_fetch_on, + last_fetch_status, last_fetch_message + FROM m_cic_bureau ORDER BY is_default DESC, id + """)) { + final Map v = new LinkedHashMap<>(); + v.put("id", b.get("id")); + v.put("code", b.get("code")); + v.put("name", b.get("name")); + v.put("whitelisted", b.get("whitelisted")); + v.put("environment", b.get("environment")); + v.put("isDefault", b.get("is_default")); + v.put("autoFetch", b.get("alert_auto_fetch")); + v.put("intervalMin", b.get("alert_fetch_interval_min")); + v.put("lastFetchOn", str(b.get("last_fetch_on"))); + v.put("lastFetchStatus", b.get("last_fetch_status")); + v.put("lastFetchMessage", b.get("last_fetch_message")); + bureaus.add(v); + } + res.put("bureaus", bureaus); + return res; + } + + private List> group(final String column, final Date since, final int limit) { + final List> out = new ArrayList<>(); + for (final Map r : jdbcTemplate.queryForList("SELECT COALESCE(" + column + ", 'NOT GIVEN') AS k, COUNT(*) AS n," + + " COALESCE(SUM(amount), 0) AS amt FROM m_cic_alert WHERE enquiry_date >= ? AND client_id IS NOT NULL GROUP BY COALESCE(" + + column + ", 'NOT GIVEN') ORDER BY COUNT(*) DESC LIMIT ?", since, limit)) { + final Map v = new LinkedHashMap<>(); + v.put("label", r.get("k")); + v.put("count", r.get("n")); + v.put("amount", r.get("amt")); + out.add(v); + } + return out; + } + + /** Filter choices for the register: the loan and lender types seen so far. */ + public Map filterOptions() { + final Map res = new LinkedHashMap<>(); + res.put("loanTypes", jdbcTemplate.queryForList( + "SELECT DISTINCT loan_type FROM m_cic_alert WHERE loan_type IS NOT NULL ORDER BY loan_type", String.class)); + res.put("institutionTypes", jdbcTemplate.queryForList( + "SELECT DISTINCT institution_type FROM m_cic_alert WHERE institution_type IS NOT NULL ORDER BY institution_type", String.class)); + res.put("statuses", CicCatalog.ALERT_STATUSES); + return res; + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private Integer count(final String sql, final Object... args) { + return jdbcTemplate.queryForObject(sql, Integer.class, args); + } + + private static String text(final JsonElement e) { + if (e == null || e.isJsonNull()) { + return null; + } + return StringUtils.trimToNull(e.isJsonPrimitive() ? e.getAsString() : e.toString()); + } + + private static String str(final Object v) { + return v == null ? null : v.toString(); + } + + private static String cut(final String s, final int max) { + return s == null ? null : StringUtils.abbreviate(s, max); + } + + private static Date sqlDate(final String iso) { + if (iso == null) { + return null; + } + try { + return Date.valueOf(LocalDate.parse(iso.length() > 10 ? iso.substring(0, 10) : iso)); + } catch (final RuntimeException e) { + return null; + } + } + + private static BigDecimal amount(final String s) { + if (s == null) { + return null; + } + try { + final String numeric = s.replaceAll("[^0-9.\\-]", ""); + return numeric.isEmpty() ? null : new BigDecimal(numeric); + } catch (final RuntimeException e) { + return null; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicCatalog.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicCatalog.java index 4b7e5599d..9faf768af 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicCatalog.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicCatalog.java @@ -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 ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION"); public static final List AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS"); - public static final List ENDPOINT_TYPES = List.of("CONSUMER_ENQUIRY", "COMMERCIAL_ENQUIRY"); + public static final String ALERT_ENDPOINT = "ENQUIRY_ALERT"; + public static final List ENDPOINT_TYPES = List.of(ALERT_ENDPOINT); public static final List HTTP_METHODS = List.of("POST", "PUT", "GET"); - public static final List CONSENT_MODES = List.of("SIGNED_FORM", "OTP", "DIGITAL_ACCEPTANCE"); + public static final List ALERT_STATUSES = List.of("NEW", "SEEN", "ACTIONED", "DISMISSED"); + public static final List 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 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 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 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 ROOT_FIELDS = Set.of("alerts", "errorCode", "errorMessage"); + public static final Map 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 SENSITIVE_FIELDS = Set.of("client.pan", "client.mobile", "client.email", "client.aadhaarMasked"); - - private static final Map 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 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); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicClientProfileService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicClientProfileService.java deleted file mode 100644 index 2f654f1a1..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicClientProfileService.java +++ /dev/null @@ -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 load(final Long clientId) { - final List> 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 c = rows.get(0); - final Map 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> 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 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; - } -} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConfigService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConfigService.java index 9da6ff3f0..eb8cfb63a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConfigService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConfigService.java @@ -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 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 existing = endpointRow(bureauId, t); final Timestamp now = Timestamp.from(Instant.now()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConsentService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConsentService.java deleted file mode 100644 index a69009b60..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConsentService.java +++ /dev/null @@ -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 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 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 revoke(final Long id, final String reason, final AppUser user) { - final Map 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 get(final Long id) { - final List> 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 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 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 pageArgs = new ArrayList<>(args); - pageArgs.add(limit); - pageArgs.add(offset); - final List> items = new ArrayList<>(); - for (final Map 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 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 requireValid(final Long clientId, final Long bureauId, final Long consentId) { - final LocalDate today = DateUtils.getBusinessLocalDate(); - final List> 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 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 view(final Map row) { - final Map 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; - } -} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicEnquiryService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicEnquiryService.java deleted file mode 100644 index 49e3a1f6b..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicEnquiryService.java +++ /dev/null @@ -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 headers, JsonObject body, - JsonObject maskedBody, List missingMandatory) {} - - // ── Request building ────────────────────────────────────────────────────── - - private Map context(final Map bureau, final Long clientId, final BigDecimal amount, final String purpose, - final String applicationNo, final String reference) { - final Map 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 bureau, final Map endpoint, final Map 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 missing = new ArrayList<>(); - for (final Map 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 headers = new LinkedHashMap<>(); - final JsonElement hj = JsonParser.parseString(StringUtils.defaultIfBlank((String) endpoint.get("headers_json"), "{}")); - if (hj.isJsonObject()) { - for (final Map.Entry 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 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 activeEndpoint(final Map bureau, final String enquiryType) { - final Map 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 preview(final Long bureauId, final JsonObject in) { - final Map 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 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 ctx = context(bureau, clientId, amount, purpose, CicConfigService.text(in, "applicationNo", null), - "PREVIEW-" + System.currentTimeMillis()); - final BuiltRequest req = build(bureau, endpoint, ctx); - final Map 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 maskedContext(final Map ctx) { - final Map 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 clientProfile(final Long clientId, final Long bureauId) { - final Map ctx = clientProfileService.load(clientId); - final Map res = new LinkedHashMap<>(); - res.put("clientId", clientId); - res.put("data", maskedContext(ctx)); - Map consent = null; - if (bureauId != null) { - try { - consent = consentService.requireValid(clientId, bureauId, null); - } catch (final RuntimeException e) { - consent = null; - } - } else { - final List> 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 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> 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> recent = new ArrayList<>(); - for (final Map r : last) { - final Map 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 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 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 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 consent = consentService.requireValid(clientId, bureauId, longOf(in, "consentId")); - final Long consentId = ((Number) consent.get("id")).longValue(); - final Map 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 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> 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 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 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 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 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 get(final Long id) { - final List> 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 v = summaryView(rows.get(0)); - final Map 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 summaryView(final Map row) { - final Map 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 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 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 pageArgs = new ArrayList<>(args); - pageArgs.add(limit); - pageArgs.add(offset); - final List> items = new ArrayList<>(); - for (final Map 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 res = new LinkedHashMap<>(); - res.put("totalFilteredRecords", total); - res.put("pageItems", items); - return res; - } - - public Map dashboard() { - final Timestamp since30 = Timestamp.from(Instant.now().minusSeconds(30L * 86400)); - final Timestamp todayStart = Timestamp.valueOf(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).atStartOfDay()); - final Map 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 byStatus = new LinkedHashMap<>(); - for (final Map 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> byBureau = new ArrayList<>(); - for (final Map 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 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> 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 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; - } -} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicSimulator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicSimulator.java index ab5c0ac10..3d7b48319 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicSimulator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicSimulator.java @@ -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 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 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> 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 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"; + } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 324be6ec3..449dcb036 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -179,4 +179,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0605_cic_enquiry_alerts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0605_cic_enquiry_alerts.xml new file mode 100644 index 000000000..f7439d10e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0605_cic_enquiry_alerts.xml @@ -0,0 +1,207 @@ + + + + + + + + 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'); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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'); + + + 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); + + + + + + + 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'); + + + 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) + + +