feat(cic): credit bureau (CIBIL / CIC) enquiry module
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

Whitelist and configure CICs (CIBIL, Experian, Equifax, CRIF) with encrypted
credentials, UI-driven request/response API mapping, consent register,
enquiries with report reuse, simulator mode, and a MENU_CREDIT_BUREAU menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 12:17:50 +05:30
co-authored by Claude Opus 5.5
parent 3a320b9c09
commit a8f8aa39b7
12 changed files with 2734 additions and 1 deletions
@@ -92,7 +92,7 @@ public class TenantMenuAccessApiResource {
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT", "MENU_CREDIT_BUREAU");
/**
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
@@ -0,0 +1,283 @@
/**
* 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.api;
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 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;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
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;
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.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.
*
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICENQUIRY / CREATE_CICENQUIRY,
* READ_CICCONSENT / CREATE_CICCONSENT / REVOKE_CICCONSENT.
*/
@Path("/v1/cic")
@Component
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping, consent and enquiries.")
@RequiredArgsConstructor
public class CicApiResource {
private final PlatformSecurityContext context;
private final CicConfigService configService;
private final CicConsentService consentService;
private final CicEnquiryService enquiryService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── Catalog ───────────────────────────────────────────────────────────────
@GET
@Path("catalog")
@Produces(MediaType.APPLICATION_JSON)
public String catalog() {
context.authenticatedUser();
final Map<String, Object> c = new LinkedHashMap<>();
c.put("environments", CicCatalog.ENVIRONMENTS);
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("transforms", CicCatalog.TRANSFORMS);
c.put("defaultConsentText", CicConsentService.DEFAULT_TEXT);
return gson.toJson(c);
}
// ── Bureaus ───────────────────────────────────────────────────────────────
@GET
@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");
return gson.toJson(configService.listBureaus(whitelistedOnly));
}
@GET
@Path("bureaus/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String bureau(@PathParam("id") final Long id) {
require("READ_CICBUREAU");
return gson.toJson(configService.getBureau(id));
}
@PUT
@Path("bureaus/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String updateBureau(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
return gson.toJson(configService.updateBureau(id, json(body), user));
}
@PUT
@Path("bureaus/{id}/whitelist")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String whitelist(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
final JsonObject in = json(body);
final boolean whitelisted = in.has("whitelisted") && !in.get("whitelisted").isJsonNull() && in.get("whitelisted").getAsBoolean();
return gson.toJson(configService.setWhitelisted(id, whitelisted, user));
}
@POST
@Path("bureaus/{id}/test-connection")
@Produces(MediaType.APPLICATION_JSON)
public String testConnection(@PathParam("id") final Long id) {
require("UPDATE_CICBUREAU");
return gson.toJson(configService.testConnection(id));
}
// ── API mapping ───────────────────────────────────────────────────────────
@GET
@Path("bureaus/{id}/endpoints/{type}")
@Produces(MediaType.APPLICATION_JSON)
public String endpoint(@PathParam("id") final Long id, @PathParam("type") final String type) {
require("READ_CICBUREAU");
return gson.toJson(configService.getEndpoint(id, type));
}
@PUT
@Path("bureaus/{id}/endpoints/{type}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String saveEndpoint(@PathParam("id") final Long id, @PathParam("type") final String type, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
return gson.toJson(configService.saveEndpoint(id, type, json(body), user));
}
@POST
@Path("bureaus/{id}/preview")
@Consumes(MediaType.APPLICATION_JSON)
@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)));
}
@POST
@Path("consents")
@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)));
}
@POST
@Path("consents/{id}/revoke")
@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");
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));
}
@POST
@Path("enquiries")
@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));
}
@GET
@Path("dashboard")
@Produces(MediaType.APPLICATION_JSON)
public String dashboard() {
require("READ_CICENQUIRY");
return gson.toJson(enquiryService.dashboard());
}
// ── helpers ──────────────────────────────────────────────────────────────
private AppUser require(final String permission) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo(permission);
return user;
}
private AppUser requireAny(final String... permissions) {
final AppUser user = context.authenticatedUser();
if (user.hasAnyPermission("ALL_FUNCTIONS") || user.hasAnyPermission(permissions)) {
return user;
}
throw new NoAuthorizationException("User has no authority to " + String.join(" / ", permissions));
}
private static JsonObject json(final String body) {
if (StringUtils.isBlank(body)) {
return new JsonObject();
}
final JsonElement e = JsonParser.parseString(body);
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
}
private static int clamp(final int limit) {
return Math.max(1, Math.min(200, limit));
}
private static String clientIp(final HttpServletRequest request) {
if (request == null) {
return null;
}
final String forwarded = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotBlank(forwarded)) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
@@ -0,0 +1,247 @@
/**
* 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.JsonElement;
import com.google.gson.JsonPrimitive;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
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.
*/
public final class CicCatalog {
private 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 List<String> HTTP_METHODS = List.of("POST", "PUT", "GET");
public static final List<String> CONSENT_MODES = List.of("SIGNED_FORM", "OTP", "DIGITAL_ACCEPTANCE");
/** 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. */
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("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. */
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");
}
public static final Map<String, String> TRANSFORMS = new LinkedHashMap<>();
static {
TRANSFORMS.put("NONE", "As is");
TRANSFORMS.put("UPPER", "UPPER CASE");
TRANSFORMS.put("LOWER", "lower case");
TRANSFORMS.put("TRIM", "Trim spaces");
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_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 DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
/** Applies a REQUEST transform to a CBS value; null stays null. */
public static JsonElement applyRequestTransform(final String transform, final Object raw) {
if (raw == null) {
return null;
}
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
final String s = String.valueOf(raw).trim();
if (s.isEmpty()) {
return null;
}
switch (t) {
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": {
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":
return new JsonPrimitive(new BigDecimal(s).stripTrailingZeros());
case "BOOLEAN":
return new JsonPrimitive(Boolean.parseBoolean(s) || "1".equals(s) || "Y".equalsIgnoreCase(s));
default:
return new JsonPrimitive(s);
}
}
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. */
public static JsonElement applyResponseTransform(final String transform, final JsonElement value) {
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
return value;
}
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
final String s = value.getAsString().trim();
try {
switch (t) {
case "NUMBER":
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));
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
case "LOWER":
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
default:
return value;
}
} catch (final RuntimeException e) {
return value;
}
}
/** ABCDE1234F -> ABxxxx234F style masking for logs and previews. */
public static String mask(final String value) {
if (value == null || value.length() <= 4) {
return value == null ? null : "****";
}
final int keepStart = Math.min(2, value.length() / 4);
final int keepEnd = Math.min(4, value.length() / 3);
return value.substring(0, keepStart) + "*".repeat(value.length() - keepStart - keepEnd) + value.substring(value.length() - keepEnd);
}
}
@@ -0,0 +1,125 @@
/**
* 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;
}
}
@@ -0,0 +1,454 @@
/**
* 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.JsonArray;
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;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
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 bank's bureau master (whitelist, environment, connection, encrypted credentials) and each bureau's API mapping:
* endpoint, headers, base body and the REQUEST / RESPONSE field mappings.
*/
@Service
@RequiredArgsConstructor
public class CicConfigService {
private static final Pattern RESULT_KEY = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,99}$");
private final JdbcTemplate jdbcTemplate;
private final CicHttpGateway gateway;
// ── Bureaus ───────────────────────────────────────────────────────────────
/** Full row including the encrypted secret - internal use only, never returned by the API. */
public Map<String, Object> bureauRow(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_bureau WHERE id = ?", id);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.bureau.not.found", "Credit bureau " + id + " was not found.");
}
return rows.get(0);
}
public List<Map<String, Object>> listBureaus(final boolean whitelistedOnly) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(
"SELECT * FROM m_cic_bureau" + (whitelistedOnly ? " WHERE whitelisted = TRUE" : "") + " ORDER BY is_default DESC, id")) {
out.add(publicView(row));
}
return out;
}
public Map<String, Object> getBureau(final Long id) {
return publicView(bureauRow(id));
}
/** A bureau row as the screens see it: never the secret itself, only whether one is set. */
private Map<String, Object> publicView(final Map<String, Object> row) {
final Map<String, Object> v = new LinkedHashMap<>();
final Long id = ((Number) row.get("id")).longValue();
v.put("id", id);
v.put("code", row.get("code"));
v.put("name", row.get("name"));
v.put("description", row.get("description"));
v.put("whitelisted", row.get("whitelisted"));
v.put("isDefault", row.get("is_default"));
v.put("environment", row.get("environment"));
v.put("baseUrl", row.get("base_url"));
v.put("memberCode", row.get("member_code"));
v.put("authType", row.get("auth_type"));
v.put("username", row.get("username"));
v.put("apiKeyHeader", row.get("api_key_header"));
v.put("hasSecret", row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank());
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("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))));
return v;
}
@Transactional
public Map<String, Object> updateBureau(final Long id, final JsonObject in, final AppUser user) {
final Map<String, Object> row = bureauRow(id);
final String environment = upper(text(in, "environment", (String) row.get("environment")));
if (!CicCatalog.ENVIRONMENTS.contains(environment)) {
throw fail("error.msg.cic.environment.invalid", "Environment must be one of " + CicCatalog.ENVIRONMENTS + ".");
}
final String authType = upper(text(in, "authType", (String) row.get("auth_type")));
if (!CicCatalog.AUTH_TYPES.contains(authType)) {
throw fail("error.msg.cic.auth.invalid", "Authentication must be one of " + CicCatalog.AUTH_TYPES + ".");
}
final String baseUrl = trimToNull(text(in, "baseUrl", (String) row.get("base_url")));
if (baseUrl != null && !StringUtils.startsWithAny(baseUrl, "https://", "http://")) {
throw fail("error.msg.cic.url.invalid", "Base URL must start with https:// (or http:// for a test server).");
}
if ("PRODUCTION".equals(environment) && baseUrl != null && !baseUrl.startsWith("https://")) {
throw fail("error.msg.cic.url.https", "A PRODUCTION bureau must be reached over https://.");
}
final String tokenUrl = trimToNull(text(in, "tokenUrl", (String) row.get("token_url")));
final int timeoutMs = intOf(in, "timeoutMs", ((Number) row.get("timeout_ms")).intValue());
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 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.");
}
String secretEnc = (String) row.get("secret_enc");
if (in.has("clearSecret") && in.get("clearSecret").getAsBoolean()) {
secretEnc = null;
} else if (in.has("secret") && !in.get("secret").isJsonNull() && !in.get("secret").getAsString().isBlank()) {
secretEnc = gateway.encrypt(in.get("secret").getAsString());
}
final boolean makeDefault = in.has("isDefault") && !in.get("isDefault").isJsonNull() && in.get("isDefault").getAsBoolean();
if (makeDefault) {
jdbcTemplate.update("UPDATE m_cic_bureau SET is_default = FALSE WHERE id <> ?", id);
}
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 = ?
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,
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.
if (Boolean.TRUE.equals(row.get("whitelisted"))) {
assertReadyForLive(bureauRow(id));
}
return getBureau(id);
}
@Transactional
public Map<String, Object> setWhitelisted(final Long id, final boolean whitelisted, final AppUser user) {
final Map<String, Object> row = bureauRow(id);
if (whitelisted) {
assertReadyForLive(row);
}
jdbcTemplate.update("UPDATE m_cic_bureau SET whitelisted = ?, updated_by = ?, updated_on = ? WHERE id = ?", whitelisted,
user.getId(), Timestamp.from(Instant.now()), id);
return getBureau(id);
}
/** What a bureau needs before enquiries may run against it. */
public void assertReadyForLive(final Map<String, Object> row) {
final String code = (String) row.get("code");
final Integer endpoints = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, row.get("id"));
if (endpoints == null || endpoints == 0) {
throw fail("error.msg.cic.no.endpoint", code + " has no active API endpoint. Configure it under API Mapping first.");
}
if ("SIMULATOR".equals(row.get("environment"))) {
return;
}
if (StringUtils.isBlank((String) row.get("base_url"))) {
throw fail("error.msg.cic.no.url", code + " has no base URL for " + row.get("environment") + ".");
}
final String auth = (String) row.get("auth_type");
final boolean hasSecret = row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank();
if (!"NONE".equals(auth) && !hasSecret) {
throw fail("error.msg.cic.no.secret", code + " uses " + auth + " but no password / key / secret is stored.");
}
if ("BASIC".equals(auth) && StringUtils.isBlank((String) row.get("username"))) {
throw fail("error.msg.cic.no.username", code + " uses BASIC authentication but has no username.");
}
if ("OAUTH2_CLIENT_CREDENTIALS".equals(auth)
&& (StringUtils.isBlank((String) row.get("token_url")) || StringUtils.isBlank((String) row.get("client_id")))) {
throw fail("error.msg.cic.no.oauth", code + " uses OAuth2 but the token URL or client id is missing.");
}
}
/** Reachability and credentials check; the result is kept on the bureau for the configuration screen. */
public Map<String, Object> testConnection(final Long id) {
final Map<String, Object> row = bureauRow(id);
String status;
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.";
} else if (StringUtils.isBlank((String) row.get("base_url"))) {
status = "FAILED";
message = "No base URL configured.";
} else {
try {
if ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type"))) {
gateway.evictToken(id);
gateway.oauthToken(row);
}
final CicHttpGateway.HttpResult r = gateway.send(row, "GET", (String) row.get("base_url"), Map.of(), null, null);
if (r.status() == 0) {
status = "FAILED";
message = "Not reachable: " + r.error();
} else if (r.status() == 401 || r.status() == 403) {
status = "FAILED";
message = "Reachable, but the bureau refused the credentials (HTTP " + r.status() + ").";
} else {
status = "OK";
message = "Reachable (HTTP " + r.status() + " in " + r.durationMs() + " ms)"
+ ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type")) ? ", OAuth2 token obtained." : ".");
}
} catch (final IOException | RuntimeException e) {
status = "FAILED";
message = e.getMessage();
}
}
jdbcTemplate.update("UPDATE m_cic_bureau SET last_test_status = ?, last_test_message = ?, last_tested_on = ? WHERE id = ?", status,
StringUtils.abbreviate(message, 1000), Timestamp.from(Instant.now()), id);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("status", status);
res.put("message", message);
return res;
}
// ── Endpoints and mappings ────────────────────────────────────────────────
public Map<String, Object> endpointRow(final Long bureauId, final String type) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND endpoint_type = ?", bureauId, type);
return rows.isEmpty() ? null : rows.get(0);
}
public List<Map<String, Object>> mappingRows(final Long endpointId, final String direction) {
return jdbcTemplate.queryForList(
"SELECT * FROM m_cic_field_mapping WHERE endpoint_id = ? AND direction = ? ORDER BY sort_order, id", endpointId, direction);
}
public Map<String, Object> getEndpoint(final Long bureauId, final String type) {
bureauRow(bureauId);
final String t = upper(type);
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final Map<String, Object> e = endpointRow(bureauId, t);
final Map<String, Object> v = new LinkedHashMap<>();
v.put("bureauId", bureauId);
v.put("endpointType", t);
v.put("exists", e != null);
v.put("id", e == null ? null : e.get("id"));
v.put("httpMethod", e == null ? "POST" : e.get("http_method"));
v.put("path", e == null ? null : e.get("path"));
v.put("contentType", e == null ? "application/json" : e.get("content_type"));
v.put("headersJson", e == null ? "{}" : e.get("headers_json"));
v.put("baseBody", e == null ? "{}" : e.get("base_body"));
v.put("isActive", e == null ? Boolean.FALSE : e.get("is_active"));
v.put("requestMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "REQUEST")));
v.put("responseMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "RESPONSE")));
return v;
}
private static List<Map<String, Object>> mappingView(final List<Map<String, Object>> rows) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> m : rows) {
final Map<String, Object> v = new LinkedHashMap<>();
v.put("id", m.get("id"));
v.put("sourceField", m.get("source_field"));
v.put("targetField", m.get("target_field"));
v.put("transform", m.get("transform"));
v.put("defaultValue", m.get("default_value"));
v.put("mandatory", m.get("is_mandatory"));
v.put("sortOrder", m.get("sort_order"));
out.add(v);
}
return out;
}
/** Saves an endpoint and replaces its whole mapping in one transaction, after validating every row. */
@Transactional
public Map<String, Object> saveEndpoint(final Long bureauId, final String type, final JsonObject in, final AppUser user) {
final Map<String, Object> bureau = bureauRow(bureauId);
final String t = upper(type);
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final String method = upper(StringUtils.defaultIfBlank(text(in, "httpMethod", "POST"), "POST"));
if (!CicCatalog.HTTP_METHODS.contains(method)) {
throw fail("error.msg.cic.method.invalid", "HTTP method must be one of " + CicCatalog.HTTP_METHODS + ".");
}
final String headersJson = StringUtils.defaultIfBlank(text(in, "headersJson", "{}"), "{}");
final String baseBody = StringUtils.defaultIfBlank(text(in, "baseBody", "{}"), "{}");
requireJsonObject(headersJson, "Headers");
requireJsonObject(baseBody, "Base request body");
for (final Map.Entry<String, JsonElement> h : JsonParser.parseString(headersJson).getAsJsonObject().entrySet()) {
if (!h.getValue().isJsonPrimitive()) {
throw fail("error.msg.cic.header.invalid", "Header '" + h.getKey() + "' must be a plain text value.");
}
}
final boolean active = !in.has("isActive") || in.get("isActive").isJsonNull() || in.get("isActive").getAsBoolean();
final List<Object[]> rows = new ArrayList<>();
validateMappings(in.has("requestMappings") ? in.getAsJsonArray("requestMappings") : new JsonArray(), "REQUEST", rows);
validateMappings(in.has("responseMappings") ? in.getAsJsonArray("responseMappings") : new JsonArray(), "RESPONSE", rows);
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.");
}
Map<String, Object> existing = endpointRow(bureauId, t);
final Timestamp now = Timestamp.from(Instant.now());
if (existing == null) {
jdbcTemplate.update("""
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body,
is_active, updated_by, updated_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", bureauId, t, method, trimToNull(text(in, "path", null)),
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
user.getId(), now);
existing = endpointRow(bureauId, t);
} else {
jdbcTemplate.update("""
UPDATE m_cic_bureau_endpoint SET http_method = ?, path = ?, content_type = ?, headers_json = ?, base_body = ?, is_active = ?,
updated_by = ?, updated_on = ?
WHERE id = ?
""", method, trimToNull(text(in, "path", null)),
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
user.getId(), now, existing.get("id"));
}
final Long endpointId = ((Number) existing.get("id")).longValue();
jdbcTemplate.update("DELETE FROM m_cic_field_mapping WHERE endpoint_id = ?", endpointId);
for (final Object[] r : rows) {
jdbcTemplate.update("""
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory,
sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", endpointId, r[0], r[1], r[2], r[3], r[4], r[5], r[6]);
}
// Switching off the last active endpoint of a whitelisted bureau would leave it whitelisted but unusable.
if (Boolean.TRUE.equals(bureau.get("whitelisted"))) {
assertReadyForLive(bureauRow(bureauId));
}
return getEndpoint(bureauId, t);
}
private void validateMappings(final JsonArray array, final String direction, final List<Object[]> out) {
int order = 0;
for (final JsonElement el : array) {
final JsonObject m = el.getAsJsonObject();
order += 10;
final String source = trimToNull(text(m, "sourceField", null));
final String target = trimToNull(text(m, "targetField", null));
final String transform = StringUtils.defaultIfBlank(upper(text(m, "transform", "NONE")), "NONE");
final String defaultValue = trimToNull(text(m, "defaultValue", null));
final boolean mandatory = m.has("mandatory") && !m.get("mandatory").isJsonNull() && m.get("mandatory").getAsBoolean();
final int sortOrder = m.has("sortOrder") && !m.get("sortOrder").isJsonNull() ? m.get("sortOrder").getAsInt() : order;
final String label = direction + " row " + (order / 10);
if (!CicCatalog.TRANSFORMS.containsKey(transform)) {
throw fail("error.msg.cic.transform.invalid", label + ": unknown transform '" + transform + "'.");
}
if (target == null) {
throw fail("error.msg.cic.mapping.target.required", label + ": the target field is required.");
}
if ("REQUEST".equals(direction)) {
if (source == null && defaultValue == null) {
throw fail("error.msg.cic.mapping.source.required", label + " (" + target + "): choose a CBS field or give a fixed value.");
}
if (source != null && !CicCatalog.CBS_FIELDS.containsKey(source)) {
throw fail("error.msg.cic.mapping.source.invalid", label + ": unknown CBS field '" + source + "'.");
}
if (!CicJsonPath.isValid(target)) {
throw fail("error.msg.cic.mapping.path.invalid", label + ": '" + target + "' is not a valid JSON path (e.g. consumer.ids[0].number).");
}
} else {
if (source == null || !CicJsonPath.isValid(source)) {
throw fail("error.msg.cic.mapping.path.invalid",
label + ": the bureau response path is missing or invalid (e.g. report.score.value).");
}
if (!RESULT_KEY.matcher(target).matches()) {
throw fail("error.msg.cic.mapping.result.invalid", label + ": result field '" + target + "' must be a simple name.");
}
}
out.add(new Object[] { direction, source, target, transform, defaultValue, mandatory, sortOrder });
}
}
// ── helpers ──────────────────────────────────────────────────────────────
private static void requireJsonObject(final String text, final String label) {
try {
if (!JsonParser.parseString(text).isJsonObject()) {
throw new IllegalArgumentException();
}
} catch (final RuntimeException e) {
throw fail("error.msg.cic.json.invalid", label + " must be a valid JSON object.");
}
}
static String text(final JsonObject in, final String key, final String fallback) {
if (in == null || !in.has(key)) {
return fallback;
}
return in.get(key).isJsonNull() ? null : in.get(key).getAsString();
}
private static int intOf(final JsonObject in, final String key, final int fallback) {
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsInt() : fallback;
}
private static String upper(final String s) {
return s == null ? null : s.trim().toUpperCase();
}
private static String trimToNull(final String s) {
return StringUtils.trimToNull(s);
}
static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -0,0 +1,245 @@
/**
* 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;
}
}
@@ -0,0 +1,615 @@
/**
* 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;
}
}
@@ -0,0 +1,194 @@
/**
* 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.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.FormBody;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor;
import org.springframework.stereotype.Service;
/**
* Outbound HTTP to a credit bureau: URL joining, authentication (Basic, API key, bearer, OAuth2 client credentials
* with an in-memory token cache), per-bureau timeouts. The bureau's secret is stored encrypted and is only decrypted
* here, for the call itself.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CicHttpGateway {
private static final int MAX_BODY_CHARS = 2_000_000;
private final DatabasePasswordEncryptor encryptor;
private final Map<Long, CachedToken> tokens = new ConcurrentHashMap<>();
private record CachedToken(String token, Instant expiresAt) {}
/** What came back from the bureau. A transport failure is a status of 0 with the error in {@code body}. */
public record HttpResult(int status, String body, long durationMs, String error) {
public boolean ok() {
return status >= 200 && status < 300;
}
}
public String encrypt(final String plain) {
return plain == null ? null : encryptor.encrypt(plain);
}
private String secretOf(final Map<String, Object> bureau) {
final Object enc = bureau.get("secret_enc");
if (enc == null || enc.toString().isBlank()) {
return null;
}
try {
return encryptor.decrypt(enc.toString());
} catch (final RuntimeException e) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.secret.unreadable",
"The stored credential of " + bureau.get("code") + " cannot be decrypted (was the master password changed?)."
+ " Re-enter it on the bureau configuration.");
}
}
public static String joinUrl(final String baseUrl, final String path) {
if (StringUtils.startsWithAny(path, "http://", "https://")) {
return path;
}
final String base = StringUtils.defaultString(baseUrl).replaceAll("/+$", "");
if (StringUtils.isBlank(path)) {
return base;
}
return base + (path.startsWith("/") ? path : "/" + path);
}
private OkHttpClient client(final Map<String, Object> bureau) {
final int timeout = bureau.get("timeout_ms") instanceof Number n && n.intValue() > 0 ? n.intValue() : 30000;
return new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS).readTimeout(timeout, TimeUnit.MILLISECONDS)
.writeTimeout(timeout, TimeUnit.MILLISECONDS).build();
}
/** Sends one request to the bureau with its configured authentication. */
public HttpResult send(final Map<String, Object> bureau, final String method, final String url, final Map<String, String> headers,
final String contentType, final String body) {
final long started = System.currentTimeMillis();
try {
final Request.Builder rb = new Request.Builder().url(url);
headers.forEach((k, v) -> {
if (StringUtils.isNotBlank(k) && v != null) {
rb.header(k, v);
}
});
applyAuth(bureau, rb);
final String m = StringUtils.defaultIfBlank(method, "POST").toUpperCase();
if ("GET".equals(m)) {
rb.get();
} else {
final MediaType mediaType = MediaType.parse(StringUtils.defaultIfBlank(contentType, "application/json"));
rb.method(m, RequestBody.create(StringUtils.defaultString(body), mediaType));
}
try (Response response = client(bureau).newCall(rb.build()).execute()) {
return new HttpResult(response.code(), read(response.body()), System.currentTimeMillis() - started, null);
}
} catch (final IOException | IllegalArgumentException e) {
log.warn("CIC {}: call to {} failed: {}", bureau.get("code"), url, e.getMessage());
return new HttpResult(0, null, System.currentTimeMillis() - started, e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
private static String read(final ResponseBody body) throws IOException {
if (body == null) {
return null;
}
final String text = new String(body.bytes(), StandardCharsets.UTF_8);
return text.length() > MAX_BODY_CHARS ? text.substring(0, MAX_BODY_CHARS) : text;
}
private void applyAuth(final Map<String, Object> bureau, final Request.Builder rb) throws IOException {
final String auth = StringUtils.defaultIfBlank((String) bureau.get("auth_type"), "NONE");
final String username = (String) bureau.get("username");
switch (auth) {
case "BASIC" -> {
final String raw = StringUtils.defaultString(username) + ":" + StringUtils.defaultString(secretOf(bureau));
rb.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)));
}
case "API_KEY" -> rb.header(StringUtils.defaultIfBlank((String) bureau.get("api_key_header"), "X-API-KEY"),
StringUtils.defaultString(secretOf(bureau)));
case "BEARER_TOKEN" -> rb.header("Authorization", "Bearer " + StringUtils.defaultString(secretOf(bureau)));
case "OAUTH2_CLIENT_CREDENTIALS" -> rb.header("Authorization", "Bearer " + oauthToken(bureau));
default -> {
// NONE: the bureau authenticates by IP whitelisting / mutual TLS at the network layer.
}
}
}
/** OAuth2 client-credentials token, cached until a minute before it expires. */
public String oauthToken(final Map<String, Object> bureau) throws IOException {
final Long id = ((Number) bureau.get("id")).longValue();
final CachedToken cached = tokens.get(id);
if (cached != null && Instant.now().isBefore(cached.expiresAt())) {
return cached.token();
}
final String tokenUrl = (String) bureau.get("token_url");
if (StringUtils.isBlank(tokenUrl)) {
throw new IOException("OAuth2 token URL is not configured");
}
final FormBody form = new FormBody.Builder().add("grant_type", "client_credentials")
.add("client_id", StringUtils.defaultString((String) bureau.get("client_id")))
.add("client_secret", StringUtils.defaultString(secretOf(bureau))).build();
try (Response response = client(bureau).newCall(new Request.Builder().url(tokenUrl).post(form).build()).execute()) {
final String text = read(response.body());
if (!response.isSuccessful()) {
throw new IOException("Token endpoint returned HTTP " + response.code() + ": " + StringUtils.abbreviate(text, 300));
}
final JsonElement json = JsonParser.parseString(StringUtils.defaultIfBlank(text, "{}"));
final JsonObject obj = json.isJsonObject() ? json.getAsJsonObject() : new JsonObject();
if (!obj.has("access_token")) {
throw new IOException("Token endpoint response has no access_token");
}
final long expiresIn = obj.has("expires_in") ? obj.get("expires_in").getAsLong() : 300L;
final String token = obj.get("access_token").getAsString();
tokens.put(id, new CachedToken(token, Instant.now().plusSeconds(Math.max(30, expiresIn - 60))));
return token;
}
}
/** Forget a cached token (after the bureau's credentials change). */
public void evictToken(final Long bureauId) {
tokens.remove(bureauId);
}
}
@@ -0,0 +1,133 @@
/**
* 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.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Minimal dotted JSON path used by the bureau API mapping: {@code consumer.name.firstName},
* {@code consumer.ids[0].number}, {@code report.score.value}. Setting creates the missing objects and arrays on the
* way; reading returns null for anything absent.
*/
public final class CicJsonPath {
private static final Pattern SEGMENT = Pattern.compile("^([^\\[\\]]*)((?:\\[\\d+\\])*)$");
private static final Pattern INDEX = Pattern.compile("\\[(\\d+)\\]");
private CicJsonPath() {}
/** One step of a path: an object key, optionally followed by array indexes. */
private record Step(String key, List<Integer> indexes) {}
private static List<Step> parse(final String path) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("Empty JSON path");
}
final List<Step> steps = new ArrayList<>();
for (final String raw : path.trim().split("\\.")) {
final Matcher m = SEGMENT.matcher(raw);
if (!m.matches()) {
throw new IllegalArgumentException("Invalid JSON path segment '" + raw + "' in '" + path + "'");
}
final List<Integer> indexes = new ArrayList<>();
final Matcher im = INDEX.matcher(m.group(2));
while (im.find()) {
indexes.add(Integer.parseInt(im.group(1)));
}
steps.add(new Step(m.group(1), indexes));
}
return steps;
}
/** Whether the path is syntactically valid. */
public static boolean isValid(final String path) {
try {
parse(path);
return true;
} catch (final IllegalArgumentException e) {
return false;
}
}
public static JsonElement get(final JsonElement root, final String path) {
JsonElement current = root;
for (final Step step : parse(path)) {
if (!step.key().isEmpty()) {
if (current == null || !current.isJsonObject()) {
return null;
}
current = current.getAsJsonObject().get(step.key());
}
for (final Integer index : step.indexes()) {
if (current == null || !current.isJsonArray() || current.getAsJsonArray().size() <= index) {
return null;
}
current = current.getAsJsonArray().get(index);
}
}
return current == null || current.isJsonNull() ? null : current;
}
public static void set(final JsonObject root, final String path, final JsonElement value) {
final List<Step> steps = parse(path);
JsonElement current = root;
for (int s = 0; s < steps.size(); s++) {
final Step step = steps.get(s);
final boolean lastStep = s == steps.size() - 1;
if (!step.key().isEmpty()) {
final JsonObject obj = current.getAsJsonObject();
if (lastStep && step.indexes().isEmpty()) {
obj.add(step.key(), value == null ? JsonNull.INSTANCE : value);
return;
}
JsonElement next = obj.get(step.key());
if (next == null || next.isJsonNull()) {
next = step.indexes().isEmpty() ? new JsonObject() : new JsonArray();
obj.add(step.key(), next);
}
current = next;
}
for (int i = 0; i < step.indexes().size(); i++) {
final int index = step.indexes().get(i);
final boolean lastIndex = lastStep && i == step.indexes().size() - 1;
final JsonArray array = current.getAsJsonArray();
while (array.size() <= index) {
array.add(JsonNull.INSTANCE);
}
if (lastIndex) {
array.set(index, value == null ? JsonNull.INSTANCE : value);
return;
}
JsonElement next = array.get(index);
if (next == null || next.isJsonNull()) {
next = i < step.indexes().size() - 1 ? new JsonArray() : new JsonObject();
array.set(index, next);
}
current = next;
}
}
}
}
@@ -0,0 +1,142 @@
/**
* 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.JsonArray;
import com.google.gson.JsonObject;
import java.time.LocalDate;
import java.util.Map;
import java.util.Random;
/**
* 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.
*/
public final 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 CicSimulator() {}
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);
final JsonObject report = new JsonObject();
report.addProperty("reportId", bureauCode + "-SIM-" + reference);
report.addProperty("bureau", bureauCode);
report.addProperty("generatedOn", businessDate.toString());
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);
return root;
}
}
@@ -178,4 +178,5 @@
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
<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"/>
</databaseChangeLog>
@@ -0,0 +1,294 @@
<?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 / Credit Bureau Enquiry module (CIBIL TransUnion, Experian, Equifax, CRIF High Mark).
m_cic_bureau - the bank's bureau master: whitelisted or not, environment, connection and credentials
(the secret is stored encrypted with the tenant master password).
m_cic_bureau_endpoint - per bureau and enquiry type: method, path, headers and a base JSON body.
m_cic_field_mapping - the API mapping, maintained from the UI: REQUEST rows put a CBS field into the bureau's
request at a JSON path (with a transform); RESPONSE rows read a JSON path of the bureau's
response into a CBS result field (score, accounts, overdue ...).
m_cic_consent - the borrower's consent to a bureau pull, required before any enquiry.
m_cic_enquiry - every enquiry: masked request, raw response, normalised result, score and status.
Every bureau is seeded NOT whitelisted, in SIMULATOR mode, with a generic request/response mapping the built-in
simulator answers to. Going live is: enter the bureau's URL and credentials, adapt the mapping to the bureau's own
specification, switch the environment, and whitelist it.
-->
<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="0604-1-cic-tables" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cic_bureau"/></not>
</preConditions>
<createTable tableName="m_cic_bureau">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="code" type="VARCHAR(30)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_bureau_code"/></column>
<column name="name" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="description" type="VARCHAR(500)"/>
<column name="whitelisted" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="is_default" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="environment" type="VARCHAR(20)" defaultValue="SIMULATOR"><constraints nullable="false"/></column>
<column name="base_url" type="VARCHAR(500)"/>
<column name="member_code" type="VARCHAR(100)"/>
<column name="auth_type" type="VARCHAR(40)" defaultValue="NONE"><constraints nullable="false"/></column>
<column name="username" type="VARCHAR(200)"/>
<column name="api_key_header" type="VARCHAR(100)" defaultValue="X-API-KEY"/>
<column name="secret_enc" type="TEXT"/>
<column name="token_url" type="VARCHAR(500)"/>
<column name="client_id" type="VARCHAR(200)"/>
<column name="timeout_ms" type="INT" defaultValueNumeric="30000"><constraints nullable="false"/></column>
<column name="report_validity_days" type="INT" defaultValueNumeric="30"><constraints nullable="false"/></column>
<column name="enquiry_charge" type="DECIMAL(19,6)" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="last_test_status" type="VARCHAR(20)"/>
<column name="last_test_message" type="VARCHAR(1000)"/>
<column name="last_tested_on" type="TIMESTAMP"/>
<column name="updated_by" type="BIGINT"/>
<column name="updated_on" type="TIMESTAMP"/>
</createTable>
<createTable tableName="m_cic_bureau_endpoint">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_endpoint_bureau" references="m_cic_bureau(id)"/></column>
<column name="endpoint_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="http_method" type="VARCHAR(10)" defaultValue="POST"><constraints nullable="false"/></column>
<column name="path" type="VARCHAR(500)"/>
<column name="content_type" type="VARCHAR(100)" defaultValue="application/json"/>
<column name="headers_json" type="TEXT"/>
<column name="base_body" type="TEXT"/>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
<column name="updated_by" type="BIGINT"/>
<column name="updated_on" type="TIMESTAMP"/>
</createTable>
<addUniqueConstraint tableName="m_cic_bureau_endpoint" columnNames="bureau_id, endpoint_type" constraintName="uq_cic_endpoint_type"/>
<createTable tableName="m_cic_field_mapping">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="endpoint_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_mapping_endpoint" references="m_cic_bureau_endpoint(id)" deleteCascade="true"/></column>
<column name="direction" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="source_field" type="VARCHAR(200)"/>
<column name="target_field" type="VARCHAR(200)"><constraints nullable="false"/></column>
<column name="transform" type="VARCHAR(40)" defaultValue="NONE"/>
<column name="default_value" type="VARCHAR(200)"/>
<column name="is_mandatory" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="sort_order" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_cic_field_mapping" indexName="idx_cic_mapping_endpoint"><column name="endpoint_id"/></createIndex>
<createTable tableName="m_cic_consent">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="consent_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_consent_ref"/></column>
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="client_name" type="VARCHAR(200)"/>
<column name="application_no" type="VARCHAR(50)"/>
<column name="bureau_id" type="BIGINT"/>
<column name="purpose" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="consent_mode" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="consent_text" type="TEXT"/>
<column name="document_ref" type="VARCHAR(200)"/>
<column name="otp_reference" type="VARCHAR(100)"/>
<column name="valid_from" type="DATE"><constraints nullable="false"/></column>
<column name="valid_until" type="DATE"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"><constraints nullable="false"/></column>
<column name="revoked_reason" type="VARCHAR(500)"/>
<column name="revoked_by" type="BIGINT"/>
<column name="revoked_on" type="TIMESTAMP"/>
<column name="captured_by" type="BIGINT"/>
<column name="captured_by_name" type="VARCHAR(200)"/>
<column name="captured_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="ip_address" type="VARCHAR(64)"/>
</createTable>
<createIndex tableName="m_cic_consent" indexName="idx_cic_consent_client"><column name="client_id"/></createIndex>
<createTable tableName="m_cic_enquiry">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="enquiry_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_enquiry_ref"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="client_name" type="VARCHAR(200)"/>
<column name="application_no" type="VARCHAR(50)"/>
<column name="consent_id" type="BIGINT"/>
<column name="enquiry_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="purpose" type="VARCHAR(40)"/>
<column name="loan_amount" type="DECIMAL(19,6)"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="score" type="INT"/>
<column name="http_status" type="INT"/>
<column name="error_message" type="VARCHAR(2000)"/>
<column name="request_masked" type="TEXT"/>
<column name="response_raw" type="TEXT"/>
<column name="result_json" type="TEXT"/>
<column name="reused_from_id" type="BIGINT"/>
<column name="charge_amount" type="DECIMAL(19,6)"/>
<column name="environment" type="VARCHAR(20)"/>
<column name="office_id" type="BIGINT"/>
<column name="requested_by" type="BIGINT"/>
<column name="requested_by_name" type="VARCHAR(200)"/>
<column name="requested_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="duration_ms" type="INT"/>
</createTable>
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_client"><column name="client_id"/></createIndex>
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_bureau_date"><column name="bureau_id"/><column name="requested_on"/></createIndex>
</changeSet>
<!-- The four RBI-licensed CICs. Seeded off (not whitelisted) and in SIMULATOR mode. -->
<changeSet id="0604-2-cic-bureaus" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau</sqlCheck>
</preConditions>
<insert tableName="m_cic_bureau">
<column name="code" value="CIBIL"/><column name="name" value="TransUnion CIBIL"/>
<column name="description" value="TransUnion CIBIL Limited - consumer and commercial credit information"/>
<column name="is_default" valueBoolean="true"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="EXPERIAN"/><column name="name" value="Experian Credit Information Company of India"/>
<column name="description" value="Experian India - consumer and commercial credit information"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="EQUIFAX"/><column name="name" value="Equifax Credit Information Services"/>
<column name="description" value="Equifax India - consumer, commercial and microfinance credit information"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="CRIF"/><column name="name" value="CRIF High Mark Credit Information Services"/>
<column name="description" value="CRIF High Mark - consumer, commercial and microfinance credit information"/>
</insert>
</changeSet>
<!-- A CONSUMER_ENQUIRY endpoint per bureau, with a generic body skeleton the simulator understands. -->
<changeSet id="0604-3-cic-endpoints" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau_endpoint</sqlCheck>
</preConditions>
<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, 'CONSUMER_ENQUIRY', 'POST', '/consumer/enquiry', 'application/json',
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{enquiry.reference}}"}',
'{"requestHeader":{"product":"CONSUMER_REPORT","version":"1.0"},"consumer":{},"enquiry":{}}',
TRUE
FROM m_cic_bureau b
</sql>
</changeSet>
<!-- The generic API mapping. Adapt it to each bureau's own specification from the API Mapping screen. -->
<changeSet id="0604-4-cic-mappings" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_field_mapping</sqlCheck>
</preConditions>
<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', 'enquiry.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
UNION ALL SELECT 'REQUEST', 'enquiry.date', 'requestHeader.enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 30
UNION ALL SELECT 'REQUEST', 'client.firstName', 'consumer.name.firstName', 'UPPER', NULL, TRUE, 40
UNION ALL SELECT 'REQUEST', 'client.middleName', 'consumer.name.middleName', 'UPPER', NULL, FALSE, 50
UNION ALL SELECT 'REQUEST', 'client.lastName', 'consumer.name.lastName', 'UPPER', NULL, FALSE, 60
UNION ALL SELECT 'REQUEST', 'client.dob', 'consumer.dateOfBirth', 'DATE_DDMMYYYY', NULL, TRUE, 70
UNION ALL SELECT 'REQUEST', 'client.gender', 'consumer.gender', 'GENDER_MF', NULL, FALSE, 80
UNION ALL SELECT 'REQUEST', NULL, 'consumer.ids[0].type', 'NONE', 'PAN', FALSE, 90
UNION ALL SELECT 'REQUEST', 'client.pan', 'consumer.ids[0].number', 'UPPER', NULL, TRUE, 100
UNION ALL SELECT 'REQUEST', NULL, 'consumer.phones[0].type', 'NONE', 'MOBILE', FALSE, 110
UNION ALL SELECT 'REQUEST', 'client.mobile', 'consumer.phones[0].number', 'DIGITS_ONLY', NULL, FALSE, 120
UNION ALL SELECT 'REQUEST', 'client.email', 'consumer.email', 'LOWER', NULL, FALSE, 130
UNION ALL SELECT 'REQUEST', 'client.addressLine1', 'consumer.address.line1', 'UPPER', NULL, FALSE, 140
UNION ALL SELECT 'REQUEST', 'client.addressLine2', 'consumer.address.line2', 'UPPER', NULL, FALSE, 150
UNION ALL SELECT 'REQUEST', 'client.city', 'consumer.address.city', 'UPPER', NULL, FALSE, 160
UNION ALL SELECT 'REQUEST', 'client.state', 'consumer.address.stateCode', 'STATE_CODE', NULL, FALSE, 170
UNION ALL SELECT 'REQUEST', 'client.pincode', 'consumer.address.pincode', 'DIGITS_ONLY', NULL, FALSE, 180
UNION ALL SELECT 'REQUEST', 'enquiry.amount', 'enquiry.amount', 'AMOUNT_INTEGER', NULL, TRUE, 190
UNION ALL SELECT 'REQUEST', 'enquiry.purposeCode', 'enquiry.purpose', 'NONE', NULL, TRUE, 200
UNION ALL SELECT 'RESPONSE', 'report.hit', 'hit', 'NONE', NULL, FALSE, 10
UNION ALL SELECT 'RESPONSE', 'report.reportId', 'reportId', 'NONE', NULL, FALSE, 20
UNION ALL SELECT 'RESPONSE', 'report.score.value', 'score', 'NUMBER', NULL, FALSE, 30
UNION ALL SELECT 'RESPONSE', 'report.score.version', 'scoreVersion', 'NONE', NULL, FALSE, 40
UNION ALL SELECT 'RESPONSE', 'report.summary.totalAccounts', 'totalAccounts', 'NUMBER', NULL, FALSE, 50
UNION ALL SELECT 'RESPONSE', 'report.summary.activeAccounts', 'activeAccounts', 'NUMBER', NULL, FALSE, 60
UNION ALL SELECT 'RESPONSE', 'report.summary.overdueAccounts', 'overdueAccounts', 'NUMBER', NULL, FALSE, 70
UNION ALL SELECT 'RESPONSE', 'report.summary.writtenOffAccounts', 'writtenOffAccounts', 'NUMBER', NULL, FALSE, 80
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOutstanding', 'totalOutstanding', 'NUMBER', NULL, FALSE, 90
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOverdue', 'totalOverdue', 'NUMBER', NULL, FALSE, 100
UNION ALL SELECT 'RESPONSE', 'report.summary.enquiriesLast6Months', 'enquiriesLast6Months', 'NUMBER', NULL, FALSE, 110
UNION ALL SELECT 'RESPONSE', 'report.summary.maxDpd', 'maxDpd', 'NUMBER', NULL, FALSE, 120
UNION ALL SELECT 'RESPONSE', 'report.accounts', 'accounts', 'NONE', NULL, FALSE, 130
UNION ALL SELECT 'RESPONSE', 'report.enquiries', 'enquiries', 'NONE', NULL, FALSE, 140
) m
</sql>
</changeSet>
<!-- Permissions. Configuration (UPDATE_CICBUREAU) is left to super users; enquiry and consent go to every role that
already works Loan Origination, which also gets the new top-nav menu. -->
<changeSet id="0604-5-cic-permissions" author="cbsnexora">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'heading_menu', 'MENU_CREDIT_BUREAU', 'MENU', 'CREDIT_BUREAU', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'MENU_CREDIT_BUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICBUREAU', 'CICBUREAU', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICBUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'UPDATE_CICBUREAU', 'CICBUREAU', 'UPDATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICBUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICENQUIRY', 'CICENQUIRY', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICENQUIRY');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'CREATE_CICENQUIRY', 'CICENQUIRY', 'CREATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICENQUIRY');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICCONSENT', 'CICCONSENT', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICCONSENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'CREATE_CICCONSENT', 'CICCONSENT', 'CREATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICCONSENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'REVOKE_CICCONSENT', 'CICCONSENT', 'REVOKE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVOKE_CICCONSENT');
</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_LOAN_ORIGINATION'
JOIN m_permission p2 ON p2.code IN ('MENU_CREDIT_BUREAU', 'READ_CICBUREAU', 'READ_CICENQUIRY', 'CREATE_CICENQUIRY',
'READ_CICCONSENT', 'CREATE_CICCONSENT')
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>
<changeSet id="0604-6-cic-menu-access" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_tenant_menu_access"/>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_CREDIT_BUREAU'</sqlCheck>
</and>
</preConditions>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CREDIT_BUREAU"/></insert>
</changeSet>
</databaseChangeLog>