Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae69d126a9 | ||
|
|
e4a2c00d75 | ||
|
|
a8f8aa39b7 |
@@ -80,6 +80,7 @@ public class Charge extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "currency_code", length = 3)
|
||||
private String currencyCode;
|
||||
|
||||
@Getter
|
||||
@Column(name = "charge_applies_to_enum", nullable = false)
|
||||
private Integer chargeAppliesTo;
|
||||
|
||||
|
||||
+2
@@ -42,6 +42,8 @@ public enum JobName {
|
||||
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||
SEND_KYC_REMINDERS("Send KYC Reminders"), //
|
||||
PROCESS_KYC_NOTICE_QUEUE("Process KYC Notice Queue"), //
|
||||
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
||||
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
|
||||
|
||||
+10
-1
@@ -83,15 +83,24 @@ public class SavingsAccountSummaryData {
|
||||
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
|
||||
*/
|
||||
private final Integer renewalCycleNo;
|
||||
/**
|
||||
* The branch this account is held at: the account's own office when it was opened at another branch than the
|
||||
* client's, else the client's (or the group's) office. Lets a customer with accounts at several branches tell
|
||||
* them apart on the client page.
|
||||
*/
|
||||
private final Long officeId;
|
||||
private final String officeName;
|
||||
|
||||
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
||||
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
||||
final String fdAccountNumber, final Integer renewalCycleNo) {
|
||||
final String fdAccountNumber, final Integer renewalCycleNo, final Long officeId, final String officeName) {
|
||||
this.fdAccountNumber = fdAccountNumber;
|
||||
this.renewalCycleNo = renewalCycleNo;
|
||||
this.officeId = officeId;
|
||||
this.officeName = officeName;
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.externalId = externalId;
|
||||
|
||||
+5
-1
@@ -21,7 +21,11 @@ package org.apache.fineract.portfolio.aml.domain;
|
||||
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
|
||||
public enum AmlAppliesTo {
|
||||
|
||||
SAVINGS, LOAN, TRANSFER, ALL;
|
||||
/**
|
||||
* CURRENT is a current / Cash Credit account. The engine is not yet fed current-account transactions, so a rule written
|
||||
* for it is catalogued but does not fire until it is.
|
||||
*/
|
||||
SAVINGS, LOAN, TRANSFER, CURRENT, ALL;
|
||||
|
||||
public boolean matches(AmlTransactionType transactionType) {
|
||||
return this == ALL || this.name().equals(transactionType.name());
|
||||
|
||||
+2
-12
@@ -20,14 +20,12 @@ package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
|
||||
@@ -40,11 +38,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_aml_recurring_review")
|
||||
public class AmlRecurringReview {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
public class AmlRecurringReview extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
@@ -107,10 +101,6 @@ public class AmlRecurringReview {
|
||||
this.alertsActioned = count;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
+9
@@ -152,6 +152,15 @@ public class AmlTransactionRule {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a rule to one direction - CREDIT (money in) or DEBIT (money out), null for either - and, when asked, to cash
|
||||
* only. The engine already honours both; a rule written without them applies to every credit and debit, cash or not.
|
||||
*/
|
||||
public void restrictTo(final String direction, final boolean cashOnly) {
|
||||
this.direction = direction;
|
||||
this.cashOnly = cashOnly;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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.client.domain;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The comparable fingerprint of an Aadhaar number: the SHA-256 of the 12 digits, as 64 lowercase hex characters.
|
||||
*
|
||||
* <p>
|
||||
* The hash is computed by the client (browser) and only the hash is sent. The KYC create/update calls go through the
|
||||
* command source, which records the request body in an audit table and in the log, so the 12-digit number must not be
|
||||
* in the body. The server therefore never sees the number and only checks that what it was given has the shape of a
|
||||
* SHA-256 digest.
|
||||
*
|
||||
* <p>
|
||||
* A plain, unkeyed SHA-256 of a 12-digit number can be reversed by trying every possible number, so this column is a
|
||||
* lookup key, not protection against someone who can read the table.
|
||||
*/
|
||||
public final class AadhaarHash {
|
||||
|
||||
private static final Pattern SHA256_HEX = Pattern.compile("^[0-9a-f]{64}$");
|
||||
|
||||
private AadhaarHash() {}
|
||||
|
||||
/** True when the value has the shape of a lowercase hex SHA-256 digest. */
|
||||
public static boolean isValid(final String value) {
|
||||
return value != null && SHA256_HEX.matcher(value).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* The value trimmed and lower-cased when it is a well-formed digest; null for blank input. A non-blank value that
|
||||
* is not a digest is rejected rather than silently dropped, so a client bug cannot disable the duplicate check.
|
||||
*/
|
||||
public static String normalizeOrNull(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String candidate = raw.trim().toLowerCase();
|
||||
if (!isValid(candidate)) {
|
||||
throw new IllegalArgumentException("aadhaarHash must be a 64-character hex SHA-256 digest");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
+27
@@ -151,6 +151,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "aadhaar_number_masked", length = 12)
|
||||
private String aadhaarNumberMasked;
|
||||
|
||||
/**
|
||||
* SHA-256 of the 12-digit Aadhaar, computed client-side (see {@link AadhaarHash}). The masked form cannot tell two
|
||||
* people apart; this can. Null for rows that predate the column and for clients with no Aadhaar.
|
||||
*/
|
||||
@Column(name = "aadhaar_hash", length = 64)
|
||||
private String aadhaarHash;
|
||||
|
||||
@Column(name = "aadhaar_verified", nullable = false)
|
||||
private boolean aadhaarVerified;
|
||||
|
||||
@@ -391,6 +398,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("aadhaarNumberMasked"),
|
||||
command.stringValueOfParameterNamed("gstinNumber"),
|
||||
command.stringValueOfParameterNamed("remarks"));
|
||||
kyc.aadhaarHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed("aadhaarHash"));
|
||||
kyc.applyExtendedFields(command);
|
||||
return kyc;
|
||||
}
|
||||
@@ -493,6 +501,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
actualChanges.put(aadhaarParam, newValue);
|
||||
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||
// The Aadhaar was removed: the fingerprint of the old number must not outlive it.
|
||||
if (this.aadhaarNumberMasked == null) {
|
||||
this.aadhaarHash = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Only the fingerprint is sent, never the number. Reported as a flag, not a value, because the changes map
|
||||
// is echoed back in the API response.
|
||||
final String aadhaarHashParam = "aadhaarHash";
|
||||
if (command.parameterExists(aadhaarHashParam)) {
|
||||
final String newHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed(aadhaarHashParam));
|
||||
if (newHash != null && !newHash.equals(this.aadhaarHash)) {
|
||||
this.aadhaarHash = newHash;
|
||||
actualChanges.put("aadhaarIdentityUpdated", true);
|
||||
}
|
||||
}
|
||||
|
||||
final String gstinParam = "gstinNumber";
|
||||
@@ -1050,6 +1073,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return aadhaarNumberMasked;
|
||||
}
|
||||
|
||||
public String getAadhaarHash() {
|
||||
return aadhaarHash;
|
||||
}
|
||||
|
||||
public boolean isAadhaarVerified() {
|
||||
return aadhaarVerified;
|
||||
}
|
||||
|
||||
+7
@@ -27,6 +27,13 @@ public interface ClientKycContactLogRepository extends JpaRepository<ClientKycCo
|
||||
|
||||
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
||||
|
||||
/** True when a notice carrying this marker has already reached a final outcome on the channel. */
|
||||
boolean existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(Long clientId, KycContactChannel channel,
|
||||
String markerPrefix, java.util.Collection<KycContactDeliveryStatus> statuses);
|
||||
|
||||
/** How many times a marked notice has been tried on the channel — bounds the retries of a failing gateway. */
|
||||
long countByClientIdAndChannelAndNotesStartingWith(Long clientId, KycContactChannel channel, String markerPrefix);
|
||||
|
||||
/**
|
||||
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
||||
* "last contacted" for the whole page without an N+1 query per row.
|
||||
|
||||
+12
@@ -61,6 +61,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
||||
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
||||
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
|
||||
|
||||
/**
|
||||
* KYC records whose re-KYC falls due after {@code asOn} but no later than {@code until}, soonest first.
|
||||
*
|
||||
* <p>The complement of {@link #findLapsedAsOn}: a record is in one list or the other, never both, so the
|
||||
* two together cover everything that needs the branch's attention. Rejected records are excluded for
|
||||
* the same reason as there.
|
||||
*/
|
||||
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate > :asOn "
|
||||
+ "AND k.expiryDate <= :until AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
||||
List<ClientKyc> findExpiringBetween(@Param("asOn") LocalDate asOn, @Param("until") LocalDate until,
|
||||
@Param("rejectedStatus") Integer rejectedStatus);
|
||||
|
||||
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
||||
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
||||
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
||||
|
||||
+47
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
|
||||
@Entity
|
||||
@@ -110,11 +111,57 @@ public class ClientRiskRule {
|
||||
@Column(name = "rule_kind", length = 10)
|
||||
private String ruleKind;
|
||||
|
||||
/**
|
||||
* Whether this scenario applies to Cash Credit accounts - a current account that is allowed to draw an overdraft
|
||||
* against a sanctioned limit. Lets the Rules Master show, in one place, every scenario that watches a CC account,
|
||||
* whatever its coverage status.
|
||||
*/
|
||||
@Column(name = "cc_relevant", nullable = false)
|
||||
private boolean ccRelevant;
|
||||
|
||||
/**
|
||||
* A rule authored from the Rules Master starts on trial: it is listed first and marked new until an officer marks it
|
||||
* normal. Every rule that was already in the catalog is normal.
|
||||
*/
|
||||
@Column(name = "on_trial", nullable = false)
|
||||
private boolean onTrial;
|
||||
|
||||
@Column(name = "trial_started_on")
|
||||
private LocalDate trialStartedOn;
|
||||
|
||||
public static final String KIND_KYC = "KYC";
|
||||
public static final String KIND_AML = "AML";
|
||||
|
||||
protected ClientRiskRule() {}
|
||||
|
||||
public boolean isCcRelevant() {
|
||||
return ccRelevant;
|
||||
}
|
||||
|
||||
public boolean isOnTrial() {
|
||||
return onTrial;
|
||||
}
|
||||
|
||||
public LocalDate getTrialStartedOn() {
|
||||
return trialStartedOn;
|
||||
}
|
||||
|
||||
/** Puts a newly authored rule on trial from {@code startedOn}. */
|
||||
public void startTrial(final LocalDate startedOn) {
|
||||
this.onTrial = true;
|
||||
this.trialStartedOn = startedOn;
|
||||
}
|
||||
|
||||
/** Ends the trial: the rule is a normal part of the catalog. */
|
||||
public void markNormal() {
|
||||
this.onTrial = false;
|
||||
}
|
||||
|
||||
/** Marks the scenario as one that watches Cash Credit accounts, so the Rules Master CC chip lists it. */
|
||||
public void markCcRelevant(final boolean ccRelevant) {
|
||||
this.ccRelevant = ccRelevant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ public interface SearchingClientRepository {
|
||||
/**
|
||||
* @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only.
|
||||
* Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY).
|
||||
* @param officeId null = no filter; otherwise only clients of that office, still inside officeHierarchy.
|
||||
*/
|
||||
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
||||
Integer legalFormValue);
|
||||
Integer legalFormValue, Long officeId);
|
||||
}
|
||||
|
||||
+34
-2
@@ -53,7 +53,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
|
||||
@Override
|
||||
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
||||
Integer legalFormValue) {
|
||||
Integer legalFormValue, Long officeId) {
|
||||
/*
|
||||
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
|
||||
* this point the upgrade is too costly
|
||||
@@ -62,6 +62,9 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
*/
|
||||
String hierarchyLikeValue = officeHierarchy + "%";
|
||||
|
||||
// Customers of other branches that hold an account at the caller's branch are listed here too.
|
||||
final List<Long> linkedClientIds = clientIdsWithAccountAtBranch(hierarchyLikeValue);
|
||||
|
||||
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
|
||||
CriteriaQuery<SearchedClient> query = cb.createQuery(SearchedClient.class);
|
||||
|
||||
@@ -74,7 +77,8 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
Join<Client, ClientIdentifier> identity = r.join("identifiers", JoinType.LEFT);
|
||||
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.like(o.get("hierarchy"), hierarchyLikeValue));
|
||||
final Predicate inHierarchy = cb.like(o.get("hierarchy"), hierarchyLikeValue);
|
||||
predicates.add(linkedClientIds.isEmpty() ? inHierarchy : cb.or(inHierarchy, r.get("id").in(linkedClientIds)));
|
||||
|
||||
String searchLikeValue = "%" + searchText + "%";
|
||||
predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue),
|
||||
@@ -93,6 +97,11 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
predicates.add(cb.equal(r.get("legalForm"), legalFormValue));
|
||||
}
|
||||
|
||||
// Branch filter. ANDed with the hierarchy predicate above, so it can only narrow results.
|
||||
if (officeId != null) {
|
||||
predicates.add(cb.equal(o.get("id"), officeId));
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
|
||||
@@ -108,4 +117,27 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
|
||||
return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of clients holding an account (savings, fixed or recurring deposit, or current) at an office at or below the
|
||||
* given hierarchy pattern, whatever office the client itself belongs to. Only accounts opened at a branch other than
|
||||
* the client's carry their own office, so this is empty for a branch with no such customers, and for Head Office.
|
||||
* Being linked to a branch is deliberately not enough to be listed there.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Long> clientIdsWithAccountAtBranch(final String hierarchyLikeValue) {
|
||||
final List<Object> rows = entityManager.createNativeQuery(
|
||||
"SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id "
|
||||
+ "WHERE sa.client_id IS NOT NULL AND ao.hierarchy LIKE ?1 "
|
||||
+ "UNION SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id "
|
||||
+ "WHERE ca.client_id IS NOT NULL AND co.hierarchy LIKE ?2")
|
||||
// Positional, not named: EclipseLink passes a native query's ":h" to PostgreSQL as written, which fails
|
||||
// with a syntax error and took the whole client list down with it.
|
||||
.setParameter(1, hierarchyLikeValue).setParameter(2, hierarchyLikeValue).getResultList();
|
||||
final List<Long> ids = new ArrayList<>(rows.size());
|
||||
for (final Object row : rows) {
|
||||
ids.add(((Number) row).longValue());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ public enum NoteType {
|
||||
GROUP(600, "noteType.group", "groups", "Group note"), //
|
||||
SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), //
|
||||
SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction note"), //
|
||||
CURRENT_ACCOUNT(900, "noteType.currentaccount", "currentaccounts", "Current account note"), //
|
||||
;
|
||||
|
||||
public static final NoteType[] VALUES = values();
|
||||
|
||||
+12
@@ -28,10 +28,22 @@ public interface CurrentAccountReadPlatformService {
|
||||
|
||||
Map<String, Object> retrieveOneTransaction(Long accountId, Long transactionId);
|
||||
|
||||
/** The account's charges from m_current_account_charge; chargeStatus is all, active or inactive. */
|
||||
List<Map<String, Object>> retrieveCharges(Long accountId, String chargeStatus);
|
||||
|
||||
Map<String, Object> retrieveCharge(Long accountId, Long accountChargeId);
|
||||
|
||||
/** Template for adding a charge: the active current-account charges in the account's currency. */
|
||||
Map<String, Object> retrieveChargeTemplate(Long accountId);
|
||||
|
||||
List<Map<String, Object>> retrieveAll();
|
||||
|
||||
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName);
|
||||
|
||||
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
||||
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate);
|
||||
|
||||
/** As above; with {@code overdraftOnly}, only the Cash Credit accounts - those that allow an overdraft. */
|
||||
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
||||
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate, boolean overdraftOnly);
|
||||
}
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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.accounting.interbranch.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 jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Tells a person looking at one ledger transaction whether it is one branch's part of a transfer between branches.
|
||||
*
|
||||
* <p>
|
||||
* A transfer between accounts at different branches is posted as one ledger transaction per branch, joined through the
|
||||
* Liability Transfer account. Looking at either part on its own shows only two ledger lines, with nothing to say it has
|
||||
* another part in another branch. The bank transfer log records the ledger ids of every part, so given any one of them
|
||||
* this reports the whole transfer: who it moved money from and to, in which branches, and who raised and approved it.
|
||||
*/
|
||||
@Path("/v1/accounting/interbranchtransfers")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class InterBranchTransferApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Path("{transactionId}")
|
||||
public String retrieve(@PathParam("transactionId") final String transactionId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission("JOURNALENTRY");
|
||||
|
||||
// Ledger transaction ids are short alphanumeric strings; anything else cannot be one.
|
||||
if (transactionId == null || !transactionId.matches("[A-Za-z0-9_-]{1,50}")) {
|
||||
return notInterBranch();
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> logs = this.jdbcTemplate.queryForList(
|
||||
"SELECT voucher_no, total_amount, gl_txn_id, debit_entries, credit_entries, status FROM m_bank_transfer_log "
|
||||
+ "WHERE (',' || gl_txn_id || ',') LIKE ? ORDER BY id DESC LIMIT 1",
|
||||
"%," + transactionId + ",%");
|
||||
if (logs.isEmpty()) {
|
||||
return notInterBranch();
|
||||
}
|
||||
final Map<String, Object> log = logs.get(0);
|
||||
|
||||
final List<Map<String, Object>> from = legsOf(log.get("debit_entries"));
|
||||
final List<Map<String, Object>> to = legsOf(log.get("credit_entries"));
|
||||
|
||||
final Set<Object> branchIds = new LinkedHashSet<>();
|
||||
for (final Map<String, Object> leg : from) {
|
||||
branchIds.add(leg.get("branchId"));
|
||||
}
|
||||
for (final Map<String, Object> leg : to) {
|
||||
branchIds.add(leg.get("branchId"));
|
||||
}
|
||||
branchIds.remove(null);
|
||||
if (branchIds.size() < 2) {
|
||||
return notInterBranch();
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("interBranch", true);
|
||||
out.put("voucherNo", log.get("voucher_no"));
|
||||
out.put("amount", log.get("total_amount"));
|
||||
out.put("status", log.get("status"));
|
||||
out.put("thisTransactionId", transactionId);
|
||||
out.put("from", from);
|
||||
out.put("to", to);
|
||||
out.put("parts", partsOf(String.valueOf(log.get("gl_txn_id"))));
|
||||
|
||||
// Raised and approved by: the clerk's request, when the transfer went through approval.
|
||||
final List<Map<String, Object>> requests = this.jdbcTemplate.queryForList(
|
||||
"SELECT clerk_name, manager_name, submitted_at, actioned_at FROM m_clerk_disbursement_request WHERE voucher_no = ? "
|
||||
+ "ORDER BY id DESC LIMIT 1",
|
||||
log.get("voucher_no"));
|
||||
if (!requests.isEmpty()) {
|
||||
final Map<String, Object> request = requests.get(0);
|
||||
out.put("raisedBy", request.get("clerk_name"));
|
||||
out.put("raisedAt", isoOf(request.get("submitted_at")));
|
||||
out.put("approvedBy", request.get("manager_name"));
|
||||
out.put("approvedAt", isoOf(request.get("actioned_at")));
|
||||
}
|
||||
return this.gson.toJson(out);
|
||||
}
|
||||
|
||||
private String notInterBranch() {
|
||||
return "{\"interBranch\":false}";
|
||||
}
|
||||
|
||||
/** Each account on one side of the transfer, with the branch its books are in. */
|
||||
private List<Map<String, Object>> legsOf(final Object storedJson) {
|
||||
final List<Map<String, Object>> legs = new ArrayList<>();
|
||||
if (storedJson == null) {
|
||||
return legs;
|
||||
}
|
||||
final JsonArray entries = this.gson.fromJson(String.valueOf(storedJson), JsonArray.class);
|
||||
for (final JsonElement element : entries) {
|
||||
final JsonObject entry = element.getAsJsonObject();
|
||||
final Map<String, Object> leg = new LinkedHashMap<>();
|
||||
leg.put("accountNo", text(entry, "accountNo"));
|
||||
leg.put("accountName", text(entry, "accountName"));
|
||||
leg.put("amount", text(entry, "amount"));
|
||||
final Long accountId = entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
|
||||
? entry.get("savingsAccountId").getAsLong()
|
||||
: null;
|
||||
final Map<String, Object> branch = accountId == null ? null : branchOfAccount(text(entry, "accountType"), accountId);
|
||||
leg.put("branchId", branch == null ? null : ((Number) branch.get("id")).longValue());
|
||||
leg.put("branchName", branch == null ? null : branch.get("name"));
|
||||
legs.add(leg);
|
||||
}
|
||||
return legs;
|
||||
}
|
||||
|
||||
/** One entry per ledger transaction of the transfer, with the branch it was posted in. */
|
||||
private List<Map<String, Object>> partsOf(final String glTxnIds) {
|
||||
final List<Map<String, Object>> parts = new ArrayList<>();
|
||||
for (final String id : glTxnIds.split(",")) {
|
||||
final String ledgerId = id.trim();
|
||||
if (ledgerId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
final List<Map<String, Object>> offices = this.jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT o.id, o.name FROM acc_gl_journal_entry je JOIN m_office o ON o.id = je.office_id "
|
||||
+ "WHERE je.transaction_id = ?",
|
||||
ledgerId);
|
||||
final Map<String, Object> part = new LinkedHashMap<>();
|
||||
part.put("transactionId", ledgerId);
|
||||
part.put("branchId", offices.isEmpty() ? null : ((Number) offices.get(0).get("id")).longValue());
|
||||
part.put("branchName", offices.isEmpty() ? null : offices.get(0).get("name"));
|
||||
parts.add(part);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch an account's books are in: its own office when it was opened at another branch than the client's,
|
||||
* otherwise the client's (or group's). A current account is its own table with its own ids.
|
||||
*/
|
||||
private Map<String, Object> branchOfAccount(final String accountType, final Long accountId) {
|
||||
final String sql = "CURRENT".equalsIgnoreCase(accountType)
|
||||
? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id "
|
||||
+ "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?"
|
||||
: "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id "
|
||||
+ "LEFT JOIN m_group g ON g.id = sa.group_id "
|
||||
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
|
||||
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private static String text(final JsonObject object, final String name) {
|
||||
final JsonElement element = object.get(name);
|
||||
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||
}
|
||||
|
||||
private static String isoOf(final Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof java.sql.Timestamp) {
|
||||
return ((java.sql.Timestamp) value).toLocalDateTime().toString();
|
||||
}
|
||||
return String.valueOf(value).replace(' ', 'T');
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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.accounting.interbranch.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Keeps a branch's cash ledger true when a cashier takes or pays cash for an account held at another branch.
|
||||
*
|
||||
* <p>
|
||||
* A savings deposit or withdrawal always posts its ledger entries in the branch the account is held at, because that is
|
||||
* where the account's books are. The cash itself, though, is in the drawer of whichever cashier handled it. When that
|
||||
* cashier sits at a different branch, the account's branch would show cash it never received (or paid), and the
|
||||
* cashier's branch would hold cash its ledger does not know about.
|
||||
*
|
||||
* <p>
|
||||
* This moves the cash leg to the cashier's branch, through the Liability Transfer account, exactly as a transfer
|
||||
* between two branches' accounts is booked: the account's branch reverses the cash line against Liability Transfer, and
|
||||
* the cashier's branch books the same cash line against it. Each branch's books balance on their own and the bank's
|
||||
* total does not change. A cashier at the account's own branch, or a user who is not a cashier, changes nothing.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class InterBranchCashService {
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200;
|
||||
private static final int CREDIT = 1;
|
||||
private static final int DEBIT = 2;
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* @param savingsTransactionId
|
||||
* the deposit or withdrawal just posted
|
||||
* @param accountOfficeId
|
||||
* the branch the account is held at, where that posting was booked
|
||||
* @param deposit
|
||||
* true for cash received, false for cash paid out
|
||||
*/
|
||||
public void moveCashLegToCashierBranch(final Long savingsTransactionId, final Long accountOfficeId, final String accountNo,
|
||||
final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
if (user.getOffice() == null || accountOfficeId == null || savingsTransactionId == null) {
|
||||
return;
|
||||
}
|
||||
if (!isCashier(user.getId())) {
|
||||
return;
|
||||
}
|
||||
moveCashLegToBranch(savingsTransactionId, accountOfficeId, user.getOffice().getId(), accountNo, amount, transactionDate, deposit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #moveCashLegToCashierBranch}, for a posting made by someone other than the cashier who handled the
|
||||
* cash, such as a manager approving a deposit a cashier took earlier. The branch the cash is in is given.
|
||||
*/
|
||||
public void moveCashLegToBranch(final Long savingsTransactionId, final Long accountOfficeId, final Long cashierOfficeId,
|
||||
final String accountNo, final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
if (cashierOfficeId == null || accountOfficeId == null || savingsTransactionId == null
|
||||
|| cashierOfficeId.equals(accountOfficeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The cash line of the posting just made: the debit of a deposit, the credit of a withdrawal.
|
||||
final List<Map<String, Object>> cashLines = this.jdbcTemplate.queryForList(
|
||||
"SELECT account_id, currency_code FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ? "
|
||||
+ "AND amount = ? AND reversed = false ORDER BY id DESC",
|
||||
savingsTransactionId, deposit ? DEBIT : CREDIT, amount);
|
||||
if (cashLines.isEmpty()) {
|
||||
log.warn("No cash line found for savings transaction {}; its cash was not moved to the cashier's branch",
|
||||
savingsTransactionId);
|
||||
return;
|
||||
}
|
||||
final Long cashGlId = ((Number) cashLines.get(0).get("account_id")).longValue();
|
||||
final String currencyCode = String.valueOf(cashLines.get(0).get("currency_code"));
|
||||
final Long suspenseGlId = liabilityTransferGlId();
|
||||
|
||||
final String note = (deposit ? "Inter-branch cash deposit" : "Inter-branch cash withdrawal") + " | A/C " + accountNo
|
||||
+ " | savings txn " + savingsTransactionId + " | cashier " + user.getUsername() + " | via Liability Transfer";
|
||||
|
||||
// Account's branch: take the cash line back out. A deposit's was Dr cash, so Cr cash; a withdrawal's was Cr cash.
|
||||
post(accountOfficeId, cashGlId, deposit ? CREDIT : DEBIT, suspenseGlId, deposit ? DEBIT : CREDIT, amount, currencyCode,
|
||||
transactionDate, user.getId(), note);
|
||||
// Cashier's branch: the cash really moved here.
|
||||
post(cashierOfficeId, cashGlId, deposit ? DEBIT : CREDIT, suspenseGlId, deposit ? CREDIT : DEBIT, amount, currencyCode,
|
||||
transactionDate, user.getId(), note);
|
||||
}
|
||||
|
||||
private boolean isCashier(final Long userId) {
|
||||
final Integer count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) 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 = ?", Integer.class, userId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private Long liabilityTransferGlId() {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_LIABILITY_TRANSFER);
|
||||
if (ids.isEmpty()) {
|
||||
throw new IllegalStateException("A cash deposit or withdrawal for another branch's account needs the Liability Transfer "
|
||||
+ "account mapped under Accounting > Financial Activity Mappings.");
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
/** One balanced entry in one branch: the cash line and its Liability Transfer counterpart. */
|
||||
private void post(final Long officeId, final Long cashGlId, final int cashType, final Long suspenseGlId, final int suspenseType,
|
||||
final BigDecimal amount, final String currencyCode, final LocalDate entryDate, final long userId, final String note) {
|
||||
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.INTER_BRANCH_CASH);
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
insert(officeId, cashGlId, cashType, amount, txnId, entryDate, userId, now, currencyCode, note);
|
||||
insert(officeId, suspenseGlId, suspenseType, amount, txnId, entryDate, userId, now, currencyCode, note);
|
||||
}
|
||||
|
||||
private void insert(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
|
||||
final java.time.OffsetDateTime utc = now.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
||||
this.jdbcTemplate.update(
|
||||
"INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||
+ " entry_date, type_enum, amount, description, created_by, last_modified_by, created_date, lastmodified_date, "
|
||||
+ " is_running_balance_calculated, office_running_balance, organization_running_balance, submitted_on_date, "
|
||||
+ " transaction_date, created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
|
||||
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), utc, utc);
|
||||
}
|
||||
}
|
||||
+4
@@ -74,12 +74,16 @@ public final class LedgerTxnPrefix {
|
||||
public static final String CASH_MIGRATION = "CSHMIG";
|
||||
/** Rounding difference on a clerk's day closure. */
|
||||
public static final String CLOSURE_ROUNDING = "CLSRND";
|
||||
/** Cash line of a deposit or withdrawal moved to the cashier's branch, when the account is held at another. */
|
||||
public static final String INTER_BRANCH_CASH = "IBCASH";
|
||||
|
||||
// ── Year end ──────────────────────────────────────────────────────────────
|
||||
/** Year-end transfer of income into the profit account. */
|
||||
public static final String YEAR_END_PROFIT = "YEPFT";
|
||||
/** Year-end closure of an income or expense account. */
|
||||
public static final String YEAR_END_CLOSE = "YECLS";
|
||||
/** Appropriation of a closed year's profit into the reserve funds. */
|
||||
public static final String PROFIT_APPROPRIATION = "PRAPP";
|
||||
|
||||
// ── Savings ───────────────────────────────────────────────────────────────
|
||||
/** Pooled savings interest posting for one product and date. */
|
||||
|
||||
+3
-2
@@ -366,7 +366,7 @@ public class IncomeProfitTransferService {
|
||||
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||
}
|
||||
|
||||
private String resolveCurrency(final Long glAccountId) {
|
||||
public String resolveCurrency(final Long glAccountId) {
|
||||
try {
|
||||
final String code = jdbcTemplate.queryForObject(
|
||||
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
|
||||
@@ -377,7 +377,8 @@ public class IncomeProfitTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
/** Writes one journal entry leg. Shared with the profit appropriation posting so both year-end steps post alike. */
|
||||
public void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update(
|
||||
|
||||
+11
@@ -238,6 +238,11 @@ public class SecurityConfig {
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
|
||||
// current accounts are deposit accounts: their notes use the savings-note permissions
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes/*"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/groups/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE")
|
||||
// notes: create
|
||||
@@ -249,6 +254,8 @@ public class SecurityConfig {
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currentaccounts/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/groups/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE")
|
||||
// notes: update
|
||||
@@ -260,6 +267,8 @@ public class SecurityConfig {
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currentaccounts/*/notes/*"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/groups/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE")
|
||||
// notes: delete
|
||||
@@ -271,6 +280,8 @@ public class SecurityConfig {
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/currentaccounts/*/notes/*"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/groups/*/notes"))
|
||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE")
|
||||
// document: clients
|
||||
|
||||
+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}.
|
||||
|
||||
+20
-4
@@ -361,9 +361,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
|
||||
// this row's cycle within the chain, so a client's account list can collapse a renewed
|
||||
// FD into one entry rather than listing every term as a separate account.
|
||||
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
|
||||
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo, ");
|
||||
// The branch the account is held at: its own office when opened at another branch, else the client's or group's.
|
||||
accountsSummary.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName ");
|
||||
accountsSummary.append("from m_savings_account sa ");
|
||||
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
||||
accountsSummary.append("left join m_client acct_client on acct_client.id = sa.client_id ");
|
||||
accountsSummary.append("left join m_group acct_group on acct_group.id = sa.group_id ");
|
||||
accountsSummary.append(
|
||||
"left join m_office acct_office on acct_office.id = coalesce(sa.office_id, acct_client.office_id, acct_group.office_id) ");
|
||||
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
|
||||
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
||||
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
||||
@@ -464,9 +470,12 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
final String fdAccountNumber = rs.getString("fdAccountNumber");
|
||||
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
|
||||
|
||||
final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId");
|
||||
final String accountOfficeName = rs.getString("accountOfficeName");
|
||||
|
||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
||||
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
|
||||
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo, accountOfficeId, accountOfficeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,9 +505,13 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
sb.append("ca.currency_code as currencyCode, ca.currency_digits as currencyDigits, ca.currency_multiplesof as inMultiplesOf, ");
|
||||
sb.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
||||
sb.append("curr.display_symbol as currencyDisplaySymbol, ");
|
||||
sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName ");
|
||||
sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName, ");
|
||||
// The branch the account is held at: its own office, else the client's.
|
||||
sb.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName ");
|
||||
sb.append("from m_current_account ca ");
|
||||
sb.append("join m_current_product cp on cp.id = ca.product_id ");
|
||||
sb.append("left join m_client acct_client on acct_client.id = ca.client_id ");
|
||||
sb.append("left join m_office acct_office on acct_office.id = coalesce(ca.office_id, acct_client.office_id) ");
|
||||
sb.append("join m_currency curr on curr.code = ca.currency_code ");
|
||||
sb.append("left join m_appuser sbu on sbu.id = ca.submittedon_userid ");
|
||||
sb.append("left join m_appuser rbu on rbu.id = ca.rejectedon_userid ");
|
||||
@@ -578,10 +591,13 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
|
||||
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
||||
|
||||
final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId");
|
||||
final String accountOfficeName = rs.getString("accountOfficeName");
|
||||
|
||||
// Current accounts have no deposit term, so no FD renewal chain to report.
|
||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
|
||||
null, null);
|
||||
null, null, accountOfficeId, accountOfficeName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* 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.almreturn.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The fixed-term part of the Structural Liquidity Statement: what the bank's customers' Fixed
|
||||
* Deposits, Recurring Deposits and term loans add up to in each residual-maturity bucket, counted
|
||||
* from an as-on date.
|
||||
*
|
||||
* <h3>How each figure is slotted</h3>
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Fixed and Recurring Deposits</b> (outflow): the account balance, on the deposit's maturity
|
||||
* date. RBI slots term deposits by residual maturity; accrued interest is a separate line of the
|
||||
* statement ("Interest Payable"), so it is not added here. A deposit already at or past its
|
||||
* maturity is an overdue liability and goes in the first bucket.</li>
|
||||
* <li><b>Term loans</b> (inflow): each unpaid instalment's principal, on that instalment's due date -
|
||||
* RBI asks for the interim cash flows, not the whole loan in one bucket. An instalment already due
|
||||
* is overdue: one that fell due less than a month ago goes in the "recent" bucket and an older one
|
||||
* in the "old" bucket. Both bucket positions are parameters, because they are the bank's
|
||||
* Overdue Receivables rule and not something this code should decide.</li>
|
||||
* <li><b>Not slotted by date</b>, reported on their own so they are not silently lost: NPA loans (RBI
|
||||
* slots them by classification, not by instalment) and cash-credit / overdraft / demand loans (their
|
||||
* stored schedule is a synthetic equal-instalment one, not a real repayment pattern).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>What this does not cover</h3>
|
||||
*
|
||||
* The bank's own placements and borrowings - FDs with other banks, investments, borrowings - have no
|
||||
* register in the system, only ledger balances, so there is nothing to slot by date. The response
|
||||
* says so rather than leaving them out silently.
|
||||
*
|
||||
* <h3>As-on date</h3>
|
||||
*
|
||||
* Accounts are read in their state today. The as-on date only decides where a maturity falls
|
||||
* relative to it; it does not rebuild what the books looked like on an earlier day.
|
||||
*
|
||||
* <h3>Buckets</h3>
|
||||
*
|
||||
* The bucket edges arrive from the caller, since the bank edits them. {@code buckets=14d,28d,3m,6m,1y,3y,5y}
|
||||
* means the first bucket ends 14 days out, the second 28 days out, the third 3 calendar months out
|
||||
* and so on, with a last bucket that has no end. Months and years are calendar ones.
|
||||
*/
|
||||
@Path("/v1/almreturn/maturity-breakdown")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class AlmMaturityBreakdownApiResource {
|
||||
|
||||
/** Same audience as the other RBI returns, so no separate permission is introduced for it. */
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN";
|
||||
|
||||
/** The bucket edges used when the caller names none: RBI's non-scheduled UCB structure. */
|
||||
private static final String DEFAULT_BUCKETS = "14d,28d,3m,6m,1y,3y,5y";
|
||||
|
||||
private static final int FIXED_DEPOSIT = 200;
|
||||
private static final int RECURRING_DEPOSIT = 300;
|
||||
|
||||
/** SavingsAccountStatusType ACTIVE and MATURED: a matured deposit is still a liability until closed or renewed. */
|
||||
private static final int SAVINGS_ACTIVE = 300;
|
||||
private static final int SAVINGS_MATURED = 800;
|
||||
|
||||
/** LoanStatus.ACTIVE. */
|
||||
private static final int LOAN_ACTIVE = 300;
|
||||
|
||||
private static final Pattern EDGE = Pattern.compile("^(\\d{1,4})([dmy])$");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
public String retrieve(@QueryParam("asOnDate") final String asOnDateParam, @QueryParam("officeId") final Long officeId,
|
||||
@QueryParam("buckets") final String bucketsParam, @QueryParam("overdueRecentBucket") final Integer overdueRecentBucket,
|
||||
@QueryParam("overdueOldBucket") final Integer overdueOldBucket) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final LocalDate asOn = parseDate(asOnDateParam);
|
||||
final List<LocalDate> limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam);
|
||||
final int bucketCount = limits.size() + 1;
|
||||
final int recent = clampBucket(overdueRecentBucket, 0, bucketCount);
|
||||
final int old = clampBucket(overdueOldBucket, 2, bucketCount);
|
||||
final String hierarchy = scopeHierarchy(officeId) + "%";
|
||||
|
||||
final List<Map<String, Object>> groups = new ArrayList<>();
|
||||
groups.add(depositGroup("FD", "Fixed Deposits", FIXED_DEPOSIT, asOn, limits, hierarchy));
|
||||
groups.add(depositGroup("RD", "Recurring Deposits", RECURRING_DEPOSIT, asOn, limits, hierarchy));
|
||||
groups.add(termLoanGroup(asOn, limits, hierarchy, recent, old));
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("asOnDate", asOn.toString());
|
||||
response.put("officeId", officeId);
|
||||
response.put("bucketCount", bucketCount);
|
||||
response.put("bucketEnds", limits.stream().map(LocalDate::toString).toList());
|
||||
response.put("groups", groups);
|
||||
response.put("notSlotted", List.of(npaLoans(hierarchy), revolvingLoans(hierarchy)));
|
||||
response.put("notes", List.of(
|
||||
"Deposits are slotted by their balance on the maturity date; accrued interest belongs to the Interest Payable line.",
|
||||
"Term loans are slotted instalment by instalment (principal only); accrued interest belongs to the Interest Receivable line.",
|
||||
"Accounts are read in their state today; the as-on date only decides where a maturity falls.",
|
||||
"The bank's own FDs with other banks, investments and borrowings have no register, so they are not part of this breakdown."));
|
||||
return this.gson.toJson(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────── Deposits ───────────────────────────
|
||||
|
||||
private Map<String, Object> depositGroup(final String key, final String label, final int depositType, final LocalDate asOn,
|
||||
final List<LocalDate> limits, final String hierarchy) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||
SELECT datp.maturity_date AS "d",
|
||||
sa.status_enum AS "st",
|
||||
COUNT(*) AS "n",
|
||||
COALESCE(SUM(sa.account_balance_derived), 0) AS "amt"
|
||||
FROM m_savings_account sa
|
||||
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
|
||||
WHERE sa.deposit_type_enum = ?
|
||||
AND sa.status_enum IN (?, ?)
|
||||
AND o.hierarchy LIKE ?
|
||||
GROUP BY datp.maturity_date, sa.status_enum
|
||||
""", depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy);
|
||||
|
||||
final BigDecimal[] buckets = zeros(limits.size() + 1);
|
||||
long accounts = 0;
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final long n = ((Number) row.get("n")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) row.get("amt");
|
||||
accounts += n;
|
||||
final Date maturity = (Date) row.get("d");
|
||||
// A MATURED deposit is already past its date and still owed: the first bucket, whatever the date says.
|
||||
final boolean matured = ((Number) row.get("st")).intValue() == SAVINGS_MATURED;
|
||||
final int index = matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits);
|
||||
buckets[index] = buckets[index].add(amount);
|
||||
}
|
||||
return group(key, label, "liability", accounts, buckets);
|
||||
}
|
||||
|
||||
// ─────────────────────────── Loans ───────────────────────────
|
||||
|
||||
private Map<String, Object> termLoanGroup(final LocalDate asOn, final List<LocalDate> limits, final String hierarchy,
|
||||
final int recentOverdueBucket, final int oldOverdueBucket) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||
SELECT rs.duedate AS "d",
|
||||
COALESCE(SUM(GREATEST(rs.principal_amount
|
||||
- COALESCE(rs.principal_completed_derived, 0)
|
||||
- COALESCE(rs.principal_writtenoff_derived, 0), 0)), 0) AS "amt"
|
||||
FROM m_loan_repayment_schedule rs
|
||||
JOIN m_loan l ON l.id = rs.loan_id
|
||||
LEFT JOIN m_client c ON c.id = l.client_id
|
||||
LEFT JOIN m_group g ON g.id = l.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE l.loan_status_id = ?
|
||||
AND COALESCE(l.is_npa, false) = false
|
||||
AND COALESCE(rs.completed_derived, false) = false
|
||||
AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
|
||||
WHERE ext.loan_product_id = l.product_id
|
||||
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
|
||||
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
|
||||
AND o.hierarchy LIKE ?
|
||||
GROUP BY rs.duedate
|
||||
""", LOAN_ACTIVE, hierarchy);
|
||||
|
||||
final BigDecimal[] buckets = zeros(limits.size() + 1);
|
||||
final LocalDate recentCutoff = asOn.minusMonths(1);
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final LocalDate due = ((Date) row.get("d")).toLocalDate();
|
||||
final BigDecimal amount = (BigDecimal) row.get("amt");
|
||||
final int index;
|
||||
if (due.isAfter(asOn)) {
|
||||
index = bucketIndex(asOn, due, limits);
|
||||
} else {
|
||||
// Already due and unpaid. Under a month overdue is still expected shortly; older is not.
|
||||
index = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket;
|
||||
}
|
||||
buckets[index] = buckets[index].add(amount);
|
||||
}
|
||||
|
||||
final Long loans = this.jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM m_loan l
|
||||
LEFT JOIN m_client c ON c.id = l.client_id
|
||||
LEFT JOIN m_group g ON g.id = l.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE l.loan_status_id = ?
|
||||
AND COALESCE(l.is_npa, false) = false
|
||||
AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
|
||||
WHERE ext.loan_product_id = l.product_id
|
||||
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
|
||||
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
|
||||
AND o.hierarchy LIKE ?
|
||||
""", Long.class, LOAN_ACTIVE, hierarchy);
|
||||
return group("LOAN", "Term Loans (principal instalments)", "asset", loans == null ? 0 : loans, buckets);
|
||||
}
|
||||
|
||||
/** NPA loans: RBI slots them by classification (sub-standard / doubtful / loss), not by instalment date. */
|
||||
private Map<String, Object> npaLoans(final String hierarchy) {
|
||||
return outstanding("NPA", "NPA loans", """
|
||||
SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt"
|
||||
FROM m_loan l
|
||||
LEFT JOIN m_client c ON c.id = l.client_id
|
||||
LEFT JOIN m_group g ON g.id = l.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE l.loan_status_id = ? AND COALESCE(l.is_npa, false) = true AND o.hierarchy LIKE ?
|
||||
""", "Slotted by classification (sub-standard, doubtful, loss), net of provision, not by instalment date.",
|
||||
hierarchy);
|
||||
}
|
||||
|
||||
/** CC / OD / demand loans: the stored schedule is synthetic, so the volatile/core split rule applies instead. */
|
||||
private Map<String, Object> revolvingLoans(final String hierarchy) {
|
||||
return outstanding("REVOLVING", "CC / OD / demand loans", """
|
||||
SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt"
|
||||
FROM m_loan l
|
||||
LEFT JOIN m_client c ON c.id = l.client_id
|
||||
LEFT JOIN m_group g ON g.id = l.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE l.loan_status_id = ?
|
||||
AND COALESCE(l.is_npa, false) = false
|
||||
AND EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
|
||||
WHERE ext.loan_product_id = l.product_id
|
||||
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
|
||||
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
|
||||
AND o.hierarchy LIKE ?
|
||||
""", "Split into a volatile and a core share by the bank's own rule; the stored instalment schedule is not a real repayment pattern.",
|
||||
hierarchy);
|
||||
}
|
||||
|
||||
/** One count-and-total line for loans that are reported apart from the date buckets. */
|
||||
private Map<String, Object> outstanding(final String key, final String label, final String sql, final String why,
|
||||
final String hierarchy) {
|
||||
final Map<String, Object> row = this.jdbcTemplate.queryForMap(sql, LOAN_ACTIVE, hierarchy);
|
||||
final Map<String, Object> line = new LinkedHashMap<>();
|
||||
line.put("key", key);
|
||||
line.put("label", label);
|
||||
line.put("side", "asset");
|
||||
line.put("accounts", ((Number) row.get("n")).longValue());
|
||||
line.put("outstanding", row.get("amt"));
|
||||
line.put("why", why);
|
||||
return line;
|
||||
}
|
||||
|
||||
// ─────────────────────────── Picking accounts one by one ───────────────────────────
|
||||
|
||||
/** How many matches one search returns. */
|
||||
private static final int SEARCH_LIMIT = 25;
|
||||
|
||||
/** How many accounts one report may cover. */
|
||||
private static final int SELECTED_LIMIT = 200;
|
||||
|
||||
/** A loan product whose stored schedule is not a real repayment pattern: cash credit, overdraft, demand. */
|
||||
private static final String REVOLVING_PRODUCT = "EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext"
|
||||
+ " WHERE ext.loan_product_id = l.product_id"
|
||||
+ " AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc') OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))";
|
||||
|
||||
/**
|
||||
* Find fixed deposits, recurring deposits and loans by account number or customer name, so a few of
|
||||
* them can be picked and broken down by days.
|
||||
*
|
||||
* @param kind
|
||||
* FD, RD or LOAN to look in one kind only; empty to look in all three
|
||||
*/
|
||||
@GET
|
||||
@Path("search")
|
||||
public String search(@QueryParam("q") final String q, @QueryParam("kind") final String kind,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final String text = q == null ? "" : q.trim().toLowerCase();
|
||||
final List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (text.length() >= 2) {
|
||||
final String like = "%" + text + "%";
|
||||
final String hierarchy = scopeHierarchy(officeId) + "%";
|
||||
final String wanted = kind == null ? "" : kind.trim().toUpperCase();
|
||||
if (wanted.isEmpty() || "FD".equals(wanted)) {
|
||||
results.addAll(searchDeposits(FIXED_DEPOSIT, "FD", like, hierarchy));
|
||||
}
|
||||
if (wanted.isEmpty() || "RD".equals(wanted)) {
|
||||
results.addAll(searchDeposits(RECURRING_DEPOSIT, "RD", like, hierarchy));
|
||||
}
|
||||
if (wanted.isEmpty() || "LOAN".equals(wanted)) {
|
||||
results.addAll(searchLoans(like, hierarchy));
|
||||
}
|
||||
}
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("results", results);
|
||||
return this.gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day-wise breakdown of the accounts that were picked: each deposit as one row on its maturity
|
||||
* date, each loan as one row per unpaid instalment on its due date, every row with the days to that
|
||||
* date from the as-on date and the bucket it falls in.
|
||||
*
|
||||
* @param accountsParam
|
||||
* the picked accounts as KIND:id pairs, for example FD:12,RD:5,LOAN:33
|
||||
*/
|
||||
@GET
|
||||
@Path("selected")
|
||||
public String selected(@QueryParam("accounts") final String accountsParam, @QueryParam("asOnDate") final String asOnDateParam,
|
||||
@QueryParam("officeId") final Long officeId, @QueryParam("buckets") final String bucketsParam,
|
||||
@QueryParam("overdueRecentBucket") final Integer overdueRecentBucket,
|
||||
@QueryParam("overdueOldBucket") final Integer overdueOldBucket) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
if (accountsParam == null || accountsParam.isBlank()) {
|
||||
throw new BadRequestException("accounts is required, like FD:12,RD:5,LOAN:33");
|
||||
}
|
||||
final LocalDate asOn = parseDate(asOnDateParam);
|
||||
final List<LocalDate> limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam);
|
||||
final int bucketCount = limits.size() + 1;
|
||||
final String hierarchy = scopeHierarchy(officeId) + "%";
|
||||
|
||||
final List<Long> fixed = new ArrayList<>();
|
||||
final List<Long> recurring = new ArrayList<>();
|
||||
final List<Long> loans = new ArrayList<>();
|
||||
for (final String token : accountsParam.split(",")) {
|
||||
final String[] parts = token.trim().split(":");
|
||||
if (parts.length != 2) {
|
||||
throw new BadRequestException("accounts must look like FD:12,RD:5,LOAN:33");
|
||||
}
|
||||
final long id;
|
||||
try {
|
||||
id = Long.parseLong(parts[1].trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new BadRequestException("Account id '" + parts[1] + "' is not a number");
|
||||
}
|
||||
switch (parts[0].trim().toUpperCase()) {
|
||||
case "FD" -> fixed.add(id);
|
||||
case "RD" -> recurring.add(id);
|
||||
case "LOAN" -> loans.add(id);
|
||||
default -> throw new BadRequestException("Account kind '" + parts[0] + "' must be FD, RD or LOAN");
|
||||
}
|
||||
}
|
||||
if (fixed.size() + recurring.size() + loans.size() > SELECTED_LIMIT) {
|
||||
throw new BadRequestException("Pick at most " + SELECTED_LIMIT + " accounts at a time");
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (!fixed.isEmpty()) {
|
||||
rows.addAll(depositRows(FIXED_DEPOSIT, "FD", fixed, asOn, limits, hierarchy));
|
||||
}
|
||||
if (!recurring.isEmpty()) {
|
||||
rows.addAll(depositRows(RECURRING_DEPOSIT, "RD", recurring, asOn, limits, hierarchy));
|
||||
}
|
||||
if (!loans.isEmpty()) {
|
||||
rows.addAll(instalmentRows(loans, asOn, limits, hierarchy, clampBucket(overdueRecentBucket, 0, bucketCount),
|
||||
clampBucket(overdueOldBucket, 2, bucketCount)));
|
||||
}
|
||||
// By date, undated rows last, then by account.
|
||||
rows.sort(Comparator.comparing((Map<String, Object> r) -> String.valueOf(r.get("date")))
|
||||
.thenComparing(r -> String.valueOf(r.get("accountNo"))));
|
||||
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : rows) {
|
||||
total = total.add((BigDecimal) row.get("amount"));
|
||||
}
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("asOnDate", asOn.toString());
|
||||
response.put("bucketCount", bucketCount);
|
||||
response.put("count", rows.size());
|
||||
response.put("total", total);
|
||||
response.put("rows", rows);
|
||||
return this.gson.toJson(response);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> searchDeposits(final int depositType, final String kind, final String like,
|
||||
final String hierarchy) {
|
||||
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList("""
|
||||
SELECT sa.id AS "id",
|
||||
sa.account_no AS "accountNo",
|
||||
COALESCE(c.display_name, g.display_name) AS "customer",
|
||||
sp.name AS "product",
|
||||
datp.maturity_date AS "d",
|
||||
COALESCE(sa.account_balance_derived, 0) AS "amt"
|
||||
FROM m_savings_account sa
|
||||
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
|
||||
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
|
||||
WHERE sa.deposit_type_enum = ?
|
||||
AND sa.status_enum IN (?, ?)
|
||||
AND o.hierarchy LIKE ?
|
||||
AND (LOWER(sa.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?)
|
||||
ORDER BY sa.account_no
|
||||
LIMIT ?
|
||||
""", depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy, like, like, SEARCH_LIMIT);
|
||||
return hits(found, kind);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> searchLoans(final String like, final String hierarchy) {
|
||||
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList("""
|
||||
SELECT l.id AS "id",
|
||||
l.account_no AS "accountNo",
|
||||
COALESCE(c.display_name, g.display_name) AS "customer",
|
||||
lp.name AS "product",
|
||||
COALESCE(l.principal_outstanding_derived, 0) AS "amt"
|
||||
FROM m_loan l
|
||||
JOIN m_product_loan lp ON lp.id = l.product_id
|
||||
LEFT JOIN m_client c ON c.id = l.client_id
|
||||
LEFT JOIN m_group g ON g.id = l.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE l.loan_status_id = ?
|
||||
AND o.hierarchy LIKE ?
|
||||
AND (LOWER(l.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?)
|
||||
ORDER BY l.account_no
|
||||
LIMIT ?
|
||||
""", LOAN_ACTIVE, hierarchy, like, like, SEARCH_LIMIT);
|
||||
return hits(found, "LOAN");
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> hits(final List<Map<String, Object>> found, final String kind) {
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final Map<String, Object> r : found) {
|
||||
final Date date = (Date) r.get("d");
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("kind", kind);
|
||||
row.put("id", r.get("id"));
|
||||
row.put("accountNo", r.get("accountNo"));
|
||||
row.put("customer", r.get("customer"));
|
||||
row.put("product", r.get("product"));
|
||||
row.put("date", date == null ? null : date.toLocalDate().toString());
|
||||
row.put("amount", r.get("amt"));
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static String placeholders(final int count) {
|
||||
return String.join(",", Collections.nCopies(count, "?"));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> depositRows(final int depositType, final String label, final List<Long> ids,
|
||||
final LocalDate asOn, final List<LocalDate> limits, final String hierarchy) {
|
||||
final String sql = """
|
||||
SELECT sa.id AS "id",
|
||||
sa.account_no AS "accountNo",
|
||||
COALESCE(c.display_name, g.display_name) AS "customer",
|
||||
sp.name AS "product",
|
||||
datp.maturity_date AS "d",
|
||||
sa.status_enum AS "st",
|
||||
COALESCE(sa.account_balance_derived, 0) AS "amt"
|
||||
FROM m_savings_account sa
|
||||
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
|
||||
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
|
||||
WHERE sa.deposit_type_enum = ?
|
||||
AND sa.status_enum IN (?, ?)
|
||||
AND o.hierarchy LIKE ?
|
||||
AND sa.id IN (%s)
|
||||
ORDER BY datp.maturity_date, sa.account_no
|
||||
""".formatted(placeholders(ids.size()));
|
||||
final List<Object> args = new ArrayList<>(List.of(depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy));
|
||||
args.addAll(ids);
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||
final Date maturity = (Date) r.get("d");
|
||||
final boolean matured = ((Number) r.get("st")).intValue() == SAVINGS_MATURED;
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", r.get("id"));
|
||||
row.put("accountNo", r.get("accountNo"));
|
||||
row.put("customer", r.get("customer"));
|
||||
row.put("product", r.get("product"));
|
||||
row.put("kind", label);
|
||||
row.put("date", maturity == null ? null : maturity.toLocalDate().toString());
|
||||
row.put("days", maturity == null ? null : ChronoUnit.DAYS.between(asOn, maturity.toLocalDate()));
|
||||
row.put("status", matured ? "MATURED" : "ACTIVE");
|
||||
// Same rule as the totals: a matured deposit is overdue and sits in the first bucket.
|
||||
row.put("bucket", matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits));
|
||||
row.put("amount", r.get("amt"));
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unpaid instalments of the picked loans. A picked loan is shown by its schedule whatever it is,
|
||||
* with a note where the schedule is not the whole story (an NPA, or a cash credit / overdraft).
|
||||
*/
|
||||
private List<Map<String, Object>> instalmentRows(final List<Long> ids, final LocalDate asOn, final List<LocalDate> limits,
|
||||
final String hierarchy, final int recentOverdueBucket, final int oldOverdueBucket) {
|
||||
final String sql = """
|
||||
SELECT l.id AS "id",
|
||||
l.account_no AS "accountNo",
|
||||
COALESCE(c.display_name, g.display_name) AS "customer",
|
||||
lp.name AS "product",
|
||||
rs.installment AS "installment",
|
||||
rs.duedate AS "d",
|
||||
GREATEST(rs.principal_amount
|
||||
- COALESCE(rs.principal_completed_derived, 0)
|
||||
- COALESCE(rs.principal_writtenoff_derived, 0), 0) AS "amt",
|
||||
COALESCE(l.is_npa, false) AS "npa",
|
||||
(%s) AS "revolving"
|
||||
FROM m_loan_repayment_schedule rs
|
||||
JOIN m_loan l ON l.id = rs.loan_id
|
||||
JOIN m_product_loan lp ON lp.id = l.product_id
|
||||
LEFT JOIN m_client c ON c.id = l.client_id
|
||||
LEFT JOIN m_group g ON g.id = l.group_id
|
||||
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE l.loan_status_id = ?
|
||||
AND COALESCE(rs.completed_derived, false) = false
|
||||
AND GREATEST(rs.principal_amount
|
||||
- COALESCE(rs.principal_completed_derived, 0)
|
||||
- COALESCE(rs.principal_writtenoff_derived, 0), 0) > 0
|
||||
AND o.hierarchy LIKE ?
|
||||
AND l.id IN (%s)
|
||||
ORDER BY rs.duedate, l.account_no
|
||||
""".formatted(REVOLVING_PRODUCT, placeholders(ids.size()));
|
||||
final List<Object> args = new ArrayList<>(List.of(LOAN_ACTIVE, hierarchy));
|
||||
args.addAll(ids);
|
||||
|
||||
final LocalDate recentCutoff = asOn.minusMonths(1);
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||
final LocalDate due = ((Date) r.get("d")).toLocalDate();
|
||||
final int bucket;
|
||||
if (due.isAfter(asOn)) {
|
||||
bucket = bucketIndex(asOn, due, limits);
|
||||
} else {
|
||||
bucket = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket;
|
||||
}
|
||||
final boolean npa = Boolean.TRUE.equals(r.get("npa"));
|
||||
final boolean revolving = Boolean.TRUE.equals(r.get("revolving"));
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", r.get("id"));
|
||||
row.put("accountNo", r.get("accountNo"));
|
||||
row.put("customer", r.get("customer"));
|
||||
row.put("product", r.get("product"));
|
||||
row.put("kind", "LOAN");
|
||||
row.put("installment", r.get("installment"));
|
||||
row.put("date", due.toString());
|
||||
row.put("days", ChronoUnit.DAYS.between(asOn, due));
|
||||
row.put("status", due.isAfter(asOn) ? "DUE LATER" : "OVERDUE");
|
||||
row.put("bucket", bucket);
|
||||
row.put("amount", r.get("amt"));
|
||||
row.put("note", npa ? "NPA loan: RBI slots it by classification, not by instalment date"
|
||||
: revolving ? "CC / OD loan: the stored schedule is not a real repayment pattern" : null);
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─────────────────────────── Helpers ───────────────────────────
|
||||
|
||||
private static Map<String, Object> group(final String key, final String label, final String side, final long accounts,
|
||||
final BigDecimal[] buckets) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final BigDecimal b : buckets) {
|
||||
total = total.add(b);
|
||||
}
|
||||
final Map<String, Object> group = new LinkedHashMap<>();
|
||||
group.put("key", key);
|
||||
group.put("label", label);
|
||||
group.put("side", side);
|
||||
group.put("accounts", accounts);
|
||||
group.put("total", total);
|
||||
group.put("buckets", List.of(buckets));
|
||||
return group;
|
||||
}
|
||||
|
||||
private static BigDecimal[] zeros(final int size) {
|
||||
final BigDecimal[] values = new BigDecimal[size];
|
||||
java.util.Arrays.fill(values, BigDecimal.ZERO);
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Bucket of a maturity: due or overdue goes in the first; otherwise the first bucket whose end it does not pass. */
|
||||
static int bucketIndex(final LocalDate asOn, final LocalDate maturity, final List<LocalDate> limits) {
|
||||
if (!maturity.isAfter(asOn)) {
|
||||
return 0;
|
||||
}
|
||||
for (int i = 0; i < limits.size(); i++) {
|
||||
if (!maturity.isAfter(limits.get(i))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return limits.size();
|
||||
}
|
||||
|
||||
/** "14d,28d,3m" to the dates those edges reach from the as-on date. Calendar months and years; edges must climb. */
|
||||
private static List<LocalDate> parseLimits(final LocalDate asOn, final String spec) {
|
||||
final List<LocalDate> limits = new ArrayList<>();
|
||||
for (final String token : spec.split(",")) {
|
||||
final Matcher m = EDGE.matcher(token.trim().toLowerCase());
|
||||
if (!m.matches() || Integer.parseInt(m.group(1)) < 1) {
|
||||
throw new BadRequestException("Bucket edge '" + token + "' is not like 14d, 3m or 1y");
|
||||
}
|
||||
final int n = Integer.parseInt(m.group(1));
|
||||
final LocalDate limit = switch (m.group(2)) {
|
||||
case "d" -> asOn.plusDays(n);
|
||||
case "m" -> asOn.plusMonths(n);
|
||||
default -> asOn.plusYears(n);
|
||||
};
|
||||
if (!limits.isEmpty() && !limit.isAfter(limits.get(limits.size() - 1))) {
|
||||
throw new BadRequestException("Bucket edges must run in increasing order: " + spec);
|
||||
}
|
||||
limits.add(limit);
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
private static int clampBucket(final Integer requested, final int fallback, final int bucketCount) {
|
||||
final int value = requested == null ? fallback : requested;
|
||||
return Math.max(0, Math.min(value, bucketCount - 1));
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw);
|
||||
} catch (final java.time.format.DateTimeParseException e) {
|
||||
throw new BadRequestException("asOnDate must be yyyy-MM-dd");
|
||||
}
|
||||
}
|
||||
|
||||
/** The office tree a caller may read: their own, or a branch under it if they asked for one. */
|
||||
private String scopeHierarchy(final Long officeId) {
|
||||
final String own = this.context.authenticatedUser().getOffice().getHierarchy();
|
||||
if (officeId == null) {
|
||||
return own;
|
||||
}
|
||||
final List<String> asked = this.jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class, officeId);
|
||||
return !asked.isEmpty() && asked.get(0).startsWith(own) ? asked.get(0) : own;
|
||||
}
|
||||
}
|
||||
+1022
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.aml.job;
|
||||
import java.time.YearMonth;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
@@ -30,9 +31,8 @@ import org.springframework.batch.repeat.RepeatStatus;
|
||||
|
||||
/**
|
||||
* Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical
|
||||
* threshold aggregation only. Admin-triggered today (see {@code AmlCtrReportApiResource}); wiring
|
||||
* to a real monthly cron is a follow-up, matching the same "driven by an admin endpoint today"
|
||||
* stance already used for {@code NeftBatchProcessorService}.
|
||||
* threshold aggregation only. Scheduled for 02:00 on the 1st (changelog 0606), and still available from the
|
||||
* admin endpoint (see {@code AmlCtrReportApiResource}) for a month run by hand.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@@ -42,7 +42,9 @@ public class AmlCtrReportBatchTasklet implements Tasklet {
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
final YearMonth previousMonth = YearMonth.now().minusMonths(1);
|
||||
// The business date, not the wall clock: the month that has just ended is the bank's month, and the two dates
|
||||
// are not always the same day.
|
||||
final YearMonth previousMonth = YearMonth.from(DateUtils.getBusinessLocalDate()).minusMonths(1);
|
||||
try {
|
||||
final AmlCtrReport report = generationService.generateForMonth(previousMonth);
|
||||
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
|
||||
|
||||
+88
-4
@@ -100,6 +100,7 @@ public class CashierDepositRequestApiResource {
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final VoucherService voucherService;
|
||||
private final FixedDepositFundingValidator fixedDepositFundingValidator;
|
||||
private final org.apache.fineract.accounting.interbranch.service.InterBranchCashService interBranchCashService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
|
||||
@@ -216,8 +217,9 @@ public class CashierDepositRequestApiResource {
|
||||
@QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||
// A branch reviews only the requests its own counters raised (and those of branches below it).
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + REQUEST_BRANCH_IN_HIERARCHY);
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
baseParams.add(Date.valueOf(fromDate));
|
||||
@@ -246,9 +248,13 @@ public class CashierDepositRequestApiResource {
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
|
||||
final List<Map<String, Object>> pageItems = new ArrayList<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
pageItems.add(withBranches(new java.util.LinkedHashMap<>(row)));
|
||||
}
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalFilteredRecords", total);
|
||||
result.add("pageItems", gson.toJsonTree(rows));
|
||||
result.add("pageItems", gson.toJsonTree(pageItems));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@@ -313,7 +319,82 @@ public class CashierDepositRequestApiResource {
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getById(@PathParam("id") final Long id) {
|
||||
return gson.toJson(loadRecord(id));
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
assertRequestInCallersBranch(record);
|
||||
final Map<String, Object> detail = new java.util.LinkedHashMap<>();
|
||||
record.forEach((key, value) -> detail.put(String.valueOf(key), value));
|
||||
return gson.toJson(withBranches(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the two branches a deposit request involves: where the cash was taken (the cashier's counter) and where the
|
||||
* account is held. When they differ the request is an inter-branch deposit, which the reviewer must be able to see.
|
||||
*/
|
||||
private Map<String, Object> withBranches(final Map<String, Object> row) {
|
||||
final Object requestId = row.get("id");
|
||||
final List<Map<String, Object>> counter = jdbcTemplate.queryForList(
|
||||
"SELECT o.id, o.name FROM m_cashier_deposit_request r LEFT JOIN m_appuser au ON au.id = r.cashier_id "
|
||||
+ "JOIN m_office o ON o.id = COALESCE(r.counter_office_id, au.office_id) WHERE r.id = ?", requestId);
|
||||
final List<Map<String, Object>> account = jdbcTemplate.queryForList("""
|
||||
SELECT o.id, o.name FROM m_cashier_deposit_request r
|
||||
LEFT JOIN m_savings_account sa ON sa.id = r.savings_account_id AND sa.account_no = r.account_no
|
||||
LEFT JOIN m_client sc ON sc.id = sa.client_id
|
||||
LEFT JOIN m_current_account ca ON ca.id = r.savings_account_id AND ca.account_no = r.account_no
|
||||
LEFT JOIN m_client cc ON cc.id = ca.client_id
|
||||
JOIN m_office o ON o.id = COALESCE(CASE WHEN r.account_type = 'GL' THEN r.office_id END,
|
||||
sa.office_id, sc.office_id, ca.office_id, cc.office_id)
|
||||
WHERE r.id = ?
|
||||
""", requestId);
|
||||
final Long counterId = counter.isEmpty() ? null : ((Number) counter.get(0).get("id")).longValue();
|
||||
final Long accountId = account.isEmpty() ? null : ((Number) account.get(0).get("id")).longValue();
|
||||
row.put("counter_office_name", counter.isEmpty() ? null : counter.get(0).get("name"));
|
||||
row.put("account_office_name", account.isEmpty() ? null : account.get(0).get("name"));
|
||||
row.put("counter_branch_id", counterId);
|
||||
row.put("account_branch_id", accountId);
|
||||
row.put("inter_branch", counterId != null && accountId != null && !counterId.equals(accountId));
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cash deposit posts in the account's branch, but the cash is in the drawer of the cashier who took it. When those
|
||||
* are different branches, moves the cash line of the posting to the counter's branch through Liability Transfer.
|
||||
* Only a cash payment has a cash line, and only a savings-table deposit (savings, FD, RD) is covered here.
|
||||
*/
|
||||
private void moveCashToCounterBranch(final Map<?, ?> record, final String txnId, final BigDecimal amount, final String txnDateStr) {
|
||||
final Object paymentType = record.get("payment_type_name");
|
||||
final Long savingsTxnId = parseTxnIdToLong(txnId);
|
||||
if (savingsTxnId == null || paymentType == null || !"cash".equalsIgnoreCase(paymentType.toString().trim())) {
|
||||
return;
|
||||
}
|
||||
final Map<String, Object> seed = new java.util.LinkedHashMap<>();
|
||||
seed.put("id", record.get("id"));
|
||||
final Map<String, Object> branches = withBranches(seed);
|
||||
final Long counterId = (Long) branches.get("counter_branch_id");
|
||||
final Long accountId = (Long) branches.get("account_branch_id");
|
||||
if (counterId == null || accountId == null || counterId.equals(accountId)) {
|
||||
return;
|
||||
}
|
||||
final java.time.LocalDate date = txnDateStr != null && !txnDateStr.isBlank() ? java.time.LocalDate.parse(txnDateStr)
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
interBranchCashService.moveCashLegToBranch(savingsTxnId, accountId, counterId, String.valueOf(record.get("account_no")), amount,
|
||||
date, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows whose branch (the counter that took the cash; the cashier's own office for requests raised before that was
|
||||
* recorded) is at or below the caller's office. Takes the caller's hierarchy plus "%" as its one parameter.
|
||||
*/
|
||||
private static final String REQUEST_BRANCH_IN_HIERARCHY = "COALESCE(counter_office_id, "
|
||||
+ "(SELECT au.office_id FROM m_appuser au WHERE au.id = cashier_id)) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)";
|
||||
|
||||
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
|
||||
private void assertRequestInCallersBranch(final Map<?, ?> record) {
|
||||
final Integer visible = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cashier_deposit_request WHERE id = ? AND " + REQUEST_BRANCH_IN_HIERARCHY, Integer.class,
|
||||
((Number) record.get("id")).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
|
||||
if (visible == null || visible == 0) {
|
||||
throw new NotFoundException("Deposit request not found: " + record.get("id"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ──
|
||||
@@ -400,6 +481,7 @@ public class CashierDepositRequestApiResource {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
assertRequestInCallersBranch(record);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||
@@ -457,6 +539,7 @@ public class CashierDepositRequestApiResource {
|
||||
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
};
|
||||
if (!isCurrentAccount && txnId != null) {
|
||||
moveCashToCounterBranch(record, txnId, amount, txnDateStr);
|
||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
||||
// hit an edge case in the new hold-placement logic would fail entirely, where before
|
||||
@@ -537,6 +620,7 @@ public class CashierDepositRequestApiResource {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
assertRequestInCallersBranch(record);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||
|
||||
+19
-5
@@ -577,11 +577,11 @@ public class ShareAdvanceCollectionApiResource {
|
||||
}
|
||||
|
||||
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
|
||||
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
|
||||
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
|
||||
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
|
||||
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
|
||||
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
|
||||
// receipt into whatever GL account the cashier actually deposited it to. This id proves the
|
||||
// fee was paid and never contributes to the share-value leg below. If the cashier banked it
|
||||
// into an income account it is left there; anywhere else (Sundry, most often) it is moved to
|
||||
// Share Entry Fee Income below. Omit it (leave null) when the fee was paid as part of the same
|
||||
// Sundry receipt(s) as the share value -- that single-receipt case is swept out of Sundry below.
|
||||
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
|
||||
|
||||
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
|
||||
@@ -748,6 +748,20 @@ public class ShareAdvanceCollectionApiResource {
|
||||
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
|
||||
currencyCode, "Share entry fee for " + accountNo);
|
||||
}
|
||||
} else {
|
||||
// A separate fee receipt is only already income if the cashier banked it into an income
|
||||
// account. One deposited into Sundry (or any other non-income account) sat there for
|
||||
// good: nothing here moved it, so the year-end close never swept it into profit. Move
|
||||
// the fee out of wherever it actually sits; any over-payment on that receipt stays put,
|
||||
// as before.
|
||||
final Long feeReceiptGl = receiptGlOf((String) chargeColl.get("gl_transaction_id"), glSuspense);
|
||||
if (!isIncomeGl(feeReceiptGl)) {
|
||||
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
||||
insertEntry(officeId, feeReceiptGl, DEBIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share entry fee settled | Receipt " + chargeColl.get("receipt_no") + " | " + accountNo);
|
||||
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share entry fee for " + accountNo);
|
||||
}
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
||||
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
||||
|
||||
+18
-1
@@ -239,7 +239,11 @@ public class ChequeBookIssuanceChargeService {
|
||||
|
||||
final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId)
|
||||
: getCurrentControlGlId(accountId);
|
||||
final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId() : null;
|
||||
// A charge that names no income GL of its own falls back to the product's Income From Fees, the
|
||||
// same order the savings charge bridge follows. Facility charges are seeded with no GL, so without
|
||||
// this every cheque book was issued free and the fee never reached income or the year's profit.
|
||||
final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId()
|
||||
: getProductFeeIncomeGlId(accountType, accountId);
|
||||
if (ownAccountGlId == null || incomeGlId == null) {
|
||||
log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); "
|
||||
+ "charge deduction skipped to avoid an unbalanced ledger.", chequeBookId, ownAccountGlId, incomeGlId);
|
||||
@@ -345,6 +349,19 @@ public class ChequeBookIssuanceChargeService {
|
||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||
}
|
||||
|
||||
/** The account's product's Income From Fees mapping (financial_account_type 4), or null if the product maps none. */
|
||||
private Long getProductFeeIncomeGlId(final ChequeAccountType accountType, final Long accountId) {
|
||||
final String sql = accountType == ChequeAccountType.SAVINGS
|
||||
? "SELECT apm.gl_account_id FROM m_savings_account sa JOIN acc_product_mapping apm ON apm.product_id = sa.product_id "
|
||||
+ "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL "
|
||||
+ "AND apm.payment_type IS NULL LIMIT 1"
|
||||
: "SELECT apm.gl_account_id FROM m_current_account ca JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
|
||||
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL "
|
||||
+ "AND apm.payment_type IS NULL LIMIT 1";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, accountId);
|
||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||
}
|
||||
|
||||
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
|
||||
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
|
||||
|
||||
+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";
|
||||
}
|
||||
}
|
||||
+208
-27
@@ -305,6 +305,10 @@ public class ClerkDisbursementRequestApiResource {
|
||||
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||
// A request belongs to the branch it was raised at. A checker sees their own branch's requests and those of the
|
||||
// branches below it; Head Office, the root, sees every branch's.
|
||||
where.append(" AND office_id IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)");
|
||||
baseParams.add(currentUser.getOffice().getHierarchy() + "%");
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
try {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
@@ -431,8 +435,63 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (!currentUser.getId().equals(clerkId)) {
|
||||
throw apiError(403, "You can only view your own disbursement requests.");
|
||||
}
|
||||
} else {
|
||||
assertRequestInCallersBranch(record, currentUser);
|
||||
}
|
||||
return gson.toJson(record);
|
||||
return gson.toJson(withLegBranches(record));
|
||||
}
|
||||
|
||||
/**
|
||||
* The request as stored, with the branch each account leg is held at added to its entries.
|
||||
*
|
||||
* <p>
|
||||
* The approver has to see when money moves between branches, and the stored entries carry only the account number
|
||||
* and name. The branch is looked up now, from the account, rather than stored at submission: a request raised before
|
||||
* this existed still shows it, and it is the branch the account is in at the moment of approval that matters. A
|
||||
* leg with no account behind it (a GL leg) gets none, and a failure here leaves the entries exactly as stored, so
|
||||
* the review screen never fails over it.
|
||||
*/
|
||||
private Map<String, Object> withLegBranches(final Map<?, ?> record) {
|
||||
final Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
record.forEach((key, value) -> out.put(String.valueOf(key), value));
|
||||
final Object stored = out.get("credit_entries");
|
||||
if (!(stored instanceof String) || ((String) stored).isBlank()) {
|
||||
return out;
|
||||
}
|
||||
try {
|
||||
final JsonArray entries = gson.fromJson((String) stored, JsonArray.class);
|
||||
for (final JsonElement element : entries) {
|
||||
final JsonObject entry = element.getAsJsonObject();
|
||||
final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||
if ("GL".equals(accountType) || !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, Object> branch = branchOfAccount(accountType, entry.get("savingsAccountId").getAsLong());
|
||||
if (branch != null) {
|
||||
entry.addProperty("officeId", ((Number) branch.get("id")).longValue());
|
||||
entry.addProperty("officeName", String.valueOf(branch.get("name")));
|
||||
}
|
||||
}
|
||||
out.put("credit_entries", gson.toJson(entries));
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("Could not add account branches to clerk request {}: {}", record.get("id"), e.getMessage());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch an account's books are in: its own office when it was opened at another branch than the client's,
|
||||
* otherwise the client's (or group's). A CURRENT leg carries an id in m_current_account's id space; every other
|
||||
* non-GL type is a m_savings_account row.
|
||||
*/
|
||||
private Map<String, Object> branchOfAccount(final String accountType, final Long accountId) {
|
||||
final String sql = "CURRENT".equalsIgnoreCase(accountType)
|
||||
? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id "
|
||||
+ "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?"
|
||||
: "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id "
|
||||
+ "LEFT JOIN m_group g ON g.id = sa.group_id "
|
||||
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
|
||||
return jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
|
||||
@@ -448,6 +507,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
assertRequestInCallersBranch(record, currentUser);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||
@@ -578,21 +638,23 @@ public class ClerkDisbursementRequestApiResource {
|
||||
"No savings-control GL mapping found for account " + acNo
|
||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||
|
||||
// Each leg is booked in the branch its own account is held at, not the request's branch.
|
||||
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
|
||||
if ("Dr".equals(dbCr)) {
|
||||
final long txnId = isCurrent
|
||||
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
||||
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||
? directDebitCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
|
||||
: directDebitSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description);
|
||||
txnRefs.add(String.valueOf(txnId));
|
||||
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
|
||||
btDebitGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId));
|
||||
btTotalDr = btTotalDr.add(entryAmt);
|
||||
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
||||
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
||||
} else {
|
||||
final long txnId = isCurrent
|
||||
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
||||
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
|
||||
? directCreditCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
|
||||
: directCreditSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser);
|
||||
txnRefs.add(String.valueOf(txnId));
|
||||
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
|
||||
btCreditGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId));
|
||||
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
|
||||
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
||||
}
|
||||
@@ -602,13 +664,10 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) {
|
||||
final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description)
|
||||
+ " → " + buildGlEntryComment("", btCreditGlEntries, null);
|
||||
final String btGlTxnId = postCombinedJournalEntry(
|
||||
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment);
|
||||
if (btGlTxnId != null) {
|
||||
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2);
|
||||
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
|
||||
}
|
||||
approvedGlTxnId = btGlTxnId;
|
||||
final String btGlTxnId = postBankTransferJournals(
|
||||
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
|
||||
// The request row keeps 60 characters; an inter-branch transfer posts one entry per branch.
|
||||
approvedGlTxnId = btGlTxnId == null || btGlTxnId.length() <= 60 ? btGlTxnId : btGlTxnId.split(",")[0];
|
||||
|
||||
// Audit log in m_bank_transfer_log
|
||||
final String btTxnRefsStr = String.join(",", txnRefs);
|
||||
@@ -855,6 +914,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
assertRequestInCallersBranch(record, currentUser);
|
||||
|
||||
// Maker-checker: the person who submitted the request cannot also reject it.
|
||||
final Long makerId = record.get("clerk_id") != null
|
||||
@@ -1005,12 +1065,14 @@ public class ClerkDisbursementRequestApiResource {
|
||||
"No savings-control GL mapping found for account " + acNo
|
||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||
|
||||
// Each leg is booked in the branch its own account is held at, not the request's branch.
|
||||
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
|
||||
final long txnId = isCurrent
|
||||
? directDebitCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
||||
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
|
||||
? directDebitCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
|
||||
: directDebitSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description);
|
||||
|
||||
txnRefs.add(String.valueOf(txnId));
|
||||
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
|
||||
debitGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId));
|
||||
if (isCurrent) {
|
||||
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
||||
} else {
|
||||
@@ -1032,11 +1094,12 @@ public class ClerkDisbursementRequestApiResource {
|
||||
"No savings-control GL mapping found for account " + acNo
|
||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||
|
||||
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
|
||||
final long txnId = isCurrent
|
||||
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
||||
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
|
||||
? directCreditCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
|
||||
: directCreditSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser);
|
||||
txnRefs.add(String.valueOf(txnId));
|
||||
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
|
||||
creditGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId));
|
||||
if (isCurrent) {
|
||||
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
||||
} else {
|
||||
@@ -1047,13 +1110,8 @@ public class ClerkDisbursementRequestApiResource {
|
||||
// ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─
|
||||
final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description)
|
||||
+ " → " + buildGlEntryComment("", creditGlEntries, null);
|
||||
glTxnId = postCombinedJournalEntry(
|
||||
debitGlEntries, creditGlEntries,
|
||||
officeId, currencyCode, txnDateStr, btComment);
|
||||
if (glTxnId != null) {
|
||||
linkGlEntriesToSavingsAccounts(glTxnId, debitGlEntries, 2);
|
||||
linkGlEntriesToSavingsAccounts(glTxnId, creditGlEntries, 1);
|
||||
}
|
||||
glTxnId = postBankTransferJournals(
|
||||
debitGlEntries, creditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
|
||||
|
||||
} catch (Exception ex) {
|
||||
for (int i = undoOps.size() - 1; i >= 0; i--) {
|
||||
@@ -1446,6 +1504,28 @@ public class ClerkDisbursementRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A request belongs to the branch it was raised at, and is the business of that branch's checker, not another's.
|
||||
* A checker may see and act on requests of their own branch and the branches below it; Head Office, as the root,
|
||||
* reaches every branch. Anything outside that is reported as not found rather than forbidden, so it does not even
|
||||
* confirm the request exists. A request with no branch on record belongs to the root.
|
||||
*/
|
||||
private void assertRequestInCallersBranch(final Map<?, ?> record, final AppUser user) {
|
||||
final String callerHierarchy = user.getOffice().getHierarchy();
|
||||
final Object officeId = record.get("office_id");
|
||||
boolean visible;
|
||||
if (officeId == null) {
|
||||
visible = ".".equals(callerHierarchy);
|
||||
} else {
|
||||
final String requestHierarchy = jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class,
|
||||
((Number) officeId).longValue()).stream().findFirst().orElse(null);
|
||||
visible = requestHierarchy != null && requestHierarchy.startsWith(callerHierarchy);
|
||||
}
|
||||
if (!visible) {
|
||||
throw new NotFoundException("Disbursement request not found: " + record.get("id"));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
||||
@@ -1690,6 +1770,8 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
||||
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
|
||||
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
|
||||
/** The branch this leg's account is held at; null where it was not resolved (the request's branch is then used). */
|
||||
Long officeId;
|
||||
|
||||
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||
final String accountRef, final String txnId) {
|
||||
@@ -2123,6 +2205,105 @@ public class ClerkDisbursementRequestApiResource {
|
||||
return result != null ? result.getTransactionId() : null;
|
||||
}
|
||||
|
||||
/** Financial Activity "Liability Transfer": the account that links branches' books on a transfer between them. */
|
||||
private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200;
|
||||
|
||||
/** The branch an account's books are in, or {@code fallbackOfficeId} where it cannot be resolved. */
|
||||
private Long legOfficeOf(final boolean isCurrent, final Long accountId, final Long fallbackOfficeId) {
|
||||
final Map<String, Object> branch = branchOfAccount(isCurrent ? "CURRENT" : "SAVINGS", accountId);
|
||||
return branch != null ? ((Number) branch.get("id")).longValue() : fallbackOfficeId;
|
||||
}
|
||||
|
||||
private static GlEntry withOffice(final GlEntry entry, final Long officeId) {
|
||||
entry.officeId = officeId;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private Long liabilityTransferGlId() {
|
||||
final List<Long> ids = jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?",
|
||||
Long.class, FINANCIAL_ACTIVITY_LIABILITY_TRANSFER);
|
||||
if (ids.isEmpty()) {
|
||||
throw new RuntimeException("A transfer between branches needs the Liability Transfer account mapped under "
|
||||
+ "Accounting > Financial Activity Mappings.");
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
private static BigDecimal totalOf(final List<GlEntry> entries) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final GlEntry entry : entries) {
|
||||
total = total.add(entry.amount);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the ledger side of a bank transfer and returns its transaction id, or the ids comma-separated when more than
|
||||
* one entry is needed.
|
||||
*
|
||||
* <p>
|
||||
* Every leg belongs to the branch its own account is held at. When they all share one branch this is the single
|
||||
* combined entry it always was, booked in that branch. When the legs span branches, each branch gets its own entry,
|
||||
* balanced through the Liability Transfer account: a branch that pays out more than it takes in credits the difference
|
||||
* to it, one that takes in more debits it. The two sides net to zero across the bank, so each branch's books balance
|
||||
* by themselves and the bank's total does not move. Each entry registers its own undo, so a failure later in the
|
||||
* transfer rolls the ledger back with the accounts.
|
||||
*/
|
||||
private String postBankTransferJournals(final List<GlEntry> debits, final List<GlEntry> credits, final Long fallbackOfficeId,
|
||||
final String currencyCode, final String txnDateStr, final String comment, final List<Runnable> undoOps) {
|
||||
final Map<Long, List<GlEntry>> debitsByOffice = new java.util.LinkedHashMap<>();
|
||||
final Map<Long, List<GlEntry>> creditsByOffice = new java.util.LinkedHashMap<>();
|
||||
for (final GlEntry debit : debits) {
|
||||
debitsByOffice.computeIfAbsent(debit.officeId != null ? debit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(debit);
|
||||
}
|
||||
for (final GlEntry credit : credits) {
|
||||
creditsByOffice.computeIfAbsent(credit.officeId != null ? credit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(credit);
|
||||
}
|
||||
final Set<Long> offices = new LinkedHashSet<>(debitsByOffice.keySet());
|
||||
offices.addAll(creditsByOffice.keySet());
|
||||
|
||||
final List<String> txnIds = new ArrayList<>();
|
||||
if (offices.size() == 1) {
|
||||
postBranchEntry(debits, credits, offices.iterator().next(), currencyCode, txnDateStr, comment, undoOps, txnIds);
|
||||
} else {
|
||||
final Long suspenseGlId = liabilityTransferGlId();
|
||||
for (final Long office : offices) {
|
||||
final List<GlEntry> branchDebits = new ArrayList<>(debitsByOffice.getOrDefault(office, List.of()));
|
||||
final List<GlEntry> branchCredits = new ArrayList<>(creditsByOffice.getOrDefault(office, List.of()));
|
||||
final BigDecimal net = totalOf(branchCredits).subtract(totalOf(branchDebits));
|
||||
if (net.signum() > 0) {
|
||||
branchDebits.add(new GlEntry(suspenseGlId, net, "Inter-branch", null));
|
||||
} else if (net.signum() < 0) {
|
||||
branchCredits.add(new GlEntry(suspenseGlId, net.negate(), "Inter-branch", null));
|
||||
}
|
||||
postBranchEntry(branchDebits, branchCredits, office, currencyCode, txnDateStr,
|
||||
comment + " | Inter-branch, via Liability Transfer", undoOps, txnIds);
|
||||
}
|
||||
}
|
||||
return txnIds.isEmpty() ? null : String.join(",", txnIds);
|
||||
}
|
||||
|
||||
private void postBranchEntry(final List<GlEntry> debits, final List<GlEntry> credits, final Long officeId, final String currencyCode,
|
||||
final String txnDateStr, final String comment, final List<Runnable> undoOps, final List<String> txnIds) {
|
||||
final String glTxnId = postCombinedJournalEntry(debits, credits, officeId, currencyCode, txnDateStr, comment);
|
||||
if (glTxnId == null) {
|
||||
return;
|
||||
}
|
||||
linkGlEntriesToSavingsAccounts(glTxnId, debits, 2);
|
||||
linkGlEntriesToSavingsAccounts(glTxnId, credits, 1);
|
||||
txnIds.add(glTxnId);
|
||||
undoOps.add(() -> reverseLedgerEntry(glTxnId));
|
||||
}
|
||||
|
||||
/** Reverses a ledger entry this request posted, when a later step of the same transfer fails. */
|
||||
private void reverseLedgerEntry(final String glTxnId) {
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder().reverseJournalEntry(glTxnId)
|
||||
.withJson("{\"comments\":\"Bank transfer rolled back\"}").build();
|
||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
}
|
||||
|
||||
/** Builds the GL entry comments string listing all affected account numbers. */
|
||||
private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
|
||||
final String description) {
|
||||
|
||||
+23
-2
@@ -351,8 +351,8 @@ public class ClerkClosureRequestApiResource {
|
||||
@QueryParam("toDate") final String toDate,
|
||||
@QueryParam("search") final String search) {
|
||||
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
baseParams.add(Date.valueOf(fromDate));
|
||||
@@ -1071,7 +1071,28 @@ public class ClerkClosureRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
|
||||
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
|
||||
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
|
||||
|
||||
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
|
||||
private void assertMakerInCallersBranch(final Map<?, ?> record) {
|
||||
final Object makerId = record.get("clerk_id");
|
||||
final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
|
||||
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
|
||||
if (visible == null || visible == 0) {
|
||||
throw new NotFoundException("Request not found: " + record.get("id"));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final Map<?, ?> record = loadRecordUnscoped(id);
|
||||
assertMakerInCallersBranch(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecordUnscoped(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, savings_account_id, account_no, client_id, client_name, closure_date,
|
||||
payment_type_id, payment_type_name, description, tok_no, status,
|
||||
|
||||
+25
-1
@@ -265,7 +265,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("search") final String search) {
|
||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit);
|
||||
return queryList("WHERE status = ? AND " + MAKER_IN_HIERARCHY,
|
||||
new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%")), fromDate, toDate, search,
|
||||
offset, limit);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
||||
@@ -1441,7 +1443,29 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
|
||||
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
|
||||
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
|
||||
|
||||
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
|
||||
private void assertMakerInCallersBranch(final Map<?, ?> record) {
|
||||
final Object makerId = record.get("clerk_id");
|
||||
final Integer visible = makerId == null ? 0
|
||||
: jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
|
||||
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
|
||||
if (visible == null || visible == 0) {
|
||||
throw new NotFoundException("Request not found: " + record.get("id"));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final Map<?, ?> record = loadRecordUnscoped(id);
|
||||
assertMakerInCallersBranch(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecordUnscoped(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
|
||||
+24
-2
@@ -188,8 +188,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
@QueryParam("toDate") final String toDate,
|
||||
@QueryParam("search") final String search) {
|
||||
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
baseParams.add(Date.valueOf(fromDate));
|
||||
@@ -308,6 +308,7 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Withdrawal request not found: " + id);
|
||||
}
|
||||
assertMakerInCallersBranch(rows.get(0));
|
||||
return gson.toJson(rows.get(0));
|
||||
}
|
||||
|
||||
@@ -942,7 +943,28 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
|
||||
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
|
||||
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
|
||||
|
||||
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
|
||||
private void assertMakerInCallersBranch(final Map<?, ?> record) {
|
||||
final Object makerId = record.get("clerk_id");
|
||||
final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
|
||||
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
|
||||
if (visible == null || visible == 0) {
|
||||
throw new NotFoundException("Request not found: " + record.get("id"));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final Map<?, ?> record = loadRecordUnscoped(id);
|
||||
assertMakerInCallersBranch(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecordUnscoped(final Long id) {
|
||||
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, cheque_no, bank_name, tok_no,
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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.client.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.service.ClientIdentityService;
|
||||
import org.apache.fineract.portfolio.client.service.ClientIdentityService.IdentityMatch;
|
||||
import org.apache.fineract.portfolio.savings.security.AccountBranchService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* "Is this person already a customer of the bank?", asked from the General step of client creation before anything is
|
||||
* saved. See {@link ClientIdentityService} for the rules.
|
||||
*
|
||||
* <p>
|
||||
* It is a plain POST resource rather than a command, so the request body is not written to the command-source audit
|
||||
* table. The Aadhaar arrives only as its SHA-256; the 12-digit number never reaches the server.
|
||||
*/
|
||||
@Path("/v1/clientidentity")
|
||||
@Component
|
||||
@Tag(name = "Client Identity", description = "Cross-branch duplicate-customer lookup by PAN, Aadhaar fingerprint or GSTIN.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientIdentityLookupApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientIdentityLookupApiResource.class);
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientIdentityService clientIdentityService;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final AccountBranchService accountBranchService;
|
||||
|
||||
/**
|
||||
* Introduces a customer of another branch to the caller's branch, so the branch can open an account for them
|
||||
* there. The customer stays at the home branch; this records who introduced them to which branch and when.
|
||||
*/
|
||||
@POST
|
||||
@Path("link")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Link an existing customer of another branch to the caller's branch",
|
||||
description = "Body: { clientId }. Idempotent. Lets the caller's branch open an account for the customer.")
|
||||
public String link(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "CREATE_SAVINGSACCOUNT")) {
|
||||
throw new NoAuthorizationException("The user doesn't have enough permissions to link a customer to a branch.");
|
||||
}
|
||||
final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}"));
|
||||
final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
|
||||
if (!body.has("clientId") || body.get("clientId").isJsonNull()) {
|
||||
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("error.msg.clientidentity.clientId.required",
|
||||
"clientId is required.", "clientId")));
|
||||
}
|
||||
final Long clientId = body.get("clientId").getAsLong();
|
||||
final boolean newlyLinked = this.accountBranchService.linkClientToCallerBranch(clientId);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("clientId", clientId);
|
||||
response.put("linked", newlyLinked);
|
||||
response.put("officeId", this.context.authenticatedUser().getOffice().getId());
|
||||
response.put("officeName", this.context.authenticatedUser().getOffice().getName());
|
||||
return GSON.toJson(response);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("lookup")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Look up an existing customer by PAN, Aadhaar hash or GSTIN, and check phone/email clashes",
|
||||
description = "Body: { panNumber?, aadhaarHash?, gstinNumber?, mobileNo?, emailAddress?, excludeClientId? }. "
|
||||
+ "Any ONE of PAN / Aadhaar hash / GSTIN matching an existing client means the same customer. A phone or "
|
||||
+ "email clash is reported separately under contactConflicts and is never the same customer.")
|
||||
public String lookup(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "UPDATE_CLIENT")) {
|
||||
throw new NoAuthorizationException("The user doesn't have enough permissions to look up customers.");
|
||||
}
|
||||
|
||||
final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}"));
|
||||
final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
|
||||
|
||||
final String pan = text(body, "panNumber");
|
||||
final String aadhaarHash = text(body, "aadhaarHash");
|
||||
final String gstin = text(body, "gstinNumber");
|
||||
final String mobileNo = text(body, "mobileNo");
|
||||
final String emailAddress = text(body, "emailAddress");
|
||||
final Long excludeClientId = body.has("excludeClientId") && !body.get("excludeClientId").isJsonNull()
|
||||
? body.get("excludeClientId").getAsLong() : null;
|
||||
|
||||
ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHash);
|
||||
|
||||
final List<IdentityMatch> matches = this.clientIdentityService.findMatches(pan, aadhaarHash, gstin, excludeClientId);
|
||||
final Map<String, Boolean> contactConflicts = this.clientIdentityService.contactConflicts(mobileNo, emailAddress,
|
||||
excludeClientId);
|
||||
|
||||
// Who looked up what, never the values: a lookup that finds a customer at another branch is exactly what
|
||||
// someone browsing for customers would do, so there has to be a trail.
|
||||
LOG.info("client identity lookup: user={} fields={} matchedClientIds={}", this.context.authenticatedUser().getUsername(),
|
||||
suppliedFields(pan, aadhaarHash, gstin, mobileNo, emailAddress), matches.stream().map(IdentityMatch::clientId).toList());
|
||||
|
||||
final Set<Long> distinctClients = new LinkedHashSet<>();
|
||||
matches.forEach(m -> distinctClients.add(m.clientId()));
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("matched", !matches.isEmpty());
|
||||
// PAN points at one customer and Aadhaar (or GSTIN) at another: bad data or fraud, not a customer to reuse.
|
||||
response.put("conflict", distinctClients.size() > 1);
|
||||
response.put("matches", toJsonList(matches));
|
||||
response.put("contactConflicts", contactConflicts);
|
||||
return GSON.toJson(response);
|
||||
}
|
||||
|
||||
private static String text(final JsonObject body, final String name) {
|
||||
final JsonElement element = body.get(name);
|
||||
return element == null || element.isJsonNull() ? null : StringUtils.trimToNull(element.getAsString());
|
||||
}
|
||||
|
||||
private static List<String> suppliedFields(final String pan, final String aadhaarHash, final String gstin, final String mobileNo,
|
||||
final String emailAddress) {
|
||||
final List<String> fields = new ArrayList<>();
|
||||
if (pan != null) {
|
||||
fields.add("pan");
|
||||
}
|
||||
if (aadhaarHash != null) {
|
||||
fields.add("aadhaar");
|
||||
}
|
||||
if (gstin != null) {
|
||||
fields.add("gstin");
|
||||
}
|
||||
if (mobileNo != null) {
|
||||
fields.add("mobile");
|
||||
}
|
||||
if (emailAddress != null) {
|
||||
fields.add("email");
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> toJsonList(final List<IdentityMatch> matches) {
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final IdentityMatch m : matches) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("clientId", m.clientId());
|
||||
row.put("displayName", m.displayName());
|
||||
row.put("cifNumber", m.cifNumber());
|
||||
row.put("officeId", m.officeId());
|
||||
row.put("officeName", m.officeName());
|
||||
row.put("legalFormEnum", m.legalFormEnum());
|
||||
row.put("kycStatus", m.kycStatus());
|
||||
// True when the caller's branch can open this client; false when it sits in another branch's hierarchy.
|
||||
row.put("visibleToCaller", m.visibleToCaller());
|
||||
row.put("panMasked", m.panMasked());
|
||||
row.put("aadhaarMasked", m.aadhaarMasked());
|
||||
row.put("gstinMasked", m.gstinMasked());
|
||||
row.put("matchedBy", new ArrayList<>(m.matchedBy()));
|
||||
out.add(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -28,6 +28,7 @@ import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
@@ -36,6 +37,7 @@ import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSer
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
||||
import org.apache.fineract.portfolio.client.service.KycNoticeComposer;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
@@ -65,6 +67,7 @@ public class ClientKycContactLogApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycContactLogService kycContactLogService;
|
||||
private final KycNoticeComposer noticeComposer;
|
||||
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
||||
|
||||
@POST
|
||||
@@ -79,11 +82,31 @@ public class ClientKycContactLogApiResource {
|
||||
|
||||
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
|
||||
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
|
||||
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"), stringOrNull(payload, "noticeType"));
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("preview")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Preview the notice a customer would receive",
|
||||
description = "The subject and text for the stage the customer is at today. type is KYC or DORMANCY. "
|
||||
+ "Empty subject and body when the customer is at no stage of that kind.")
|
||||
public String previewNotice(@PathParam("clientId") final Long clientId, @QueryParam("type") final String type) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final KycNoticeComposer.KycNotice notice = this.noticeComposer.compose(clientId, type);
|
||||
final JsonObject json = new JsonObject();
|
||||
if (notice != null) {
|
||||
final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(notice);
|
||||
json.addProperty("subject", delivery.subject());
|
||||
json.addProperty("body", delivery.body());
|
||||
}
|
||||
return json.toString();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
|
||||
+20
@@ -89,4 +89,24 @@ public class ClientKycExceptionsApiResource {
|
||||
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
|
||||
return this.toApiJsonSerializer.serialize(lapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customers whose periodic re-KYC falls due within the next {@code days} days (default 30).
|
||||
*
|
||||
* <p>
|
||||
* The counterpart of {@code lapsed}: nothing has stopped working for these customers yet, which is
|
||||
* why they are worth reaching now.
|
||||
*/
|
||||
@GET
|
||||
@Path("expiring")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve KYC records expiring soon", description = "Returns clients whose periodic re-KYC falls due within the next N days (default 30), soonest first.")
|
||||
public String retrieveExpiringKyc(@QueryParam("days") final Integer days) {
|
||||
this.context.authenticatedUser();
|
||||
final int window = days == null ? 30 : days;
|
||||
final List<ClientKycData> expiring = this.readPlatformService.retrieveExpiringKyc(window);
|
||||
LOG.info("retrieveExpiringKyc: fetched {} KYC record(s) expiring within {} day(s)", expiring.size(), window);
|
||||
return this.toApiJsonSerializer.serialize(expiring);
|
||||
}
|
||||
}
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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.client.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Every customer notice on record, across all customers, for audit.
|
||||
*
|
||||
* <pre>
|
||||
* GET /v1/kyc/contact-audit/days one line per day: how many customers and notices, and how they fared
|
||||
* GET /v1/kyc/contact-audit the notices themselves, a page at a time: {"total": n, "rows": [...]}
|
||||
* </pre>
|
||||
*
|
||||
* <p>
|
||||
* Both take the same optional filters: {@code from} and {@code to} (yyyy-MM-dd, both inclusive),
|
||||
* {@code channel}, {@code status} and {@code search} (customer name, account number or reference). The
|
||||
* per-customer contact log answers "what was this customer told"; this answers "what did the bank send,
|
||||
* to whom, and did it arrive" — the question an inspection asks.
|
||||
*
|
||||
* <p>
|
||||
* Built for a bank with a very large number of customers: the day view is computed in the database and
|
||||
* is a few hundred rows at most, and the notices of one day are fetched a page at a time, so nothing
|
||||
* here ever loads the whole log.
|
||||
*/
|
||||
@Path("/v1/kyc/contact-audit")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Contact Audit", description = "All customer notices on record.")
|
||||
@RequiredArgsConstructor
|
||||
public class KycContactAuditApiResource {
|
||||
|
||||
private static final int MAX_PAGE = 500;
|
||||
private static final int MAX_DAYS = 400;
|
||||
|
||||
private static final String FROM_CLAUSE = " FROM m_client_kyc_contact_log l JOIN m_client c ON c.id = l.client_id WHERE 1 = 1";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
@Path("days")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.")
|
||||
public String days(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||
@QueryParam("search") final String search) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
final String where = filters(from, to, channel, status, search, args);
|
||||
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'PROVIDER_NOT_CONFIGURED' THEN 1 ELSE 0 END) AS not_configured, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded" + FROM_CLAUSE + where
|
||||
+ " GROUP BY CAST(l.contacted_at AS DATE) ORDER BY d DESC LIMIT " + MAX_DAYS;
|
||||
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||
final Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("date", String.valueOf(r.get("d")));
|
||||
item.put("notices", number(r.get("notices")));
|
||||
item.put("customers", number(r.get("customers")));
|
||||
item.put("delivered", number(r.get("delivered")));
|
||||
item.put("failed", number(r.get("failed")));
|
||||
item.put("noContact", number(r.get("no_contact")));
|
||||
item.put("notConfigured", number(r.get("not_configured")));
|
||||
item.put("recorded", number(r.get("recorded")));
|
||||
out.add(item);
|
||||
}
|
||||
return this.gson.toJson(out);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.")
|
||||
public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||
@QueryParam("search") final String search, @QueryParam("offset") final Integer offset,
|
||||
@QueryParam("limit") final Integer limit) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
final String where = filters(from, to, channel, status, search, args);
|
||||
|
||||
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray());
|
||||
|
||||
final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE);
|
||||
final int skip = Math.max(offset == null ? 0 : offset, 0);
|
||||
final String sql = "SELECT l.id, l.client_id, c.display_name, c.account_no, l.channel, l.notes, l.officer_name, "
|
||||
+ "l.delivery_status, l.destination_masked, l.provider, l.provider_reference, l.failure_reason, l.message_body, "
|
||||
+ "l.contacted_at" + FROM_CLAUSE + where + " ORDER BY l.contacted_at DESC, l.id DESC LIMIT " + rows + " OFFSET " + skip;
|
||||
|
||||
final List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||
final Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", r.get("id"));
|
||||
item.put("clientId", r.get("client_id"));
|
||||
item.put("clientName", r.get("display_name"));
|
||||
item.put("accountNo", r.get("account_no"));
|
||||
item.put("channel", r.get("channel"));
|
||||
item.put("notes", r.get("notes"));
|
||||
item.put("officerName", r.get("officer_name"));
|
||||
item.put("deliveryStatus", r.get("delivery_status"));
|
||||
item.put("destinationMasked", r.get("destination_masked"));
|
||||
item.put("provider", r.get("provider"));
|
||||
item.put("providerReference", r.get("provider_reference"));
|
||||
item.put("failureReason", r.get("failure_reason"));
|
||||
item.put("messageBody", r.get("message_body"));
|
||||
final Object at = r.get("contacted_at");
|
||||
item.put("contactedAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
|
||||
list.add(item);
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("total", total == null ? 0 : total);
|
||||
out.put("rows", list);
|
||||
return this.gson.toJson(out);
|
||||
}
|
||||
|
||||
/** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */
|
||||
private String filters(final String from, final String to, final String channel, final String status, final String search,
|
||||
final List<Object> args) {
|
||||
final StringBuilder where = new StringBuilder();
|
||||
if (StringUtils.isNotBlank(from)) {
|
||||
where.append(" AND l.contacted_at >= ?");
|
||||
args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(to)) {
|
||||
where.append(" AND l.contacted_at < ?");
|
||||
args.add(Timestamp.valueOf(parse(to, "to").plusDays(1).atStartOfDay()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(channel)) {
|
||||
where.append(" AND l.channel = ?");
|
||||
args.add(channel.trim().toUpperCase());
|
||||
}
|
||||
if (StringUtils.isNotBlank(status)) {
|
||||
where.append(" AND l.delivery_status = ?");
|
||||
args.add(status.trim().toUpperCase());
|
||||
}
|
||||
if (StringUtils.isNotBlank(search)) {
|
||||
final String like = "%" + search.trim().toLowerCase() + "%";
|
||||
where.append(" AND (LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(l.provider_reference) LIKE ?)");
|
||||
args.add(like);
|
||||
args.add(like);
|
||||
args.add(like);
|
||||
}
|
||||
return where.toString();
|
||||
}
|
||||
|
||||
private static long number(final Object value) {
|
||||
return value == null ? 0 : ((Number) value).longValue();
|
||||
}
|
||||
|
||||
private LocalDate parse(final String value, final String parameter) {
|
||||
try {
|
||||
return LocalDate.parse(value.trim());
|
||||
} catch (final DateTimeParseException e) {
|
||||
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||
"validation.msg.kyc.contact.audit." + parameter + ".invalid", parameter + " must look like 2028-02-17.", parameter)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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.client.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
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.core.MediaType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService;
|
||||
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.EnqueueResult;
|
||||
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.QueueItem;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Notices queued to be sent in the background.
|
||||
*
|
||||
* <pre>
|
||||
* POST /v1/kyc/notice-queue body: {"items":[{"clientId":1,"noticeType":"KYC","channel":"SMS",
|
||||
* "accountNo":"..","stageLabel":".."}],
|
||||
* "notes":"..","officerName":"..","sendAt":"2028-02-17T10:00"}
|
||||
* GET /v1/kyc/notice-queue?status=PENDING newest first
|
||||
* POST /v1/kyc/notice-queue/{id}?command=cancel | retry
|
||||
* </pre>
|
||||
*
|
||||
* <p>Authorisation matches the contact log: sending a notice to a customer is a maintenance action on
|
||||
* the customer's record.
|
||||
*/
|
||||
@Path("/v1/kyc/notice-queue")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Notice Queue", description = "Queue customer notices to be sent in the background.")
|
||||
@RequiredArgsConstructor
|
||||
public class KycNoticeQueueApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycNoticeQueueService queueService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Queue notices for background sending")
|
||||
public String enqueue(final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||
final List<QueueItem> items = new ArrayList<>();
|
||||
final JsonElement raw = payload.get("items");
|
||||
if (raw != null && raw.isJsonArray()) {
|
||||
final JsonArray array = raw.getAsJsonArray();
|
||||
for (final JsonElement element : array) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject o = element.getAsJsonObject();
|
||||
final String clientId = string(o, "clientId");
|
||||
items.add(new QueueItem(clientId == null ? null : Long.valueOf(clientId), string(o, "noticeType"), string(o, "channel"),
|
||||
string(o, "accountNo"), string(o, "stageLabel")));
|
||||
}
|
||||
}
|
||||
|
||||
LocalDateTime sendAt = null;
|
||||
final String sendAtText = string(payload, "sendAt");
|
||||
if (sendAtText != null && !sendAtText.isBlank()) {
|
||||
try {
|
||||
sendAt = LocalDateTime.parse(sendAtText);
|
||||
} catch (final DateTimeParseException e) {
|
||||
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||
"validation.msg.kyc.notice.queue.sendAt.invalid", "sendAt must look like 2028-02-17T10:00.", "sendAt")));
|
||||
}
|
||||
}
|
||||
|
||||
final EnqueueResult result = this.queueService.enqueue(items, string(payload, "notes"), string(payload, "officerName"), sendAt);
|
||||
final JsonObject out = new JsonObject();
|
||||
out.addProperty("queued", result.queued());
|
||||
out.addProperty("skipped", result.skipped());
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "List queued notices", description = "Newest first. status narrows to PENDING, SENT, FAILED or CANCELLED.")
|
||||
public String list(@QueryParam("status") final String status, @QueryParam("limit") final Integer limit) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
return this.gson.toJson(this.queueService.list(status, limit == null ? 200 : limit));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Cancel or retry a queued notice", description = "command=cancel withdraws a notice still waiting; "
|
||||
+ "command=retry puts a failed one back in line.")
|
||||
public String act(@PathParam("id") final Long id, @QueryParam("command") final String command) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
if ("cancel".equalsIgnoreCase(command)) {
|
||||
this.queueService.cancel(id);
|
||||
} else if ("retry".equalsIgnoreCase(command)) {
|
||||
this.queueService.retry(id);
|
||||
} else {
|
||||
throw new PlatformApiDataValidationException(List.of(ApiParameterError
|
||||
.parameterError("validation.msg.kyc.notice.queue.command.invalid", "command must be cancel or retry.", "command")));
|
||||
}
|
||||
final JsonObject out = new JsonObject();
|
||||
out.addProperty("id", id);
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private JsonObject parse(final String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(json);
|
||||
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||
}
|
||||
|
||||
private String string(final JsonObject o, final String member) {
|
||||
final JsonElement element = o.get(member);
|
||||
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||
}
|
||||
}
|
||||
+11
@@ -24,6 +24,7 @@ import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
@@ -69,4 +70,14 @@ public class KycRiskRulesApiResource {
|
||||
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
||||
return this.toApiJsonSerializer.serialize(rule);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{ruleId}/mark-normal")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Mark a rule as normal", description = "Ends the trial of a newly added rule. It stays in the catalog as a normal rule.")
|
||||
public String markNormal(@PathParam("ruleId") final Long ruleId) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
return this.toApiJsonSerializer.serialize(this.writePlatformService.markNormal(ruleId));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -33,6 +33,8 @@ public final class ClientKycApiConstants {
|
||||
public static final String riskCategoryParam = "riskCategory";
|
||||
public static final String panNumberParam = "panNumber";
|
||||
public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked";
|
||||
/** SHA-256 of the full Aadhaar, computed client-side. The 12-digit number itself is never sent on create/update. */
|
||||
public static final String aadhaarHashParam = "aadhaarHash";
|
||||
public static final String gstinNumberParam = "gstinNumber";
|
||||
public static final String remarksParam = "remarks";
|
||||
public static final String ckycNumberParam = "ckycNumber";
|
||||
@@ -118,7 +120,7 @@ public final class ClientKycApiConstants {
|
||||
|
||||
// ── Supported parameters for initiate/update ──────────────────────────────
|
||||
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam,
|
||||
kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam,
|
||||
gstinNumberParam, remarksParam, "locale", "dateFormat",
|
||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
@@ -136,7 +138,7 @@ public final class ClientKycApiConstants {
|
||||
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
|
||||
|
||||
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam, gstinNumberParam,
|
||||
riskCategoryParam, remarksParam, "locale", "dateFormat",
|
||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
|
||||
+23
-1
@@ -64,6 +64,12 @@ public final class KycRiskRuleData implements Serializable {
|
||||
* used in matching; a null maximum means the rule is genuinely open-ended. */
|
||||
private final BigDecimal txnMinLimitAmount;
|
||||
private final BigDecimal txnMaxLimitAmount;
|
||||
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
|
||||
private final boolean ccRelevant;
|
||||
/** True while a newly authored rule is on trial; it is listed first until an officer marks it normal. */
|
||||
private final boolean onTrial;
|
||||
/** When the trial began (ISO date), or null for a rule that is not on trial. */
|
||||
private final String trialStartedOn;
|
||||
|
||||
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||
@@ -76,7 +82,8 @@ public final class KycRiskRuleData implements Serializable {
|
||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
||||
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
||||
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
|
||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant,
|
||||
final boolean onTrial, final String trialStartedOn) {
|
||||
this.id = id;
|
||||
this.ruleName = ruleName;
|
||||
this.ruleKind = ruleKind;
|
||||
@@ -106,6 +113,9 @@ public final class KycRiskRuleData implements Serializable {
|
||||
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
|
||||
this.txnMinLimitAmount = txnMinLimitAmount;
|
||||
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
||||
this.ccRelevant = ccRelevant;
|
||||
this.onTrial = onTrial;
|
||||
this.trialStartedOn = trialStartedOn;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -223,4 +233,16 @@ public final class KycRiskRuleData implements Serializable {
|
||||
public BigDecimal getTxnMaxLimitAmount() {
|
||||
return txnMaxLimitAmount;
|
||||
}
|
||||
|
||||
public boolean isCcRelevant() {
|
||||
return ccRelevant;
|
||||
}
|
||||
|
||||
public boolean isOnTrial() {
|
||||
return onTrial;
|
||||
}
|
||||
|
||||
public String getTrialStartedOn() {
|
||||
return trialStartedOn;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -104,15 +104,26 @@ public class KycRiskRuleRequest {
|
||||
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
|
||||
private String txnRuleType;
|
||||
|
||||
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
|
||||
@Schema(description = "SAVINGS | LOAN | TRANSFER | CURRENT | ALL. Defaults to ALL. CURRENT is a current / Cash Credit account.", example = "ALL")
|
||||
private String txnAppliesTo;
|
||||
|
||||
@Schema(description = "CREDIT (money in) or DEBIT (money out). Omit for either direction.", example = "CREDIT")
|
||||
private String txnDirection;
|
||||
|
||||
@Schema(description = "True when the rule is about cash only. Defaults to false.")
|
||||
private Boolean txnCashOnly;
|
||||
|
||||
@Schema(description = "True when the scenario watches Cash Credit accounts, so the Rules Master CC chip lists it.")
|
||||
private Boolean ccRelevant;
|
||||
|
||||
/**
|
||||
* True when the officer has described a live-transaction rule rather than a pure KYC profile
|
||||
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
|
||||
*/
|
||||
public boolean hasTransactionRule() {
|
||||
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|
||||
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
|
||||
|| (this.txnWindowDays != null && this.txnWindowDays > 0)
|
||||
|| (this.txnDirection != null && !this.txnDirection.isBlank()) || Boolean.TRUE.equals(this.txnCashOnly)
|
||||
|| (this.txnAppliesTo != null && !this.txnAppliesTo.isBlank() && !"ALL".equalsIgnoreCase(this.txnAppliesTo.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -25,6 +25,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -39,6 +40,7 @@ public class ClientRepositoryWrapper {
|
||||
|
||||
private final ClientRepository repository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Client findOneWithNotFoundDetection(final Long id) {
|
||||
@@ -94,12 +96,33 @@ public class ClientRepositoryWrapper {
|
||||
|
||||
public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) {
|
||||
Client client = this.repository.fetchByClientIdAndHierarchy(clientId, hierarchySearchString, hierarchySearchString);
|
||||
// A customer of another branch who has been linked to the caller's branch can be seen here, so the branch can
|
||||
// open an account for them. Reading only: the write paths do not go through this method.
|
||||
if (client == null && isLinkedToBranchUnder(clientId, hierarchySearchString)) {
|
||||
client = this.repository.findById(clientId).orElse(null);
|
||||
}
|
||||
if (client == null) {
|
||||
throw new ClientNotFoundException(clientId.toString(), "client.id");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the client has been linked to an office at or below the given hierarchy (a LIKE pattern ending in %), or
|
||||
* holds an account there. A link is what lets a branch open the customer's page to start an account; the client list
|
||||
* is stricter and shows the customer only once an account exists.
|
||||
*/
|
||||
public boolean isLinkedToBranchUnder(final Long clientId, final String hierarchySearchString) {
|
||||
final Integer count = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM ("
|
||||
+ "SELECT l.client_id FROM m_client_branch_link l JOIN m_office lo ON lo.id = l.office_id WHERE l.client_id = ? AND lo.hierarchy LIKE ? "
|
||||
+ "UNION ALL SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id WHERE sa.client_id = ? AND ao.hierarchy LIKE ? "
|
||||
+ "UNION ALL SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id WHERE ca.client_id = ? AND co.hierarchy LIKE ?"
|
||||
+ ") t",
|
||||
Integer.class, clientId, hierarchySearchString, clientId, hierarchySearchString, clientId, hierarchySearchString);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
public Long findIdByExternalId(final ExternalId externalId) {
|
||||
return this.repository.findIdByExternalId(externalId);
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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.client.jobs.processkycnoticequeue;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class ProcessKycNoticeQueueConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Bean
|
||||
protected Step processKycNoticeQueueStep(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) {
|
||||
return new StepBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository)
|
||||
.tasklet(processKycNoticeQueueTasklet, transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job processKycNoticeQueueJob(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) {
|
||||
return new JobBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository)
|
||||
.start(processKycNoticeQueueStep(processKycNoticeQueueTasklet)).incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.client.jobs.processkycnoticequeue;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Sends the notices officers have queued, once they are due. */
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class ProcessKycNoticeQueueTasklet implements Tasklet {
|
||||
|
||||
private final KycNoticeQueueService kycNoticeQueueService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
kycNoticeQueueService.processDue();
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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.client.jobs.sendkycreminders;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class SendKycRemindersConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Bean
|
||||
protected Step sendKycRemindersStep(SendKycRemindersTasklet sendKycRemindersTasklet) {
|
||||
return new StepBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).tasklet(sendKycRemindersTasklet, transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job sendKycRemindersJob(SendKycRemindersTasklet sendKycRemindersTasklet) {
|
||||
return new JobBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).start(sendKycRemindersStep(sendKycRemindersTasklet))
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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.client.jobs.sendkycreminders;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.client.service.KycReminderService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Daily pass that tells customers which stage their KYC or account has reached and what happens
|
||||
* next, by SMS and email.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class SendKycRemindersTasklet implements Tasklet {
|
||||
|
||||
private final KycReminderService kycReminderService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
kycReminderService.sendReminders();
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -29,6 +29,7 @@ import java.time.format.DateTimeFormatterBuilder;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
@@ -275,7 +276,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
|
||||
// CBS extended fields
|
||||
clientFamilyMembers.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
|
||||
clientFamilyMembers.setPanNumber(command.stringValueOfParameterNamed("panNumber"));
|
||||
clientFamilyMembers.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber")));
|
||||
clientFamilyMembers.setNationality(command.stringValueOfParameterNamed("nationality"));
|
||||
final Boolean isAlive = command.booleanObjectValueOfParameterNamed("isAlive");
|
||||
clientFamilyMembers.setIsAlive(isAlive != null ? isAlive : true);
|
||||
@@ -426,7 +427,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
familyMember.setAadhaarNumber(member.get("aadhaarNumber").getAsString());
|
||||
}
|
||||
if (member.get("panNumber") != null && !member.get("panNumber").isJsonNull()) {
|
||||
familyMember.setPanNumber(member.get("panNumber").getAsString());
|
||||
familyMember.setPanNumber(StringUtils.upperCase(member.get("panNumber").getAsString()));
|
||||
}
|
||||
if (member.get("nationality") != null && !member.get("nationality").isJsonNull()) {
|
||||
familyMember.setNationality(member.get("nationality").getAsString());
|
||||
@@ -585,7 +586,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
clientFamilyMember.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
|
||||
}
|
||||
if (command.parameterExists("panNumber")) {
|
||||
clientFamilyMember.setPanNumber(command.stringValueOfParameterNamed("panNumber"));
|
||||
clientFamilyMember.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber")));
|
||||
}
|
||||
if (command.parameterExists("nationality")) {
|
||||
clientFamilyMember.setNationality(command.stringValueOfParameterNamed("nationality"));
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.domain.AadhaarHash;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Answers "is this person already a customer of the bank?" by identity number, across every branch.
|
||||
*
|
||||
* <p>
|
||||
* PAN, the Aadhaar fingerprint and GSTIN identify a person or an entity, so any ONE of them matching means the same
|
||||
* customer. Phone and email are only contact details - a match there is a different customer using a number or
|
||||
* address that is already taken, never the same customer.
|
||||
*
|
||||
* <p>
|
||||
* The lookup deliberately ignores the caller's office hierarchy: the whole point is that a branch must learn that the
|
||||
* customer is already on the books at another branch. What it hands back is kept to the minimum needed to act on that
|
||||
* (name, CIF, branch, masked identifiers) and every lookup is logged.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ClientIdentityService {
|
||||
|
||||
/** What a caller is told about an existing customer. Identifiers are masked; nothing here is editable. */
|
||||
public record IdentityMatch(Long clientId, String displayName, String cifNumber, Long officeId, String officeName,
|
||||
Integer legalFormEnum, Integer kycStatus, boolean visibleToCaller, String panMasked, String aadhaarMasked,
|
||||
String gstinMasked, Set<String> matchedBy) {
|
||||
}
|
||||
|
||||
public static final String MATCHED_PAN = "PAN";
|
||||
public static final String MATCHED_AADHAAR = "AADHAAR";
|
||||
public static final String MATCHED_GSTIN = "GSTIN";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
/**
|
||||
* Every active client whose KYC carries any of the given identifiers. A blank identifier is ignored; if all three
|
||||
* are blank nothing can match. At most one row per client (a client has one KYC row).
|
||||
*
|
||||
* @param excludeClientId a client to leave out (the one being edited), or null
|
||||
*/
|
||||
public List<IdentityMatch> findMatches(final String panNumber, final String aadhaarHash, final String gstinNumber,
|
||||
final Long excludeClientId) {
|
||||
final String pan = normalizeId(panNumber);
|
||||
final String hash = AadhaarHash.normalizeOrNull(aadhaarHash);
|
||||
final String gstin = normalizeId(gstinNumber);
|
||||
|
||||
final List<String> conditions = new ArrayList<>();
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (pan != null) {
|
||||
conditions.add("k.pan_number = ?");
|
||||
params.add(pan);
|
||||
}
|
||||
if (hash != null) {
|
||||
conditions.add("k.aadhaar_hash = ?");
|
||||
params.add(hash);
|
||||
}
|
||||
if (gstin != null) {
|
||||
conditions.add("k.gstin_number = ?");
|
||||
params.add(gstin);
|
||||
}
|
||||
if (conditions.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final StringBuilder sql = new StringBuilder("SELECT c.id, c.display_name, c.cif_number, c.office_id, o.name AS office_name, ")
|
||||
.append("o.hierarchy AS office_hierarchy, c.legal_form_enum, k.kyc_status, k.pan_number, ")
|
||||
.append("k.aadhaar_number_masked, k.aadhaar_hash, k.gstin_number ")
|
||||
.append("FROM m_client_kyc k JOIN m_client c ON c.id = k.client_id JOIN m_office o ON o.id = c.office_id ")
|
||||
.append("WHERE k.is_deleted = false AND (").append(String.join(" OR ", conditions)).append(")");
|
||||
if (excludeClientId != null) {
|
||||
sql.append(" AND c.id <> ?");
|
||||
params.add(excludeClientId);
|
||||
}
|
||||
sql.append(" ORDER BY c.id");
|
||||
|
||||
final String callerHierarchy = this.context.officeHierarchy();
|
||||
return this.jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
|
||||
final Set<String> matchedBy = new LinkedHashSet<>();
|
||||
if (pan != null && pan.equals(rs.getString("pan_number"))) {
|
||||
matchedBy.add(MATCHED_PAN);
|
||||
}
|
||||
if (hash != null && hash.equals(rs.getString("aadhaar_hash"))) {
|
||||
matchedBy.add(MATCHED_AADHAAR);
|
||||
}
|
||||
if (gstin != null && gstin.equals(rs.getString("gstin_number"))) {
|
||||
matchedBy.add(MATCHED_GSTIN);
|
||||
}
|
||||
final String officeHierarchy = rs.getString("office_hierarchy");
|
||||
// wasNull() only describes the column read immediately before it, so capture it right here.
|
||||
final int legalForm = rs.getInt("legal_form_enum");
|
||||
final boolean legalFormMissing = rs.wasNull();
|
||||
return new IdentityMatch(rs.getLong("id"), rs.getString("display_name"), rs.getString("cif_number"),
|
||||
rs.getLong("office_id"), rs.getString("office_name"), legalFormMissing ? null : legalForm,
|
||||
rs.getInt("kyc_status"), officeHierarchy != null && officeHierarchy.startsWith(callerHierarchy),
|
||||
maskKeepingEnds(rs.getString("pan_number"), 2, 1), rs.getString("aadhaar_number_masked"),
|
||||
maskKeepingEnds(rs.getString("gstin_number"), 2, 3), matchedBy);
|
||||
}, params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a mobile number or email address is already used by another client. Contact details are unique per
|
||||
* client, and a hit here is never "the same customer".
|
||||
*/
|
||||
public Map<String, Boolean> contactConflicts(final String mobileNo, final String emailAddress, final Long excludeClientId) {
|
||||
final Map<String, Boolean> conflicts = new LinkedHashMap<>();
|
||||
conflicts.put("mobileNo", isTaken("mobile_no", StringUtils.trimToNull(mobileNo), excludeClientId));
|
||||
conflicts.put("emailAddress", isTaken("email_address", StringUtils.trimToNull(emailAddress), excludeClientId));
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a KYC save that would put an identifier on a second client. Called with only the identifiers being
|
||||
* added or changed, so editing a client that already shares an identifier with another (possible in data saved
|
||||
* before this check existed) is not blocked by that old clash.
|
||||
*/
|
||||
public void assertNoDuplicate(final Long clientId, final String panNumber, final String aadhaarHash, final String gstinNumber) {
|
||||
final List<IdentityMatch> matches = findMatches(panNumber, aadhaarHash, gstinNumber, clientId);
|
||||
if (matches.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final IdentityMatch other = matches.get(0);
|
||||
final String where = " (CIF " + other.cifNumber() + ", branch " + other.officeName() + ").";
|
||||
if (other.matchedBy().contains(MATCHED_PAN)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.panNumber",
|
||||
"A client with PAN `" + normalizeId(panNumber) + "` already exists" + where
|
||||
+ " PAN must be unique across clients.", "panNumber", normalizeId(panNumber));
|
||||
}
|
||||
if (other.matchedBy().contains(MATCHED_AADHAAR)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.aadhaar",
|
||||
"A client with this Aadhaar already exists" + where + " Aadhaar must be unique across clients.",
|
||||
"aadhaarNumber", other.cifNumber());
|
||||
}
|
||||
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.gstin",
|
||||
"A client with GSTIN `" + normalizeId(gstinNumber) + "` already exists" + where
|
||||
+ " GSTIN must be unique across clients.", "gstinNumber", normalizeId(gstinNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks an Aadhaar fingerprint from a request is well formed, as a normal validation error rather than a 500.
|
||||
* Blank is allowed (no Aadhaar).
|
||||
*/
|
||||
public static void validateAadhaarHashOrThrow(final String rawHash) {
|
||||
if (StringUtils.isNotBlank(rawHash) && !AadhaarHash.isValid(rawHash.trim().toLowerCase(Locale.ROOT))) {
|
||||
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||
"error.msg.client.kyc.aadhaarHash.invalid", "aadhaarHash must be a 64-character hex SHA-256 digest.",
|
||||
"aadhaarHash")));
|
||||
}
|
||||
}
|
||||
|
||||
/** PAN and GSTIN are stored upper-case; compare them that way. Null for blank. */
|
||||
public static String normalizeId(final String raw) {
|
||||
final String trimmed = StringUtils.trimToNull(raw);
|
||||
return trimmed == null ? null : trimmed.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private boolean isTaken(final String column, final String value, final Long excludeClientId) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
// column is one of two fixed names above, never caller input.
|
||||
final String sql = "SELECT COUNT(1) FROM m_client WHERE " + column + " = ?" + (excludeClientId != null ? " AND id <> ?" : "");
|
||||
final Integer count = excludeClientId != null ? this.jdbcTemplate.queryForObject(sql, Integer.class, value, excludeClientId)
|
||||
: this.jdbcTemplate.queryForObject(sql, Integer.class, value);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
/** Keeps the first {@code head} and last {@code tail} characters, e.g. PAN ABCDE1234F -> AB*******F. */
|
||||
private static String maskKeepingEnds(final String value, final int head, final int tail) {
|
||||
if (value == null || value.length() <= head + tail) {
|
||||
return value == null ? null : "*".repeat(value.length());
|
||||
}
|
||||
return value.substring(0, head) + "*".repeat(value.length() - head - tail) + value.substring(value.length() - tail);
|
||||
}
|
||||
}
|
||||
+10
@@ -45,4 +45,14 @@ public interface ClientKycReadPlatformService {
|
||||
* included: nothing is owed on it yet.
|
||||
*/
|
||||
java.util.List<ClientKycData> retrieveLapsedKyc();
|
||||
|
||||
/**
|
||||
* KYC records whose re-KYC falls due within the next {@code days} days, soonest first.
|
||||
*
|
||||
* <p>
|
||||
* Nothing is owed on these yet, but they are the customers the branch can still reach before the
|
||||
* restriction ladder starts. A record that has already lapsed is in {@link #retrieveLapsedKyc()}
|
||||
* instead.
|
||||
*/
|
||||
java.util.List<ClientKycData> retrieveExpiringKyc(int days);
|
||||
}
|
||||
|
||||
+9
@@ -173,4 +173,13 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.map(this::toData)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<ClientKycData> retrieveExpiringKyc(final int days) {
|
||||
final java.time.LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
return this.kycRepository
|
||||
.findExpiringBetween(today, today.plusDays(Math.max(days, 0)), ClientKycStatus.REJECTED.getValue()).stream()
|
||||
.map(this::toData)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -77,6 +77,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
|
||||
private final SavingsAccountRepository savingsAccountRepository;
|
||||
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
|
||||
private final ClientIdentityService clientIdentityService;
|
||||
|
||||
/** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */
|
||||
private static final int CKYC_ZIP_MAX_BYTES = 50 * 1024 * 1024;
|
||||
@@ -85,7 +86,8 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
@Override
|
||||
public CommandProcessingResult initiateKyc(final Long clientId, final JsonCommand command) {
|
||||
this.context.authenticatedUser();
|
||||
LOG.info("initiateKyc: clientId={}, requestJson={}", clientId, command.json());
|
||||
LOG.info("initiateKyc: clientId={}, requestJson={}", clientId,
|
||||
command.json().replaceAll("\"aadhaarHash\"\\s*:\\s*\"[0-9a-fA-F]*\"", "\"aadhaarHash\":\"<redacted>\""));
|
||||
|
||||
final Client clientForValidation = clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
|
||||
@@ -108,6 +110,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
.build();
|
||||
}
|
||||
|
||||
// PAN, Aadhaar and GSTIN identify one customer for the whole bank. The create-client request used to be the
|
||||
// only place this was checked, but the wizard saves the KYC through this call, so a duplicate got through.
|
||||
final String aadhaarHashInRequest = command.stringValueOfParameterNamed(ClientKycApiConstants.aadhaarHashParam);
|
||||
ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHashInRequest);
|
||||
this.clientIdentityService.assertNoDuplicate(clientId, command.stringValueOfParameterNamed(ClientKycApiConstants.panNumberParam),
|
||||
aadhaarHashInRequest, command.stringValueOfParameterNamed(ClientKycApiConstants.gstinNumberParam));
|
||||
|
||||
try {
|
||||
final Client client = clientForValidation;
|
||||
final ClientKyc kyc = ClientKyc.initiateKyc(client, command);
|
||||
@@ -145,6 +154,18 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
|
||||
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
||||
|
||||
// Check only identifiers this request adds or changes. A client that already shares one with another client
|
||||
// (saved before this check existed) must still be editable.
|
||||
final String hashInRequest = command.stringValueOfParameterNamed(ClientKycApiConstants.aadhaarHashParam);
|
||||
ClientIdentityService.validateAadhaarHashOrThrow(hashInRequest);
|
||||
final String newPan = command.isChangeInStringParameterNamed(ClientKycApiConstants.panNumberParam, kyc.getPanNumber())
|
||||
? command.stringValueOfParameterNamed(ClientKycApiConstants.panNumberParam) : null;
|
||||
final String newGstin = command.isChangeInStringParameterNamed(ClientKycApiConstants.gstinNumberParam, kyc.getGstinNumber())
|
||||
? command.stringValueOfParameterNamed(ClientKycApiConstants.gstinNumberParam) : null;
|
||||
final String newHash = StringUtils.isNotBlank(hashInRequest) && !hashInRequest.trim().equalsIgnoreCase(kyc.getAadhaarHash())
|
||||
? hashInRequest : null;
|
||||
this.clientIdentityService.assertNoDuplicate(clientId, newPan, newHash, newGstin);
|
||||
|
||||
final Map<String, Object> changes = kyc.update(command);
|
||||
|
||||
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
|
||||
|
||||
+9
-2
@@ -109,11 +109,18 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
||||
// this.context.validateAccessRights(searchParameters.getHierarchy());
|
||||
// underHierarchySearchString = searchParameters.getHierarchy() + "%";
|
||||
// }
|
||||
List<Object> paramList = new ArrayList<>(Arrays.asList(underHierarchySearchString, underHierarchySearchString));
|
||||
List<Object> paramList = new ArrayList<>(Arrays.asList(underHierarchySearchString, underHierarchySearchString,
|
||||
underHierarchySearchString, underHierarchySearchString));
|
||||
final StringBuilder sqlBuilder = new StringBuilder(200);
|
||||
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
|
||||
sqlBuilder.append(this.clientToDataMapper.schema());
|
||||
sqlBuilder.append(" where (o.hierarchy like ? or transferToOffice.hierarchy like ?) ");
|
||||
// The last two alternatives: a customer of another branch who holds an account at this branch. Being linked to a
|
||||
// branch is not enough to be listed there; the customer appears once an account is actually opened.
|
||||
sqlBuilder.append(" where (o.hierarchy like ? or transferToOffice.hierarchy like ?"
|
||||
+ " or c.id in (select sa.client_id from m_savings_account sa join m_office ao on ao.id = sa.office_id"
|
||||
+ " where sa.client_id is not null and ao.hierarchy like ?)"
|
||||
+ " or c.id in (select ca.client_id from m_current_account ca join m_office co on co.id = ca.office_id"
|
||||
+ " where ca.client_id is not null and co.hierarchy like ?)) ");
|
||||
|
||||
if (searchParameters != null) {
|
||||
if (searchParameters.getIsSelfUser()) {
|
||||
|
||||
+3
-1
@@ -274,7 +274,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
aadhaar = this.fromApiJsonHelper.extractStringNamed(
|
||||
org.apache.fineract.portfolio.client.data.ClientKycApiConstants.aadhaarNumberParam, kyc);
|
||||
}
|
||||
if (StringUtils.isNotBlank(aadhaar)) {
|
||||
// "XXXXXXXX0000" is the placeholder the wizard sends for entities and for clients with no Aadhaar. It is not a
|
||||
// number, so every such client would "match" every other one.
|
||||
if (StringUtils.isNotBlank(aadhaar) && !"XXXXXXXX0000".equals(aadhaar)) {
|
||||
final String masked = aadhaar.length() == 12 ? "XXXXXXXX" + aadhaar.substring(8) : aadhaar;
|
||||
final boolean clash = excludeClientId == null
|
||||
? clientKycRepository.existsByAadhaarMasked(masked)
|
||||
|
||||
+31
@@ -42,6 +42,37 @@ public interface KycContactLogService {
|
||||
*/
|
||||
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
|
||||
|
||||
/**
|
||||
* As {@link #logContact(Long, String, String, String)}, but a sending channel carries the notice for the stage
|
||||
* the customer is at today ({@code noticeType} {@code KYC} or {@code DORMANCY}) instead of the generic
|
||||
* "KYC pending" text. The stage is read from the customer's own record, not from the caller.
|
||||
*
|
||||
* @param noticeType {@code KYC}, {@code DORMANCY}, or blank for the generic notice
|
||||
*/
|
||||
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName, String noticeType);
|
||||
|
||||
/**
|
||||
* Sends a notice whose wording the caller supplies, and logs it exactly as {@link #logContact} would.
|
||||
*
|
||||
* <p>Used by the nightly reminder job, which knows which stage the customer is at and what happens
|
||||
* next, and says so in the message — the generic "KYC pending" text does not. Only the sending
|
||||
* channels (SMS, Email, WhatsApp) are accepted. The bank's name is appended to {@code body}.
|
||||
*
|
||||
* @param notes recorded against the log; the caller starts it with a marker so a reminder is not
|
||||
* repeated
|
||||
* @param subject email subject (without the bank name)
|
||||
* @param body plain-text message
|
||||
*/
|
||||
KycContactLogData sendNotice(Long clientId, String rawChannel, String notes, String officerName, String subject, String body);
|
||||
|
||||
/**
|
||||
* Sends the notice for the stage the customer is at today and logs it, without needing a logged-in user —
|
||||
* for the queue job, which sends what an officer queued earlier.
|
||||
*
|
||||
* @param noticeType {@code KYC} or {@code DORMANCY}
|
||||
*/
|
||||
KycContactLogData sendStageNotice(Long clientId, String rawChannel, String notes, String officerName, String noticeType);
|
||||
|
||||
/** All contact attempts for one customer, oldest first. */
|
||||
List<KycContactLogData> retrieveForClient(Long clientId);
|
||||
}
|
||||
|
||||
+83
-5
@@ -42,6 +42,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@@ -64,21 +65,30 @@ public class KycContactLogServiceImpl implements KycContactLogService {
|
||||
private final CustomerNotificationDispatcher dispatcher;
|
||||
private final EmailTemplateService emailTemplateService;
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycNoticeComposer noticeComposer;
|
||||
|
||||
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
|
||||
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
|
||||
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) {
|
||||
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context,
|
||||
final KycNoticeComposer noticeComposer) {
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.kycRepository = kycRepository;
|
||||
this.contactLogRepository = contactLogRepository;
|
||||
this.dispatcher = dispatcher;
|
||||
this.emailTemplateService = emailTemplateService;
|
||||
this.context = context;
|
||||
this.noticeComposer = noticeComposer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
|
||||
return logContact(clientId, rawChannel, notes, officerName, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
|
||||
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName,
|
||||
final String noticeType) {
|
||||
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
|
||||
if (channel == null) {
|
||||
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
|
||||
@@ -99,11 +109,22 @@ public class KycContactLogServiceImpl implements KycContactLogService {
|
||||
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
|
||||
|
||||
if (channel.isPlatformSent()) {
|
||||
final String subject = "Action Required: KYC Verification Pending - " + this.bankName;
|
||||
final String plainBody = buildPlainBody(client, exceptionReason, channel);
|
||||
String subject = "Action Required: KYC Verification Pending - " + this.bankName;
|
||||
String plainBody = buildPlainBody(client, exceptionReason, channel);
|
||||
String htmlTitle = "KYC Verification Pending";
|
||||
if (StringUtils.isNotBlank(noticeType)) {
|
||||
final KycNoticeComposer.KycNotice stage = this.noticeComposer.compose(clientId, noticeType);
|
||||
if (stage == null) {
|
||||
throw validationError("noticeType", "This customer is not at a stage that has a notice: " + noticeType);
|
||||
}
|
||||
final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(stage);
|
||||
subject = delivery.subject();
|
||||
plainBody = delivery.body();
|
||||
htmlTitle = stage.subject();
|
||||
}
|
||||
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
|
||||
final String htmlBody = channel == KycContactChannel.EMAIL
|
||||
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody)
|
||||
? this.emailTemplateService.renderAlert(displayName(client), htmlTitle, plainBody)
|
||||
: null;
|
||||
|
||||
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
|
||||
@@ -122,6 +143,63 @@ public class KycContactLogServiceImpl implements KycContactLogService {
|
||||
return toData(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Its own transaction, so a notice that cannot be sent rolls back only itself. Joined to the queue job's
|
||||
* transaction instead, one failure would mark the whole run rollback-only and every queued notice would
|
||||
* stay waiting for ever.
|
||||
*/
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public KycContactLogData sendStageNotice(final Long clientId, final String rawChannel, final String notes, final String officerName,
|
||||
final String noticeType) {
|
||||
final KycNoticeComposer.KycNotice stage = this.noticeComposer.compose(clientId, noticeType);
|
||||
if (stage == null) {
|
||||
throw validationError("noticeType", "The customer is no longer at a stage that has a notice: " + noticeType);
|
||||
}
|
||||
return sendNotice(clientId, rawChannel, notes, officerName, stage.subject(), stage.body());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycContactLogData sendNotice(final Long clientId, final String rawChannel, final String notes, final String officerName,
|
||||
final String subject, final String body) {
|
||||
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
|
||||
if (channel == null || !channel.isPlatformSent()) {
|
||||
throw validationError("channel", "Only SMS, Email and WhatsApp can be sent: " + rawChannel);
|
||||
}
|
||||
if (StringUtils.isBlank(notes) || StringUtils.isBlank(body)) {
|
||||
throw validationError("notes", "A note and a message are required.");
|
||||
}
|
||||
|
||||
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||
final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null);
|
||||
final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null;
|
||||
|
||||
// A scheduled job has no logged-in user, so the user is optional here rather than required.
|
||||
final AppUser currentUser = this.context.getAuthenticatedUserIfPresent();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel,
|
||||
StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName,
|
||||
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
|
||||
|
||||
final String fullSubject = subject + " - " + this.bankName;
|
||||
final String plainBody = body.trim() + " - " + this.bankName;
|
||||
final String htmlBody = channel == KycContactChannel.EMAIL
|
||||
? this.emailTemplateService.renderAlert(displayName(client), subject, plainBody)
|
||||
: null;
|
||||
|
||||
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, fullSubject, plainBody, htmlBody);
|
||||
log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(),
|
||||
result.failureReason(), plainBody);
|
||||
if (!result.isDelivered()) {
|
||||
LOG.warn("Automatic notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel, result.status(),
|
||||
result.failureReason());
|
||||
}
|
||||
|
||||
return toData(this.contactLogRepository.saveAndFlush(log));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<KycContactLogData> retrieveForClient(final Long clientId) {
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Locale;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
|
||||
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The one place that decides what a customer is told, for each stage they can be at.
|
||||
*
|
||||
* <p>
|
||||
* Kept apart from both the nightly reminder job and the officer's Notify button so the two can never
|
||||
* word the same stage differently — and apart from the contact-log service so neither of them depends
|
||||
* on the other.
|
||||
*
|
||||
* <p>
|
||||
* The stage is worked out from the customer's own record on the day the notice is composed, not taken
|
||||
* from the caller. A screen that is a few minutes stale therefore cannot send a notice for a stage the
|
||||
* customer has already left.
|
||||
*/
|
||||
@Component
|
||||
public class KycNoticeComposer {
|
||||
|
||||
/** The two families of notice: a re-KYC notice, or one about an account that has gone quiet. */
|
||||
public static final String TYPE_KYC = "KYC";
|
||||
public static final String TYPE_DORMANCY = "DORMANCY";
|
||||
|
||||
public static final int DEBIT_FREEZE_DAYS = 90;
|
||||
public static final int INOPERATIVE_DAYS = 180;
|
||||
|
||||
public static final int SUB_STATUS_INACTIVE = 100;
|
||||
public static final int SUB_STATUS_DORMANT = 200;
|
||||
public static final int SUB_STATUS_ESCHEAT = 300;
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH);
|
||||
|
||||
/** Subject and plain-text body of one notice. */
|
||||
public record KycNotice(String subject, String body) {
|
||||
}
|
||||
|
||||
private final ClientKycRepository kycRepository;
|
||||
private final DormantAccountReadPlatformService dormantAccountReadPlatformService;
|
||||
|
||||
@Value("${fineract.email.from-name:CBSnexora}")
|
||||
private String bankName;
|
||||
|
||||
public KycNoticeComposer(final ClientKycRepository kycRepository,
|
||||
final DormantAccountReadPlatformService dormantAccountReadPlatformService) {
|
||||
this.kycRepository = kycRepository;
|
||||
this.dormantAccountReadPlatformService = dormantAccountReadPlatformService;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice for the stage this customer is at today, or {@code null} when they are at none of the
|
||||
* stages of that family (no KYC expiry on record, or no inactive / dormant / escheated account).
|
||||
*/
|
||||
public KycNotice compose(final Long clientId, final String type) {
|
||||
if (TYPE_KYC.equalsIgnoreCase(type)) {
|
||||
final ClientKyc kyc = kycRepository.findByClientId(clientId).orElse(null);
|
||||
if (kyc == null || kyc.getExpiryDate() == null) {
|
||||
return null;
|
||||
}
|
||||
final long offset = ChronoUnit.DAYS.between(kyc.getExpiryDate(), DateUtils.getBusinessLocalDate());
|
||||
return new KycNotice(kycSubject(offset), kycMessage(kyc.getExpiryDate(), offset));
|
||||
}
|
||||
if (TYPE_DORMANCY.equalsIgnoreCase(type)) {
|
||||
DormantAccountData worst = null;
|
||||
for (final DormantAccountData account : dormantAccountReadPlatformService.retrieveDormantAccounts(null, null)) {
|
||||
if (clientId.equals(account.getClientId()) && account.getSubStatus() != null
|
||||
&& (worst == null || idle(account) > idle(worst))) {
|
||||
worst = account;
|
||||
}
|
||||
}
|
||||
return worst == null ? null
|
||||
: new KycNotice(dormantSubject(worst), dormantMessage(worst, DateUtils.getBusinessLocalDate()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The notice as it reaches the customer: the bank's name added to the subject and the sign-off. */
|
||||
public KycNotice forDelivery(final KycNotice notice) {
|
||||
return new KycNotice(notice.subject() + " - " + bankName, notice.body() + " - " + bankName);
|
||||
}
|
||||
|
||||
// ── Re-KYC ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public static String kycSubject(final long offset) {
|
||||
if (offset < 0) {
|
||||
return "Your KYC is due for renewal";
|
||||
}
|
||||
if (offset < DEBIT_FREEZE_DAYS) {
|
||||
return "Your KYC has expired";
|
||||
}
|
||||
if (offset < INOPERATIVE_DAYS) {
|
||||
return "Debits on your account are stopped";
|
||||
}
|
||||
return "Your account is inoperative";
|
||||
}
|
||||
|
||||
/** What the customer is told, by the stage they are at: where they stand and what happens next. */
|
||||
public static String kycMessage(final LocalDate expiry, final long offset) {
|
||||
final String expired = expiry.format(DATE);
|
||||
final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE);
|
||||
final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE);
|
||||
if (offset < 0) {
|
||||
return "Your KYC expires on " + expired + " (in " + (-offset) + " days). Please visit your branch with valid ID and address"
|
||||
+ " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze
|
||||
+ " and the account will become inoperative on " + inoperative + ".";
|
||||
}
|
||||
if (offset < DEBIT_FREEZE_DAYS) {
|
||||
return "Your KYC expired on " + expired + ". Please renew it at your branch now. If it is not renewed, debits on your"
|
||||
+ " account will stop on " + freeze + " and the account will become inoperative on " + inoperative + ".";
|
||||
}
|
||||
if (offset < INOPERATIVE_DAYS) {
|
||||
return "Your KYC expired on " + expired + " and debits on your account are now stopped. Please renew your KYC at your"
|
||||
+ " branch; if it is not renewed, the account will become inoperative on " + inoperative + ".";
|
||||
}
|
||||
return "Your KYC expired on " + expired + " and your account is now inoperative. Please visit your branch with valid"
|
||||
+ " documents to renew your KYC and have the account restored.";
|
||||
}
|
||||
|
||||
// ── Inactive / dormant / escheated accounts ────────────────────────────────
|
||||
|
||||
public static String dormantSubject(final DormantAccountData account) {
|
||||
final Integer sub = account.getSubStatus();
|
||||
if (sub != null && sub == SUB_STATUS_ESCHEAT) {
|
||||
return "Your account has been closed";
|
||||
}
|
||||
return sub != null && sub == SUB_STATUS_DORMANT ? "Your account is dormant" : "Your account is inactive";
|
||||
}
|
||||
|
||||
public static String dormantMessage(final DormantAccountData account, final LocalDate today) {
|
||||
final String since = account.getLastCustomerTransactionDate() != null ? account.getLastCustomerTransactionDate().format(DATE)
|
||||
: "a long time";
|
||||
final Integer sub = account.getSubStatus();
|
||||
if (sub != null && sub == SUB_STATUS_ESCHEAT) {
|
||||
return "Your account " + account.getAccountNo() + " has been closed after a very long period with no transaction by you"
|
||||
+ " (last on " + since + "), and the balance has been transferred to RBI's Depositor Education and Awareness Fund."
|
||||
+ " You can still claim it: please visit your branch with valid ID proof.";
|
||||
}
|
||||
if (sub != null && sub == SUB_STATUS_DORMANT) {
|
||||
final String escheat = account.getDaysToEscheat() != null && account.getDaysToEscheat() > 0
|
||||
? " If it stays dormant, the balance will be transferred to RBI's Depositor Education and Awareness Fund on "
|
||||
+ today.plusDays(account.getDaysToEscheat()).format(DATE) + "."
|
||||
: "";
|
||||
return "Your account " + account.getAccountNo() + " is dormant: there has been no transaction by you since " + since
|
||||
+ ", so debits are stopped. To reactivate it, please visit your branch with valid KYC documents to verify your"
|
||||
+ " identity." + escheat;
|
||||
}
|
||||
final String dormant = account.getDaysToDormancy() != null && account.getDaysToDormancy() > 0
|
||||
? " If there is still no transaction, it will become dormant on " + today.plusDays(account.getDaysToDormancy()).format(DATE)
|
||||
+ " and debits will be stopped."
|
||||
: "";
|
||||
return "Your account " + account.getAccountNo() + " is inactive: there has been no transaction by you since " + since
|
||||
+ ". Please make a transaction or visit your branch." + dormant;
|
||||
}
|
||||
|
||||
private static long idle(final DormantAccountData account) {
|
||||
return account.getDaysIdle() == null ? 0 : account.getDaysIdle();
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Notices an officer has asked to be sent in the background.
|
||||
*
|
||||
* <p>
|
||||
* The officer picks customers and submits; the notices wait in the queue until the queue job sends
|
||||
* them. Each is sent exactly as an immediate Notify would be — the wording is for the stage the
|
||||
* customer is at when the notice goes out, and it is recorded in the customer's contact log.
|
||||
*/
|
||||
public interface KycNoticeQueueService {
|
||||
|
||||
/** One customer and channel to notify. {@code noticeType} is KYC or DORMANCY. */
|
||||
record QueueItem(Long clientId, String noticeType, String channel, String accountNo, String stageLabel) {
|
||||
}
|
||||
|
||||
/** How many were queued, and how many were left out because the same notice was already waiting. */
|
||||
record EnqueueResult(int queued, int skipped) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues one notice per item.
|
||||
*
|
||||
* @param sendAt when to send; {@code null} means as soon as the queue job next runs
|
||||
*/
|
||||
EnqueueResult enqueue(List<QueueItem> items, String notes, String officerName, LocalDateTime sendAt);
|
||||
|
||||
/** Queue rows, newest first. {@code status} narrows to one of PENDING, SENT, FAILED, CANCELLED. */
|
||||
List<Map<String, Object>> list(String status, int limit);
|
||||
|
||||
/** Withdraws a notice that has not been sent yet. */
|
||||
void cancel(Long id);
|
||||
|
||||
/** Puts a failed notice back to be sent again. */
|
||||
void retry(Long id);
|
||||
|
||||
/**
|
||||
* Sends everything that is due. Called by the queue job.
|
||||
*
|
||||
* @return how many notices were sent
|
||||
*/
|
||||
int processDue();
|
||||
}
|
||||
+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.client.service;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
|
||||
|
||||
private static final String PENDING = "PENDING";
|
||||
private static final String SENDING = "SENDING";
|
||||
private static final String SENT = "SENT";
|
||||
private static final String FAILED = "FAILED";
|
||||
private static final String CANCELLED = "CANCELLED";
|
||||
|
||||
/** A gateway that is down is tried again a couple of times, ten minutes apart, then given up on. */
|
||||
private static final int MAX_ATTEMPTS = 3;
|
||||
private static final int RETRY_MINUTES = 10;
|
||||
/** A notice stuck mid-send (the server stopped) is put back after this long. */
|
||||
private static final int STUCK_MINUTES = 15;
|
||||
private static final int BATCH = 100;
|
||||
private static final int MAX_ITEMS = 200;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final KycContactLogService contactLogService;
|
||||
private final KycNoticeComposer noticeComposer;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
public EnqueueResult enqueue(final List<QueueItem> items, final String notes, final String officerName, final LocalDateTime sendAt) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
throw validationError("items", "Choose at least one customer.");
|
||||
}
|
||||
if (items.size() > MAX_ITEMS) {
|
||||
throw validationError("items", "Queue at most " + MAX_ITEMS + " notices at a time.");
|
||||
}
|
||||
final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant();
|
||||
final LocalDateTime when = sendAt == null || sendAt.isBefore(now) ? now : sendAt;
|
||||
final String note = StringUtils.isBlank(notes) ? "Notice queued by an officer." : StringUtils.abbreviate(notes.trim(), 1000);
|
||||
|
||||
final AppUser user = this.context.getAuthenticatedUserIfPresent();
|
||||
final Long userId = user != null ? user.getId() : null;
|
||||
|
||||
int queued = 0;
|
||||
int skipped = 0;
|
||||
for (final QueueItem item : items) {
|
||||
final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel());
|
||||
if (item.clientId() == null || channel == null || !channel.isPlatformSent()) {
|
||||
throw validationError("channel", "Only SMS, Email and WhatsApp can be queued.");
|
||||
}
|
||||
final String type = item.noticeType() == null ? "" : item.noticeType().toUpperCase();
|
||||
if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) {
|
||||
throw validationError("noticeType", "Notice type must be KYC or DORMANCY.");
|
||||
}
|
||||
// The same notice already waiting is not queued twice; a double click must not message a customer twice.
|
||||
final Integer waiting = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_kyc_notice_queue WHERE client_id = ? "
|
||||
+ "AND channel = ? AND notice_type = ? AND status IN ('" + PENDING + "', '" + SENDING + "')", Integer.class,
|
||||
item.clientId(), channel.name(), type);
|
||||
if (waiting != null && waiting > 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
this.jdbcTemplate.update("INSERT INTO m_kyc_notice_queue (client_id, notice_type, channel, account_no, stage_label, notes, "
|
||||
+ "officer_name, queued_by_user_id, status, attempts, scheduled_for, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
item.clientId(), type, channel.name(), StringUtils.abbreviate(item.accountNo(), 50),
|
||||
StringUtils.abbreviate(item.stageLabel(), 100), note, StringUtils.abbreviate(officerName, 200), userId, PENDING,
|
||||
Timestamp.valueOf(when), Timestamp.valueOf(now));
|
||||
queued++;
|
||||
}
|
||||
return new EnqueueResult(queued, skipped);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> list(final String status, final int limit) {
|
||||
final int rows = Math.min(Math.max(limit, 1), 500);
|
||||
final StringBuilder sql = new StringBuilder("SELECT q.id, q.client_id, c.display_name, q.account_no, q.notice_type, q.channel, "
|
||||
+ "q.stage_label, q.status, q.attempts, q.result_message, q.scheduled_for, q.created_at, q.processed_at "
|
||||
+ "FROM m_kyc_notice_queue q JOIN m_client c ON c.id = q.client_id");
|
||||
final List<Object> args = new ArrayList<>();
|
||||
if (StringUtils.isNotBlank(status)) {
|
||||
sql.append(" WHERE q.status = ?");
|
||||
args.add(status.trim().toUpperCase());
|
||||
}
|
||||
sql.append(" ORDER BY q.id DESC LIMIT ").append(rows);
|
||||
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql.toString(), args.toArray())) {
|
||||
final Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", r.get("id"));
|
||||
item.put("clientId", r.get("client_id"));
|
||||
item.put("clientName", r.get("display_name"));
|
||||
item.put("accountNo", r.get("account_no"));
|
||||
item.put("noticeType", r.get("notice_type"));
|
||||
item.put("channel", r.get("channel"));
|
||||
item.put("stageLabel", r.get("stage_label"));
|
||||
item.put("status", r.get("status"));
|
||||
item.put("attempts", r.get("attempts"));
|
||||
item.put("resultMessage", r.get("result_message"));
|
||||
item.put("scheduledFor", iso(r.get("scheduled_for")));
|
||||
item.put("createdAt", iso(r.get("created_at")));
|
||||
item.put("processedAt", iso(r.get("processed_at")));
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel(final Long id) {
|
||||
final int changed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, processed_at = ?, "
|
||||
+ "result_message = 'Cancelled before it was sent.' WHERE id = ? AND status = ?", CANCELLED,
|
||||
Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant()), id, PENDING);
|
||||
if (changed == 0) {
|
||||
throw validationError("id", "Only a notice that is still waiting can be cancelled.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void retry(final Long id) {
|
||||
final Timestamp now = Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant());
|
||||
final int changed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = 0, scheduled_for = ?, "
|
||||
+ "result_message = NULL, processed_at = NULL WHERE id = ? AND status = ?", PENDING, now, id, FAILED);
|
||||
if (changed == 0) {
|
||||
throw validationError("id", "Only a notice that failed can be retried.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int processDue() {
|
||||
final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant();
|
||||
|
||||
// Anything left half-sent by a stopped server goes back in line.
|
||||
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ? WHERE status = ? AND processed_at < ?", PENDING, SENDING,
|
||||
Timestamp.valueOf(now.minusMinutes(STUCK_MINUTES)));
|
||||
|
||||
final List<Map<String, Object>> due = this.jdbcTemplate.queryForList("SELECT id, client_id, notice_type, channel, notes, "
|
||||
+ "officer_name, attempts FROM m_kyc_notice_queue WHERE status = ? AND scheduled_for <= ? ORDER BY id LIMIT " + BATCH,
|
||||
PENDING, Timestamp.valueOf(now));
|
||||
|
||||
int sent = 0;
|
||||
for (final Map<String, Object> row : due) {
|
||||
final Long id = ((Number) row.get("id")).longValue();
|
||||
// Claim it first, so two overlapping runs cannot both send the same notice.
|
||||
final int claimed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, processed_at = ? WHERE id = ? AND status = ?",
|
||||
SENDING, Timestamp.valueOf(now), id, PENDING);
|
||||
if (claimed == 0) {
|
||||
continue;
|
||||
}
|
||||
final int attempts = ((Number) row.get("attempts")).intValue() + 1;
|
||||
try {
|
||||
// The stage is read when the notice goes out. A customer who has since transacted or renewed their KYC is
|
||||
// no longer at a stage that has a notice, and telling them they are would be wrong — so it is dropped, not failed.
|
||||
if (this.noticeComposer.compose(((Number) row.get("client_id")).longValue(), (String) row.get("notice_type")) == null) {
|
||||
finish(id, CANCELLED, attempts, "Not sent — the customer is no longer at a stage that has a notice (they may have transacted or renewed their KYC since).", null, null);
|
||||
continue;
|
||||
}
|
||||
final KycContactLogData result = this.contactLogService.sendStageNotice(((Number) row.get("client_id")).longValue(),
|
||||
(String) row.get("channel"), (String) row.get("notes"), (String) row.get("officer_name"),
|
||||
(String) row.get("notice_type"));
|
||||
final String delivery = result.getDeliveryStatus();
|
||||
if ("SENT".equals(delivery)) {
|
||||
finish(id, SENT, attempts, result.getDeliveryMessage(), result.getId(), null);
|
||||
sent++;
|
||||
} else if ("FAILED".equals(delivery) && attempts < MAX_ATTEMPTS) {
|
||||
// The gateway refused it; try again shortly rather than give up on the first miss.
|
||||
finish(id, PENDING, attempts, result.getDeliveryMessage(), result.getId(),
|
||||
Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant().plusMinutes(RETRY_MINUTES)));
|
||||
} else {
|
||||
finish(id, FAILED, attempts, result.getDeliveryMessage(), result.getId(), null);
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("Queued KYC notice {} could not be sent: {}", id, e.getMessage());
|
||||
finish(id, FAILED, attempts, "Could not be sent: " + e.getMessage(), null, null);
|
||||
}
|
||||
}
|
||||
if (!due.isEmpty()) {
|
||||
log.info("[KYC notice queue] {} of {} due notice(s) sent", sent, due.size());
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
private void finish(final Long id, final String status, final int attempts, final String message, final Long logId,
|
||||
final Timestamp nextAttempt) {
|
||||
final Timestamp now = Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant());
|
||||
if (nextAttempt != null) {
|
||||
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = ?, result_message = ?, contact_log_id = ?, "
|
||||
+ "processed_at = ?, scheduled_for = ? WHERE id = ?", status, attempts, StringUtils.abbreviate(message, 500), logId, now,
|
||||
nextAttempt, id);
|
||||
} else {
|
||||
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = ?, result_message = ?, contact_log_id = ?, "
|
||||
+ "processed_at = ? WHERE id = ?", status, attempts, StringUtils.abbreviate(message, 500), logId, now, id);
|
||||
}
|
||||
}
|
||||
|
||||
private static String iso(final Object value) {
|
||||
return value == null ? null : String.valueOf(value).replace(' ', 'T');
|
||||
}
|
||||
|
||||
private PlatformApiDataValidationException validationError(final String parameter, final String message) {
|
||||
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.notice.queue." + parameter + ".invalid",
|
||||
message, parameter);
|
||||
return new PlatformApiDataValidationException(List.of(error));
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
/**
|
||||
* Sends customers the reminder that matches the stage they have reached, and says what happens to
|
||||
* their account next.
|
||||
*/
|
||||
public interface KycReminderService {
|
||||
|
||||
/**
|
||||
* One pass over every customer whose re-KYC is within 30 days of falling due or already lapsed,
|
||||
* and every customer with an inactive or dormant savings account.
|
||||
*
|
||||
* <p>
|
||||
* Each customer gets the latest stage notice they have not yet been sent, by SMS and by email, and
|
||||
* a notice is never repeated for the same stage. Running the pass twice in a day, or after a
|
||||
* missed night, sends nothing extra.
|
||||
*
|
||||
* @return how many notices were attempted
|
||||
*/
|
||||
int sendReminders();
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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.client.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
|
||||
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
|
||||
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* The nightly reminder pass.
|
||||
*
|
||||
* <p>
|
||||
* The restriction job ({@link KycExpiryRestrictionService}) acts on accounts; this one tells the
|
||||
* customer first, and tells them what comes next. A customer is therefore never surprised by a
|
||||
* freeze they were not warned of.
|
||||
*
|
||||
* <p>
|
||||
* The stage a customer is at is worked out from today's date every run rather than remembered, and
|
||||
* the latest stage they have reached is the one they are told about. A missed night therefore costs
|
||||
* nothing: the next run sends the current stage, not the stale one.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KycReminderServiceImpl implements KycReminderService {
|
||||
|
||||
/** Days from expiry (negative = before) at which a customer's KYC stage changes. */
|
||||
private static final int[] KYC_MILESTONES = { -30, -15, -7, 0, 80, 90, 170, 180 };
|
||||
|
||||
private static final int SUB_STATUS_INACTIVE = 100;
|
||||
private static final int SUB_STATUS_DORMANT = 200;
|
||||
|
||||
/** A failing gateway is retried on later nights, but not for ever. */
|
||||
private static final long MAX_ATTEMPTS = 3;
|
||||
|
||||
private static final String OFFICER = "System — automatic reminder";
|
||||
private static final List<KycContactChannel> CHANNELS = List.of(KycContactChannel.SMS, KycContactChannel.EMAIL);
|
||||
|
||||
private final ClientKycRepository kycRepository;
|
||||
private final ClientKycContactLogRepository contactLogRepository;
|
||||
private final KycContactLogService contactLogService;
|
||||
private final DormantAccountReadPlatformService dormantAccountReadPlatformService;
|
||||
|
||||
@Override
|
||||
public int sendReminders() {
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
int attempted = 0;
|
||||
|
||||
final List<ClientKyc> records = new ArrayList<>(
|
||||
kycRepository.findExpiringBetween(today, today.plusDays(-KYC_MILESTONES[0]), ClientKycStatus.REJECTED.getValue()));
|
||||
records.addAll(kycRepository.findLapsedAsOn(today, ClientKycStatus.REJECTED.getValue()));
|
||||
for (final ClientKyc kyc : records) {
|
||||
try {
|
||||
attempted += remindKyc(kyc, today);
|
||||
} catch (final RuntimeException e) {
|
||||
// One customer's failure must not stop the rest of the night's reminders.
|
||||
log.warn("KYC reminder for client {} failed: {}", kyc.getClient() != null ? kyc.getClient().getId() : null,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
attempted += remindDormant(today);
|
||||
log.info("[KYC reminders] {} notice(s) attempted", attempted);
|
||||
return attempted;
|
||||
}
|
||||
|
||||
// ── Re-KYC ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private int remindKyc(final ClientKyc kyc, final LocalDate today) {
|
||||
if (kyc.getClient() == null || kyc.getExpiryDate() == null) {
|
||||
return 0;
|
||||
}
|
||||
final long offset = ChronoUnit.DAYS.between(kyc.getExpiryDate(), today);
|
||||
Integer milestone = null;
|
||||
for (final int m : KYC_MILESTONES) {
|
||||
if (offset >= m) {
|
||||
milestone = m;
|
||||
}
|
||||
}
|
||||
if (milestone == null) {
|
||||
return 0;
|
||||
}
|
||||
// The expiry date is in the marker so a renewed KYC starts a fresh set of reminders.
|
||||
final String marker = "[AUTO KYC " + kyc.getExpiryDate() + " M" + milestone + "]";
|
||||
return sendBoth(kyc.getClient().getId(), marker, KycNoticeComposer.kycSubject(offset),
|
||||
KycNoticeComposer.kycMessage(kyc.getExpiryDate(), offset));
|
||||
}
|
||||
|
||||
// ── Inactive / dormant accounts ────────────────────────────────────────────
|
||||
|
||||
private int remindDormant(final LocalDate today) {
|
||||
// One notice per customer, about the account that has been quiet longest.
|
||||
final Map<Long, DormantAccountData> worst = new LinkedHashMap<>();
|
||||
for (final DormantAccountData account : dormantAccountReadPlatformService.retrieveDormantAccounts(null, null)) {
|
||||
final Integer sub = account.getSubStatus();
|
||||
if (account.getClientId() == null || sub == null || (sub != SUB_STATUS_INACTIVE && sub != SUB_STATUS_DORMANT)) {
|
||||
continue; // escheated accounts are closed; there is nothing left to warn about
|
||||
}
|
||||
final DormantAccountData held = worst.get(account.getClientId());
|
||||
if (held == null || idle(account) > idle(held)) {
|
||||
worst.put(account.getClientId(), account);
|
||||
}
|
||||
}
|
||||
|
||||
int attempted = 0;
|
||||
for (final DormantAccountData account : worst.values()) {
|
||||
try {
|
||||
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + "]";
|
||||
final String subject = KycNoticeComposer.dormantSubject(account);
|
||||
attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today));
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("Dormancy reminder for client {} failed: {}", account.getClientId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return attempted;
|
||||
}
|
||||
|
||||
private static long idle(final DormantAccountData account) {
|
||||
return account.getDaysIdle() == null ? 0 : account.getDaysIdle();
|
||||
}
|
||||
|
||||
// ── Sending ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sends the message by every channel, skipping any channel this stage has already been told on. */
|
||||
private int sendBoth(final Long clientId, final String marker, final String subject, final String body) {
|
||||
int attempted = 0;
|
||||
for (final KycContactChannel channel : CHANNELS) {
|
||||
// Delivered, or nothing to deliver to, is final. A failure is retried, a few times only.
|
||||
final boolean settled = contactLogRepository.existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(clientId,
|
||||
channel, marker, EnumSet.of(KycContactDeliveryStatus.SENT, KycContactDeliveryStatus.SKIPPED_NO_DESTINATION));
|
||||
if (settled || contactLogRepository.countByClientIdAndChannelAndNotesStartingWith(clientId, channel, marker) >= MAX_ATTEMPTS) {
|
||||
continue;
|
||||
}
|
||||
contactLogService.sendNotice(clientId, channel.name(), marker + " Automatic reminder by " + channel.getDisplayLabel() + ".",
|
||||
OFFICER, subject, body);
|
||||
attempted++;
|
||||
}
|
||||
return attempted;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -92,7 +92,10 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
||||
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
|
||||
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
|
||||
txnRule != null ? txnRule.getMinLimitAmount() : null,
|
||||
txnRule != null ? txnRule.getMaxLimitAmount() : null
|
||||
txnRule != null ? txnRule.getMaxLimitAmount() : null,
|
||||
rule.isCcRelevant(),
|
||||
rule.isOnTrial(),
|
||||
rule.getTrialStartedOn() == null ? null : rule.getTrialStartedOn().toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -31,4 +31,7 @@ public interface KycRiskRulesWritePlatformService {
|
||||
* @return the persisted rule exactly as the catalog GET would return it
|
||||
*/
|
||||
KycRiskRuleData createRule(KycRiskRuleRequest request);
|
||||
|
||||
/** Ends a rule's trial: it stays in the catalog as a normal rule. Returns the rule as the catalog GET would. */
|
||||
KycRiskRuleData markNormal(Long ruleId);
|
||||
}
|
||||
|
||||
+27
-2
@@ -28,6 +28,7 @@ import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrappe
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
@@ -81,6 +82,13 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
||||
rule.setOccupation(occupation);
|
||||
}
|
||||
|
||||
// A rule written for a Cash Credit account, or marked so, is listed under the CC chip.
|
||||
rule.markCcRelevant(Boolean.TRUE.equals(request.getCcRelevant())
|
||||
|| "CURRENT".equalsIgnoreCase(StringUtils.defaultString(request.getTxnAppliesTo()).trim()));
|
||||
|
||||
// A rule authored here starts on trial, from the business date, until an officer marks it normal.
|
||||
rule.startTrial(DateUtils.getBusinessLocalDate());
|
||||
|
||||
final ClientRiskRule saved = this.repository.saveAndFlush(rule);
|
||||
|
||||
if (request.hasTransactionRule()) {
|
||||
@@ -138,8 +146,21 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
||||
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
|
||||
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
|
||||
|
||||
return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType,
|
||||
request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
|
||||
final AmlTransactionRule transactionRule = AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo,
|
||||
ruleType, request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
|
||||
transactionRule.restrictTo(StringUtils.isBlank(request.getTxnDirection()) ? null : request.getTxnDirection().trim().toUpperCase(),
|
||||
Boolean.TRUE.equals(request.getTxnCashOnly()));
|
||||
return transactionRule;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycRiskRuleData markNormal(final Long ruleId) {
|
||||
final ClientRiskRule rule = this.repository.findById(ruleId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found"));
|
||||
rule.markNormal();
|
||||
this.repository.saveAndFlush(rule);
|
||||
return this.readPlatformService.retrieveRule(ruleId);
|
||||
}
|
||||
|
||||
private Integer nextOrderPosition() {
|
||||
@@ -182,6 +203,10 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
||||
|
||||
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
|
||||
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class);
|
||||
if (StringUtils.isNotBlank(request.getTxnDirection()) && !"CREDIT".equalsIgnoreCase(request.getTxnDirection().trim())
|
||||
&& !"DEBIT".equalsIgnoreCase(request.getTxnDirection().trim())) {
|
||||
validator.reset().parameter("txnDirection").value(request.getTxnDirection()).failWithCode("must.be.credit.or.debit");
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(errors);
|
||||
|
||||
+3
-1
@@ -64,7 +64,9 @@ public class ClientSearchService {
|
||||
|
||||
Pageable pageable = searchRequest.toPageable();
|
||||
|
||||
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue)
|
||||
Long officeId = request.map(ClientTextSearch::getOfficeId).orElse(null);
|
||||
|
||||
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue, officeId)
|
||||
.map(clientSearchDataMapper::map);
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -41,4 +41,11 @@ public class ClientTextSearch {
|
||||
* not be able to link entities by omitting the param). Defaults to null = all legal forms.
|
||||
*/
|
||||
private String legalFormType;
|
||||
|
||||
/**
|
||||
* Narrows results to one branch (office). It is applied on top of the caller's office-hierarchy
|
||||
* restriction, never instead of it, so a branch user passing another branch's id still gets nothing.
|
||||
* Used by the client list's branch filter for Head Office users. Null = no extra filter.
|
||||
*/
|
||||
private Long officeId;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||
import org.apache.fineract.portfolio.group.domain.Group;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
|
||||
@@ -73,6 +74,10 @@ public class Note extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@JoinColumn(name = "share_account_id", nullable = true)
|
||||
private ShareAccount shareAccount;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "current_account_id", nullable = true)
|
||||
private CurrentAccount currentAccount;
|
||||
|
||||
public static Note clientNote(final Client client, final String note) {
|
||||
return new Note(client, note);
|
||||
}
|
||||
@@ -110,6 +115,10 @@ public class Note extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return new Note(account, note);
|
||||
}
|
||||
|
||||
public static Note currentAccountNote(final CurrentAccount account, final String note) {
|
||||
return new Note(account, note);
|
||||
}
|
||||
|
||||
public Note(final Client client, final String note) {
|
||||
this.client = client;
|
||||
this.note = note;
|
||||
@@ -161,6 +170,13 @@ public class Note extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
this.noteTypeId = NoteType.SHARE_ACCOUNT.getValue();
|
||||
}
|
||||
|
||||
private Note(final CurrentAccount account, final String note) {
|
||||
this.currentAccount = account;
|
||||
this.client = account.getClient();
|
||||
this.note = note;
|
||||
this.noteTypeId = NoteType.CURRENT_ACCOUNT.getValue();
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final String note) {
|
||||
if (!Strings.CI.equals(note, this.note)) {
|
||||
this.note = StringUtils.defaultIfEmpty(note, null);
|
||||
|
||||
+3
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.note.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||
import org.apache.fineract.portfolio.group.domain.Group;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
|
||||
@@ -50,6 +51,8 @@ public interface NoteRepository extends JpaRepository<Note, Long>, JpaSpecificat
|
||||
|
||||
Note findBySavingsAccountAndId(SavingsAccount savingAccount, Long id);
|
||||
|
||||
Note findByCurrentAccountAndId(CurrentAccount currentAccount, Long id);
|
||||
|
||||
@Query("select note from Note note where note.savingsTransaction.id = :savingsTransactionId")
|
||||
List<Note> findBySavingsTransactionId(@Param("savingsTransactionId") Long savingsTransactionId);
|
||||
|
||||
|
||||
+4
@@ -137,6 +137,10 @@ public class NoteReadPlatformServiceImpl implements NoteReadPlatformService {
|
||||
case GROUP:
|
||||
conditionSql = " n.group_id = ? ";
|
||||
break;
|
||||
case CURRENT_ACCOUNT:
|
||||
paramList.add(NoteType.CURRENT_ACCOUNT.getValue());
|
||||
conditionSql = " n.current_account_id = ? and n.note_type_enum = ? ";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
+17
-1
@@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.group.domain.GroupRepository;
|
||||
import org.apache.fineract.portfolio.group.exception.GroupNotFoundException;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
|
||||
@@ -50,16 +51,19 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf
|
||||
private final LoanRepositoryWrapper loanRepository;
|
||||
private final LoanTransactionRepository loanTransactionRepository;
|
||||
private final SavingsAccountRepository savingsAccountRepository;
|
||||
private final CurrentAccountRepositoryWrapper currentAccountRepository;
|
||||
|
||||
public NoteWritePlatformServiceJpaRepositoryImpl(final NoteRepository noteRepository, final ClientRepositoryWrapper clientRepository,
|
||||
final GroupRepository groupRepository, final LoanRepositoryWrapper loanRepository,
|
||||
final LoanTransactionRepository loanTransactionRepository, final SavingsAccountRepository savingsAccountRepository) {
|
||||
final LoanTransactionRepository loanTransactionRepository, final SavingsAccountRepository savingsAccountRepository,
|
||||
final CurrentAccountRepositoryWrapper currentAccountRepository) {
|
||||
this.noteRepository = noteRepository;
|
||||
this.clientRepository = clientRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.loanRepository = loanRepository;
|
||||
this.loanTransactionRepository = loanTransactionRepository;
|
||||
this.savingsAccountRepository = savingsAccountRepository;
|
||||
this.currentAccountRepository = currentAccountRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,6 +105,12 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf
|
||||
officeId = savingAccount.getClient().getOffice().getId();
|
||||
}
|
||||
break;
|
||||
case CURRENT_ACCOUNT: {
|
||||
final var currentAccount = currentAccountRepository.findOneWithNotFoundDetection(request.getResourceId());
|
||||
note = noteRepository.saveAndFlush(Note.currentAccountNote(currentAccount, request.getNote()));
|
||||
officeId = currentAccount.getOffice().getId();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new NoteResourceNotSupportedException(request.getType().getApiUrl());
|
||||
}
|
||||
@@ -168,6 +178,12 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf
|
||||
officeId = savingAccount.getClient().getOffice().getId();
|
||||
}
|
||||
break;
|
||||
case CURRENT_ACCOUNT: {
|
||||
final var currentAccount = currentAccountRepository.findOneWithNotFoundDetection(resourceId);
|
||||
note = noteRepository.findByCurrentAccountAndId(currentAccount, noteId);
|
||||
officeId = currentAccount.getOffice().getId();
|
||||
}
|
||||
break;
|
||||
case SHARE_ACCOUNT:
|
||||
case SAVINGS_TRANSACTION:
|
||||
log.error("Not yet implemented: {}", type);
|
||||
|
||||
+3
-2
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.note.starter;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.group.domain.GroupRepository;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||
@@ -46,8 +47,8 @@ public class NoteAutoConfiguration {
|
||||
@ConditionalOnMissingBean
|
||||
public NoteWritePlatformService noteWritePlatformService(NoteRepository noteRepository, ClientRepositoryWrapper clientRepository,
|
||||
GroupRepository groupRepository, LoanRepositoryWrapper loanRepository, LoanTransactionRepository loanTransactionRepository,
|
||||
SavingsAccountRepository savingsAccountRepository) {
|
||||
SavingsAccountRepository savingsAccountRepository, CurrentAccountRepositoryWrapper currentAccountRepository) {
|
||||
return new NoteWritePlatformServiceJpaRepositoryImpl(noteRepository, clientRepository, groupRepository, loanRepository,
|
||||
loanTransactionRepository, savingsAccountRepository);
|
||||
loanTransactionRepository, savingsAccountRepository, currentAccountRepository);
|
||||
}
|
||||
}
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 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.profitappropriation.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.profitappropriation.service.ProfitAppropriationPostingService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reserve funds and the percentage of the year's net profit appropriated to each - Statutory Reserve, Building
|
||||
* Fund, Investment Depreciation Reserve and so on - with the GL account each reserve is credited to.
|
||||
*
|
||||
* The screen saves the whole list at once (see {@link #update}), so the stored set is always exactly what one
|
||||
* administrator chose, never an interleaving of two open screens.
|
||||
*/
|
||||
@Path("/v1/profitappropriationconfig")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ProfitAppropriationConfigApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSION = "PROFITAPPROPRIATIONCONFIG";
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
private static final int MAX_NAME_LENGTH = 100;
|
||||
// Reserves and funds are equity (3) or, for funds held for a purpose, liability (2) accounts.
|
||||
private static final String ELIGIBLE_GL_SQL = "disabled = false AND account_usage = 1 AND classification_enum IN (2, 3)";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final ProfitAppropriationPostingService postingService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
public String retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
return gson.toJson(configuration());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("template")
|
||||
public String template() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
// Header accounts carry no entries of their own, so only postable accounts can receive an appropriation.
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList(
|
||||
"SELECT id, name, gl_code, classification_enum FROM acc_gl_account WHERE " + ELIGIBLE_GL_SQL + " ORDER BY gl_code");
|
||||
final List<Map<String, Object>> options = new ArrayList<>();
|
||||
for (final Map<String, Object> a : accounts) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", a.get("id"));
|
||||
o.put("name", a.get("name"));
|
||||
o.put("glCode", a.get("gl_code"));
|
||||
o.put("classification", a.get("classification_enum"));
|
||||
options.add(o);
|
||||
}
|
||||
return gson.toJson(Map.of("glAccountOptions", options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the whole list. Body: {@code {"reserves":[{"name":"Statutory Reserve","glAccountId":5,"percentage":25}]}}.
|
||||
* A reserve may be saved without a GL account while the bank has not opened that account yet.
|
||||
*/
|
||||
@PUT
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String update(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final JsonElement root = this.fromJsonHelper.parse(body);
|
||||
final JsonArray rows = root != null && root.isJsonObject() && root.getAsJsonObject().has("reserves")
|
||||
&& root.getAsJsonObject().get("reserves").isJsonArray() ? root.getAsJsonObject().getAsJsonArray("reserves") : null;
|
||||
if (rows == null) {
|
||||
throw invalid("reserves.required", "The request must contain a 'reserves' list.");
|
||||
}
|
||||
|
||||
final List<Reserve> reserves = new ArrayList<>();
|
||||
final Set<String> names = new HashSet<>();
|
||||
final Set<Long> accounts = new HashSet<>();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
|
||||
for (final JsonElement element : rows) {
|
||||
final JsonObject row = element.getAsJsonObject();
|
||||
final String name = StringUtils.trimToNull(text(row, "name"));
|
||||
if (name == null) {
|
||||
throw invalid("name.required", "Every reserve needs a name.");
|
||||
}
|
||||
if (name.length() > MAX_NAME_LENGTH) {
|
||||
throw invalid("name.too.long", "Reserve name '" + name + "' is longer than " + MAX_NAME_LENGTH + " characters.");
|
||||
}
|
||||
if (!names.add(name.toLowerCase(Locale.ROOT))) {
|
||||
throw invalid("name.duplicate", "Reserve '" + name + "' is listed twice.");
|
||||
}
|
||||
|
||||
final BigDecimal percentage = row.has("percentage") && !row.get("percentage").isJsonNull()
|
||||
? row.get("percentage").getAsBigDecimal()
|
||||
: null;
|
||||
if (percentage == null || percentage.signum() <= 0 || percentage.compareTo(HUNDRED) > 0) {
|
||||
throw invalid("percentage.out.of.range", "The percentage for '" + name + "' must be more than 0 and at most 100.");
|
||||
}
|
||||
total = total.add(percentage);
|
||||
|
||||
final Long glAccountId = row.has("glAccountId") && !row.get("glAccountId").isJsonNull() ? row.get("glAccountId").getAsLong()
|
||||
: null;
|
||||
if (glAccountId != null) {
|
||||
final Integer eligible = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM acc_gl_account WHERE id = ? AND " + ELIGIBLE_GL_SQL, Integer.class, glAccountId);
|
||||
if (eligible == null || eligible == 0) {
|
||||
throw invalid("gl.account.not.eligible", "The GL account chosen for '" + name
|
||||
+ "' was not found, is disabled, is a header account, or is not an equity or liability account.");
|
||||
}
|
||||
// Two reserves credited to one account would merge into a single balance nobody can split again.
|
||||
if (!accounts.add(glAccountId)) {
|
||||
throw invalid("gl.account.duplicate", "The GL account chosen for '" + name + "' is already used by another reserve.");
|
||||
}
|
||||
}
|
||||
reserves.add(new Reserve(name, glAccountId, percentage));
|
||||
}
|
||||
|
||||
// More than 100% would appropriate more than the year earned.
|
||||
if (total.compareTo(HUNDRED) > 0) {
|
||||
throw invalid("total.exceeds.hundred",
|
||||
"The reserves add up to " + total.stripTrailingZeros().toPlainString() + "% of net profit; the total cannot exceed 100%.");
|
||||
}
|
||||
|
||||
jdbcTemplate.update("DELETE FROM m_profit_appropriation_config");
|
||||
int position = 0;
|
||||
for (final Reserve reserve : reserves) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_profit_appropriation_config (name, gl_account_id, percentage, position, updated_on, updated_by)
|
||||
VALUES (?, ?, ?, ?, now(), ?)
|
||||
""", reserve.name(), reserve.glAccountId(), reserve.percentage(), position++, user.getId());
|
||||
}
|
||||
return gson.toJson(configuration());
|
||||
}
|
||||
|
||||
/**
|
||||
* What appropriating a year's profit would post - each reserve's amount from the profit GL - or, for a year already
|
||||
* appropriated, what was posted. {@code financialYear} defaults to the newest closed year not yet appropriated.
|
||||
*/
|
||||
@GET
|
||||
@Path("posting")
|
||||
public String postingPreview(@QueryParam("financialYear") final String financialYear) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
final String fy = StringUtils.isBlank(financialYear) ? postingService.defaultFinancialYear() : financialYear.trim();
|
||||
return gson.toJson(postingService.preview(fy));
|
||||
}
|
||||
|
||||
/** Posts the year's appropriation. Body: {@code {"financialYear":"2026-27","note":"AGM resolution 12/2027"}}. */
|
||||
@POST
|
||||
@Path("posting")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String post(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
// Configuring the reserves is not enough - this posts journal entries, so it needs that right as well.
|
||||
user.validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION);
|
||||
user.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
|
||||
final JsonElement root = this.fromJsonHelper.parse(body);
|
||||
final JsonObject json = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
|
||||
final String financialYear = StringUtils.trimToNull(text(json, "financialYear"));
|
||||
if (financialYear == null) {
|
||||
throw invalid("financial.year.required", "Choose the financial year whose profit is to be appropriated.");
|
||||
}
|
||||
return gson.toJson(postingService.post(financialYear, text(json, "note"), user.getId(), user.getUsername()));
|
||||
}
|
||||
|
||||
private Map<String, Object> configuration() {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT c.id, c.name, c.gl_account_id, g.gl_code, g.name AS gl_name, c.percentage, c.position,
|
||||
c.updated_on, u.username AS updated_by
|
||||
FROM m_profit_appropriation_config c
|
||||
LEFT JOIN acc_gl_account g ON g.id = c.gl_account_id
|
||||
LEFT JOIN m_appuser u ON u.id = c.updated_by
|
||||
ORDER BY c.position, c.id
|
||||
""");
|
||||
final List<Map<String, Object>> reserves = new ArrayList<>();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
Object updatedOn = null;
|
||||
Object updatedBy = null;
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final BigDecimal percentage = (BigDecimal) r.get("percentage");
|
||||
total = total.add(percentage);
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("name", r.get("name"));
|
||||
o.put("glAccountId", r.get("gl_account_id"));
|
||||
o.put("glCode", r.get("gl_code"));
|
||||
o.put("glAccountName", r.get("gl_name"));
|
||||
o.put("percentage", plain(percentage));
|
||||
reserves.add(o);
|
||||
updatedOn = r.get("updated_on") != null ? r.get("updated_on").toString() : updatedOn;
|
||||
updatedBy = r.get("updated_by") != null ? r.get("updated_by") : updatedBy;
|
||||
}
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("reserves", reserves);
|
||||
out.put("totalPercentage", plain(total));
|
||||
out.put("remainingPercentage", plain(HUNDRED.subtract(total)));
|
||||
out.put("updatedOn", updatedOn);
|
||||
out.put("updatedBy", updatedBy);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 25.0000 -> 25 and 100.0000 -> 100 (not 1E+2), so the screen shows what was typed. */
|
||||
private static BigDecimal plain(final BigDecimal value) {
|
||||
return new BigDecimal(value.stripTrailingZeros().toPlainString());
|
||||
}
|
||||
|
||||
private static String text(final JsonObject row, final String field) {
|
||||
return row.has(field) && !row.get(field).isJsonNull() ? row.get(field).getAsString() : null;
|
||||
}
|
||||
|
||||
private static PlatformDataIntegrityException invalid(final String code, final String message) {
|
||||
return new PlatformDataIntegrityException("error.msg.profitappropriationconfig." + code, message);
|
||||
}
|
||||
|
||||
private record Reserve(String name, Long glAccountId, BigDecimal percentage) {}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 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.profitappropriation.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryType;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Appropriates a closed financial year's profit into the reserve funds configured on the Reserve Fund
|
||||
* Configuration screen: each reserve gets its percentage of the profit in the Distributable Profit GL, posted as
|
||||
* one journal entry ({@code Dr profit GL / Cr reserve GL} per reserve), dated the last day of the year so it sits in
|
||||
* the same year as the close it follows.
|
||||
*
|
||||
* Refused until the year has been closed into the profit GL (before that the GL holds a partial figure), while any
|
||||
* reserve has no GL account, and once the year has been appropriated - each year's profit is appropriated once.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ProfitAppropriationPostingService {
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
private static final int MAX_NOTE_LENGTH = 500;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final IncomeProfitTransferService yearEnd;
|
||||
|
||||
/** The financial years the screen offers: the current one and the three before it, newest first. */
|
||||
public List<String> financialYearOptions() {
|
||||
final String current = yearEnd.currentFinancialYear();
|
||||
final int startYear = Integer.parseInt(current.substring(0, 4));
|
||||
final List<String> years = new ArrayList<>();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
final int y = startYear - i;
|
||||
years.add(y + "-" + String.format("%02d", (y + 1) % 100));
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
/** The year the screen opens on: the newest closed year not yet appropriated, else the year before the current. */
|
||||
public String defaultFinancialYear() {
|
||||
final List<String> years = financialYearOptions();
|
||||
for (final String fy : years) {
|
||||
if (yearEnd.isYearClosed(fy) && !isPosted(fy)) {
|
||||
return fy;
|
||||
}
|
||||
}
|
||||
return years.get(1);
|
||||
}
|
||||
|
||||
/** What posting the year would do - or, for a year already appropriated, what was posted. */
|
||||
public Map<String, Object> preview(final String financialYear) {
|
||||
final LocalDate[] range = yearEnd.financialYearRange(financialYear);
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("financialYear", financialYear);
|
||||
out.put("fromDate", range[0].toString());
|
||||
out.put("toDate", range[1].toString());
|
||||
out.put("financialYearOptions", financialYearOptions());
|
||||
|
||||
final List<String> blockers = new ArrayList<>();
|
||||
|
||||
final List<Map<String, Object>> posted = postedRows(financialYear);
|
||||
if (!posted.isEmpty()) {
|
||||
final Map<String, Object> first = posted.get(0);
|
||||
out.put("posted", true);
|
||||
out.put("profitAmount", plain((BigDecimal) first.get("profit_amount")));
|
||||
out.put("glTransactionId", first.get("gl_transaction_id"));
|
||||
out.put("postedOnDate", String.valueOf(first.get("posted_on_date")));
|
||||
out.put("postedBy", first.get("posted_by_name"));
|
||||
out.put("note", first.get("note"));
|
||||
out.put("profitGlCode", first.get("profit_gl_code"));
|
||||
out.put("profitGlName", first.get("profit_gl_name"));
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
for (final Map<String, Object> r : posted) {
|
||||
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||
total = total.add(amount);
|
||||
lines.add(line((String) r.get("reserve_name"), r.get("gl_account_id"), r.get("gl_code"), r.get("gl_name"),
|
||||
(BigDecimal) r.get("percentage"), amount));
|
||||
}
|
||||
out.put("lines", lines);
|
||||
out.put("totalAmount", plain(total));
|
||||
out.put("remainingAmount", plain(((BigDecimal) first.get("profit_amount")).subtract(total)));
|
||||
out.put("blockers", List.of("Profit for " + financialYear + " has already been appropriated."));
|
||||
out.put("canPost", false);
|
||||
return out;
|
||||
}
|
||||
out.put("posted", false);
|
||||
|
||||
Map<String, Object> profitGl = null;
|
||||
try {
|
||||
profitGl = yearEnd.loadDistributableProfitGl();
|
||||
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||
out.put("profitGlName", profitGl.get("name"));
|
||||
} catch (RuntimeException e) {
|
||||
blockers.add("No GL is mapped to Distributable Profit (Accounting → Accounts Linked to Financial Activities).");
|
||||
}
|
||||
|
||||
final boolean closed = yearEnd.isYearClosed(financialYear);
|
||||
out.put("yearClosed", closed);
|
||||
if (!closed) {
|
||||
blockers.add("FY " + financialYear + " has not been closed into the profit GL yet. The year-end close runs on "
|
||||
+ range[1] + "; until then the profit GL does not hold the year's profit.");
|
||||
}
|
||||
|
||||
final BigDecimal profit = profitGl != null ? yearEnd.profitForFinancialYear(financialYear) : BigDecimal.ZERO;
|
||||
out.put("profitAmount", plain(profit));
|
||||
if (profitGl != null && closed && profit.signum() <= 0) {
|
||||
blockers.add("There is no profit to appropriate for " + financialYear + " (the profit GL shows " + plain(profit) + ").");
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> reserves = jdbcTemplate.queryForList("""
|
||||
SELECT c.name, c.gl_account_id, g.gl_code, g.name AS gl_name, c.percentage
|
||||
FROM m_profit_appropriation_config c
|
||||
LEFT JOIN acc_gl_account g ON g.id = c.gl_account_id
|
||||
ORDER BY c.position, c.id
|
||||
""");
|
||||
if (reserves.isEmpty()) {
|
||||
blockers.add("No reserve fund is configured.");
|
||||
}
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
final List<String> unmapped = new ArrayList<>();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> r : reserves) {
|
||||
final BigDecimal percentage = (BigDecimal) r.get("percentage");
|
||||
final BigDecimal amount = profit.signum() > 0 ? share(profit, percentage) : BigDecimal.ZERO;
|
||||
total = total.add(amount);
|
||||
if (r.get("gl_account_id") == null) {
|
||||
unmapped.add((String) r.get("name"));
|
||||
}
|
||||
lines.add(line((String) r.get("name"), r.get("gl_account_id"), r.get("gl_code"), r.get("gl_name"), percentage, amount));
|
||||
}
|
||||
if (!unmapped.isEmpty()) {
|
||||
blockers.add("Select a GL account for: " + String.join(", ", unmapped) + ".");
|
||||
}
|
||||
out.put("lines", lines);
|
||||
out.put("totalAmount", plain(total));
|
||||
out.put("remainingAmount", plain(profit.subtract(total)));
|
||||
out.put("blockers", blockers);
|
||||
out.put("canPost", blockers.isEmpty());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Posts the year's appropriation as one journal entry and records it. */
|
||||
@Transactional
|
||||
public Map<String, Object> post(final String financialYear, final String note, final Long userId, final String userName) {
|
||||
// Serialises two users posting the same year at once: the second waits here, then sees the first's rows.
|
||||
jdbcTemplate.execute("LOCK TABLE m_profit_appropriation_posting IN SHARE ROW EXCLUSIVE MODE");
|
||||
|
||||
final Map<String, Object> preview = preview(financialYear);
|
||||
if (!Boolean.TRUE.equals(preview.get("canPost"))) {
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<String> blockers = (List<String>) preview.get("blockers");
|
||||
throw new PlatformDataIntegrityException("error.msg.profitappropriation.cannot.post",
|
||||
blockers.isEmpty() ? "Profit for " + financialYear + " cannot be appropriated." : blockers.get(0));
|
||||
}
|
||||
|
||||
final Map<String, Object> profitGl = yearEnd.loadDistributableProfitGl();
|
||||
final Long profitGlId = ((Number) profitGl.get("id")).longValue();
|
||||
final BigDecimal profit = new BigDecimal(preview.get("profitAmount").toString());
|
||||
final LocalDate postingDate = yearEnd.financialYearRange(financialYear)[1];
|
||||
final Long officeId = yearEnd.headOfficeId();
|
||||
final String currency = yearEnd.resolveCurrency(profitGlId);
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String txnId = LedgerTransactionId.next(jdbcTemplate, LedgerTxnPrefix.PROFIT_APPROPRIATION);
|
||||
final String trimmedNote = StringUtils.abbreviate(StringUtils.trimToNull(note), MAX_NOTE_LENGTH);
|
||||
final int debit = JournalEntryType.DEBIT.getValue();
|
||||
final int credit = JournalEntryType.CREDIT.getValue();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<Map<String, Object>> lines = (List<Map<String, Object>>) preview.get("lines");
|
||||
for (final Map<String, Object> l : lines) {
|
||||
final String reserve = (String) l.get("reserveName");
|
||||
final Long glId = ((Number) l.get("glAccountId")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) l.get("amount");
|
||||
if (amount.signum() > 0) {
|
||||
final String descr = "Profit appropriation " + financialYear + " — " + reserve
|
||||
+ (trimmedNote != null ? " | " + trimmedNote : "");
|
||||
yearEnd.insertEntry(officeId, profitGlId, debit, amount, txnId, postingDate, userId, now, currency, descr);
|
||||
yearEnd.insertEntry(officeId, glId, credit, amount, txnId, postingDate, userId, now, currency, descr);
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_profit_appropriation_posting
|
||||
(financial_year, reserve_name, gl_account_id, profit_gl_id, percentage, profit_amount, amount,
|
||||
gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?, now())
|
||||
""", financialYear, reserve, glId, profitGlId, l.get("percentage"), profit, amount, txnId,
|
||||
java.sql.Date.valueOf(postingDate), userId, userName, trimmedNote);
|
||||
}
|
||||
log.info("Profit appropriation {} posted as {}: {} of {} to {} reserve(s)", financialYear, txnId, preview.get("totalAmount"),
|
||||
profit, lines.size());
|
||||
return preview(financialYear);
|
||||
}
|
||||
|
||||
private boolean isPosted(final String financialYear) {
|
||||
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_profit_appropriation_posting WHERE financial_year = ?",
|
||||
Integer.class, financialYear);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> postedRows(final String financialYear) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT p.reserve_name, p.gl_account_id, g.gl_code, g.name AS gl_name, p.percentage, p.profit_amount, p.amount,
|
||||
p.gl_transaction_id, p.posted_on_date, p.posted_by_name, p.note,
|
||||
pg.gl_code AS profit_gl_code, pg.name AS profit_gl_name
|
||||
FROM m_profit_appropriation_posting p
|
||||
JOIN acc_gl_account g ON g.id = p.gl_account_id
|
||||
JOIN acc_gl_account pg ON pg.id = p.profit_gl_id
|
||||
WHERE p.financial_year = ?
|
||||
ORDER BY p.id
|
||||
""", financialYear);
|
||||
}
|
||||
|
||||
/** A reserve's share of the profit, to the paisa. */
|
||||
private static BigDecimal share(final BigDecimal profit, final BigDecimal percentage) {
|
||||
return profit.multiply(percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static Map<String, Object> line(final String name, final Object glId, final Object glCode, final Object glName,
|
||||
final BigDecimal percentage, final BigDecimal amount) {
|
||||
final Map<String, Object> l = new LinkedHashMap<>();
|
||||
l.put("reserveName", name);
|
||||
l.put("glAccountId", glId);
|
||||
l.put("glCode", glCode);
|
||||
l.put("glAccountName", glName);
|
||||
l.put("percentage", plain(percentage));
|
||||
l.put("amount", plain(amount));
|
||||
return l;
|
||||
}
|
||||
|
||||
private static BigDecimal plain(final BigDecimal value) {
|
||||
return new BigDecimal(value.stripTrailingZeros().toPlainString());
|
||||
}
|
||||
}
|
||||
+726
-43
@@ -19,7 +19,12 @@
|
||||
package org.apache.fineract.portfolio.rbireserve.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
@@ -28,6 +33,7 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
@@ -36,6 +42,12 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
|
||||
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.domain.FineractRequestContextHolder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
@@ -129,6 +141,15 @@ public class RbiReserveReturnApiResource {
|
||||
private static final String USAGE_SLR_ASSET = "SLR_ASSET";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
/**
|
||||
* Raises the transfer entry in Java rather than over HTTP.
|
||||
*
|
||||
* The manual journal entry endpoint is turned off at the HTTP edge on this installation; an
|
||||
* internal command is how the clearing and disbursement flows post theirs, and it goes through
|
||||
* maker-checker on exactly the same terms.
|
||||
*/
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@@ -765,10 +786,19 @@ public class RbiReserveReturnApiResource {
|
||||
|
||||
final BigDecimal percentage;
|
||||
final boolean seedDefault;
|
||||
/**
|
||||
* The date this rate came into force.
|
||||
*
|
||||
* Carried with the rate because a return read day by day has to say WHEN a requirement
|
||||
* changed, not only what it changed to: a rate rising mid-period leaves two requirements
|
||||
* on one sheet, and without the date a reader cannot tell which days each one settled.
|
||||
*/
|
||||
final LocalDate effectiveFrom;
|
||||
|
||||
Rate(final BigDecimal percentage, final boolean seedDefault) {
|
||||
Rate(final BigDecimal percentage, final boolean seedDefault, final LocalDate effectiveFrom) {
|
||||
this.percentage = percentage;
|
||||
this.seedDefault = seedDefault;
|
||||
this.effectiveFrom = effectiveFrom;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,7 +809,7 @@ public class RbiReserveReturnApiResource {
|
||||
*/
|
||||
private Rate rateInForce(final String rateType, final LocalDate onDate) {
|
||||
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
|
||||
SELECT percentage, is_seed_default FROM m_rbi_reserve_rate
|
||||
SELECT percentage, is_seed_default, effective_from FROM m_rbi_reserve_rate
|
||||
WHERE rate_type = ? AND effective_from <= ?
|
||||
ORDER BY effective_from DESC LIMIT 1
|
||||
""", rateType, java.sql.Date.valueOf(onDate));
|
||||
@@ -787,7 +817,8 @@ public class RbiReserveReturnApiResource {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> row = found.get(0);
|
||||
return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default")));
|
||||
return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default")),
|
||||
((java.sql.Date) row.get("effective_from")).toLocalDate());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1134,6 +1165,388 @@ public class RbiReserveReturnApiResource {
|
||||
* code. A head left unmapped reports as nil, because on this form a nil line is a real answer -
|
||||
* a bank with no balances at the State Co-operative Bank files that line empty.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Today's reserve position - what the bank must hold, what it is holding, and where.
|
||||
*
|
||||
* <p>
|
||||
* The return above reports fortnight closes. This answers a different question, and the one a
|
||||
* treasury desk asks every morning: as things stand RIGHT NOW, are we short? A return made up to
|
||||
* a close two weeks ago cannot say that, because the balances it reports have since moved.
|
||||
*
|
||||
* <p>
|
||||
* The requirement is still charged on the base date - RBI's lag is a rule, not an approximation,
|
||||
* and today's own liabilities settle nothing until they become a base themselves. So the figures
|
||||
* are read on two different dates on purpose: the base from the close it is charged on, the
|
||||
* balances as they stand today. Both dates are stated, because a reader comparing them has to
|
||||
* know they are not the same day.
|
||||
*
|
||||
* <p>
|
||||
* The accounts are listed individually rather than only totalled: a shortfall is covered by
|
||||
* moving money into one of them, and a desk cannot act on a total.
|
||||
*/
|
||||
@GET
|
||||
@Path("position")
|
||||
public String reservePosition(@QueryParam("onDate") final String onDateParam,
|
||||
@QueryParam("officeId") final Long officeId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final LocalDate today = parseOrBusinessDate(onDateParam);
|
||||
final LocalDate baseDate = formOneBaseDate(today);
|
||||
final Rate crr = rateInForce("CRR", today);
|
||||
final Rate slr = rateInForce("SLR", today);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("onDate", today.toString());
|
||||
response.put("baseDate", baseDate.toString());
|
||||
response.put("crrPercentage", crr == null ? null : crr.percentage);
|
||||
response.put("crrEffectiveFrom", crr == null ? null : crr.effectiveFrom.toString());
|
||||
response.put("slrPercentage", slr == null ? null : slr.percentage);
|
||||
response.put("slrEffectiveFrom", slr == null ? null : slr.effectiveFrom.toString());
|
||||
|
||||
// The liabilities as they stand today, split the way the form splits them. Not what the
|
||||
// requirement is charged on - that is the base below - but what a desk watches, because it
|
||||
// is what the NEXT base will be struck from.
|
||||
final RoleFigure dtlToday = figureFor("DEMAND_TIME_LIABILITIES", today, officeId);
|
||||
final Map<String, Object> liabilities = new LinkedHashMap<>();
|
||||
liabilities.put("demand", dtlToday.demandAmount);
|
||||
liabilities.put("time", dtlToday.timeAmount);
|
||||
liabilities.put("total", dtlToday.amount);
|
||||
liabilities.put("net", formOneNetLiabilities(today, officeId));
|
||||
response.put("today", liabilities);
|
||||
|
||||
// The base the requirement is actually charged on, split the same way so the two can be
|
||||
// compared line for line.
|
||||
final RoleFigure dtlBase = figureFor("DEMAND_TIME_LIABILITIES", baseDate, officeId);
|
||||
final BigDecimal base = formOneNetLiabilities(baseDate, officeId);
|
||||
final Map<String, Object> charged = new LinkedHashMap<>();
|
||||
charged.put("demand", dtlBase.demandAmount);
|
||||
charged.put("time", dtlBase.timeAmount);
|
||||
charged.put("total", dtlBase.amount);
|
||||
charged.put("net", base);
|
||||
response.put("base", charged);
|
||||
|
||||
final BigDecimal crrRequired = crr == null ? BigDecimal.ZERO
|
||||
: base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO
|
||||
: base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
|
||||
// What is actually held, as of today. The same definition Part B and Part C use, so the
|
||||
// dashboard and the return cannot disagree about whether the bank is compliant.
|
||||
final BigDecimal crrHeld = formOnePartBMaintained(today, officeId);
|
||||
final BigDecimal slrHeld = formOneSlrHeld(today, crrHeld, crrRequired, officeId);
|
||||
|
||||
response.put("crr", formOneRatioPosition(crrRequired, crrHeld));
|
||||
response.put("slr", formOneRatioPosition(slrRequired, slrHeld));
|
||||
|
||||
// Every account the cash reserve is maintained in, with what it holds today. These are the
|
||||
// accounts a shortfall is covered INTO, so the balance that matters is today's, not the
|
||||
// close's.
|
||||
//
|
||||
// The RBI current account is listed even where the return leaves item VI(a) empty: money
|
||||
// can be moved there whatever the filed form shows, and a transfer screen that hid the
|
||||
// account would leave a desk unable to use it.
|
||||
final List<Map<String, Object>> accounts = new ArrayList<>();
|
||||
accounts.addAll(formOnePositionAccounts("V", "Cash on hand", "CASH_IN_HAND", today, officeId));
|
||||
accounts.addAll(
|
||||
formOnePositionAccounts("VI(a)", "Reserve Bank of India", "FORM1_VI_A_RBI_CURRENT", today, officeId));
|
||||
accounts.addAll(formOnePositionAccounts("VI(b)", "State Co-operative Bank", "FORM1_VI_B_STATE_COOP_CURRENT",
|
||||
today, officeId));
|
||||
accounts.addAll(formOnePositionAccounts("VI(c)", "District Central Co-operative Bank",
|
||||
"FORM1_VI_C_DCCB_CURRENT", today, officeId));
|
||||
|
||||
// Item VIII — the correspondent balances, which item X counts as surely as the cash and the
|
||||
// current accounts above (X = V + VI + VIII). Left out, the accounts on screen added to
|
||||
// less than the maintained figure beside them, and a desk reading the list could not see
|
||||
// where the rest of its reserve was sitting.
|
||||
//
|
||||
// The balances themselves, not the net: item VIII reports only the excess over what the
|
||||
// same banks hold with this bank, but what a desk can MOVE is the balance, and a row
|
||||
// showing a net it cannot transfer is not a row it can act on.
|
||||
accounts.addAll(formOnePositionAccounts("VIII", "Correspondent banks (net balance counts)",
|
||||
"FORM1_III_A_SBI_CURRENT", today, officeId));
|
||||
|
||||
// Reserve accounts the bank holds but has not mapped to a head of the form. They are shown
|
||||
// because money can be moved into them whatever the filed return counts, and a desk cannot
|
||||
// act on an account the screen does not list - but they are marked as entering no total, so
|
||||
// a reader does not add them to a requirement they do not settle.
|
||||
for (final Map<String, Object> unmapped : formOneUnmappedReserveAccounts(today, officeId, accounts)) {
|
||||
accounts.add(unmapped);
|
||||
}
|
||||
response.put("accounts", accounts);
|
||||
|
||||
return this.gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is held against section 24 on a date - item XII, built exactly as Part C builds it.
|
||||
*
|
||||
* <p>
|
||||
* The cash held over the section 18 requirement, plus VII(c), plus gold, plus the approved
|
||||
* securities mapped to XII(c). VII(a) and VII(b) are NOT in it: they are deposits placed with
|
||||
* the State and District Central Co-operative Banks, which the filed return does not admit as
|
||||
* liquid assets for SLR.
|
||||
*
|
||||
* <p>
|
||||
* One definition for every place that reports it. The position screen and the day-by-day sheet
|
||||
* each carried their own copy, written before Part C was corrected, and went on counting VII(a)
|
||||
* and VII(b) - so one dialog quoted two different SLR figures, five and a half crore apart,
|
||||
* depending on which tab was open.
|
||||
*/
|
||||
private BigDecimal formOneSlrHeld(final LocalDate date, final BigDecimal crrHeld, final BigDecimal crrRequired,
|
||||
final Long officeId) {
|
||||
final BigDecimal excessCash = crrHeld.subtract(crrRequired).max(BigDecimal.ZERO);
|
||||
return excessCash.add(figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount)
|
||||
.add(figureFor("FORMA_SLR_GOLD", date, officeId).amount)
|
||||
.add(figureFor("SLR_INVESTMENTS", date, officeId).amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* One ratio's standing - required, held, and the gap, with the gap named rather than signed.
|
||||
*/
|
||||
private Map<String, Object> formOneRatioPosition(final BigDecimal required, final BigDecimal held) {
|
||||
final Map<String, Object> position = new LinkedHashMap<>();
|
||||
final BigDecimal gap = held.subtract(required);
|
||||
position.put("required", required);
|
||||
position.put("maintained", held);
|
||||
position.put("surplus", gap.max(BigDecimal.ZERO));
|
||||
// Positive: a shortfall is an amount to be covered, and a desk reading "move 4,13,000" does
|
||||
// not want to re-sign a negative first.
|
||||
position.put("shortfall", gap.min(BigDecimal.ZERO).negate());
|
||||
position.put("met", gap.compareTo(BigDecimal.ZERO) >= 0);
|
||||
return position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve accounts the bank holds that no head of the form picks up.
|
||||
*
|
||||
* <p>
|
||||
* A balance with the Reserve Bank that the filed return leaves out of item VI(a) is still an
|
||||
* account money can be moved into, and a screen that listed only mapped accounts would leave a
|
||||
* desk unable to use it. They are returned marked - entering no total - rather than silently
|
||||
* added to what is maintained, which would have the dashboard claim compliance the return does
|
||||
* not.
|
||||
*
|
||||
* <p>
|
||||
* Identified by the GL's own account_usage and classification rather than by code, so a bank
|
||||
* that names its reserve accounts differently is not left with an empty list.
|
||||
*/
|
||||
private List<Map<String, Object>> formOneUnmappedReserveAccounts(final LocalDate onDate, final Long officeId,
|
||||
final List<Map<String, Object>> alreadyListed) {
|
||||
final Set<Object> listed = new HashSet<>();
|
||||
for (final Map<String, Object> account : alreadyListed) {
|
||||
listed.add(account.get("glAccountId"));
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT a.id, a.gl_code, a.name
|
||||
FROM acc_gl_account a
|
||||
WHERE a.classification_enum = 1 AND a.account_usage = 1 AND a.disabled = false
|
||||
AND (UPPER(a.name) LIKE '%RESERVE BANK%' OR UPPER(a.name) LIKE '%RBI%')
|
||||
ORDER BY a.gl_code
|
||||
""");
|
||||
|
||||
final List<Map<String, Object>> unmapped = new ArrayList<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final Long accountId = ((Number) row.get("id")).longValue();
|
||||
if (listed.contains(accountId)) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, Object> account = new LinkedHashMap<>();
|
||||
account.put("glAccountId", accountId);
|
||||
account.put("glCode", row.get("gl_code"));
|
||||
account.put("name", row.get("name"));
|
||||
account.put("balance", balanceOf(accountId, onDate, officeId));
|
||||
account.put("item", "VI(a)");
|
||||
account.put("head", "Reserve Bank of India");
|
||||
account.put("configKey", null);
|
||||
// Shown, but not counted: the filed return does not read this balance, so adding it to
|
||||
// what is maintained would have the dashboard and the form disagree.
|
||||
account.put("countsTowardMaintained", false);
|
||||
unmapped.add(account);
|
||||
}
|
||||
return unmapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* One GL account's balance on a date, read the way the rest of the return reads balances.
|
||||
*/
|
||||
private BigDecimal balanceOf(final Long accountId, final LocalDate onDate, final Long officeId) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END), 0) AS balance
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND entry_date <= ?
|
||||
AND (reversed IS NULL OR reversed = false)
|
||||
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>(List.of(accountId, java.sql.Date.valueOf(onDate)));
|
||||
if (officeId != null) {
|
||||
sql.append(" AND office_id = ?");
|
||||
args.add(officeId);
|
||||
}
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap(sql.toString(), args.toArray());
|
||||
return (BigDecimal) row.get("balance");
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts mapped to one head, with what each holds on the date given.
|
||||
*/
|
||||
private List<Map<String, Object>> formOnePositionAccounts(final String item, final String head,
|
||||
final String configKey, final LocalDate onDate, final Long officeId) {
|
||||
final List<Map<String, Object>> accounts = new ArrayList<>();
|
||||
final RoleFigure figure = figureFor(configKey, onDate, officeId);
|
||||
for (final Map<String, Object> account : figure.accounts) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>(account);
|
||||
row.put("item", item);
|
||||
row.put("head", head);
|
||||
row.put("configKey", configKey);
|
||||
// Mapped to a head the form reads, so this balance is part of what is maintained.
|
||||
row.put("countsTowardMaintained", true);
|
||||
accounts.add(row);
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves money between the accounts the cash reserve is held in.
|
||||
*
|
||||
* <p>
|
||||
* Its own endpoint rather than the manual journal entry screen, because that screen is turned
|
||||
* off at the HTTP edge on this installation (see DisabledFeatureApiFilter) - deliberately, so
|
||||
* that nobody posts a free-hand entry against the ledger. A reserve transfer is not a free-hand
|
||||
* entry: both sides are reserve accounts the administrator has mapped, the amount is checked
|
||||
* against what the source actually holds, and the entry is raised in Java like the clearing and
|
||||
* disbursement flows, so it goes through maker-checker exactly as they do.
|
||||
*
|
||||
* <p>
|
||||
* It records the movement; it does not make it. The money reaches the other bank when the
|
||||
* treasury desk sends the NEFT or RTGS - this is the bank's own record of having done so.
|
||||
*
|
||||
* <p>
|
||||
* These are asset accounts, so the one money LEAVES is credited and the one it arrives in is
|
||||
* debited. The request names them as a desk thinks of them, from and to, and the sides are
|
||||
* worked out here.
|
||||
*/
|
||||
@POST
|
||||
@Path("transfer")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String reserveTransfer(final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||
|
||||
final JsonObject request = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
|
||||
final Long fromAccountId = request.get("fromGlAccountId").getAsLong();
|
||||
final Long toAccountId = request.get("toGlAccountId").getAsLong();
|
||||
final BigDecimal amount = request.get("amount").getAsBigDecimal();
|
||||
final Long officeId = request.has("officeId") && !request.get("officeId").isJsonNull()
|
||||
? request.get("officeId").getAsLong()
|
||||
: null;
|
||||
final String reference = request.has("reference") && !request.get("reference").isJsonNull()
|
||||
? request.get("reference").getAsString()
|
||||
: null;
|
||||
final String note = request.has("comments") && !request.get("comments").isJsonNull()
|
||||
? request.get("comments").getAsString()
|
||||
: null;
|
||||
|
||||
if (fromAccountId.equals(toAccountId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.reserve.transfer.same.account",
|
||||
"The money cannot be moved to the account it is already in.");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.reserve.transfer.invalid.amount",
|
||||
"The amount to transfer must be more than nothing.");
|
||||
}
|
||||
|
||||
// Both sides must be accounts the reserve is actually held in. Checked here rather than
|
||||
// trusted from the request: an endpoint that moved money between any two GLs on the say-so
|
||||
// of its caller would be the manual journal entry screen under another name, which is the
|
||||
// thing this installation turned off.
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final Set<Long> reserveAccounts = formOneReserveAccountIds();
|
||||
if (!reserveAccounts.contains(fromAccountId) || !reserveAccounts.contains(toAccountId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.reserve.transfer.not.reserve.account",
|
||||
"Both accounts must be mapped to a cash reserve head on the RBI Reserve Configuration.");
|
||||
}
|
||||
|
||||
// An account cannot send more than it holds: the ledger would carry a negative balance on a
|
||||
// cash or bank account, which is not a position that exists.
|
||||
final BigDecimal available = balanceOf(fromAccountId, today, officeId);
|
||||
if (amount.compareTo(available) > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.reserve.transfer.insufficient.balance",
|
||||
"The account holds " + available.toPlainString() + ", which is less than the amount to transfer.");
|
||||
}
|
||||
|
||||
final JsonArray debits = new JsonArray();
|
||||
final JsonObject debit = new JsonObject();
|
||||
debit.addProperty("glAccountId", toAccountId);
|
||||
debit.addProperty("amount", amount);
|
||||
debits.add(debit);
|
||||
|
||||
final JsonArray credits = new JsonArray();
|
||||
final JsonObject credit = new JsonObject();
|
||||
credit.addProperty("glAccountId", fromAccountId);
|
||||
credit.addProperty("amount", amount);
|
||||
credits.add(credit);
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("officeId", officeId == null ? 1L : officeId);
|
||||
payload.addProperty("transactionDate", today.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")));
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("currencyCode", "INR");
|
||||
payload.addProperty("comments", note == null || note.isBlank() ? "Reserve transfer" : note);
|
||||
if (reference != null && !reference.isBlank()) {
|
||||
payload.addProperty("referenceNumber", reference);
|
||||
}
|
||||
payload.add("debits", debits);
|
||||
payload.add("credits", credits);
|
||||
|
||||
final CommandWrapper command = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString())
|
||||
.build();
|
||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("transactionId", result == null ? null : result.getTransactionId());
|
||||
response.put("fromGlAccountId", fromAccountId);
|
||||
response.put("toGlAccountId", toAccountId);
|
||||
response.put("amount", amount);
|
||||
response.put("transactionDate", today.toString());
|
||||
return this.gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL accounts the cash reserve may be moved between - every account mapped to a head item X
|
||||
* reads, plus the correspondent balances item VIII counts.
|
||||
*
|
||||
* <p>
|
||||
* Read from the configuration rather than listed here, so a bank that opens another current
|
||||
* account and maps it can move money into it without this endpoint being changed.
|
||||
*/
|
||||
private Set<Long> formOneReserveAccountIds() {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT DISTINCT c.gl_account_id AS id
|
||||
FROM m_rbi_reserve_gl_config c
|
||||
JOIN acc_gl_account a ON a.id = c.gl_account_id
|
||||
WHERE a.disabled = false
|
||||
AND c.config_key IN ('CASH_IN_HAND', 'FORM1_VI_A_RBI_CURRENT', 'FORM1_VI_B_STATE_COOP_CURRENT',
|
||||
'FORM1_VI_C_DCCB_CURRENT', 'FORM1_III_A_SBI_CURRENT')
|
||||
""");
|
||||
final Set<Long> ids = new HashSet<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
ids.add(((Number) row.get("id")).longValue());
|
||||
}
|
||||
// The RBI current account the return leaves unmapped is still somewhere money can be sent,
|
||||
// and the position screen lists it for that reason. Admitted here on the same terms.
|
||||
final List<Map<String, Object>> rbi = jdbcTemplate.queryForList("""
|
||||
SELECT a.id FROM acc_gl_account a
|
||||
WHERE a.classification_enum = 1 AND a.account_usage = 1 AND a.disabled = false
|
||||
AND (UPPER(a.name) LIKE '%RESERVE BANK%' OR UPPER(a.name) LIKE '%RBI%')
|
||||
""");
|
||||
for (final Map<String, Object> row : rbi) {
|
||||
ids.add(((Number) row.get("id")).longValue());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@GET
|
||||
@Path("form1-statutory")
|
||||
public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam,
|
||||
@@ -1252,6 +1665,10 @@ public class RbiReserveReturnApiResource {
|
||||
response.put("partB", formOnePartB(columnDates, baseDates, officeId, crr));
|
||||
response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr));
|
||||
|
||||
// The working behind item XII(c): what the securities opened at, what moved inside the
|
||||
// fortnight, and what they closed at. The compliance sheet states one figure; this is it.
|
||||
response.put("securities", formOneSecurities(columnDates, officeId));
|
||||
|
||||
// Every day of the month the closes fall in, charged on each day's own base. The form
|
||||
// reports two closes; compliance turns on every day between them.
|
||||
response.put("daily", formOneDaily(previousClose.withDayOfMonth(1), currentClose, officeId, crr, slr));
|
||||
@@ -1301,37 +1718,46 @@ public class RbiReserveReturnApiResource {
|
||||
final List<BigDecimal> ib = formOneLine(lines, "(b) Time Liabilities to the banking system", "FORM1_I_B_TIME_BS",
|
||||
dates, officeId, 1);
|
||||
final List<BigDecimal> totalI = sumOf(iai, iaii, ib);
|
||||
lines.add(formOneTotal("TOTAL OF I : >>>>>>>>>>>", totalI));
|
||||
lines.add(withFormula(formOneTotal("TOTAL OF I : >>>>>>>>>>>", totalI), "I(a)(i) + I(a)(ii) + I(b)"));
|
||||
|
||||
lines.add(formOneHeading("II. LIABILITIES IN INDIA TO OTHERS"));
|
||||
// Demand and time come from the one DTL head, split by the class each mapped account
|
||||
// carries - the same split the fortnightly return reports.
|
||||
final List<BigDecimal> iiA = new ArrayList<>();
|
||||
final List<BigDecimal> iiB = new ArrayList<>();
|
||||
final List<List<Map<String, Object>>> iiASources = new ArrayList<>();
|
||||
final List<List<Map<String, Object>>> iiBSources = new ArrayList<>();
|
||||
for (final LocalDate date : dates) {
|
||||
final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", date, officeId);
|
||||
iiA.add(dtl.demandAmount);
|
||||
iiB.add(dtl.timeAmount);
|
||||
iiASources.add(sourcesOf(dtl, DEMAND));
|
||||
iiBSources.add(sourcesOf(dtl, TIME));
|
||||
}
|
||||
lines.add(formOneRow("(A) DEMAND LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiA, 1));
|
||||
lines.add(formOneRow("(B) TIME LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiB, 1));
|
||||
lines.add(withSources(formOneRow("(A) DEMAND LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiA, 1), iiASources));
|
||||
lines.add(withSources(formOneRow("(B) TIME LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiB, 1), iiBSources));
|
||||
final List<BigDecimal> totalII = sumOf(iiA, iiB);
|
||||
lines.add(formOneTotal("TOTAL OF II : >>>>>>>>>>>", totalII));
|
||||
lines.add(withFormula(formOneTotal("TOTAL OF II : >>>>>>>>>>>", totalII), "II(A) + II(B)"));
|
||||
|
||||
lines.add(formOneHeading("III. ASSETS IN INDIA WITH THE BANKING SYSTEM"));
|
||||
final List<BigDecimal> iiiA = formOneLine(lines,
|
||||
"(A) TOTAL OF CREDIT BALANCES IN CURRENT ACCOUNT maintained with S.B.I, Subsidiary Banks & corresponding new banks",
|
||||
"FORM1_III_A_SBI_CURRENT", dates, officeId, 1);
|
||||
lines.add(formOneHeading("(B) TOTAL OF OTHER ASSETS WITH THE BANKING SYSTEM, VIZ."));
|
||||
final List<BigDecimal> iiiBi = formOneLine(lines,
|
||||
"(i) Balances in all Accounts other than those included in item III (a) :",
|
||||
"FORM1_III_B_I_OTHER_BALANCES", dates, officeId, 2);
|
||||
final List<BigDecimal> iiiBii = formOneLine(lines, "(ii) Money at Call and Short notice :",
|
||||
"FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId, 2);
|
||||
final List<BigDecimal> iiiBiv = formOneLine(lines, "(iv) Advances & any other Assets :",
|
||||
"FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2);
|
||||
// The filed form carries the sub-total of III(b) against the (B) heading itself and
|
||||
// leaves (i), (ii) and (iv) blank beneath it, so the sub-lines are gathered first and
|
||||
// the heading row is the one that prints the figure.
|
||||
final List<BigDecimal> iiiBi = amountsFor("FORM1_III_B_I_OTHER_BALANCES", dates, officeId);
|
||||
final List<BigDecimal> iiiBii = amountsFor("FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId);
|
||||
final List<BigDecimal> iiiBiv = amountsFor("FORM1_III_B_IV_ADVANCES_BS", dates, officeId);
|
||||
lines.add(withSources(formOneRow("(B) TOTAL OF OTHER ASSETS WITH THE BANKING SYSTEM, VIZ.", null,
|
||||
sumOf(iiiBi, iiiBii, iiiBiv), 1),
|
||||
mergedSources(List.of("FORM1_III_B_I_OTHER_BALANCES", "FORM1_III_B_II_MONEY_AT_CALL_BS",
|
||||
"FORM1_III_B_IV_ADVANCES_BS"), dates, officeId, null)));
|
||||
lines.add(formOneHeading("(i) Balances in all Accounts other than those included in item III (a) :"));
|
||||
lines.add(formOneHeading("(ii) Money at Call and Short notice :"));
|
||||
lines.add(formOneHeading("(iv) Advances & any other Assets :"));
|
||||
final List<BigDecimal> totalIII = sumOf(iiiA, iiiBi, iiiBii, iiiBiv);
|
||||
lines.add(formOneTotal("TOTAL OF III : >>>>>>>>>>", totalIII));
|
||||
lines.add(withFormula(formOneTotal("TOTAL OF III : >>>>>>>>>>", totalIII), "III(A) + III(B)"));
|
||||
|
||||
// IV: the banking system nets out, and what is left is added to the liabilities to others.
|
||||
// Floored at zero per column - the form's own wording, "(I - III) + II if minus figure",
|
||||
@@ -1345,10 +1771,10 @@ public class RbiReserveReturnApiResource {
|
||||
// reported. (b) is the base: the same figure read at the earlier close, and the only one the
|
||||
// requirement is actually charged on. A form showing one IV reads as though the bank had to
|
||||
// hold a ratio of today's liabilities, which is not what either section says.
|
||||
lines.add(formOneTotal(
|
||||
lines.add(withFormula(formOneTotal(
|
||||
"IV. a) TOTAL (NET) DEMAND & TIME LIABILITIES FOR THE PURPOSE OF SEC.18 & 24 OF THE ACT = (I-III)+II IF MINUS"
|
||||
+ " FIGURE [CURRENT FORTNIGHT]",
|
||||
iv));
|
||||
iv), "(I - III) + II. The net is floored at zero before II is added"));
|
||||
final List<BigDecimal> ivBase = new ArrayList<>();
|
||||
final List<String> ivBaseDates = new ArrayList<>();
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
@@ -1365,6 +1791,7 @@ public class RbiReserveReturnApiResource {
|
||||
"IV. b) DEMAND AND TIME LIABILITY OF BASE DATE FOR THE PURPOSE OF SEC.18 & 24",
|
||||
ivBase);
|
||||
ivBaseRow.put("baseDates", ivBaseDates);
|
||||
withFormula(ivBaseRow, "The same net liabilities, struck on the base date shown in the column heading");
|
||||
lines.add(ivBaseRow);
|
||||
|
||||
final List<BigDecimal> v = formOneLine(lines, "V. CASH ON HAND :", "CASH_IN_HAND", dates, officeId, 0);
|
||||
@@ -1377,7 +1804,7 @@ public class RbiReserveReturnApiResource {
|
||||
final List<BigDecimal> viC = formOneLine(lines, "(c) Central Co.Op.Bank of the Dist.Concerned :",
|
||||
"FORM1_VI_C_DCCB_CURRENT", dates, officeId, 1);
|
||||
final List<BigDecimal> totalVI = sumOf(viA, viB, viC);
|
||||
lines.add(formOneTotal("TOTAL OF VI : >>>>>>>>>>", totalVI));
|
||||
lines.add(withFormula(formOneTotal("TOTAL OF VI : >>>>>>>>>>", totalVI), "VI(a) + VI(b) + VI(c)"));
|
||||
|
||||
lines.add(formOneHeading("VII. Balance Of all Other types With :"));
|
||||
final List<BigDecimal> viiA = formOneLine(lines, "(a) State Co.Op.Bank of the State Concerned :",
|
||||
@@ -1387,7 +1814,7 @@ public class RbiReserveReturnApiResource {
|
||||
final List<BigDecimal> viiC = formOneLine(lines, "(C) INVESTMENT OF GOVT. SECURITY",
|
||||
"FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1);
|
||||
final List<BigDecimal> totalVII = sumOf(viiA, viiB, viiC);
|
||||
lines.add(formOneTotal("TOTAL OF VII : >>>>>>>>>>", totalVII));
|
||||
lines.add(withFormula(formOneTotal("TOTAL OF VII : >>>>>>>>>>", totalVII), "VII(a) + VII(b) + VII(c)"));
|
||||
|
||||
// VIII: only the excess counts. Where the banking system's claim on this bank exceeds the
|
||||
// bank's own balance there is nothing to carry into the cash reserve, so it floors at zero.
|
||||
@@ -1395,7 +1822,7 @@ public class RbiReserveReturnApiResource {
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
viii.add(iiiA.get(i).subtract(iai.get(i)).max(BigDecimal.ZERO));
|
||||
}
|
||||
lines.add(formOneTotal("VIII. Net balance in current accounts i.e.excess of III (a) over I (a) (i) :", viii));
|
||||
lines.add(withFormula(formOneTotal("VIII. Net balance in current accounts i.e.excess of III (a) over I (a) (i) :", viii), "III(A) - I(a)(i), nil if negative"));
|
||||
|
||||
// The base liabilities are IV(b) above - it used to be repeated as a row of its own at the
|
||||
// foot of the part, which put the same figure on the form twice under two different names.
|
||||
@@ -1432,9 +1859,9 @@ public class RbiReserveReturnApiResource {
|
||||
// template's own wording says "Last Friday of the Second Preceding fortnight"; this bank's
|
||||
// base rule is the matching close of the previous month, so the line says IV(b) and leaves
|
||||
// the base date itself to the column heading rather than claiming a Friday it did not use.
|
||||
lines.add(formOneTotal("IX. " + (crr == null ? "CRR rate not set" : crr.percentage + " PERCENT") + " OF IV AS ON THE BASE DATE",
|
||||
required));
|
||||
lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED := V+VI+VIII", maintained));
|
||||
lines.add(withFormula(formOneTotal("IX. " + (crr == null ? "CRR rate not set" : crr.percentage + " PERCENT") + " OF IV AS ON THE BASE DATE",
|
||||
required), "CRR rate x IV(b), the net liabilities on the base date"));
|
||||
lines.add(withFormula(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED := V+VI+VIII", maintained), "V + VI + VIII"));
|
||||
|
||||
final Map<String, Object> part = new LinkedHashMap<>();
|
||||
part.put("title", "PART B : COMPLIANCE WITH SECTION 18. (Not applicable to scheduled state co-op. Bank)");
|
||||
@@ -1478,14 +1905,21 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
|
||||
// Charged on IV(b) like the cash reserve above, and named the same way.
|
||||
lines.add(formOneTotal("XI. " + (slr == null ? "SLR rate not set" : slr.percentage + " percent") + " (or a higher specified percentage) of IV as on the base date",
|
||||
required));
|
||||
lines.add(withFormula(formOneTotal("XI. " + (slr == null ? "SLR rate not set" : slr.percentage + " percent") + " (or a higher specified percentage) of IV as on the base date",
|
||||
required), "SLR rate x IV(b), the net liabilities on the base date"));
|
||||
lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED :"));
|
||||
lines.add(formOneRow("(a) Cash and other balances maintained in India i.e. X-IX+VII :", null, cashAndOther, 1));
|
||||
lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1));
|
||||
lines.add(formOneRow("(c) Unencombered approved Securites $$", "SLR_INVESTMENTS", securities, 1));
|
||||
lines.add(withSources(withFormula(
|
||||
formOneRow("(a) Cash and other balances maintained in India i.e. X-IX+VII :", null, cashAndOther, 1),
|
||||
"Excess of X over IX (cash reserve held above the section 18 requirement, nil if short) + VII(c) "
|
||||
+ "government securities"),
|
||||
mergedSources(List.of("FORM1_VII_C_GOVT_SECURITIES"), dates, officeId, null)));
|
||||
lines.add(withSources(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1),
|
||||
mergedSources(List.of("FORMA_SLR_GOLD"), dates, officeId, null)));
|
||||
lines.add(withSources(formOneRow("(c) Unencombered approved Securites $$", "SLR_INVESTMENTS", securities, 1),
|
||||
mergedSources(List.of("SLR_INVESTMENTS"), dates, officeId, null)));
|
||||
final List<BigDecimal> totalXII = sumOf(cashAndOther, gold, securities);
|
||||
lines.add(formOneTotal("Total of XII.....................................", totalXII));
|
||||
lines.add(withFormula(formOneTotal("Total of XII.....................................", totalXII),
|
||||
"XII(a) + XII(b) + XII(c)"));
|
||||
|
||||
final Map<String, Object> part = new LinkedHashMap<>();
|
||||
part.put("title", "PART C : COMPLIANCE WITH SECTION 24 (Not applicable to scheduled state co-op. banks)");
|
||||
@@ -1496,6 +1930,153 @@ public class RbiReserveReturnApiResource {
|
||||
return part;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Part C.XII.c - the SLR securities the return is backed by, with how they moved.
|
||||
*
|
||||
* <p>
|
||||
* The compliance sheet states one figure for securities held; this is the working behind it.
|
||||
* Each column reports a fortnight, and within a fortnight a holding is opened, added to, sold
|
||||
* out of and closed, so the closing figure alone cannot be checked - an inspector asks what
|
||||
* moved, not only what is left.
|
||||
*
|
||||
* <p>
|
||||
* The opening balance is the holding at the close of the fortnight before, and the additions
|
||||
* and deductions are the debits and credits posted inside the fortnight itself. Both are read
|
||||
* off the journal rather than stated, so the closing balance is arithmetic a reader can follow
|
||||
* from the ledger rather than a figure this return asserts.
|
||||
*
|
||||
* <p>
|
||||
* Face value and book value are both reported at the ledger's carrying amount, and depreciation
|
||||
* as nil: a securities GL holds one figure, already net of any provision made against it.
|
||||
* Reporting depreciation off the investment depreciation reserve would deduct it twice, since
|
||||
* that reserve is not held security by security.
|
||||
*/
|
||||
private Map<String, Object> formOneSecurities(final List<LocalDate> dates, final Long officeId) {
|
||||
final Map<String, Object> securities = new LinkedHashMap<>();
|
||||
securities.put("title", "Part C.XII.c - SLR securities");
|
||||
|
||||
// Government securities are Part I; approved securities that are not Government paper -
|
||||
// land mortgage debentures, trustee securities - are Part II. Gold is NOT either of them:
|
||||
// the form reports it on its own line at item XII(b), and counting it here would have the
|
||||
// sheet total to more than the securities line it is the working for.
|
||||
final List<Map<String, Object>> sections = new ArrayList<>();
|
||||
sections.add(formOneSecuritySection("Part - I (Government Securities)", "FORM1_VII_C_GOVT_SECURITIES", dates,
|
||||
officeId));
|
||||
sections.add(formOneSecuritySection("Part - II (Other Approved Securities)", "FORM1_XII_C_OTHER_APPROVED",
|
||||
dates, officeId));
|
||||
securities.put("sections", sections);
|
||||
|
||||
// The two parts together, which is the figure Part C item XII(c) reports.
|
||||
final List<BigDecimal> closing = new ArrayList<>();
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> section : sections) {
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<BigDecimal> sectionClosing = (List<BigDecimal>) section.get("closing");
|
||||
total = total.add(sectionClosing.get(i));
|
||||
}
|
||||
closing.add(total);
|
||||
}
|
||||
securities.put("closing", closing);
|
||||
return securities;
|
||||
}
|
||||
|
||||
/**
|
||||
* One part of the securities sheet - what it opened at, what moved, and what it closed at.
|
||||
*/
|
||||
private Map<String, Object> formOneSecuritySection(final String title, final String configKey,
|
||||
final List<LocalDate> dates, final Long officeId) {
|
||||
final Map<String, Object> section = new LinkedHashMap<>();
|
||||
section.put("title", title);
|
||||
section.put("configKey", configKey);
|
||||
|
||||
final List<BigDecimal> opening = new ArrayList<>();
|
||||
final List<BigDecimal> additions = new ArrayList<>();
|
||||
final List<BigDecimal> deductions = new ArrayList<>();
|
||||
final List<BigDecimal> closing = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
final LocalDate close = dates.get(i);
|
||||
// The fortnight a column reports runs from the close before it. For the first column
|
||||
// that close is one fortnight earlier, so the opening balance is a real position
|
||||
// rather than a zero that would report the whole holding as bought this period.
|
||||
final LocalDate openedOn = i == 0 ? previousFortnightClose(close) : dates.get(i - 1);
|
||||
|
||||
final BigDecimal[] moved = formOneSecurityMovement(configKey, openedOn, close, officeId);
|
||||
opening.add(figureFor(configKey, openedOn, officeId).amount);
|
||||
additions.add(moved[0]);
|
||||
deductions.add(moved[1]);
|
||||
closing.add(figureFor(configKey, close, officeId).amount);
|
||||
}
|
||||
|
||||
section.put("opening", opening);
|
||||
section.put("additions", additions);
|
||||
section.put("deductions", deductions);
|
||||
section.put("closing", closing);
|
||||
return section;
|
||||
}
|
||||
|
||||
/**
|
||||
* What was bought and what was sold inside a fortnight, read off the journal.
|
||||
*
|
||||
* <p>
|
||||
* Additions first and deductions second, both positive: the sheet carries them in separate
|
||||
* columns, and a filer copying between the two should not have to re-sign anything.
|
||||
*/
|
||||
private BigDecimal[] formOneSecurityMovement(final String configKey, final LocalDate after, final LocalDate upto,
|
||||
final Long officeId) {
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT a.id, c.weight_percentage
|
||||
FROM m_rbi_reserve_gl_config c
|
||||
JOIN acc_gl_account a ON a.id = c.gl_account_id
|
||||
WHERE c.config_key = ? AND a.disabled = false
|
||||
""", configKey);
|
||||
|
||||
BigDecimal additions = BigDecimal.ZERO;
|
||||
BigDecimal deductions = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> account : accounts) {
|
||||
final Long accountId = ((Number) account.get("id")).longValue();
|
||||
final BigDecimal weight = account.get("weight_percentage") == null ? HUNDRED
|
||||
: (BigDecimal) account.get("weight_percentage");
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS bought,
|
||||
COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS sold
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND entry_date > ? AND entry_date <= ?
|
||||
AND (reversed IS NULL OR reversed = false)
|
||||
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>(
|
||||
List.of(accountId, java.sql.Date.valueOf(after), java.sql.Date.valueOf(upto)));
|
||||
if (officeId != null) {
|
||||
sql.append(" AND office_id = ?");
|
||||
args.add(officeId);
|
||||
}
|
||||
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap(sql.toString(), args.toArray());
|
||||
final BigDecimal bought = (BigDecimal) row.get("bought");
|
||||
final BigDecimal sold = (BigDecimal) row.get("sold");
|
||||
final BigDecimal share = weight.divide(HUNDRED, 6, RoundingMode.HALF_UP);
|
||||
additions = additions.add(bought.multiply(share).setScale(2, RoundingMode.HALF_UP));
|
||||
deductions = deductions.add(sold.multiply(share).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
return new BigDecimal[] { additions, deductions };
|
||||
}
|
||||
|
||||
/**
|
||||
* The fortnight close before the one given - the 15th, or the month end going back. A column's
|
||||
* opening balance is read there.
|
||||
*/
|
||||
private LocalDate previousFortnightClose(final LocalDate close) {
|
||||
if (close.getDayOfMonth() > 15) {
|
||||
return close.withDayOfMonth(15);
|
||||
}
|
||||
final LocalDate previousMonth = close.minusMonths(1);
|
||||
return previousMonth.withDayOfMonth(previousMonth.lengthOfMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day of the period the form covers, with what each ratio required that day and what was
|
||||
* held against it.
|
||||
@@ -1529,21 +2110,22 @@ public class RbiReserveReturnApiResource {
|
||||
|
||||
for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) {
|
||||
final BigDecimal base = formOneNetLiabilities(formOneBaseDate(day), officeId);
|
||||
final BigDecimal crrRequired = crr == null ? BigDecimal.ZERO
|
||||
: base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO
|
||||
: base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
|
||||
// The rate IN FORCE THAT DAY, not the one in force on the reporting date. Rates are
|
||||
// effective-dated, so a rate changed mid-month leaves the days before it charged at the
|
||||
// old rate and the days after at the new one. Charging the whole period at the closing
|
||||
// rate would restate days already settled - and on a return that is read to see whether
|
||||
// each day was met, every day before the change would be measured against a
|
||||
// requirement that did not exist yet.
|
||||
final Rate crrThatDay = rateInForce("CRR", day);
|
||||
final Rate slrThatDay = rateInForce("SLR", day);
|
||||
final BigDecimal crrRequired = crrThatDay == null ? BigDecimal.ZERO
|
||||
: base.multiply(crrThatDay.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
final BigDecimal slrRequired = slrThatDay == null ? BigDecimal.ZERO
|
||||
: base.multiply(slrThatDay.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
|
||||
final BigDecimal crrHeld = formOnePartBMaintained(day, officeId);
|
||||
// SLR counts the cash held OVER the section 18 requirement, plus the other balances and
|
||||
// the securities - the same definition Part C uses, so the two cannot disagree.
|
||||
final BigDecimal excessCash = crrHeld.subtract(crrRequired).max(BigDecimal.ZERO);
|
||||
final BigDecimal slrHeld = excessCash
|
||||
.add(figureFor("FORM1_VII_A_STATE_COOP_OTHER", day, officeId).amount)
|
||||
.add(figureFor("FORM1_VII_B_DCCB_OTHER", day, officeId).amount)
|
||||
.add(figureFor("FORM1_VII_C_GOVT_SECURITIES", day, officeId).amount)
|
||||
.add(figureFor("FORMA_SLR_GOLD", day, officeId).amount)
|
||||
.add(figureFor("SLR_INVESTMENTS", day, officeId).amount);
|
||||
final BigDecimal slrHeld = formOneSlrHeld(day, crrHeld, crrRequired, officeId);
|
||||
|
||||
final BigDecimal crrSurplus = crrHeld.subtract(crrRequired);
|
||||
final BigDecimal slrSurplus = slrHeld.subtract(slrRequired);
|
||||
@@ -1577,6 +2159,14 @@ public class RbiReserveReturnApiResource {
|
||||
// liabilities rose or fell on any particular day - every row of the fortnight reads the
|
||||
// same. This moves daily, and the gap between the two is the lag the rules build in.
|
||||
row.put("ownLiabilities", formOneNetLiabilities(day, officeId));
|
||||
// The rate each requirement was struck at, and the date it came into force. Stated per
|
||||
// day because a rate changed mid-period leaves the sheet carrying two of them, and a
|
||||
// reader checking a day's requirement against the rate shown at the top of the screen
|
||||
// would find arithmetic that does not work for the days on the other side of the change.
|
||||
row.put("crrPercentage", crrThatDay == null ? null : crrThatDay.percentage);
|
||||
row.put("crrEffectiveFrom", crrThatDay == null ? null : crrThatDay.effectiveFrom.toString());
|
||||
row.put("slrPercentage", slrThatDay == null ? null : slrThatDay.percentage);
|
||||
row.put("slrEffectiveFrom", slrThatDay == null ? null : slrThatDay.effectiveFrom.toString());
|
||||
row.put("crrRequired", crrRequired);
|
||||
row.put("crrMaintained", crrHeld);
|
||||
row.put("crrSurplus", crrSurplus);
|
||||
@@ -1723,18 +2313,88 @@ public class RbiReserveReturnApiResource {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts behind one column of a printed line - what the screen lists when a reader
|
||||
* rests on a figure to see where it came from.
|
||||
*
|
||||
* @param liabilityClass
|
||||
* when given, only the slices of that class: the one DTL head prints as two lines,
|
||||
* demand and time, and each must show only the accounts it was made of.
|
||||
*/
|
||||
private static List<Map<String, Object>> sourcesOf(final RoleFigure figure, final String liabilityClass) {
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> account : figure.accounts) {
|
||||
if (liabilityClass != null && !liabilityClass.equals(account.get("liabilityClass"))) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, Object> source = new LinkedHashMap<>();
|
||||
source.put("glCode", account.get("glCode"));
|
||||
source.put("name", account.get("name"));
|
||||
source.put("accountBalance", account.get("accountBalance"));
|
||||
source.put("weightPercentage", account.get("weightPercentage"));
|
||||
source.put("amount", account.get("balance"));
|
||||
source.put("excluded", account.get("excluded"));
|
||||
out.add(source);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sources for a line made of several heads, one list per column. */
|
||||
private List<List<Map<String, Object>>> mergedSources(final List<String> configKeys, final List<LocalDate> dates,
|
||||
final Long officeId, final String liabilityClass) {
|
||||
final List<List<Map<String, Object>>> columns = new ArrayList<>();
|
||||
for (final LocalDate date : dates) {
|
||||
final List<Map<String, Object>> column = new ArrayList<>();
|
||||
for (final String key : configKeys) {
|
||||
column.addAll(sourcesOf(figureFor(key, date, officeId), liabilityClass));
|
||||
}
|
||||
columns.add(column);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static Map<String, Object> withSources(final Map<String, Object> row,
|
||||
final List<List<Map<String, Object>>> sources) {
|
||||
row.put("sources", sources);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** How a figure with no account of its own is worked out, in words, for the same popup. */
|
||||
private static Map<String, Object> withFormula(final Map<String, Object> row, final String formula) {
|
||||
row.put("formula", formula);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Reads one head across every column and appends its row. */
|
||||
/**
|
||||
* The amounts a role carries across the reported dates, without adding a row for them.
|
||||
*
|
||||
* Used where the filed form prints a sub-total against a heading and leaves the lines
|
||||
* beneath it blank: the figures are still needed to build that total and to carry into
|
||||
* item IV, but each one must not appear as a row of its own.
|
||||
*/
|
||||
private List<BigDecimal> amountsFor(final String configKey, final List<LocalDate> dates, final Long officeId) {
|
||||
final List<BigDecimal> values = new ArrayList<>();
|
||||
for (final LocalDate date : dates) {
|
||||
values.add(figureFor(configKey, date, officeId).amount);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<BigDecimal> formOneLine(final List<Map<String, Object>> lines, final String label,
|
||||
final String configKey, final List<LocalDate> dates, final Long officeId, final int indent) {
|
||||
final List<BigDecimal> values = new ArrayList<>();
|
||||
final List<List<Map<String, Object>>> sources = new ArrayList<>();
|
||||
boolean configured = false;
|
||||
for (final LocalDate date : dates) {
|
||||
final RoleFigure figure = figureFor(configKey, date, officeId);
|
||||
configured |= figure.configured;
|
||||
values.add(figure.amount);
|
||||
sources.add(sourcesOf(figure, null));
|
||||
}
|
||||
final Map<String, Object> row = formOneRow(label, configKey, values, indent);
|
||||
row.put("configured", configured);
|
||||
withSources(row, sources);
|
||||
lines.add(row);
|
||||
return values;
|
||||
}
|
||||
@@ -1821,6 +2481,10 @@ public class RbiReserveReturnApiResource {
|
||||
// with nothing behind it.
|
||||
response.put("partA", formOnePartA(columnDates, baseDates, officeId, crr, slr));
|
||||
response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr));
|
||||
|
||||
// The working behind item XII(c): what the securities opened at, what moved inside the
|
||||
// fortnight, and what they closed at. The compliance sheet states one figure; this is it.
|
||||
response.put("securities", formOneSecurities(columnDates, officeId));
|
||||
// Every day of the month, as on Form 1. Section 24 is reckoned over the period too, so a
|
||||
// month-end position that passes says nothing about the days behind it.
|
||||
response.put("daily", formOneDaily(currentClose.withDayOfMonth(1), currentClose, officeId, crr, slr));
|
||||
@@ -2280,10 +2944,29 @@ public class RbiReserveReturnApiResource {
|
||||
}
|
||||
response.put("caveats", caveats);
|
||||
response.put("computable", true);
|
||||
// Item 12 as the bank filed it, where one is on record. Sent with the return, not left for
|
||||
// the screen to look up, so every user who may read the return sees the same figure rather
|
||||
// than one that depends on also holding the permission to read Global Configurations.
|
||||
final BigDecimal filedLoss = filedBalanceOfLoss(asOn);
|
||||
if (filedLoss != null) {
|
||||
response.put("filedBalanceOfLoss", filedLoss);
|
||||
}
|
||||
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Balance of Loss the bank filed for a Form IX date, in rupees thousands, from the global
|
||||
* configuration {@code form9-balance-of-loss-<yyyy-MM-dd>}. No GL carries that figure, so it is a
|
||||
* stated number kept as data, where Admin can see and change it.
|
||||
*/
|
||||
private BigDecimal filedBalanceOfLoss(final LocalDate asOn) {
|
||||
final List<Integer> rows = jdbcTemplate.queryForList(
|
||||
"SELECT value FROM c_configuration WHERE name = ? AND enabled = true AND value IS NOT NULL",
|
||||
Integer.class, "form9-balance-of-loss-" + asOn);
|
||||
return rows.isEmpty() ? null : BigDecimal.valueOf(rows.get(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rupees to rupees-thousands, rounded half-up.
|
||||
*
|
||||
|
||||
+20
-16
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
@@ -35,21 +37,18 @@ import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
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.api.ApiRequestParameterHelper;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/currentaccounts/{accountId}/charges")
|
||||
@@ -58,11 +57,12 @@ import org.springframework.stereotype.Component;
|
||||
@RequiredArgsConstructor
|
||||
public class CurrentAccountChargesApiResource {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DefaultToApiJsonSerializer<SavingsAccountChargeData> toApiJsonSerializer;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
||||
private final SavingsAccountChargeReadPlatformService savingsAccountChargeReadPlatformService;
|
||||
private final CurrentAccountReadPlatformService currentAccountReadPlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -73,11 +73,19 @@ public class CurrentAccountChargesApiResource {
|
||||
@DefaultValue("all") @QueryParam("chargeStatus") final String chargeStatus, @Context final UriInfo uriInfo) {
|
||||
|
||||
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_CHARGE_RESOURCE_NAME);
|
||||
final Collection<SavingsAccountChargeData> charges = savingsAccountChargeReadPlatformService
|
||||
.retrieveSavingsAccountCharges(accountId, chargeStatus);
|
||||
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
||||
return toApiJsonSerializer.serialize(settings, charges,
|
||||
SavingsApiSetConstants.SAVINGS_ACCOUNT_CHARGES_RESPONSE_DATA_PARAMETERS);
|
||||
// m_current_account_charge, not the savings charge table: the same id names a different account there.
|
||||
return GSON.toJson(currentAccountReadPlatformService.retrieveCharges(accountId, chargeStatus));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("template")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Charge options for adding a charge to a current account")
|
||||
@ApiResponse(responseCode = "200", description = "OK")
|
||||
public String retrieveTemplate(@PathParam("accountId") final Long accountId) {
|
||||
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_CHARGE_RESOURCE_NAME);
|
||||
return GSON.toJson(currentAccountReadPlatformService.retrieveChargeTemplate(accountId));
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -90,11 +98,7 @@ public class CurrentAccountChargesApiResource {
|
||||
@PathParam("chargeId") final Long chargeId, @Context final UriInfo uriInfo) {
|
||||
|
||||
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_CHARGE_RESOURCE_NAME);
|
||||
final SavingsAccountChargeData charge = savingsAccountChargeReadPlatformService.retrieveSavingsAccountChargeDetails(chargeId,
|
||||
accountId);
|
||||
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
||||
return toApiJsonSerializer.serialize(settings, charge,
|
||||
SavingsApiSetConstants.SAVINGS_ACCOUNT_CHARGES_RESPONSE_DATA_PARAMETERS);
|
||||
return GSON.toJson(currentAccountReadPlatformService.retrieveCharge(accountId, chargeId));
|
||||
}
|
||||
|
||||
@POST
|
||||
|
||||
+65
-2
@@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
@@ -62,6 +63,13 @@ import org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiReso
|
||||
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
|
||||
import org.apache.fineract.portfolio.note.api.NotesApiResource;
|
||||
import org.apache.fineract.portfolio.note.data.NoteCreateRequest;
|
||||
import org.apache.fineract.portfolio.note.data.NoteCreateResponse;
|
||||
import org.apache.fineract.portfolio.note.data.NoteData;
|
||||
import org.apache.fineract.portfolio.note.data.NoteDeleteResponse;
|
||||
import org.apache.fineract.portfolio.note.data.NoteUpdateRequest;
|
||||
import org.apache.fineract.portfolio.note.data.NoteUpdateResponse;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
||||
@@ -100,6 +108,7 @@ public class CurrentAccountsApiResource {
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
|
||||
private final DocumentApiResource documentApiResource;
|
||||
private final NotesApiResource notesApiResource;
|
||||
|
||||
/** Matches the {@code entityType} current-account document requests are stored/looked up under. */
|
||||
private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts";
|
||||
@@ -139,14 +148,16 @@ public class CurrentAccountsApiResource {
|
||||
@QueryParam("status") @Parameter(description = "status") final String status,
|
||||
@QueryParam("fromDate") @Parameter(description = "fromDate (YYYY-MM-DD)") final String fromDate,
|
||||
@QueryParam("toDate") @Parameter(description = "toDate (YYYY-MM-DD)") final String toDate,
|
||||
@QueryParam("asOnDate") @Parameter(description = "asOnDate (YYYY-MM-DD)") final String asOnDate) {
|
||||
@QueryParam("asOnDate") @Parameter(description = "asOnDate (YYYY-MM-DD)") final String asOnDate,
|
||||
@QueryParam("overdraftOnly") @Parameter(description = "only Cash Credit (overdraft) accounts") final Boolean overdraftOnly) {
|
||||
|
||||
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
|
||||
final List<Map<String, Object>> accounts = currentAccountReadPlatformService.retrieveAll(offset, limit, accountNo, clientName,
|
||||
status,
|
||||
fromDate != null ? LocalDate.parse(fromDate) : null,
|
||||
toDate != null ? LocalDate.parse(toDate) : null,
|
||||
asOnDate != null ? LocalDate.parse(asOnDate) : null);
|
||||
asOnDate != null ? LocalDate.parse(asOnDate) : null,
|
||||
Boolean.TRUE.equals(overdraftOnly));
|
||||
return GSON.toJson(accounts);
|
||||
}
|
||||
|
||||
@@ -406,4 +417,56 @@ public class CurrentAccountsApiResource {
|
||||
return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId);
|
||||
}
|
||||
|
||||
// ── Notes ────────────────────────────────────────────────────────────────
|
||||
// Same reason as Documents above: the generic /v1/{resourceType}/{resourceId}/notes resource is never
|
||||
// reached for current accounts, so these delegate to NotesApiResource (resource type "currentaccounts").
|
||||
|
||||
@GET
|
||||
@Path("{accountId}/notes")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List notes on a current account")
|
||||
@ApiResponse(responseCode = "200", description = "OK")
|
||||
public List<NoteData> retrieveNotes(@PathParam("accountId") final Long accountId) {
|
||||
return notesApiResource.retrieveNotesByResource(DOCUMENT_ENTITY_TYPE, accountId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{accountId}/notes/{noteId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve a note on a current account")
|
||||
@ApiResponse(responseCode = "200", description = "OK")
|
||||
public NoteData retrieveNote(@PathParam("accountId") final Long accountId, @PathParam("noteId") final Long noteId) {
|
||||
return notesApiResource.retrieveNote(DOCUMENT_ENTITY_TYPE, accountId, noteId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{accountId}/notes")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Add a note to a current account")
|
||||
@ApiResponse(responseCode = "200", description = "OK")
|
||||
public NoteCreateResponse addNote(@PathParam("accountId") final Long accountId, @Valid final NoteCreateRequest request) {
|
||||
return notesApiResource.addNewNote(DOCUMENT_ENTITY_TYPE, accountId, request);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{accountId}/notes/{noteId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update a note on a current account")
|
||||
@ApiResponse(responseCode = "200", description = "OK")
|
||||
public NoteUpdateResponse updateNote(@PathParam("accountId") final Long accountId, @PathParam("noteId") final Long noteId,
|
||||
@Valid final NoteUpdateRequest request) {
|
||||
return notesApiResource.updateNote(DOCUMENT_ENTITY_TYPE, accountId, noteId, request);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{accountId}/notes/{noteId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Delete a note on a current account")
|
||||
@ApiResponse(responseCode = "200", description = "OK")
|
||||
public NoteDeleteResponse deleteNote(@PathParam("accountId") final Long accountId, @PathParam("noteId") final Long noteId) {
|
||||
return notesApiResource.deleteNote(DOCUMENT_ENTITY_TYPE, accountId, noteId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+4
-4
@@ -22,7 +22,7 @@ import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -31,16 +31,16 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@CommandType(entity = "CURRENTACCOUNTCHARGE", action = "CREATE")
|
||||
public class AddCurrentAccountChargeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SavingsAccountWritePlatformService writePlatformService;
|
||||
private final CurrentAccountChargeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public AddCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) {
|
||||
public AddCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.addSavingsAccountCharge(command);
|
||||
return this.writePlatformService.addCharge(command.getSavingsId(), command);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -22,7 +22,7 @@ import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -31,16 +31,16 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@CommandType(entity = "CURRENTACCOUNTCHARGE", action = "DELETE")
|
||||
public class DeleteCurrentAccountChargeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SavingsAccountWritePlatformService writePlatformService;
|
||||
private final CurrentAccountChargeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public DeleteCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) {
|
||||
public DeleteCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.deleteSavingsAccountCharge(command.getSavingsId(), command.entityId(), command);
|
||||
return this.writePlatformService.deleteCharge(command.getSavingsId(), command.entityId());
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -31,10 +31,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@CommandType(entity = "CURRENTACCOUNTCHARGE", action = "INACTIVATE")
|
||||
public class InactivateCurrentAccountChargeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SavingsAccountWritePlatformService writePlatformService;
|
||||
private final CurrentAccountChargeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public InactivateCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) {
|
||||
public InactivateCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -31,10 +31,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@CommandType(entity = "CURRENTACCOUNTCHARGE", action = "PAY")
|
||||
public class PayCurrentAccountChargeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SavingsAccountWritePlatformService writePlatformService;
|
||||
private final CurrentAccountChargeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public PayCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) {
|
||||
public PayCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -22,7 +22,7 @@ import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -31,16 +31,16 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@CommandType(entity = "CURRENTACCOUNTCHARGE", action = "UPDATE")
|
||||
public class UpdateCurrentAccountChargeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SavingsAccountWritePlatformService writePlatformService;
|
||||
private final CurrentAccountChargeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) {
|
||||
public UpdateCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.updateSavingsAccountCharge(command);
|
||||
return this.writePlatformService.updateCharge(command.getSavingsId(), command.entityId(), command);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.CurrentAccountChargeWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -31,10 +31,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@CommandType(entity = "CURRENTACCOUNTCHARGE", action = "WAIVE")
|
||||
public class WaiveCurrentAccountChargeCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SavingsAccountWritePlatformService writePlatformService;
|
||||
private final CurrentAccountChargeWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public WaiveCurrentAccountChargeCommandHandler(final SavingsAccountWritePlatformService writePlatformService) {
|
||||
public WaiveCurrentAccountChargeCommandHandler(final CurrentAccountChargeWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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.security;
|
||||
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.ws.rs.Priorities;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.container.ContainerRequestFilter;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Keeps a branch user inside their own branch's savings, fixed deposit, recurring deposit and current accounts.
|
||||
*
|
||||
* <p>
|
||||
* Those four API families only checked that the caller holds the read/write permission for the resource type, never
|
||||
* whose account it is, so a branch user could open or transact on another branch's account just by naming its id. This
|
||||
* filter runs on every {@code /v1/<type>/{numericId}...} request (reads, commands, transactions, charges, documents,
|
||||
* notes) and applies the same office-hierarchy rule the client and group write services use
|
||||
* ({@link PlatformSecurityContext#validateAccessRights}): the account's office must sit at or below the caller's
|
||||
* office. Head Office is the root, so it still reaches every branch.
|
||||
*
|
||||
* <p>
|
||||
* It only guards HTTP requests. Scheduled jobs (maturity, interest posting, auto-renewal) call the services directly
|
||||
* and have no logged-in user, so they are untouched.
|
||||
*
|
||||
* <p>
|
||||
* An account that does not exist is left alone so the resource returns its own not-found; an account that exists in
|
||||
* another branch gets the same not-found answer ({@link AccountNotVisibleException}).
|
||||
*/
|
||||
@Provider
|
||||
@Component
|
||||
@Priority(Priorities.AUTHORIZATION)
|
||||
@RequiredArgsConstructor
|
||||
public class AccountBranchAccessFilter implements ContainerRequestFilter {
|
||||
|
||||
/** {@code v1/<family>/<numeric account id>[/anything]}. Non-numeric segments (dormant, gsim, dashboard...) never match. */
|
||||
private static final Pattern ACCOUNT_PATH = Pattern
|
||||
.compile("^/?v1/(fixeddepositaccounts|recurringdepositaccounts|savingsaccounts|currentaccounts)/(\\d+)(?:/.*)?$");
|
||||
|
||||
/**
|
||||
* FD, RD and savings all live in m_savings_account. The office is the branch the account is held at
|
||||
* (sa.office_id) when that was set, otherwise the client's, or the group's for a group account.
|
||||
*/
|
||||
private static final String SAVINGS_FAMILY_HIERARCHY_SQL = "SELECT o.hierarchy FROM m_savings_account sa "
|
||||
+ "LEFT JOIN m_client c ON c.id = sa.client_id LEFT JOIN m_group g ON g.id = sa.group_id "
|
||||
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
|
||||
|
||||
/** A current account carries its own office_id; fall back to the client's if it was left empty. */
|
||||
private static final String CURRENT_ACCOUNT_HIERARCHY_SQL = "SELECT o.hierarchy FROM m_current_account ca "
|
||||
+ "LEFT JOIN m_client c ON c.id = ca.client_id JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) "
|
||||
+ "WHERE ca.id = ?";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public void filter(final ContainerRequestContext requestContext) throws IOException {
|
||||
final Matcher matcher = ACCOUNT_PATH.matcher(requestContext.getUriInfo().getPath());
|
||||
if (!matcher.matches()) {
|
||||
return;
|
||||
}
|
||||
// No resolved user: authentication has already rejected the request or it is not ours to judge.
|
||||
if (this.context.getAuthenticatedUserIfPresent() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final boolean currentAccount = "currentaccounts".equals(matcher.group(1));
|
||||
final Long accountId = Long.valueOf(matcher.group(2));
|
||||
final String sql = currentAccount ? CURRENT_ACCOUNT_HIERARCHY_SQL : SAVINGS_FAMILY_HIERARCHY_SQL;
|
||||
|
||||
final String hierarchy = this.jdbcTemplate.query(sql, (rs, rowNum) -> rs.getString(1), accountId).stream().findFirst()
|
||||
.orElse(null);
|
||||
if (hierarchy == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.context.validateAccessRights(hierarchy);
|
||||
} catch (final NoAuthorizationException e) {
|
||||
throw new AccountNotVisibleException(accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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.security;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountConstant;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* One customer, one CIF, accounts at more than one branch.
|
||||
*
|
||||
* <p>
|
||||
* A customer is registered at a home branch, and an account normally belongs to that branch. To open an account at
|
||||
* another branch the customer first has to be <em>linked</em> to it ({@link #linkClientToCallerBranch}), which records
|
||||
* who introduced the customer to the branch and lets that branch see them. The account is then opened with an
|
||||
* {@code accountOfficeId} ({@link #applyAccountOffice}), which becomes the branch of its transactions and journal
|
||||
* entries. Without an {@code accountOfficeId} nothing here changes anything: the account stays at the client's branch.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccountBranchService {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final OfficeRepositoryWrapper officeRepository;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* The branch an account opened for this client is held at, or null when it is simply the client's own branch.
|
||||
*
|
||||
* <p>
|
||||
* Which branch: the one the request names, else - when the client belongs to a branch the caller is not part of
|
||||
* (a customer of Head Office being served at Bhopal) - the caller's own branch, so an account can never be
|
||||
* silently booked in the home branch's books by a branch that merely served the customer. A user can only open
|
||||
* an account at their own branch or one below it, and for a customer of another branch only once that customer
|
||||
* has been linked to the branch.
|
||||
*/
|
||||
public Office resolveAccountOffice(final Client client, final Long requestedOfficeId) {
|
||||
final Office callerOffice = this.context.authenticatedUser().getOffice();
|
||||
Long targetOfficeId = requestedOfficeId;
|
||||
if (targetOfficeId == null) {
|
||||
if (client.getOffice().getHierarchy().startsWith(callerOffice.getHierarchy())) {
|
||||
return null;
|
||||
}
|
||||
targetOfficeId = callerOffice.getId();
|
||||
}
|
||||
|
||||
final Office office = this.officeRepository.findOneWithNotFoundDetection(targetOfficeId);
|
||||
this.context.validateAccessRights(office.getHierarchy());
|
||||
|
||||
if (office.getId().equals(client.getOffice().getId())) {
|
||||
return null;
|
||||
}
|
||||
if (!isLinked(client.getId(), office.getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savings.account.branch.not.linked",
|
||||
"This customer is not linked to " + office.getName() + ". Link the customer to the branch before opening an account there.",
|
||||
SavingsAccountConstant.accountOfficeIdParamName, targetOfficeId);
|
||||
}
|
||||
return office;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a newly assembled account at the branch {@link #resolveAccountOffice} picks. Leaves it at the client's
|
||||
* branch in the ordinary case, so accounts opened the usual way are unchanged.
|
||||
*/
|
||||
public void applyAccountOffice(final SavingsAccount account, final JsonCommand command) {
|
||||
final Long requestedOfficeId = command.longValueOfParameterNamed(SavingsAccountConstant.accountOfficeIdParamName);
|
||||
final Client client = account.getClient();
|
||||
if (client == null) {
|
||||
if (requestedOfficeId != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savings.account.branch.client.only",
|
||||
"A branch can only be chosen for a client's account, not a group's.",
|
||||
SavingsAccountConstant.accountOfficeIdParamName, requestedOfficeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
account.setAccountOffice(resolveAccountOffice(client, requestedOfficeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (submitted, approved, active, ...) accounts a client already holds in one product at one branch.
|
||||
*
|
||||
* <p>
|
||||
* The rule that a client may hold one account per product used to be counted across the whole client. A customer
|
||||
* of one branch who opens the same product at another is a different account in a different branch's books, so
|
||||
* the count is per branch: it uses the account's own branch, falling back to the client's.
|
||||
*/
|
||||
public long countOpenAccountsAtBranch(final Long clientId, final Long productId, final Long officeId) {
|
||||
final Long count = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM m_savings_account sa JOIN m_client c ON c.id = sa.client_id "
|
||||
+ "WHERE sa.client_id = ? AND sa.product_id = ? AND sa.status_enum IN (100, 200, 300, 303, 304) "
|
||||
+ "AND COALESCE(sa.office_id, c.office_id) = ?",
|
||||
Long.class, clientId, productId, officeId);
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Introduces a customer of another branch to the caller's branch, so it can see them and open an account for them.
|
||||
* Idempotent. Returns false when there is nothing to do because the customer is already inside the caller's
|
||||
* hierarchy (their own branch's customer, or any customer to Head Office).
|
||||
*/
|
||||
@Transactional
|
||||
public boolean linkClientToCallerBranch(final Long clientId) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final Office callerOffice = user.getOffice();
|
||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
if (client.isNotActive()) {
|
||||
throw new ClientNotActiveException(clientId);
|
||||
}
|
||||
if (client.getOffice().getHierarchy().startsWith(callerOffice.getHierarchy())) {
|
||||
return false;
|
||||
}
|
||||
if (isLinked(clientId, callerOffice.getId())) {
|
||||
return false;
|
||||
}
|
||||
this.jdbcTemplate.update("INSERT INTO m_client_branch_link (client_id, office_id, linked_by, linked_on) VALUES (?, ?, ?, ?)", clientId,
|
||||
callerOffice.getId(), user.getId(), DateUtils.getAuditLocalDateTime());
|
||||
// Who introduced which customer to which branch: the trail Head Office reads to see where a customer is banked.
|
||||
log.info("client {} linked to branch {} by user {}", clientId, callerOffice.getId(), user.getUsername());
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean isLinked(final Long clientId, final Long officeId) {
|
||||
final Integer count = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM m_client_branch_link WHERE client_id = ? AND office_id = ?", Integer.class, clientId, officeId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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.security;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
/**
|
||||
* Raised when a branch user names an account that belongs to another branch. It is deliberately a "not found" rather
|
||||
* than a "forbidden": a 403 would confirm that the account exists, which is itself information a branch should not get
|
||||
* about another branch's customers.
|
||||
*/
|
||||
public class AccountNotVisibleException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public AccountNotVisibleException(final Long accountId) {
|
||||
super("error.msg.account.id.invalid", "Account with identifier " + accountId + " does not exist", accountId);
|
||||
}
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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.time.MonthDay;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
|
||||
import org.apache.fineract.accounting.common.AccountingRuleType;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
|
||||
import org.apache.fineract.accounting.journalentry.domain.JournalEntryType;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
|
||||
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||
import org.apache.fineract.portfolio.charge.domain.Charge;
|
||||
import org.apache.fineract.portfolio.charge.domain.ChargeAppliesTo;
|
||||
import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
|
||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct;
|
||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
|
||||
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Charges a teller adds to a current account by hand: add, edit, delete, pay, waive, inactivate.
|
||||
*
|
||||
* The CURRENTACCOUNTCHARGE commands used to go to the savings charge service with the current
|
||||
* account's id, so they read and wrote m_savings_account / m_savings_account_charge. That either
|
||||
* failed, or charged a same-numbered savings account and posted to that savings product's GL. The
|
||||
* fee never reached the current product's income account, and so never reached the year's profit.
|
||||
*
|
||||
* Everything here works on m_current_account_charge, the table the account's Charges tab reads.
|
||||
* Only paying moves money: the account is debited with a Charge Deduction and the ledger gets
|
||||
* Dr Current Control / Cr Income From Fees (Income From Penalties for a penalty, when mapped).
|
||||
* Waiving books nothing: on the cash basis the fee was never income, so there is nothing to undo.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CurrentAccountChargeWritePlatformService {
|
||||
|
||||
private final CurrentAccountRepository currentAccountRepository;
|
||||
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
||||
private final CurrentProductRepository currentProductRepository;
|
||||
private final ChargeRepositoryWrapper chargeRepository;
|
||||
private final JournalEntryRepository journalEntryRepository;
|
||||
private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
|
||||
@Transactional
|
||||
public CommandProcessingResult addCharge(final Long accountId, final JsonCommand command) {
|
||||
final CurrentAccount account = findAccount(accountId);
|
||||
if (account.getStatusEnum() == null || account.getStatusEnum() >= 600) {
|
||||
throw rule("error.msg.current.account.charge.account.closed", "Charges cannot be added to a closed current account.",
|
||||
accountId);
|
||||
}
|
||||
final Charge charge = chargeRepository.findOneWithNotFoundDetection(command.longValueOfParameterNamed("chargeId"));
|
||||
// Savings-type charges are accepted too: current accounts' facility charges are defined that way,
|
||||
// and a tenant may have no current-account-type charge at all.
|
||||
if (!ChargeAppliesTo.CURRENT_ACCOUNT.getValue().equals(charge.getChargeAppliesTo())
|
||||
&& !ChargeAppliesTo.SAVINGS.getValue().equals(charge.getChargeAppliesTo())) {
|
||||
throw rule("error.msg.current.account.charge.not.current.account.charge",
|
||||
"Charge '" + charge.getName() + "' is not defined for savings or current accounts.", charge.getId());
|
||||
}
|
||||
if (!charge.isActive() || charge.isDeleted()) {
|
||||
throw rule("error.msg.current.account.charge.inactive", "Charge '" + charge.getName() + "' is inactive.", charge.getId());
|
||||
}
|
||||
|
||||
final BigDecimal amount = command.parameterExists("amount") ? command.bigDecimalValueOfParameterNamed("amount")
|
||||
: charge.getAmount();
|
||||
requirePositive(amount, "amount");
|
||||
|
||||
final CurrentAccountCharge accountCharge = CurrentAccountCharge.createFromFacility(account, charge);
|
||||
accountCharge.setAmount(amount);
|
||||
accountCharge.setAmountOutstandingDerived(amount);
|
||||
if (command.parameterExists("dueDate")) {
|
||||
accountCharge.setChargeDueDate(command.localDateValueOfParameterNamed("dueDate"));
|
||||
}
|
||||
if (command.parameterExists("feeOnMonthDay")) {
|
||||
final MonthDay feeOnMonthDay = command.extractMonthDayNamed("feeOnMonthDay");
|
||||
if (feeOnMonthDay != null) {
|
||||
accountCharge.setFeeOnMonth(feeOnMonthDay.getMonthValue());
|
||||
accountCharge.setFeeOnDay(feeOnMonthDay.getDayOfMonth());
|
||||
}
|
||||
}
|
||||
if (command.parameterExists("feeInterval")) {
|
||||
accountCharge.setFeeInterval(command.integerValueOfParameterNamed("feeInterval"));
|
||||
}
|
||||
account.getCharges().add(accountCharge);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
|
||||
return result(account, accountCharge.getId(), null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CommandProcessingResult updateCharge(final Long accountId, final Long accountChargeId, final JsonCommand command) {
|
||||
final CurrentAccount account = findAccount(accountId);
|
||||
final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId);
|
||||
requireUntouched(accountCharge, "edited");
|
||||
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
if (command.parameterExists("amount")) {
|
||||
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount");
|
||||
requirePositive(amount, "amount");
|
||||
accountCharge.setAmount(amount);
|
||||
accountCharge.setAmountOutstandingDerived(amount);
|
||||
changes.put("amount", amount);
|
||||
}
|
||||
if (command.parameterExists("dueDate")) {
|
||||
final LocalDate dueDate = command.localDateValueOfParameterNamed("dueDate");
|
||||
accountCharge.setChargeDueDate(dueDate);
|
||||
changes.put("dueDate", dueDate);
|
||||
}
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
return result(account, accountChargeId, changes);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CommandProcessingResult deleteCharge(final Long accountId, final Long accountChargeId) {
|
||||
final CurrentAccount account = findAccount(accountId);
|
||||
final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId);
|
||||
requireUntouched(accountCharge, "deleted");
|
||||
account.getCharges().remove(accountCharge);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
return result(account, accountChargeId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the fee from the account and books it as the bank's income.
|
||||
*
|
||||
* The payment date is the teller's {@code dueDate}, defaulting to the business date, and may not
|
||||
* be in the future. The amount defaults to what is outstanding and may not exceed it.
|
||||
*/
|
||||
@Transactional
|
||||
public CommandProcessingResult payCharge(final Long accountId, final Long accountChargeId, final JsonCommand command) {
|
||||
final CurrentAccount account = findAccount(accountId);
|
||||
final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId);
|
||||
if (!accountCharge.isActive()) {
|
||||
throw rule("error.msg.current.account.charge.inactive", "An inactive charge cannot be paid.", accountChargeId);
|
||||
}
|
||||
final BigDecimal outstanding = zeroIfNull(accountCharge.getAmountOutstandingDerived());
|
||||
if (accountCharge.isPaid() || accountCharge.isWaived() || outstanding.signum() <= 0) {
|
||||
throw rule("error.msg.current.account.charge.nothing.outstanding", "Charge " + accountChargeId + " has nothing outstanding.",
|
||||
accountChargeId);
|
||||
}
|
||||
final BigDecimal amount = command.parameterExists("amount") ? command.bigDecimalValueOfParameterNamed("amount") : outstanding;
|
||||
requirePositive(amount, "amount");
|
||||
if (amount.compareTo(outstanding) > 0) {
|
||||
throw rule("error.msg.current.account.charge.overpayment",
|
||||
"Payment of " + amount + " exceeds the " + outstanding + " outstanding on charge " + accountChargeId + ".",
|
||||
accountChargeId);
|
||||
}
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate paymentDate = command.parameterExists("dueDate") && command.localDateValueOfParameterNamed("dueDate") != null
|
||||
? command.localDateValueOfParameterNamed("dueDate")
|
||||
: today;
|
||||
if (paymentDate.isAfter(today)) {
|
||||
throw rule("error.msg.current.account.charge.payment.date.in.future",
|
||||
"A charge cannot be paid on " + paymentDate + ", which is after the business date " + today + ".", accountChargeId);
|
||||
}
|
||||
|
||||
if (!account.isActive()) {
|
||||
throw rule("error.msg.account.not.active", "Current account " + accountId + " is not active.", accountId);
|
||||
}
|
||||
kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
final BigDecimal newBalance = assertBalanceCovers(account, amount);
|
||||
|
||||
final CurrentAccountTransaction txn = CurrentAccountTransaction.chargeDeduction(account, account.getOffice(), amount, paymentDate);
|
||||
txn.setRunningBalanceDerived(newBalance);
|
||||
currentAccountTransactionRepository.saveAndFlush(txn);
|
||||
account.setAccountBalanceDerived(newBalance);
|
||||
|
||||
final BigDecimal paid = zeroIfNull(accountCharge.getAmountPaidDerived()).add(amount);
|
||||
final BigDecimal remaining = outstanding.subtract(amount);
|
||||
accountCharge.setAmountPaidDerived(paid);
|
||||
accountCharge.setAmountOutstandingDerived(remaining);
|
||||
accountCharge.setPaid(remaining.signum() == 0);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
|
||||
postChargeIncome(account, accountCharge, txn);
|
||||
log.info("Paid current account charge {} ({}) of {} from account {}", accountChargeId, accountCharge.getCharge().getName(), amount,
|
||||
accountId);
|
||||
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
changes.put("amountPaid", amount);
|
||||
changes.put("transactionId", txn.getId());
|
||||
return result(account, accountChargeId, changes);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CommandProcessingResult waiveCharge(final Long accountId, final Long accountChargeId) {
|
||||
final CurrentAccount account = findAccount(accountId);
|
||||
final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId);
|
||||
final BigDecimal outstanding = zeroIfNull(accountCharge.getAmountOutstandingDerived());
|
||||
if (accountCharge.isWaived() || outstanding.signum() <= 0) {
|
||||
throw rule("error.msg.current.account.charge.nothing.outstanding", "Charge " + accountChargeId + " has nothing to waive.",
|
||||
accountChargeId);
|
||||
}
|
||||
accountCharge.setAmountWaivedDerived(zeroIfNull(accountCharge.getAmountWaivedDerived()).add(outstanding));
|
||||
accountCharge.setAmountOutstandingDerived(BigDecimal.ZERO);
|
||||
accountCharge.setWaived(true);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
changes.put("amountWaived", outstanding);
|
||||
return result(account, accountChargeId, changes);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CommandProcessingResult inactivateCharge(final Long accountId, final Long accountChargeId) {
|
||||
final CurrentAccount account = findAccount(accountId);
|
||||
final CurrentAccountCharge accountCharge = findCharge(account, accountChargeId);
|
||||
if (!accountCharge.isActive()) {
|
||||
throw rule("error.msg.current.account.charge.already.inactive", "Charge " + accountChargeId + " is already inactive.",
|
||||
accountChargeId);
|
||||
}
|
||||
accountCharge.setActive(false);
|
||||
accountCharge.setInactivatedOnDate(DateUtils.getBusinessLocalDate());
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
return result(account, accountChargeId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dr the product's Current Control (the bank owes the customer less), Cr its fee income — or its
|
||||
* penalty income for a penalty, where the product maps one. Skipped only when the product keeps no
|
||||
* accounts (rule NONE), the same as the module's other postings; a product that does keep
|
||||
* accounts but lacks a mapping fails the payment rather than debit the customer off the books.
|
||||
*/
|
||||
private void postChargeIncome(final CurrentAccount account, final CurrentAccountCharge accountCharge,
|
||||
final CurrentAccountTransaction txn) {
|
||||
final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null);
|
||||
if (product == null || product.getAccountingType() == null
|
||||
|| product.getAccountingType().equals(AccountingRuleType.NONE.getValue())) {
|
||||
log.debug("Current product {} keeps no accounts - charge {} posted no journal entry", account.getProductId(),
|
||||
accountCharge.getId());
|
||||
return;
|
||||
}
|
||||
final GLAccount control = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue());
|
||||
GLAccount income = accountCharge.isPenalty()
|
||||
? findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_PENALTIES.getValue())
|
||||
: null;
|
||||
if (income == null) {
|
||||
income = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue());
|
||||
}
|
||||
if (control == null || income == null) {
|
||||
throw rule("error.msg.current.account.charge.gl.not.mapped", "Current product '" + product.getName() + "' has no "
|
||||
+ (control == null ? "Current Control" : "Income From Fees") + " GL mapped. Map it on the product's Accounting tab.",
|
||||
account.getId());
|
||||
}
|
||||
|
||||
final String transactionRef = "CAC" + txn.getId();
|
||||
final String description = "Current account charge - " + accountCharge.getCharge().getName();
|
||||
postEntry(account, control, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), transactionRef, description);
|
||||
postEntry(account, income, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), transactionRef, description);
|
||||
}
|
||||
|
||||
private void postEntry(final CurrentAccount account, final GLAccount glAccount, final JournalEntryType entryType,
|
||||
final BigDecimal amount, final LocalDate date, final String transactionRef, final String description) {
|
||||
final JournalEntry entry = JournalEntry.createNew(account.getOffice(), null, glAccount, account.getCurrencyCode(), transactionRef,
|
||||
false, date, entryType, amount, description, PortfolioProductType.CURRENT.getValue(), account.getId(), null, null, null,
|
||||
null, null);
|
||||
journalEntryRepository.save(entry);
|
||||
}
|
||||
|
||||
private GLAccount findGlAccount(final Long productId, final int financialAccountType) {
|
||||
final ProductToGLAccountMapping mapping = productToGLAccountMappingRepository
|
||||
.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(productId, PortfolioProductType.CURRENT.getValue(),
|
||||
financialAccountType, null);
|
||||
return mapping != null ? mapping.getGlAccount() : null;
|
||||
}
|
||||
|
||||
/** The balance after the charge, once it is clear the account can bear it. Same rules as the cheque book charge. */
|
||||
private BigDecimal assertBalanceCovers(final CurrentAccount account, final BigDecimal amount) {
|
||||
final BigDecimal balance = zeroIfNull(account.getAccountBalanceDerived());
|
||||
final BigDecimal available = balance.subtract(zeroIfNull(account.getOnHoldFundsDerived()));
|
||||
if (!account.isAllowOverdraft() && amount.compareTo(available) > 0) {
|
||||
throw rule("error.msg.account.insufficient.balance", "Current account " + account.getId()
|
||||
+ " has insufficient available balance for a charge of " + amount + ". Available: " + available + ".", account.getId());
|
||||
}
|
||||
final BigDecimal newBalance = balance.subtract(amount);
|
||||
if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null
|
||||
&& account.getMinRequiredBalance().signum() > 0 && newBalance.compareTo(account.getMinRequiredBalance()) < 0) {
|
||||
throw rule("error.msg.current.account.charge.min.required.balance.breached",
|
||||
"A charge of " + amount + " would take current account " + account.getId() + " below its minimum required balance of "
|
||||
+ account.getMinRequiredBalance() + ".",
|
||||
account.getId());
|
||||
}
|
||||
return newBalance;
|
||||
}
|
||||
|
||||
private CurrentAccount findAccount(final Long accountId) {
|
||||
return currentAccountRepository.findById(accountId).orElseThrow(() -> new SavingsAccountNotFoundException(accountId));
|
||||
}
|
||||
|
||||
private CurrentAccountCharge findCharge(final CurrentAccount account, final Long accountChargeId) {
|
||||
return account.getCharges().stream().filter(c -> accountChargeId.equals(c.getId())).findFirst()
|
||||
.orElseThrow(() -> rule("error.msg.current.account.charge.not.found",
|
||||
"Charge " + accountChargeId + " was not found on current account " + account.getId() + ".", accountChargeId));
|
||||
}
|
||||
|
||||
/** Edits and deletes are only for a charge no money has touched; after that the record is history. */
|
||||
private void requireUntouched(final CurrentAccountCharge accountCharge, final String action) {
|
||||
if (zeroIfNull(accountCharge.getAmountPaidDerived()).signum() > 0 || zeroIfNull(accountCharge.getAmountWaivedDerived()).signum() > 0
|
||||
|| accountCharge.isPaid() || accountCharge.isWaived()) {
|
||||
throw rule("error.msg.current.account.charge.already.settled",
|
||||
"Charge " + accountCharge.getId() + " has been paid or waived and cannot be " + action + ".", accountCharge.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePositive(final BigDecimal amount, final String field) {
|
||||
if (amount == null || amount.signum() <= 0) {
|
||||
throw rule("error.msg.current.account.charge." + field + ".not.positive", "Charge " + field + " must be greater than zero.",
|
||||
amount);
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal zeroIfNull(final BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private static GeneralPlatformDomainRuleException rule(final String code, final String message, final Object arg) {
|
||||
return new GeneralPlatformDomainRuleException(code, message, arg);
|
||||
}
|
||||
|
||||
private CommandProcessingResult result(final CurrentAccount account, final Long accountChargeId, final Map<String, Object> changes) {
|
||||
final CommandProcessingResultBuilder builder = new CommandProcessingResultBuilder().withEntityId(accountChargeId)
|
||||
.withSavingsId(account.getId()).withOfficeId(account.getOffice() != null ? account.getOffice().getId() : null)
|
||||
.withClientId(account.getClient() != null ? account.getClient().getId() : null);
|
||||
if (changes != null) {
|
||||
builder.with(changes);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||
import org.apache.fineract.portfolio.note.domain.Note;
|
||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Saves the free-text "note" a user types on a current-account action screen (approve, reject, close, deposit...)
|
||||
* as a note on that account, so it shows in the account's Notes tab - the same thing savings and loans already do
|
||||
* with their action notes. The action is prefixed ("Approved: KYC complete") because, unlike a savings note, a
|
||||
* current-account note has no transaction or status link to say where it came from.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CurrentAccountNoteRecorder {
|
||||
|
||||
private static final String NOTE_PARAM = "note";
|
||||
// m_note.note is VARCHAR(1000)
|
||||
private static final int MAX_NOTE_LENGTH = 1000;
|
||||
|
||||
private final NoteRepository noteRepository;
|
||||
|
||||
/** Records the command's "note", if one was typed; a blank or missing note records nothing. */
|
||||
public void record(final CurrentAccount account, final String action, final JsonCommand command) {
|
||||
if (command == null || !command.parameterExists(NOTE_PARAM)) {
|
||||
return;
|
||||
}
|
||||
final String typed = StringUtils.trimToNull(command.stringValueOfParameterNamed(NOTE_PARAM));
|
||||
if (typed == null) {
|
||||
return;
|
||||
}
|
||||
final String text = StringUtils.abbreviate(action + ": " + typed, MAX_NOTE_LENGTH);
|
||||
this.noteRepository.save(Note.currentAccountNote(account, text));
|
||||
}
|
||||
}
|
||||
+92
-3
@@ -31,6 +31,7 @@ import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException;
|
||||
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -57,6 +58,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
||||
+ "LEFT JOIN m_currency curr ON curr.code = ca.currency_code ";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> retrieveOne(Long accountId) {
|
||||
@@ -280,6 +282,74 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> retrieveCharges(Long accountId) {
|
||||
return queryCharges("cac.current_account_id = ? AND cac.is_active = true", accountId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> retrieveCharges(final Long accountId, final String chargeStatus) {
|
||||
if ("active".equalsIgnoreCase(chargeStatus)) {
|
||||
return retrieveCharges(accountId);
|
||||
}
|
||||
if ("inactive".equalsIgnoreCase(chargeStatus)) {
|
||||
return queryCharges("cac.current_account_id = ? AND cac.is_active = false", accountId);
|
||||
}
|
||||
return queryCharges("cac.current_account_id = ?", accountId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> retrieveCharge(final Long accountId, final Long accountChargeId) {
|
||||
final List<Map<String, Object>> rows = queryCharges("cac.current_account_id = ? AND cac.id = ?", accountId, accountChargeId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new CurrentAccountNotFoundException(accountId);
|
||||
}
|
||||
// The charge detail page reads these alongside the list fields.
|
||||
final Map<String, Object> charge = rows.get(0);
|
||||
charge.put("accountId", accountId);
|
||||
charge.put("dueDate", charge.get("chargeDueDate"));
|
||||
charge.put("amountOrPercentage", charge.get("amount"));
|
||||
final List<Map<String, Object>> currency = jdbcTemplate.queryForList(
|
||||
"SELECT curr.code, curr.name, curr.display_symbol FROM m_current_account ca "
|
||||
+ "JOIN m_currency curr ON curr.code = ca.currency_code WHERE ca.id = ?",
|
||||
accountId);
|
||||
if (!currency.isEmpty()) {
|
||||
final Map<String, Object> c = new LinkedHashMap<>();
|
||||
c.put("code", currency.get(0).get("code"));
|
||||
c.put("name", currency.get(0).get("name"));
|
||||
c.put("displaySymbol", currency.get(0).get("display_symbol"));
|
||||
c.put("displayLabel", currency.get(0).get("name") + " (" + currency.get(0).get("display_symbol") + ")");
|
||||
charge.put("currency", c);
|
||||
}
|
||||
return charge;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> retrieveChargeTemplate(final Long accountId) {
|
||||
// charge_applies_to_enum 2 = SAVINGS, 5 = CURRENT_ACCOUNT: the charges the add-charge write accepts.
|
||||
final List<Map<String, Object>> options = jdbcTemplate.query(
|
||||
"SELECT ch.id, ch.name, ch.amount, ch.is_penalty, curr.code, curr.name AS currency_name, curr.display_symbol "
|
||||
+ "FROM m_current_account ca JOIN m_charge ch ON ch.currency_code = ca.currency_code "
|
||||
+ "LEFT JOIN m_currency curr ON curr.code = ch.currency_code "
|
||||
+ "WHERE ca.id = ? AND ch.charge_applies_to_enum IN (2, 5) AND ch.is_active = true AND ch.is_deleted = false "
|
||||
+ "ORDER BY ch.name",
|
||||
(rs, rowNum) -> {
|
||||
final Map<String, Object> option = new LinkedHashMap<>();
|
||||
option.put("id", rs.getLong("id"));
|
||||
option.put("name", rs.getString("name"));
|
||||
option.put("amount", rs.getBigDecimal("amount"));
|
||||
option.put("penalty", rs.getBoolean("is_penalty"));
|
||||
final Map<String, Object> currency = new LinkedHashMap<>();
|
||||
currency.put("code", rs.getString("code"));
|
||||
currency.put("name", rs.getString("currency_name"));
|
||||
currency.put("displaySymbol", rs.getString("display_symbol"));
|
||||
option.put("currency", currency);
|
||||
return option;
|
||||
}, accountId);
|
||||
final Map<String, Object> template = new LinkedHashMap<>();
|
||||
template.put("chargeOptions", options);
|
||||
return template;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> queryCharges(final String where, final Object... args) {
|
||||
final String sql =
|
||||
"SELECT cac.id, cac.charge_id, ch.name AS charge_name, "
|
||||
+ "cac.charge_time_enum, cac.charge_due_date, cac.fee_on_month, cac.fee_on_day, "
|
||||
@@ -289,7 +359,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
||||
+ "cac.is_paid_derived, cac.waived, cac.is_penalty, cac.is_active "
|
||||
+ "FROM m_current_account_charge cac "
|
||||
+ "JOIN m_charge ch ON ch.id = cac.charge_id "
|
||||
+ "WHERE cac.current_account_id = ? AND cac.is_active = true "
|
||||
+ "WHERE " + where + " "
|
||||
+ "ORDER BY cac.id";
|
||||
|
||||
return jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||
@@ -318,7 +388,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
||||
c.put("penalty", rs.getBoolean("is_penalty"));
|
||||
c.put("isActive", rs.getBoolean("is_active"));
|
||||
return c;
|
||||
}, accountId);
|
||||
}, args);
|
||||
}
|
||||
|
||||
private Map<String, Object> buildFeeOnMonthDay(Object month, Object day) {
|
||||
@@ -376,9 +446,28 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
||||
public List<Map<String, Object>> retrieveAll(final Integer offset, final Integer limit, final String accountNo,
|
||||
final String clientName, final String status, final LocalDate fromDate, final LocalDate toDate,
|
||||
final LocalDate asOnDate) {
|
||||
return retrieveAll(offset, limit, accountNo, clientName, status, fromDate, toDate, asOnDate, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> retrieveAll(final Integer offset, final Integer limit, final String accountNo,
|
||||
final String clientName, final String status, final LocalDate fromDate, final LocalDate toDate,
|
||||
final LocalDate asOnDate, final boolean overdraftOnly) {
|
||||
final StringBuilder sqlBuilder = new StringBuilder(BASE_SQL);
|
||||
final List<Object> params = new ArrayList<>();
|
||||
boolean hasWhere = false;
|
||||
|
||||
// Branch scope: the list used to return every branch's current accounts to any user. An account belongs
|
||||
// to the office it carries (ca.office_id), or its client's if that was left empty. Head Office is the
|
||||
// root of the hierarchy, so it still sees all branches.
|
||||
sqlBuilder.append(" WHERE EXISTS (SELECT 1 FROM m_office bo WHERE bo.id = COALESCE(ca.office_id, c.office_id) "
|
||||
+ "AND bo.hierarchy LIKE ?)");
|
||||
params.add(this.context.officeHierarchy() + "%");
|
||||
boolean hasWhere = true;
|
||||
|
||||
// A Cash Credit account is a current account that is allowed an overdraft.
|
||||
if (overdraftOnly) {
|
||||
sqlBuilder.append(" AND ca.allow_overdraft = true");
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(accountNo)) {
|
||||
sqlBuilder.append(hasWhere ? " AND " : " WHERE ");
|
||||
|
||||
+3
@@ -69,6 +69,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
private final AppUserRepositoryWrapper appUserRepositoryWrapper;
|
||||
private final CurrentAccountCashGlResolver cashGlResolver;
|
||||
private final CurrentAccountNoteRecorder noteRecorder;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -108,6 +109,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
|
||||
currentAccountRepository.save(account);
|
||||
|
||||
postTransactionGl(account, txn, JournalEntryType.DEBIT, JournalEntryType.CREDIT, "Current Account Deposit");
|
||||
noteRecorder.record(account, "Deposit of " + amount.toPlainString() + " (txn #" + txn.getId() + ")", command);
|
||||
|
||||
return new CommandProcessingResultBuilder()
|
||||
.withEntityId(txn.getId())
|
||||
@@ -171,6 +173,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
|
||||
|
||||
// Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR branch cash GL (cash out)
|
||||
postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal");
|
||||
noteRecorder.record(account, "Withdrawal of " + amount.toPlainString() + " (txn #" + txn.getId() + ")", command);
|
||||
|
||||
return new CommandProcessingResultBuilder()
|
||||
.withEntityId(txn.getId())
|
||||
|
||||
+12
-3
@@ -36,6 +36,7 @@ import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.savings.security.AccountBranchService;
|
||||
import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
|
||||
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig;
|
||||
@@ -77,6 +78,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
||||
private final JournalEntryRepository journalEntryRepository;
|
||||
private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository;
|
||||
private final CurrentAccountCashGlResolver cashGlResolver;
|
||||
private final CurrentAccountNoteRecorder noteRecorder;
|
||||
private final AccountBranchService accountBranchService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -99,9 +102,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
||||
"Client already has an active Current Account with the same product.",
|
||||
"productId", productId);
|
||||
}
|
||||
final Office office = officeId != null
|
||||
? officeRepository.findOneWithNotFoundDetection(officeId)
|
||||
: client.getOffice();
|
||||
// The branch named in the request, or the branch that is opening it for a customer of another branch, else the
|
||||
// client's own. The request's officeId used to be taken on trust, with no check it was the caller's to use.
|
||||
final Office resolvedOffice = accountBranchService.resolveAccountOffice(client, officeId);
|
||||
final Office office = resolvedOffice != null ? resolvedOffice : client.getOffice();
|
||||
|
||||
final String dateFormat = getString(json, "dateFormat");
|
||||
final String locale = getString(json, "locale");
|
||||
@@ -271,6 +275,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
||||
if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
account.approve(approvedOnDate, user);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
noteRecorder.record(account, "Approved", command);
|
||||
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
||||
}
|
||||
|
||||
@@ -283,6 +288,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
||||
account.setApprovedOnDate(null);
|
||||
account.setApprovedOnByUser(null);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
noteRecorder.record(account, "Approval undone", command);
|
||||
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
||||
}
|
||||
|
||||
@@ -317,6 +323,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
||||
if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
account.reject(rejectedOnDate, user);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
noteRecorder.record(account, "Rejected", command);
|
||||
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
||||
}
|
||||
|
||||
@@ -330,6 +337,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
||||
if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
account.withdraw(withdrawnOnDate, user);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
noteRecorder.record(account, "Withdrawn by client", command);
|
||||
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
||||
}
|
||||
|
||||
@@ -343,6 +351,7 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
||||
if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
account.close(closedOnDate, user);
|
||||
currentAccountRepository.saveAndFlush(account);
|
||||
noteRecorder.record(account, "Closed", command);
|
||||
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -115,6 +115,14 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
private static final RecurringDepositAccountMapper RECURRING_DEPOSIT_ACCOUNT_MAPPER = new RecurringDepositAccountMapper();
|
||||
private static final DepositAccountLookupMapper DEPOSIT_ACCOUNT_LOOKUP_MAPPER = new DepositAccountLookupMapper();
|
||||
private static final DepositAccountForMaturityMapper DEPOSIT_ACCOUNT_FOR_MATURITY_MAPPER = new DepositAccountForMaturityMapper();
|
||||
/**
|
||||
* Branch scope for the FD/RD lists: only accounts whose branch (the one it was opened at, else its client's or group's office) sits at or below the caller's
|
||||
* office. Head Office is the root, so it sees every branch. Applied to the API list methods only; the job-facing
|
||||
* methods (maturity update, interest transfer, running RD ids) deliberately stay unscoped because they have no
|
||||
* logged-in user.
|
||||
*/
|
||||
private static final String BRANCH_SCOPE_SQL = " and exists (select 1 from m_office bo where bo.id = coalesce(sa.office_id, c.office_id, g.office_id) and bo.hierarchy like ?) ";
|
||||
|
||||
// allowed column names for sorting the query result
|
||||
private static final Set<String> supportedOrderByValues = new HashSet<>(Arrays.asList("id", "accountNumbr", "officeId", "officeName"));
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
@@ -158,10 +166,12 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
sqlBuilder.append("select ");
|
||||
sqlBuilder.append(depositAccountMapper.schema());
|
||||
sqlBuilder.append(" where sa.deposit_type_enum = ? ");
|
||||
sqlBuilder.append(BRANCH_SCOPE_SQL);
|
||||
// always append at the end of a sql statement
|
||||
sqlBuilder.append(paginationParameters.paginationSql());
|
||||
|
||||
return jdbcTemplate.query(sqlBuilder.toString(), depositAccountMapper, new Object[] { depositAccountType.getValue() }); // NOSONAR
|
||||
return jdbcTemplate.query(sqlBuilder.toString(), depositAccountMapper,
|
||||
new Object[] { depositAccountType.getValue(), this.context.officeHierarchy() + "%" }); // NOSONAR
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -180,10 +190,11 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
|
||||
sqlBuilder.append(depositAccountMapper.schema());
|
||||
sqlBuilder.append(" where sa.deposit_type_enum = ? ");
|
||||
sqlBuilder.append(BRANCH_SCOPE_SQL);
|
||||
sqlBuilder.append(paginationParameters.paginationSql());
|
||||
|
||||
return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), new Object[] { depositAccountType.getValue() },
|
||||
depositAccountMapper);
|
||||
return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(),
|
||||
new Object[] { depositAccountType.getValue(), this.context.officeHierarchy() + "%" }, depositAccountMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -209,9 +220,11 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
|
||||
sqlBuilder.append(depositAccountMapper.schema());
|
||||
sqlBuilder.append(" where sa.deposit_type_enum = ? ");
|
||||
sqlBuilder.append(BRANCH_SCOPE_SQL);
|
||||
|
||||
final List<Object> params = new ArrayList<>();
|
||||
params.add(depositAccountType.getValue());
|
||||
params.add(this.context.officeHierarchy() + "%");
|
||||
|
||||
if (StringUtils.isNotBlank(accountNo)) {
|
||||
sqlBuilder.append(" and sa.account_no like ? ");
|
||||
|
||||
+6
@@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrap
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsProduct;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
|
||||
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
|
||||
import org.apache.fineract.portfolio.savings.security.AccountBranchService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -127,6 +128,7 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
|
||||
private final RecurringDepositProductRules recurringDepositProductRules;
|
||||
private final AccountBranchService accountBranchService;
|
||||
|
||||
/*
|
||||
* Guaranteed to throw an exception no matter what the data integrity issue is.
|
||||
@@ -185,6 +187,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
|
||||
// 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.
|
||||
// Held at the branch that opens it when the customer belongs to another branch; otherwise unchanged.
|
||||
this.accountBranchService.applyAccountOffice(account, command);
|
||||
|
||||
final MathContext mc = MathContext.DECIMAL64;
|
||||
final boolean isPreMatureClosure = false;
|
||||
@@ -262,6 +266,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
|
||||
// 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.
|
||||
// Held at the branch that opens it when the customer belongs to another branch; otherwise unchanged.
|
||||
this.accountBranchService.applyAccountOffice(account, command);
|
||||
|
||||
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
|
||||
|
||||
|
||||
+3
-2
@@ -271,7 +271,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
|
||||
sqlBuilder.append(this.savingAccountMapper.schema());
|
||||
|
||||
sqlBuilder.append(" join m_office o on o.id = c.office_id");
|
||||
// the branch the account is held at, else its client's: a customer of another branch can hold an account here
|
||||
sqlBuilder.append(" join m_office o on o.id = coalesce(sa.office_id, c.office_id)");
|
||||
sqlBuilder.append(" where o.hierarchy like ?");
|
||||
|
||||
final Object[] objectArray = new Object[15];
|
||||
@@ -300,7 +301,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
arrayPos = arrayPos + 1;
|
||||
}
|
||||
if (searchParameters.getOfficeId() != null) {
|
||||
sqlBuilder.append(" and c.office_id = ?");
|
||||
sqlBuilder.append(" and coalesce(sa.office_id, c.office_id) = ?");
|
||||
objectArray[arrayPos++] = searchParameters.getOfficeId();
|
||||
}
|
||||
if (StringUtils.isNotBlank(searchParameters.getAccountNo())) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user