Compare commits

...
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 e4a2c00d75 feat(cic): switch CIC module to enquiry-alert service
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Remove CIBIL enquiry/consent flows; add alert fetch (manual + scheduled),
customer matching, fetch-run log, dashboard and ENQUIRY_ALERT mapping
(Liquibase 0605).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:14:18 +05:30
niteshandClaude Opus 5.5 a8f8aa39b7 feat(cic): credit bureau (CIBIL / CIC) enquiry module
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Whitelist and configure CICs (CIBIL, Experian, Equifax, CRIF) with encrypted
credentials, UI-driven request/response API mapping, consent register,
enquiries with report reuse, simulator mode, and a MENU_CREDIT_BUREAU menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:17:50 +05:30
niteshandClaude Opus 5.5 3a320b9c09 feat(fixed-deposits): FD funding rules and no FD withdrawals from clerk/cashier requests
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
New FixedDepositFundingValidator, used on every path money can reach an FD:
- an FD is funded once, with exactly its principal: it must be active and not matured,
  hold no deposit yet, and the amount must equal its deposit amount;
- when funded from a Savings/Current account (clerk Bank/Fund Transfer), that account
  must be a Savings or Current account, active, not debit-frozen (sub-status BLOCK /
  BLOCK_DEBIT), out of lock-in, belong to the FD's client or be held jointly with them,
  and have enough available balance after holds, lien, clearing holds and minimum
  balance (plus overdraft for current accounts).

Applied in:
- FD deposit command (counter / FD screen / approved cashier deposit requests);
- cashier deposit request submit, so the counter sees the reason at once;
- clerk Disbursement / Collection / Fund Transfer / Bank Transfer, at submit and again
  at approval before the request is locked; one request funds FDs of one customer, an
  FD appears once, and an FD can never be a debit leg. After posting, each funded FD's
  maturity date and amount are recomputed (new
  DepositAccountWritePlatformService.recalculateFixedDepositMaturity), since the direct
  book credit does not do it;
- clerk withdrawal request submit refuses an FD: money leaves an FD only through its
  maturity or premature closure.

