Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a2c00d75 | ||
|
|
a8f8aa39b7 | ||
|
|
3a320b9c09 | ||
|
|
8ba0420398 | ||
|
|
6be32272c1 | ||
|
|
b47a34b5ef | ||
|
|
04d1eb20f0 | ||
|
|
da1e21f2ca | ||
|
|
b1eea68b17 |
+6
@@ -60,6 +60,7 @@ import org.apache.fineract.infrastructure.core.api.DateParam;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.DateFormat;
|
||||
import org.apache.fineract.infrastructure.core.data.UploadRequest;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
@@ -229,6 +230,11 @@ public class JournalEntriesApiResource {
|
||||
@PathParam("transactionId") @Parameter(description = "transactionId") final String transactionId,
|
||||
@QueryParam("command") @Parameter(description = "command") final String commandParam) {
|
||||
CommandProcessingResult result;
|
||||
if (is(commandParam, "reverse")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.journalentry.reverse.not.allowed",
|
||||
"Reversing a journal entry is not allowed.");
|
||||
}
|
||||
|
||||
if (is(commandParam, "reverse")) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().reverseJournalEntry(transactionId).withJson(jsonRequestBody)
|
||||
.build();
|
||||
|
||||
+1
-1
@@ -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}.
|
||||
|
||||
+135
-110
@@ -16,6 +16,8 @@ import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -29,6 +31,7 @@ import org.apache.fineract.portfolio.denominationmaster.service.DenominationBrea
|
||||
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetail;
|
||||
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetailRepository;
|
||||
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeRepository;
|
||||
import org.apache.fineract.portfolio.client.data.ClientKycApiConstants;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -50,6 +53,12 @@ public class CashExchangeApiResource {
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* Above this amount the customer's PAN must be recorded: Income Tax Rule 114B requires PAN for cash
|
||||
* transactions of more than Rs 50,000 in a day, and RBI cash-handling guidelines follow it.
|
||||
*/
|
||||
static final BigDecimal PAN_MANDATORY_ABOVE = new BigDecimal("50000");
|
||||
|
||||
@POST
|
||||
@Transactional
|
||||
public String create(String body) {
|
||||
@@ -78,15 +87,25 @@ public class CashExchangeApiResource {
|
||||
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
|
||||
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
|
||||
? json.get("customerIdProof").getAsString() : null;
|
||||
final String customerName = trimToNull(json, "customerName");
|
||||
final String customerMobile = trimToNull(json, "customerMobile");
|
||||
final String panInput = trimToNull(json, "panNumber");
|
||||
final String panNumber = panInput != null ? panInput.toUpperCase(java.util.Locale.ROOT) : null;
|
||||
|
||||
final JsonArray received = json.has("received") && json.get("received").isJsonArray() ? json.getAsJsonArray("received")
|
||||
: new JsonArray();
|
||||
final JsonArray given = json.has("given") && json.get("given").isJsonArray() ? json.getAsJsonArray("given") : new JsonArray();
|
||||
|
||||
final BigDecimal receivedTotal = sumTotal(received);
|
||||
final BigDecimal givenTotal = sumTotal(given);
|
||||
// Each line is checked against the denomination master and carries the master's value: the amount
|
||||
// of an exchange is never taken from a value the caller sent.
|
||||
final Map<Long, Integer> masterValues = denominationValues();
|
||||
final List<Leg> receivedLegs = parseLegs("received", received, masterValues);
|
||||
final List<Leg> givenLegs = parseLegs("given", given, masterValues);
|
||||
|
||||
if (!hasPositiveCount(received) || !hasPositiveCount(given)) {
|
||||
final BigDecimal receivedTotal = sumTotal(receivedLegs);
|
||||
final BigDecimal givenTotal = sumTotal(givenLegs);
|
||||
|
||||
if (receivedLegs.isEmpty() || givenLegs.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashexchange.empty",
|
||||
"Both received and given denominations are required.");
|
||||
}
|
||||
@@ -99,6 +118,19 @@ public class CashExchangeApiResource {
|
||||
"Both received and given denominations are required.");
|
||||
}
|
||||
|
||||
// PAN is checked whenever it is given, and required above Rs 50,000. The screen enforced the
|
||||
// threshold on its own, through a free-text "PAN / Aadhaar" box, so an API call -- or any
|
||||
// text in that box -- recorded a large exchange with no PAN behind it.
|
||||
if (panNumber != null && !panNumber.matches(ClientKycApiConstants.PAN_REGEX)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.invalid",
|
||||
"PAN must be 5 letters, 4 digits and 1 letter (e.g. ABCDE1234F).", "panNumber", panNumber);
|
||||
}
|
||||
if (receivedTotal.compareTo(PAN_MANDATORY_ABOVE) > 0 && panNumber == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.required",
|
||||
"Customer PAN is required for a cash exchange above Rs 50,000 (amount " + receivedTotal + ").", "panNumber",
|
||||
receivedTotal);
|
||||
}
|
||||
|
||||
CashExchange header = new CashExchange();
|
||||
header.setAccountId(accountId);
|
||||
header.setAccountNo(accountNo);
|
||||
@@ -106,7 +138,12 @@ public class CashExchangeApiResource {
|
||||
header.setExchangeAmount(receivedTotal);
|
||||
header.setExchangeDate(exchangeDate);
|
||||
header.setNote(note);
|
||||
header.setCustomerIdProof(customerIdProof);
|
||||
// The Excess Transaction (CTR) report reads customer_id_proof, so the verified PAN fills it when
|
||||
// nothing else was sent.
|
||||
header.setCustomerIdProof(customerIdProof != null && !customerIdProof.isBlank() ? customerIdProof : panNumber);
|
||||
header.setCustomerName(customerName);
|
||||
header.setCustomerMobile(customerMobile);
|
||||
header.setPanNumber(panNumber);
|
||||
header.setIsReversed(false);
|
||||
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
try {
|
||||
@@ -114,91 +151,48 @@ public class CashExchangeApiResource {
|
||||
if (user != null) {
|
||||
header.setPerformedBy(user.getId());
|
||||
header.setPerformedByUsername(user.getUsername());
|
||||
// Auto-detect cashier linked to this user
|
||||
try {
|
||||
Long cashierId = jdbcTemplate.queryForObject(
|
||||
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
|
||||
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
|
||||
Long.class, user.getId());
|
||||
header.setCashierId(cashierId);
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// resolving the current user must never block recording the exchange
|
||||
// who made the entry is recorded when it can be read
|
||||
}
|
||||
|
||||
// Validate that cashier has enough of each denomination being GIVEN to the customer
|
||||
if (header.getCashierId() != null) {
|
||||
final java.util.Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(header.getCashierId());
|
||||
final java.util.List<String> shortages = new java.util.ArrayList<>();
|
||||
for (JsonElement el : given) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count <= 0) continue;
|
||||
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) continue;
|
||||
long denomId = item.get("denominationId").getAsLong();
|
||||
long available = stock.getOrDefault(denomId, 0L);
|
||||
if (count > available) {
|
||||
int denomValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||
? item.get("denominationValue").getAsInt() : 0;
|
||||
shortages.add("₹" + denomValue + " (need " + count + ", available " + available + ")");
|
||||
}
|
||||
}
|
||||
if (!shortages.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException(
|
||||
"error.msg.cashier.denomination.insufficient",
|
||||
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
|
||||
// An exchange takes notes into one cashier's drawer and hands change out of it, so only the
|
||||
// cashier behind this login can record it, and it is always theirs. It used to go through with no
|
||||
// cashier at all when the login was not a cashier's: the entry then belonged to no drawer, moved no
|
||||
// cashier's position, and skipped the stock check below.
|
||||
final Long cashierId = cashierRoleHelper.getCurrentCashierId();
|
||||
if (cashierId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashexchange.cashier.required",
|
||||
"A cash exchange can only be recorded by a cashier. Your login is not linked to a cashier.");
|
||||
}
|
||||
header.setCashierId(cashierId);
|
||||
|
||||
// The cashier has to hold, denomination by denomination, what they hand to the customer. A
|
||||
// denomination is counted across all its lines (parseLegs merges them): checked line by line, two
|
||||
// lines of 3 against a stock of 5 each passed although 6 were being given.
|
||||
final Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(cashierId);
|
||||
final List<String> shortages = new ArrayList<>();
|
||||
for (final Leg leg : givenLegs) {
|
||||
final long available = stock.getOrDefault(leg.denominationId(), 0L);
|
||||
if (leg.count() > available) {
|
||||
shortages.add("₹" + leg.value() + " (need " + leg.count() + ", available " + available + ")");
|
||||
}
|
||||
}
|
||||
if (!shortages.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashier.denomination.insufficient",
|
||||
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
|
||||
}
|
||||
|
||||
CashExchange saved = repository.saveAndFlush(header);
|
||||
|
||||
saveDetails(saved.getId(), "RECEIVED", received);
|
||||
saveDetails(saved.getId(), "GIVEN", given);
|
||||
saveDetails(saved.getId(), "RECEIVED", receivedLegs);
|
||||
saveDetails(saved.getId(), "GIVEN", givenLegs);
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", saved.getId());
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/reverse")
|
||||
public String reverse(@PathParam("id") Long id, String body) {
|
||||
// Reversing moves the same cash back, so it is gated the same way REVERSE_CASHTRANSFER and
|
||||
// REVERSE_CASHSETTLEMENT already are. See create() above and migration 0539.
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHEXCHANGE");
|
||||
|
||||
CashExchange exchange = repository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
|
||||
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashexchange.already.reversed",
|
||||
"Cash exchange " + id + " is already reversed.");
|
||||
}
|
||||
String reason = null;
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
reason = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null;
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
exchange.setIsReversed(true);
|
||||
exchange.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
exchange.setReverseReason(reason);
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
if (user != null) exchange.setReversedBy(user.getId());
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
repository.save(exchange);
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("reversed", true);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
public String getAll(
|
||||
@QueryParam("accountId") Long accountId,
|
||||
@@ -238,52 +232,80 @@ public class CashExchangeApiResource {
|
||||
return gson.toJson(map);
|
||||
}
|
||||
|
||||
private BigDecimal sumTotal(JsonArray items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||
? item.get("denominationValue").getAsInt()
|
||||
: 0;
|
||||
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count > 0) {
|
||||
total = total.add(BigDecimal.valueOf((long) value * count));
|
||||
/** One checked line of an exchange: a denomination from the master, the master's value for it, and how many. */
|
||||
private record Leg(long denominationId, int value, int count) {
|
||||
}
|
||||
|
||||
/** The value of every denomination in the master (a deleted one is gone; a discontinued one is still there). */
|
||||
private Map<Long, Integer> denominationValues() {
|
||||
final Map<Long, Integer> values = new HashMap<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate
|
||||
.queryForList("SELECT id, denomination FROM m_denomination_master WHERE deleted = false")) {
|
||||
values.put(((Number) row.get("id")).longValue(), ((Number) row.get("denomination")).intValue());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines of one side with a count, each checked against the denomination master and merged per
|
||||
* denomination. A line must name a known denomination, and a value the caller sent for it has to be the
|
||||
* master's: with the caller's value trusted, a request could pass a ₹10 note off as ₹500, make the two
|
||||
* sides "equal", and shift a drawer's books by the difference. A discontinued denomination is accepted --
|
||||
* a customer can still bring withdrawn notes to be exchanged.
|
||||
*/
|
||||
private List<Leg> parseLegs(final String side, final JsonArray items, final Map<Long, Integer> masterValues) {
|
||||
final Map<Long, Leg> merged = new LinkedHashMap<>();
|
||||
for (final JsonElement el : items) {
|
||||
final JsonObject item = el.getAsJsonObject();
|
||||
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.required",
|
||||
"Every " + side + " line needs a denomination.");
|
||||
}
|
||||
final long id = item.get("denominationId").getAsLong();
|
||||
final Integer value = masterValues.get(id);
|
||||
if (value == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.unknown",
|
||||
"Unknown denomination (id " + id + ") in the " + side + " notes.");
|
||||
}
|
||||
if (item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||
&& item.get("denominationValue").getAsInt() != value) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.value.mismatch",
|
||||
"Denomination " + id + " is ₹" + value + ", not ₹" + item.get("denominationValue").getAsInt() + ".");
|
||||
}
|
||||
merged.merge(id, new Leg(id, value, count), (a, b) -> new Leg(id, value, a.count() + b.count()));
|
||||
}
|
||||
return new ArrayList<>(merged.values());
|
||||
}
|
||||
|
||||
private BigDecimal sumTotal(final List<Leg> legs) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Leg leg : legs) {
|
||||
total = total.add(BigDecimal.valueOf((long) leg.value() * leg.count()));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private boolean hasPositiveCount(JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count > 0) {
|
||||
return true;
|
||||
}
|
||||
private static String trimToNull(final JsonObject json, final String key) {
|
||||
if (!json.has(key) || json.get(key).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
return false;
|
||||
final String value = json.get(key).getAsString().trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
private void saveDetails(Long exchangeId, String direction, JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count <= 0) {
|
||||
continue;
|
||||
}
|
||||
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||
? item.get("denominationValue").getAsInt()
|
||||
: 0;
|
||||
Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
|
||||
? item.get("denominationId").getAsLong()
|
||||
: null;
|
||||
CashExchangeDetail detail = new CashExchangeDetail();
|
||||
private void saveDetails(final Long exchangeId, final String direction, final List<Leg> legs) {
|
||||
for (final Leg leg : legs) {
|
||||
final CashExchangeDetail detail = new CashExchangeDetail();
|
||||
detail.setExchangeId(exchangeId);
|
||||
detail.setDirection(direction);
|
||||
detail.setDenominationId(denominationId);
|
||||
detail.setDenominationValue(value);
|
||||
detail.setCount(count);
|
||||
detail.setTotalAmount(BigDecimal.valueOf((long) value * count));
|
||||
detail.setDenominationId(leg.denominationId());
|
||||
detail.setDenominationValue(leg.value());
|
||||
detail.setCount(leg.count());
|
||||
detail.setTotalAmount(BigDecimal.valueOf((long) leg.value() * leg.count()));
|
||||
detailRepository.save(detail);
|
||||
}
|
||||
}
|
||||
@@ -296,6 +318,9 @@ public class CashExchangeApiResource {
|
||||
map.put("accountNo", e.getAccountNo());
|
||||
map.put("clientId", e.getClientId());
|
||||
map.put("customerIdProof", e.getCustomerIdProof());
|
||||
map.put("customerName", e.getCustomerName());
|
||||
map.put("customerMobile", e.getCustomerMobile());
|
||||
map.put("panNumber", e.getPanNumber());
|
||||
map.put("exchangeAmount", e.getExchangeAmount());
|
||||
map.put("exchangeDate", e.getExchangeDate() != null ? e.getExchangeDate().toString() : null);
|
||||
map.put("note", e.getNote());
|
||||
|
||||
+11
@@ -52,6 +52,17 @@ public class CashExchange {
|
||||
@Column(name = "customer_id_proof", length = 50)
|
||||
private String customerIdProof;
|
||||
|
||||
/** Walk-in customer's name; for an existing client, their display name at the time of the exchange. */
|
||||
@Column(name = "customer_name", length = 150)
|
||||
private String customerName;
|
||||
|
||||
@Column(name = "customer_mobile", length = 20)
|
||||
private String customerMobile;
|
||||
|
||||
/** PAN verified at the counter; mandatory when the exchange is above Rs 50,000. */
|
||||
@Column(name = "pan_number", length = 10)
|
||||
private String panNumber;
|
||||
|
||||
@Column(name = "performed_by")
|
||||
private Long performedBy;
|
||||
|
||||
|
||||
+7
@@ -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);
|
||||
|
||||
|
||||
+90
-45
@@ -74,6 +74,15 @@ public class CashInterCashierApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.same.cashier",
|
||||
"From-cashier and To-cashier cannot be the same.");
|
||||
}
|
||||
// The cash leaves the sender's drawer, so only the sender can hand it over: the logged-in cashier
|
||||
// is always the from-cashier. Nobody -- a manager, another cashier -- records a transfer out of
|
||||
// somebody else's drawer.
|
||||
final Long ownCashierId = cashierRoleHelper.getCurrentCashierId();
|
||||
if (ownCashierId == null || !ownCashierId.equals(fromCashierId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.from.must.be.own",
|
||||
"An inter-cashier transfer can only be made by the cashier handing the cash over, from their own"
|
||||
+ " drawer.");
|
||||
}
|
||||
|
||||
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
@@ -92,6 +101,13 @@ public class CashInterCashierApiResource {
|
||||
"At least one denomination with a positive count is required.");
|
||||
}
|
||||
|
||||
// Neither side can take part before the vault has allocated them cash for the day: a cashier
|
||||
// who never opened their till has no morning allocation behind their drawer, so they can
|
||||
// neither hand cash on nor take it in. Checked first so that is the message they see, not a
|
||||
// shortfall on some denomination.
|
||||
validateCashAllocated(fromCashierId, transferDate, "from");
|
||||
validateCashAllocated(toCashierId, transferDate, "to");
|
||||
|
||||
// The sending cashier has to actually hold these notes. Cash transfer validates the vault's
|
||||
// stock and settlement validates the cashier's; this register validated neither, so a cashier
|
||||
// holding five ₹500 notes could hand 200 of them to a colleague. Both sides are then wrong
|
||||
@@ -134,45 +150,6 @@ public class CashInterCashierApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/reverse")
|
||||
@Transactional
|
||||
public String reverse(@PathParam("id") Long id, String body) {
|
||||
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHINTERCASHIER");
|
||||
|
||||
CashInterCashier record = repository.findById(id)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashintercashier.not.found",
|
||||
"Inter-cashier transfer not found: " + id));
|
||||
if (Boolean.TRUE.equals(record.getIsReversed())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.already.reversed",
|
||||
"Inter-cashier transfer " + id + " is already reversed.");
|
||||
}
|
||||
|
||||
String reason = null;
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
|
||||
AppUser user = null;
|
||||
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
|
||||
record.setIsReversed(true);
|
||||
record.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
record.setReverseReason(reason);
|
||||
if (user != null) record.setReversedBy(user.getId());
|
||||
repository.save(record);
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("reversed", true);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
public String getAll(
|
||||
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
|
||||
@@ -209,6 +186,77 @@ public class CashInterCashierApiResource {
|
||||
return gson.toJson(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* One cashier's details, today's vault allocation and denomination-wise cash, so the inter-cashier
|
||||
* screen can show both sides before the transfer is recorded. The Cashier Position endpoint cannot
|
||||
* serve this: for a plain cashier it ignores {@code cashierId} and returns their own position, so
|
||||
* the receiving cashier's figures would never show.
|
||||
*/
|
||||
@GET
|
||||
@Path("cashier-summary")
|
||||
public String cashierSummary(@QueryParam("cashierId") final Long cashierId, @QueryParam("date") final String date) {
|
||||
context.authenticatedUser().validateHasReadPermission("CASHINTERCASHIER");
|
||||
if (cashierId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.required", "cashierId is required.");
|
||||
}
|
||||
final LocalDate asOf = date != null && !date.isBlank() ? LocalDate.parse(date) : DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT c.id AS cashier_id, s.display_name AS staff_name, c.teller_id, t.name AS teller_name,
|
||||
t.office_id, o.name AS office_name
|
||||
FROM m_cashiers c
|
||||
LEFT JOIN m_staff s ON s.id = c.staff_id
|
||||
LEFT JOIN m_tellers t ON t.id = c.teller_id
|
||||
LEFT JOIN m_office o ON o.id = t.office_id
|
||||
WHERE c.id = ?
|
||||
""", cashierId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.not.found",
|
||||
"Cashier not found: " + cashierId);
|
||||
}
|
||||
final Map<String, Object> row = rows.get(0);
|
||||
final BigDecimal allocated = allocatedAmount(cashierId, asOf);
|
||||
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(cashierId, asOf.toString());
|
||||
|
||||
final Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("cashierId", cashierId);
|
||||
resp.put("cashierName", row.get("staff_name"));
|
||||
resp.put("tellerId", row.get("teller_id"));
|
||||
resp.put("tellerName", row.get("teller_name"));
|
||||
resp.put("officeId", row.get("office_id"));
|
||||
resp.put("officeName", row.get("office_name"));
|
||||
resp.put("asOfDate", asOf.toString());
|
||||
resp.put("cashAllocated", allocated.signum() > 0);
|
||||
resp.put("allocatedAmount", allocated);
|
||||
resp.put("totalAmount", position.get("totalAmount"));
|
||||
resp.put("denominations", position.get("denominations"));
|
||||
return gson.toJson(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash the vault allocated to the cashier on the date, from the same non-reversed
|
||||
* {@code m_cash_transfer} rows the "No Cash Allocated" gate on the transaction screens counts.
|
||||
*/
|
||||
private BigDecimal allocatedAmount(final Long cashierId, final LocalDate date) {
|
||||
final BigDecimal amount = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct
|
||||
WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)
|
||||
AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
|
||||
""", BigDecimal.class, cashierId, date.toString());
|
||||
return amount != null ? amount : BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
private void validateCashAllocated(final Long cashierId, final LocalDate date, final String side) {
|
||||
if (allocatedAmount(cashierId, date).signum() > 0) {
|
||||
return;
|
||||
}
|
||||
final String role = "from".equals(side) ? "sending" : "receiving";
|
||||
throw new PlatformDataIntegrityException("error.msg.cashintercashier." + side + ".cashier.cash.not.allocated",
|
||||
"The " + role + " cashier has not been allocated cash from the vault for " + date
|
||||
+ ". Allocate cash to them through Cash Transfer before an inter-cashier transfer.",
|
||||
side + "CashierId", cashierId);
|
||||
}
|
||||
|
||||
private BigDecimal sumTotal(JsonArray items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (JsonElement el : items) {
|
||||
@@ -231,8 +279,8 @@ public class CashInterCashierApiResource {
|
||||
* Same check, same source of truth and same message shape as
|
||||
* {@code CashSettlementApiResource.validateCashierHoldsDenominations} -- the Cashier Position
|
||||
* figure, so a cashier cannot hand over notes that report says they are not holding. An empty
|
||||
* position is left alone rather than refused, matching the settlement path: a cashier with no
|
||||
* recorded position yet is a setup state, not an overdraft.
|
||||
* position means the cashier holds nothing: create() only gets here for a cashier the vault has
|
||||
* allocated cash to, so an empty position is cash already handed on, not a setup state.
|
||||
*/
|
||||
private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations,
|
||||
final LocalDate transferDate) {
|
||||
@@ -242,12 +290,9 @@ public class CashInterCashierApiResource {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
|
||||
if (heldRows == null || heldRows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<Long, Long> held = new java.util.HashMap<>();
|
||||
for (final Map<String, Object> row : heldRows) {
|
||||
for (final Map<String, Object> row : heldRows == null ? List.<Map<String, Object>>of() : heldRows) {
|
||||
final Object id = row.get("denominationId");
|
||||
final Object count = row.get("count");
|
||||
if (id instanceof Number && count instanceof Number) {
|
||||
|
||||
+34
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
|
||||
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
|
||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -46,8 +47,26 @@ public class CashReconciliationApiResource {
|
||||
private final CashReconciliationDetailRepository detailRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* A cashier reconciles only their own drawer. The cashier is picked on the screen, and two cashiers
|
||||
* under one teller could name each other: one cashier's count then stands in for another's, and the
|
||||
* reconciliation record is what a settlement is later allowed on the strength of. A user who is not
|
||||
* a cashier-only login (a manager, the branch head) is not limited.
|
||||
*/
|
||||
private void requireOwnCashier(final Long cashierId) {
|
||||
if (!cashierRoleHelper.isCashierOnly()) {
|
||||
return;
|
||||
}
|
||||
final Long own = cashierRoleHelper.getCurrentCashierId();
|
||||
if (own == null || !own.equals(cashierId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.other.cashier",
|
||||
"A cashier can only reconcile their own cash.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
|
||||
* Returns denomination-wise expected balance for a cashier.
|
||||
@@ -64,6 +83,7 @@ public class CashReconciliationApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
|
||||
"cashierId query param is required.");
|
||||
}
|
||||
requireOwnCashier(cashierId);
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
|
||||
// In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
|
||||
@@ -227,6 +247,7 @@ public class CashReconciliationApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
|
||||
"cashierId query param is required.");
|
||||
}
|
||||
requireOwnCashier(cashierId);
|
||||
final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)",
|
||||
@@ -250,6 +271,14 @@ public class CashReconciliationApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
|
||||
"cashierId is required.");
|
||||
}
|
||||
// A reconciliation is the cashier's own count of their own drawer. Unlike reading one (a manager
|
||||
// looks at a cashier's, and day close checks each), recording one is the cashier's alone: neither
|
||||
// another cashier nor a manager can count a drawer on its owner's behalf.
|
||||
final Long ownCashierId = cashierRoleHelper.getCurrentCashierId();
|
||||
if (ownCashierId == null || !ownCashierId.equals(cashierId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.own.cashier.only",
|
||||
"A reconciliation can only be recorded by the cashier whose drawer it is.");
|
||||
}
|
||||
final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull()
|
||||
@@ -338,7 +367,11 @@ public class CashReconciliationApiResource {
|
||||
|
||||
@GET
|
||||
public String getAll() {
|
||||
// A cashier sees their own reconciliations only; everyone else sees all of them.
|
||||
final boolean cashierOnly = cashierRoleHelper.isCashierOnly();
|
||||
final Long ownCashierId = cashierOnly ? cashierRoleHelper.getCurrentCashierId() : null;
|
||||
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
|
||||
.filter(e -> !cashierOnly || (ownCashierId != null && ownCashierId.equals(e.getCashierId())))
|
||||
.map(this::toMap).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@@ -347,6 +380,7 @@ public class CashReconciliationApiResource {
|
||||
public String getOne(@PathParam("id") Long id) {
|
||||
CashReconciliation header = repository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Cash reconciliation not found: " + id));
|
||||
requireOwnCashier(header.getCashierId());
|
||||
Map<String, Object> map = toMap(header);
|
||||
map.put("details", detailRepository.findByReconciliationId(id).stream()
|
||||
.map(this::detailToMap).collect(Collectors.toList()));
|
||||
|
||||
+146
-1
@@ -14,11 +14,13 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
@@ -35,6 +37,7 @@ public class CashReportApiResource {
|
||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final VoucherService voucherService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
@@ -328,7 +331,18 @@ public class CashReportApiResource {
|
||||
BigDecimal priorCreditTotal = BigDecimal.ZERO;
|
||||
BigDecimal priorDebitTotal = BigDecimal.ZERO;
|
||||
|
||||
// Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on
|
||||
// the voucher that replaced it, so it must not be counted in the day's totals a second time.
|
||||
final Map<String, Map<String, Object>> marks = voucherService.voucherMarks(
|
||||
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final Map<String, Object> mark = marks.get((String) row.get("voucherNo"));
|
||||
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
|
||||
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
|
||||
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
|
||||
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
|
||||
final boolean revoked = "REVOKED".equals(row.get("voucherStatus"));
|
||||
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
||||
continue;
|
||||
}
|
||||
@@ -349,7 +363,7 @@ public class CashReportApiResource {
|
||||
// was already earned, and the day it belongs to has been counted and signed off. Adding
|
||||
// it to this day's totals would make them disagree with the ledger for either date.
|
||||
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
|
||||
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||
if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
if (amount != null) {
|
||||
@@ -1083,6 +1097,137 @@ public class CashReportApiResource {
|
||||
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /cashreports/branch-position?officeId=X&asOfDate=yyyy-MM-dd
|
||||
*
|
||||
* The notes and coins a branch holds, denomination by denomination: its vault(s) plus every cashier's
|
||||
* drawer. Cash moving between the vault and a drawer, or from one drawer to another, nets out in this
|
||||
* total; what changes it is what crosses the counter -- a deposit, a withdrawal, an exchange. An exchange
|
||||
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) is +10 on ₹500 and -50 on ₹100 here, and the
|
||||
* "exchangeNet" column carries that day's exchange movement beside the total so it can be seen.
|
||||
*
|
||||
* Until now no screen showed this total: the vault is untouched by an exchange and each drawer is shown
|
||||
* alone, so what the branch held per denomination was only known from the hand-entered count at day
|
||||
* open and close. Not for a cashier -- it spans every drawer; they see their own on Cashier Position.
|
||||
*/
|
||||
@GET
|
||||
@Path("branch-position")
|
||||
public String branchPosition(@QueryParam("officeId") final Long officeIdParam, @QueryParam("asOfDate") final String asOfDate) {
|
||||
if (cashierRoleHelper.isCashierOnly()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.not.for.cashier",
|
||||
"The branch cash position covers every drawer; a cashier sees their own on the Cashier Position screen.");
|
||||
}
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
|
||||
: DateUtils.getBusinessLocalDate().toString();
|
||||
final Long officeId = officeIdParam != null ? officeIdParam : context.authenticatedUser().getOffice().getId();
|
||||
final List<String> officeNames = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
|
||||
if (officeNames.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.office.not.found",
|
||||
"Office not found: " + officeId);
|
||||
}
|
||||
|
||||
// One row per denomination in the master; a discontinued one (the ₹2000 note) shows only while
|
||||
// something is still held or moved in it.
|
||||
final Map<Long, Map<String, Object>> rows = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> m : jdbcTemplate
|
||||
.queryForList("SELECT id, denomination, kind, is_active FROM m_denomination_master WHERE deleted = false")) {
|
||||
final long id = ((Number) m.get("id")).longValue();
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", id);
|
||||
row.put("denominationValue", ((Number) m.get("denomination")).intValue());
|
||||
row.put("kind", m.get("kind"));
|
||||
row.put("active", Boolean.TRUE.equals(m.get("is_active")));
|
||||
row.put("vaultCount", 0L);
|
||||
row.put("cashierCount", 0L);
|
||||
row.put("exchangeNet", 0L);
|
||||
rows.put(id, row);
|
||||
}
|
||||
|
||||
boolean vaultHasBreakdown = false;
|
||||
for (final Long tellerId : jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ? ORDER BY id", Long.class,
|
||||
officeId)) {
|
||||
final Map<String, Object> vault = vaultPositionService.buildPositionForVault(tellerId, dateFilter);
|
||||
vaultHasBreakdown = vaultHasBreakdown || Boolean.TRUE.equals(vault.get("hasBreakdown"));
|
||||
addPositionCounts(rows, vault, "vaultCount");
|
||||
}
|
||||
for (final Long cashierId : jdbcTemplate.queryForList(
|
||||
"SELECT mc.id FROM m_cashiers mc JOIN m_tellers t ON t.id = mc.teller_id WHERE t.office_id = ? ORDER BY mc.id",
|
||||
Long.class, officeId)) {
|
||||
addPositionCounts(rows, buildPositionForCashier(cashierId, dateFilter), "cashierCount");
|
||||
}
|
||||
|
||||
// The day's exchanges across the branch's cashiers: notes taken in less notes given out, per denomination.
|
||||
for (final Map<String, Object> r : jdbcTemplate.queryForList("""
|
||||
SELECT ced.denomination_id,
|
||||
SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE -ced.count END) AS net
|
||||
FROM m_cash_exchange_detail ced
|
||||
JOIN m_cash_exchange ce ON ce.id = ced.exchange_id
|
||||
JOIN m_cashiers mc ON mc.id = ce.cashier_id
|
||||
JOIN m_tellers t ON t.id = mc.teller_id
|
||||
WHERE t.office_id = ? AND ce.exchange_date = CAST(? AS DATE)
|
||||
AND (ce.is_reversed IS NULL OR ce.is_reversed = false)
|
||||
GROUP BY ced.denomination_id
|
||||
""", officeId, dateFilter)) {
|
||||
final Object id = r.get("denomination_id");
|
||||
final Object net = r.get("net");
|
||||
if (id instanceof Number && net instanceof Number && rows.containsKey(((Number) id).longValue())) {
|
||||
rows.get(((Number) id).longValue()).put("exchangeNet", ((Number) net).longValue());
|
||||
}
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
BigDecimal vaultAmount = BigDecimal.ZERO;
|
||||
BigDecimal cashiersAmount = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : rows.values()) {
|
||||
final long vaultCount = ((Number) row.get("vaultCount")).longValue();
|
||||
final long cashierCount = ((Number) row.get("cashierCount")).longValue();
|
||||
final long exchangeNet = ((Number) row.get("exchangeNet")).longValue();
|
||||
final int value = ((Number) row.get("denominationValue")).intValue();
|
||||
if (!Boolean.TRUE.equals(row.get("active")) && vaultCount == 0 && cashierCount == 0 && exchangeNet == 0) {
|
||||
continue;
|
||||
}
|
||||
row.put("totalCount", vaultCount + cashierCount);
|
||||
row.put("totalAmount", BigDecimal.valueOf(value * (vaultCount + cashierCount)));
|
||||
vaultAmount = vaultAmount.add(BigDecimal.valueOf(value * vaultCount));
|
||||
cashiersAmount = cashiersAmount.add(BigDecimal.valueOf(value * cashierCount));
|
||||
lines.add(row);
|
||||
}
|
||||
lines.sort((a, b) -> Integer.compare(((Number) b.get("denominationValue")).intValue(),
|
||||
((Number) a.get("denominationValue")).intValue()));
|
||||
|
||||
final Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("officeId", officeId);
|
||||
resp.put("officeName", officeNames.get(0));
|
||||
resp.put("asOfDate", dateFilter);
|
||||
resp.put("vaultHasBreakdown", vaultHasBreakdown);
|
||||
resp.put("vaultAmount", vaultAmount);
|
||||
resp.put("cashiersAmount", cashiersAmount);
|
||||
resp.put("totalAmount", vaultAmount.add(cashiersAmount));
|
||||
resp.put("denominations", lines);
|
||||
return gson.toJson(resp);
|
||||
}
|
||||
|
||||
/** Adds a position's per-denomination counts (a vault's or a cashier's) into one field of the rows. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private void addPositionCounts(final Map<Long, Map<String, Object>> rows, final Map<String, Object> position,
|
||||
final String field) {
|
||||
final List<Map<String, Object>> lines = (List<Map<String, Object>>) position.get("denominations");
|
||||
if (lines == null) {
|
||||
return;
|
||||
}
|
||||
for (final Map<String, Object> line : lines) {
|
||||
final Object id = line.get("denominationId");
|
||||
final Object count = line.get("count");
|
||||
if (!(id instanceof Number) || !(count instanceof Number)) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, Object> row = rows.get(((Number) id).longValue());
|
||||
if (row != null) {
|
||||
row.merge(field, ((Number) count).longValue(), (a, b) -> ((Number) a).longValue() + ((Number) b).longValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
|
||||
* Returns denomination-wise cash received by the cashier from vault on the given date
|
||||
|
||||
-59
@@ -33,7 +33,6 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
|
||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
|
||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
|
||||
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -181,64 +180,6 @@ public class CashSettlementApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/reverse")
|
||||
@Transactional
|
||||
public String reverse(@PathParam("id") Long id, String body) {
|
||||
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHSETTLEMENT");
|
||||
|
||||
CashSettlement settlement = repository.findById(id)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashsettlement.not.found",
|
||||
"Settlement not found: " + id));
|
||||
if (Boolean.TRUE.equals(settlement.getIsReversed())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.already.reversed",
|
||||
"Settlement " + id + " is already reversed.");
|
||||
}
|
||||
|
||||
String reason = null;
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
|
||||
AppUser user = null;
|
||||
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
|
||||
settlement.setIsReversed(true);
|
||||
settlement.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
settlement.setReverseReason(reason);
|
||||
if (user != null) settlement.setReversedBy(user.getId());
|
||||
repository.save(settlement);
|
||||
|
||||
if (settlement.getToTellerId() != null && settlement.getFromCashierId() != null) {
|
||||
try {
|
||||
Long glTellerId = settlement.getToTellerId();
|
||||
try {
|
||||
Long resolved = jdbcTemplate.queryForObject(
|
||||
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, settlement.getFromCashierId());
|
||||
if (resolved != null) glTellerId = resolved;
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
glDispatchService.allocateCashToCashier(glTellerId,
|
||||
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("reversed", true);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("check")
|
||||
public String check(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) {
|
||||
|
||||
+178
-62
@@ -35,7 +35,6 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
@@ -58,9 +57,23 @@ public class CashTransferApiResource {
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
|
||||
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
|
||||
* {@link #postTransfer}.
|
||||
*/
|
||||
@POST
|
||||
public String create(String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
|
||||
"A cash transfer is raised as a request and posted when a manager approves it.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
|
||||
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
|
||||
* when it is approved, since the vault can change in between.
|
||||
*/
|
||||
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
|
||||
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
|
||||
@@ -103,6 +116,9 @@ public class CashTransferApiResource {
|
||||
}
|
||||
|
||||
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
|
||||
if (dryRun) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
CashTransfer header = new CashTransfer();
|
||||
header.setTransferDate(transferDate);
|
||||
@@ -113,15 +129,9 @@ public class CashTransferApiResource {
|
||||
header.setTransferAmount(total);
|
||||
header.setNote(note);
|
||||
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
if (user != null) {
|
||||
header.setPerformedBy(user.getId());
|
||||
header.setPerformedByUsername(user.getUsername());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// resolving the current user must never block recording the transfer
|
||||
}
|
||||
// Recorded against the person who raised the request, not the one who approved it.
|
||||
header.setPerformedBy(performedBy);
|
||||
header.setPerformedByUsername(performedByUsername);
|
||||
|
||||
// Save header + details in one committed transaction, then GL dispatch separately.
|
||||
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
|
||||
@@ -160,65 +170,171 @@ public class CashTransferApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
|
||||
|
||||
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
|
||||
@POST
|
||||
@Path("{id}/reverse")
|
||||
@Transactional
|
||||
public String reverse(@PathParam("id") Long id, String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER");
|
||||
@Path("requests")
|
||||
public String submitRequest(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
|
||||
postTransfer(body, user.getId(), user.getUsername(), true);
|
||||
|
||||
CashTransfer transfer = repository.findById(id)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashtransfer.not.found",
|
||||
"Cash transfer not found: " + id));
|
||||
if (Boolean.TRUE.equals(transfer.getIsReversed())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.already.reversed",
|
||||
"Cash transfer " + id + " is already reversed.");
|
||||
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
|
||||
// a second click would otherwise release the same cash twice once both were approved.
|
||||
final Integer identical = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
|
||||
REQUEST_PENDING, user.getId(), body);
|
||||
if (identical != null && identical > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
|
||||
"An identical cash transfer request from you is already waiting for approval.");
|
||||
}
|
||||
|
||||
String reason = null;
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
transfer.setReversedBy(user.getId());
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
|
||||
transfer.setIsReversed(true);
|
||||
transfer.setReversedAt(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
transfer.setReverseReason(reason);
|
||||
repository.save(transfer);
|
||||
|
||||
if (transfer.getFromTellerId() != null && transfer.getToCashierId() != null) {
|
||||
try {
|
||||
Long glTellerId = transfer.getFromTellerId();
|
||||
try {
|
||||
Long resolved = jdbcTemplate.queryForObject(
|
||||
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, transfer.getToCashierId());
|
||||
if (resolved != null) glTellerId = resolved;
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
glDispatchService.settleCashFromCashier(glTellerId,
|
||||
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
final JsonArray denominations = json.getAsJsonArray("denominations");
|
||||
final Long id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_cash_transfer_request
|
||||
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
|
||||
status, maker_id, maker_name, maker_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
|
||||
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
|
||||
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
|
||||
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("reversed", true);
|
||||
result.addProperty("status", REQUEST_PENDING);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
|
||||
@GET
|
||||
@Path("requests")
|
||||
public String listRequests(@QueryParam("status") final String status,
|
||||
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
|
||||
final List<Object> params = new java.util.ArrayList<>();
|
||||
if (status != null && !status.isBlank()) {
|
||||
final String[] statuses = status.split(",");
|
||||
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
|
||||
for (final String s : statuses) {
|
||||
params.add(s.trim().toUpperCase());
|
||||
}
|
||||
}
|
||||
if (mine) {
|
||||
sql.append(" AND maker_id = ?");
|
||||
params.add(user.getId());
|
||||
}
|
||||
sql.append(" ORDER BY id DESC LIMIT 200");
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
for (final Map<String, Object> row : rows) {
|
||||
// The notes asked for, so the approver sees what they are releasing without opening anything.
|
||||
final Object requestBody = row.remove("request_body");
|
||||
row.put("denominations", requestBody == null ? new JsonArray()
|
||||
: JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations"));
|
||||
}
|
||||
return gson.toJson(rows);
|
||||
}
|
||||
|
||||
/** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */
|
||||
@POST
|
||||
@Path("requests/{id}/approve")
|
||||
public String approveRequest(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
requireChecker(user);
|
||||
final Map<String, Object> r = loadRequest(id);
|
||||
requirePending(r, "approved");
|
||||
final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null;
|
||||
if (makerId != null && makerId.equals(user.getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user",
|
||||
"Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager.");
|
||||
}
|
||||
final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false);
|
||||
final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject();
|
||||
final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null;
|
||||
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
|
||||
transferId, id, REQUEST_PENDING);
|
||||
if (updated == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned",
|
||||
"This request was already actioned by another manager.");
|
||||
}
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("status", REQUEST_APPROVED);
|
||||
if (transferId != null) {
|
||||
result.addProperty("cashTransferId", transferId);
|
||||
}
|
||||
if (postedJson.has("glWarning")) {
|
||||
result.add("glWarning", postedJson.get("glWarning"));
|
||||
}
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("requests/{id}/reject")
|
||||
public String rejectRequest(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
requireChecker(user);
|
||||
final Map<String, Object> r = loadRequest(id);
|
||||
requirePending(r, "rejected");
|
||||
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
|
||||
final String reason = textOrNull(json, "reason");
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject.");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
|
||||
reason.trim(), id, REQUEST_PENDING);
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("status", REQUEST_REJECTED);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
private static final String REQUEST_PENDING = "PENDING";
|
||||
private static final String REQUEST_APPROVED = "APPROVED";
|
||||
private static final String REQUEST_REJECTED = "REJECTED";
|
||||
|
||||
private Map<String, Object> loadRequest(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private void requirePending(final Map<String, Object> request, final String action) {
|
||||
if (!REQUEST_PENDING.equals(request.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status",
|
||||
"Only PENDING requests can be " + action + ". Current status: " + request.get("status"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
|
||||
private void requireChecker(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return;
|
||||
}
|
||||
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||
if (!isChecker) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker",
|
||||
"Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String textOrNull(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
@GET
|
||||
public String getAll(
|
||||
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
|
||||
|
||||
+319
@@ -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));
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.cic.service;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||
import org.apache.fineract.infrastructure.core.domain.ActionContext;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Fetches enquiry alerts automatically: every few minutes, for every tenant, each whitelisted bureau with automatic
|
||||
* fetch on is fetched once its interval has passed since the last fetch. Runs only on the batch-manager node.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CicAlertScheduler {
|
||||
|
||||
private final FineractProperties fineractProperties;
|
||||
private final TenantDetailsService tenantDetailsService;
|
||||
private final BusinessDateReadPlatformService businessDateReadPlatformService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final CicAlertService alertService;
|
||||
|
||||
@Scheduled(fixedDelayString = "${cbs.cic.alert-scheduler-delay-ms:300000}", initialDelayString = "${cbs.cic.alert-scheduler-initial-delay-ms:180000}")
|
||||
public void fetchDueAlerts() {
|
||||
if (!fineractProperties.getMode().isBatchManagerEnabled()) {
|
||||
return;
|
||||
}
|
||||
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
|
||||
try {
|
||||
ThreadLocalContextUtil.setTenant(tenant);
|
||||
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
|
||||
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
|
||||
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||
SELECT id, code, alert_fetch_interval_min, last_fetch_on FROM m_cic_bureau
|
||||
WHERE whitelisted = TRUE AND alert_auto_fetch = TRUE
|
||||
""");
|
||||
final long now = Instant.now().toEpochMilli();
|
||||
for (final Map<String, Object> b : due) {
|
||||
final long intervalMs = ((Number) b.get("alert_fetch_interval_min")).longValue() * 60_000L;
|
||||
final Object last = b.get("last_fetch_on");
|
||||
if (last instanceof Timestamp ts && now - ts.getTime() < intervalMs) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
alertService.fetch(((Number) b.get("id")).longValue(), null, null, "SCHEDULED", null);
|
||||
} catch (final RuntimeException e) {
|
||||
// A bureau that is not ready (no URL, no credential ...) is reported on the run / bureau, not retried here.
|
||||
log.warn("CIC scheduled alert fetch of {} for tenant {} failed: {}", b.get("code"), tenant.getTenantIdentifier(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
// A tenant whose database does not have the CIC tables yet.
|
||||
log.debug("CIC alert scheduler skipped tenant {}: {}", tenant.getTenantIdentifier(), e.getMessage());
|
||||
} finally {
|
||||
ThreadLocalContextUtil.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1041
File diff suppressed because it is too large
Load Diff
+215
@@ -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);
|
||||
}
|
||||
}
|
||||
+463
@@ -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);
|
||||
}
|
||||
}
|
||||
+194
@@ -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);
|
||||
}
|
||||
}
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
@@ -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";
|
||||
}
|
||||
}
|
||||
+81
@@ -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;
|
||||
|
||||
|
||||
+166
-16
@@ -48,6 +48,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -56,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;
|
||||
@@ -84,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 ─────────
|
||||
@@ -110,13 +113,28 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
// A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is
|
||||
// paid from instead of a savings account; it is approved and disbursed exactly like any other slip.
|
||||
final boolean isGl = "GL".equalsIgnoreCase(accountType);
|
||||
final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root);
|
||||
final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root);
|
||||
final Long officeId = fromJsonHelper.extractLongNamed("officeId", root);
|
||||
final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root);
|
||||
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw apiError(400, "Account number is required");
|
||||
}
|
||||
if (savingsAccountId == null) {
|
||||
if (isGl) {
|
||||
validateGlWithdrawalAccounts(glAccountId, contraGlId);
|
||||
} 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");
|
||||
}
|
||||
@@ -124,7 +142,9 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
throw apiError(400, "Token number (TOK. NO.) is required");
|
||||
}
|
||||
validateTokenUniqueToday(tokNo, null);
|
||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
if (!isGl) {
|
||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
}
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
@@ -132,8 +152,9 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
INSERT INTO m_clerk_withdrawal_request
|
||||
(account_no, account_type, savings_account_id, client_id, client_name, amount,
|
||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
|
||||
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
@@ -142,7 +163,9 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
txnDate != null ? Date.valueOf(txnDate) : null,
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
denominationData, activityLog, Timestamp.from(Instant.now())
|
||||
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||
isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null,
|
||||
isGl ? (currencyCode != null ? currencyCode : "INR") : null
|
||||
);
|
||||
|
||||
// Globally unique voucher number from the central m_voucher register
|
||||
@@ -198,7 +221,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
|
||||
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
||||
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
||||
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
|
||||
+ " cashier_id, disbursed_by, disbursed_at, voucher_no,"
|
||||
+ " gl_account_id, contra_gl_id, office_id, currency_code"
|
||||
+ " FROM m_clerk_withdrawal_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
@@ -252,7 +276,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
payment_type_name, description, tok_no, txn_date, status,
|
||||
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
|
||||
submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code
|
||||
FROM m_clerk_withdrawal_request
|
||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray()
|
||||
@@ -276,7 +301,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||
denomination_data, activity_log, submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code
|
||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
@@ -295,7 +321,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
amount, payment_type_id, payment_type_name, description, tok_no, status,
|
||||
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
|
||||
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code
|
||||
FROM m_clerk_withdrawal_request
|
||||
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
|
||||
ORDER BY submitted_at DESC LIMIT 1
|
||||
@@ -455,13 +482,16 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
|
||||
}
|
||||
|
||||
final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type"));
|
||||
final Long savingsAccountId = record.get("savings_account_id") != null
|
||||
? ((Number) record.get("savings_account_id")).longValue() : null;
|
||||
// Checked again on approval, not only on submission: a request can sit in the queue for days,
|
||||
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
|
||||
// below refuses a dormant account anyway, but that failure would surface to the manager as a
|
||||
// rejected transaction after the approval was recorded; this says why, before anything moves.
|
||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
if (!isGlRequest) {
|
||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
}
|
||||
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
||||
final Object ptId = record.get("payment_type_id");
|
||||
final Object txnDateObj = record.get("txn_date");
|
||||
@@ -469,7 +499,11 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final String description = (String) record.get("description");
|
||||
|
||||
String txnId = null;
|
||||
if (savingsAccountId != null) {
|
||||
if (isGlRequest) {
|
||||
// Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal.
|
||||
validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id")));
|
||||
txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description);
|
||||
} else if (savingsAccountId != null) {
|
||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
|
||||
txnId = isCurrentAccount
|
||||
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||
@@ -477,14 +511,18 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
}
|
||||
|
||||
// Link voucher to the created savings txn and stamp voucher_no on it
|
||||
if (txnId != null) {
|
||||
if (txnId != null && !isGlRequest) {
|
||||
voucherService.linkSavingsTransaction(
|
||||
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
||||
}
|
||||
|
||||
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
||||
// debited and the till that pays it out is credited.
|
||||
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
||||
if (isGlRequest) {
|
||||
addGlWithdrawalVoucherLines(record, amount);
|
||||
} else {
|
||||
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||
@@ -689,8 +727,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
if (denomMap == null || denomMap.size() == 0) return;
|
||||
|
||||
final Object txnIdObj = record.get("txn_id");
|
||||
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
|
||||
if (txnId == null) return; // no savings txn linked — cannot record denomination
|
||||
final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null;
|
||||
if (txnId == null) return; // no txn linked — cannot record denomination
|
||||
|
||||
final Object saObj = record.get("savings_account_id");
|
||||
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
|
||||
@@ -793,6 +831,117 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private static Long toLong(final Object value) {
|
||||
return value instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one
|
||||
* Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under
|
||||
* the id of its own journal entry in the cashier's position and closing report.
|
||||
*/
|
||||
private Long parseTxnKey(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(raw);
|
||||
} catch (final NumberFormatException ignored) {
|
||||
try {
|
||||
final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw;
|
||||
return Long.parseUnsignedLong(hex, 16);
|
||||
} catch (final NumberFormatException ignored2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the
|
||||
* GL it is paid from must be one of the branch's mapped cash accounts. */
|
||||
private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) {
|
||||
if (glAccountId == null || contraGlId == null) {
|
||||
throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request");
|
||||
}
|
||||
final List<Long> cashGlIds = jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)",
|
||||
Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue());
|
||||
if (!cashGlIds.contains(contraGlId)) {
|
||||
throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL.");
|
||||
}
|
||||
if (cashGlIds.contains(glAccountId)) {
|
||||
throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is
|
||||
* switched off for manual postings). Returns the journal entry's transaction id. */
|
||||
private String executeGlWithdrawalJournal(final Map<?, ?> record, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject debit = new JsonObject();
|
||||
debit.addProperty("glAccountId", toLong(record.get("gl_account_id")));
|
||||
debit.addProperty("amount", amount);
|
||||
final JsonObject credit = new JsonObject();
|
||||
credit.addProperty("glAccountId", toLong(record.get("contra_gl_id")));
|
||||
credit.addProperty("amount", amount);
|
||||
final JsonArray debits = new JsonArray();
|
||||
debits.add(debit);
|
||||
final JsonArray credits = new JsonArray();
|
||||
credits.add(credit);
|
||||
|
||||
final Object office = record.get("office_id");
|
||||
final Object currency = record.get("currency_code");
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L);
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR");
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("comments", note);
|
||||
}
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
payload.add("credits", credits);
|
||||
payload.add("debits", debits);
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||
// The request raised its own voucher at submission; the journal command must not raise a second.
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getTransactionId();
|
||||
}
|
||||
|
||||
/** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */
|
||||
private void addGlWithdrawalVoucherLines(final Map<?, ?> record, final BigDecimal amount) {
|
||||
final String voucherNo = (String) record.get("voucher_no");
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final Long glId = toLong(record.get("gl_account_id"));
|
||||
final Long contraId = toLong(record.get("contra_gl_id"));
|
||||
final Map<String, Object> gl = glAccountForVoucher(glId);
|
||||
final Map<String, Object> contra = glAccountForVoucher(contraId);
|
||||
voucherService.addLines(voucherNo, List.of(
|
||||
VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount),
|
||||
VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount)));
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return Map.of();
|
||||
}
|
||||
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
@@ -800,7 +949,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||
denomination_data, activity_log, submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code
|
||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
|
||||
+8
-2
@@ -38,14 +38,19 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
|
||||
private static final Set<String> VALID_ACCOUNT_TYPES = Set.of("SAVINGS", "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT");
|
||||
|
||||
// A customer's PAN lives on their KYC record (m_client_kyc, one row per client) and, for some, as a
|
||||
// "PAN Card" ID proof. The KYC record is what the client screens and cash screens read, so it wins;
|
||||
// the ID proof is the fallback. Reading only the ID proof showed "PAN -" for customers whose PAN was
|
||||
// on file, and a search by PAN number found none of them.
|
||||
private static final String BASE_SELECT = """
|
||||
SELECT DISTINCT c.id AS client_id, c.display_name, c.mobile_no, o.name AS office_name,
|
||||
acct.account_no, acct.account_type,
|
||||
pan.document_key AS pan_number, aad.document_key AS aadhaar_number,
|
||||
COALESCE(NULLIF(kyc.pan_number, ''), pan.document_key) AS pan_number, aad.document_key AS aadhaar_number,
|
||||
addr.address_line_1, addr.address_line_2, addr.town_village, addr.city,
|
||||
addr.county_district, addr.postal_code
|
||||
FROM m_client c
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc kyc ON kyc.client_id = c.id
|
||||
LEFT JOIN m_client_identifier pan ON pan.client_id = c.id AND pan.document_type_id = (
|
||||
SELECT id FROM m_code_value WHERE code_value = 'PAN Card'
|
||||
AND code_id = (SELECT id FROM m_code WHERE code_name = 'KYCIdProofType'))
|
||||
@@ -100,7 +105,8 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
params.add(aadhaarNumber.trim());
|
||||
}
|
||||
if (hasPan) {
|
||||
sql.append(" AND UPPER(pan.document_key) = UPPER(?) ");
|
||||
sql.append(" AND (UPPER(kyc.pan_number) = UPPER(?) OR UPPER(pan.document_key) = UPPER(?)) ");
|
||||
params.add(panNumber.trim());
|
||||
params.add(panNumber.trim());
|
||||
}
|
||||
if (hasAddress) {
|
||||
|
||||
+5
@@ -58,6 +58,7 @@ import org.apache.fineract.infrastructure.core.api.jersey.Pagination;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.DateFormat;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
@@ -737,6 +738,10 @@ public class LoanTransactionsApiResource {
|
||||
commandRequest = builder.buyDownFeeAdjustment(resolvedLoanId, resolvedTransactionId).build();
|
||||
} else if (CommandParameterUtil.is(commandParam, INTEREST_REFUND_COMMAND_VALUE)) {
|
||||
commandRequest = builder.manualInterestRefund(resolvedLoanId, resolvedTransactionId).build();
|
||||
} else if (apiRequestBodyAsJson != null
|
||||
&& apiRequestBodyAsJson.matches("(?s).*\"transactionAmount\"\\s*:\\s*\"?0(\\.0*)?\"?\\s*[,}].*")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.loan.transaction.undo.not.allowed",
|
||||
"Undo of a loan transaction is not allowed.");
|
||||
} else { // Default to adjust the Loan Transaction
|
||||
commandRequest = builder.adjustTransaction(resolvedLoanId, resolvedTransactionId).build();
|
||||
}
|
||||
|
||||
+6
@@ -43,6 +43,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
@@ -140,6 +141,11 @@ public class CurrentAccountTransactionsApiResource {
|
||||
@QueryParam("command") final String commandParam,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
|
||||
if (is(commandParam, "undo") || is(commandParam, "reverse")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.currentaccount.transaction.undo.not.allowed",
|
||||
"Undo or reverse of a current account transaction is not allowed.");
|
||||
}
|
||||
|
||||
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
|
||||
|
||||
CommandProcessingResult result = null;
|
||||
|
||||
+6
@@ -40,6 +40,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
@@ -148,6 +149,11 @@ public class FixedDepositAccountTransactionsApiResource {
|
||||
@PathParam("transactionId") final Long transactionId, @QueryParam("command") final String commandParam,
|
||||
final String apiRequestBodyAsJson) {
|
||||
|
||||
if (is(commandParam, DepositsApiConstants.COMMAND_UNDO_TRANSACTION)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.fixeddeposit.transaction.undo.not.allowed",
|
||||
"Undo of a fixed deposit transaction is not allowed.");
|
||||
}
|
||||
|
||||
String jsonApiRequest = apiRequestBodyAsJson;
|
||||
if (StringUtils.isBlank(jsonApiRequest)) {
|
||||
jsonApiRequest = "{}";
|
||||
|
||||
+6
@@ -47,6 +47,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
@@ -202,6 +203,11 @@ public class RecurringDepositAccountTransactionsApiResource {
|
||||
@QueryParam("command") @Parameter(description = "command") final String commandParam,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
|
||||
if (is(commandParam, DepositsApiConstants.COMMAND_UNDO_TRANSACTION)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.recurringdeposit.transaction.undo.not.allowed",
|
||||
"Undo of a recurring deposit transaction is not allowed.");
|
||||
}
|
||||
|
||||
String jsonApiRequest = apiRequestBodyAsJson;
|
||||
if (StringUtils.isBlank(jsonApiRequest)) {
|
||||
jsonApiRequest = "{}";
|
||||
|
||||
+7
@@ -48,6 +48,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
@@ -218,6 +219,12 @@ public class SavingsAccountTransactionsApiResource {
|
||||
public String adjustTransaction(@PathParam("savingsId") final Long savingsId, @PathParam("transactionId") final Long transactionId,
|
||||
@QueryParam("command") final String commandParam, final String apiRequestBodyAsJson) {
|
||||
|
||||
if (is(commandParam, SavingsApiConstants.COMMAND_UNDO_TRANSACTION)
|
||||
|| is(commandParam, SavingsApiConstants.COMMAND_REVERSE_TRANSACTION)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savings.transaction.undo.not.allowed",
|
||||
"Undo or reverse of a savings transaction is not allowed.");
|
||||
}
|
||||
|
||||
String jsonApiRequest = apiRequestBodyAsJson;
|
||||
if (StringUtils.isBlank(jsonApiRequest)) {
|
||||
jsonApiRequest = "{}";
|
||||
|
||||
+17
@@ -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) {
|
||||
|
||||
+4
-21
@@ -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);
|
||||
|
||||
|
||||
+238
@@ -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);
|
||||
}
|
||||
}
|
||||
+623
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* 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.voucher.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import 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.NotFoundException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Corrects a cash deposit that went to the wrong savings account, without ever "undoing" it behind the
|
||||
* cashier's back.
|
||||
*
|
||||
* <ol>
|
||||
* <li>The cashier picks the day's voucher and asks to <b>unpass</b> it (UNPASS_PENDING). Nothing moves.</li>
|
||||
* <li>A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply
|
||||
* waiting for its correction entry.</li>
|
||||
* <li>The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number
|
||||
* (CORRECTION_PENDING).</li>
|
||||
* <li>A manager approves the correction (COMPLETED). Only now do accounts change: the original deposit is
|
||||
* reversed on the wrong account and the same cash is deposited into the right one, under a new voucher; the
|
||||
* original voucher is revoked and the cashier's denominations follow the deposit.</li>
|
||||
* </ol>
|
||||
*
|
||||
* The balance of the wrong account is checked twice, when the cashier enters the correction and again when it is
|
||||
* approved, because the customer may have drawn the money in between.
|
||||
*/
|
||||
@Path("/v1/vouchers/corrections")
|
||||
@Component
|
||||
@Tag(name = "Voucher Corrections", description = "Unpass a wrongly-posted cash deposit voucher and re-post it to the right account.")
|
||||
@RequiredArgsConstructor
|
||||
public class VoucherCorrectionApiResource {
|
||||
|
||||
public static final String REF_VOUCHER_CORRECTION = "VOUCHER_CORRECTION";
|
||||
|
||||
private static final String UNPASS_PENDING = "UNPASS_PENDING";
|
||||
private static final String UNPASSED = "UNPASSED";
|
||||
private static final String UNPASS_REJECTED = "UNPASS_REJECTED";
|
||||
private static final String CORRECTION_PENDING = "CORRECTION_PENDING";
|
||||
private static final String COMPLETED = "COMPLETED";
|
||||
private static final String CLOSED = "CLOSED";
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
private static final int DEPOSIT_TYPE_SAVINGS = 100;
|
||||
|
||||
private static final DateTimeFormatter VOUCHER_DAY = DateTimeFormatter.ofPattern("ddMMyyyy");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final VoucherService voucherService;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ── Today's vouchers the cashier may unpass ──────────────────────────────────
|
||||
|
||||
/** Today's cash deposit vouchers on a savings account that have not been reversed, revoked or taken up already. */
|
||||
@GET
|
||||
@Path("eligible")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String eligible(@QueryParam("search") final String search) {
|
||||
requireUnpasser(this.context.authenticatedUser());
|
||||
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
|
||||
final List<Object> params = new ArrayList<>();
|
||||
params.add("%/" + day + "/%");
|
||||
final StringBuilder sql = new StringBuilder(ELIGIBLE_SQL);
|
||||
if (search != null && !search.isBlank()) {
|
||||
sql.append(" AND (v.voucher_no ILIKE ? OR sa.account_no ILIKE ? OR c.display_name ILIKE ?)");
|
||||
final String like = "%" + search.trim() + "%";
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
}
|
||||
sql.append(" ORDER BY v.id DESC");
|
||||
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
|
||||
}
|
||||
|
||||
private static final String ELIGIBLE_SQL = """
|
||||
SELECT v.voucher_no AS "voucherNo", v.amount, v.created_at AS "createdAt",
|
||||
v.created_by_username AS "cashier", v.savings_txn_id AS "txnId",
|
||||
t.savings_account_id AS "savingsAccountId", sa.account_no AS "accountNo",
|
||||
c.display_name AS "clientName", pt.value AS "paymentType"
|
||||
FROM m_voucher v
|
||||
JOIN m_savings_account_transaction t ON t.id = v.savings_txn_id
|
||||
JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||
WHERE v.voucher_type = 'DEPOSIT'
|
||||
AND COALESCE(v.reference_type, '') <> 'VOUCHER_CORRECTION'
|
||||
AND v.voucher_no LIKE ?
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_type_enum = 1
|
||||
AND pt.is_cash_payment = true
|
||||
AND sa.deposit_type_enum = 100
|
||||
AND NOT EXISTS (SELECT 1 FROM m_voucher_correction vc
|
||||
WHERE vc.original_voucher_no = v.voucher_no
|
||||
AND vc.status NOT IN ('UNPASS_REJECTED'))
|
||||
""";
|
||||
|
||||
// ── Step 1: cashier unpasses a voucher ───────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("unpass")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String unpass(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
requireUnpasser(user);
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final String voucherNo = fromJsonHelper.extractStringNamed("voucherNo", root);
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
throw apiError(400, "Voucher number is required");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw apiError(400, "Reason is required");
|
||||
}
|
||||
|
||||
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(ELIGIBLE_SQL + " AND v.voucher_no = ?", "%/" + day + "/%",
|
||||
voucherNo.trim());
|
||||
if (rows.isEmpty()) {
|
||||
throw apiError(409, "Voucher " + voucherNo
|
||||
+ " cannot be unpassed: only today's cash deposit vouchers on a savings account that are still active qualify.");
|
||||
}
|
||||
final Map<String, Object> v = rows.get(0);
|
||||
|
||||
final Long id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_voucher_correction
|
||||
(original_voucher_no, original_txn_id, wrong_savings_account_id, wrong_account_no, wrong_client_name,
|
||||
amount, payment_type_id, txn_date, status, reason, unpass_by_id, unpass_by_name, unpass_at, activity_log)
|
||||
VALUES (?,?,?,?,?,?,(SELECT pd.payment_type_id FROM m_savings_account_transaction t
|
||||
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id WHERE t.id = ?),
|
||||
(SELECT transaction_date FROM m_savings_account_transaction WHERE id = ?),
|
||||
?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, voucherNo.trim(), v.get("txnId"), v.get("savingsAccountId"), v.get("accountNo"), v.get("clientName"),
|
||||
v.get("amount"), v.get("txnId"), v.get("txnId"), UNPASS_PENDING, reason.trim(), user.getId(), user.getDisplayName(),
|
||||
Timestamp.from(Instant.now()), log(null, "UNPASS REQUESTED", user, reason.trim()));
|
||||
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_PENDING));
|
||||
}
|
||||
|
||||
// ── Lists ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Corrections by status. {@code status} may be a comma-separated list. The approval page asks for
|
||||
* UNPASS_PENDING and CORRECTION_PENDING, the cashier's page for UNPASSED.
|
||||
*/
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(@QueryParam("status") final String status, @QueryParam("mine") @DefaultValue("false") final boolean mine) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final StringBuilder sql = new StringBuilder("SELECT * FROM m_voucher_correction WHERE 1=1");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (status != null && !status.isBlank()) {
|
||||
final String[] statuses = status.split(",");
|
||||
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
|
||||
for (final String s : statuses) {
|
||||
params.add(s.trim().toUpperCase());
|
||||
}
|
||||
}
|
||||
if (mine) {
|
||||
sql.append(" AND (unpass_by_id = ? OR correction_by_id = ?)");
|
||||
params.add(user.getId());
|
||||
params.add(user.getId());
|
||||
}
|
||||
sql.append(" ORDER BY id DESC LIMIT 200");
|
||||
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String get(@PathParam("id") final Long id) {
|
||||
this.context.authenticatedUser();
|
||||
return gson.toJson(load(id));
|
||||
}
|
||||
|
||||
// ── Step 2: manager approves / rejects the unpass ────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("{id}/unpass/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approveUnpass(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
validateCheckerRole(user);
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, UNPASS_PENDING, "approved");
|
||||
requireNotMaker(user, r.get("unpass_by_id"), "unpass request");
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_voucher_correction SET status = ?, unpass_approved_by_id = ?, unpass_approved_by_name = ?,
|
||||
unpass_approved_at = ?, activity_log = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", UNPASSED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
|
||||
log((String) r.get("activity_log"), "UNPASS APPROVED", user, null), id, UNPASS_PENDING);
|
||||
if (updated == 0) {
|
||||
throw apiError(409, "This request was already actioned by another manager.");
|
||||
}
|
||||
// Deliberately no account movement here: the deposit stays exactly where it is until the correction is approved.
|
||||
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/unpass/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String rejectUnpass(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
validateCheckerRole(user);
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, UNPASS_PENDING, "rejected");
|
||||
final String reason = requireReason(body);
|
||||
jdbcTemplate.update("UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ?", UNPASS_REJECTED,
|
||||
reason, log((String) r.get("activity_log"), "UNPASS REJECTED", user, reason), id);
|
||||
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_REJECTED));
|
||||
}
|
||||
|
||||
// ── Step 3: cashier enters the correct account ───────────────────────────────
|
||||
|
||||
/** Resolves the account number the cashier typed: name, balance and whether it can receive the deposit. */
|
||||
@GET
|
||||
@Path("account-lookup")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String accountLookup(@QueryParam("accountNo") final String accountNo) {
|
||||
this.context.authenticatedUser();
|
||||
final Map<String, Object> account = findSavingsAccount(accountNo);
|
||||
if (account == null) {
|
||||
throw apiError(404, "No savings account found for '" + accountNo + "'.");
|
||||
}
|
||||
return gson.toJson(account);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/correction")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String submitCorrection(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
// The corrected entry is the cashier's own: a deposit is taken at the counter, so only a cashier can re-enter it.
|
||||
if (cashierRoleHelper.getCurrentCashierId() == null) {
|
||||
throw apiError(403, "Only a cashier can enter the correction for a voucher. Your login is not linked to a cashier.");
|
||||
}
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, UNPASSED, "corrected");
|
||||
final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body));
|
||||
final Map<String, Object> correct = validateCorrectAccount(r, accountNo);
|
||||
checkWrongAccountBalance(r);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_voucher_correction SET status = ?, correct_savings_account_id = ?, correct_account_no = ?,
|
||||
correct_client_id = ?, correct_client_name = ?, correction_by_id = ?, correction_by_name = ?,
|
||||
correction_at = ?, rejection_reason = NULL, activity_log = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", CORRECTION_PENDING, correct.get("id"), correct.get("accountNo"), correct.get("clientId"), correct.get("clientName"),
|
||||
user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
|
||||
log((String) r.get("activity_log"), "CORRECTION SUBMITTED", user, "to " + correct.get("accountNo")), id, UNPASSED);
|
||||
return gson.toJson(Map.of("resourceId", id, "status", CORRECTION_PENDING));
|
||||
}
|
||||
|
||||
// ── Step 4: manager approves / rejects the correction ────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("{id}/correction/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approveCorrection(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
validateCheckerRole(user);
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, CORRECTION_PENDING, "approved");
|
||||
requireNotMaker(user, r.get("correction_by_id"), "correction");
|
||||
|
||||
// Both sides are checked again: a lot can happen between the cashier saving and the manager approving.
|
||||
validateCorrectAccount(r, (String) r.get("correct_account_no"));
|
||||
checkWrongAccountBalance(r);
|
||||
|
||||
final Long wrongId = toLong(r.get("wrong_savings_account_id"));
|
||||
final Long correctId = toLong(r.get("correct_savings_account_id"));
|
||||
final Long origTxnId = toLong(r.get("original_txn_id"));
|
||||
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
|
||||
final String origVoucher = (String) r.get("original_voucher_no");
|
||||
|
||||
final Integer stillLive = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE id = ? AND savings_account_id = ? AND is_reversed = false",
|
||||
Integer.class, origTxnId, wrongId);
|
||||
if (stillLive == null || stillLive == 0) {
|
||||
throw apiError(409, "The original deposit has already been reversed; this voucher can no longer be corrected.");
|
||||
}
|
||||
|
||||
// 1. Take the deposit off the wrong account, 2. put the same cash into the right one.
|
||||
final JsonObject undoPayload = new JsonObject();
|
||||
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(
|
||||
new CommandWrapperBuilder().undoSavingsAccountTransaction(wrongId, origTxnId).withJson(undoPayload.toString()).build()));
|
||||
|
||||
final String newTxnId = depositInto(correctId, amount, r);
|
||||
final Long newTxnKey = Long.valueOf(newTxnId);
|
||||
|
||||
// 3. A new voucher for the corrected entry, in the name of the cashier who made it, with the same till side.
|
||||
final String newVoucher = voucherService.createVoucher(VoucherService.TYPE_DEPOSIT, REF_VOUCHER_CORRECTION, id, amount, user);
|
||||
jdbcTemplate.update("UPDATE m_voucher SET created_by = ?, created_by_username = (SELECT username FROM m_appuser WHERE id = ?) WHERE voucher_no = ?",
|
||||
r.get("correction_by_id"), r.get("correction_by_id"), newVoucher);
|
||||
voucherService.linkSavingsTransaction(REF_VOUCHER_CORRECTION, id, newTxnKey);
|
||||
addNewVoucherLines(newVoucher, origVoucher, correctId, (String) r.get("correct_client_name"), (String) r.get("correct_account_no"),
|
||||
amount);
|
||||
|
||||
// 4. The original voucher is not touched. That it is revoked, and by which voucher, is recorded on this
|
||||
// correction (original_voucher_no / new_voucher_no) and read from there by the registers.
|
||||
|
||||
// 5. The notes the cashier took in follow the deposit to its new transaction and account.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_txn_denomination SET txn_id = ?, savings_account_id = ?, account_no = ?, client_id = ?
|
||||
WHERE txn_id = ? AND savings_account_id = ?
|
||||
""", newTxnKey, correctId, r.get("correct_account_no"), r.get("correct_client_id"), origTxnId, wrongId);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_voucher_correction SET status = ?, correction_approved_by_id = ?, correction_approved_by_name = ?,
|
||||
correction_approved_at = ?, new_voucher_no = ?, new_txn_id = ?, activity_log = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", COMPLETED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), newVoucher, newTxnKey,
|
||||
log((String) r.get("activity_log"), "CORRECTION APPROVED", user, "new voucher " + newVoucher), id, CORRECTION_PENDING);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", COMPLETED, "newVoucherNo", newVoucher));
|
||||
}
|
||||
|
||||
/** A rejected correction goes back to the cashier as UNPASSED, to enter the account again. */
|
||||
@POST
|
||||
@Path("{id}/correction/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String rejectCorrection(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
validateCheckerRole(user);
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, CORRECTION_PENDING, "rejected");
|
||||
final String reason = requireReason(body);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, correct_savings_account_id = NULL,
|
||||
correct_account_no = NULL, correct_client_id = NULL, correct_client_name = NULL, activity_log = ?
|
||||
WHERE id = ?
|
||||
""", UNPASSED, reason, log((String) r.get("activity_log"), "CORRECTION REJECTED", user, reason), id);
|
||||
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
|
||||
}
|
||||
|
||||
/**
|
||||
* The cashier gives up the correction. Used when the wrong account no longer holds the money (the customer has
|
||||
* already drawn it), so it cannot be taken back: the cashier then puts the amount into the right account from
|
||||
* their own pocket as a fresh deposit, and this request is closed. No account moves here, and the original voucher
|
||||
* stays as it was.
|
||||
*/
|
||||
@POST
|
||||
@Path("{id}/close")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String close(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
if (cashierRoleHelper.getCurrentCashierId() == null) {
|
||||
throw apiError(403, "Only a cashier can close a voucher correction.");
|
||||
}
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, UNPASSED, "closed");
|
||||
final String reason = requireReason(body);
|
||||
final int updated = jdbcTemplate.update(
|
||||
"UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ? AND status = ?", CLOSED,
|
||||
reason, log((String) r.get("activity_log"), "CLOSED BY CASHIER", user, reason), id, UNPASSED);
|
||||
if (updated == 0) {
|
||||
throw apiError(409, "This request was already actioned.");
|
||||
}
|
||||
return gson.toJson(Map.of("resourceId", id, "status", CLOSED));
|
||||
}
|
||||
|
||||
// ── For the passbook and transaction list of the wrong account ───────────────
|
||||
|
||||
/** Transactions of one account that were removed by a completed correction, so its screens can leave them out. */
|
||||
@GET
|
||||
@Path("voided-transactions")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String voidedTransactions(@QueryParam("savingsAccountId") final Long savingsAccountId) {
|
||||
this.context.authenticatedUser();
|
||||
final JsonArray ids = new JsonArray();
|
||||
jdbcTemplate.queryForList(
|
||||
"SELECT original_txn_id FROM m_voucher_correction WHERE status = ? AND wrong_savings_account_id = ?", Long.class, COMPLETED,
|
||||
savingsAccountId).forEach(ids::add);
|
||||
return gson.toJson(ids);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Unpassing a voucher is for the back office: an administrator or a clerk. The cashier does not unpass, only re-enters. */
|
||||
private void requireUnpasser(final AppUser user) {
|
||||
final boolean admin = user.hasAnyPermission("ALL_FUNCTIONS");
|
||||
final boolean clerk = user.getRoles().stream().anyMatch(role -> role.getName() != null && "clerk".equals(role.getName().toLowerCase().trim()));
|
||||
if (!admin && !clerk) {
|
||||
throw apiError(403, "Only an administrator or a clerk can unpass a voucher.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> validateCorrectAccount(final Map<String, Object> r, final String accountNo) {
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw apiError(400, "The correct account number is required");
|
||||
}
|
||||
final Map<String, Object> correct = findSavingsAccount(accountNo);
|
||||
if (correct == null) {
|
||||
throw apiError(404, "No savings account found for '" + accountNo + "'.");
|
||||
}
|
||||
if (toLong(correct.get("id")).equals(toLong(r.get("wrong_savings_account_id")))) {
|
||||
throw apiError(400, "The correct account is the same account the deposit was posted to.");
|
||||
}
|
||||
if (!Boolean.TRUE.equals(correct.get("canReceive"))) {
|
||||
throw apiError(409, "Account " + correct.get("accountNo") + " is not active, so it cannot receive this deposit.");
|
||||
}
|
||||
final Object wrongCurrency = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class,
|
||||
r.get("wrong_savings_account_id")).stream().findFirst().orElse(null);
|
||||
if (wrongCurrency != null && !wrongCurrency.equals(correct.get("currencyCode"))) {
|
||||
throw apiError(409, "The correct account is in a different currency.");
|
||||
}
|
||||
kycRestrictionGuard.check(toLong(correct.get("id")), KycRestrictionGuard.Operation.CREDIT);
|
||||
return correct;
|
||||
}
|
||||
|
||||
/** The wrong account must still hold at least this deposit, or taking it back would overdraw it. */
|
||||
private void checkWrongAccountBalance(final Map<String, Object> r) {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("SELECT account_balance_derived FROM m_savings_account WHERE id = ?",
|
||||
BigDecimal.class, r.get("wrong_savings_account_id"));
|
||||
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
|
||||
if (balance == null || balance.compareTo(amount) < 0) {
|
||||
throw apiError(409, "Account " + r.get("wrong_account_no") + " no longer holds ₹" + amount + " (balance ₹"
|
||||
+ (balance == null ? "0" : balance) + "), so the deposit cannot be taken back. The customer may have used the money.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> findSavingsAccount(final String accountNo) {
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT sa.id, sa.account_no AS "accountNo", sa.client_id AS "clientId", c.display_name AS "clientName",
|
||||
sa.account_balance_derived AS "balance", sa.currency_code AS "currencyCode",
|
||||
(sa.status_enum = ? AND sa.deposit_type_enum = ?) AS "canReceive"
|
||||
FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
WHERE sa.account_no = ?
|
||||
""", SAVINGS_STATUS_ACTIVE, DEPOSIT_TYPE_SAVINGS, accountNo.trim());
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private String depositInto(final Long savingsAccountId, final BigDecimal amount, final Map<String, Object> r) {
|
||||
final Object txnDate = r.get("txn_date");
|
||||
final String date = txnDate == null ? DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"))
|
||||
: LocalDate.parse(txnDate.toString().substring(0, 10)).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (r.get("payment_type_id") != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) r.get("payment_type_id")).longValue());
|
||||
}
|
||||
payload.addProperty("note", "Correction of voucher " + r.get("original_voucher_no"));
|
||||
final CommandWrapper command = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
|
||||
.build();
|
||||
final CommandProcessingResult result = voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(command));
|
||||
if (result.getResourceId() == null) {
|
||||
throw apiError(500, "The corrected deposit could not be posted.");
|
||||
}
|
||||
return result.getResourceId().toString();
|
||||
}
|
||||
|
||||
/** The new voucher keeps the original's till side and names the right account on the credit side. */
|
||||
private void addNewVoucherLines(final String newVoucher, final String origVoucher, final Long correctId, final String clientName,
|
||||
final String accountNo, final BigDecimal amount) {
|
||||
try {
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
for (final Map<String, Object> l : jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id, party_name, account_no FROM m_voucher_line WHERE voucher_no = ? AND entry_type = 'DR' ORDER BY line_no",
|
||||
origVoucher)) {
|
||||
lines.add(VoucherLine.debitGl(toLong(l.get("gl_account_id")), (String) l.get("party_name"), (String) l.get("account_no"),
|
||||
amount));
|
||||
}
|
||||
if (lines.isEmpty()) {
|
||||
lines.add(VoucherLine.debitGl(null, "Cash in Hand", null, amount));
|
||||
}
|
||||
lines.add(VoucherLine.creditSavings(correctId, clientName, accountNo, amount));
|
||||
voucherService.addLines(newVoucher, lines);
|
||||
} catch (final RuntimeException e) {
|
||||
// The deposit is already posted; a voucher without its sides is better than undoing a correct posting.
|
||||
org.slf4j.LoggerFactory.getLogger(VoucherCorrectionApiResource.class)
|
||||
.error("Could not record the sides of correction voucher {}", newVoucher, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> load(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_voucher_correction WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Voucher correction not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private void requireStatus(final Map<String, Object> r, final String expected, final String action) {
|
||||
if (!expected.equals(r.get("status"))) {
|
||||
throw apiError(409, "Only " + expected + " requests can be " + action + ". Current status: " + r.get("status"));
|
||||
}
|
||||
}
|
||||
|
||||
private void requireNotMaker(final AppUser user, final Object makerId, final String what) {
|
||||
if (makerId != null && toLong(makerId).equals(user.getId())) {
|
||||
throw apiError(403, "Maker-checker: you cannot approve a " + what + " you raised yourself. Ask another manager.");
|
||||
}
|
||||
}
|
||||
|
||||
private String requireReason(final String body) {
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}"));
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw apiError(400, "Reason is required");
|
||||
}
|
||||
return reason.trim();
|
||||
}
|
||||
|
||||
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
|
||||
private void validateCheckerRole(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return;
|
||||
}
|
||||
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||
if (!isChecker) {
|
||||
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject voucher corrections.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Long toLong(final Object value) {
|
||||
return value instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
private String log(final String existing, final String action, final AppUser user, final String note) {
|
||||
final JsonArray entries = existing == null || existing.isBlank() ? new JsonArray() : gson.fromJson(existing, JsonArray.class);
|
||||
final JsonObject e = new JsonObject();
|
||||
e.addProperty("action", action);
|
||||
e.addProperty("by", user.getUsername());
|
||||
e.addProperty("at", Instant.now().toString());
|
||||
if (note != null) {
|
||||
e.addProperty("note", note);
|
||||
}
|
||||
entries.add(e);
|
||||
return gson.toJson(entries);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("message", message);
|
||||
body.addProperty("defaultUserMessage", message);
|
||||
body.addProperty("httpStatus", httpStatus);
|
||||
final JsonArray errors = new JsonArray();
|
||||
final JsonObject err = new JsonObject();
|
||||
err.addProperty("defaultUserMessage", message);
|
||||
errors.add(err);
|
||||
body.add("errors", errors);
|
||||
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -628,9 +628,15 @@ public class VoucherVerificationApiResource {
|
||||
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
final List<Map<String, Object>> out = new java.util.ArrayList<>();
|
||||
final Map<String, Map<String, Object>> marks = voucherService
|
||||
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||
if (amount != null) {
|
||||
final Map<String, Object> mark = marks.get((String) r.get("voucher_no"));
|
||||
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
|
||||
// A revoked voucher stays on the register, marked, but its money is on the voucher that replaced it, so it
|
||||
// is left out of the total rather than counted twice.
|
||||
if (amount != null && !revoked) {
|
||||
total = total.add(amount);
|
||||
}
|
||||
|
||||
@@ -638,6 +644,7 @@ public class VoucherVerificationApiResource {
|
||||
m.put("voucherNo", r.get("voucher_no"));
|
||||
m.put("voucherType", r.get("voucher_type"));
|
||||
m.put("amount", amount);
|
||||
putVoucherMark(m, mark);
|
||||
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
|
||||
// The day the posting belongs to, and whether that differs from the day it was written.
|
||||
// A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's
|
||||
@@ -847,6 +854,8 @@ public class VoucherVerificationApiResource {
|
||||
int raisedCount = 0;
|
||||
int verifiedCount = 0;
|
||||
final List<Map<String, Object>> out = new java.util.ArrayList<>();
|
||||
final Map<String, Map<String, Object>> myWorkMarks = voucherService
|
||||
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
|
||||
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||
@@ -869,7 +878,10 @@ public class VoucherVerificationApiResource {
|
||||
final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched
|
||||
? "JV" : rawSide;
|
||||
|
||||
if (amount != null) {
|
||||
final Map<String, Object> mark = myWorkMarks.get(voucherNo);
|
||||
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
|
||||
// A revoked voucher is shown, marked, but its money is on the voucher that replaced it, so it is not counted.
|
||||
if (amount != null && !revoked) {
|
||||
if ("CR".equals(side)) {
|
||||
receivedTotal = receivedTotal.add(amount);
|
||||
} else if ("DR".equals(side)) {
|
||||
@@ -897,6 +909,7 @@ public class VoucherVerificationApiResource {
|
||||
m.put("raisedBy", r.get("created_by_username"));
|
||||
m.put("branchName", r.get("branch_name"));
|
||||
m.put("verificationStatus", r.get("verification_status"));
|
||||
putVoucherMark(m, mark);
|
||||
m.put("verifiedByName", r.get("verified_by_name"));
|
||||
m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString());
|
||||
m.put("debitParty", r.get("debit_party"));
|
||||
@@ -968,6 +981,14 @@ public class VoucherVerificationApiResource {
|
||||
* returns an empty list rather than an error: the voucher is real, its posting simply was not
|
||||
* captured at the time, and reporting it as missing would be wrong.
|
||||
*/
|
||||
/** Adds where the voucher stands (active, revoked, under correction, corrected) to a register row. */
|
||||
private void putVoucherMark(final Map<String, Object> row, final Map<String, Object> mark) {
|
||||
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
|
||||
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
|
||||
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
|
||||
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("detail")
|
||||
public String detail(@QueryParam("voucherNo") final String voucherNo) {
|
||||
@@ -1032,6 +1053,7 @@ public class VoucherVerificationApiResource {
|
||||
out.put("createdBy", h.get("created_by_username"));
|
||||
out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString());
|
||||
out.put("verificationStatus", h.get("verification_status"));
|
||||
putVoucherMark(out, voucherService.voucherMarks(List.of(voucherNo)).get(voucherNo));
|
||||
out.put("lines", lines);
|
||||
out.put("totalDebit", totalDebit);
|
||||
out.put("totalCredit", totalCredit);
|
||||
|
||||
+60
@@ -595,6 +595,66 @@ public class VoucherService {
|
||||
""", voucherNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each of these vouchers stands, for the registers that list vouchers.
|
||||
*
|
||||
* A voucher is ACTIVE until a correction touches it. REVOKED means its deposit was moved to another account and a
|
||||
* new voucher replaced it (revokedByVoucherNo). A voucher with a correction under way reads UNPASS_PENDING,
|
||||
* UNPASSED or CORRECTION_PENDING. The replacement itself reads CORRECTED, with the voucher it replaced in
|
||||
* replacesVoucherNo. A register adds these to its rows and leaves a REVOKED voucher out of its totals, since its
|
||||
* money now sits on the replacement.
|
||||
*/
|
||||
public Map<String, Map<String, Object>> voucherMarks(final java.util.Collection<String> voucherNos) {
|
||||
final Map<String, Map<String, Object>> marks = new java.util.HashMap<>();
|
||||
final List<String> nos = voucherNos == null ? List.of()
|
||||
: voucherNos.stream().filter(n -> n != null && !n.isBlank()).distinct().toList();
|
||||
if (nos.isEmpty()) {
|
||||
return marks;
|
||||
}
|
||||
final String in = String.join(",", java.util.Collections.nCopies(nos.size(), "?"));
|
||||
// Read from the correction register alone: the voucher itself carries no status, so nothing about a correction
|
||||
// is ever written onto m_voucher and the voucher rows stay exactly as they were raised.
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT v.voucher_no,"
|
||||
+ " (SELECT vc.new_voucher_no FROM m_voucher_correction vc"
|
||||
+ " WHERE vc.original_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
|
||||
+ " ORDER BY vc.id DESC LIMIT 1) AS revoked_by_voucher_no,"
|
||||
+ " (SELECT vc.original_voucher_no FROM m_voucher_correction vc"
|
||||
+ " WHERE vc.new_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
|
||||
+ " ORDER BY vc.id DESC LIMIT 1) AS replaces_voucher_no,"
|
||||
+ " (SELECT vc.status FROM m_voucher_correction vc"
|
||||
+ " WHERE vc.original_voucher_no = v.voucher_no"
|
||||
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED')"
|
||||
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_status,"
|
||||
// The reason behind it: why a closed request was closed, otherwise why the voucher was unpassed.
|
||||
+ " (SELECT CASE WHEN vc.status = 'CLOSED' THEN vc.rejection_reason ELSE vc.reason END"
|
||||
+ " FROM m_voucher_correction vc"
|
||||
+ " WHERE vc.original_voucher_no = v.voucher_no"
|
||||
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED', 'COMPLETED')"
|
||||
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_note"
|
||||
+ " FROM m_voucher v WHERE v.voucher_no IN (" + in + ")",
|
||||
nos.toArray());
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final String status;
|
||||
if (r.get("revoked_by_voucher_no") != null) {
|
||||
status = "REVOKED";
|
||||
} else if (r.get("correction_status") != null) {
|
||||
status = (String) r.get("correction_status");
|
||||
} else if (r.get("replaces_voucher_no") != null) {
|
||||
status = "CORRECTED";
|
||||
} else {
|
||||
status = "ACTIVE";
|
||||
}
|
||||
final Map<String, Object> m = new java.util.LinkedHashMap<>();
|
||||
m.put("voucherStatus", status);
|
||||
m.put("revokedByVoucherNo", r.get("revoked_by_voucher_no"));
|
||||
m.put("replacesVoucherNo", r.get("replaces_voucher_no"));
|
||||
m.put("correctionNote", r.get("correction_note"));
|
||||
marks.put((String) r.get("voucher_no"), m);
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the voucher for the given request and returns its voucher_no,
|
||||
* or null if none exists (e.g. records created before vouchers existed).
|
||||
|
||||
@@ -173,4 +173,11 @@
|
||||
<include file="parts/0597_enable_recurring_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0600_clerk_withdrawal_request_gl.xml" relativeToChangelogFile="true"/>
|
||||
<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>
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
Cash exchange records who the customer was: an existing client is already linked through client_id,
|
||||
a walk-in customer through the name and mobile captured at the counter. pan_number holds the PAN the
|
||||
cashier verified, which is mandatory above Rs 50,000 (Income Tax Rule 114B, followed under RBI
|
||||
cash-handling guidelines). All three are nullable: exchanges recorded before this change have none.
|
||||
-->
|
||||
<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="0599_cash_exchange_add_customer_name" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_cash_exchange" columnName="customer_name"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_cash_exchange">
|
||||
<column name="customer_name" type="VARCHAR(150)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0599_cash_exchange_add_customer_mobile" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_cash_exchange" columnName="customer_mobile"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_cash_exchange">
|
||||
<column name="customer_mobile" type="VARCHAR(20)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0599_cash_exchange_add_pan_number" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_cash_exchange" columnName="pan_number"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_cash_exchange">
|
||||
<column name="pan_number" type="VARCHAR(10)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) goes through the same request, manager approval and
|
||||
cashier disbursement as a savings withdrawal, so the request carries the GL account and the cash GL it is paid
|
||||
from. All four columns are nullable: savings, current and deposit requests do not use them.
|
||||
-->
|
||||
<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="0600_clerk_withdrawal_request_add_gl_columns" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_clerk_withdrawal_request"/>
|
||||
<not><columnExists tableName="m_clerk_withdrawal_request" columnName="gl_account_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_withdrawal_request">
|
||||
<column name="gl_account_id" type="BIGINT"/>
|
||||
<column name="contra_gl_id" type="BIGINT"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<column name="currency_code" type="VARCHAR(10)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
Voucher correction: a cash deposit posted to the wrong savings account is unpassed, approved, re-entered against
|
||||
the right account by the cashier and approved again. m_voucher_correction carries that request through its steps;
|
||||
m_voucher gains a status so the original voucher can be REVOKED once its replacement exists.
|
||||
-->
|
||||
<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="0601_voucher_add_status" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_voucher" columnName="status"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_voucher">
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="revoked_at" type="TIMESTAMP"/>
|
||||
<column name="revoked_by_voucher_no" type="VARCHAR(30)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0601_create_voucher_correction" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_voucher_correction"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_voucher_correction">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="original_voucher_no" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="original_txn_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="wrong_savings_account_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="wrong_account_no" type="VARCHAR(50)"/>
|
||||
<column name="wrong_client_name" type="VARCHAR(255)"/>
|
||||
<column name="amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
|
||||
<column name="payment_type_id" type="BIGINT"/>
|
||||
<column name="txn_date" type="DATE"/>
|
||||
<column name="status" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="reason" type="VARCHAR(1000)"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
<column name="unpass_by_id" type="BIGINT"/>
|
||||
<column name="unpass_by_name" type="VARCHAR(255)"/>
|
||||
<column name="unpass_at" type="TIMESTAMP"/>
|
||||
<column name="unpass_approved_by_id" type="BIGINT"/>
|
||||
<column name="unpass_approved_by_name" type="VARCHAR(255)"/>
|
||||
<column name="unpass_approved_at" type="TIMESTAMP"/>
|
||||
<column name="correct_savings_account_id" type="BIGINT"/>
|
||||
<column name="correct_account_no" type="VARCHAR(50)"/>
|
||||
<column name="correct_client_id" type="BIGINT"/>
|
||||
<column name="correct_client_name" type="VARCHAR(255)"/>
|
||||
<column name="correction_by_id" type="BIGINT"/>
|
||||
<column name="correction_by_name" type="VARCHAR(255)"/>
|
||||
<column name="correction_at" type="TIMESTAMP"/>
|
||||
<column name="correction_approved_by_id" type="BIGINT"/>
|
||||
<column name="correction_approved_by_name" type="VARCHAR(255)"/>
|
||||
<column name="correction_approved_at" type="TIMESTAMP"/>
|
||||
<column name="new_voucher_no" type="VARCHAR(30)"/>
|
||||
<column name="new_txn_id" type="BIGINT"/>
|
||||
<column name="activity_log" type="TEXT"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_voucher">
|
||||
<column name="original_voucher_no"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
The voucher itself carries no correction status: whether a voucher was unpassed, revoked or closed is read from
|
||||
m_voucher_correction. 0601 had added status, revoked_at and revoked_by_voucher_no to m_voucher; nothing writes them
|
||||
any more, so they are dropped and the voucher table is back to exactly what the registers raised.
|
||||
-->
|
||||
<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="0602_voucher_drop_status_columns" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_voucher" columnName="status"/>
|
||||
</preConditions>
|
||||
<dropColumn tableName="m_voucher" columnName="status"/>
|
||||
<dropColumn tableName="m_voucher" columnName="revoked_at"/>
|
||||
<dropColumn tableName="m_voucher" columnName="revoked_by_voucher_no"/>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
Cash Transfer is maker-checker: the person who allocates cash from the vault to a cashier raises a request, and the
|
||||
allocation is posted only when a different person approves it. m_cash_transfer stays the table of posted transfers;
|
||||
this table holds the requests, with the whole request body so the approval posts exactly what was asked for.
|
||||
-->
|
||||
<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="0603_create_cash_transfer_request" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_cash_transfer_request"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_cash_transfer_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="transfer_date" type="DATE"/>
|
||||
<column name="from_teller_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="to_cashier_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="from_party" type="VARCHAR(255)"/>
|
||||
<column name="to_party" type="VARCHAR(255)"/>
|
||||
<column name="note" type="VARCHAR(1000)"/>
|
||||
<column name="total_amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
|
||||
<column name="request_body" type="TEXT"><constraints nullable="false"/></column>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="maker_id" type="BIGINT"/>
|
||||
<column name="maker_name" type="VARCHAR(255)"/>
|
||||
<column name="maker_at" type="TIMESTAMP"/>
|
||||
<column name="checker_id" type="BIGINT"/>
|
||||
<column name="checker_name" type="VARCHAR(255)"/>
|
||||
<column name="checker_at" type="TIMESTAMP"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
<column name="cash_transfer_id" type="BIGINT"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cash_transfer_request" indexName="idx_cash_transfer_request_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+294
@@ -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>
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<!--
|
||||
CIC module, second shape: the bank no longer pulls credit reports. Instead the CIC tells the bank when one of
|
||||
ITS customers was enquired by ANOTHER bank / NBFC - i.e. the customer applied for a loan elsewhere - with the
|
||||
lender, loan type and amount. These "enquiry alerts" are fetched through the same bureau configuration and
|
||||
UI-driven API mapping (endpoint type ENQUIRY_ALERT) and shown on the Credit Bureau dashboard.
|
||||
|
||||
Removed: borrower consent (m_cic_consent), report enquiries (m_cic_enquiry), their permissions, and the
|
||||
CONSUMER_ENQUIRY endpoints / mappings.
|
||||
Added: m_cic_alert (one row per enquiry made on our customer at another lender),
|
||||
m_cic_alert_fetch_run (every fetch / file import with its counts and raw response),
|
||||
alert fetch settings on m_cic_bureau, the ENQUIRY_ALERT endpoint and its generic mapping.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="0605-1-drop-cic-enquiry-consent" author="cbsnexora">
|
||||
<sql>
|
||||
DROP TABLE IF EXISTS m_cic_enquiry;
|
||||
DROP TABLE IF EXISTS m_cic_consent;
|
||||
DELETE FROM m_cic_field_mapping
|
||||
WHERE endpoint_id IN (SELECT id FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY'));
|
||||
DELETE FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY');
|
||||
DELETE FROM m_role_permission
|
||||
WHERE permission_id IN (SELECT id FROM m_permission
|
||||
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT',
|
||||
'REVOKE_CICCONSENT'));
|
||||
DELETE FROM m_permission
|
||||
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT', 'REVOKE_CICCONSENT');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0605-2-cic-bureau-alert-settings" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_cic_bureau" columnName="report_validity_days"/>
|
||||
</preConditions>
|
||||
<dropColumn tableName="m_cic_bureau" columnName="report_validity_days"/>
|
||||
<dropColumn tableName="m_cic_bureau" columnName="enquiry_charge"/>
|
||||
<addColumn tableName="m_cic_bureau">
|
||||
<column name="alert_auto_fetch" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
|
||||
<column name="alert_fetch_interval_min" type="INT" defaultValueNumeric="60"><constraints nullable="false"/></column>
|
||||
<column name="alert_lookback_days" type="INT" defaultValueNumeric="7"><constraints nullable="false"/></column>
|
||||
<column name="last_fetch_on" type="TIMESTAMP"/>
|
||||
<column name="last_fetch_status" type="VARCHAR(20)"/>
|
||||
<column name="last_fetch_message" type="VARCHAR(1000)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0605-3-cic-alert-tables" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_cic_alert"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_cic_alert_fetch_run">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="run_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_fetch_run_ref"/></column>
|
||||
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="bureau_code" type="VARCHAR(30)"/>
|
||||
<column name="trigger_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="environment" type="VARCHAR(20)"/>
|
||||
<column name="from_date" type="DATE"/>
|
||||
<column name="to_date" type="DATE"/>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="http_status" type="INT"/>
|
||||
<column name="received" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="new_alerts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="matched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="unmatched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="duplicates" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="own_bank" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="error_message" type="VARCHAR(2000)"/>
|
||||
<column name="request_masked" type="TEXT"/>
|
||||
<column name="response_raw" type="TEXT"/>
|
||||
<column name="duration_ms" type="INT"/>
|
||||
<column name="triggered_by" type="BIGINT"/>
|
||||
<column name="triggered_by_name" type="VARCHAR(200)"/>
|
||||
<column name="started_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cic_alert_fetch_run" indexName="idx_cic_fetch_run_bureau"><column name="bureau_id"/><column name="started_on"/></createIndex>
|
||||
|
||||
<createTable tableName="m_cic_alert">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="bureau_code" type="VARCHAR(30)"/>
|
||||
<column name="alert_key" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<column name="client_name" type="VARCHAR(200)"/>
|
||||
<column name="client_account_no" type="VARCHAR(50)"/>
|
||||
<column name="match_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="customer_name" type="VARCHAR(200)"/>
|
||||
<column name="pan_masked" type="VARCHAR(20)"/>
|
||||
<column name="aadhaar_masked" type="VARCHAR(20)"/>
|
||||
<column name="customer_ref" type="VARCHAR(100)"/>
|
||||
<column name="date_of_birth" type="DATE"/>
|
||||
<column name="institution_code" type="VARCHAR(100)"/>
|
||||
<column name="institution_name" type="VARCHAR(200)"/>
|
||||
<column name="institution_type" type="VARCHAR(60)"/>
|
||||
<column name="enquiry_date" type="DATE"/>
|
||||
<column name="loan_type" type="VARCHAR(100)"/>
|
||||
<column name="amount" type="DECIMAL(19,6)"/>
|
||||
<column name="purpose" type="VARCHAR(200)"/>
|
||||
<column name="location" type="VARCHAR(200)"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="NEW"><constraints nullable="false"/></column>
|
||||
<column name="remarks" type="VARCHAR(1000)"/>
|
||||
<column name="actioned_by" type="BIGINT"/>
|
||||
<column name="actioned_by_name" type="VARCHAR(200)"/>
|
||||
<column name="actioned_on" type="TIMESTAMP"/>
|
||||
<column name="received_via" type="VARCHAR(20)"/>
|
||||
<column name="fetch_run_id" type="BIGINT"/>
|
||||
<column name="received_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="raw_json" type="TEXT"/>
|
||||
</createTable>
|
||||
<addUniqueConstraint tableName="m_cic_alert" columnNames="bureau_id, alert_key" constraintName="uq_cic_alert_key"/>
|
||||
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_client"><column name="client_id"/></createIndex>
|
||||
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_status"><column name="status"/></createIndex>
|
||||
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_enquiry_date"><column name="enquiry_date"/></createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!-- An ENQUIRY_ALERT endpoint per bureau, with a generic body the built-in simulator answers to. -->
|
||||
<changeSet id="0605-4-cic-alert-endpoints" author="cbsnexora">
|
||||
<sql>
|
||||
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
|
||||
SELECT b.id, 'ENQUIRY_ALERT', 'POST', '/monitoring/enquiry-alerts', 'application/json',
|
||||
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{fetch.reference}}"}',
|
||||
'{"requestHeader":{"product":"ENQUIRY_ALERT","version":"1.0"},"criteria":{}}',
|
||||
TRUE
|
||||
FROM m_cic_bureau b
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_cic_bureau_endpoint e WHERE e.bureau_id = b.id AND e.endpoint_type = 'ENQUIRY_ALERT');
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
|
||||
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
|
||||
FROM m_cic_bureau_endpoint e
|
||||
CROSS JOIN (
|
||||
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
|
||||
UNION ALL SELECT 'REQUEST', 'fetch.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
|
||||
UNION ALL SELECT 'REQUEST', 'fetch.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', NULL, TRUE, 30
|
||||
UNION ALL SELECT 'REQUEST', 'fetch.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', NULL, TRUE, 40
|
||||
UNION ALL SELECT 'REQUEST', NULL, 'criteria.scope', 'NONE', 'OTHER_MEMBERS', FALSE, 50
|
||||
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorCode', 'errorCode', 'NONE', NULL, FALSE, 10
|
||||
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorMessage', 'errorMessage', 'NONE', NULL, FALSE, 20
|
||||
UNION ALL SELECT 'RESPONSE', 'data.alerts', 'alerts', 'NONE', NULL, FALSE, 30
|
||||
UNION ALL SELECT 'RESPONSE', 'alertId', 'alertRef', 'NONE', NULL, FALSE, 40
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.pan', 'pan', 'UPPER', NULL, FALSE, 50
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.aadhaar', 'aadhaar', 'NONE', NULL, FALSE, 60
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.name', 'customerName', 'UPPER', NULL, FALSE, 70
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.dateOfBirth', 'dateOfBirth', 'DATE_DDMMYYYY', NULL, FALSE, 80
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.memberReference', 'customerRef', 'NONE', NULL, FALSE, 90
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.memberCode', 'institutionCode', 'NONE', NULL, FALSE, 100
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.memberName', 'institutionName', 'NONE', NULL, FALSE, 110
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.memberType', 'institutionType', 'NONE', NULL, FALSE, 120
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.date', 'enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 130
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.productType', 'loanType', 'NONE', NULL, FALSE, 140
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.amount', 'amount', 'NUMBER', NULL, FALSE, 150
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.purpose', 'purpose', 'NONE', NULL, FALSE, 160
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.location', 'location', 'NONE', NULL, FALSE, 170
|
||||
) m
|
||||
WHERE e.endpoint_type = 'ENQUIRY_ALERT'
|
||||
AND NOT EXISTS (SELECT 1 FROM m_cic_field_mapping x WHERE x.endpoint_id = e.id);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Alert permissions; every role that has the Credit Bureau menu may read, work and fetch alerts. -->
|
||||
<changeSet id="0605-5-cic-alert-permissions" author="cbsnexora">
|
||||
<sql>
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'READ_CICALERT', 'CICALERT', 'READ', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICALERT');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'UPDATE_CICALERT', 'CICALERT', 'UPDATE', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICALERT');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'FETCH_CICALERT', 'CICALERT', 'FETCH', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'FETCH_CICALERT');
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT DISTINCT rp.role_id, p2.id
|
||||
FROM m_role_permission rp
|
||||
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_CREDIT_BUREAU'
|
||||
JOIN m_permission p2 ON p2.code IN ('READ_CICALERT', 'UPDATE_CICALERT', 'FETCH_CICALERT')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+7
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user