diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java index 1bff751e7..7a0264577 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/TenantMenuAccessApiResource.java @@ -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}. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/api/CicApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/api/CicApiResource.java new file mode 100644 index 000000000..4ad9a2bd3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/api/CicApiResource.java @@ -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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicCatalog.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicCatalog.java new file mode 100644 index 000000000..4b7e5599d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicCatalog.java @@ -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 ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION"); + public static final List AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS"); + public static final List ENDPOINT_TYPES = List.of("CONSUMER_ENQUIRY", "COMMERCIAL_ENQUIRY"); + public static final List HTTP_METHODS = List.of("POST", "PUT", "GET"); + public static final List CONSENT_MODES = List.of("SIGNED_FORM", "OTP", "DIGITAL_ACCEPTANCE"); + + /** Enquiry purposes with the code sent to the bureau (enquiry.purposeCode). */ + public static final Map PURPOSES = new LinkedHashMap<>(); + static { + PURPOSES.put("NEW_LOAN", "New loan application"); + PURPOSES.put("CREDIT_CARD", "Credit card / overdraft"); + PURPOSES.put("LOAN_RENEWAL", "Renewal / enhancement"); + PURPOSES.put("ACCOUNT_REVIEW", "Account review / monitoring"); + PURPOSES.put("GUARANTOR_CHECK", "Guarantor / co-applicant check"); + PURPOSES.put("COLLECTION", "Collection / recovery"); + PURPOSES.put("OTHER", "Other"); + } + + /** CBS fields a REQUEST mapping can take its value from. */ + public static final Map CBS_FIELDS = new LinkedHashMap<>(); + static { + CBS_FIELDS.put("client.firstName", "Client first name"); + CBS_FIELDS.put("client.middleName", "Client middle name"); + CBS_FIELDS.put("client.lastName", "Client last name"); + CBS_FIELDS.put("client.fullName", "Client full / display name"); + CBS_FIELDS.put("client.dob", "Date of birth"); + CBS_FIELDS.put("client.gender", "Gender"); + CBS_FIELDS.put("client.pan", "PAN"); + CBS_FIELDS.put("client.aadhaarMasked", "Aadhaar (masked)"); + CBS_FIELDS.put("client.ckycNumber", "CKYC number"); + CBS_FIELDS.put("client.mobile", "Mobile number"); + CBS_FIELDS.put("client.email", "Email"); + CBS_FIELDS.put("client.addressLine1", "Address line 1"); + CBS_FIELDS.put("client.addressLine2", "Address line 2"); + CBS_FIELDS.put("client.city", "City / town"); + CBS_FIELDS.put("client.state", "State"); + CBS_FIELDS.put("client.pincode", "PIN code"); + CBS_FIELDS.put("client.cif", "CIF / client account number"); + CBS_FIELDS.put("client.id", "Client id"); + CBS_FIELDS.put("client.branch", "Branch name"); + CBS_FIELDS.put("enquiry.reference", "Enquiry reference number"); + CBS_FIELDS.put("enquiry.date", "Enquiry date (business date)"); + CBS_FIELDS.put("enquiry.amount", "Loan amount applied for"); + CBS_FIELDS.put("enquiry.purposeCode", "Enquiry purpose code"); + CBS_FIELDS.put("enquiry.purpose", "Enquiry purpose description"); + CBS_FIELDS.put("enquiry.applicationNo", "Loan application number"); + CBS_FIELDS.put("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 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 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 SENSITIVE_FIELDS = Set.of("client.pan", "client.mobile", "client.email", "client.aadhaarMasked"); + + private static final Map STATE_CODES = new LinkedHashMap<>(); + static { + String[][] states = { { "JAMMU AND KASHMIR", "01" }, { "HIMACHAL PRADESH", "02" }, { "PUNJAB", "03" }, { "CHANDIGARH", "04" }, + { "UTTARAKHAND", "05" }, { "UTTARANCHAL", "05" }, { "HARYANA", "06" }, { "DELHI", "07" }, { "RAJASTHAN", "08" }, + { "UTTAR PRADESH", "09" }, { "BIHAR", "10" }, { "SIKKIM", "11" }, { "ARUNACHAL PRADESH", "12" }, { "NAGALAND", "13" }, + { "MANIPUR", "14" }, { "MIZORAM", "15" }, { "TRIPURA", "16" }, { "MEGHALAYA", "17" }, { "ASSAM", "18" }, + { "WEST BENGAL", "19" }, { "JHARKHAND", "20" }, { "ODISHA", "21" }, { "ORISSA", "21" }, { "CHHATTISGARH", "22" }, + { "MADHYA PRADESH", "23" }, { "GUJARAT", "24" }, { "DAMAN AND DIU", "25" }, { "DADRA AND NAGAR HAVELI", "26" }, + { "MAHARASHTRA", "27" }, { "KARNATAKA", "29" }, { "GOA", "30" }, { "LAKSHADWEEP", "31" }, { "KERALA", "32" }, + { "TAMIL NADU", "33" }, { "PUDUCHERRY", "34" }, { "PONDICHERRY", "34" }, { "ANDAMAN AND NICOBAR ISLANDS", "35" }, + { "TELANGANA", "36" }, { "ANDHRA PRADESH", "37" }, { "LADAKH", "38" } }; + for (final String[] s : states) { + STATE_CODES.put(s[0], s[1]); + } + } + + private static final 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicClientProfileService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicClientProfileService.java new file mode 100644 index 000000000..2f654f1a1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicClientProfileService.java @@ -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 load(final Long clientId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id, c.firstname, c.middlename, c.lastname, c.fullname, c.display_name, c.date_of_birth, + g.code_value AS gender, c.mobile_no, c.email_address, c.account_no, c.status_enum, c.office_id, + o.name AS office_name, + (SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL + ORDER BY k.id DESC LIMIT 1) AS pan, + (SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id + AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar_masked, + (SELECT k.ckyc_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.ckyc_number IS NOT NULL + ORDER BY k.id DESC LIMIT 1) AS ckyc_number + FROM m_client c + LEFT JOIN m_code_value g ON g.id = c.gender_cv_id + LEFT JOIN m_office o ON o.id = c.office_id + WHERE c.id = ? + """, clientId); + if (rows.isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.cic.client.not.found", "Client " + clientId + " was not found."); + } + final Map c = rows.get(0); + final Map ctx = new LinkedHashMap<>(); + ctx.put("client.id", c.get("id")); + String first = str(c.get("firstname")); + String last = str(c.get("lastname")); + final String display = str(c.get("display_name")); + if (first == null && last == null && display != null) { + // An entity or a client captured with a single full name: split it the way bureaus expect. + final String[] parts = display.trim().split("\\s+"); + first = parts[0]; + last = parts.length > 1 ? parts[parts.length - 1] : null; + } + ctx.put("client.firstName", first); + ctx.put("client.middleName", str(c.get("middlename"))); + ctx.put("client.lastName", last); + ctx.put("client.fullName", display != null ? display : str(c.get("fullname"))); + ctx.put("client.dob", c.get("date_of_birth") == null ? null : c.get("date_of_birth").toString()); + ctx.put("client.gender", str(c.get("gender"))); + ctx.put("client.pan", str(c.get("pan"))); + ctx.put("client.aadhaarMasked", str(c.get("aadhaar_masked"))); + ctx.put("client.ckycNumber", str(c.get("ckyc_number"))); + ctx.put("client.mobile", str(c.get("mobile_no"))); + ctx.put("client.email", str(c.get("email_address"))); + ctx.put("client.cif", str(c.get("account_no"))); + ctx.put("client.branch", str(c.get("office_name"))); + ctx.put("client.officeId", c.get("office_id")); + ctx.put("client.status", c.get("status_enum")); + + try { + final List> addresses = jdbcTemplate.queryForList(""" + SELECT a.address_line_1, a.address_line_2, a.street, a.town_village, a.city, a.postal_code, + sp.code_value AS state_name + FROM m_client_address ca + JOIN m_address a ON a.id = ca.address_id + LEFT JOIN m_code_value sp ON sp.id = a.state_province_id + WHERE ca.client_id = ? AND (ca.is_active IS NULL OR ca.is_active = TRUE) + ORDER BY ca.id + LIMIT 1 + """, clientId); + if (!addresses.isEmpty()) { + final Map a = addresses.get(0); + ctx.put("client.addressLine1", first(str(a.get("address_line_1")), str(a.get("street")))); + ctx.put("client.addressLine2", str(a.get("address_line_2"))); + ctx.put("client.city", first(str(a.get("city")), str(a.get("town_village")))); + ctx.put("client.state", str(a.get("state_name"))); + ctx.put("client.pincode", str(a.get("postal_code"))); + } + } catch (final RuntimeException e) { + // A tenant whose address tables differ simply sends no address; the enquiry still works on PAN + DOB. + log.warn("CIC: could not read the address of client {}: {}", clientId, e.getMessage()); + } + return ctx; + } + + private static String str(final Object value) { + if (value == null) { + return null; + } + final String s = value.toString().trim(); + return s.isEmpty() ? null : s; + } + + private static String first(final String a, final String b) { + return a != null ? a : b; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConfigService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConfigService.java new file mode 100644 index 000000000..9da6ff3f0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConfigService.java @@ -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 bureauRow(final Long id) { + final List> 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> listBureaus(final boolean whitelistedOnly) { + final List> out = new ArrayList<>(); + for (final Map 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 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 publicView(final Map row) { + final Map 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 updateBureau(final Long id, final JsonObject in, final AppUser user) { + final Map 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 setWhitelisted(final Long id, final boolean whitelisted, final AppUser user) { + final Map 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 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 testConnection(final Long id) { + final Map 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 res = new LinkedHashMap<>(); + res.put("status", status); + res.put("message", message); + return res; + } + + // ── Endpoints and mappings ──────────────────────────────────────────────── + + public Map endpointRow(final Long bureauId, final String type) { + final List> 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> 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 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 e = endpointRow(bureauId, t); + final Map 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> mappingView(final List> rows) { + final List> out = new ArrayList<>(); + for (final Map m : rows) { + final Map 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 saveEndpoint(final Long bureauId, final String type, final JsonObject in, final AppUser user) { + final Map 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 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 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 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConsentService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConsentService.java new file mode 100644 index 000000000..a69009b60 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicConsentService.java @@ -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 create(final JsonObject in, final AppUser user, final String ipAddress) { + final Long clientId = in.has("clientId") && !in.get("clientId").isJsonNull() ? in.get("clientId").getAsLong() : null; + if (clientId == null) { + throw CicConfigService.fail("error.msg.cic.consent.client.required", "Client is required."); + } + final Map client = clientProfileService.load(clientId); + final Long bureauId = in.has("bureauId") && !in.get("bureauId").isJsonNull() ? in.get("bureauId").getAsLong() : null; + if (bureauId != null && jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_bureau WHERE id = ?", Integer.class, bureauId) == 0) { + throw CicConfigService.fail("error.msg.cic.bureau.not.found", "Credit bureau " + bureauId + " was not found."); + } + final String purpose = StringUtils.defaultIfBlank(CicConfigService.text(in, "purpose", "NEW_LOAN"), "NEW_LOAN").toUpperCase(); + if (!CicCatalog.PURPOSES.containsKey(purpose)) { + throw CicConfigService.fail("error.msg.cic.purpose.invalid", "Unknown purpose '" + purpose + "'."); + } + final String mode = StringUtils.defaultIfBlank(CicConfigService.text(in, "consentMode", "SIGNED_FORM"), "SIGNED_FORM").toUpperCase(); + if (!CicCatalog.CONSENT_MODES.contains(mode)) { + throw CicConfigService.fail("error.msg.cic.consent.mode.invalid", "Consent mode must be one of " + CicCatalog.CONSENT_MODES + "."); + } + final String documentRef = StringUtils.trimToNull(CicConfigService.text(in, "documentRef", null)); + final String otpReference = StringUtils.trimToNull(CicConfigService.text(in, "otpReference", null)); + if ("SIGNED_FORM".equals(mode) && documentRef == null) { + throw CicConfigService.fail("error.msg.cic.consent.document.required", + "A signed consent form needs its document reference (form number or uploaded document name)."); + } + if ("OTP".equals(mode) && otpReference == null) { + throw CicConfigService.fail("error.msg.cic.consent.otp.required", "An OTP consent needs the OTP / transaction reference."); + } + final boolean declared = in.has("customerDeclarationAccepted") && in.get("customerDeclarationAccepted").getAsBoolean(); + if (!declared) { + throw CicConfigService.fail("error.msg.cic.consent.declaration.required", + "Confirm that the customer has read and accepted the consent declaration."); + } + final int validityDays = in.has("validityDays") && !in.get("validityDays").isJsonNull() ? in.get("validityDays").getAsInt() : 30; + if (validityDays < 1 || validityDays > 365) { + throw CicConfigService.fail("error.msg.cic.consent.validity.invalid", "Consent validity must be between 1 and 365 days."); + } + final LocalDate from = DateUtils.getBusinessLocalDate(); + final LocalDate until = from.plusDays(validityDays - 1L); + final String ref = "CNS-" + from.format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + + String.format("%06d", ThreadLocalRandom.current().nextInt(1_000_000)); + final String consentText = StringUtils.defaultIfBlank(CicConfigService.text(in, "consentText", null), DEFAULT_TEXT); + + jdbcTemplate.update(""" + INSERT INTO m_cic_consent (consent_ref, client_id, client_name, application_no, bureau_id, purpose, consent_mode, consent_text, + document_ref, otp_reference, valid_from, valid_until, status, captured_by, captured_by_name, + captured_on, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?) + """, ref, clientId, client.get("client.fullName"), StringUtils.trimToNull(CicConfigService.text(in, "applicationNo", null)), + bureauId, purpose, mode, consentText, documentRef, otpReference, Date.valueOf(from), Date.valueOf(until), user.getId(), + user.getDisplayName(), Timestamp.from(Instant.now()), StringUtils.abbreviate(ipAddress, 64)); + final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_cic_consent WHERE consent_ref = ?", Long.class, ref); + return get(id); + } + + @Transactional + public Map revoke(final Long id, final String reason, final AppUser user) { + final Map c = get(id); + if (!"ACTIVE".equals(c.get("status"))) { + throw CicConfigService.fail("error.msg.cic.consent.not.active", "Only an active consent can be revoked (this one is " + c.get("status") + ")."); + } + if (StringUtils.isBlank(reason)) { + throw CicConfigService.fail("error.msg.cic.consent.reason.required", "Give the reason for revoking the consent."); + } + jdbcTemplate.update("UPDATE m_cic_consent SET status = 'REVOKED', revoked_reason = ?, revoked_by = ?, revoked_on = ? WHERE id = ?", + StringUtils.abbreviate(reason.trim(), 500), user.getId(), Timestamp.from(Instant.now()), id); + return get(id); + } + + public Map get(final Long id) { + final List> rows = jdbcTemplate.queryForList( + "SELECT c.*, b.code AS bureau_code FROM m_cic_consent c LEFT JOIN m_cic_bureau b ON b.id = c.bureau_id WHERE c.id = ?", id); + if (rows.isEmpty()) { + throw CicConfigService.fail("error.msg.cic.consent.not.found", "Consent " + id + " was not found."); + } + return view(rows.get(0)); + } + + public Map list(final Long clientId, final String status, final String search, final int offset, final int limit) { + final StringBuilder where = new StringBuilder(" WHERE 1 = 1"); + final List args = new ArrayList<>(); + if (clientId != null) { + where.append(" AND c.client_id = ?"); + args.add(clientId); + } + final LocalDate today = DateUtils.getBusinessLocalDate(); + if (StringUtils.isNotBlank(status)) { + switch (status.toUpperCase()) { + case "ACTIVE" -> { + where.append(" AND c.status = 'ACTIVE' AND c.valid_until >= ?"); + args.add(Date.valueOf(today)); + } + case "EXPIRED" -> { + where.append(" AND c.status = 'ACTIVE' AND c.valid_until < ?"); + args.add(Date.valueOf(today)); + } + default -> { + where.append(" AND c.status = ?"); + args.add(status.toUpperCase()); + } + } + } + if (StringUtils.isNotBlank(search)) { + where.append(" AND (LOWER(c.client_name) LIKE ? OR LOWER(c.consent_ref) LIKE ? OR LOWER(c.application_no) LIKE ?)"); + final String like = "%" + search.trim().toLowerCase() + "%"; + args.add(like); + args.add(like); + args.add(like); + } + final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_consent c" + where, Integer.class, args.toArray()); + final List pageArgs = new ArrayList<>(args); + pageArgs.add(limit); + pageArgs.add(offset); + final List> items = new ArrayList<>(); + for (final Map row : jdbcTemplate.queryForList( + "SELECT c.*, b.code AS bureau_code FROM m_cic_consent c LEFT JOIN m_cic_bureau b ON b.id = c.bureau_id" + where + + " ORDER BY c.id DESC LIMIT ? OFFSET ?", + pageArgs.toArray())) { + items.add(view(row)); + } + final Map res = new LinkedHashMap<>(); + res.put("totalFilteredRecords", total); + res.put("pageItems", items); + return res; + } + + /** + * The consent an enquiry runs under: the one named, or the latest active, unexpired consent of this client that + * covers this bureau (a consent with no bureau covers every bureau). + */ + public Map requireValid(final Long clientId, final Long bureauId, final Long consentId) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final List> rows; + if (consentId != null) { + rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_consent WHERE id = ?", consentId); + if (rows.isEmpty()) { + throw CicConfigService.fail("error.msg.cic.consent.not.found", "Consent " + consentId + " was not found."); + } + final Map c = rows.get(0); + if (((Number) c.get("client_id")).longValue() != clientId) { + throw CicConfigService.fail("error.msg.cic.consent.other.client", "Consent " + c.get("consent_ref") + " belongs to another client."); + } + if (c.get("bureau_id") != null && ((Number) c.get("bureau_id")).longValue() != bureauId) { + throw CicConfigService.fail("error.msg.cic.consent.other.bureau", "Consent " + c.get("consent_ref") + " was given for another bureau."); + } + if (!"ACTIVE".equals(c.get("status")) || ((Date) c.get("valid_until")).toLocalDate().isBefore(today)) { + throw CicConfigService.fail("error.msg.cic.consent.not.valid", "Consent " + c.get("consent_ref") + " is revoked or expired."); + } + return c; + } + rows = jdbcTemplate.queryForList(""" + SELECT * FROM m_cic_consent + WHERE client_id = ? AND status = 'ACTIVE' AND valid_until >= ? AND (bureau_id IS NULL OR bureau_id = ?) + ORDER BY id DESC LIMIT 1 + """, clientId, Date.valueOf(today), bureauId); + if (rows.isEmpty()) { + throw CicConfigService.fail("error.msg.cic.consent.missing", + "No valid credit bureau consent is on record for this customer. Capture the customer's consent first."); + } + return rows.get(0); + } + + private static Map view(final Map row) { + final Map v = new LinkedHashMap<>(); + v.put("id", row.get("id")); + v.put("consentRef", row.get("consent_ref")); + v.put("clientId", row.get("client_id")); + v.put("clientName", row.get("client_name")); + v.put("applicationNo", row.get("application_no")); + v.put("bureauId", row.get("bureau_id")); + v.put("bureauCode", row.get("bureau_code") == null ? "ANY" : row.get("bureau_code")); + v.put("purpose", row.get("purpose")); + v.put("consentMode", row.get("consent_mode")); + v.put("consentText", row.get("consent_text")); + v.put("documentRef", row.get("document_ref")); + v.put("otpReference", row.get("otp_reference")); + v.put("validFrom", row.get("valid_from") == null ? null : row.get("valid_from").toString()); + v.put("validUntil", row.get("valid_until") == null ? null : row.get("valid_until").toString()); + String status = (String) row.get("status"); + if ("ACTIVE".equals(status) && row.get("valid_until") instanceof Date d && d.toLocalDate().isBefore(DateUtils.getBusinessLocalDate())) { + status = "EXPIRED"; + } + v.put("status", status); + v.put("revokedReason", row.get("revoked_reason")); + v.put("revokedOn", row.get("revoked_on") == null ? null : row.get("revoked_on").toString()); + v.put("capturedByName", row.get("captured_by_name")); + v.put("capturedOn", row.get("captured_on") == null ? null : row.get("captured_on").toString()); + v.put("ipAddress", row.get("ip_address")); + return v; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicEnquiryService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicEnquiryService.java new file mode 100644 index 000000000..49e3a1f6b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicEnquiryService.java @@ -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 headers, JsonObject body, + JsonObject maskedBody, List missingMandatory) {} + + // ── Request building ────────────────────────────────────────────────────── + + private Map context(final Map bureau, final Long clientId, final BigDecimal amount, final String purpose, + final String applicationNo, final String reference) { + final Map ctx = new LinkedHashMap<>(clientProfileService.load(clientId)); + ctx.put("enquiry.reference", reference); + ctx.put("enquiry.date", DateUtils.getBusinessLocalDate().toString()); + ctx.put("enquiry.amount", amount); + ctx.put("enquiry.purposeCode", purpose); + ctx.put("enquiry.purpose", CicCatalog.PURPOSES.get(purpose)); + ctx.put("enquiry.applicationNo", applicationNo); + ctx.put("bureau.memberCode", bureau.get("member_code")); + ctx.put("bureau.username", bureau.get("username")); + return ctx; + } + + public BuiltRequest build(final Map bureau, final Map endpoint, final Map ctx) { + final Long endpointId = ((Number) endpoint.get("id")).longValue(); + final JsonElement base = JsonParser.parseString(StringUtils.defaultIfBlank((String) endpoint.get("base_body"), "{}")); + final JsonObject body = base.isJsonObject() ? base.getAsJsonObject() : new JsonObject(); + final JsonObject masked = body.deepCopy(); + final List missing = new ArrayList<>(); + for (final Map m : configService.mappingRows(endpointId, "REQUEST")) { + final String source = (String) m.get("source_field"); + final String target = (String) m.get("target_field"); + Object raw = source == null ? null : ctx.get(source); + if ((raw == null || raw.toString().isBlank()) && m.get("default_value") != null) { + raw = m.get("default_value"); + } + JsonElement value; + try { + value = CicCatalog.applyRequestTransform((String) m.get("transform"), raw); + } catch (final RuntimeException e) { + value = null; + } + if (value == null) { + if (Boolean.TRUE.equals(m.get("is_mandatory"))) { + missing.add(CicCatalog.CBS_FIELDS.getOrDefault(source, String.valueOf(source)) + " (" + target + ")"); + } + continue; + } + CicJsonPath.set(body, target, value); + final boolean sensitive = source != null && CicCatalog.SENSITIVE_FIELDS.contains(source); + CicJsonPath.set(masked, target, sensitive && value.isJsonPrimitive() ? new JsonPrimitive(CicCatalog.mask(value.getAsString())) : value); + } + final Map headers = new LinkedHashMap<>(); + final JsonElement hj = JsonParser.parseString(StringUtils.defaultIfBlank((String) endpoint.get("headers_json"), "{}")); + if (hj.isJsonObject()) { + for (final Map.Entry h : hj.getAsJsonObject().entrySet()) { + headers.put(h.getKey(), resolvePlaceholders(h.getValue().getAsString(), ctx)); + } + } + final String url = CicHttpGateway.joinUrl((String) bureau.get("base_url"), (String) endpoint.get("path")); + return new BuiltRequest(url, (String) endpoint.get("http_method"), (String) endpoint.get("content_type"), headers, body, masked, + missing); + } + + private static String resolvePlaceholders(final String template, final Map ctx) { + final Matcher m = PLACEHOLDER.matcher(template); + final StringBuilder sb = new StringBuilder(); + while (m.find()) { + final Object v = ctx.get(m.group(1)); + m.appendReplacement(sb, Matcher.quoteReplacement(v == null ? "" : v.toString())); + } + m.appendTail(sb); + return sb.toString(); + } + + private Map activeEndpoint(final Map bureau, final String enquiryType) { + final Map endpoint = configService.endpointRow(((Number) bureau.get("id")).longValue(), enquiryType); + if (endpoint == null || !Boolean.TRUE.equals(endpoint.get("is_active"))) { + throw CicConfigService.fail("error.msg.cic.endpoint.inactive", + bureau.get("code") + " has no active " + enquiryType + " endpoint. Configure it under API Mapping."); + } + return endpoint; + } + + /** Dry run: the request exactly as it would go to the bureau (sensitive values masked), with what is missing. */ + public Map preview(final Long bureauId, final JsonObject in) { + final Map bureau = configService.bureauRow(bureauId); + final Long clientId = longOf(in, "clientId"); + if (clientId == null) { + throw CicConfigService.fail("error.msg.cic.client.required", "Choose a client for the preview."); + } + final String enquiryType = StringUtils.defaultIfBlank(CicConfigService.text(in, "enquiryType", "CONSUMER_ENQUIRY"), "CONSUMER_ENQUIRY") + .toUpperCase(); + final String purpose = StringUtils.defaultIfBlank(CicConfigService.text(in, "purpose", "NEW_LOAN"), "NEW_LOAN").toUpperCase(); + final BigDecimal amount = in.has("loanAmount") && !in.get("loanAmount").isJsonNull() ? in.get("loanAmount").getAsBigDecimal() + : new BigDecimal("100000"); + final Map endpoint = configService.endpointRow(bureauId, enquiryType); + if (endpoint == null) { + throw CicConfigService.fail("error.msg.cic.endpoint.missing", bureau.get("code") + " has no " + enquiryType + " endpoint yet."); + } + final Map ctx = context(bureau, clientId, amount, purpose, CicConfigService.text(in, "applicationNo", null), + "PREVIEW-" + System.currentTimeMillis()); + final BuiltRequest req = build(bureau, endpoint, ctx); + final Map res = new LinkedHashMap<>(); + res.put("environment", bureau.get("environment")); + res.put("url", "SIMULATOR".equals(bureau.get("environment")) ? "(simulator - no network call)" : req.url()); + res.put("method", req.method()); + res.put("headers", req.headers()); + res.put("authentication", bureau.get("auth_type")); + res.put("body", gson.toJson(req.maskedBody())); + res.put("missingMandatory", req.missingMandatory()); + res.put("clientData", maskedContext(ctx)); + return res; + } + + private static Map maskedContext(final Map ctx) { + final Map out = new LinkedHashMap<>(); + ctx.forEach((k, v) -> { + if (k.startsWith("client.") && !"client.officeId".equals(k) && !"client.status".equals(k)) { + out.put(k, v != null && CicCatalog.SENSITIVE_FIELDS.contains(k) ? CicCatalog.mask(v.toString()) : v); + } + }); + return out; + } + + /** What the screens show about a client before an enquiry: the data that will go out, and the consent on file. */ + public Map clientProfile(final Long clientId, final Long bureauId) { + final Map ctx = clientProfileService.load(clientId); + final Map res = new LinkedHashMap<>(); + res.put("clientId", clientId); + res.put("data", maskedContext(ctx)); + Map consent = null; + if (bureauId != null) { + try { + consent = consentService.requireValid(clientId, bureauId, null); + } catch (final RuntimeException e) { + consent = null; + } + } else { + final List> rows = jdbcTemplate.queryForList(""" + SELECT * FROM m_cic_consent WHERE client_id = ? AND status = 'ACTIVE' AND valid_until >= ? ORDER BY id DESC LIMIT 1 + """, clientId, Date.valueOf(DateUtils.getBusinessLocalDate())); + consent = rows.isEmpty() ? null : rows.get(0); + } + Map consentView = null; + if (consent != null) { + consentView = new LinkedHashMap<>(); + consentView.put("id", consent.get("id")); + consentView.put("consentRef", consent.get("consent_ref")); + consentView.put("validUntil", String.valueOf(consent.get("valid_until"))); + consentView.put("consentMode", consent.get("consent_mode")); + consentView.put("bureauId", consent.get("bureau_id")); + } + res.put("validConsent", consentView); + final List> last = jdbcTemplate.queryForList(""" + SELECT e.id, e.enquiry_ref, e.bureau_code, e.status, e.score, e.requested_on FROM m_cic_enquiry e + WHERE e.client_id = ? ORDER BY e.id DESC LIMIT 5 + """, clientId); + final List> recent = new ArrayList<>(); + for (final Map r : last) { + final Map v = new LinkedHashMap<>(); + v.put("id", r.get("id")); + v.put("enquiryRef", r.get("enquiry_ref")); + v.put("bureauCode", r.get("bureau_code")); + v.put("status", r.get("status")); + v.put("score", r.get("score")); + v.put("requestedOn", String.valueOf(r.get("requested_on"))); + recent.add(v); + } + res.put("recentEnquiries", recent); + return res; + } + + // ── Enquiry ─────────────────────────────────────────────────────────────── + + public Map enquire(final JsonObject in, final AppUser user) { + final Long clientId = longOf(in, "clientId"); + if (clientId == null) { + throw CicConfigService.fail("error.msg.cic.client.required", "Choose the customer to enquire on."); + } + Long bureauId = longOf(in, "bureauId"); + if (bureauId == null) { + final List def = jdbcTemplate.queryForList( + "SELECT id FROM m_cic_bureau WHERE whitelisted = TRUE ORDER BY is_default DESC, id LIMIT 1", Long.class); + if (def.isEmpty()) { + throw CicConfigService.fail("error.msg.cic.none.whitelisted", + "No credit bureau is whitelisted for this bank. Whitelist one under CIC Configuration."); + } + bureauId = def.get(0); + } + final Map bureau = configService.bureauRow(bureauId); + if (!Boolean.TRUE.equals(bureau.get("whitelisted"))) { + throw CicConfigService.fail("error.msg.cic.bureau.not.whitelisted", + bureau.get("code") + " is not whitelisted for this bank. Only whitelisted bureaus can be enquired."); + } + configService.assertReadyForLive(bureau); + + final String enquiryType = StringUtils.defaultIfBlank(CicConfigService.text(in, "enquiryType", "CONSUMER_ENQUIRY"), "CONSUMER_ENQUIRY") + .toUpperCase(); + if (!CicCatalog.ENDPOINT_TYPES.contains(enquiryType)) { + throw CicConfigService.fail("error.msg.cic.endpoint.type.invalid", "Enquiry type must be one of " + CicCatalog.ENDPOINT_TYPES + "."); + } + final String purpose = StringUtils.defaultIfBlank(CicConfigService.text(in, "purpose", "NEW_LOAN"), "NEW_LOAN").toUpperCase(); + if (!CicCatalog.PURPOSES.containsKey(purpose)) { + throw CicConfigService.fail("error.msg.cic.purpose.invalid", "Unknown enquiry purpose '" + purpose + "'."); + } + final BigDecimal amount = in.has("loanAmount") && !in.get("loanAmount").isJsonNull() ? in.get("loanAmount").getAsBigDecimal() : null; + if (amount == null || amount.signum() <= 0) { + throw CicConfigService.fail("error.msg.cic.amount.required", "Enter the loan / credit amount the enquiry is for."); + } + final String applicationNo = StringUtils.trimToNull(CicConfigService.text(in, "applicationNo", null)); + final boolean forceFresh = in.has("forceFresh") && !in.get("forceFresh").isJsonNull() && in.get("forceFresh").getAsBoolean(); + + final Map consent = consentService.requireValid(clientId, bureauId, longOf(in, "consentId")); + final Long consentId = ((Number) consent.get("id")).longValue(); + final Map endpoint = activeEndpoint(bureau, enquiryType); + + final LocalDate today = DateUtils.getBusinessLocalDate(); + final String reference = "CIC" + today.format(DateTimeFormatter.ofPattern("yyMMdd")) + + String.format("%08d", ThreadLocalRandom.current().nextInt(100_000_000)); + final Map ctx = context(bureau, clientId, amount, purpose, applicationNo, reference); + + final Row row = new Row(); + row.reference = reference; + row.bureau = bureau; + row.clientId = clientId; + row.clientName = (String) ctx.get("client.fullName"); + row.officeId = ctx.get("client.officeId"); + row.applicationNo = applicationNo; + row.consentId = consentId; + row.enquiryType = enquiryType; + row.purpose = purpose; + row.amount = amount; + row.user = user; + + // Reuse a recent successful report of the same bureau instead of paying for, and leaving, another hard enquiry. + final int validityDays = ((Number) bureau.get("report_validity_days")).intValue(); + if (!forceFresh && validityDays > 0) { + final List> recent = jdbcTemplate.queryForList(""" + SELECT * FROM m_cic_enquiry + WHERE client_id = ? AND bureau_id = ? AND enquiry_type = ? AND status = 'SUCCESS' AND requested_on >= ? + ORDER BY id DESC LIMIT 1 + """, clientId, bureauId, enquiryType, Timestamp.from(Instant.now().minusSeconds(validityDays * 86400L))); + if (!recent.isEmpty()) { + final Map src = recent.get(0); + row.status = "REUSED"; + row.score = src.get("score") == null ? null : ((Number) src.get("score")).intValue(); + row.resultJson = (String) src.get("result_json"); + row.requestMasked = (String) src.get("request_masked"); + row.responseRaw = (String) src.get("response_raw"); + row.reusedFromId = ((Number) src.get("id")).longValue(); + row.charge = BigDecimal.ZERO; + row.errorMessage = "Report " + src.get("enquiry_ref") + " of " + src.get("requested_on") + " reused (within " + validityDays + + " days). Pull fresh to override."; + row.durationMs = 0; + return insert(row); + } + } + + final BuiltRequest req = build(bureau, endpoint, ctx); + row.requestMasked = gson.toJson(Map.of("method", req.method(), "url", req.url(), "headers", req.headers(), "body", req.maskedBody())); + row.charge = (BigDecimal) bureau.get("enquiry_charge"); + if (!req.missingMandatory().isEmpty()) { + row.status = "FAILED"; + row.errorMessage = "Customer data missing for mandatory bureau fields: " + String.join(", ", req.missingMandatory()); + row.charge = BigDecimal.ZERO; + row.durationMs = 0; + return insert(row); + } + + // Call the bureau, or the simulator. + final long started = System.currentTimeMillis(); + JsonElement response; + if ("SIMULATOR".equals(bureau.get("environment"))) { + response = CicSimulator.respond((String) bureau.get("code"), ctx, today, reference); + row.httpStatus = 200; + row.responseRaw = gson.toJson(response); + } else { + final CicHttpGateway.HttpResult r = gateway.send(bureau, req.method(), req.url(), req.headers(), req.contentType(), + "GET".equalsIgnoreCase(req.method()) ? null : gson.toJson(req.body())); + row.httpStatus = r.status(); + row.responseRaw = r.body(); + if (!r.ok()) { + row.status = "FAILED"; + row.errorMessage = r.status() == 0 ? "Bureau not reachable: " + r.error() + : "Bureau returned HTTP " + r.status() + ": " + StringUtils.abbreviate(StringUtils.defaultString(r.body()), 500); + row.durationMs = (int) (System.currentTimeMillis() - started); + return insert(row); + } + try { + response = JsonParser.parseString(StringUtils.defaultIfBlank(r.body(), "{}")); + } catch (final RuntimeException e) { + row.status = "FAILED"; + row.errorMessage = "Bureau response is not valid JSON: " + e.getMessage(); + row.durationMs = (int) (System.currentTimeMillis() - started); + return insert(row); + } + } + row.durationMs = (int) (System.currentTimeMillis() - started); + + final JsonObject result = parse(((Number) endpoint.get("id")).longValue(), response); + row.resultJson = gson.toJson(result); + row.score = result.has("score") && result.get("score").isJsonPrimitive() ? scoreOf(result.get("score")) : null; + final boolean noHit = result.has("hit") && result.get("hit").isJsonPrimitive() && !truthy(result.get("hit")); + if (result.has("errorCode") && !result.get("errorCode").isJsonNull() && StringUtils.isNotBlank(result.get("errorCode").getAsString())) { + row.status = "FAILED"; + row.errorMessage = "Bureau error " + result.get("errorCode").getAsString() + + (result.has("errorMessage") && !result.get("errorMessage").isJsonNull() ? ": " + result.get("errorMessage").getAsString() : ""); + } else if (noHit) { + row.status = "NO_HIT"; + row.errorMessage = "No record found at " + bureau.get("code") + " (new to credit)."; + } else { + row.status = "SUCCESS"; + } + return insert(row); + } + + private JsonObject parse(final Long endpointId, final JsonElement response) { + final JsonObject result = new JsonObject(); + for (final Map m : configService.mappingRows(endpointId, "RESPONSE")) { + JsonElement value = null; + try { + value = CicJsonPath.get(response, (String) m.get("source_field")); + } catch (final RuntimeException e) { + log.debug("CIC: response path {} unreadable: {}", m.get("source_field"), e.getMessage()); + } + if (value == null && m.get("default_value") != null) { + value = new JsonPrimitive((String) m.get("default_value")); + } + if (value != null) { + result.add((String) m.get("target_field"), CicCatalog.applyResponseTransform((String) m.get("transform"), value)); + } + } + return result; + } + + private static Integer scoreOf(final JsonElement e) { + try { + final String digits = e.getAsString().replaceAll("[^0-9\\-]", ""); + return digits.isEmpty() ? null : Integer.valueOf(new BigDecimal(e.getAsString().replaceAll("[^0-9.\\-]", "")).intValue()); + } catch (final RuntimeException ex) { + return null; + } + } + + private static boolean truthy(final JsonElement e) { + final String s = e.getAsString(); + return "true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s) || "HIT".equalsIgnoreCase(s); + } + + /** Everything one enquiry row carries; written once, at the end. */ + private static final class Row { + + private String reference; + private Map bureau; + private Long clientId; + private String clientName; + private Object officeId; + private String applicationNo; + private Long consentId; + private String enquiryType; + private String purpose; + private BigDecimal amount; + private String status; + private Integer score; + private Integer httpStatus; + private String errorMessage; + private String requestMasked; + private String responseRaw; + private String resultJson; + private Long reusedFromId; + private BigDecimal charge; + private int durationMs; + private AppUser user; + } + + private Map insert(final Row r) { + jdbcTemplate.update(""" + INSERT INTO m_cic_enquiry (enquiry_ref, bureau_id, bureau_code, client_id, client_name, application_no, consent_id, enquiry_type, + purpose, loan_amount, status, score, http_status, error_message, request_masked, response_raw, + result_json, reused_from_id, charge_amount, environment, office_id, requested_by, requested_by_name, + requested_on, duration_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, r.reference, r.bureau.get("id"), r.bureau.get("code"), r.clientId, r.clientName, r.applicationNo, r.consentId, + r.enquiryType, r.purpose, r.amount, r.status, r.score, r.httpStatus, StringUtils.abbreviate(r.errorMessage, 2000), + r.requestMasked, r.responseRaw, r.resultJson, r.reusedFromId, r.charge, r.bureau.get("environment"), + r.officeId instanceof Number n ? n.longValue() : null, r.user.getId(), r.user.getDisplayName(), Timestamp.from(Instant.now()), + r.durationMs); + final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_cic_enquiry WHERE enquiry_ref = ?", Long.class, r.reference); + log.info("CIC enquiry {} on client {} at {}: {} (score {})", r.reference, r.clientId, r.bureau.get("code"), r.status, r.score); + return get(id); + } + + // ── Reads ──────────────────────────────────────────────────────────────── + + public Map get(final Long id) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT e.*, c.consent_ref, b.name AS bureau_name, src.enquiry_ref AS reused_from_ref + FROM m_cic_enquiry e + LEFT JOIN m_cic_consent c ON c.id = e.consent_id + LEFT JOIN m_cic_bureau b ON b.id = e.bureau_id + LEFT JOIN m_cic_enquiry src ON src.id = e.reused_from_id + WHERE e.id = ? + """, id); + if (rows.isEmpty()) { + throw CicConfigService.fail("error.msg.cic.enquiry.not.found", "Enquiry " + id + " was not found."); + } + final Map v = summaryView(rows.get(0)); + final Map row = rows.get(0); + v.put("consentRef", row.get("consent_ref")); + v.put("bureauName", row.get("bureau_name")); + v.put("reusedFromRef", row.get("reused_from_ref")); + v.put("httpStatus", row.get("http_status")); + v.put("requestMasked", row.get("request_masked")); + v.put("responseRaw", row.get("response_raw")); + v.put("result", row.get("result_json") == null ? null : JsonParser.parseString((String) row.get("result_json"))); + return v; + } + + private static Map summaryView(final Map row) { + final Map v = new LinkedHashMap<>(); + v.put("id", row.get("id")); + v.put("enquiryRef", row.get("enquiry_ref")); + v.put("bureauId", row.get("bureau_id")); + v.put("bureauCode", row.get("bureau_code")); + v.put("clientId", row.get("client_id")); + v.put("clientName", row.get("client_name")); + v.put("applicationNo", row.get("application_no")); + v.put("enquiryType", row.get("enquiry_type")); + v.put("purpose", row.get("purpose")); + v.put("loanAmount", row.get("loan_amount")); + v.put("status", row.get("status")); + v.put("score", row.get("score")); + v.put("errorMessage", row.get("error_message")); + v.put("chargeAmount", row.get("charge_amount")); + v.put("environment", row.get("environment")); + v.put("requestedByName", row.get("requested_by_name")); + v.put("requestedOn", row.get("requested_on") == null ? null : row.get("requested_on").toString()); + v.put("durationMs", row.get("duration_ms")); + return v; + } + + public Map list(final Long clientId, final Long bureauId, final String status, final String fromDate, final String toDate, + final String search, final int offset, final int limit) { + final StringBuilder where = new StringBuilder(" WHERE 1 = 1"); + final List args = new ArrayList<>(); + if (clientId != null) { + where.append(" AND e.client_id = ?"); + args.add(clientId); + } + if (bureauId != null) { + where.append(" AND e.bureau_id = ?"); + args.add(bureauId); + } + if (StringUtils.isNotBlank(status)) { + where.append(" AND e.status = ?"); + args.add(status.trim().toUpperCase()); + } + if (StringUtils.isNotBlank(fromDate)) { + where.append(" AND e.requested_on >= ?"); + args.add(Timestamp.valueOf(LocalDate.parse(fromDate).atStartOfDay())); + } + if (StringUtils.isNotBlank(toDate)) { + where.append(" AND e.requested_on < ?"); + args.add(Timestamp.valueOf(LocalDate.parse(toDate).plusDays(1).atStartOfDay())); + } + if (StringUtils.isNotBlank(search)) { + where.append(" AND (LOWER(e.client_name) LIKE ? OR LOWER(e.enquiry_ref) LIKE ? OR LOWER(e.application_no) LIKE ?)"); + final String like = "%" + search.trim().toLowerCase() + "%"; + args.add(like); + args.add(like); + args.add(like); + } + final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_enquiry e" + where, Integer.class, args.toArray()); + final List pageArgs = new ArrayList<>(args); + pageArgs.add(limit); + pageArgs.add(offset); + final List> items = new ArrayList<>(); + for (final Map row : jdbcTemplate + .queryForList("SELECT e.* FROM m_cic_enquiry e" + where + " ORDER BY e.id DESC LIMIT ? OFFSET ?", pageArgs.toArray())) { + items.add(summaryView(row)); + } + final Map res = new LinkedHashMap<>(); + res.put("totalFilteredRecords", total); + res.put("pageItems", items); + return res; + } + + public Map dashboard() { + final Timestamp since30 = Timestamp.from(Instant.now().minusSeconds(30L * 86400)); + final Timestamp todayStart = Timestamp.valueOf(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).atStartOfDay()); + final Map res = new LinkedHashMap<>(); + res.put("enquiriesToday", count("SELECT COUNT(*) FROM m_cic_enquiry WHERE requested_on >= ?", todayStart)); + res.put("enquiriesLast30Days", count("SELECT COUNT(*) FROM m_cic_enquiry WHERE requested_on >= ?", since30)); + final Map byStatus = new LinkedHashMap<>(); + for (final Map r : jdbcTemplate + .queryForList("SELECT status, COUNT(*) AS n FROM m_cic_enquiry WHERE requested_on >= ? GROUP BY status", since30)) { + byStatus.put((String) r.get("status"), r.get("n")); + } + res.put("byStatusLast30Days", byStatus); + res.put("averageScoreLast30Days", jdbcTemplate.queryForObject( + "SELECT AVG(score) FROM m_cic_enquiry WHERE status IN ('SUCCESS','REUSED') AND score IS NOT NULL AND requested_on >= ?", + BigDecimal.class, since30)); + res.put("chargesLast30Days", jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(charge_amount), 0) FROM m_cic_enquiry WHERE requested_on >= ?", BigDecimal.class, since30)); + final List> byBureau = new ArrayList<>(); + for (final Map r : jdbcTemplate.queryForList(""" + SELECT b.id, b.code, b.name, b.whitelisted, b.environment, b.is_default, + (SELECT COUNT(*) FROM m_cic_enquiry e WHERE e.bureau_id = b.id AND e.requested_on >= ?) AS enquiries, + (SELECT COUNT(*) FROM m_cic_enquiry e WHERE e.bureau_id = b.id AND e.requested_on >= ? AND e.status = 'FAILED') AS failed + FROM m_cic_bureau b ORDER BY b.is_default DESC, b.id + """, since30, since30)) { + final Map v = new LinkedHashMap<>(); + v.put("id", r.get("id")); + v.put("code", r.get("code")); + v.put("name", r.get("name")); + v.put("whitelisted", r.get("whitelisted")); + v.put("environment", r.get("environment")); + v.put("isDefault", r.get("is_default")); + v.put("enquiries", r.get("enquiries")); + v.put("failed", r.get("failed")); + byBureau.add(v); + } + res.put("bureaus", byBureau); + res.put("activeConsents", count("SELECT COUNT(*) FROM m_cic_consent WHERE status = 'ACTIVE' AND valid_until >= ?", + Date.valueOf(DateUtils.getBusinessLocalDate()))); + final List> bands = new ArrayList<>(); + final int[][] ranges = { { 300, 549 }, { 550, 649 }, { 650, 699 }, { 700, 749 }, { 750, 900 } }; + final String[] labels = { "Poor (300-549)", "Fair (550-649)", "Good (650-699)", "Very good (700-749)", "Excellent (750+)" }; + for (int i = 0; i < ranges.length; i++) { + final Map band = new LinkedHashMap<>(); + band.put("label", labels[i]); + band.put("count", count("SELECT COUNT(*) FROM m_cic_enquiry WHERE status = 'SUCCESS' AND score BETWEEN ? AND ? AND requested_on >= ?", + ranges[i][0], ranges[i][1], since30)); + bands.add(band); + } + res.put("scoreBandsLast30Days", bands); + res.put("recent", list(null, null, null, null, null, null, 0, 10).get("pageItems")); + return res; + } + + private Integer count(final String sql, final Object... args) { + return jdbcTemplate.queryForObject(sql, Integer.class, args); + } + + private static Long longOf(final JsonObject in, final String key) { + return in != null && in.has(key) && !in.get(key).isJsonNull() && !in.get(key).getAsString().isBlank() ? in.get(key).getAsLong() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicHttpGateway.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicHttpGateway.java new file mode 100644 index 000000000..0b64a6d7d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicHttpGateway.java @@ -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 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 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 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 bureau, final String method, final String url, final Map 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 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicJsonPath.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicJsonPath.java new file mode 100644 index 000000000..3198ce3e8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicJsonPath.java @@ -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 indexes) {} + + private static List parse(final String path) { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("Empty JSON path"); + } + final List 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 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 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; + } + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicSimulator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicSimulator.java new file mode 100644 index 000000000..ab5c0ac10 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cic/service/CicSimulator.java @@ -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 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; + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 959cdfcfa..324be6ec3 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -178,4 +178,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_cic_credit_bureau_module.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_cic_credit_bureau_module.xml new file mode 100644 index 000000000..64070c45b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_cic_credit_bureau_module.xml @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_cic_bureau + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_cic_bureau_endpoint + + + 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 + + + + + + + SELECT COUNT(*) FROM m_cic_field_mapping + + + 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 + + + + + + + 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'); + + + 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) + + + + + + + + SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_CREDIT_BUREAU' + + + + +