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