Previously a direct book debit in Collection / Bank Transfer could take money out of an
FD, bypassing the FD's own withdrawal refusal, and an FD could be credited any number of
times for any amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Opus 5.5 8ba0420398 feat(deposits): allow several FD/RD accounts on the same product for one client
A client may now open any number of Fixed Deposit and Recurring Deposit accounts on
the same product, even while an earlier one is still active. The one-active-account-
per-product check is removed from submitFDApplication and submitRDApplication only;
the same rule on plain Savings and Current accounts is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
19 changed files with 3473 additions and 22 deletions
@@ -92,7 +92,7 @@ public class TenantMenuAccessApiResource {
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT", "MENU_CREDIT_BUREAU");
/**
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
@@ -56,6 +56,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -98,6 +99,7 @@ public class CashierDepositRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
@@ -155,6 +157,11 @@ public class CashierDepositRequestApiResource {
throw apiError(400, "Amount must be greater than zero");
}
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
// An FD is funded once, with exactly its principal. Told here at the counter rather than when the
// manager approves; the FD deposit command checks the same again at posting.
if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) {
fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount);
}
validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue);
@@ -0,0 +1,319 @@
/**
* 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.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.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.CicAlertService;
import org.apache.fineract.portfolio.cic.service.CicCatalog;
import org.apache.fineract.portfolio.cic.service.CicConfigService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* CIC / credit bureau: bureau whitelist and configuration, API mapping, and the enquiry alerts the CICs send when one of
* the bank's customers is enquired by another lender.
*
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICALERT (dashboard and register),
* UPDATE_CICALERT (work an alert), FETCH_CICALERT (fetch now / import a file).
*/
@Path("/v1/cic")
@Component
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping and enquiry alerts.")
@RequiredArgsConstructor
public class CicApiResource {
private final PlatformSecurityContext context;
private final CicConfigService configService;
private final CicAlertService alertService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── Catalog ───────────────────────────────────────────────────────────────
@GET
@Path("catalog")
@Produces(MediaType.APPLICATION_JSON)
public String catalog() {
context.authenticatedUser();
final Map<String, Object> c = new LinkedHashMap<>();
c.put("environments", CicCatalog.ENVIRONMENTS);
c.put("authTypes", CicCatalog.AUTH_TYPES);
c.put("endpointTypes", CicCatalog.ENDPOINT_TYPES);
c.put("httpMethods", CicCatalog.HTTP_METHODS);
c.put("cbsFields", CicCatalog.CBS_FIELDS);
c.put("resultFields", CicCatalog.RESULT_FIELDS);
c.put("rootFields", CicCatalog.ROOT_FIELDS);
c.put("transforms", CicCatalog.TRANSFORMS);
c.put("alertStatuses", CicCatalog.ALERT_STATUSES);
c.put("matchTypes", CicCatalog.MATCH_TYPES);
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_CICALERT");
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(alertService.preview(id, json(body)));
}
@POST
@Path("bureaus/{id}/parse-test")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String parseTest(@PathParam("id") final Long id, final String body) {
require("READ_CICBUREAU");
return gson.toJson(alertService.parseTest(id, json(body)));
}
// ── Fetch / import ────────────────────────────────────────────────────────
@POST
@Path("bureaus/{id}/fetch")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String fetch(@PathParam("id") final Long id, final String body) {
final AppUser user = require("FETCH_CICALERT");
final JsonObject in = json(body);
return gson.toJson(alertService.fetch(id, text(in, "fromDate"), text(in, "toDate"), "MANUAL", user));
}
@POST
@Path("bureaus/{id}/import")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String importFile(@PathParam("id") final Long id, final String body) {
final AppUser user = require("FETCH_CICALERT");
return gson.toJson(alertService.importFile(id, json(body), user));
}
@GET
@Path("fetch-runs")
@Produces(MediaType.APPLICATION_JSON)
public String fetchRuns(@QueryParam("bureauId") final Long bureauId, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
requireAny("READ_CICALERT", "READ_CICBUREAU");
return gson.toJson(alertService.fetchRuns(bureauId, Math.max(0, offset), clamp(limit)));
}
@GET
@Path("fetch-runs/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String fetchRun(@PathParam("id") final Long id) {
requireAny("READ_CICALERT", "READ_CICBUREAU");
return gson.toJson(alertService.fetchRun(id, true));
}
// ── Alerts ────────────────────────────────────────────────────────────────
@GET
@Path("dashboard")
@Produces(MediaType.APPLICATION_JSON)
public String dashboard(@QueryParam("days") @DefaultValue("30") final int days) {
require("READ_CICALERT");
return gson.toJson(alertService.dashboard(Math.max(1, Math.min(365, days))));
}
@GET
@Path("alerts")
@Produces(MediaType.APPLICATION_JSON)
public String alerts(@QueryParam("status") final String status, @QueryParam("match") final String match,
@QueryParam("clientId") final String clientId, @QueryParam("bureauId") final String bureauId,
@QueryParam("officeId") final String officeId, @QueryParam("loanType") final String loanType,
@QueryParam("institutionType") final String institutionType, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate, @QueryParam("minAmount") final String minAmount,
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
require("READ_CICALERT");
final JsonObject f = new JsonObject();
f.addProperty("status", status);
f.addProperty("match", match);
f.addProperty("clientId", clientId);
f.addProperty("bureauId", bureauId);
f.addProperty("officeId", officeId);
f.addProperty("loanType", loanType);
f.addProperty("institutionType", institutionType);
f.addProperty("fromDate", fromDate);
f.addProperty("toDate", toDate);
f.addProperty("minAmount", minAmount);
f.addProperty("search", search);
return gson.toJson(alertService.list(f, Math.max(0, offset), Math.max(1, Math.min(5000, limit))));
}
@GET
@Path("alerts/options")
@Produces(MediaType.APPLICATION_JSON)
public String alertOptions() {
require("READ_CICALERT");
return gson.toJson(alertService.filterOptions());
}
@GET
@Path("alerts/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String alert(@PathParam("id") final Long id) {
require("READ_CICALERT");
return gson.toJson(alertService.get(id));
}
@PUT
@Path("alerts/{id}/status")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String updateStatus(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.updateStatus(id, json(body), user));
}
@PUT
@Path("alerts/{id}/client")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String linkClient(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.linkClient(id, json(body), user));
}
@POST
@Path("alerts/mark-seen")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String markSeen(final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.markSeen(json(body), user));
}
// ── helpers ──────────────────────────────────────────────────────────────
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 String text(final JsonObject in, final String key) {
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsString() : null;
}
private static int clamp(final int limit) {
return Math.max(1, Math.min(200, limit));
}
}
@@ -0,0 +1,89 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Fetches enquiry alerts automatically: every few minutes, for every tenant, each whitelisted bureau with automatic
* fetch on is fetched once its interval has passed since the last fetch. Runs only on the batch-manager node.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CicAlertScheduler {
private final FineractProperties fineractProperties;
private final TenantDetailsService tenantDetailsService;
private final BusinessDateReadPlatformService businessDateReadPlatformService;
private final JdbcTemplate jdbcTemplate;
private final CicAlertService alertService;
@Scheduled(fixedDelayString = "${cbs.cic.alert-scheduler-delay-ms:300000}", initialDelayString = "${cbs.cic.alert-scheduler-initial-delay-ms:180000}")
public void fetchDueAlerts() {
if (!fineractProperties.getMode().isBatchManagerEnabled()) {
return;
}
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
try {
ThreadLocalContextUtil.setTenant(tenant);
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT id, code, alert_fetch_interval_min, last_fetch_on FROM m_cic_bureau
WHERE whitelisted = TRUE AND alert_auto_fetch = TRUE
""");
final long now = Instant.now().toEpochMilli();
for (final Map<String, Object> b : due) {
final long intervalMs = ((Number) b.get("alert_fetch_interval_min")).longValue() * 60_000L;
final Object last = b.get("last_fetch_on");
if (last instanceof Timestamp ts && now - ts.getTime() < intervalMs) {
continue;
}
try {
alertService.fetch(((Number) b.get("id")).longValue(), null, null, "SCHEDULED", null);
} catch (final RuntimeException e) {
// A bureau that is not ready (no URL, no credential ...) is reported on the run / bureau, not retried here.
log.warn("CIC scheduled alert fetch of {} for tenant {} failed: {}", b.get("code"), tenant.getTenantIdentifier(),
e.getMessage());
}
}
} catch (final RuntimeException e) {
// A tenant whose database does not have the CIC tables yet.
log.debug("CIC alert scheduler skipped tenant {}: {}", tenant.getTenantIdentifier(), e.getMessage());
} finally {
ThreadLocalContextUtil.reset();
}
}
}
}
@@ -0,0 +1,215 @@
/**
* 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 for the ENQUIRY_ALERT call, and the transforms applied when a
* value is written into the bureau request or read back from the bureau response.
*/
public final class CicCatalog {
private CicCatalog() {}
public static final List<String> ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION");
public static final List<String> AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS");
public static final String ALERT_ENDPOINT = "ENQUIRY_ALERT";
public static final List<String> ENDPOINT_TYPES = List.of(ALERT_ENDPOINT);
public static final List<String> HTTP_METHODS = List.of("POST", "PUT", "GET");
public static final List<String> ALERT_STATUSES = List.of("NEW", "SEEN", "ACTIONED", "DISMISSED");
public static final List<String> MATCH_TYPES = List.of("PAN", "AADHAAR", "CUSTOMER_REF", "MANUAL", "NONE");
/** Values a REQUEST mapping can take: the fetch window and the bank's identity at the bureau. */
public static final Map<String, String> CBS_FIELDS = new LinkedHashMap<>();
static {
CBS_FIELDS.put("fetch.reference", "Fetch reference number");
CBS_FIELDS.put("fetch.fromDate", "Alerts from date");
CBS_FIELDS.put("fetch.toDate", "Alerts to date");
CBS_FIELDS.put("fetch.date", "Request date (business date)");
CBS_FIELDS.put("bureau.memberCode", "Bank's member code at the bureau");
CBS_FIELDS.put("bureau.username", "Bureau user id");
}
/**
* Result fields a RESPONSE mapping can fill. errorCode / errorMessage / alerts are read from the root of the
* response; "alerts" points at the list, and every other field is read from each item of that list.
*/
public static final Map<String, String> RESULT_FIELDS = new LinkedHashMap<>();
static {
RESULT_FIELDS.put("alerts", "List of alerts (array) - root");
RESULT_FIELDS.put("errorCode", "Bureau error code - root");
RESULT_FIELDS.put("errorMessage", "Bureau error message - root");
RESULT_FIELDS.put("alertRef", "Alert / enquiry id at the bureau");
RESULT_FIELDS.put("pan", "Customer PAN");
RESULT_FIELDS.put("aadhaar", "Customer Aadhaar (masked or last 4)");
RESULT_FIELDS.put("customerName", "Customer name");
RESULT_FIELDS.put("dateOfBirth", "Customer date of birth");
RESULT_FIELDS.put("customerRef", "Our customer reference (CIF / account no) reported to the bureau");
RESULT_FIELDS.put("institutionCode", "Enquiring lender's member code");
RESULT_FIELDS.put("institutionName", "Enquiring lender (other bank / NBFC)");
RESULT_FIELDS.put("institutionType", "Lender type (Bank / NBFC / MFI ...)");
RESULT_FIELDS.put("enquiryDate", "Enquiry date");
RESULT_FIELDS.put("loanType", "Loan type applied for");
RESULT_FIELDS.put("amount", "Loan amount applied for");
RESULT_FIELDS.put("purpose", "Enquiry purpose");
RESULT_FIELDS.put("location", "Branch / city of the enquiry");
}
/** Result fields read from the root of the response, not from each alert. */
public static final Set<String> ROOT_FIELDS = Set.of("alerts", "errorCode", "errorMessage");
public static final Map<String, String> TRANSFORMS = new LinkedHashMap<>();
static {
TRANSFORMS.put("NONE", "As is");
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_DD_SLASH_MM_YYYY", "Date dd/MM/yyyy");
TRANSFORMS.put("DATE_YYYY_MM_DD", "Date yyyy-MM-dd");
TRANSFORMS.put("AMOUNT_INTEGER", "Amount, whole rupees");
TRANSFORMS.put("NUMBER", "Number");
TRANSFORMS.put("BOOLEAN", "true / false");
}
private static final Map<String, String> DATE_PATTERNS = Map.of("DATE_DDMMYYYY", "ddMMyyyy", "DATE_DD_MM_YYYY", "dd-MM-yyyy",
"DATE_DD_SLASH_MM_YYYY", "dd/MM/yyyy", "DATE_YYYY_MM_DD", "yyyy-MM-dd");
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
/** Applies a REQUEST transform to a CBS value; null stays null. Dates come in as ISO yyyy-MM-dd. */
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;
}
if (DATE_PATTERNS.containsKey(t)) {
try {
return new JsonPrimitive(LocalDate.parse(s.length() > 10 ? s.substring(0, 10) : s, ISO)
.format(DateTimeFormatter.ofPattern(DATE_PATTERNS.get(t))));
} catch (final RuntimeException e) {
return new JsonPrimitive(s);
}
}
switch (t) {
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
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 "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);
}
}
/**
* Converts a value read from a bureau response. A DATE_* transform names the format the bureau sends, and the
* value comes out as ISO yyyy-MM-dd. Arrays and objects pass through.
*/
public static JsonElement applyResponseTransform(final String transform, final JsonElement value) {
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
return value;
}
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
final String s = value.getAsString().trim();
try {
if (DATE_PATTERNS.containsKey(t)) {
return new JsonPrimitive(parseDate(s, DATE_PATTERNS.get(t)).toString());
}
switch (t) {
case "NUMBER":
case "AMOUNT_INTEGER": {
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));
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
case "LOWER":
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
case "TRIM":
return new JsonPrimitive(s.replaceAll("\\s+", " "));
case "DIGITS_ONLY":
return new JsonPrimitive(s.replaceAll("\\D", ""));
default:
return value;
}
} catch (final RuntimeException e) {
return value;
}
}
/** Parses a bureau date in the given pattern, falling back to ISO. */
public static LocalDate parseDate(final String s, final String pattern) {
final String v = s.trim();
try {
return LocalDate.parse(v, DateTimeFormatter.ofPattern(pattern));
} catch (final RuntimeException e) {
return LocalDate.parse(v.length() > 10 ? v.substring(0, 10) : v, ISO);
}
}
/** ABCDE1234F -> AB****234F style masking for screens and logs. */
public static String mask(final String value) {
if (value == null || value.length() <= 4) {
return value == null ? null : "****";
}
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);
}
/** Aadhaar shown as XXXX-XXXX-1234 whatever form the bureau sends it in. */
public static String maskAadhaar(final String value) {
if (value == null) {
return null;
}
final String digits = value.replaceAll("\\D", "");
return digits.length() < 4 ? null : "XXXX-XXXX-" + digits.substring(digits.length() - 4);
}
}
@@ -0,0 +1,463 @@
/**
* 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.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The bank's bureau master (whitelist, environment, connection, encrypted credentials) and each bureau's API mapping:
* endpoint, headers, base body and the REQUEST / RESPONSE field mappings.
*/
@Service
@RequiredArgsConstructor
public class CicConfigService {
private static final Pattern RESULT_KEY = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,99}$");
private final JdbcTemplate jdbcTemplate;
private final CicHttpGateway gateway;
// ── Bureaus ───────────────────────────────────────────────────────────────
/** Full row including the encrypted secret - internal use only, never returned by the API. */
public Map<String, Object> bureauRow(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_bureau WHERE id = ?", id);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.bureau.not.found", "Credit bureau " + id + " was not found.");
}
return rows.get(0);
}
public List<Map<String, Object>> listBureaus(final boolean whitelistedOnly) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(
"SELECT * FROM m_cic_bureau" + (whitelistedOnly ? " WHERE whitelisted = TRUE" : "") + " ORDER BY is_default DESC, id")) {
out.add(publicView(row));
}
return out;
}
public Map<String, Object> getBureau(final Long id) {
return publicView(bureauRow(id));
}
/** A bureau row as the screens see it: never the secret itself, only whether one is set. */
private Map<String, Object> publicView(final Map<String, Object> row) {
final Map<String, Object> v = new LinkedHashMap<>();
final Long id = ((Number) row.get("id")).longValue();
v.put("id", id);
v.put("code", row.get("code"));
v.put("name", row.get("name"));
v.put("description", row.get("description"));
v.put("whitelisted", row.get("whitelisted"));
v.put("isDefault", row.get("is_default"));
v.put("environment", row.get("environment"));
v.put("baseUrl", row.get("base_url"));
v.put("memberCode", row.get("member_code"));
v.put("authType", row.get("auth_type"));
v.put("username", row.get("username"));
v.put("apiKeyHeader", row.get("api_key_header"));
v.put("hasSecret", row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank());
v.put("tokenUrl", row.get("token_url"));
v.put("clientId", row.get("client_id"));
v.put("timeoutMs", row.get("timeout_ms"));
v.put("alertAutoFetch", row.get("alert_auto_fetch"));
v.put("alertFetchIntervalMin", row.get("alert_fetch_interval_min"));
v.put("alertLookbackDays", row.get("alert_lookback_days"));
v.put("lastFetchOn", row.get("last_fetch_on") == null ? null : row.get("last_fetch_on").toString());
v.put("lastFetchStatus", row.get("last_fetch_status"));
v.put("lastFetchMessage", row.get("last_fetch_message"));
v.put("lastTestStatus", row.get("last_test_status"));
v.put("lastTestMessage", row.get("last_test_message"));
v.put("lastTestedOn", row.get("last_tested_on") == null ? null : row.get("last_tested_on").toString());
v.put("updatedOn", row.get("updated_on") == null ? null : row.get("updated_on").toString());
v.put("activeEndpoints", jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, id));
v.put("alertsLast30Days", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND received_on >= ?",
Integer.class, id, Timestamp.from(Instant.now().minusSeconds(30L * 86400))));
v.put("newAlerts", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND status = 'NEW'",
Integer.class, id));
return v;
}
@Transactional
public Map<String, Object> updateBureau(final Long id, final JsonObject in, final AppUser user) {
final Map<String, Object> row = bureauRow(id);
final String environment = upper(text(in, "environment", (String) row.get("environment")));
if (!CicCatalog.ENVIRONMENTS.contains(environment)) {
throw fail("error.msg.cic.environment.invalid", "Environment must be one of " + CicCatalog.ENVIRONMENTS + ".");
}
final String authType = upper(text(in, "authType", (String) row.get("auth_type")));
if (!CicCatalog.AUTH_TYPES.contains(authType)) {
throw fail("error.msg.cic.auth.invalid", "Authentication must be one of " + CicCatalog.AUTH_TYPES + ".");
}
final String baseUrl = trimToNull(text(in, "baseUrl", (String) row.get("base_url")));
if (baseUrl != null && !StringUtils.startsWithAny(baseUrl, "https://", "http://")) {
throw fail("error.msg.cic.url.invalid", "Base URL must start with https:// (or http:// for a test server).");
}
if ("PRODUCTION".equals(environment) && baseUrl != null && !baseUrl.startsWith("https://")) {
throw fail("error.msg.cic.url.https", "A PRODUCTION bureau must be reached over https://.");
}
final String tokenUrl = trimToNull(text(in, "tokenUrl", (String) row.get("token_url")));
final int timeoutMs = intOf(in, "timeoutMs", ((Number) row.get("timeout_ms")).intValue());
if (timeoutMs < 1000 || timeoutMs > 300000) {
throw fail("error.msg.cic.timeout.invalid", "Timeout must be between 1,000 and 300,000 milliseconds.");
}
final boolean autoFetch = in.has("alertAutoFetch") && !in.get("alertAutoFetch").isJsonNull() ? in.get("alertAutoFetch").getAsBoolean()
: Boolean.TRUE.equals(row.get("alert_auto_fetch"));
final int interval = intOf(in, "alertFetchIntervalMin", ((Number) row.get("alert_fetch_interval_min")).intValue());
if (interval < 15 || interval > 1440) {
throw fail("error.msg.cic.interval.invalid", "Automatic fetch interval must be between 15 minutes and 24 hours (1440).");
}
final int lookback = intOf(in, "alertLookbackDays", ((Number) row.get("alert_lookback_days")).intValue());
if (lookback < 1 || lookback > 90) {
throw fail("error.msg.cic.lookback.invalid", "Look-back window must be between 1 and 90 days.");
}
String secretEnc = (String) row.get("secret_enc");
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 = ?,
alert_auto_fetch = ?, alert_fetch_interval_min = ?, alert_lookback_days = ?, is_default = ?, updated_by = ?,
updated_on = ?
WHERE id = ?
""", StringUtils.defaultIfBlank(text(in, "name", (String) row.get("name")), (String) row.get("name")),
trimToNull(text(in, "description", (String) row.get("description"))), environment, baseUrl,
trimToNull(text(in, "memberCode", (String) row.get("member_code"))), authType,
trimToNull(text(in, "username", (String) row.get("username"))),
StringUtils.defaultIfBlank(text(in, "apiKeyHeader", (String) row.get("api_key_header")), "X-API-KEY"), secretEnc, tokenUrl,
trimToNull(text(in, "clientId", (String) row.get("client_id"))), timeoutMs, autoFetch, interval, lookback,
makeDefault || Boolean.TRUE.equals(row.get("is_default")), user.getId(), Timestamp.from(Instant.now()), id);
gateway.evictToken(id);
// A whitelisted bureau must stay usable after the edit; otherwise every alert fetch would fail silently.
if (Boolean.TRUE.equals(row.get("whitelisted"))) {
assertReadyForLive(bureauRow(id));
}
return getBureau(id);
}
@Transactional
public Map<String, Object> setWhitelisted(final Long id, final boolean whitelisted, final AppUser user) {
final Map<String, Object> row = bureauRow(id);
if (whitelisted) {
assertReadyForLive(row);
}
jdbcTemplate.update("UPDATE m_cic_bureau SET whitelisted = ?, updated_by = ?, updated_on = ? WHERE id = ?", whitelisted,
user.getId(), Timestamp.from(Instant.now()), id);
return getBureau(id);
}
/** What a bureau needs before alerts may be fetched from it. */
public void assertReadyForLive(final Map<String, Object> row) {
final String code = (String) row.get("code");
final Integer endpoints = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, row.get("id"));
if (endpoints == null || endpoints == 0) {
throw fail("error.msg.cic.no.endpoint", code + " has no active API endpoint. Configure it under API Mapping first.");
}
if ("SIMULATOR".equals(row.get("environment"))) {
return;
}
if (StringUtils.isBlank((String) row.get("base_url"))) {
throw fail("error.msg.cic.no.url", code + " has no base URL for " + row.get("environment") + ".");
}
final String auth = (String) row.get("auth_type");
final boolean hasSecret = row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank();
if (!"NONE".equals(auth) && !hasSecret) {
throw fail("error.msg.cic.no.secret", code + " uses " + auth + " but no password / key / secret is stored.");
}
if ("BASIC".equals(auth) && StringUtils.isBlank((String) row.get("username"))) {
throw fail("error.msg.cic.no.username", code + " uses BASIC authentication but has no username.");
}
if ("OAUTH2_CLIENT_CREDENTIALS".equals(auth)
&& (StringUtils.isBlank((String) row.get("token_url")) || StringUtils.isBlank((String) row.get("client_id")))) {
throw fail("error.msg.cic.no.oauth", code + " uses OAuth2 but the token URL or client id is missing.");
}
}
/** Reachability and credentials check; the result is kept on the bureau for the configuration screen. */
public Map<String, Object> testConnection(final Long id) {
final Map<String, Object> row = bureauRow(id);
String status;
String message;
if ("SIMULATOR".equals(row.get("environment"))) {
status = "OK";
message = "Simulator mode - no network call is made; alerts are answered by the built-in simulator.";
} else if (StringUtils.isBlank((String) row.get("base_url"))) {
status = "FAILED";
message = "No base URL configured.";
} else {
try {
if ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type"))) {
gateway.evictToken(id);
gateway.oauthToken(row);
}
final CicHttpGateway.HttpResult r = gateway.send(row, "GET", (String) row.get("base_url"), Map.of(), null, null);
if (r.status() == 0) {
status = "FAILED";
message = "Not reachable: " + r.error();
} else if (r.status() == 401 || r.status() == 403) {
status = "FAILED";
message = "Reachable, but the bureau refused the credentials (HTTP " + r.status() + ").";
} else {
status = "OK";
message = "Reachable (HTTP " + r.status() + " in " + r.durationMs() + " ms)"
+ ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type")) ? ", OAuth2 token obtained." : ".");
}
} catch (final IOException | RuntimeException e) {
status = "FAILED";
message = e.getMessage();
}
}
jdbcTemplate.update("UPDATE m_cic_bureau SET last_test_status = ?, last_test_message = ?, last_tested_on = ? WHERE id = ?", status,
StringUtils.abbreviate(message, 1000), Timestamp.from(Instant.now()), id);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("status", status);
res.put("message", message);
return res;
}
// ── Endpoints and mappings ────────────────────────────────────────────────
public Map<String, Object> endpointRow(final Long bureauId, final String type) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND endpoint_type = ?", bureauId, type);
return rows.isEmpty() ? null : rows.get(0);
}
public List<Map<String, Object>> mappingRows(final Long endpointId, final String direction) {
return jdbcTemplate.queryForList(
"SELECT * FROM m_cic_field_mapping WHERE endpoint_id = ? AND direction = ? ORDER BY sort_order, id", endpointId, direction);
}
public Map<String, Object> getEndpoint(final Long bureauId, final String type) {
bureauRow(bureauId);
final String t = upper(type);
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final Map<String, Object> e = endpointRow(bureauId, t);
final Map<String, Object> v = new LinkedHashMap<>();
v.put("bureauId", bureauId);
v.put("endpointType", t);
v.put("exists", e != null);
v.put("id", e == null ? null : e.get("id"));
v.put("httpMethod", e == null ? "POST" : e.get("http_method"));
v.put("path", e == null ? null : e.get("path"));
v.put("contentType", e == null ? "application/json" : e.get("content_type"));
v.put("headersJson", e == null ? "{}" : e.get("headers_json"));
v.put("baseBody", e == null ? "{}" : e.get("base_body"));
v.put("isActive", e == null ? Boolean.FALSE : e.get("is_active"));
v.put("requestMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "REQUEST")));
v.put("responseMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "RESPONSE")));
return v;
}
private static List<Map<String, Object>> mappingView(final List<Map<String, Object>> rows) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> m : rows) {
final Map<String, Object> v = new LinkedHashMap<>();
v.put("id", m.get("id"));
v.put("sourceField", m.get("source_field"));
v.put("targetField", m.get("target_field"));
v.put("transform", m.get("transform"));
v.put("defaultValue", m.get("default_value"));
v.put("mandatory", m.get("is_mandatory"));
v.put("sortOrder", m.get("sort_order"));
out.add(v);
}
return out;
}
/** Saves an endpoint and replaces its whole mapping in one transaction, after validating every row. */
@Transactional
public Map<String, Object> saveEndpoint(final Long bureauId, final String type, final JsonObject in, final AppUser user) {
final Map<String, Object> bureau = bureauRow(bureauId);
final String t = upper(type);
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final String method = upper(StringUtils.defaultIfBlank(text(in, "httpMethod", "POST"), "POST"));
if (!CicCatalog.HTTP_METHODS.contains(method)) {
throw fail("error.msg.cic.method.invalid", "HTTP method must be one of " + CicCatalog.HTTP_METHODS + ".");
}
final String headersJson = StringUtils.defaultIfBlank(text(in, "headersJson", "{}"), "{}");
final String baseBody = StringUtils.defaultIfBlank(text(in, "baseBody", "{}"), "{}");
requireJsonObject(headersJson, "Headers");
requireJsonObject(baseBody, "Base request body");
for (final Map.Entry<String, JsonElement> h : JsonParser.parseString(headersJson).getAsJsonObject().entrySet()) {
if (!h.getValue().isJsonPrimitive()) {
throw fail("error.msg.cic.header.invalid", "Header '" + h.getKey() + "' must be a plain text value.");
}
}
final boolean active = !in.has("isActive") || in.get("isActive").isJsonNull() || in.get("isActive").getAsBoolean();
final List<Object[]> rows = new ArrayList<>();
validateMappings(in.has("requestMappings") ? in.getAsJsonArray("requestMappings") : new JsonArray(), "REQUEST", rows);
validateMappings(in.has("responseMappings") ? in.getAsJsonArray("responseMappings") : new JsonArray(), "RESPONSE", rows);
if (active && rows.stream().noneMatch(r -> "REQUEST".equals(r[0]))) {
throw fail("error.msg.cic.no.request.mapping", "An active endpoint needs at least one REQUEST mapping.");
}
if (active && rows.stream().noneMatch(r -> "RESPONSE".equals(r[0]) && "alerts".equals(r[2]))) {
throw fail("error.msg.cic.no.alert.list",
"Map the response path of the alert list to the result field 'alerts' (e.g. data.alerts), or the alerts cannot be read.");
}
Map<String, Object> existing = endpointRow(bureauId, t);
final Timestamp now = Timestamp.from(Instant.now());
if (existing == null) {
jdbcTemplate.update("""
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body,
is_active, updated_by, updated_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", bureauId, t, method, trimToNull(text(in, "path", null)),
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
user.getId(), now);
existing = endpointRow(bureauId, t);
} else {
jdbcTemplate.update("""
UPDATE m_cic_bureau_endpoint SET http_method = ?, path = ?, content_type = ?, headers_json = ?, base_body = ?, is_active = ?,
updated_by = ?, updated_on = ?
WHERE id = ?
""", method, trimToNull(text(in, "path", null)),
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
user.getId(), now, existing.get("id"));
}
final Long endpointId = ((Number) existing.get("id")).longValue();
jdbcTemplate.update("DELETE FROM m_cic_field_mapping WHERE endpoint_id = ?", endpointId);
for (final Object[] r : rows) {
jdbcTemplate.update("""
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory,
sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", endpointId, r[0], r[1], r[2], r[3], r[4], r[5], r[6]);
}
// Switching off the last active endpoint of a whitelisted bureau would leave it whitelisted but unusable.
if (Boolean.TRUE.equals(bureau.get("whitelisted"))) {
assertReadyForLive(bureauRow(bureauId));
}
return getEndpoint(bureauId, t);
}
private void validateMappings(final JsonArray array, final String direction, final List<Object[]> out) {
int order = 0;
for (final JsonElement el : array) {
final JsonObject m = el.getAsJsonObject();
order += 10;
final String source = trimToNull(text(m, "sourceField", null));
final String target = trimToNull(text(m, "targetField", null));
final String transform = StringUtils.defaultIfBlank(upper(text(m, "transform", "NONE")), "NONE");
final String defaultValue = trimToNull(text(m, "defaultValue", null));
final boolean mandatory = m.has("mandatory") && !m.get("mandatory").isJsonNull() && m.get("mandatory").getAsBoolean();
final int sortOrder = m.has("sortOrder") && !m.get("sortOrder").isJsonNull() ? m.get("sortOrder").getAsInt() : order;
final String label = direction + " row " + (order / 10);
if (!CicCatalog.TRANSFORMS.containsKey(transform)) {
throw fail("error.msg.cic.transform.invalid", label + ": unknown transform '" + transform + "'.");
}
if (target == null) {
throw fail("error.msg.cic.mapping.target.required", label + ": the target field is required.");
}
if ("REQUEST".equals(direction)) {
if (source == null && defaultValue == null) {
throw fail("error.msg.cic.mapping.source.required", label + " (" + target + "): choose a CBS field or give a fixed value.");
}
if (source != null && !CicCatalog.CBS_FIELDS.containsKey(source)) {
throw fail("error.msg.cic.mapping.source.invalid", label + ": unknown CBS field '" + source + "'.");
}
if (!CicJsonPath.isValid(target)) {
throw fail("error.msg.cic.mapping.path.invalid", label + ": '" + target + "' is not a valid JSON path (e.g. consumer.ids[0].number).");
}
} else {
if (source == null || !CicJsonPath.isValid(source)) {
throw fail("error.msg.cic.mapping.path.invalid",
label + ": the bureau response path is missing or invalid (e.g. report.score.value).");
}
if (!RESULT_KEY.matcher(target).matches()) {
throw fail("error.msg.cic.mapping.result.invalid", label + ": result field '" + target + "' must be a simple name.");
}
}
out.add(new Object[] { direction, source, target, transform, defaultValue, mandatory, sortOrder });
}
}
// ── helpers ──────────────────────────────────────────────────────────────
private static void requireJsonObject(final String text, final String label) {
try {
if (!JsonParser.parseString(text).isJsonObject()) {
throw new IllegalArgumentException();
}
} catch (final RuntimeException e) {
throw fail("error.msg.cic.json.invalid", label + " must be a valid JSON object.");
}
}
static String text(final JsonObject in, final String key, final String fallback) {
if (in == null || !in.has(key)) {
return fallback;
}
return in.get(key).isJsonNull() ? null : in.get(key).getAsString();
}
private static int intOf(final JsonObject in, final String key, final int fallback) {
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsInt() : fallback;
}
private static String upper(final String s) {
return s == null ? null : s.trim().toUpperCase();
}
private static String trimToNull(final String s) {
return StringUtils.trimToNull(s);
}
static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -0,0 +1,194 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.FormBody;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor;
import org.springframework.stereotype.Service;
/**
* Outbound HTTP to a credit bureau: URL joining, authentication (Basic, API key, bearer, OAuth2 client credentials
* with an in-memory token cache), per-bureau timeouts. The bureau's secret is stored encrypted and is only decrypted
* here, for the call itself.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CicHttpGateway {
private static final int MAX_BODY_CHARS = 2_000_000;
private final DatabasePasswordEncryptor encryptor;
private final Map<Long, CachedToken> tokens = new ConcurrentHashMap<>();
private record CachedToken(String token, Instant expiresAt) {}
/** What came back from the bureau. A transport failure is a status of 0 with the error in {@code body}. */
public record HttpResult(int status, String body, long durationMs, String error) {
public boolean ok() {
return status >= 200 && status < 300;
}
}
public String encrypt(final String plain) {
return plain == null ? null : encryptor.encrypt(plain);
}
private String secretOf(final Map<String, Object> bureau) {
final Object enc = bureau.get("secret_enc");
if (enc == null || enc.toString().isBlank()) {
return null;
}
try {
return encryptor.decrypt(enc.toString());
} catch (final RuntimeException e) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.secret.unreadable",
"The stored credential of " + bureau.get("code") + " cannot be decrypted (was the master password changed?)."
+ " Re-enter it on the bureau configuration.");
}
}
public static String joinUrl(final String baseUrl, final String path) {
if (StringUtils.startsWithAny(path, "http://", "https://")) {
return path;
}
final String base = StringUtils.defaultString(baseUrl).replaceAll("/+$", "");
if (StringUtils.isBlank(path)) {
return base;
}
return base + (path.startsWith("/") ? path : "/" + path);
}
private OkHttpClient client(final Map<String, Object> bureau) {
final int timeout = bureau.get("timeout_ms") instanceof Number n && n.intValue() > 0 ? n.intValue() : 30000;
return new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS).readTimeout(timeout, TimeUnit.MILLISECONDS)
.writeTimeout(timeout, TimeUnit.MILLISECONDS).build();
}
/** Sends one request to the bureau with its configured authentication. */
public HttpResult send(final Map<String, Object> bureau, final String method, final String url, final Map<String, String> headers,
final String contentType, final String body) {
final long started = System.currentTimeMillis();
try {
final Request.Builder rb = new Request.Builder().url(url);
headers.forEach((k, v) -> {
if (StringUtils.isNotBlank(k) && v != null) {
rb.header(k, v);
}
});
applyAuth(bureau, rb);
final String m = StringUtils.defaultIfBlank(method, "POST").toUpperCase();
if ("GET".equals(m)) {
rb.get();
} else {
final MediaType mediaType = MediaType.parse(StringUtils.defaultIfBlank(contentType, "application/json"));
rb.method(m, RequestBody.create(StringUtils.defaultString(body), mediaType));
}
try (Response response = client(bureau).newCall(rb.build()).execute()) {
return new HttpResult(response.code(), read(response.body()), System.currentTimeMillis() - started, null);
}
} catch (final IOException | IllegalArgumentException e) {
log.warn("CIC {}: call to {} failed: {}", bureau.get("code"), url, e.getMessage());
return new HttpResult(0, null, System.currentTimeMillis() - started, e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
private static String read(final ResponseBody body) throws IOException {
if (body == null) {
return null;
}
final String text = new String(body.bytes(), StandardCharsets.UTF_8);
return text.length() > MAX_BODY_CHARS ? text.substring(0, MAX_BODY_CHARS) : text;
}
private void applyAuth(final Map<String, Object> bureau, final Request.Builder rb) throws IOException {
final String auth = StringUtils.defaultIfBlank((String) bureau.get("auth_type"), "NONE");
final String username = (String) bureau.get("username");
switch (auth) {
case "BASIC" -> {
final String raw = StringUtils.defaultString(username) + ":" + StringUtils.defaultString(secretOf(bureau));
rb.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)));
}
case "API_KEY" -> rb.header(StringUtils.defaultIfBlank((String) bureau.get("api_key_header"), "X-API-KEY"),
StringUtils.defaultString(secretOf(bureau)));
case "BEARER_TOKEN" -> rb.header("Authorization", "Bearer " + StringUtils.defaultString(secretOf(bureau)));
case "OAUTH2_CLIENT_CREDENTIALS" -> rb.header("Authorization", "Bearer " + oauthToken(bureau));
default -> {
// NONE: the bureau authenticates by IP whitelisting / mutual TLS at the network layer.
}
}
}
/** OAuth2 client-credentials token, cached until a minute before it expires. */
public String oauthToken(final Map<String, Object> bureau) throws IOException {
final Long id = ((Number) bureau.get("id")).longValue();
final CachedToken cached = tokens.get(id);
if (cached != null && Instant.now().isBefore(cached.expiresAt())) {
return cached.token();
}
final String tokenUrl = (String) bureau.get("token_url");
if (StringUtils.isBlank(tokenUrl)) {
throw new IOException("OAuth2 token URL is not configured");
}
final FormBody form = new FormBody.Builder().add("grant_type", "client_credentials")
.add("client_id", StringUtils.defaultString((String) bureau.get("client_id")))
.add("client_secret", StringUtils.defaultString(secretOf(bureau))).build();
try (Response response = client(bureau).newCall(new Request.Builder().url(tokenUrl).post(form).build()).execute()) {
final String text = read(response.body());
if (!response.isSuccessful()) {
throw new IOException("Token endpoint returned HTTP " + response.code() + ": " + StringUtils.abbreviate(text, 300));
}
final JsonElement json = JsonParser.parseString(StringUtils.defaultIfBlank(text, "{}"));
final JsonObject obj = json.isJsonObject() ? json.getAsJsonObject() : new JsonObject();
if (!obj.has("access_token")) {
throw new IOException("Token endpoint response has no access_token");
}
final long expiresIn = obj.has("expires_in") ? obj.get("expires_in").getAsLong() : 300L;
final String token = obj.get("access_token").getAsString();
tokens.put(id, new CachedToken(token, Instant.now().plusSeconds(Math.max(30, expiresIn - 60))));
return token;
}
}
/** Forget a cached token (after the bureau's credentials change). */
public void evictToken(final Long bureauId) {
tokens.remove(bureauId);
}
}
@@ -0,0 +1,133 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Minimal dotted JSON path used by the bureau API mapping: {@code consumer.name.firstName},
* {@code consumer.ids[0].number}, {@code report.score.value}. Setting creates the missing objects and arrays on the
* way; reading returns null for anything absent.
*/
public final class CicJsonPath {
private static final Pattern SEGMENT = Pattern.compile("^([^\\[\\]]*)((?:\\[\\d+\\])*)$");
private static final Pattern INDEX = Pattern.compile("\\[(\\d+)\\]");
private CicJsonPath() {}
/** One step of a path: an object key, optionally followed by array indexes. */
private record Step(String key, List<Integer> indexes) {}
private static List<Step> parse(final String path) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("Empty JSON path");
}
final List<Step> steps = new ArrayList<>();
for (final String raw : path.trim().split("\\.")) {
final Matcher m = SEGMENT.matcher(raw);
if (!m.matches()) {
throw new IllegalArgumentException("Invalid JSON path segment '" + raw + "' in '" + path + "'");
}
final List<Integer> indexes = new ArrayList<>();
final Matcher im = INDEX.matcher(m.group(2));
while (im.find()) {
indexes.add(Integer.parseInt(im.group(1)));
}
steps.add(new Step(m.group(1), indexes));
}
return steps;
}
/** Whether the path is syntactically valid. */
public static boolean isValid(final String path) {
try {
parse(path);
return true;
} catch (final IllegalArgumentException e) {
return false;
}
}
public static JsonElement get(final JsonElement root, final String path) {
JsonElement current = root;
for (final Step step : parse(path)) {
if (!step.key().isEmpty()) {
if (current == null || !current.isJsonObject()) {
return null;
}
current = current.getAsJsonObject().get(step.key());
}
for (final Integer index : step.indexes()) {
if (current == null || !current.isJsonArray() || current.getAsJsonArray().size() <= index) {
return null;
}
current = current.getAsJsonArray().get(index);
}
}
return current == null || current.isJsonNull() ? null : current;
}
public static void set(final JsonObject root, final String path, final JsonElement value) {
final List<Step> steps = parse(path);
JsonElement current = root;
for (int s = 0; s < steps.size(); s++) {
final Step step = steps.get(s);
final boolean lastStep = s == steps.size() - 1;
if (!step.key().isEmpty()) {
final JsonObject obj = current.getAsJsonObject();
if (lastStep && step.indexes().isEmpty()) {
obj.add(step.key(), value == null ? JsonNull.INSTANCE : value);
return;
}
JsonElement next = obj.get(step.key());
if (next == null || next.isJsonNull()) {
next = step.indexes().isEmpty() ? new JsonObject() : new JsonArray();
obj.add(step.key(), next);
}
current = next;
}
for (int i = 0; i < step.indexes().size(); i++) {
final int index = step.indexes().get(i);
final boolean lastIndex = lastStep && i == step.indexes().size() - 1;
final JsonArray array = current.getAsJsonArray();
while (array.size() <= index) {
array.add(JsonNull.INSTANCE);
}
if (lastIndex) {
array.set(index, value == null ? JsonNull.INSTANCE : value);
return;
}
JsonElement next = array.get(index);
if (next == null || next.isJsonNull()) {
next = i < step.indexes().size() - 1 ? new JsonArray() : new JsonObject();
array.set(index, next);
}
current = next;
}
}
}
}
@@ -0,0 +1,153 @@
/**
* 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.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Random;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Stand-in CIC for the SIMULATOR environment. Plays the bureau's enquiry-alert service: for every day of the asked
* window it reports a few of this bank's own customers as enquired by other lenders, in the shape of the seeded generic
* mapping. Deterministic per bureau and day, so fetching the same window again yields the same alerts (and they are
* de-duplicated). Now and then it also sends an alert for an unknown customer and one raised by this bank itself, so
* the unmatched and own-bank paths can be seen too. Never used once the bureau is set to UAT or PRODUCTION.
*/
@Component
@RequiredArgsConstructor
public class CicSimulator {
private static final String[][] LENDERS = { { "HDFC BANK LTD", "BANK" }, { "STATE BANK OF INDIA", "BANK" }, { "ICICI BANK LTD", "BANK" },
{ "BAJAJ FINANCE LTD", "NBFC" }, { "MUTHOOT FINANCE LTD", "NBFC" }, { "UJJIVAN SMALL FINANCE BANK", "SMALL FINANCE BANK" },
{ "BANDHAN BANK LTD", "BANK" }, { "SATIN CREDITCARE NETWORK", "MFI" }, { "MADHYA PRADESH GRAMIN BANK", "REGIONAL RURAL BANK" },
{ "SARASWAT CO-OPERATIVE BANK", "CO-OPERATIVE BANK" }, { "TATA CAPITAL LTD", "NBFC" }, { "PUNJAB NATIONAL BANK", "BANK" } };
private static final Object[][] LOAN_TYPES = { { "PERSONAL LOAN", 50_000, 1_000_000 }, { "HOUSING LOAN", 800_000, 6_000_000 },
{ "AUTO LOAN", 300_000, 1_500_000 }, { "TWO-WHEELER LOAN", 50_000, 200_000 }, { "GOLD LOAN", 20_000, 500_000 },
{ "BUSINESS LOAN", 200_000, 5_000_000 }, { "CREDIT CARD", 25_000, 300_000 }, { "KISAN CREDIT CARD", 50_000, 300_000 },
{ "EDUCATION LOAN", 200_000, 2_000_000 }, { "CONSUMER DURABLE LOAN", 10_000, 150_000 },
{ "LOAN AGAINST PROPERTY", 500_000, 5_000_000 }, { "MICROFINANCE LOAN", 20_000, 100_000 } };
private static final String[] CITIES = { "BHOPAL", "INDORE", "VIDISHA", "SAGAR", "JABALPUR", "GWALIOR", "BASODA", "RAISEN" };
private static final DateTimeFormatter DDMMYYYY = DateTimeFormatter.ofPattern("ddMMyyyy");
private final JdbcTemplate jdbcTemplate;
public JsonObject respond(final Map<String, Object> bureau, final LocalDate from, final LocalDate to, final String reference) {
final String code = String.valueOf(bureau.get("code"));
final String ownMemberCode = bureau.get("member_code") == null ? "OWNBANK" : bureau.get("member_code").toString();
final List<Map<String, Object>> clients = jdbcTemplate.queryForList("""
SELECT c.id, c.display_name, c.account_no, c.date_of_birth,
(SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL
ORDER BY k.id DESC LIMIT 1) AS pan,
(SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id
AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar
FROM m_client c
WHERE c.status_enum = 300
ORDER BY c.id
LIMIT 300
""");
final JsonArray alerts = new JsonArray();
final long days = Math.min(90, ChronoUnit.DAYS.between(from, to));
for (int d = 0; d <= days; d++) {
final LocalDate day = from.plusDays(d);
final Random r = new Random(Math.abs((long) (code + "|" + day).hashCode()));
final int count = clients.isEmpty() ? 0 : r.nextInt(4);
int seq = 0;
for (int i = 0; i < count; i++) {
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, null));
}
if (r.nextInt(8) == 0) {
// A consumer the bureau has on file for this bank but whom the bank no longer has (or never had) under that id.
alerts.add(alert(code, day, ++seq, Map.of("display_name", "UNKNOWN CUSTOMER", "pan", "ZZZPZ" + (1000 + r.nextInt(8999)) + "Z"),
r, null));
}
if (!clients.isEmpty() && r.nextInt(10) == 0) {
// An enquiry this bank made itself - the bureau reports it, the CBS must skip it.
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, ownMemberCode));
}
}
final JsonObject header = new JsonObject();
header.addProperty("status", "SUCCESS");
header.addProperty("referenceNumber", reference);
header.addProperty("bureau", code);
header.addProperty("fromDate", from.format(DDMMYYYY));
header.addProperty("toDate", to.format(DDMMYYYY));
final JsonObject data = new JsonObject();
data.addProperty("totalAlerts", alerts.size());
data.add("alerts", alerts);
final JsonObject root = new JsonObject();
root.add("responseHeader", header);
root.add("data", data);
return root;
}
private static JsonObject alert(final String code, final LocalDate day, final int seq, final Map<String, Object> client, final Random r,
final String forcedMemberCode) {
final JsonObject consumer = new JsonObject();
consumer.addProperty("name", String.valueOf(client.get("display_name")).toUpperCase());
final Object pan = client.get("pan");
final Object aadhaar = client.get("aadhaar");
if (pan != null && !pan.toString().isBlank()) {
consumer.addProperty("pan", pan.toString().trim().toUpperCase());
} else if (aadhaar != null && !aadhaar.toString().isBlank()) {
consumer.addProperty("aadhaar", "XXXXXXXX" + lastFour(aadhaar.toString()));
} else if (client.get("account_no") != null) {
consumer.addProperty("memberReference", client.get("account_no").toString());
}
if (client.get("date_of_birth") != null) {
consumer.addProperty("dateOfBirth", LocalDate.parse(client.get("date_of_birth").toString().substring(0, 10)).format(DDMMYYYY));
}
final int lenderIdx = r.nextInt(LENDERS.length);
final Object[] type = LOAN_TYPES[r.nextInt(LOAN_TYPES.length)];
final int min = (Integer) type[1];
final int max = (Integer) type[2];
final long amount = Math.round((min + r.nextDouble() * (max - min)) / 5000.0) * 5000L;
final JsonObject enquiry = new JsonObject();
enquiry.addProperty("memberCode", forcedMemberCode != null ? forcedMemberCode : "SIM" + String.format("%04d", 101 + lenderIdx));
enquiry.addProperty("memberName", forcedMemberCode != null ? "THIS BANK" : LENDERS[lenderIdx][0]);
enquiry.addProperty("memberType", forcedMemberCode != null ? "BANK" : LENDERS[lenderIdx][1]);
enquiry.addProperty("date", day.format(DDMMYYYY));
enquiry.addProperty("productType", (String) type[0]);
enquiry.addProperty("amount", amount);
enquiry.addProperty("purpose", r.nextInt(5) == 0 ? "LOAN RENEWAL / ENHANCEMENT" : "NEW LOAN APPLICATION");
enquiry.addProperty("location", CITIES[r.nextInt(CITIES.length)]);
final JsonObject a = new JsonObject();
a.addProperty("alertId", code + "-" + day.format(DDMMYYYY) + "-" + seq);
a.add("consumer", consumer);
a.add("enquiry", enquiry);
return a;
}
private static String lastFour(final String value) {
final String digits = value.replaceAll("\\D", "");
return digits.length() >= 4 ? digits.substring(digits.length() - 4) : "0000";
}
}
@@ -69,6 +69,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -90,6 +92,8 @@ public class ClerkDisbursementRequestApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ────────
@@ -202,6 +206,9 @@ public class ClerkDisbursementRequestApiResource {
}
}
// Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account.
validateFixedDepositLegs(creditEntries, requestType);
// Validate balance when both DR and CR entries present (collection mode)
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
@@ -491,6 +498,10 @@ public class ClerkDisbursementRequestApiResource {
rejectRecurringDepositLeg(entry, entryType);
}
}
// Re-checked here, not only at submit: another request (or a counter deposit) may have funded the
// same FD while this one waited, and a refusal now still leaves the request PENDING.
final Set<Long> fundedFixedDeposits = validateFixedDepositLegs(creditEntries,
record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT");
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
// posting duplicate Fineract transactions for the same request.
@@ -773,6 +784,12 @@ public class ClerkDisbursementRequestApiResource {
}
} // end else (disbursement/collection mode)
// A funded FD's maturity date and amount were projected off its principal with no money in it;
// the direct credit above does not recompute them, so do it now that the money has landed.
for (final Long fdId : fundedFixedDeposits) {
depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId);
}
} catch (Exception ex) {
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
// This restores account balances even when a later entry fails mid-processing.
@@ -1223,6 +1240,70 @@ public class ClerkDisbursementRequestApiResource {
}
}
/**
* Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label.
* <ul>
* <li>An FD is never debited here: money leaves an FD only through its maturity or premature closure. The
* direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.</li>
* <li>An FD credit is its first and only funding, for exactly its principal, and an FD appears once.</li>
* <li>The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund
* them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is
* the bank's own money and has no customer to compare.</li>
* </ul>
*
* @return the ids of the FDs this request funds, for the maturity recalculation after posting
*/
private Set<Long> validateFixedDepositLegs(final JsonArray entries, final String requestType) {
final Set<Long> fdIds = new LinkedHashSet<>();
Long firstFdId = null;
Long fdClientId = null;
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) {
continue;
}
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId);
if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) {
throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only"
+ " through its maturity or premature closure.");
}
if (!fdIds.add(accountId)) {
throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once,"
+ " with its full principal in one entry.");
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount);
if (firstFdId == null) {
firstFdId = accountId;
fdClientId = clientId;
} else if (!Objects.equals(fdClientId, clientId)) {
throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer"
+ " only. Raise a separate request for each customer.");
}
}
if (fdIds.isEmpty()) {
return fdIds;
}
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type)
|| !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(),
"CURRENT".equals(type), amount);
}
return fdIds;
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -57,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -85,6 +86,7 @@ public class ClerkWithdrawalRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
@@ -127,6 +129,12 @@ public class ClerkWithdrawalRequestApiResource {
} else if (savingsAccountId == null) {
throw apiError(400, "Savings account id is required for a withdrawal request");
}
// Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip.
// Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account.
if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) {
throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from."
+ " Money leaves a fixed deposit only through its maturity or premature closure.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero");
}
@@ -392,6 +392,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
// An FD is funded once, with exactly its principal (see FixedDepositFundingValidator).
new FixedDepositFundingValidator(this.jdbcTemplate).validateFirstFunding(savingsId, transactionAmount);
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validatePreviousDayClosed();
@@ -422,6 +425,20 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Transactional
@Override
public void recalculateFixedDepositMaturity(final Long savingsId) {
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@Transactional
@Override
public CommandProcessingResult updateDepositAmountForRDAccount(Long savingsId, JsonCommand command) {
@@ -48,7 +48,6 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.event.business.domain.deposit.FixedDepositAccountCreateBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.deposit.RecurringDepositAccountCreateBusinessEvent;
@@ -184,16 +183,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy,
DepositAccountType.FIXED_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Fixed Deposit account with the same product.",
"productId", account.productId());
}
}
// A client may hold any number of Fixed Deposits on the same product, even while an earlier one is
// still active, so there is deliberately no one-active-account-per-product check here.
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
@@ -269,16 +260,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(command,
submittedBy, DepositAccountType.RECURRING_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Recurring Deposit account with the same product.",
"productId", account.productId());
}
}
// A client may hold any number of Recurring Deposits on the same product, even while an earlier one
// is still active, so there is deliberately no one-active-account-per-product check here.
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
@@ -0,0 +1,238 @@
/**
* 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.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The rules a fixed deposit is funded under, shared by every path money can reach an FD by: the FD deposit
* command itself (cash at the counter, approved cashier deposit requests) and the clerk's Bank Transfer,
* Fund Transfer and Disbursement requests, which credit the account directly.
*
* <p>
* An FD opens with no money in it and is funded exactly once, with exactly its principal. A second credit
* would silently change the principal the maturity was agreed on; anything more goes into a new FD (several
* FDs on the same product are allowed). Money never leaves an FD through these paths either - only through
* its maturity or premature closure.
*
* <p>
* When the money comes from the customer's own Savings or Current account, that account must be able to pay
* it: active, not debit-frozen, out of any lock-in, belonging to the FD's client (or held jointly with them),
* and holding enough available balance once holds, liens, clearing holds and its minimum balance are set
* aside. The KYC debit restriction on that account is checked by the callers through KycRestrictionGuard.
*/
@Component
@RequiredArgsConstructor
public class FixedDepositFundingValidator {
/** m_savings_account.deposit_type_enum (Fineract DepositAccountType). */
public static final int DEPOSIT_TYPE_SAVINGS = 100;
public static final int DEPOSIT_TYPE_FIXED = 200;
private static final int STATUS_ACTIVE = 300;
private static final int TXN_TYPE_DEPOSIT = 1;
/** SavingsAccountSubStatusEnum.BLOCK (no debits or credits) and BLOCK_DEBIT. */
private static final int SUB_STATUS_BLOCK = 400;
private static final int SUB_STATUS_BLOCK_DEBIT = 600;
private final JdbcTemplate jdbcTemplate;
/** Whether this m_savings_account id is a fixed deposit (a CURRENT account id must not be passed here). */
public boolean isFixedDeposit(final Long savingsAccountId) {
if (savingsAccountId == null) {
return false;
}
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
Integer.class, savingsAccountId, DEPOSIT_TYPE_FIXED);
return count != null && count > 0;
}
/**
* The FD's first and only funding: the account must be an active, not yet matured fixed deposit with no
* money in it, and the amount must be exactly its principal.
*
* @return the FD's client id, for the source-account checks
*/
public Long validateFirstFunding(final Long fdAccountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT s.account_no, s.status_enum, s.deposit_type_enum, s.client_id, s.account_balance_derived,
t.deposit_amount, t.maturity_date
FROM m_savings_account s
LEFT JOIN m_deposit_account_term_and_preclosure t ON t.savings_account_id = s.id
WHERE s.id = ?
""", fdAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.account.not.found", "Fixed deposit account " + fdAccountId + " was not found.");
}
final Map<String, Object> fd = rows.get(0);
final String accountNo = (String) fd.get("account_no");
if (intOf(fd.get("deposit_type_enum")) != DEPOSIT_TYPE_FIXED) {
throw fail("error.msg.fd.funding.not.a.fixed.deposit", "Account " + accountNo + " is not a fixed deposit.");
}
if (intOf(fd.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.not.active",
"Fixed deposit " + accountNo + " is not active. Only an active fixed deposit can be funded.");
}
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final LocalDate maturityDate = fd.get("maturity_date") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (maturityDate != null && !businessDate.isBefore(maturityDate)) {
throw fail("error.msg.fd.funding.matured",
"Fixed deposit " + accountNo + " has reached its maturity date (" + maturityDate + ") and can no longer be funded.");
}
final Integer deposits = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE savings_account_id = ? AND transaction_type_enum = ?"
+ " AND is_reversed = false",
Integer.class, fdAccountId, TXN_TYPE_DEPOSIT);
if ((deposits != null && deposits > 0) || amountOf(fd.get("account_balance_derived")).signum() > 0) {
throw fail("error.msg.fd.funding.already.funded", "Fixed deposit " + accountNo
+ " is already funded. A fixed deposit is funded only once - open a new fixed deposit for more money.");
}
final BigDecimal principal = fd.get("deposit_amount") == null ? null : amountOf(fd.get("deposit_amount"));
if (principal == null || principal.signum() <= 0) {
throw fail("error.msg.fd.funding.principal.missing", "Fixed deposit " + accountNo + " has no deposit amount (principal) set.");
}
if (amount == null || amount.compareTo(principal) != 0) {
throw fail("error.msg.fd.funding.amount.mismatch", "Amount " + (amount == null ? "0" : amount.toPlainString())
+ " for fixed deposit " + accountNo + " must be exactly its principal " + principal.toPlainString() + " - not less, not more.");
}
return fd.get("client_id") == null ? null : ((Number) fd.get("client_id")).longValue();
}
/**
* The Savings or Current account an FD is being funded from.
*
* @param fdAccountId
* the FD being funded
* @param fdClientId
* its client, from {@link #validateFirstFunding}
* @param sourceAccountId
* m_savings_account id, or m_current_account id when {@code sourceIsCurrent}
*/
public void validateFundingSource(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final BigDecimal amount) {
final List<Map<String, Object>> rows = sourceIsCurrent ? jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, account_balance_derived, on_hold_funds_derived,
total_savings_amount_on_hold, min_required_balance, enforce_min_required_balance,
allow_overdraft, overdraft_limit, lockedin_until_date_derived
FROM m_current_account WHERE id = ?
""", sourceAccountId) : jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, deposit_type_enum, account_balance_derived,
on_hold_funds_derived, total_savings_amount_on_hold, total_savings_amount_on_hold_clearing,
min_required_balance, enforce_min_required_balance, lockedin_until_date_derived
FROM m_savings_account WHERE id = ?
""", sourceAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.source.not.found", "Source account " + sourceAccountId + " was not found.");
}
final Map<String, Object> src = rows.get(0);
final String accountNo = (String) src.get("account_no");
if (!sourceIsCurrent && intOf(src.get("deposit_type_enum")) != DEPOSIT_TYPE_SAVINGS) {
throw fail("error.msg.fd.funding.source.type",
"Account " + accountNo + " cannot fund a fixed deposit - only a Savings or Current account can.");
}
if (intOf(src.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.source.not.active", "Source account " + accountNo + " is not active.");
}
final int subStatus = intOf(src.get("sub_status_enum"));
if (subStatus == SUB_STATUS_BLOCK || subStatus == SUB_STATUS_BLOCK_DEBIT) {
throw fail("error.msg.fd.funding.source.debit.frozen", "Source account " + accountNo + " is blocked for debits (debit freeze).");
}
final LocalDate lockedUntil = src.get("lockedin_until_date_derived") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (lockedUntil != null && DateUtils.getBusinessLocalDate().isBefore(lockedUntil)) {
throw fail("error.msg.fd.funding.source.locked.in", "Source account " + accountNo + " is locked-in until " + lockedUntil + ".");
}
final Long sourceClientId = src.get("client_id") == null ? null : ((Number) src.get("client_id")).longValue();
if (!sameClientOrJointHolder(fdAccountId, fdClientId, sourceAccountId, sourceIsCurrent, sourceClientId)) {
throw fail("error.msg.fd.funding.source.other.client", "Source account " + accountNo
+ " does not belong to the fixed deposit's customer. Fund an FD only from the customer's own account"
+ " or one they hold jointly.");
}
BigDecimal available = amountOf(src.get("account_balance_derived")).subtract(amountOf(src.get("on_hold_funds_derived")))
.subtract(amountOf(src.get("total_savings_amount_on_hold")));
if (!sourceIsCurrent) {
available = available.subtract(amountOf(src.get("total_savings_amount_on_hold_clearing")));
}
if (Boolean.TRUE.equals(src.get("enforce_min_required_balance"))) {
available = available.subtract(amountOf(src.get("min_required_balance")));
}
if (sourceIsCurrent && Boolean.TRUE.equals(src.get("allow_overdraft"))) {
available = available.add(amountOf(src.get("overdraft_limit")));
}
if (amount == null || available.compareTo(amount) < 0) {
throw fail("error.msg.fd.funding.source.insufficient", "Source account " + accountNo + " has an available balance of "
+ available.max(BigDecimal.ZERO).toPlainString() + " (after holds, lien and minimum balance), less than "
+ (amount == null ? "0" : amount.toPlainString()) + ".");
}
}
private boolean sameClientOrJointHolder(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final Long sourceClientId) {
if (fdClientId == null) {
return false;
}
if (Objects.equals(fdClientId, sourceClientId)) {
return true;
}
// The FD's customer is a joint holder on the source account ...
final Integer onSource = sourceIsCurrent
? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_current_account_joint_holder WHERE current_account_id = ? AND client_id = ?",
Integer.class, sourceAccountId, fdClientId)
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
sourceAccountId, fdClientId);
if (onSource != null && onSource > 0) {
return true;
}
// ... or the source account's customer is a joint holder on the FD.
if (sourceClientId == null) {
return false;
}
final Integer onFd = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
fdAccountId, sourceClientId);
return onFd != null && onFd > 0;
}
private static int intOf(final Object value) {
return value instanceof Number n ? n.intValue() : -1;
}
private static BigDecimal amountOf(final Object value) {
return value instanceof BigDecimal b ? b : value instanceof Number n ? new BigDecimal(n.toString()) : BigDecimal.ZERO;
}
private static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -178,4 +178,6 @@
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0604_cic_credit_bureau_module.xml" relativeToChangelogFile="true"/>
<include file="parts/0605_cic_enquiry_alerts.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,294 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
CIC / Credit Bureau Enquiry module (CIBIL TransUnion, Experian, Equifax, CRIF High Mark).
m_cic_bureau - the bank's bureau master: whitelisted or not, environment, connection and credentials
(the secret is stored encrypted with the tenant master password).
m_cic_bureau_endpoint - per bureau and enquiry type: method, path, headers and a base JSON body.
m_cic_field_mapping - the API mapping, maintained from the UI: REQUEST rows put a CBS field into the bureau's
request at a JSON path (with a transform); RESPONSE rows read a JSON path of the bureau's
response into a CBS result field (score, accounts, overdue ...).
m_cic_consent - the borrower's consent to a bureau pull, required before any enquiry.
m_cic_enquiry - every enquiry: masked request, raw response, normalised result, score and status.
Every bureau is seeded NOT whitelisted, in SIMULATOR mode, with a generic request/response mapping the built-in
simulator answers to. Going live is: enter the bureau's URL and credentials, adapt the mapping to the bureau's own
specification, switch the environment, and whitelist it.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0604-1-cic-tables" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cic_bureau"/></not>
</preConditions>
<createTable tableName="m_cic_bureau">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="code" type="VARCHAR(30)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_bureau_code"/></column>
<column name="name" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="description" type="VARCHAR(500)"/>
<column name="whitelisted" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="is_default" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="environment" type="VARCHAR(20)" defaultValue="SIMULATOR"><constraints nullable="false"/></column>
<column name="base_url" type="VARCHAR(500)"/>
<column name="member_code" type="VARCHAR(100)"/>
<column name="auth_type" type="VARCHAR(40)" defaultValue="NONE"><constraints nullable="false"/></column>
<column name="username" type="VARCHAR(200)"/>
<column name="api_key_header" type="VARCHAR(100)" defaultValue="X-API-KEY"/>
<column name="secret_enc" type="TEXT"/>
<column name="token_url" type="VARCHAR(500)"/>
<column name="client_id" type="VARCHAR(200)"/>
<column name="timeout_ms" type="INT" defaultValueNumeric="30000"><constraints nullable="false"/></column>
<column name="report_validity_days" type="INT" defaultValueNumeric="30"><constraints nullable="false"/></column>
<column name="enquiry_charge" type="DECIMAL(19,6)" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="last_test_status" type="VARCHAR(20)"/>
<column name="last_test_message" type="VARCHAR(1000)"/>
<column name="last_tested_on" type="TIMESTAMP"/>
<column name="updated_by" type="BIGINT"/>
<column name="updated_on" type="TIMESTAMP"/>
</createTable>
<createTable tableName="m_cic_bureau_endpoint">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_endpoint_bureau" references="m_cic_bureau(id)"/></column>
<column name="endpoint_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="http_method" type="VARCHAR(10)" defaultValue="POST"><constraints nullable="false"/></column>
<column name="path" type="VARCHAR(500)"/>
<column name="content_type" type="VARCHAR(100)" defaultValue="application/json"/>
<column name="headers_json" type="TEXT"/>
<column name="base_body" type="TEXT"/>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
<column name="updated_by" type="BIGINT"/>
<column name="updated_on" type="TIMESTAMP"/>
</createTable>
<addUniqueConstraint tableName="m_cic_bureau_endpoint" columnNames="bureau_id, endpoint_type" constraintName="uq_cic_endpoint_type"/>
<createTable tableName="m_cic_field_mapping">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="endpoint_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_mapping_endpoint" references="m_cic_bureau_endpoint(id)" deleteCascade="true"/></column>
<column name="direction" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="source_field" type="VARCHAR(200)"/>
<column name="target_field" type="VARCHAR(200)"><constraints nullable="false"/></column>
<column name="transform" type="VARCHAR(40)" defaultValue="NONE"/>
<column name="default_value" type="VARCHAR(200)"/>
<column name="is_mandatory" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="sort_order" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_cic_field_mapping" indexName="idx_cic_mapping_endpoint"><column name="endpoint_id"/></createIndex>
<createTable tableName="m_cic_consent">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="consent_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_consent_ref"/></column>
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="client_name" type="VARCHAR(200)"/>
<column name="application_no" type="VARCHAR(50)"/>
<column name="bureau_id" type="BIGINT"/>
<column name="purpose" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="consent_mode" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="consent_text" type="TEXT"/>
<column name="document_ref" type="VARCHAR(200)"/>
<column name="otp_reference" type="VARCHAR(100)"/>
<column name="valid_from" type="DATE"><constraints nullable="false"/></column>
<column name="valid_until" type="DATE"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"><constraints nullable="false"/></column>
<column name="revoked_reason" type="VARCHAR(500)"/>
<column name="revoked_by" type="BIGINT"/>
<column name="revoked_on" type="TIMESTAMP"/>
<column name="captured_by" type="BIGINT"/>
<column name="captured_by_name" type="VARCHAR(200)"/>
<column name="captured_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="ip_address" type="VARCHAR(64)"/>
</createTable>
<createIndex tableName="m_cic_consent" indexName="idx_cic_consent_client"><column name="client_id"/></createIndex>
<createTable tableName="m_cic_enquiry">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="enquiry_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_enquiry_ref"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="client_name" type="VARCHAR(200)"/>
<column name="application_no" type="VARCHAR(50)"/>
<column name="consent_id" type="BIGINT"/>
<column name="enquiry_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="purpose" type="VARCHAR(40)"/>
<column name="loan_amount" type="DECIMAL(19,6)"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="score" type="INT"/>
<column name="http_status" type="INT"/>
<column name="error_message" type="VARCHAR(2000)"/>
<column name="request_masked" type="TEXT"/>
<column name="response_raw" type="TEXT"/>
<column name="result_json" type="TEXT"/>
<column name="reused_from_id" type="BIGINT"/>
<column name="charge_amount" type="DECIMAL(19,6)"/>
<column name="environment" type="VARCHAR(20)"/>
<column name="office_id" type="BIGINT"/>
<column name="requested_by" type="BIGINT"/>
<column name="requested_by_name" type="VARCHAR(200)"/>
<column name="requested_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="duration_ms" type="INT"/>
</createTable>
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_client"><column name="client_id"/></createIndex>
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_bureau_date"><column name="bureau_id"/><column name="requested_on"/></createIndex>
</changeSet>
<!-- The four RBI-licensed CICs. Seeded off (not whitelisted) and in SIMULATOR mode. -->
<changeSet id="0604-2-cic-bureaus" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau</sqlCheck>
</preConditions>
<insert tableName="m_cic_bureau">
<column name="code" value="CIBIL"/><column name="name" value="TransUnion CIBIL"/>
<column name="description" value="TransUnion CIBIL Limited - consumer and commercial credit information"/>
<column name="is_default" valueBoolean="true"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="EXPERIAN"/><column name="name" value="Experian Credit Information Company of India"/>
<column name="description" value="Experian India - consumer and commercial credit information"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="EQUIFAX"/><column name="name" value="Equifax Credit Information Services"/>
<column name="description" value="Equifax India - consumer, commercial and microfinance credit information"/>
</insert>
<insert tableName="m_cic_bureau">
<column name="code" value="CRIF"/><column name="name" value="CRIF High Mark Credit Information Services"/>
<column name="description" value="CRIF High Mark - consumer, commercial and microfinance credit information"/>
</insert>
</changeSet>
<!-- A CONSUMER_ENQUIRY endpoint per bureau, with a generic body skeleton the simulator understands. -->
<changeSet id="0604-3-cic-endpoints" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau_endpoint</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
SELECT b.id, 'CONSUMER_ENQUIRY', 'POST', '/consumer/enquiry', 'application/json',
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{enquiry.reference}}"}',
'{"requestHeader":{"product":"CONSUMER_REPORT","version":"1.0"},"consumer":{},"enquiry":{}}',
TRUE
FROM m_cic_bureau b
</sql>
</changeSet>
<!-- The generic API mapping. Adapt it to each bureau's own specification from the API Mapping screen. -->
<changeSet id="0604-4-cic-mappings" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_field_mapping</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
FROM m_cic_bureau_endpoint e
CROSS JOIN (
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
UNION ALL SELECT 'REQUEST', 'enquiry.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
UNION ALL SELECT 'REQUEST', 'enquiry.date', 'requestHeader.enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 30
UNION ALL SELECT 'REQUEST', 'client.firstName', 'consumer.name.firstName', 'UPPER', NULL, TRUE, 40
UNION ALL SELECT 'REQUEST', 'client.middleName', 'consumer.name.middleName', 'UPPER', NULL, FALSE, 50
UNION ALL SELECT 'REQUEST', 'client.lastName', 'consumer.name.lastName', 'UPPER', NULL, FALSE, 60
UNION ALL SELECT 'REQUEST', 'client.dob', 'consumer.dateOfBirth', 'DATE_DDMMYYYY', NULL, TRUE, 70
UNION ALL SELECT 'REQUEST', 'client.gender', 'consumer.gender', 'GENDER_MF', NULL, FALSE, 80
UNION ALL SELECT 'REQUEST', NULL, 'consumer.ids[0].type', 'NONE', 'PAN', FALSE, 90
UNION ALL SELECT 'REQUEST', 'client.pan', 'consumer.ids[0].number', 'UPPER', NULL, TRUE, 100
UNION ALL SELECT 'REQUEST', NULL, 'consumer.phones[0].type', 'NONE', 'MOBILE', FALSE, 110
UNION ALL SELECT 'REQUEST', 'client.mobile', 'consumer.phones[0].number', 'DIGITS_ONLY', NULL, FALSE, 120
UNION ALL SELECT 'REQUEST', 'client.email', 'consumer.email', 'LOWER', NULL, FALSE, 130
UNION ALL SELECT 'REQUEST', 'client.addressLine1', 'consumer.address.line1', 'UPPER', NULL, FALSE, 140
UNION ALL SELECT 'REQUEST', 'client.addressLine2', 'consumer.address.line2', 'UPPER', NULL, FALSE, 150
UNION ALL SELECT 'REQUEST', 'client.city', 'consumer.address.city', 'UPPER', NULL, FALSE, 160
UNION ALL SELECT 'REQUEST', 'client.state', 'consumer.address.stateCode', 'STATE_CODE', NULL, FALSE, 170
UNION ALL SELECT 'REQUEST', 'client.pincode', 'consumer.address.pincode', 'DIGITS_ONLY', NULL, FALSE, 180
UNION ALL SELECT 'REQUEST', 'enquiry.amount', 'enquiry.amount', 'AMOUNT_INTEGER', NULL, TRUE, 190
UNION ALL SELECT 'REQUEST', 'enquiry.purposeCode', 'enquiry.purpose', 'NONE', NULL, TRUE, 200
UNION ALL SELECT 'RESPONSE', 'report.hit', 'hit', 'NONE', NULL, FALSE, 10
UNION ALL SELECT 'RESPONSE', 'report.reportId', 'reportId', 'NONE', NULL, FALSE, 20
UNION ALL SELECT 'RESPONSE', 'report.score.value', 'score', 'NUMBER', NULL, FALSE, 30
UNION ALL SELECT 'RESPONSE', 'report.score.version', 'scoreVersion', 'NONE', NULL, FALSE, 40
UNION ALL SELECT 'RESPONSE', 'report.summary.totalAccounts', 'totalAccounts', 'NUMBER', NULL, FALSE, 50
UNION ALL SELECT 'RESPONSE', 'report.summary.activeAccounts', 'activeAccounts', 'NUMBER', NULL, FALSE, 60
UNION ALL SELECT 'RESPONSE', 'report.summary.overdueAccounts', 'overdueAccounts', 'NUMBER', NULL, FALSE, 70
UNION ALL SELECT 'RESPONSE', 'report.summary.writtenOffAccounts', 'writtenOffAccounts', 'NUMBER', NULL, FALSE, 80
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOutstanding', 'totalOutstanding', 'NUMBER', NULL, FALSE, 90
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOverdue', 'totalOverdue', 'NUMBER', NULL, FALSE, 100
UNION ALL SELECT 'RESPONSE', 'report.summary.enquiriesLast6Months', 'enquiriesLast6Months', 'NUMBER', NULL, FALSE, 110
UNION ALL SELECT 'RESPONSE', 'report.summary.maxDpd', 'maxDpd', 'NUMBER', NULL, FALSE, 120
UNION ALL SELECT 'RESPONSE', 'report.accounts', 'accounts', 'NONE', NULL, FALSE, 130
UNION ALL SELECT 'RESPONSE', 'report.enquiries', 'enquiries', 'NONE', NULL, FALSE, 140
) m
</sql>
</changeSet>
<!-- Permissions. Configuration (UPDATE_CICBUREAU) is left to super users; enquiry and consent go to every role that
already works Loan Origination, which also gets the new top-nav menu. -->
<changeSet id="0604-5-cic-permissions" author="cbsnexora">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'heading_menu', 'MENU_CREDIT_BUREAU', 'MENU', 'CREDIT_BUREAU', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'MENU_CREDIT_BUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICBUREAU', 'CICBUREAU', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICBUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'UPDATE_CICBUREAU', 'CICBUREAU', 'UPDATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICBUREAU');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICENQUIRY', 'CICENQUIRY', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICENQUIRY');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'CREATE_CICENQUIRY', 'CICENQUIRY', 'CREATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICENQUIRY');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICCONSENT', 'CICCONSENT', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICCONSENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'CREATE_CICCONSENT', 'CICCONSENT', 'CREATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICCONSENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'REVOKE_CICCONSENT', 'CICCONSENT', 'REVOKE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVOKE_CICCONSENT');
</sql>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT DISTINCT rp.role_id, p2.id
FROM m_role_permission rp
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_LOAN_ORIGINATION'
JOIN m_permission p2 ON p2.code IN ('MENU_CREDIT_BUREAU', 'READ_CICBUREAU', 'READ_CICENQUIRY', 'CREATE_CICENQUIRY',
'READ_CICCONSENT', 'CREATE_CICCONSENT')
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
</sql>
</changeSet>
<changeSet id="0604-6-cic-menu-access" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_tenant_menu_access"/>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_CREDIT_BUREAU'</sqlCheck>
</and>
</preConditions>
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CREDIT_BUREAU"/></insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
CIC module, second shape: the bank no longer pulls credit reports. Instead the CIC tells the bank when one of
ITS customers was enquired by ANOTHER bank / NBFC - i.e. the customer applied for a loan elsewhere - with the
lender, loan type and amount. These "enquiry alerts" are fetched through the same bureau configuration and
UI-driven API mapping (endpoint type ENQUIRY_ALERT) and shown on the Credit Bureau dashboard.
Removed: borrower consent (m_cic_consent), report enquiries (m_cic_enquiry), their permissions, and the
CONSUMER_ENQUIRY endpoints / mappings.
Added: m_cic_alert (one row per enquiry made on our customer at another lender),
m_cic_alert_fetch_run (every fetch / file import with its counts and raw response),
alert fetch settings on m_cic_bureau, the ENQUIRY_ALERT endpoint and its generic mapping.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0605-1-drop-cic-enquiry-consent" author="cbsnexora">
<sql>
DROP TABLE IF EXISTS m_cic_enquiry;
DROP TABLE IF EXISTS m_cic_consent;
DELETE FROM m_cic_field_mapping
WHERE endpoint_id IN (SELECT id FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY'));
DELETE FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY');
DELETE FROM m_role_permission
WHERE permission_id IN (SELECT id FROM m_permission
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT',
'REVOKE_CICCONSENT'));
DELETE FROM m_permission
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT', 'REVOKE_CICCONSENT');
</sql>
</changeSet>
<changeSet id="0605-2-cic-bureau-alert-settings" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_cic_bureau" columnName="report_validity_days"/>
</preConditions>
<dropColumn tableName="m_cic_bureau" columnName="report_validity_days"/>
<dropColumn tableName="m_cic_bureau" columnName="enquiry_charge"/>
<addColumn tableName="m_cic_bureau">
<column name="alert_auto_fetch" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
<column name="alert_fetch_interval_min" type="INT" defaultValueNumeric="60"><constraints nullable="false"/></column>
<column name="alert_lookback_days" type="INT" defaultValueNumeric="7"><constraints nullable="false"/></column>
<column name="last_fetch_on" type="TIMESTAMP"/>
<column name="last_fetch_status" type="VARCHAR(20)"/>
<column name="last_fetch_message" type="VARCHAR(1000)"/>
</addColumn>
</changeSet>
<changeSet id="0605-3-cic-alert-tables" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cic_alert"/></not>
</preConditions>
<createTable tableName="m_cic_alert_fetch_run">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="run_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_fetch_run_ref"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="trigger_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="environment" type="VARCHAR(20)"/>
<column name="from_date" type="DATE"/>
<column name="to_date" type="DATE"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="http_status" type="INT"/>
<column name="received" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="new_alerts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="matched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="unmatched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="duplicates" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="own_bank" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="error_message" type="VARCHAR(2000)"/>
<column name="request_masked" type="TEXT"/>
<column name="response_raw" type="TEXT"/>
<column name="duration_ms" type="INT"/>
<column name="triggered_by" type="BIGINT"/>
<column name="triggered_by_name" type="VARCHAR(200)"/>
<column name="started_on" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_cic_alert_fetch_run" indexName="idx_cic_fetch_run_bureau"><column name="bureau_id"/><column name="started_on"/></createIndex>
<createTable tableName="m_cic_alert">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="bureau_code" type="VARCHAR(30)"/>
<column name="alert_key" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="client_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="client_name" type="VARCHAR(200)"/>
<column name="client_account_no" type="VARCHAR(50)"/>
<column name="match_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="customer_name" type="VARCHAR(200)"/>
<column name="pan_masked" type="VARCHAR(20)"/>
<column name="aadhaar_masked" type="VARCHAR(20)"/>
<column name="customer_ref" type="VARCHAR(100)"/>
<column name="date_of_birth" type="DATE"/>
<column name="institution_code" type="VARCHAR(100)"/>
<column name="institution_name" type="VARCHAR(200)"/>
<column name="institution_type" type="VARCHAR(60)"/>
<column name="enquiry_date" type="DATE"/>
<column name="loan_type" type="VARCHAR(100)"/>
<column name="amount" type="DECIMAL(19,6)"/>
<column name="purpose" type="VARCHAR(200)"/>
<column name="location" type="VARCHAR(200)"/>
<column name="status" type="VARCHAR(20)" defaultValue="NEW"><constraints nullable="false"/></column>
<column name="remarks" type="VARCHAR(1000)"/>
<column name="actioned_by" type="BIGINT"/>
<column name="actioned_by_name" type="VARCHAR(200)"/>
<column name="actioned_on" type="TIMESTAMP"/>
<column name="received_via" type="VARCHAR(20)"/>
<column name="fetch_run_id" type="BIGINT"/>
<column name="received_on" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="raw_json" type="TEXT"/>
</createTable>
<addUniqueConstraint tableName="m_cic_alert" columnNames="bureau_id, alert_key" constraintName="uq_cic_alert_key"/>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_client"><column name="client_id"/></createIndex>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_status"><column name="status"/></createIndex>
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_enquiry_date"><column name="enquiry_date"/></createIndex>
</changeSet>
<!-- An ENQUIRY_ALERT endpoint per bureau, with a generic body the built-in simulator answers to. -->
<changeSet id="0605-4-cic-alert-endpoints" author="cbsnexora">
<sql>
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
SELECT b.id, 'ENQUIRY_ALERT', 'POST', '/monitoring/enquiry-alerts', 'application/json',
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{fetch.reference}}"}',
'{"requestHeader":{"product":"ENQUIRY_ALERT","version":"1.0"},"criteria":{}}',
TRUE
FROM m_cic_bureau b
WHERE NOT EXISTS (SELECT 1 FROM m_cic_bureau_endpoint e WHERE e.bureau_id = b.id AND e.endpoint_type = 'ENQUIRY_ALERT');
</sql>
<sql>
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
FROM m_cic_bureau_endpoint e
CROSS JOIN (
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
UNION ALL SELECT 'REQUEST', 'fetch.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
UNION ALL SELECT 'REQUEST', 'fetch.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', NULL, TRUE, 30
UNION ALL SELECT 'REQUEST', 'fetch.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', NULL, TRUE, 40
UNION ALL SELECT 'REQUEST', NULL, 'criteria.scope', 'NONE', 'OTHER_MEMBERS', FALSE, 50
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorCode', 'errorCode', 'NONE', NULL, FALSE, 10
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorMessage', 'errorMessage', 'NONE', NULL, FALSE, 20
UNION ALL SELECT 'RESPONSE', 'data.alerts', 'alerts', 'NONE', NULL, FALSE, 30
UNION ALL SELECT 'RESPONSE', 'alertId', 'alertRef', 'NONE', NULL, FALSE, 40
UNION ALL SELECT 'RESPONSE', 'consumer.pan', 'pan', 'UPPER', NULL, FALSE, 50
UNION ALL SELECT 'RESPONSE', 'consumer.aadhaar', 'aadhaar', 'NONE', NULL, FALSE, 60
UNION ALL SELECT 'RESPONSE', 'consumer.name', 'customerName', 'UPPER', NULL, FALSE, 70
UNION ALL SELECT 'RESPONSE', 'consumer.dateOfBirth', 'dateOfBirth', 'DATE_DDMMYYYY', NULL, FALSE, 80
UNION ALL SELECT 'RESPONSE', 'consumer.memberReference', 'customerRef', 'NONE', NULL, FALSE, 90
UNION ALL SELECT 'RESPONSE', 'enquiry.memberCode', 'institutionCode', 'NONE', NULL, FALSE, 100
UNION ALL SELECT 'RESPONSE', 'enquiry.memberName', 'institutionName', 'NONE', NULL, FALSE, 110
UNION ALL SELECT 'RESPONSE', 'enquiry.memberType', 'institutionType', 'NONE', NULL, FALSE, 120
UNION ALL SELECT 'RESPONSE', 'enquiry.date', 'enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 130
UNION ALL SELECT 'RESPONSE', 'enquiry.productType', 'loanType', 'NONE', NULL, FALSE, 140
UNION ALL SELECT 'RESPONSE', 'enquiry.amount', 'amount', 'NUMBER', NULL, FALSE, 150
UNION ALL SELECT 'RESPONSE', 'enquiry.purpose', 'purpose', 'NONE', NULL, FALSE, 160
UNION ALL SELECT 'RESPONSE', 'enquiry.location', 'location', 'NONE', NULL, FALSE, 170
) m
WHERE e.endpoint_type = 'ENQUIRY_ALERT'
AND NOT EXISTS (SELECT 1 FROM m_cic_field_mapping x WHERE x.endpoint_id = e.id);
</sql>
</changeSet>
<!-- Alert permissions; every role that has the Credit Bureau menu may read, work and fetch alerts. -->
<changeSet id="0605-5-cic-alert-permissions" author="cbsnexora">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'READ_CICALERT', 'CICALERT', 'READ', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICALERT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'UPDATE_CICALERT', 'CICALERT', 'UPDATE', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICALERT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cic', 'FETCH_CICALERT', 'CICALERT', 'FETCH', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'FETCH_CICALERT');
</sql>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT DISTINCT rp.role_id, p2.id
FROM m_role_permission rp
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_CREDIT_BUREAU'
JOIN m_permission p2 ON p2.code IN ('READ_CICALERT', 'UPDATE_CICALERT', 'FETCH_CICALERT')
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
</sql>
</changeSet>
</databaseChangeLog>
@@ -39,6 +39,13 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult depositToFDAccount(Long savingsId, JsonCommand command);
/**
* Recomputes a fixed deposit's maturity date and amount from its real transaction history. Used after an FD
* is funded by a direct book credit (clerk Bank Transfer / Fund Transfer / Disbursement), which does not go
* through {@link #depositToFDAccount}.
*/
void recalculateFixedDepositMaturity(Long savingsId);
CommandProcessingResult depositToRDAccount(Long savingsId, JsonCommand command);
CommandProcessingResult withdrawal(Long savingsId, JsonCommand command, DepositAccountType depositAccountType);