Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa99c7ede0 | ||
|
|
dfb22a1898 | ||
|
|
22e4fa4b96 | ||
|
|
ceb8e97901 | ||
|
|
7d9c5563a4 | ||
|
|
43c60223af | ||
|
|
812eafc191 | ||
|
|
b8ebfb3aeb | ||
|
|
e95e981bcd | ||
|
|
3a320b9c09 | ||
|
|
8ba0420398 | ||
|
|
0191461102 | ||
|
|
4c40784a0e | ||
|
|
6cfa7ef3d1 | ||
|
|
6be32272c1 | ||
|
|
b47a34b5ef | ||
|
|
04d1eb20f0 |
+2
-2
@@ -113,7 +113,7 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper
|
final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element);
|
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue())
|
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue())
|
||||||
.value(incomeFromRecoveryAccountId).notNull().integerGreaterThanZero();
|
.value(incomeFromRecoveryAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||||
|
|
||||||
final Long writeOffAccountId = this.fromApiJsonHelper
|
final Long writeOffAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||||
@@ -128,7 +128,7 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
.extractLongNamed(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
baseDataValidator.reset().parameter(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
.value(transfersInSuspenseAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ public class Charge extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "currency_code", length = 3)
|
@Column(name = "currency_code", length = 3)
|
||||||
private String currencyCode;
|
private String currencyCode;
|
||||||
|
|
||||||
|
@Getter
|
||||||
@Column(name = "charge_applies_to_enum", nullable = false)
|
@Column(name = "charge_applies_to_enum", nullable = false)
|
||||||
private Integer chargeAppliesTo;
|
private Integer chargeAppliesTo;
|
||||||
|
|
||||||
|
|||||||
+2
@@ -42,6 +42,8 @@ public enum JobName {
|
|||||||
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
||||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
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_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||||
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
||||||
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit 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.
|
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
|
||||||
*/
|
*/
|
||||||
private final Integer renewalCycleNo;
|
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,
|
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 String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
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.fdAccountNumber = fdAccountNumber;
|
||||||
this.renewalCycleNo = renewalCycleNo;
|
this.renewalCycleNo = renewalCycleNo;
|
||||||
|
this.officeId = officeId;
|
||||||
|
this.officeName = officeName;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.accountNo = accountNo;
|
this.accountNo = accountNo;
|
||||||
this.externalId = externalId;
|
this.externalId = externalId;
|
||||||
|
|||||||
+2
-12
@@ -20,14 +20,12 @@ package org.apache.fineract.portfolio.aml.domain;
|
|||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
|
||||||
@@ -40,11 +38,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
|||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "m_aml_recurring_review")
|
@Table(name = "m_aml_recurring_review")
|
||||||
public class AmlRecurringReview {
|
public class AmlRecurringReview extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(optional = false)
|
@ManyToOne(optional = false)
|
||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
@@ -107,10 +101,6 @@ public class AmlRecurringReview {
|
|||||||
this.alertsActioned = count;
|
this.alertsActioned = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Client getClient() {
|
public Client getClient() {
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
@Column(name = "aadhaar_number_masked", length = 12)
|
||||||
private String aadhaarNumberMasked;
|
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)
|
@Column(name = "aadhaar_verified", nullable = false)
|
||||||
private boolean aadhaarVerified;
|
private boolean aadhaarVerified;
|
||||||
|
|
||||||
@@ -391,6 +398,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
command.stringValueOfParameterNamed("aadhaarNumberMasked"),
|
command.stringValueOfParameterNamed("aadhaarNumberMasked"),
|
||||||
command.stringValueOfParameterNamed("gstinNumber"),
|
command.stringValueOfParameterNamed("gstinNumber"),
|
||||||
command.stringValueOfParameterNamed("remarks"));
|
command.stringValueOfParameterNamed("remarks"));
|
||||||
|
kyc.aadhaarHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed("aadhaarHash"));
|
||||||
kyc.applyExtendedFields(command);
|
kyc.applyExtendedFields(command);
|
||||||
return kyc;
|
return kyc;
|
||||||
}
|
}
|
||||||
@@ -493,6 +501,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
actualChanges.put(aadhaarParam, newValue);
|
actualChanges.put(aadhaarParam, newValue);
|
||||||
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
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";
|
final String gstinParam = "gstinNumber";
|
||||||
@@ -1050,6 +1073,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return aadhaarNumberMasked;
|
return aadhaarNumberMasked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getAadhaarHash() {
|
||||||
|
return aadhaarHash;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isAadhaarVerified() {
|
public boolean isAadhaarVerified() {
|
||||||
return aadhaarVerified;
|
return aadhaarVerified;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -27,6 +27,13 @@ public interface ClientKycContactLogRepository extends JpaRepository<ClientKycCo
|
|||||||
|
|
||||||
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
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
|
* 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.
|
* "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")
|
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
||||||
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
|
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 ───────────────────────────────────────────
|
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
||||||
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
// 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
|
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
||||||
|
|||||||
+12
@@ -110,11 +110,23 @@ public class ClientRiskRule {
|
|||||||
@Column(name = "rule_kind", length = 10)
|
@Column(name = "rule_kind", length = 10)
|
||||||
private String ruleKind;
|
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;
|
||||||
|
|
||||||
public static final String KIND_KYC = "KYC";
|
public static final String KIND_KYC = "KYC";
|
||||||
public static final String KIND_AML = "AML";
|
public static final String KIND_AML = "AML";
|
||||||
|
|
||||||
protected ClientRiskRule() {}
|
protected ClientRiskRule() {}
|
||||||
|
|
||||||
|
public boolean isCcRelevant() {
|
||||||
|
return ccRelevant;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
* 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.
|
* 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.
|
* @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only.
|
||||||
* Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY).
|
* 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,
|
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
|
@Override
|
||||||
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
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 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
|
* this point the upgrade is too costly
|
||||||
@@ -62,6 +62,9 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
|||||||
*/
|
*/
|
||||||
String hierarchyLikeValue = officeHierarchy + "%";
|
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();
|
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
|
||||||
CriteriaQuery<SearchedClient> query = cb.createQuery(SearchedClient.class);
|
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);
|
Join<Client, ClientIdentifier> identity = r.join("identifiers", JoinType.LEFT);
|
||||||
|
|
||||||
List<Predicate> predicates = new ArrayList<>();
|
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 + "%";
|
String searchLikeValue = "%" + searchText + "%";
|
||||||
predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue),
|
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));
|
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]));
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
};
|
};
|
||||||
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
|
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
|
||||||
@@ -108,4 +117,27 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
|||||||
|
|
||||||
return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -46,17 +46,27 @@ public class DelinquencyRange extends AbstractAuditableWithUTCDateTimeCustom<Lon
|
|||||||
@Column(name = "max_age_days", nullable = true)
|
@Column(name = "max_age_days", nullable = true)
|
||||||
private Integer maximumAgeDays;
|
private Integer maximumAgeDays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which ECL stage (1, 2 or 3) this range maps to under the RBI 2026 Directions. Nullable for ranges created
|
||||||
|
* before this column existed; a range with no stage is treated as informational only and does not drive
|
||||||
|
* staging or provisioning.
|
||||||
|
*/
|
||||||
|
@Column(name = "stage", nullable = true)
|
||||||
|
private Integer stage;
|
||||||
|
|
||||||
@Version
|
@Version
|
||||||
private Long version;
|
private Long version;
|
||||||
|
|
||||||
protected DelinquencyRange(@NotNull String classification, @NotNull Integer minimumAgeDays, Integer maximumAgeDays) {
|
protected DelinquencyRange(@NotNull String classification, @NotNull Integer minimumAgeDays, Integer maximumAgeDays, Integer stage) {
|
||||||
this.classification = classification;
|
this.classification = classification;
|
||||||
this.minimumAgeDays = minimumAgeDays;
|
this.minimumAgeDays = minimumAgeDays;
|
||||||
this.maximumAgeDays = maximumAgeDays;
|
this.maximumAgeDays = maximumAgeDays;
|
||||||
|
this.stage = stage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static DelinquencyRange instance(@NotNull String classification, @NotNull Integer minimumAge, Integer maximumAge) {
|
public static DelinquencyRange instance(@NotNull String classification, @NotNull Integer minimumAge, Integer maximumAge,
|
||||||
return new DelinquencyRange(classification, minimumAge, maximumAge);
|
Integer stage) {
|
||||||
|
return new DelinquencyRange(classification, minimumAge, maximumAge, stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public enum NoteType {
|
|||||||
GROUP(600, "noteType.group", "groups", "Group note"), //
|
GROUP(600, "noteType.group", "groups", "Group note"), //
|
||||||
SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), //
|
SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), //
|
||||||
SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction 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();
|
public static final NoteType[] VALUES = values();
|
||||||
|
|||||||
+8
@@ -28,6 +28,14 @@ public interface CurrentAccountReadPlatformService {
|
|||||||
|
|
||||||
Map<String, Object> retrieveOneTransaction(Long accountId, Long transactionId);
|
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();
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
+17
@@ -53,12 +53,20 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
|
|||||||
paymentTypeRepositoryWrapper, codeValueRepository);
|
paymentTypeRepositoryWrapper, codeValueRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only the GLs the request names are mapped; the bank maps nine and leaves the other placeholders unmapped. */
|
||||||
|
private boolean isMapped(final JsonElement element, final String paramName) {
|
||||||
|
return this.fromApiJsonHelper.extractLongNamed(paramName, element) != null;
|
||||||
|
}
|
||||||
|
|
||||||
/***
|
/***
|
||||||
* Set of abstractions for saving Loan Products to GL Account Mappings
|
* Set of abstractions for saving Loan Products to GL Account Mappings
|
||||||
***/
|
***/
|
||||||
|
|
||||||
public void saveLoanToAssetAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
public void saveLoanToAssetAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
||||||
final int placeHolderTypeId) {
|
final int placeHolderTypeId) {
|
||||||
|
if (!isMapped(element, paramName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.ASSET, PortfolioProductType.LOAN);
|
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.ASSET, PortfolioProductType.LOAN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,16 +80,25 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
|
|||||||
|
|
||||||
public void saveLoanToIncomeAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
public void saveLoanToIncomeAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
||||||
final int placeHolderTypeId) {
|
final int placeHolderTypeId) {
|
||||||
|
if (!isMapped(element, paramName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.INCOME, PortfolioProductType.LOAN);
|
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.INCOME, PortfolioProductType.LOAN);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveLoanToExpenseAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
public void saveLoanToExpenseAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
||||||
final int placeHolderTypeId) {
|
final int placeHolderTypeId) {
|
||||||
|
if (!isMapped(element, paramName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.EXPENSE, PortfolioProductType.LOAN);
|
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.EXPENSE, PortfolioProductType.LOAN);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveLoanToLiabilityAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
public void saveLoanToLiabilityAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
||||||
final int placeHolderTypeId) {
|
final int placeHolderTypeId) {
|
||||||
|
if (!isMapped(element, paramName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.LIABILITY, PortfolioProductType.LOAN);
|
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.LIABILITY, PortfolioProductType.LOAN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -29,6 +29,7 @@ public final class DelinquencyApiConstants {
|
|||||||
public static final String RANGES_PARAM_NAME = "ranges";
|
public static final String RANGES_PARAM_NAME = "ranges";
|
||||||
public static final String MINIMUMAGEDAYS_PARAM_NAME = "minimumAgeDays";
|
public static final String MINIMUMAGEDAYS_PARAM_NAME = "minimumAgeDays";
|
||||||
public static final String MAXIMUMAGEDAYS_PARAM_NAME = "maximumAgeDays";
|
public static final String MAXIMUMAGEDAYS_PARAM_NAME = "maximumAgeDays";
|
||||||
|
public static final String STAGE_PARAM_NAME = "stage";
|
||||||
public static final String LOCALE_PARAM_NAME = "locale";
|
public static final String LOCALE_PARAM_NAME = "locale";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -35,5 +35,6 @@ public class DelinquencyRangeRequest implements Serializable {
|
|||||||
private String classification;
|
private String classification;
|
||||||
private Integer minimumAgeDays;
|
private Integer minimumAgeDays;
|
||||||
private Integer maximumAgeDays;
|
private Integer maximumAgeDays;
|
||||||
|
private Integer stage;
|
||||||
private String locale;
|
private String locale;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -34,13 +34,15 @@ public class DelinquencyRangeData implements Serializable {
|
|||||||
private String classification;
|
private String classification;
|
||||||
private Integer minimumAgeDays;
|
private Integer minimumAgeDays;
|
||||||
private Integer maximumAgeDays;
|
private Integer maximumAgeDays;
|
||||||
|
/** ECL stage (1, 2 or 3) this range maps to under the RBI 2026 Directions; null on ranges predating this field. */
|
||||||
|
private Integer stage;
|
||||||
|
|
||||||
public static DelinquencyRangeData instance(String classification, Integer minimumAgeDays, Integer maximumAgeDays) {
|
public static DelinquencyRangeData instance(String classification, Integer minimumAgeDays, Integer maximumAgeDays, Integer stage) {
|
||||||
return new DelinquencyRangeData(null, classification, minimumAgeDays, maximumAgeDays);
|
return new DelinquencyRangeData(null, classification, minimumAgeDays, maximumAgeDays, stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static DelinquencyRangeData reference(Long id) {
|
public static DelinquencyRangeData reference(Long id) {
|
||||||
return new DelinquencyRangeData(id, "", 0, 0);
|
return new DelinquencyRangeData(id, "", 0, 0, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -305,7 +305,7 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
|
|||||||
throw new DelinquencyRangeInvalidAgesException(errorMessage, data.getMinimumAgeDays(), data.getMaximumAgeDays());
|
throw new DelinquencyRangeInvalidAgesException(errorMessage, data.getMinimumAgeDays(), data.getMaximumAgeDays());
|
||||||
}
|
}
|
||||||
DelinquencyRange newDelinquencyRange = DelinquencyRange.instance(data.getClassification(), data.getMinimumAgeDays(),
|
DelinquencyRange newDelinquencyRange = DelinquencyRange.instance(data.getClassification(), data.getMinimumAgeDays(),
|
||||||
data.getMaximumAgeDays());
|
data.getMaximumAgeDays(), data.getStage());
|
||||||
return repositoryRange.saveAndFlush(newDelinquencyRange);
|
return repositoryRange.saveAndFlush(newDelinquencyRange);
|
||||||
} else {
|
} else {
|
||||||
throw new PlatformDataIntegrityException("error.msg.data.integrity.issue.entity.duplicated",
|
throw new PlatformDataIntegrityException("error.msg.data.integrity.issue.entity.duplicated",
|
||||||
@@ -327,6 +327,10 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
|
|||||||
delinquencyRange.setMaximumAgeDays(data.getMaximumAgeDays());
|
delinquencyRange.setMaximumAgeDays(data.getMaximumAgeDays());
|
||||||
changes.put(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, data.getMaximumAgeDays());
|
changes.put(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, data.getMaximumAgeDays());
|
||||||
}
|
}
|
||||||
|
if (!java.util.Objects.equals(data.getStage(), delinquencyRange.getStage())) {
|
||||||
|
delinquencyRange.setStage(data.getStage());
|
||||||
|
changes.put(DelinquencyApiConstants.STAGE_PARAM_NAME, data.getStage());
|
||||||
|
}
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
delinquencyRange = repositoryRange.saveAndFlush(delinquencyRange);
|
delinquencyRange = repositoryRange.saveAndFlush(delinquencyRange);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -56,7 +56,8 @@ public class DelinquencyRangeParseAndValidator extends ParseAndValidator {
|
|||||||
|
|
||||||
jsonHelper.checkForUnsupportedParameters(element,
|
jsonHelper.checkForUnsupportedParameters(element,
|
||||||
List.of(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME,
|
List.of(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME,
|
||||||
DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, DelinquencyApiConstants.LOCALE_PARAM_NAME));
|
DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, DelinquencyApiConstants.STAGE_PARAM_NAME,
|
||||||
|
DelinquencyApiConstants.LOCALE_PARAM_NAME));
|
||||||
|
|
||||||
final String localeValue = jsonHelper.extractStringNamed(DelinquencyApiConstants.LOCALE_PARAM_NAME, element);
|
final String localeValue = jsonHelper.extractStringNamed(DelinquencyApiConstants.LOCALE_PARAM_NAME, element);
|
||||||
dataValidator.reset().parameter(DelinquencyApiConstants.LOCALE_PARAM_NAME).value(localeValue).notBlank();
|
dataValidator.reset().parameter(DelinquencyApiConstants.LOCALE_PARAM_NAME).value(localeValue).notBlank();
|
||||||
@@ -65,13 +66,15 @@ public class DelinquencyRangeParseAndValidator extends ParseAndValidator {
|
|||||||
final String classification = jsonHelper.extractStringNamed(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, element);
|
final String classification = jsonHelper.extractStringNamed(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, element);
|
||||||
final Integer minimumAge = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME, element, locale);
|
final Integer minimumAge = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME, element, locale);
|
||||||
final Integer maximumAge = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, element, locale);
|
final Integer maximumAge = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, element, locale);
|
||||||
|
final Integer stage = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.STAGE_PARAM_NAME, element, locale);
|
||||||
dataValidator.reset().parameter(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME).value(classification).notBlank();
|
dataValidator.reset().parameter(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME).value(classification).notBlank();
|
||||||
dataValidator.reset().parameter(DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME).value(minimumAge).notBlank()
|
dataValidator.reset().parameter(DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME).value(minimumAge).notBlank()
|
||||||
.integerGreaterThanNumber(0);
|
.integerGreaterThanNumber(0);
|
||||||
dataValidator.reset().parameter(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME).value(maximumAge).ignoreIfNull()
|
dataValidator.reset().parameter(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME).value(maximumAge).ignoreIfNull()
|
||||||
.integerGreaterThanNumber(0);
|
.integerGreaterThanNumber(0);
|
||||||
|
dataValidator.reset().parameter(DelinquencyApiConstants.STAGE_PARAM_NAME).value(stage).ignoreIfNull().inMinMaxRange(1, 3);
|
||||||
|
|
||||||
return dataValidator.hasError() ? null : DelinquencyRangeData.instance(classification, minimumAge, maximumAge);
|
return dataValidator.hasError() ? null : DelinquencyRangeData.instance(classification, minimumAge, maximumAge, stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+172
@@ -137,6 +137,141 @@ public class LoanProductAdvanceExt {
|
|||||||
@Column(name = "npa_trigger")
|
@Column(name = "npa_trigger")
|
||||||
private String npaTrigger;
|
private String npaTrigger;
|
||||||
|
|
||||||
|
/** Months after disbursement before overdue days (SMA) start counting: Advances 9, Loans (EMI) 1. */
|
||||||
|
@Column(name = "sma_start_after_months")
|
||||||
|
private Integer smaStartAfterMonths;
|
||||||
|
|
||||||
|
/** CC/OD: stock statement older than this many days makes drawings an NPA trigger (RBI: 90). */
|
||||||
|
@Column(name = "stock_statement_validity_days")
|
||||||
|
private Integer stockStatementValidityDays;
|
||||||
|
|
||||||
|
// ---- Loan against Gold (pledged ornaments) ---------------------------
|
||||||
|
@Column(name = "gold_max_ltv_pct")
|
||||||
|
private BigDecimal goldMaxLtvPct;
|
||||||
|
|
||||||
|
@Column(name = "gold_min_purity_carat")
|
||||||
|
private Integer goldMinPurityCarat;
|
||||||
|
|
||||||
|
@Column(name = "gold_rate_source")
|
||||||
|
private String goldRateSource;
|
||||||
|
|
||||||
|
@Column(name = "gold_max_per_gram")
|
||||||
|
private BigDecimal goldMaxPerGram;
|
||||||
|
|
||||||
|
@Column(name = "gold_min_net_weight_g")
|
||||||
|
private BigDecimal goldMinNetWeightG;
|
||||||
|
|
||||||
|
@Column(name = "gold_max_net_weight_per_customer_g")
|
||||||
|
private BigDecimal goldMaxNetWeightPerCustomerG;
|
||||||
|
|
||||||
|
@Column(name = "gold_allowed_items")
|
||||||
|
private String goldAllowedItems;
|
||||||
|
|
||||||
|
@Column(name = "gold_appraiser_required")
|
||||||
|
private Boolean goldAppraiserRequired;
|
||||||
|
|
||||||
|
@Column(name = "gold_valuation_certificate_required")
|
||||||
|
private Boolean goldValuationCertificateRequired;
|
||||||
|
|
||||||
|
@Column(name = "gold_max_valuation_per_appraiser")
|
||||||
|
private BigDecimal goldMaxValuationPerAppraiser;
|
||||||
|
|
||||||
|
@Column(name = "gold_packet_prefix")
|
||||||
|
private String goldPacketPrefix;
|
||||||
|
|
||||||
|
@Column(name = "gold_auction_notice_days")
|
||||||
|
private Integer goldAuctionNoticeDays;
|
||||||
|
|
||||||
|
@Column(name = "gold_revalue_on_npa")
|
||||||
|
private Boolean goldRevalueOnNpa;
|
||||||
|
|
||||||
|
@Column(name = "gold_revaluation_charge_applicable")
|
||||||
|
private Boolean goldRevaluationChargeApplicable;
|
||||||
|
|
||||||
|
@Column(name = "gold_partial_release_allowed")
|
||||||
|
private Boolean goldPartialReleaseAllowed;
|
||||||
|
|
||||||
|
@Column(name = "gold_third_party_allowed")
|
||||||
|
private Boolean goldThirdPartyAllowed;
|
||||||
|
|
||||||
|
@Column(name = "gold_disbursement_mode")
|
||||||
|
private String goldDisbursementMode;
|
||||||
|
|
||||||
|
@Column(name = "gold_cash_disbursement_limit")
|
||||||
|
private BigDecimal goldCashDisbursementLimit;
|
||||||
|
|
||||||
|
@Column(name = "gold_ltv_breach_alert_pct")
|
||||||
|
private BigDecimal goldLtvBreachAlertPct;
|
||||||
|
|
||||||
|
@Column(name = "gold_auction_reserve_pct")
|
||||||
|
private BigDecimal goldAuctionReservePct;
|
||||||
|
|
||||||
|
@Column(name = "gold_redeem_before_auction")
|
||||||
|
private Boolean goldRedeemBeforeAuction;
|
||||||
|
|
||||||
|
// ---- Loan against Warehouse Receipt (pledged commodity) ----------------
|
||||||
|
@Column(name = "wr_max_ltv_pct")
|
||||||
|
private BigDecimal wrMaxLtvPct;
|
||||||
|
|
||||||
|
@Column(name = "wr_receipt_type")
|
||||||
|
private String wrReceiptType;
|
||||||
|
|
||||||
|
@Column(name = "wr_wdra_registered")
|
||||||
|
private Boolean wrWdraRegistered;
|
||||||
|
|
||||||
|
@Column(name = "wr_assay_certificate_required")
|
||||||
|
private Boolean wrAssayCertificateRequired;
|
||||||
|
|
||||||
|
@Column(name = "wr_tenure_within_receipt_validity")
|
||||||
|
private Boolean wrTenureWithinReceiptValidity;
|
||||||
|
|
||||||
|
@Column(name = "wr_pledge_marked_before_disbursement")
|
||||||
|
private Boolean wrPledgeMarkedBeforeDisbursement;
|
||||||
|
|
||||||
|
@Column(name = "wr_commodity_groups")
|
||||||
|
private String wrCommodityGroups;
|
||||||
|
|
||||||
|
@Column(name = "wr_valuation_price_source")
|
||||||
|
private String wrValuationPriceSource;
|
||||||
|
|
||||||
|
@Column(name = "wr_max_loan_per_quintal")
|
||||||
|
private BigDecimal wrMaxLoanPerQuintal;
|
||||||
|
|
||||||
|
@Column(name = "wr_stock_inspection_months")
|
||||||
|
private Integer wrStockInspectionMonths;
|
||||||
|
|
||||||
|
@Column(name = "wr_ltv_breach_alert_pct")
|
||||||
|
private BigDecimal wrLtvBreachAlertPct;
|
||||||
|
|
||||||
|
@Column(name = "wr_partial_release_allowed")
|
||||||
|
private Boolean wrPartialReleaseAllowed;
|
||||||
|
|
||||||
|
@Column(name = "wr_disposal_notice_days")
|
||||||
|
private Integer wrDisposalNoticeDays;
|
||||||
|
|
||||||
|
@Column(name = "wr_disposal_reserve_pct")
|
||||||
|
private BigDecimal wrDisposalReservePct;
|
||||||
|
|
||||||
|
@Column(name = "wr_redeem_before_disposal")
|
||||||
|
private Boolean wrRedeemBeforeDisposal;
|
||||||
|
|
||||||
|
// ---- common eligibility & document checklist (Advances and Loans) ----
|
||||||
|
@Column(name = "elig_member_required")
|
||||||
|
private Boolean memberRequired;
|
||||||
|
|
||||||
|
@Column(name = "elig_guarantor_must_be_member")
|
||||||
|
private Boolean guarantorMustBeMember;
|
||||||
|
|
||||||
|
@Column(name = "elig_income_multiple")
|
||||||
|
private BigDecimal incomeMultiple;
|
||||||
|
|
||||||
|
@Column(name = "elig_min_vintage_months")
|
||||||
|
private Integer minVintageMonths;
|
||||||
|
|
||||||
|
/** Comma separated document codes the borrower must submit for this product. */
|
||||||
|
@Column(name = "required_documents", length = 1000)
|
||||||
|
private String requiredDocuments;
|
||||||
|
|
||||||
@Column(name = "interest_after_npa")
|
@Column(name = "interest_after_npa")
|
||||||
private String interestAfterNpa;
|
private String interestAfterNpa;
|
||||||
|
|
||||||
@@ -464,6 +599,43 @@ public class LoanProductAdvanceExt {
|
|||||||
@Column(name = "loan_balance_transfer_out_allowed")
|
@Column(name = "loan_balance_transfer_out_allowed")
|
||||||
private Boolean balanceTransferOutAllowed;
|
private Boolean balanceTransferOutAllowed;
|
||||||
|
|
||||||
|
// ---- SICR & LGD (RBI 2026 ECL framework; floor % itself lives in the Prudential Floor master, not here) ----
|
||||||
|
@Column(name = "sicr_days_threshold")
|
||||||
|
private Integer sicrDaysThreshold;
|
||||||
|
|
||||||
|
@Column(name = "sicr_revolving_days_threshold")
|
||||||
|
private Integer sicrRevolvingDaysThreshold;
|
||||||
|
|
||||||
|
@Column(name = "sicr_rating_downgrade_notches")
|
||||||
|
private Integer sicrRatingDowngradeNotches;
|
||||||
|
|
||||||
|
@Column(name = "sicr_pricing_increase_trigger_pct")
|
||||||
|
private BigDecimal sicrPricingIncreaseTriggerPct;
|
||||||
|
|
||||||
|
@Column(name = "sicr_assessment_basis")
|
||||||
|
private String sicrAssessmentBasis;
|
||||||
|
|
||||||
|
@Column(name = "sicr_rebuttal_allowed")
|
||||||
|
private Boolean sicrRebuttalAllowed;
|
||||||
|
|
||||||
|
@Column(name = "npa_out_of_order_basis")
|
||||||
|
private String npaOutOfOrderBasis;
|
||||||
|
|
||||||
|
@Column(name = "crop_duration")
|
||||||
|
private String cropDuration;
|
||||||
|
|
||||||
|
@Column(name = "use_own_lgd_model")
|
||||||
|
private Boolean useOwnLgdModel;
|
||||||
|
|
||||||
|
@Column(name = "lgd_secured_pct")
|
||||||
|
private BigDecimal lgdSecuredPct;
|
||||||
|
|
||||||
|
@Column(name = "lgd_unsecured_pct")
|
||||||
|
private BigDecimal lgdUnsecuredPct;
|
||||||
|
|
||||||
|
@Column(name = "eligible_collateral_type")
|
||||||
|
private String eligibleCollateralType;
|
||||||
|
|
||||||
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
|
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
|
||||||
this.loanProduct = loanProduct;
|
this.loanProduct = loanProduct;
|
||||||
this.loanProductId = loanProduct.getId();
|
this.loanProductId = loanProduct.getId();
|
||||||
|
|||||||
+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.prudentialfloor.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
@ToString
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class PrudentialFloorData implements Serializable {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String productCategory;
|
||||||
|
private Integer stage;
|
||||||
|
private Integer durationFromDays;
|
||||||
|
private Integer durationToDays;
|
||||||
|
private BigDecimal securedFloorPct;
|
||||||
|
private BigDecimal unsecuredFloorPct;
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 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.prudentialfloor.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the RBI 2026 ECL "prudential floor" table (Directions para 82): the minimum provisioning percentage
|
||||||
|
* a bank must hold for a given loan product category and stage. Seeded centrally from the regulation rather than
|
||||||
|
* entered per product - a loan product only ever declares its category ({@code loanProductCategory} on
|
||||||
|
* {@link org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt}) and its stage (via its
|
||||||
|
* Delinquency Bucket); the applicable floor % is always looked up here, never duplicated onto the product or the
|
||||||
|
* range.
|
||||||
|
*
|
||||||
|
* Stage 1 and Stage 2 rows apply regardless of duration ({@code durationFromDays}/{@code durationToDays} are
|
||||||
|
* null and {@code securedFloorPct} equals {@code unsecuredFloorPct}, since the Directions do not split those two
|
||||||
|
* stages by security). Stage 3 rows are further split by how long the exposure has been in Stage 3
|
||||||
|
* ({@code durationToDays} null means "and beyond") and by secured/unsecured portion.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_prudential_floor")
|
||||||
|
public class PrudentialFloor extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||||
|
|
||||||
|
@Column(name = "product_category", nullable = false)
|
||||||
|
private String productCategory;
|
||||||
|
|
||||||
|
/** 1, 2 or 3. */
|
||||||
|
@Column(name = "stage", nullable = false)
|
||||||
|
private Integer stage;
|
||||||
|
|
||||||
|
@Column(name = "duration_from_days", nullable = true)
|
||||||
|
private Integer durationFromDays;
|
||||||
|
|
||||||
|
/** Null means open-ended ("after 4 years" etc.). */
|
||||||
|
@Column(name = "duration_to_days", nullable = true)
|
||||||
|
private Integer durationToDays;
|
||||||
|
|
||||||
|
@Column(name = "secured_floor_pct", nullable = false)
|
||||||
|
private BigDecimal securedFloorPct;
|
||||||
|
|
||||||
|
@Column(name = "unsecured_floor_pct", nullable = false)
|
||||||
|
private BigDecimal unsecuredFloorPct;
|
||||||
|
|
||||||
|
@Version
|
||||||
|
private Long version;
|
||||||
|
|
||||||
|
private PrudentialFloor(@NotNull String productCategory, @NotNull Integer stage, Integer durationFromDays, Integer durationToDays,
|
||||||
|
@NotNull BigDecimal securedFloorPct, @NotNull BigDecimal unsecuredFloorPct) {
|
||||||
|
this.productCategory = productCategory;
|
||||||
|
this.stage = stage;
|
||||||
|
this.durationFromDays = durationFromDays;
|
||||||
|
this.durationToDays = durationToDays;
|
||||||
|
this.securedFloorPct = securedFloorPct;
|
||||||
|
this.unsecuredFloorPct = unsecuredFloorPct;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PrudentialFloor instance(@NotNull String productCategory, @NotNull Integer stage, Integer durationFromDays,
|
||||||
|
Integer durationToDays, @NotNull BigDecimal securedFloorPct, @NotNull BigDecimal unsecuredFloorPct) {
|
||||||
|
return new PrudentialFloor(productCategory, stage, durationFromDays, durationToDays, securedFloorPct, unsecuredFloorPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when {@code daysInStage} falls within this row's duration band (always true for a Stage 1/2 row). */
|
||||||
|
public boolean coversDuration(Integer daysInStage) {
|
||||||
|
if (this.durationFromDays == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (daysInStage == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean aboveFrom = daysInStage >= this.durationFromDays;
|
||||||
|
boolean belowTo = this.durationToDays == null || daysInStage <= this.durationToDays;
|
||||||
|
return aboveFrom && belowTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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.prudentialfloor.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface PrudentialFloorRepository extends JpaRepository<PrudentialFloor, Long> {
|
||||||
|
|
||||||
|
List<PrudentialFloor> findByProductCategoryAndStage(String productCategory, Integer stage);
|
||||||
|
|
||||||
|
List<PrudentialFloor> findByProductCategoryOrderByStageAscDurationFromDaysAsc(String productCategory);
|
||||||
|
}
|
||||||
+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.prudentialfloor.mapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig;
|
||||||
|
import org.apache.fineract.portfolio.prudentialfloor.data.PrudentialFloorData;
|
||||||
|
import org.apache.fineract.portfolio.prudentialfloor.domain.PrudentialFloor;
|
||||||
|
import org.mapstruct.Mapper;
|
||||||
|
|
||||||
|
@Mapper(config = MapstructMapperConfig.class)
|
||||||
|
public interface PrudentialFloorMapper {
|
||||||
|
|
||||||
|
PrudentialFloorData map(PrudentialFloor source);
|
||||||
|
|
||||||
|
List<PrudentialFloorData> map(List<PrudentialFloor> sources);
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ dependencies {
|
|||||||
'org.apache.avro:avro',
|
'org.apache.avro:avro',
|
||||||
'jakarta.ws.rs:jakarta.ws.rs-api',
|
'jakarta.ws.rs:jakarta.ws.rs-api',
|
||||||
'org.glassfish.jersey.media:jersey-media-multipart',
|
'org.glassfish.jersey.media:jersey-media-multipart',
|
||||||
|
// Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one.
|
||||||
|
'org.glassfish.jersey.media:jersey-media-sse',
|
||||||
|
|
||||||
'org.springframework:spring-jms',
|
'org.springframework:spring-jms',
|
||||||
'org.springframework:spring-context-support',
|
'org.springframework:spring-context-support',
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -1570,9 +1570,76 @@ public class AccountingProcessorHelper {
|
|||||||
return mapping == null ? null : mapping.getGlAccount();
|
return mapping == null ? null : mapping.getGlAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clearing account for the savings-to-savings account transfer running on this thread, taken from a product's
|
||||||
|
* own Transfers In Suspense mapping (see {@link #useProductTransferSuspense}). Null outside such a transfer.
|
||||||
|
*/
|
||||||
|
private static final ThreadLocal<GLAccount> TRANSFER_CLEARING_ACCOUNT = new ThreadLocal<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes both legs of a savings-to-savings account transfer (savings to RD by auto debit, savings to savings, a
|
||||||
|
* deposit paying out) clear through the Transfers In Suspense account set on the product's Accounting tab, rather
|
||||||
|
* than the organisation-wide Liability Transfer financial activity.
|
||||||
|
* <p>
|
||||||
|
* The receiving product's mapping is used first, then the paying product's: a savings product screen does not
|
||||||
|
* offer Transfers In Suspense, so for savings to RD it is the RD product's mapping. Both legs use the same account,
|
||||||
|
* so it nets to nil. Where neither product has one, nothing is set and the transfer clears through Liability
|
||||||
|
* Transfer as before.
|
||||||
|
* <p>
|
||||||
|
* Must be paired with {@link #clearProductTransferSuspense()} in a finally block.
|
||||||
|
*
|
||||||
|
* @return true when a product mapping was found and is now in use
|
||||||
|
*/
|
||||||
|
public boolean useProductTransferSuspense(final Long toSavingsProductId, final Long fromSavingsProductId) {
|
||||||
|
final int transfersSuspense = CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue();
|
||||||
|
GLAccount account = optionalLinkedGLAccountForSavingsProduct(toSavingsProductId, transfersSuspense, null);
|
||||||
|
if (account == null) {
|
||||||
|
account = optionalLinkedGLAccountForSavingsProduct(fromSavingsProductId, transfersSuspense, null);
|
||||||
|
}
|
||||||
|
if (account == null) {
|
||||||
|
TRANSFER_CLEARING_ACCOUNT.remove();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TRANSFER_CLEARING_ACCOUNT.set(account);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearProductTransferSuspense() {
|
||||||
|
TRANSFER_CLEARING_ACCOUNT.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For undoing one leg of an account transfer: reverses through the clearing account that leg was actually posted
|
||||||
|
* to, whether a product's Transfers In Suspense or the Liability Transfer financial activity. A reversal works the
|
||||||
|
* accounts out again rather than copying the original rows, so without this a transfer posted to a product's
|
||||||
|
* suspense account would be reversed against Liability Transfer and leave both accounts open.
|
||||||
|
* <p>
|
||||||
|
* The clearing row is the credit of a withdrawal and the debit of a deposit (the largest, should a fee sit beside
|
||||||
|
* it). Nothing is set where the leg has no journal entries. Must be paired with
|
||||||
|
* {@link #clearProductTransferSuspense()} in a finally block.
|
||||||
|
*/
|
||||||
|
public void useClearingAccountPostedFor(final Long savingsTransactionId, final boolean isWithdrawal) {
|
||||||
|
TRANSFER_CLEARING_ACCOUNT.remove();
|
||||||
|
if (savingsTransactionId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final List<Long> found = jdbcTemplate.queryForList("""
|
||||||
|
SELECT account_id FROM acc_gl_journal_entry
|
||||||
|
WHERE savings_transaction_id = ? AND type_enum = ? AND reversed = false
|
||||||
|
ORDER BY amount DESC, id
|
||||||
|
LIMIT 1
|
||||||
|
""", Long.class, savingsTransactionId, isWithdrawal ? JournalEntryType.CREDIT.getValue() : JournalEntryType.DEBIT.getValue());
|
||||||
|
if (!found.isEmpty()) {
|
||||||
|
glAccountRepository.findById(found.get(0)).ifPresent(TRANSFER_CLEARING_ACCOUNT::set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||||
final Long paymentTypeId) {
|
final Long paymentTypeId) {
|
||||||
GLAccount glAccount;
|
GLAccount glAccount;
|
||||||
|
if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) {
|
||||||
|
return TRANSFER_CLEARING_ACCOUNT.get();
|
||||||
|
}
|
||||||
if (isOrganizationAccount(accountMappingTypeId)) {
|
if (isOrganizationAccount(accountMappingTypeId)) {
|
||||||
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
|
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
|
||||||
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
|
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
|
||||||
|
|||||||
+4
@@ -74,12 +74,16 @@ public final class LedgerTxnPrefix {
|
|||||||
public static final String CASH_MIGRATION = "CSHMIG";
|
public static final String CASH_MIGRATION = "CSHMIG";
|
||||||
/** Rounding difference on a clerk's day closure. */
|
/** Rounding difference on a clerk's day closure. */
|
||||||
public static final String CLOSURE_ROUNDING = "CLSRND";
|
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 ──────────────────────────────────────────────────────────────
|
||||||
/** Year-end transfer of income into the profit account. */
|
/** Year-end transfer of income into the profit account. */
|
||||||
public static final String YEAR_END_PROFIT = "YEPFT";
|
public static final String YEAR_END_PROFIT = "YEPFT";
|
||||||
/** Year-end closure of an income or expense account. */
|
/** Year-end closure of an income or expense account. */
|
||||||
public static final String YEAR_END_CLOSE = "YECLS";
|
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 ───────────────────────────────────────────────────────────────
|
// ── Savings ───────────────────────────────────────────────────────────────
|
||||||
/** Pooled savings interest posting for one product and date. */
|
/** 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);
|
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 {
|
try {
|
||||||
final String code = jdbcTemplate.queryForObject(
|
final String code = jdbcTemplate.queryForObject(
|
||||||
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
|
"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) {
|
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
|
|||||||
+11
@@ -79,6 +79,17 @@ public class MakercheckersApiResource {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/mine")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List my pending Maker Checker Entries", description = "Requests the requestor submitted that still await approval. Unlike the checker inbox it needs no checker permission, so a maker can see what is pending. Example: makercheckers/mine?entityName=LOANPRODUCT")
|
||||||
|
public List<AuditData> retrieveMyPendingCommands(@Context final UriInfo uriInfo, @BeanParam MakerCheckerRequest makerCheckerRequest) {
|
||||||
|
final SQLBuilder extraCriteria = getExtraCriteria(makerCheckerRequest);
|
||||||
|
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
||||||
|
return readPlatformService.retrieveMyPendingEntries(extraCriteria, settings.isIncludeJson());
|
||||||
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/searchtemplate")
|
@Path("/searchtemplate")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
+3
@@ -33,6 +33,9 @@ public interface AuditReadPlatformService {
|
|||||||
|
|
||||||
List<AuditData> retrieveAllEntriesToBeChecked(SQLBuilder extraCriteria, boolean includeJson);
|
List<AuditData> retrieveAllEntriesToBeChecked(SQLBuilder extraCriteria, boolean includeJson);
|
||||||
|
|
||||||
|
/** Requests the current user submitted that still await a checker, whatever the user's own checker permissions. */
|
||||||
|
List<AuditData> retrieveMyPendingEntries(SQLBuilder extraCriteria, boolean includeJson);
|
||||||
|
|
||||||
AuditData retrieveAuditEntry(Long auditId);
|
AuditData retrieveAuditEntry(Long auditId);
|
||||||
|
|
||||||
AuditSearchData retrieveSearchTemplate(String useType);
|
AuditSearchData retrieveSearchTemplate(String useType);
|
||||||
|
|||||||
+8
-1
@@ -214,10 +214,17 @@ public class AuditReadPlatformServiceImpl implements AuditReadPlatformService {
|
|||||||
return retrieveEntries("makerchecker", extraCriteria, " order by aud.id, mk.username", includeJson);
|
return retrieveEntries("makerchecker", extraCriteria, " order by aud.id, mk.username", includeJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AuditData> retrieveMyPendingEntries(final SQLBuilder extraCriteria, final boolean includeJson) {
|
||||||
|
extraCriteria.addCriteria("aud.status = ", 2);
|
||||||
|
extraCriteria.addCriteria("aud.maker_id = ", this.context.authenticatedUser().getId());
|
||||||
|
return retrieveEntries("makerchecker-mine", extraCriteria, " order by aud.id, mk.username", includeJson);
|
||||||
|
}
|
||||||
|
|
||||||
private List<AuditData> retrieveEntries(final String useType, final SQLBuilder extraCriteria, final String groupAndOrderBySQL,
|
private List<AuditData> retrieveEntries(final String useType, final SQLBuilder extraCriteria, final String groupAndOrderBySQL,
|
||||||
final boolean includeJson) {
|
final boolean includeJson) {
|
||||||
|
|
||||||
if ((!useType.equals("audit") && !useType.equals("makerchecker"))) {
|
if ((!useType.equals("audit") && !useType.equals("makerchecker") && !useType.equals("makerchecker-mine"))) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.invalid.auditSearchTemplate.useType",
|
throw new PlatformDataIntegrityException("error.msg.invalid.auditSearchTemplate.useType",
|
||||||
"Invalid Audit Search Template UseType: " + useType);
|
"Invalid Audit Search Template UseType: " + useType);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -238,6 +238,11 @@ public class SecurityConfig {
|
|||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
|
.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"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/groups/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE")
|
||||||
// notes: create
|
// notes: create
|
||||||
@@ -249,6 +254,8 @@ public class SecurityConfig {
|
|||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
|
.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"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/groups/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE")
|
||||||
// notes: update
|
// notes: update
|
||||||
@@ -260,6 +267,8 @@ public class SecurityConfig {
|
|||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
|
.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"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/groups/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE")
|
||||||
// notes: delete
|
// notes: delete
|
||||||
@@ -271,6 +280,8 @@ public class SecurityConfig {
|
|||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
|
.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"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/groups/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE")
|
||||||
// document: clients
|
// document: clients
|
||||||
|
|||||||
+43
-13
@@ -144,14 +144,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
|
|
||||||
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
|
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
|
||||||
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
|
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
|
||||||
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt,
|
|
||||||
transactionDate, transactionAmount, paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
|
|
||||||
|
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
|
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
|
||||||
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
|
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
// As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped.
|
||||||
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
final SavingsAccountTransaction withdrawal;
|
||||||
|
final SavingsAccountTransaction deposit;
|
||||||
|
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
|
||||||
|
try {
|
||||||
|
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, transactionDate, transactionAmount,
|
||||||
|
paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||||
|
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, transactionAmount,
|
||||||
|
paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||||
|
} finally {
|
||||||
|
this.accountingProcessorHelper.clearProductTransferSuspense();
|
||||||
|
}
|
||||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||||
|
|
||||||
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
||||||
@@ -277,14 +285,27 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
if (accountTransfer.getToLoanTransaction() != null) {
|
if (accountTransfer.getToLoanTransaction() != null) {
|
||||||
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
|
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
|
||||||
}
|
}
|
||||||
|
// Each leg is reversed through the clearing account it was posted to (a product's Transfers In Suspense or
|
||||||
|
// Liability Transfer), so the reversal clears that same account.
|
||||||
if (accountTransfer.getFromTransaction() != null) {
|
if (accountTransfer.getFromTransaction() != null) {
|
||||||
|
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true);
|
||||||
|
try {
|
||||||
this.savingsAccountWritePlatformService.undoTransaction(
|
this.savingsAccountWritePlatformService.undoTransaction(
|
||||||
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(),
|
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(),
|
||||||
true);
|
accountTransfer.getFromTransaction().getId(), true);
|
||||||
|
} finally {
|
||||||
|
this.accountingProcessorHelper.clearProductTransferSuspense();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (accountTransfer.getToSavingsTransaction() != null) {
|
if (accountTransfer.getToSavingsTransaction() != null) {
|
||||||
this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
|
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false);
|
||||||
|
try {
|
||||||
|
this.savingsAccountWritePlatformService.undoTransaction(
|
||||||
|
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
|
||||||
accountTransfer.getToSavingsTransaction().getId(), true);
|
accountTransfer.getToSavingsTransaction().getId(), true);
|
||||||
|
} finally {
|
||||||
|
this.accountingProcessorHelper.clearProductTransferSuspense();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
accountTransfer.reverse();
|
accountTransfer.reverse();
|
||||||
this.accountTransferRepository.save(accountTransfer);
|
this.accountTransferRepository.save(accountTransfer);
|
||||||
@@ -407,13 +428,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
transactionDate = transactionDate.plusDays(1);
|
transactionDate = transactionDate.plusDays(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount,
|
// Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving
|
||||||
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
// product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping.
|
||||||
accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill);
|
final SavingsAccountTransaction withdrawal;
|
||||||
|
final SavingsAccountTransaction deposit;
|
||||||
|
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
|
||||||
|
try {
|
||||||
|
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, accountTransferDTO.getFmt(),
|
||||||
|
transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
|
||||||
|
transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
|
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate,
|
||||||
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer,
|
||||||
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
isRegularTransaction, backdatedTxnsAllowedTill);
|
||||||
|
} finally {
|
||||||
|
this.accountingProcessorHelper.clearProductTransferSuspense();
|
||||||
|
}
|
||||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||||
|
|
||||||
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
||||||
|
|||||||
+20
-4
@@ -361,9 +361,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
|
// 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
|
// 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.
|
// 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("from m_savings_account sa ");
|
||||||
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
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("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("join m_currency curr on curr.code = sa.currency_code ");
|
||||||
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
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 String fdAccountNumber = rs.getString("fdAccountNumber");
|
||||||
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
|
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,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
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("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.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
||||||
sb.append("curr.display_symbol as currencyDisplaySymbol, ");
|
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("from m_current_account ca ");
|
||||||
sb.append("join m_current_product cp on cp.id = ca.product_id ");
|
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("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 sbu on sbu.id = ca.submittedon_userid ");
|
||||||
sb.append("left join m_appuser rbu on rbu.id = ca.rejectedon_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 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.
|
// Current accounts have no deposit term, so no FD renewal chain to report.
|
||||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.aml.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import jakarta.ws.rs.DefaultValue;
|
||||||
|
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.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much came into, and went out of, each Cash Credit account over a window, set against what the customer declared
|
||||||
|
* and what the bank sanctioned.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Read straight from the posted current-account transactions, so it works today without the AML engine being fed those
|
||||||
|
* transactions. A Cash Credit account is a current account allowed to draw an overdraft; every active current account
|
||||||
|
* is listed, with that flag, so a plain current account is not hidden from view. Accounts are limited to the caller's
|
||||||
|
* branch hierarchy, and the busiest are listed first.
|
||||||
|
*/
|
||||||
|
@Path("/v1/aml/cc-tracking")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public class AmlCcTrackingApiResource {
|
||||||
|
|
||||||
|
/** Current-account transaction types 1 and 2 are a deposit and a withdrawal, as on a savings account. */
|
||||||
|
private static final String SQL = """
|
||||||
|
SELECT ca.id AS account_id, ca.account_no, c.id AS client_id, c.display_name AS client_name,
|
||||||
|
o.name AS branch_name, ca.allow_overdraft, ca.overdraft_limit, ca.account_balance_derived,
|
||||||
|
COALESCE(NULLIF(c.monthly_turnover, 0), NULLIF(k.declared_annual_turnover, 0) / 12) AS declared_monthly_turnover,
|
||||||
|
COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 1), 0) AS credits,
|
||||||
|
COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 2), 0) AS debits,
|
||||||
|
COUNT(*) FILTER (WHERE t.transaction_type_enum = 1) AS credit_count,
|
||||||
|
COUNT(*) FILTER (WHERE t.transaction_type_enum = 2) AS debit_count,
|
||||||
|
COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 1 AND pt.is_cash_payment), 0) AS cash_credits,
|
||||||
|
COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 2 AND pt.is_cash_payment), 0) AS cash_debits,
|
||||||
|
MAX(t.transaction_date) AS last_txn_date
|
||||||
|
FROM m_current_account ca
|
||||||
|
LEFT JOIN m_client c ON c.id = ca.client_id
|
||||||
|
LEFT JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id)
|
||||||
|
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||||
|
LEFT JOIN m_current_account_transaction t ON t.current_account_id = ca.id AND t.is_reversed = false
|
||||||
|
AND t.transaction_type_enum IN (1, 2) AND t.transaction_date > ? AND t.transaction_date <= ?
|
||||||
|
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||||
|
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||||
|
WHERE ca.status_enum = 300
|
||||||
|
AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
|
||||||
|
GROUP BY ca.id, c.id, o.id, k.id
|
||||||
|
ORDER BY SUM(t.amount) DESC NULLS LAST, ca.account_no
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public String retrieve(@QueryParam("days") @DefaultValue("30") final int days) {
|
||||||
|
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
|
||||||
|
final int window = Math.min(Math.max(days, 1), 366);
|
||||||
|
final LocalDate asOf = DateUtils.getBusinessLocalDate();
|
||||||
|
final LocalDate from = asOf.minusDays(window);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
int rank = 0;
|
||||||
|
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from),
|
||||||
|
java.sql.Date.valueOf(asOf), hierarchy + "%")) {
|
||||||
|
final BigDecimal credits = money(r.get("credits"));
|
||||||
|
final BigDecimal debits = money(r.get("debits"));
|
||||||
|
final BigDecimal limit = money(r.get("overdraft_limit"));
|
||||||
|
final BigDecimal balance = money(r.get("account_balance_derived"));
|
||||||
|
final BigDecimal declaredMonthly = r.get("declared_monthly_turnover") == null ? null : money(r.get("declared_monthly_turnover"));
|
||||||
|
// What the declared monthly turnover comes to over this window, so a 7-day look is not judged against a month.
|
||||||
|
final BigDecimal declaredForWindow = declaredMonthly == null ? null
|
||||||
|
: declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("rank", ++rank);
|
||||||
|
row.put("accountId", r.get("account_id"));
|
||||||
|
row.put("accountNo", r.get("account_no"));
|
||||||
|
row.put("clientId", r.get("client_id"));
|
||||||
|
row.put("clientName", r.get("client_name"));
|
||||||
|
row.put("branchName", r.get("branch_name"));
|
||||||
|
row.put("isCc", Boolean.TRUE.equals(r.get("allow_overdraft")));
|
||||||
|
row.put("sanctionedLimit", limit);
|
||||||
|
row.put("balance", balance);
|
||||||
|
// How much of the sanctioned limit is drawn: the overdrawn part of the balance over the limit.
|
||||||
|
row.put("limitDrawnPercent", limit.signum() > 0 && balance.signum() < 0
|
||||||
|
? balance.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
|
||||||
|
row.put("declaredMonthlyTurnover", declaredMonthly);
|
||||||
|
row.put("credits", credits);
|
||||||
|
row.put("debits", debits);
|
||||||
|
row.put("net", credits.subtract(debits));
|
||||||
|
row.put("creditCount", r.get("credit_count"));
|
||||||
|
row.put("debitCount", r.get("debit_count"));
|
||||||
|
row.put("cashCredits", money(r.get("cash_credits")));
|
||||||
|
row.put("cashDebits", money(r.get("cash_debits")));
|
||||||
|
row.put("cashCreditPercent", credits.signum() > 0
|
||||||
|
? money(r.get("cash_credits")).multiply(BigDecimal.valueOf(100)).divide(credits, 1, RoundingMode.HALF_UP) : null);
|
||||||
|
// Credits and debits as a multiple of what the customer said the business turns over in this window.
|
||||||
|
row.put("creditsVsDeclared", ratio(credits, declaredForWindow));
|
||||||
|
row.put("debitsVsDeclared", ratio(debits, declaredForWindow));
|
||||||
|
row.put("lastTxnDate", r.get("last_txn_date") == null ? null : r.get("last_txn_date").toString());
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("asOf", asOf.toString());
|
||||||
|
out.put("from", from.toString());
|
||||||
|
out.put("days", window);
|
||||||
|
out.put("accounts", rows);
|
||||||
|
return this.gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal money(final Object value) {
|
||||||
|
return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal ratio(final BigDecimal amount, final BigDecimal base) {
|
||||||
|
return base == null || base.signum() <= 0 ? null : amount.divide(base, 2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.aml.job;
|
|||||||
import java.time.YearMonth;
|
import java.time.YearMonth;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.domain.AmlCtrReport;
|
||||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
||||||
import org.springframework.batch.core.StepContribution;
|
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
|
* Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical
|
||||||
* threshold aggregation only. Admin-triggered today (see {@code AmlCtrReportApiResource}); wiring
|
* threshold aggregation only. Scheduled for 02:00 on the 1st (changelog 0606), and still available from the
|
||||||
* to a real monthly cron is a follow-up, matching the same "driven by an admin endpoint today"
|
* admin endpoint (see {@code AmlCtrReportApiResource}) for a month run by hand.
|
||||||
* stance already used for {@code NeftBatchProcessorService}.
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -42,7 +42,9 @@ public class AmlCtrReportBatchTasklet implements Tasklet {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
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 {
|
try {
|
||||||
final AmlCtrReport report = generationService.generateForMonth(previousMonth);
|
final AmlCtrReport report = generationService.generateForMonth(previousMonth);
|
||||||
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
|
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package org.apache.fineract.portfolio.approvals.api;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.ws.rs.DefaultValue;
|
||||||
|
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.CacheControl;
|
||||||
|
import jakarta.ws.rs.core.Context;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import jakarta.ws.rs.sse.Sse;
|
||||||
|
import jakarta.ws.rs.sse.SseEventSink;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService;
|
||||||
|
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu.
|
||||||
|
* Numbers only: opening a tab still loads that tab's own list. The counting is in
|
||||||
|
* {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}.
|
||||||
|
*/
|
||||||
|
@Path("/v1/approvals/counts")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class ApprovalCountsApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ApprovalCountsReadPlatformService readService;
|
||||||
|
private final ApprovalCountsStreamService streamService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held
|
||||||
|
* for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
final CacheControl noStore = new CacheControl();
|
||||||
|
noStore.setNoStore(true);
|
||||||
|
return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change,
|
||||||
|
* so a screen neither polls nor reloads. The first event carries the numbers as they stand.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("stream")
|
||||||
|
@Produces(MediaType.SERVER_SENT_EVENTS)
|
||||||
|
public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) {
|
||||||
|
// Ask reverse proxies not to hold the events back.
|
||||||
|
response.setHeader("X-Accel-Buffering", "no");
|
||||||
|
response.setHeader("Cache-Control", "no-cache, no-store");
|
||||||
|
try {
|
||||||
|
this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
sink.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package org.apache.fineract.portfolio.approvals.data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Approval Center tab and the question that counts what waits on it.
|
||||||
|
*
|
||||||
|
* @param key
|
||||||
|
* the tab's route segment, which is also the key its badge reads on the screen
|
||||||
|
* @param permission
|
||||||
|
* the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees
|
||||||
|
* the tab
|
||||||
|
* @param sql
|
||||||
|
* a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan}
|
||||||
|
*/
|
||||||
|
public record ApprovalCounter(String key, String permission, String sql) {}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package org.apache.fineract.portfolio.approvals.data;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal
|
||||||
|
* exactly when the numbers are, which is how a change is told from a repeat.
|
||||||
|
*/
|
||||||
|
public record ApprovalCountsData(long total, Map<String, Long> counts, String version) {
|
||||||
|
|
||||||
|
private static final Gson GSON = new Gson();
|
||||||
|
|
||||||
|
public static ApprovalCountsData of(final Map<String, Long> counts) {
|
||||||
|
final long total = counts.values().stream().mapToLong(Long::longValue).sum();
|
||||||
|
return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toJson() {
|
||||||
|
final Map<String, Object> json = new LinkedHashMap<>();
|
||||||
|
json.put("total", this.total);
|
||||||
|
json.put("counts", this.counts);
|
||||||
|
json.put("version", this.version);
|
||||||
|
return GSON.toJson(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package org.apache.fineract.portfolio.approvals.data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those
|
||||||
|
* tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then
|
||||||
|
* be used from any thread.
|
||||||
|
*
|
||||||
|
* @param tenant
|
||||||
|
* the tenant the counts are read from
|
||||||
|
* @param visible
|
||||||
|
* the tabs this caller may see, in the order the Approval Center shows them
|
||||||
|
* @param params
|
||||||
|
* the named parameters the tabs' queries use
|
||||||
|
* @param group
|
||||||
|
* equal for callers who are counted identically, so they can share one query
|
||||||
|
*/
|
||||||
|
public record ApprovalCountsPlan(FineractPlatformTenant tenant, List<ApprovalCounter> visible, MapSqlParameterSource params,
|
||||||
|
String group) {}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package org.apache.fineract.portfolio.approvals.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the
|
||||||
|
* one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new
|
||||||
|
* tab, add one line here and one badge on the screen.
|
||||||
|
*
|
||||||
|
* The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head
|
||||||
|
* office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command).
|
||||||
|
*/
|
||||||
|
public final class ApprovalCounterRegistry {
|
||||||
|
|
||||||
|
private ApprovalCounterRegistry() {}
|
||||||
|
|
||||||
|
/** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */
|
||||||
|
private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
|
||||||
|
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'";
|
||||||
|
|
||||||
|
/** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */
|
||||||
|
private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
|
||||||
|
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'";
|
||||||
|
|
||||||
|
/** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */
|
||||||
|
private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'";
|
||||||
|
|
||||||
|
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
|
||||||
|
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
|
||||||
|
|
||||||
|
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
|
||||||
|
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
|
||||||
|
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
|
||||||
|
+ "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id "
|
||||||
|
+ "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid "
|
||||||
|
+ "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name "
|
||||||
|
+ "AND p.entity_name = aud.entity_name)) ";
|
||||||
|
|
||||||
|
/** Where the person who raised a loan or account sits, for the office scope the tab applies. */
|
||||||
|
private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) "
|
||||||
|
+ "AND o.hierarchy LIKE :h)";
|
||||||
|
|
||||||
|
/** The one tab whose list is open to managers and head cashiers only. */
|
||||||
|
public static final String DISBURSEMENT_REQUESTS = "disbursement-requests";
|
||||||
|
|
||||||
|
/** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */
|
||||||
|
public static final List<ApprovalCounter> ALL = List.of(
|
||||||
|
// ── Fineract's own maker-checker and pending lists ────────────────────────────
|
||||||
|
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
|
||||||
|
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
|
||||||
|
new ApprovalCounter("client-approval", "READ_CLIENT",
|
||||||
|
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
|
||||||
|
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
|
||||||
|
new ApprovalCounter("loan-approval", "READ_LOAN",
|
||||||
|
"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 "
|
||||||
|
+ "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE),
|
||||||
|
new ApprovalCounter("loan-disbursal", "READ_LOAN",
|
||||||
|
"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 "
|
||||||
|
+ "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE),
|
||||||
|
new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN",
|
||||||
|
"SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"),
|
||||||
|
new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id "
|
||||||
|
+ "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 "
|
||||||
|
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
|
||||||
|
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
|
||||||
|
+ "AND COALESCE(ca.terminate_true, false) = false "
|
||||||
|
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
|
||||||
|
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
|
||||||
|
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
|
||||||
|
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
|
||||||
|
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
|
||||||
|
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
|
||||||
|
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
|
||||||
|
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
|
||||||
|
+ "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"),
|
||||||
|
new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"),
|
||||||
|
// ── Requests raised by clerks, cashiers and managers ──────────────────────────
|
||||||
|
new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"),
|
||||||
|
new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id "
|
||||||
|
+ "WHERE p.status = 100"),
|
||||||
|
new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id "
|
||||||
|
+ "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id "
|
||||||
|
+ "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"),
|
||||||
|
new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("dds-approvals", null,
|
||||||
|
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
|
||||||
|
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
|
||||||
|
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
|
||||||
|
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
|
||||||
|
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
|
||||||
|
"SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("dd-issue-approvals", null,
|
||||||
|
"SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("dd-reprint-approvals", null,
|
||||||
|
"SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("dd-revoke-approvals", null,
|
||||||
|
"SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"),
|
||||||
|
new ApprovalCounter("risk-change-approvals", null,
|
||||||
|
"SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'"));
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package org.apache.fineract.portfolio.approvals.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
||||||
|
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
|
||||||
|
/** Reads how many requests wait on each Approval Center tab, in one statement. */
|
||||||
|
public interface ApprovalCountsReadPlatformService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the
|
||||||
|
* tenant of the current request.
|
||||||
|
*/
|
||||||
|
ApprovalCountsPlan planFor(AppUser user);
|
||||||
|
|
||||||
|
/** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */
|
||||||
|
ApprovalCountsData retrieve(ApprovalCountsPlan plan);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together
|
||||||
|
* cost the database one query. {@code fresh} skips the held answer.
|
||||||
|
*/
|
||||||
|
ApprovalCountsData retrieveHeld(AppUser user, boolean fresh);
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package org.apache.fineract.portfolio.approvals.service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||||
|
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
|
||||||
|
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
||||||
|
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is
|
||||||
|
* one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService {
|
||||||
|
|
||||||
|
/** Screens asking together read one answer instead of each running the statement. */
|
||||||
|
private static final long HOLD_MILLIS = 3_000L;
|
||||||
|
|
||||||
|
private record Held(long at, ApprovalCountsData data) {}
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Map<String, Held> held = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ApprovalCountsPlan planFor(final AppUser user) {
|
||||||
|
final List<ApprovalCounter> visible = new ArrayList<>();
|
||||||
|
for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) {
|
||||||
|
if (isVisible(user, counter)) {
|
||||||
|
visible.add(counter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final String hierarchy = user.getOffice().getHierarchy();
|
||||||
|
final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER");
|
||||||
|
final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%")
|
||||||
|
.addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker);
|
||||||
|
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
|
||||||
|
// Callers counted the same way share one query: only a limited checker's numbers depend on who they are.
|
||||||
|
final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|'
|
||||||
|
+ (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(","));
|
||||||
|
return new ApprovalCountsPlan(tenant, visible, params, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) {
|
||||||
|
final Map<String, Long> counts = new LinkedHashMap<>();
|
||||||
|
final List<ApprovalCounter> visible = plan.visible();
|
||||||
|
if (!visible.isEmpty()) {
|
||||||
|
final StringBuilder sql = new StringBuilder("SELECT ");
|
||||||
|
for (int i = 0; i < visible.size(); i++) {
|
||||||
|
sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i);
|
||||||
|
}
|
||||||
|
final Map<String, Object> row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(),
|
||||||
|
plan.params());
|
||||||
|
for (int i = 0; i < visible.size(); i++) {
|
||||||
|
final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L;
|
||||||
|
if (n > 0) {
|
||||||
|
counts.put(visible.get(i).key(), n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ApprovalCountsData.of(counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) {
|
||||||
|
final ApprovalCountsPlan plan = planFor(user);
|
||||||
|
final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId();
|
||||||
|
final long now = System.currentTimeMillis();
|
||||||
|
final Held recent = this.held.get(holdKey);
|
||||||
|
if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) {
|
||||||
|
return recent.data();
|
||||||
|
}
|
||||||
|
final ApprovalCountsData data = retrieve(plan);
|
||||||
|
this.held.put(holdKey, new Held(now, data));
|
||||||
|
if (this.held.size() > 500) {
|
||||||
|
this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */
|
||||||
|
private boolean isVisible(final AppUser user, final ApprovalCounter counter) {
|
||||||
|
if (counter.permission() != null) {
|
||||||
|
final boolean allowed = counter.permission().startsWith("READ_")
|
||||||
|
? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ")
|
||||||
|
: user.hasAnyPermission(counter.permission());
|
||||||
|
if (!allowed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isChecker(final AppUser user) {
|
||||||
|
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||||
|
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package org.apache.fineract.portfolio.approvals.service;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import jakarta.ws.rs.sse.OutboundSseEvent;
|
||||||
|
import jakarta.ws.rs.sse.Sse;
|
||||||
|
import jakarta.ws.rs.sse.SseEventSink;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||||
|
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
||||||
|
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its
|
||||||
|
* numbers change, so it neither polls nor reloads.
|
||||||
|
*
|
||||||
|
* One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share
|
||||||
|
* its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the
|
||||||
|
* server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean
|
||||||
|
* connection, which bounds what a browser accumulates on one.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApprovalCountsStreamService {
|
||||||
|
|
||||||
|
/** How often the open screens are looked at: the longest a change waits before it is pushed. */
|
||||||
|
private static final long CHECK_MILLIS = 1_000L;
|
||||||
|
|
||||||
|
private static final long PING_MILLIS = 15_000L;
|
||||||
|
|
||||||
|
private static final long LIFETIME_MILLIS = 300_000L;
|
||||||
|
|
||||||
|
/** A screen listening, and the version of the numbers it was last sent. */
|
||||||
|
private static final class Listener {
|
||||||
|
|
||||||
|
private final ApprovalCountsPlan plan;
|
||||||
|
private final SseEventSink sink;
|
||||||
|
private final Sse sse;
|
||||||
|
private final long expiresAt;
|
||||||
|
private volatile String version;
|
||||||
|
private volatile long lastSent;
|
||||||
|
|
||||||
|
private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) {
|
||||||
|
this.plan = plan;
|
||||||
|
this.sink = sink;
|
||||||
|
this.sse = sse;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ApprovalCountsReadPlatformService readService;
|
||||||
|
private final Set<Listener> listeners = ConcurrentHashMap.newKeySet();
|
||||||
|
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||||
|
final Thread thread = new Thread(runnable, "approval-counts-stream");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void startTicker() {
|
||||||
|
this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
void stopTicker() {
|
||||||
|
this.ticker.shutdownNow();
|
||||||
|
this.listeners.forEach(this::close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */
|
||||||
|
public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) {
|
||||||
|
final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS);
|
||||||
|
try {
|
||||||
|
publish(listener, this.readService.retrieve(plan));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
sink.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
this.listeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */
|
||||||
|
private void tick() {
|
||||||
|
if (this.listeners.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final long now = System.currentTimeMillis();
|
||||||
|
final Map<String, ApprovalCountsData> computed = new HashMap<>();
|
||||||
|
for (final Listener listener : this.listeners) {
|
||||||
|
try {
|
||||||
|
if (listener.sink.isClosed() || now >= listener.expiresAt) {
|
||||||
|
close(listener);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ApprovalCountsData data = computed.get(listener.plan.group());
|
||||||
|
if (data == null) {
|
||||||
|
ThreadLocalContextUtil.setTenant(listener.plan.tenant());
|
||||||
|
data = this.readService.retrieve(listener.plan);
|
||||||
|
computed.put(listener.plan.group(), data);
|
||||||
|
}
|
||||||
|
if (!data.version().equals(listener.version)) {
|
||||||
|
publish(listener, data);
|
||||||
|
} else if (now - listener.lastSent >= PING_MILLIS) {
|
||||||
|
listener.lastSent = now;
|
||||||
|
send(listener, listener.sse.newEventBuilder().comment("ping").build());
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
close(listener);
|
||||||
|
} finally {
|
||||||
|
ThreadLocalContextUtil.clearTenant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publish(final Listener listener, final ApprovalCountsData data) {
|
||||||
|
listener.version = data.version();
|
||||||
|
listener.lastSent = System.currentTimeMillis();
|
||||||
|
send(listener, listener.sse.newEvent("counts", data.toJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(final Listener listener, final OutboundSseEvent event) {
|
||||||
|
listener.sink.send(event).whenComplete((sent, failure) -> {
|
||||||
|
if (failure != null) {
|
||||||
|
close(listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void close(final Listener listener) {
|
||||||
|
this.listeners.remove(listener);
|
||||||
|
try {
|
||||||
|
listener.sink.close();
|
||||||
|
} catch (final RuntimeException ignored) {
|
||||||
|
// The screen is already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-4
@@ -56,6 +56,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
||||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -98,6 +99,8 @@ public class CashierDepositRequestApiResource {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final VoucherService voucherService;
|
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();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
|
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
|
||||||
@@ -155,6 +158,11 @@ public class CashierDepositRequestApiResource {
|
|||||||
throw apiError(400, "Amount must be greater than zero");
|
throw apiError(400, "Amount must be greater than zero");
|
||||||
}
|
}
|
||||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
|
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
|
||||||
|
// An FD is funded once, with exactly its principal. Told here at the counter rather than when the
|
||||||
|
// manager approves; the FD deposit command checks the same again at posting.
|
||||||
|
if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) {
|
||||||
|
fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount);
|
||||||
|
}
|
||||||
validateDenominationTotal(denominationData, amount);
|
validateDenominationTotal(denominationData, amount);
|
||||||
validateDdLimit(ddFaceValue);
|
validateDdLimit(ddFaceValue);
|
||||||
|
|
||||||
@@ -209,8 +217,9 @@ public class CashierDepositRequestApiResource {
|
|||||||
@QueryParam("fromDate") final String fromDate,
|
@QueryParam("fromDate") final String fromDate,
|
||||||
@QueryParam("toDate") final String toDate) {
|
@QueryParam("toDate") final String toDate) {
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
// A branch reviews only the requests its own counters raised (and those of branches below it).
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
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()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
baseParams.add(Date.valueOf(fromDate));
|
baseParams.add(Date.valueOf(fromDate));
|
||||||
@@ -239,9 +248,13 @@ public class CashierDepositRequestApiResource {
|
|||||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray());
|
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();
|
final JsonObject result = new JsonObject();
|
||||||
result.addProperty("totalFilteredRecords", total);
|
result.addProperty("totalFilteredRecords", total);
|
||||||
result.add("pageItems", gson.toJsonTree(rows));
|
result.add("pageItems", gson.toJsonTree(pageItems));
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +319,82 @@ public class CashierDepositRequestApiResource {
|
|||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String getById(@PathParam("id") final Long id) {
|
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 ──
|
// ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ──
|
||||||
@@ -393,6 +481,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record);
|
||||||
|
|
||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||||
@@ -450,6 +539,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||||
};
|
};
|
||||||
if (!isCurrentAccount && txnId != null) {
|
if (!isCurrentAccount && txnId != null) {
|
||||||
|
moveCashToCounterBranch(record, txnId, amount, txnDateStr);
|
||||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
// 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
|
// 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
|
// hit an edge case in the new hold-placement logic would fail entirely, where before
|
||||||
@@ -530,6 +620,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record);
|
||||||
|
|
||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||||
|
|||||||
+14
-1
@@ -20,6 +20,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
|||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||||
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
@@ -36,6 +37,7 @@ public class CashReportApiResource {
|
|||||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
private final VaultPositionService vaultPositionService;
|
private final VaultPositionService vaultPositionService;
|
||||||
|
private final VoucherService voucherService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -329,7 +331,18 @@ public class CashReportApiResource {
|
|||||||
BigDecimal priorCreditTotal = BigDecimal.ZERO;
|
BigDecimal priorCreditTotal = BigDecimal.ZERO;
|
||||||
BigDecimal priorDebitTotal = BigDecimal.ZERO;
|
BigDecimal priorDebitTotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
// Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on
|
||||||
|
// the voucher that replaced it, so it must not be counted in the day's totals a second time.
|
||||||
|
final Map<String, Map<String, Object>> marks = voucherService.voucherMarks(
|
||||||
|
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
|
||||||
|
|
||||||
for (final Map<String, Object> row : rows) {
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final Map<String, Object> mark = marks.get((String) row.get("voucherNo"));
|
||||||
|
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
|
||||||
|
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
|
||||||
|
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
|
||||||
|
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
|
||||||
|
final boolean revoked = "REVOKED".equals(row.get("voucherStatus"));
|
||||||
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -350,7 +363,7 @@ public class CashReportApiResource {
|
|||||||
// was already earned, and the day it belongs to has been counted and signed off. Adding
|
// was already earned, and the day it belongs to has been counted and signed off. Adding
|
||||||
// it to this day's totals would make them disagree with the ledger for either date.
|
// it to this day's totals would make them disagree with the ledger for either date.
|
||||||
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
|
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
|
||||||
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
|
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
|
||||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||||
if (amount != null) {
|
if (amount != null) {
|
||||||
|
|||||||
+186
-10
@@ -57,9 +57,23 @@ public class CashTransferApiResource {
|
|||||||
private final VaultPositionService vaultPositionService;
|
private final VaultPositionService vaultPositionService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
|
||||||
|
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
|
||||||
|
* {@link #postTransfer}.
|
||||||
|
*/
|
||||||
@POST
|
@POST
|
||||||
public String create(String body) {
|
public String create(String body) {
|
||||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
|
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
|
||||||
|
"A cash transfer is raised as a request and posted when a manager approves it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
|
||||||
|
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
|
||||||
|
* when it is approved, since the vault can change in between.
|
||||||
|
*/
|
||||||
|
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
|
||||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
|
||||||
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
|
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
|
||||||
@@ -102,6 +116,9 @@ public class CashTransferApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
|
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
|
||||||
|
if (dryRun) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
|
||||||
CashTransfer header = new CashTransfer();
|
CashTransfer header = new CashTransfer();
|
||||||
header.setTransferDate(transferDate);
|
header.setTransferDate(transferDate);
|
||||||
@@ -112,15 +129,9 @@ public class CashTransferApiResource {
|
|||||||
header.setTransferAmount(total);
|
header.setTransferAmount(total);
|
||||||
header.setNote(note);
|
header.setNote(note);
|
||||||
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||||
try {
|
// Recorded against the person who raised the request, not the one who approved it.
|
||||||
AppUser user = this.context.authenticatedUser();
|
header.setPerformedBy(performedBy);
|
||||||
if (user != null) {
|
header.setPerformedByUsername(performedByUsername);
|
||||||
header.setPerformedBy(user.getId());
|
|
||||||
header.setPerformedByUsername(user.getUsername());
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
// resolving the current user must never block recording the transfer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save header + details in one committed transaction, then GL dispatch separately.
|
// Save header + details in one committed transaction, then GL dispatch separately.
|
||||||
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
|
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
|
||||||
@@ -159,6 +170,171 @@ public class CashTransferApiResource {
|
|||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
|
||||||
|
|
||||||
|
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
|
||||||
|
@POST
|
||||||
|
@Path("requests")
|
||||||
|
public String submitRequest(final String body) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
|
||||||
|
postTransfer(body, user.getId(), user.getUsername(), true);
|
||||||
|
|
||||||
|
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
|
||||||
|
// a second click would otherwise release the same cash twice once both were approved.
|
||||||
|
final Integer identical = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
|
||||||
|
REQUEST_PENDING, user.getId(), body);
|
||||||
|
if (identical != null && identical > 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
|
||||||
|
"An identical cash transfer request from you is already waiting for approval.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
final JsonArray denominations = json.getAsJsonArray("denominations");
|
||||||
|
final Long id = jdbcTemplate.queryForObject("""
|
||||||
|
INSERT INTO m_cash_transfer_request
|
||||||
|
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
|
||||||
|
status, maker_id, maker_name, maker_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
RETURNING id
|
||||||
|
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
|
||||||
|
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
|
||||||
|
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
|
||||||
|
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
|
||||||
|
final JsonObject result = new JsonObject();
|
||||||
|
result.addProperty("resourceId", id);
|
||||||
|
result.addProperty("status", REQUEST_PENDING);
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
|
||||||
|
@GET
|
||||||
|
@Path("requests")
|
||||||
|
public String listRequests(@QueryParam("status") final String status,
|
||||||
|
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
|
||||||
|
final List<Object> params = new java.util.ArrayList<>();
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
final String[] statuses = status.split(",");
|
||||||
|
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
|
||||||
|
for (final String s : statuses) {
|
||||||
|
params.add(s.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mine) {
|
||||||
|
sql.append(" AND maker_id = ?");
|
||||||
|
params.add(user.getId());
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY id DESC LIMIT 200");
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
// The notes asked for, so the approver sees what they are releasing without opening anything.
|
||||||
|
final Object requestBody = row.remove("request_body");
|
||||||
|
row.put("denominations", requestBody == null ? new JsonArray()
|
||||||
|
: JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations"));
|
||||||
|
}
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */
|
||||||
|
@POST
|
||||||
|
@Path("requests/{id}/approve")
|
||||||
|
public String approveRequest(@PathParam("id") final Long id) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
requireChecker(user);
|
||||||
|
final Map<String, Object> r = loadRequest(id);
|
||||||
|
requirePending(r, "approved");
|
||||||
|
final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null;
|
||||||
|
if (makerId != null && makerId.equals(user.getId())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user",
|
||||||
|
"Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager.");
|
||||||
|
}
|
||||||
|
final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false);
|
||||||
|
final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject();
|
||||||
|
final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null;
|
||||||
|
|
||||||
|
final int updated = jdbcTemplate.update("""
|
||||||
|
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ?
|
||||||
|
WHERE id = ? AND status = ?
|
||||||
|
""", REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
|
||||||
|
transferId, id, REQUEST_PENDING);
|
||||||
|
if (updated == 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned",
|
||||||
|
"This request was already actioned by another manager.");
|
||||||
|
}
|
||||||
|
final JsonObject result = new JsonObject();
|
||||||
|
result.addProperty("resourceId", id);
|
||||||
|
result.addProperty("status", REQUEST_APPROVED);
|
||||||
|
if (transferId != null) {
|
||||||
|
result.addProperty("cashTransferId", transferId);
|
||||||
|
}
|
||||||
|
if (postedJson.has("glWarning")) {
|
||||||
|
result.add("glWarning", postedJson.get("glWarning"));
|
||||||
|
}
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("requests/{id}/reject")
|
||||||
|
public String rejectRequest(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
requireChecker(user);
|
||||||
|
final Map<String, Object> r = loadRequest(id);
|
||||||
|
requirePending(r, "rejected");
|
||||||
|
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
final String reason = textOrNull(json, "reason");
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject.");
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
|
||||||
|
WHERE id = ? AND status = ?
|
||||||
|
""", REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
|
||||||
|
reason.trim(), id, REQUEST_PENDING);
|
||||||
|
final JsonObject result = new JsonObject();
|
||||||
|
result.addProperty("resourceId", id);
|
||||||
|
result.addProperty("status", REQUEST_REJECTED);
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String REQUEST_PENDING = "PENDING";
|
||||||
|
private static final String REQUEST_APPROVED = "APPROVED";
|
||||||
|
private static final String REQUEST_REJECTED = "REJECTED";
|
||||||
|
|
||||||
|
private Map<String, Object> loadRequest(final Long id) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requirePending(final Map<String, Object> request, final String action) {
|
||||||
|
if (!REQUEST_PENDING.equals(request.get("status"))) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status",
|
||||||
|
"Only PENDING requests can be " + action + ". Current status: " + request.get("status"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
|
||||||
|
private void requireChecker(final AppUser user) {
|
||||||
|
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
|
||||||
|
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||||
|
if (!isChecker) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker",
|
||||||
|
"Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textOrNull(final JsonObject json, final String key) {
|
||||||
|
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public String getAll(
|
public String getAll(
|
||||||
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
|
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
|
||||||
|
|||||||
+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
|
// 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.
|
// receipt into whatever GL account the cashier actually deposited it to. This id proves the
|
||||||
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
|
// fee was paid and never contributes to the share-value leg below. If the cashier banked it
|
||||||
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
|
// into an income account it is left there; anywhere else (Sundry, most often) it is moved to
|
||||||
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
|
// Share Entry Fee Income below. Omit it (leave null) when the fee was paid as part of the same
|
||||||
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
|
// Sundry receipt(s) as the share value -- that single-receipt case is swept out of Sundry below.
|
||||||
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
|
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
|
||||||
|
|
||||||
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
|
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,
|
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
|
||||||
currencyCode, "Share entry fee for " + accountNo);
|
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, "
|
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);
|
+ " 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)
|
final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId)
|
||||||
: getCurrentControlGlId(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) {
|
if (ownAccountGlId == null || incomeGlId == null) {
|
||||||
log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); "
|
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);
|
+ "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();
|
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). */
|
/** 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,
|
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,
|
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
|
||||||
|
|||||||
+289
-27
@@ -69,6 +69,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
|
|||||||
import org.apache.fineract.portfolio.PortfolioProductType;
|
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -90,6 +92,8 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final VoucherService voucherService;
|
private final VoucherService voucherService;
|
||||||
|
private final FixedDepositFundingValidator fixedDepositFundingValidator;
|
||||||
|
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
// ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ────────
|
// ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ────────
|
||||||
@@ -202,6 +206,9 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account.
|
||||||
|
validateFixedDepositLegs(creditEntries, requestType);
|
||||||
|
|
||||||
// Validate balance when both DR and CR entries present (collection mode)
|
// Validate balance when both DR and CR entries present (collection mode)
|
||||||
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
|
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
|
||||||
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
|
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
|
||||||
@@ -298,6 +305,10 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(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()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
try {
|
try {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
@@ -424,8 +435,63 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
if (!currentUser.getId().equals(clerkId)) {
|
if (!currentUser.getId().equals(clerkId)) {
|
||||||
throw apiError(403, "You can only view your own disbursement requests.");
|
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 ─────
|
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
|
||||||
@@ -441,6 +507,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record, currentUser);
|
||||||
|
|
||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||||
@@ -491,6 +558,10 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
rejectRecurringDepositLeg(entry, entryType);
|
rejectRecurringDepositLeg(entry, entryType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Re-checked here, not only at submit: another request (or a counter deposit) may have funded the
|
||||||
|
// same FD while this one waited, and a refusal now still leaves the request PENDING.
|
||||||
|
final Set<Long> fundedFixedDeposits = validateFixedDepositLegs(creditEntries,
|
||||||
|
record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT");
|
||||||
|
|
||||||
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
|
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
|
||||||
// posting duplicate Fineract transactions for the same request.
|
// posting duplicate Fineract transactions for the same request.
|
||||||
@@ -567,21 +638,23 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
"No savings-control GL mapping found for account " + acNo
|
"No savings-control GL mapping found for account " + acNo
|
||||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
+ ". 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)) {
|
if ("Dr".equals(dbCr)) {
|
||||||
final long txnId = isCurrent
|
final long txnId = isCurrent
|
||||||
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
? directDebitCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
|
||||||
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
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);
|
btTotalDr = btTotalDr.add(entryAmt);
|
||||||
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
||||||
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
||||||
} else {
|
} else {
|
||||||
final long txnId = isCurrent
|
final long txnId = isCurrent
|
||||||
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
? directCreditCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
|
||||||
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
|
: directCreditSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
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));
|
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
|
||||||
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
||||||
}
|
}
|
||||||
@@ -591,13 +664,10 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) {
|
if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) {
|
||||||
final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description)
|
final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description)
|
||||||
+ " → " + buildGlEntryComment("", btCreditGlEntries, null);
|
+ " → " + buildGlEntryComment("", btCreditGlEntries, null);
|
||||||
final String btGlTxnId = postCombinedJournalEntry(
|
final String btGlTxnId = postBankTransferJournals(
|
||||||
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment);
|
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
|
||||||
if (btGlTxnId != null) {
|
// The request row keeps 60 characters; an inter-branch transfer posts one entry per branch.
|
||||||
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2);
|
approvedGlTxnId = btGlTxnId == null || btGlTxnId.length() <= 60 ? btGlTxnId : btGlTxnId.split(",")[0];
|
||||||
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
|
|
||||||
}
|
|
||||||
approvedGlTxnId = btGlTxnId;
|
|
||||||
|
|
||||||
// Audit log in m_bank_transfer_log
|
// Audit log in m_bank_transfer_log
|
||||||
final String btTxnRefsStr = String.join(",", txnRefs);
|
final String btTxnRefsStr = String.join(",", txnRefs);
|
||||||
@@ -773,6 +843,12 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
}
|
}
|
||||||
} // end else (disbursement/collection mode)
|
} // end else (disbursement/collection mode)
|
||||||
|
|
||||||
|
// A funded FD's maturity date and amount were projected off its principal with no money in it;
|
||||||
|
// the direct credit above does not recompute them, so do it now that the money has landed.
|
||||||
|
for (final Long fdId : fundedFixedDeposits) {
|
||||||
|
depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
|
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
|
||||||
// This restores account balances even when a later entry fails mid-processing.
|
// This restores account balances even when a later entry fails mid-processing.
|
||||||
@@ -838,6 +914,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record, currentUser);
|
||||||
|
|
||||||
// Maker-checker: the person who submitted the request cannot also reject it.
|
// Maker-checker: the person who submitted the request cannot also reject it.
|
||||||
final Long makerId = record.get("clerk_id") != null
|
final Long makerId = record.get("clerk_id") != null
|
||||||
@@ -988,12 +1065,14 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
"No savings-control GL mapping found for account " + acNo
|
"No savings-control GL mapping found for account " + acNo
|
||||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
+ ". 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
|
final long txnId = isCurrent
|
||||||
? directDebitCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
? directDebitCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
|
||||||
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description);
|
||||||
|
|
||||||
txnRefs.add(String.valueOf(txnId));
|
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) {
|
if (isCurrent) {
|
||||||
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -1015,11 +1094,12 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
"No savings-control GL mapping found for account " + acNo
|
"No savings-control GL mapping found for account " + acNo
|
||||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||||
|
|
||||||
|
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
|
||||||
final long txnId = isCurrent
|
final long txnId = isCurrent
|
||||||
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
? directCreditCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
|
||||||
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
|
: directCreditSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
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) {
|
if (isCurrent) {
|
||||||
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -1030,13 +1110,8 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
// ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─
|
// ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─
|
||||||
final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description)
|
final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description)
|
||||||
+ " → " + buildGlEntryComment("", creditGlEntries, null);
|
+ " → " + buildGlEntryComment("", creditGlEntries, null);
|
||||||
glTxnId = postCombinedJournalEntry(
|
glTxnId = postBankTransferJournals(
|
||||||
debitGlEntries, creditGlEntries,
|
debitGlEntries, creditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
|
||||||
officeId, currencyCode, txnDateStr, btComment);
|
|
||||||
if (glTxnId != null) {
|
|
||||||
linkGlEntriesToSavingsAccounts(glTxnId, debitGlEntries, 2);
|
|
||||||
linkGlEntriesToSavingsAccounts(glTxnId, creditGlEntries, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
for (int i = undoOps.size() - 1; i >= 0; i--) {
|
for (int i = undoOps.size() - 1; i >= 0; i--) {
|
||||||
@@ -1223,6 +1298,70 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label.
|
||||||
|
* <ul>
|
||||||
|
* <li>An FD is never debited here: money leaves an FD only through its maturity or premature closure. The
|
||||||
|
* direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.</li>
|
||||||
|
* <li>An FD credit is its first and only funding, for exactly its principal, and an FD appears once.</li>
|
||||||
|
* <li>The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund
|
||||||
|
* them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is
|
||||||
|
* the bank's own money and has no customer to compare.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @return the ids of the FDs this request funds, for the maturity recalculation after posting
|
||||||
|
*/
|
||||||
|
private Set<Long> validateFixedDepositLegs(final JsonArray entries, final String requestType) {
|
||||||
|
final Set<Long> fdIds = new LinkedHashSet<>();
|
||||||
|
Long firstFdId = null;
|
||||||
|
Long fdClientId = null;
|
||||||
|
for (final JsonElement el : entries) {
|
||||||
|
final JsonObject entry = el.getAsJsonObject();
|
||||||
|
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||||
|
if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId")
|
||||||
|
|| entry.get("savingsAccountId").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Long accountId = entry.get("savingsAccountId").getAsLong();
|
||||||
|
if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId);
|
||||||
|
if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) {
|
||||||
|
throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only"
|
||||||
|
+ " through its maturity or premature closure.");
|
||||||
|
}
|
||||||
|
if (!fdIds.add(accountId)) {
|
||||||
|
throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once,"
|
||||||
|
+ " with its full principal in one entry.");
|
||||||
|
}
|
||||||
|
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
|
||||||
|
final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount);
|
||||||
|
if (firstFdId == null) {
|
||||||
|
firstFdId = accountId;
|
||||||
|
fdClientId = clientId;
|
||||||
|
} else if (!Objects.equals(fdClientId, clientId)) {
|
||||||
|
throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer"
|
||||||
|
+ " only. Raise a separate request for each customer.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fdIds.isEmpty()) {
|
||||||
|
return fdIds;
|
||||||
|
}
|
||||||
|
for (final JsonElement el : entries) {
|
||||||
|
final JsonObject entry = el.getAsJsonObject();
|
||||||
|
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||||
|
if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type)
|
||||||
|
|| !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
|
||||||
|
fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(),
|
||||||
|
"CURRENT".equals(type), amount);
|
||||||
|
}
|
||||||
|
return fdIds;
|
||||||
|
}
|
||||||
|
|
||||||
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
|
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
|
||||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||||
|
|
||||||
@@ -1365,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) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
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,
|
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
||||||
@@ -1609,6 +1770,8 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
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 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
|
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,
|
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||||
final String accountRef, final String txnId) {
|
final String accountRef, final String txnId) {
|
||||||
@@ -2042,6 +2205,105 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return result != null ? result.getTransactionId() : null;
|
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. */
|
/** Builds the GL entry comments string listing all affected account numbers. */
|
||||||
private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
|
private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
|
||||||
final String description) {
|
final String description) {
|
||||||
|
|||||||
+23
-2
@@ -351,8 +351,8 @@ public class ClerkClosureRequestApiResource {
|
|||||||
@QueryParam("toDate") final String toDate,
|
@QueryParam("toDate") final String toDate,
|
||||||
@QueryParam("search") final String search) {
|
@QueryParam("search") final String search) {
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
baseParams.add(Date.valueOf(fromDate));
|
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) {
|
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("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, savings_account_id, account_no, client_id, client_name, closure_date,
|
SELECT id, savings_account_id, account_no, client_id, client_name, closure_date,
|
||||||
payment_type_id, payment_type_name, description, tok_no, status,
|
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("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||||
@QueryParam("search") final String search) {
|
@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 ──────
|
// ── 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) {
|
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
|
final List<Map<String, Object>> rows = jdbcTemplate
|
||||||
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
|
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
|
|||||||
+187
-15
@@ -48,6 +48,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -56,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
||||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -84,6 +86,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final VoucherService voucherService;
|
private final VoucherService voucherService;
|
||||||
|
private final FixedDepositFundingValidator fixedDepositFundingValidator;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
|
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
|
||||||
@@ -110,13 +113,28 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
||||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||||
|
// A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is
|
||||||
|
// paid from instead of a savings account; it is approved and disbursed exactly like any other slip.
|
||||||
|
final boolean isGl = "GL".equalsIgnoreCase(accountType);
|
||||||
|
final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root);
|
||||||
|
final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root);
|
||||||
|
final Long officeId = fromJsonHelper.extractLongNamed("officeId", root);
|
||||||
|
final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root);
|
||||||
|
|
||||||
if (accountNo == null || accountNo.isBlank()) {
|
if (accountNo == null || accountNo.isBlank()) {
|
||||||
throw apiError(400, "Account number is required");
|
throw apiError(400, "Account number is required");
|
||||||
}
|
}
|
||||||
if (savingsAccountId == null) {
|
if (isGl) {
|
||||||
|
validateGlWithdrawalAccounts(glAccountId, contraGlId);
|
||||||
|
} else if (savingsAccountId == null) {
|
||||||
throw apiError(400, "Savings account id is required for a withdrawal request");
|
throw apiError(400, "Savings account id is required for a withdrawal request");
|
||||||
}
|
}
|
||||||
|
// Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip.
|
||||||
|
// Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account.
|
||||||
|
if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) {
|
||||||
|
throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from."
|
||||||
|
+ " Money leaves a fixed deposit only through its maturity or premature closure.");
|
||||||
|
}
|
||||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
throw apiError(400, "Amount must be greater than zero");
|
throw apiError(400, "Amount must be greater than zero");
|
||||||
}
|
}
|
||||||
@@ -124,7 +142,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
throw apiError(400, "Token number (TOK. NO.) is required");
|
throw apiError(400, "Token number (TOK. NO.) is required");
|
||||||
}
|
}
|
||||||
validateTokenUniqueToday(tokNo, null);
|
validateTokenUniqueToday(tokNo, null);
|
||||||
|
if (!isGl) {
|
||||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||||
|
}
|
||||||
|
|
||||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||||
|
|
||||||
@@ -132,8 +152,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
INSERT INTO m_clerk_withdrawal_request
|
INSERT INTO m_clerk_withdrawal_request
|
||||||
(account_no, account_type, savings_account_id, client_id, client_name, amount,
|
(account_no, account_type, savings_account_id, client_id, client_name, amount,
|
||||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
|
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
|
||||||
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
|
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
gl_account_id, contra_gl_id, office_id, currency_code)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
Long.class,
|
Long.class,
|
||||||
@@ -142,7 +163,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
txnDate != null ? Date.valueOf(txnDate) : null,
|
txnDate != null ? Date.valueOf(txnDate) : null,
|
||||||
"PENDING",
|
"PENDING",
|
||||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||||
denominationData, activityLog, Timestamp.from(Instant.now())
|
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||||
|
isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null,
|
||||||
|
isGl ? (currencyCode != null ? currencyCode : "INR") : null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Globally unique voucher number from the central m_voucher register
|
// Globally unique voucher number from the central m_voucher register
|
||||||
@@ -165,8 +188,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
@QueryParam("toDate") final String toDate,
|
@QueryParam("toDate") final String toDate,
|
||||||
@QueryParam("search") final String search) {
|
@QueryParam("search") final String search) {
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
baseParams.add(Date.valueOf(fromDate));
|
baseParams.add(Date.valueOf(fromDate));
|
||||||
@@ -198,7 +221,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
|
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
|
||||||
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
||||||
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
||||||
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
|
+ " cashier_id, disbursed_by, disbursed_at, voucher_no,"
|
||||||
|
+ " gl_account_id, contra_gl_id, office_id, currency_code"
|
||||||
+ " FROM m_clerk_withdrawal_request " + where
|
+ " FROM m_clerk_withdrawal_request " + where
|
||||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray());
|
pageParams.toArray());
|
||||||
@@ -252,7 +276,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
payment_type_name, description, tok_no, txn_date, status,
|
payment_type_name, description, tok_no, txn_date, status,
|
||||||
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
|
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
|
||||||
submitted_at, actioned_at,
|
submitted_at, actioned_at,
|
||||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||||
|
gl_account_id, contra_gl_id, office_id, currency_code
|
||||||
FROM m_clerk_withdrawal_request
|
FROM m_clerk_withdrawal_request
|
||||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray()
|
pageParams.toArray()
|
||||||
@@ -276,12 +301,14 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||||
denomination_data, activity_log, submitted_at, actioned_at,
|
denomination_data, activity_log, submitted_at, actioned_at,
|
||||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||||
|
gl_account_id, contra_gl_id, office_id, currency_code
|
||||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||||
""", id);
|
""", id);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
throw new NotFoundException("Withdrawal request not found: " + id);
|
throw new NotFoundException("Withdrawal request not found: " + id);
|
||||||
}
|
}
|
||||||
|
assertMakerInCallersBranch(rows.get(0));
|
||||||
return gson.toJson(rows.get(0));
|
return gson.toJson(rows.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +322,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||||
amount, payment_type_id, payment_type_name, description, tok_no, status,
|
amount, payment_type_id, payment_type_name, description, tok_no, status,
|
||||||
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
|
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no,
|
||||||
|
gl_account_id, contra_gl_id, office_id, currency_code
|
||||||
FROM m_clerk_withdrawal_request
|
FROM m_clerk_withdrawal_request
|
||||||
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
|
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
|
||||||
ORDER BY submitted_at DESC LIMIT 1
|
ORDER BY submitted_at DESC LIMIT 1
|
||||||
@@ -455,13 +483,16 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
|
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type"));
|
||||||
final Long savingsAccountId = record.get("savings_account_id") != null
|
final Long savingsAccountId = record.get("savings_account_id") != null
|
||||||
? ((Number) record.get("savings_account_id")).longValue() : null;
|
? ((Number) record.get("savings_account_id")).longValue() : null;
|
||||||
// Checked again on approval, not only on submission: a request can sit in the queue for days,
|
// Checked again on approval, not only on submission: a request can sit in the queue for days,
|
||||||
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
|
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
|
||||||
// below refuses a dormant account anyway, but that failure would surface to the manager as a
|
// below refuses a dormant account anyway, but that failure would surface to the manager as a
|
||||||
// rejected transaction after the approval was recorded; this says why, before anything moves.
|
// rejected transaction after the approval was recorded; this says why, before anything moves.
|
||||||
|
if (!isGlRequest) {
|
||||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||||
|
}
|
||||||
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
||||||
final Object ptId = record.get("payment_type_id");
|
final Object ptId = record.get("payment_type_id");
|
||||||
final Object txnDateObj = record.get("txn_date");
|
final Object txnDateObj = record.get("txn_date");
|
||||||
@@ -469,7 +500,11 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final String description = (String) record.get("description");
|
final String description = (String) record.get("description");
|
||||||
|
|
||||||
String txnId = null;
|
String txnId = null;
|
||||||
if (savingsAccountId != null) {
|
if (isGlRequest) {
|
||||||
|
// Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal.
|
||||||
|
validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id")));
|
||||||
|
txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description);
|
||||||
|
} else if (savingsAccountId != null) {
|
||||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
|
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
|
||||||
txnId = isCurrentAccount
|
txnId = isCurrentAccount
|
||||||
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
|
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||||
@@ -477,14 +512,18 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Link voucher to the created savings txn and stamp voucher_no on it
|
// Link voucher to the created savings txn and stamp voucher_no on it
|
||||||
if (txnId != null) {
|
if (txnId != null && !isGlRequest) {
|
||||||
voucherService.linkSavingsTransaction(
|
voucherService.linkSavingsTransaction(
|
||||||
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
||||||
// debited and the till that pays it out is credited.
|
// debited and the till that pays it out is credited.
|
||||||
|
if (isGlRequest) {
|
||||||
|
addGlWithdrawalVoucherLines(record, amount);
|
||||||
|
} else {
|
||||||
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
||||||
|
}
|
||||||
|
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||||
@@ -689,8 +728,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
if (denomMap == null || denomMap.size() == 0) return;
|
if (denomMap == null || denomMap.size() == 0) return;
|
||||||
|
|
||||||
final Object txnIdObj = record.get("txn_id");
|
final Object txnIdObj = record.get("txn_id");
|
||||||
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
|
final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null;
|
||||||
if (txnId == null) return; // no savings txn linked — cannot record denomination
|
if (txnId == null) return; // no txn linked — cannot record denomination
|
||||||
|
|
||||||
final Object saObj = record.get("savings_account_id");
|
final Object saObj = record.get("savings_account_id");
|
||||||
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
|
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
|
||||||
@@ -793,14 +832,147 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
return rows.isEmpty() ? null : rows.get(0);
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Long toLong(final Object value) {
|
||||||
|
return value instanceof Number n ? n.longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one
|
||||||
|
* Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under
|
||||||
|
* the id of its own journal entry in the cashier's position and closing report.
|
||||||
|
*/
|
||||||
|
private Long parseTxnKey(final String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.valueOf(raw);
|
||||||
|
} catch (final NumberFormatException ignored) {
|
||||||
|
try {
|
||||||
|
final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw;
|
||||||
|
return Long.parseUnsignedLong(hex, 16);
|
||||||
|
} catch (final NumberFormatException ignored2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the
|
||||||
|
* GL it is paid from must be one of the branch's mapped cash accounts. */
|
||||||
|
private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) {
|
||||||
|
if (glAccountId == null || contraGlId == null) {
|
||||||
|
throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request");
|
||||||
|
}
|
||||||
|
final List<Long> cashGlIds = jdbcTemplate.queryForList(
|
||||||
|
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)",
|
||||||
|
Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue());
|
||||||
|
if (!cashGlIds.contains(contraGlId)) {
|
||||||
|
throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL.");
|
||||||
|
}
|
||||||
|
if (cashGlIds.contains(glAccountId)) {
|
||||||
|
throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is
|
||||||
|
* switched off for manual postings). Returns the journal entry's transaction id. */
|
||||||
|
private String executeGlWithdrawalJournal(final Map<?, ?> record, final BigDecimal amount,
|
||||||
|
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||||
|
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||||
|
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||||
|
|
||||||
|
final JsonObject debit = new JsonObject();
|
||||||
|
debit.addProperty("glAccountId", toLong(record.get("gl_account_id")));
|
||||||
|
debit.addProperty("amount", amount);
|
||||||
|
final JsonObject credit = new JsonObject();
|
||||||
|
credit.addProperty("glAccountId", toLong(record.get("contra_gl_id")));
|
||||||
|
credit.addProperty("amount", amount);
|
||||||
|
final JsonArray debits = new JsonArray();
|
||||||
|
debits.add(debit);
|
||||||
|
final JsonArray credits = new JsonArray();
|
||||||
|
credits.add(credit);
|
||||||
|
|
||||||
|
final Object office = record.get("office_id");
|
||||||
|
final Object currency = record.get("currency_code");
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L);
|
||||||
|
payload.addProperty("transactionDate", date);
|
||||||
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR");
|
||||||
|
if (note != null && !note.isBlank()) {
|
||||||
|
payload.addProperty("comments", note);
|
||||||
|
}
|
||||||
|
if (paymentTypeId != null) {
|
||||||
|
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||||
|
}
|
||||||
|
payload.add("credits", credits);
|
||||||
|
payload.add("debits", debits);
|
||||||
|
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
|
// The request raised its own voucher at submission; the journal command must not raise a second.
|
||||||
|
final CommandProcessingResult result = voucherService
|
||||||
|
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||||
|
return result.getTransactionId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */
|
||||||
|
private void addGlWithdrawalVoucherLines(final Map<?, ?> record, final BigDecimal amount) {
|
||||||
|
final String voucherNo = (String) record.get("voucher_no");
|
||||||
|
if (voucherNo == null || voucherNo.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final Long glId = toLong(record.get("gl_account_id"));
|
||||||
|
final Long contraId = toLong(record.get("contra_gl_id"));
|
||||||
|
final Map<String, Object> gl = glAccountForVoucher(glId);
|
||||||
|
final Map<String, Object> contra = glAccountForVoucher(contraId);
|
||||||
|
voucherService.addLines(voucherNo, List.of(
|
||||||
|
VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount),
|
||||||
|
VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||||
|
if (glAccountId == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||||
|
.orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
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("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
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,
|
amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,
|
||||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||||
denomination_data, activity_log, submitted_at, actioned_at,
|
denomination_data, activity_log, submitted_at, actioned_at,
|
||||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||||
|
gl_account_id, contra_gl_id, office_id, currency_code
|
||||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||||
""", id);
|
""", id);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
|
|||||||
+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.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.List;
|
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.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||||
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycNoticeComposer;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,6 +67,7 @@ public class ClientKycContactLogApiResource {
|
|||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final KycContactLogService kycContactLogService;
|
private final KycContactLogService kycContactLogService;
|
||||||
|
private final KycNoticeComposer noticeComposer;
|
||||||
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -79,11 +82,31 @@ public class ClientKycContactLogApiResource {
|
|||||||
|
|
||||||
final JsonObject payload = parse(apiRequestBodyAsJson);
|
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||||
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
|
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);
|
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
|
@GET
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(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());
|
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
|
||||||
return this.toApiJsonSerializer.serialize(lapsed);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+434
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* 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, @QueryParam("real") final Boolean real) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
final String where = filters(from, to, channel, status, search, real, args);
|
||||||
|
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
|
||||||
|
+ "COUNT(DISTINCT CASE WHEN l.delivery_status = 'SENT' THEN l.client_id END) AS notified, "
|
||||||
|
+ "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")));
|
||||||
|
// Customers a notice actually reached that day, as against customers it was tried for.
|
||||||
|
item.put("notifiedCustomers", number(r.get("notified")));
|
||||||
|
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("real") final Boolean real,
|
||||||
|
@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, real, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line per customer: how many notices, how they fared, and the latest. Newest activity first, a page at a time.
|
||||||
|
* Answers "who was contacted, and what happened" without paging through every day.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("customers")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Notices per customer", description = "Same filters as the day view. Returns total and rows.")
|
||||||
|
public String customers(@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("real") final Boolean real,
|
||||||
|
@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, real, args);
|
||||||
|
|
||||||
|
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(DISTINCT l.client_id)" + 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.client_id, c.display_name, c.account_no, COUNT(*) AS notices, "
|
||||||
|
+ "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 IN ('SKIPPED_NO_DESTINATION', 'PROVIDER_NOT_CONFIGURED') THEN 1 ELSE 0 END) AS not_sent, "
|
||||||
|
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded, "
|
||||||
|
+ "MAX(l.contacted_at) AS last_at" + FROM_CLAUSE + where
|
||||||
|
+ " GROUP BY l.client_id, c.display_name, c.account_no ORDER BY last_at DESC, l.client_id DESC LIMIT " + rows + " OFFSET "
|
||||||
|
+ skip;
|
||||||
|
|
||||||
|
final List<Map<String, Object>> list = new ArrayList<>();
|
||||||
|
final List<Long> pageIds = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||||
|
final Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("clientId", r.get("client_id"));
|
||||||
|
item.put("clientName", r.get("display_name"));
|
||||||
|
item.put("accountNo", r.get("account_no"));
|
||||||
|
item.put("notices", number(r.get("notices")));
|
||||||
|
item.put("delivered", number(r.get("delivered")));
|
||||||
|
item.put("failed", number(r.get("failed")));
|
||||||
|
item.put("notSent", number(r.get("not_sent")));
|
||||||
|
item.put("recorded", number(r.get("recorded")));
|
||||||
|
final Object at = r.get("last_at");
|
||||||
|
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
|
||||||
|
pageIds.add(((Number) r.get("client_id")).longValue());
|
||||||
|
list.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest notice's channel and outcome, for the customers on this page only.
|
||||||
|
if (!pageIds.isEmpty()) {
|
||||||
|
final String marks = String.join(",", pageIds.stream().map(x -> "?").toList());
|
||||||
|
final Map<Long, Map<String, Object>> byId = new LinkedHashMap<>();
|
||||||
|
list.forEach((m) -> byId.put(((Number) m.get("clientId")).longValue(), m));
|
||||||
|
final List<Object> latestArgs = new ArrayList<>(pageIds);
|
||||||
|
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT client_id, channel, delivery_status FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
|
||||||
|
+ ") ORDER BY contacted_at DESC, id DESC",
|
||||||
|
latestArgs.toArray())) {
|
||||||
|
final Map<String, Object> item = byId.get(((Number) r.get("client_id")).longValue());
|
||||||
|
if (item != null && !item.containsKey("lastChannel")) {
|
||||||
|
item.put("lastChannel", r.get("channel"));
|
||||||
|
item.put("lastStatus", r.get("delivery_status"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("total", total == null ? 0 : total);
|
||||||
|
out.put("rows", list);
|
||||||
|
return this.gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the listed customers have been sent so far: the latest notice and which channels have been done.
|
||||||
|
* One round trip for a whole screen of customers, so a list can show "last notice" without a call per row.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("clients")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Notice summary for some customers", description = "ids is a comma-separated list of client ids (up to 500).")
|
||||||
|
public String clients(@QueryParam("ids") final String ids) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final List<Long> clientIds = new ArrayList<>();
|
||||||
|
if (StringUtils.isNotBlank(ids)) {
|
||||||
|
for (final String part : ids.split(",")) {
|
||||||
|
if (StringUtils.isNumeric(part.trim()) && clientIds.size() < MAX_PAGE) {
|
||||||
|
clientIds.add(Long.valueOf(part.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clientIds.isEmpty()) {
|
||||||
|
return this.gson.toJson(new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
final String marks = String.join(",", clientIds.stream().map(x -> "?").toList());
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT client_id, channel, delivery_status, contacted_at, message_body FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
|
||||||
|
+ ") ORDER BY contacted_at DESC, id DESC",
|
||||||
|
clientIds.toArray());
|
||||||
|
|
||||||
|
final Map<Long, Map<String, Object>> byClient = new LinkedHashMap<>();
|
||||||
|
// Every customer asked for comes back, with or without notices, so the screen knows who has no mobile or email.
|
||||||
|
for (final Map<String, Object> c : this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT c.id, c.mobile_no, c.email_address, "
|
||||||
|
+ "COALESCE((SELECT SUM(s.account_balance_derived) FROM m_savings_account s WHERE s.client_id = c.id AND s.status_enum = 300), 0) "
|
||||||
|
+ "+ COALESCE((SELECT SUM(a.account_balance_derived) FROM m_current_account a WHERE a.client_id = c.id AND a.status_enum = 300), 0) AS balance "
|
||||||
|
+ "FROM m_client c WHERE c.id IN (" + marks + ")", clientIds.toArray())) {
|
||||||
|
final Long id = ((Number) c.get("id")).longValue();
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("clientId", id);
|
||||||
|
m.put("count", 0);
|
||||||
|
m.put("lastAt", null);
|
||||||
|
m.put("lastChannel", null);
|
||||||
|
m.put("lastStatus", null);
|
||||||
|
m.put("doneChannels", new ArrayList<String>());
|
||||||
|
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
|
||||||
|
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
|
||||||
|
// What the customer holds in open savings and current accounts.
|
||||||
|
m.put("balance", c.get("balance"));
|
||||||
|
m.put("hasMobile", StringUtils.isNotBlank((String) c.get("mobile_no")));
|
||||||
|
m.put("hasEmail", StringUtils.isNotBlank((String) c.get("email_address")));
|
||||||
|
// Masked, never the full value: the screen only needs to show that something is on file.
|
||||||
|
m.put("mobileMasked", maskMobile((String) c.get("mobile_no")));
|
||||||
|
m.put("emailMasked", maskEmail((String) c.get("email_address")));
|
||||||
|
byClient.put(id, m);
|
||||||
|
}
|
||||||
|
for (final Map<String, Object> r : rows) {
|
||||||
|
final Long clientId = ((Number) r.get("client_id")).longValue();
|
||||||
|
final Map<String, Object> item = byClient.computeIfAbsent(clientId, k -> {
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("clientId", k);
|
||||||
|
m.put("count", 0);
|
||||||
|
m.put("lastAt", null);
|
||||||
|
m.put("lastChannel", null);
|
||||||
|
m.put("lastStatus", null);
|
||||||
|
m.put("doneChannels", new ArrayList<String>());
|
||||||
|
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
|
||||||
|
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
if (item.get("lastAt") == null) {
|
||||||
|
// Rows come newest first, so the first one seen is the latest notice.
|
||||||
|
final Object at = r.get("contacted_at");
|
||||||
|
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
|
||||||
|
item.put("lastChannel", r.get("channel"));
|
||||||
|
item.put("lastStatus", r.get("delivery_status"));
|
||||||
|
}
|
||||||
|
item.put("count", ((Integer) item.get("count")) + 1);
|
||||||
|
// A channel counts as done once it reached the customer, or was recorded (a call or a visit).
|
||||||
|
final String status = String.valueOf(r.get("delivery_status"));
|
||||||
|
final String channel = String.valueOf(r.get("channel"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final List<String> done = (List<String>) item.get("doneChannels");
|
||||||
|
if (("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) && !done.contains(channel)) {
|
||||||
|
done.add(channel);
|
||||||
|
}
|
||||||
|
// The same, by the stage the notice was written for, so a notice sent at an earlier stage is not taken for this one.
|
||||||
|
// A call, a visit or any other record that is not a stage notice is filed under ANY: it counts at whatever stage the customer is at.
|
||||||
|
final String found = stageOfNotice((String) r.get("message_body"));
|
||||||
|
final String stage = found == null ? "ANY" : found;
|
||||||
|
if ("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final Map<String, List<String>> byStage = (Map<String, List<String>>) item.get("doneByStage");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final Map<String, Integer> counts = (Map<String, Integer>) item.get("noticesByStage");
|
||||||
|
counts.merge(stage, 1, Integer::sum);
|
||||||
|
final List<String> inStage = byStage.computeIfAbsent(stage, k -> new ArrayList<>());
|
||||||
|
if (!inStage.contains(channel)) {
|
||||||
|
inStage.add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.gson.toJson(new ArrayList<>(byClient.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 Boolean real, final List<Object> args) {
|
||||||
|
final StringBuilder where = new StringBuilder();
|
||||||
|
if (Boolean.TRUE.equals(real)) {
|
||||||
|
// An attempt that went nowhere (no number or address on file, or no gateway set up) is not a notice a customer received.
|
||||||
|
where.append(" AND l.delivery_status IN ('SENT', 'FAILED', 'NOT_APPLICABLE')");
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage a notice was written for, read from its wording (the composer words every stage differently). Null for a call,
|
||||||
|
* a visit or any message that is not one of the stage notices.
|
||||||
|
*/
|
||||||
|
private static String stageOfNotice(final String body) {
|
||||||
|
if (StringUtils.isBlank(body)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (body.startsWith("Your KYC expires on")) {
|
||||||
|
return "EXPIRING";
|
||||||
|
}
|
||||||
|
if (body.startsWith("Your KYC expired on")) {
|
||||||
|
if (body.contains("account is now inoperative")) {
|
||||||
|
return "INOPERATIVE";
|
||||||
|
}
|
||||||
|
return body.contains("debits on your account are now stopped") ? "PARTIAL_FREEZE" : "GRACE";
|
||||||
|
}
|
||||||
|
if (body.startsWith("Your account ")) {
|
||||||
|
if (body.contains("has been closed")) {
|
||||||
|
return "ESCHEAT";
|
||||||
|
}
|
||||||
|
return body.contains("is dormant") ? "DORMANT" : body.contains("is inactive") ? "INACTIVE" : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskMobile(final String mobile) {
|
||||||
|
if (StringUtils.isBlank(mobile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String digits = mobile.trim();
|
||||||
|
return digits.length() <= 4 ? digits : "XXXXXX" + digits.substring(digits.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskEmail(final String email) {
|
||||||
|
if (StringUtils.isBlank(email) || !email.contains("@")) {
|
||||||
|
return StringUtils.isBlank(email) ? null : email;
|
||||||
|
}
|
||||||
|
final String trimmed = email.trim();
|
||||||
|
final int at = trimmed.indexOf('@');
|
||||||
|
return trimmed.substring(0, Math.min(1, at)) + "***" + trimmed.substring(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 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());
|
||||||
|
out.addProperty("unreachable", result.unreachable());
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -33,6 +33,8 @@ public final class ClientKycApiConstants {
|
|||||||
public static final String riskCategoryParam = "riskCategory";
|
public static final String riskCategoryParam = "riskCategory";
|
||||||
public static final String panNumberParam = "panNumber";
|
public static final String panNumberParam = "panNumber";
|
||||||
public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked";
|
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 gstinNumberParam = "gstinNumber";
|
||||||
public static final String remarksParam = "remarks";
|
public static final String remarksParam = "remarks";
|
||||||
public static final String ckycNumberParam = "ckycNumber";
|
public static final String ckycNumberParam = "ckycNumber";
|
||||||
@@ -118,7 +120,7 @@ public final class ClientKycApiConstants {
|
|||||||
|
|
||||||
// ── Supported parameters for initiate/update ──────────────────────────────
|
// ── Supported parameters for initiate/update ──────────────────────────────
|
||||||
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
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",
|
gstinNumberParam, remarksParam, "locale", "dateFormat",
|
||||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||||
@@ -136,7 +138,7 @@ public final class ClientKycApiConstants {
|
|||||||
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
|
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
|
||||||
|
|
||||||
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
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",
|
riskCategoryParam, remarksParam, "locale", "dateFormat",
|
||||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||||
|
|||||||
+8
-1
@@ -64,6 +64,8 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
* used in matching; a null maximum means the rule is genuinely open-ended. */
|
* used in matching; a null maximum means the rule is genuinely open-ended. */
|
||||||
private final BigDecimal txnMinLimitAmount;
|
private final BigDecimal txnMinLimitAmount;
|
||||||
private final BigDecimal txnMaxLimitAmount;
|
private final BigDecimal txnMaxLimitAmount;
|
||||||
|
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
|
||||||
|
private final boolean ccRelevant;
|
||||||
|
|
||||||
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
||||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||||
@@ -76,7 +78,7 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
||||||
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
||||||
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
||||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
|
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.ruleName = ruleName;
|
this.ruleName = ruleName;
|
||||||
this.ruleKind = ruleKind;
|
this.ruleKind = ruleKind;
|
||||||
@@ -106,6 +108,7 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
|
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
|
||||||
this.txnMinLimitAmount = txnMinLimitAmount;
|
this.txnMinLimitAmount = txnMinLimitAmount;
|
||||||
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
||||||
|
this.ccRelevant = ccRelevant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -223,4 +226,8 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
public BigDecimal getTxnMaxLimitAmount() {
|
public BigDecimal getTxnMaxLimitAmount() {
|
||||||
return txnMaxLimitAmount;
|
return txnMaxLimitAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isCcRelevant() {
|
||||||
|
return ccRelevant;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
|
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
|
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ public class ClientRepositoryWrapper {
|
|||||||
|
|
||||||
private final ClientRepository repository;
|
private final ClientRepository repository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Client findOneWithNotFoundDetection(final Long id) {
|
public Client findOneWithNotFoundDetection(final Long id) {
|
||||||
@@ -94,12 +96,33 @@ public class ClientRepositoryWrapper {
|
|||||||
|
|
||||||
public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) {
|
public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) {
|
||||||
Client client = this.repository.fetchByClientIdAndHierarchy(clientId, hierarchySearchString, 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) {
|
if (client == null) {
|
||||||
throw new ClientNotFoundException(clientId.toString(), "client.id");
|
throw new ClientNotFoundException(clientId.toString(), "client.id");
|
||||||
}
|
}
|
||||||
return client;
|
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) {
|
public Long findIdByExternalId(final ExternalId externalId) {
|
||||||
return this.repository.findIdByExternalId(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.time.format.DateTimeParseException;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
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.CodeValue;
|
||||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
@@ -275,7 +276,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
|
|
||||||
// CBS extended fields
|
// CBS extended fields
|
||||||
clientFamilyMembers.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
|
clientFamilyMembers.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
|
||||||
clientFamilyMembers.setPanNumber(command.stringValueOfParameterNamed("panNumber"));
|
clientFamilyMembers.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber")));
|
||||||
clientFamilyMembers.setNationality(command.stringValueOfParameterNamed("nationality"));
|
clientFamilyMembers.setNationality(command.stringValueOfParameterNamed("nationality"));
|
||||||
final Boolean isAlive = command.booleanObjectValueOfParameterNamed("isAlive");
|
final Boolean isAlive = command.booleanObjectValueOfParameterNamed("isAlive");
|
||||||
clientFamilyMembers.setIsAlive(isAlive != null ? isAlive : true);
|
clientFamilyMembers.setIsAlive(isAlive != null ? isAlive : true);
|
||||||
@@ -426,7 +427,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
familyMember.setAadhaarNumber(member.get("aadhaarNumber").getAsString());
|
familyMember.setAadhaarNumber(member.get("aadhaarNumber").getAsString());
|
||||||
}
|
}
|
||||||
if (member.get("panNumber") != null && !member.get("panNumber").isJsonNull()) {
|
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()) {
|
if (member.get("nationality") != null && !member.get("nationality").isJsonNull()) {
|
||||||
familyMember.setNationality(member.get("nationality").getAsString());
|
familyMember.setNationality(member.get("nationality").getAsString());
|
||||||
@@ -585,7 +586,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
clientFamilyMember.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
|
clientFamilyMember.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
|
||||||
}
|
}
|
||||||
if (command.parameterExists("panNumber")) {
|
if (command.parameterExists("panNumber")) {
|
||||||
clientFamilyMember.setPanNumber(command.stringValueOfParameterNamed("panNumber"));
|
clientFamilyMember.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber")));
|
||||||
}
|
}
|
||||||
if (command.parameterExists("nationality")) {
|
if (command.parameterExists("nationality")) {
|
||||||
clientFamilyMember.setNationality(command.stringValueOfParameterNamed("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.
|
* included: nothing is owed on it yet.
|
||||||
*/
|
*/
|
||||||
java.util.List<ClientKycData> retrieveLapsedKyc();
|
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)
|
.map(this::toData)
|
||||||
.collect(java.util.stream.Collectors.toList());
|
.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 org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
|
||||||
private final SavingsAccountRepository savingsAccountRepository;
|
private final SavingsAccountRepository savingsAccountRepository;
|
||||||
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
|
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). */
|
/** 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;
|
private static final int CKYC_ZIP_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
@@ -85,7 +86,8 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult initiateKyc(final Long clientId, final JsonCommand command) {
|
public CommandProcessingResult initiateKyc(final Long clientId, final JsonCommand command) {
|
||||||
this.context.authenticatedUser();
|
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);
|
final Client clientForValidation = clientRepository.findOneWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
@@ -108,6 +110,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
.build();
|
.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 {
|
try {
|
||||||
final Client client = clientForValidation;
|
final Client client = clientForValidation;
|
||||||
final ClientKyc kyc = ClientKyc.initiateKyc(client, command);
|
final ClientKyc kyc = ClientKyc.initiateKyc(client, command);
|
||||||
@@ -145,6 +154,18 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
|
|
||||||
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
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);
|
final Map<String, Object> changes = kyc.update(command);
|
||||||
|
|
||||||
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
|
// 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());
|
// this.context.validateAccessRights(searchParameters.getHierarchy());
|
||||||
// underHierarchySearchString = 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);
|
final StringBuilder sqlBuilder = new StringBuilder(200);
|
||||||
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
|
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
|
||||||
sqlBuilder.append(this.clientToDataMapper.schema());
|
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 != null) {
|
||||||
if (searchParameters.getIsSelfUser()) {
|
if (searchParameters.getIsSelfUser()) {
|
||||||
|
|||||||
+3
-1
@@ -274,7 +274,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
aadhaar = this.fromApiJsonHelper.extractStringNamed(
|
aadhaar = this.fromApiJsonHelper.extractStringNamed(
|
||||||
org.apache.fineract.portfolio.client.data.ClientKycApiConstants.aadhaarNumberParam, kyc);
|
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 String masked = aadhaar.length() == 12 ? "XXXXXXXX" + aadhaar.substring(8) : aadhaar;
|
||||||
final boolean clash = excludeClientId == null
|
final boolean clash = excludeClientId == null
|
||||||
? clientKycRepository.existsByAadhaarMasked(masked)
|
? clientKycRepository.existsByAadhaarMasked(masked)
|
||||||
|
|||||||
+31
@@ -42,6 +42,37 @@ public interface KycContactLogService {
|
|||||||
*/
|
*/
|
||||||
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
|
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. */
|
/** All contact attempts for one customer, oldest first. */
|
||||||
List<KycContactLogData> retrieveForClient(Long clientId);
|
List<KycContactLogData> retrieveForClient(Long clientId);
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-5
@@ -42,6 +42,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -64,21 +65,30 @@ public class KycContactLogServiceImpl implements KycContactLogService {
|
|||||||
private final CustomerNotificationDispatcher dispatcher;
|
private final CustomerNotificationDispatcher dispatcher;
|
||||||
private final EmailTemplateService emailTemplateService;
|
private final EmailTemplateService emailTemplateService;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
|
private final KycNoticeComposer noticeComposer;
|
||||||
|
|
||||||
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
|
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
|
||||||
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
|
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.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||||
this.kycRepository = kycRepository;
|
this.kycRepository = kycRepository;
|
||||||
this.contactLogRepository = contactLogRepository;
|
this.contactLogRepository = contactLogRepository;
|
||||||
this.dispatcher = dispatcher;
|
this.dispatcher = dispatcher;
|
||||||
this.emailTemplateService = emailTemplateService;
|
this.emailTemplateService = emailTemplateService;
|
||||||
this.context = context;
|
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
|
@Override
|
||||||
@Transactional
|
@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);
|
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
|
||||||
if (channel == null) {
|
if (channel == null) {
|
||||||
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
|
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
|
||||||
@@ -99,11 +109,22 @@ public class KycContactLogServiceImpl implements KycContactLogService {
|
|||||||
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
|
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
|
||||||
|
|
||||||
if (channel.isPlatformSent()) {
|
if (channel.isPlatformSent()) {
|
||||||
final String subject = "Action Required: KYC Verification Pending - " + this.bankName;
|
String subject = "Action Required: KYC Verification Pending - " + this.bankName;
|
||||||
final String plainBody = buildPlainBody(client, exceptionReason, channel);
|
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.
|
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
|
||||||
final String htmlBody = channel == KycContactChannel.EMAIL
|
final String htmlBody = channel == KycContactChannel.EMAIL
|
||||||
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody)
|
? this.emailTemplateService.renderAlert(displayName(client), htmlTitle, plainBody)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
|
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
|
||||||
@@ -122,6 +143,63 @@ public class KycContactLogServiceImpl implements KycContactLogService {
|
|||||||
return toData(saved);
|
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
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<KycContactLogData> retrieveForClient(final Long clientId) {
|
public List<KycContactLogData> retrieveForClient(final Long clientId) {
|
||||||
|
|||||||
+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.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, and in how many days. */
|
||||||
|
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);
|
||||||
|
final String freezeIn = days(DEBIT_FREEZE_DAYS - offset);
|
||||||
|
final String inoperativeIn = days(INOPERATIVE_DAYS - offset);
|
||||||
|
if (offset < 0) {
|
||||||
|
return "Your KYC expires on " + expired + " (in " + days(-offset) + "). 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 + " (in " + freezeIn
|
||||||
|
+ ") and the account will become inoperative on " + inoperative + " (in " + inoperativeIn + ").";
|
||||||
|
}
|
||||||
|
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 + " (in " + freezeIn + ") and the account will become inoperative on "
|
||||||
|
+ inoperative + " (in " + inoperativeIn + ").";
|
||||||
|
}
|
||||||
|
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 + " (in " + inoperativeIn
|
||||||
|
+ ").";
|
||||||
|
}
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String days(final long n) {
|
||||||
|
return n == 1 ? "1 day" : n + " days";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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) + " (in " + days(account.getDaysToEscheat()) + ")."
|
||||||
|
: "";
|
||||||
|
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)
|
||||||
|
+ " (in " + days(account.getDaysToDormancy()) + ") 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 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, how many were left out because the same notice was already waiting, and how many because the
|
||||||
|
* customer has no mobile number or email address to send it to.
|
||||||
|
*/
|
||||||
|
record EnqueueResult(int queued, int skipped, int unreachable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
int unreachable = 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.");
|
||||||
|
}
|
||||||
|
// A customer with no mobile number (or no email, for Email) cannot be sent this. Queuing it would only write a
|
||||||
|
// "not sent" record every time, so it is left out and counted — those customers are found and reached another way.
|
||||||
|
final Map<String, Object> contact = this.jdbcTemplate.queryForList("SELECT mobile_no, email_address FROM m_client WHERE id = ?",
|
||||||
|
item.clientId()).stream().findFirst().orElse(null);
|
||||||
|
final String destination = contact == null ? null
|
||||||
|
: (String) contact.get(channel == KycContactChannel.EMAIL ? "email_address" : "mobile_no");
|
||||||
|
if (StringUtils.isBlank(destination)) {
|
||||||
|
unreachable++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 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, unreachable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
/**
|
||||||
|
* Days from expiry (negative = before) at which a KYC reminder goes out: 30, 15, 7 and 1 day before it expires, the
|
||||||
|
* day it expires, then 7 and 1 day before debits stop (day 90) and before the account goes inoperative (day 180), and
|
||||||
|
* on each of those two days themselves.
|
||||||
|
*/
|
||||||
|
private static final int[] KYC_MILESTONES = { -30, -15, -7, -1, 0, 83, 89, 90, 173, 179, 180 };
|
||||||
|
|
||||||
|
/** An account is warned 7 days and 1 day before it moves to its next stage. */
|
||||||
|
private static final long WARN_DAYS = 7;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// The step is "E" (the account has just entered this stage), "D7" (a week before the next) or "D1" (the day
|
||||||
|
// before). The latest one reached is what the customer is told, once.
|
||||||
|
final Long remaining = account.getSubStatus() == SUB_STATUS_DORMANT ? account.getDaysToEscheat() : account.getDaysToDormancy();
|
||||||
|
final String step = remaining != null && remaining <= 1 ? "D1" : remaining != null && remaining <= WARN_DAYS ? "D7" : "E";
|
||||||
|
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + " " + step + "]";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -92,7 +92,8 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
|||||||
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
|
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
|
||||||
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
|
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
|
||||||
txnRule != null ? txnRule.getMinLimitAmount() : null,
|
txnRule != null ? txnRule.getMinLimitAmount() : null,
|
||||||
txnRule != null ? txnRule.getMaxLimitAmount() : null
|
txnRule != null ? txnRule.getMaxLimitAmount() : null,
|
||||||
|
rule.isCcRelevant()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -64,7 +64,9 @@ public class ClientSearchService {
|
|||||||
|
|
||||||
Pageable pageable = searchRequest.toPageable();
|
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);
|
.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.
|
* not be able to link entities by omitting the param). Defaults to null = all legal forms.
|
||||||
*/
|
*/
|
||||||
private String legalFormType;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-2
@@ -137,6 +137,19 @@ public class LoanOriginationPipelineApiResource {
|
|||||||
// the updated application, so the client replaces its copy rather than guessing the result.
|
// the updated application, so the client replaces its copy rather than guessing the result.
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/deposit-selection-complete")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Complete Deposit Selection & Eligibility",
|
||||||
|
description = "Loan against a deposit only. Requires a saved deposit selection that covers the requested amount "
|
||||||
|
+ "and tenure; the credit bureau stage is skipped.")
|
||||||
|
public String completeDepositSelection(@PathParam("applicationNo") final String applicationNo,
|
||||||
|
final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.completeDepositSelection(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("{applicationNo}/kyc-complete")
|
@Path("{applicationNo}/kyc-complete")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@@ -239,8 +252,29 @@ public class LoanOriginationPipelineApiResource {
|
|||||||
+ "requires a resolution number and date. A REJECTED decision is always permitted.")
|
+ "requires a resolution number and date. A REJECTED decision is always permitted.")
|
||||||
public String decideApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
|
public String decideApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
|
||||||
final String apiRequestBodyAsJson) {
|
final String apiRequestBodyAsJson) {
|
||||||
this.context.authenticatedUser();
|
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson,
|
||||||
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson);
|
this.context.authenticatedUser().getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/approvals/{field}/submit")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Send a stage for approval",
|
||||||
|
description = "Maker step. The stage's checklist must be complete; a different user then approves or rejects it.")
|
||||||
|
public String submitApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
|
||||||
|
final String apiRequestBodyAsJson) {
|
||||||
|
return this.stageService.submitApproval(applicationNo, field, apiRequestBodyAsJson,
|
||||||
|
this.context.authenticatedUser().getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/approvals/{field}/withdraw")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Withdraw an approval request", description = "Only the user who sent the request can withdraw it.")
|
||||||
|
public String withdrawApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field) {
|
||||||
|
return this.stageService.withdrawApproval(applicationNo, field, this.context.authenticatedUser().getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package org.apache.fineract.portfolio.loanoriginationpipeline.service;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.event.business.BusinessEventListener;
|
||||||
|
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanStatusChangedBusinessEvent;
|
||||||
|
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
|
||||||
|
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Daily Deposit Scheme account pledged for a loan is held with a lien whose reason names the loan
|
||||||
|
* ({@link #reasonFor}). The DDS module and the loan module are not connected, so nothing else would ever lift
|
||||||
|
* that hold: this lifts it when the loan stops being live (repaid, closed, written off, withdrawn).
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* FD and RD liens need nothing like this: they are Fineract guarantor funding, which Fineract releases itself as the
|
||||||
|
* loan is repaid.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class DdsLienReleaseService {
|
||||||
|
|
||||||
|
private static final String LIEN_PREFIX = "Held for loan ";
|
||||||
|
|
||||||
|
private final BusinessEventNotifierService businessEventNotifierService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/** The text written on the DDS account's lien, and what is matched when the loan ends. */
|
||||||
|
public static String reasonFor(final String loanAccountNo, final String applicationNo) {
|
||||||
|
return LIEN_PREFIX + loanAccountNo + " (" + applicationNo + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void addListeners() {
|
||||||
|
this.businessEventNotifierService.addPostBusinessEventListener(LoanStatusChangedBusinessEvent.class,
|
||||||
|
new ReleaseOnLoanEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ReleaseOnLoanEnd implements BusinessEventListener<LoanStatusChangedBusinessEvent> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBusinessEvent(final LoanStatusChangedBusinessEvent event) {
|
||||||
|
final Loan loan = event.get();
|
||||||
|
if (loan == null || loan.getStatus() == null || loan.getStatus().isActiveOrAwaitingApprovalOrDisbursal()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int released = jdbcTemplate.update(
|
||||||
|
"UPDATE m_dds_party SET lien = FALSE, lien_reason = NULL WHERE lien = TRUE AND lien_reason LIKE ?",
|
||||||
|
LIEN_PREFIX + loan.getAccountNumber() + " (%");
|
||||||
|
if (released > 0) {
|
||||||
|
log.info("Released the lien on {} DDS account(s) held for loan {}", released, loan.getAccountNumber());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-1
@@ -185,7 +185,58 @@ public class LoanOriginationPipelineService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public String save(final String applicationNo, final String requestBodyAsJson) {
|
public String save(final String applicationNo, final String requestBodyAsJson) {
|
||||||
return storeApplication(applicationNo, parseObject(requestBodyAsJson));
|
final JsonObject stored = loadApplication(applicationNo);
|
||||||
|
// Once a stage is sent for approval nothing on the application may change until it is decided or withdrawn,
|
||||||
|
// so a full-object save is ignored and the stored application is returned as it is.
|
||||||
|
if (hasPendingApprovalRequest(stored)) {
|
||||||
|
return findOneAsJson(applicationNo);
|
||||||
|
}
|
||||||
|
// A disbursed or rejected application is final. A screen that was opened before that happened still holds the
|
||||||
|
// older copy, and saving it back would undo the disbursement (and lose the link to the loan account).
|
||||||
|
final String storedStatus = stringOrNull(stored, "overallStatus");
|
||||||
|
if ("DISBURSED".equals(storedStatus) || "REJECTED".equals(storedStatus)) {
|
||||||
|
return findOneAsJson(applicationNo);
|
||||||
|
}
|
||||||
|
final JsonObject incoming = parseObject(requestBodyAsJson);
|
||||||
|
// A copy that is further back than what is stored is a stale screen: another user or a stage endpoint has moved the
|
||||||
|
// application on since it was opened. Its save is ignored and the stored application is returned, so the screen
|
||||||
|
// picks up the real state instead of overwriting it.
|
||||||
|
if (furthestStageOf(incoming) < furthestStageOf(stored)) {
|
||||||
|
return findOneAsJson(applicationNo);
|
||||||
|
}
|
||||||
|
// What only the server decides (approval decisions and requests, the loan account it was disbursed to) is never
|
||||||
|
// taken from the screen's copy.
|
||||||
|
for (final String key : SERVER_OWNED_FIELDS) {
|
||||||
|
if (stored.has(key)) {
|
||||||
|
incoming.add(key, stored.get(key).deepCopy());
|
||||||
|
} else {
|
||||||
|
incoming.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storeApplication(applicationNo, incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final java.util.List<String> SERVER_OWNED_FIELDS = java.util.List.of("creditCommitteeApproval", "riskApproval",
|
||||||
|
"legalApproval", "postCheckApproval", "disbursementApproval", "approvalRequests", "fineractLoanId", "fineractLoanAccountNo");
|
||||||
|
|
||||||
|
private int furthestStageOf(final JsonObject app) {
|
||||||
|
final JsonElement value = app.get("furthestStageIndex");
|
||||||
|
return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber() ? value.getAsInt() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasPendingApprovalRequest(final JsonObject app) {
|
||||||
|
final JsonElement requests = app.get("approvalRequests");
|
||||||
|
if (requests == null || !requests.isJsonObject()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (final java.util.Map.Entry<String, JsonElement> entry : requests.getAsJsonObject().entrySet()) {
|
||||||
|
final JsonElement request = entry.getValue();
|
||||||
|
if (request.isJsonObject() && request.getAsJsonObject().has("status")
|
||||||
|
&& "PENDING".equals(request.getAsJsonObject().get("status").getAsString())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes a whole application object back. Shared by the full-object save and every per-stage
|
/** Writes a whole application object back. Shared by the full-object save and every per-stage
|
||||||
|
|||||||
+441
-36
@@ -36,9 +36,11 @@ import java.time.LocalDate;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -71,14 +73,59 @@ public class LoanOriginationStageService {
|
|||||||
private static final DateTimeFormatter WORKFLOW_DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
private static final DateTimeFormatter WORKFLOW_DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||||
|
|
||||||
/** Must stay in step with STAGE_ORDER on the Application Detail screen. */
|
/** Must stay in step with STAGE_ORDER on the Application Detail screen. */
|
||||||
private static final List<String> STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "CIC_CHECK", "FIELD_INVESTIGATION",
|
private static final List<String> STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "DEPOSIT_SELECTION", "CIC_CHECK", "FIELD_INVESTIGATION",
|
||||||
"PERSONAL_DISCUSSION", "CREDIT_COMMITTEE", "RISK_APPROVAL", "LEGAL_APPROVAL", "CUSTOMER_SIGNING", "CHEQUE_ACH",
|
"PERSONAL_DISCUSSION", "CREDIT_COMMITTEE", "RISK_APPROVAL", "LEGAL_APPROVAL", "CUSTOMER_SIGNING", "CHEQUE_ACH",
|
||||||
"POST_CHECK", "DISBURSED");
|
"POST_CHECK", "DISBURSED");
|
||||||
|
|
||||||
private static final List<String> FI_CHECK_KEYS = List.of("addressVerification", "officeVerification", "propertyVerification",
|
private static final List<String> FI_CHECK_KEYS = List.of("addressVerification", "officeVerification", "propertyVerification",
|
||||||
"shopVerification");
|
"shopVerification");
|
||||||
|
|
||||||
|
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security. */
|
||||||
|
private static final Set<String> DEPOSIT_PRODUCT_CODES = Set.of("FD", "RD", "DS", "LI");
|
||||||
|
|
||||||
private final LoanOriginationPipelineService pipelineService;
|
private final LoanOriginationPipelineService pipelineService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/** A loan above the product's limit on the deposit (85%) is allowed only as a recorded officer's discretion with a reason. */
|
||||||
|
private void requireWithinLimitOrException(final JsonObject app, final JsonObject selection) {
|
||||||
|
final double value = selection.has("securityValue") ? selection.get("securityValue").getAsDouble() : 0;
|
||||||
|
final double limitPct = selection.has("limitPct") ? selection.get("limitPct").getAsDouble() : 85;
|
||||||
|
final double requested = intOr(app, "requestedAmount", 0);
|
||||||
|
if (requested > value * limitPct / 100 + 0.5) {
|
||||||
|
require(bool(selection, "over85") && !strOr(selection, "over85Reason", "").isBlank(),
|
||||||
|
"The loan is above " + (int) limitPct + "% of the deposit; record the reason for sanctioning above the limit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Product Creation sub types of the loans against a deposit or policy. */
|
||||||
|
private static final Set<String> DEPOSIT_SUB_TYPES = Set.of("fd", "rd", "dds", "lic");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A loan against a deposit is told by the product's own sub type (chosen in Product Creation), so a product made
|
||||||
|
* later under any short name (a second FD product, say) follows the deposit flow. The fixed short names stay as
|
||||||
|
* the answer for the original products.
|
||||||
|
*/
|
||||||
|
private boolean isDepositLoan(final JsonObject app) {
|
||||||
|
final String code = str(app, "productCode");
|
||||||
|
if (code == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return DEPOSIT_PRODUCT_CODES.contains(code) || DEPOSIT_SUB_TYPES.contains(productSubType(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isLicLoan(final JsonObject app) {
|
||||||
|
final String code = str(app, "productCode");
|
||||||
|
return code != null && ("LI".equals(code) || "lic".equals(productSubType(code)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sub type saved with the loan product of this short name; empty when the product has none. */
|
||||||
|
private String productSubType(final String shortName) {
|
||||||
|
final List<String> found = jdbcTemplate.queryForList(
|
||||||
|
"select e.product_type_sub_group from m_product_loan p join m_product_loan_advance_ext e on e.loan_product_id = p.id"
|
||||||
|
+ " where p.short_name = ?",
|
||||||
|
String.class, shortName);
|
||||||
|
return found.isEmpty() || found.get(0) == null ? "" : found.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
// ── KYC & Documents ──────────────────────────────────────────────────────
|
// ── KYC & Documents ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -90,16 +137,83 @@ public class LoanOriginationStageService {
|
|||||||
|
|
||||||
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
||||||
require(bool(app, "panVerified"), "PAN has not been verified.");
|
require(bool(app, "panVerified"), "PAN has not been verified.");
|
||||||
|
requirePurposeAndPriorityClassification(app);
|
||||||
|
final boolean depositLoan = isDepositLoan(app);
|
||||||
|
if (!depositLoan) {
|
||||||
|
// For a loan against a deposit these are optional: the deposit is the security.
|
||||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||||
require(bool(app, "bankStatementSubmitted"), "Bank statement has not been submitted.");
|
require(bool(app, "bankStatementSubmitted"), "Bank statement has not been submitted.");
|
||||||
require(bool(app, "electricityBillSubmitted"), "Address proof (electricity bill) has not been submitted.");
|
require(bool(app, "electricityBillSubmitted"), "Address proof (electricity bill) has not been submitted.");
|
||||||
require(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed.");
|
require(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed.");
|
||||||
|
}
|
||||||
for (final JsonElement element : arr(app, "productDocuments")) {
|
for (final JsonElement element : arr(app, "productDocuments")) {
|
||||||
final JsonObject doc = element.getAsJsonObject();
|
final JsonObject doc = element.getAsJsonObject();
|
||||||
require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (app.has("guarantorsRequiredCount")) {
|
||||||
|
requireGuarantorsNamed(app);
|
||||||
|
}
|
||||||
|
|
||||||
advance(app, actor(input), "KYC Complete", strOr(input, "remarks", "KYC and document collection complete."));
|
advance(app, actor(input), "KYC Complete", strOr(input, "remarks", "KYC and document collection complete."));
|
||||||
|
if (!depositLoan) {
|
||||||
|
// Deposit selection only exists for a loan against a deposit; every other product passes straight through.
|
||||||
|
advance(app, actor(input), "Deposit Selection Not Required", "Not a loan against a deposit.");
|
||||||
|
}
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every loan states what it is for and how it is classified under the RBI priority sector lending directions before
|
||||||
|
* KYC is complete: the purpose, the priority sector decision (with its category when it is priority sector) and
|
||||||
|
* whether the borrower is a weaker section (with its type when it is).
|
||||||
|
*/
|
||||||
|
private void requirePurposeAndPriorityClassification(final JsonObject app) {
|
||||||
|
require(!strOr(app, "loanPurpose", "").isBlank(), "The purpose of the loan has not been entered.");
|
||||||
|
final String priority = strOr(app, "pslClassified", "");
|
||||||
|
require("yes".equals(priority) || "no".equals(priority), "The priority sector decision has not been made.");
|
||||||
|
if ("yes".equals(priority)) {
|
||||||
|
require(!strOr(app, "prioritySectorCategory", "").isBlank(), "The priority sector category has not been chosen.");
|
||||||
|
}
|
||||||
|
final String weaker = strOr(app, "weakerSection", "");
|
||||||
|
require("yes".equals(weaker) || "no".equals(weaker), "Whether the borrower is a weaker section has not been decided.");
|
||||||
|
if ("yes".equals(weaker)) {
|
||||||
|
require(!strOr(app, "weakerSectionType", "").isBlank(), "The weaker section type has not been chosen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deposit Selection & Eligibility (loan against FD / RD / DDS / LIC) ───
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String completeDepositSelection(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "DEPOSIT_SELECTION");
|
||||||
|
require(isDepositLoan(app), "Deposit selection applies only to a loan against a deposit.");
|
||||||
|
|
||||||
|
final JsonObject selection = obj(app, "depositSelection");
|
||||||
|
require(selection != null && bool(selection, "saved"), "No deposit has been selected and saved for this application.");
|
||||||
|
if (isLicLoan(app)) {
|
||||||
|
// A LIC policy is not a bank account: it is recorded on the application from the policy documents.
|
||||||
|
final JsonObject policy = obj(selection, "policy");
|
||||||
|
require(policy != null && !strOr(policy, "policyNo", "").isBlank(), "The LIC policy number has not been recorded.");
|
||||||
|
require(intOr(policy, "surrenderValue", 0) > 0, "The surrender value of the policy has not been recorded.");
|
||||||
|
require(!strOr(policy, "maturityDate", "").isBlank(), "The maturity date of the policy has not been recorded.");
|
||||||
|
} else {
|
||||||
|
require(!arr(selection, "accountIds").isEmpty(), "Select at least one deposit.");
|
||||||
|
}
|
||||||
|
require(intOr(selection, "eligibleAmount", 0) >= intOr(app, "requestedAmount", 0),
|
||||||
|
"The requested amount is more than the eligible amount on the selected deposits.");
|
||||||
|
require(intOr(app, "tenureMonths", 0) <= intOr(selection, "maxTenure", 0),
|
||||||
|
"The tenure runs past the maturity of the selected deposits.");
|
||||||
|
requireWithinLimitOrException(app, selection);
|
||||||
|
|
||||||
|
advance(app, actor(input), "Deposit Selection Complete",
|
||||||
|
strOr(input, "remarks", "Deposit selected and eligibility confirmed."));
|
||||||
|
// No credit bureau check on a loan against a deposit: the stage is recorded as not required and skipped.
|
||||||
|
// The deposit is the security: no credit bureau check and no field investigation. Every other stage stays.
|
||||||
|
advance(app, actor(input), "CIC Not Required", "Loan against a deposit - no credit bureau check.");
|
||||||
|
advance(app, actor(input), "Field Investigation Not Required", "Loan against a deposit - no field investigation.");
|
||||||
return pipelineService.storeApplication(applicationNo, app);
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +300,8 @@ public class LoanOriginationStageService {
|
|||||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
final JsonObject input = parseObject(body);
|
final JsonObject input = parseObject(body);
|
||||||
requireStage(app, "PERSONAL_DISCUSSION");
|
requireStage(app, "PERSONAL_DISCUSSION");
|
||||||
require(bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled.");
|
// A loan against a deposit has no meeting to schedule: the loan plan is reviewed instead.
|
||||||
|
require(isDepositLoan(app) || bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled.");
|
||||||
|
|
||||||
final String remarks = strOr(input, "remarks", "No discrepancy found — cleared to proceed to Loan Committee.");
|
final String remarks = strOr(input, "remarks", "No discrepancy found — cleared to proceed to Loan Committee.");
|
||||||
app.addProperty("discrepancyType", "NONE");
|
app.addProperty("discrepancyType", "NONE");
|
||||||
@@ -247,74 +362,247 @@ public class LoanOriginationStageService {
|
|||||||
*
|
*
|
||||||
* @param field one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval
|
* @param field one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval
|
||||||
*/
|
*/
|
||||||
@Transactional
|
private String approvalStage(final String field) {
|
||||||
public String decideApproval(final String applicationNo, final String field, final String body) {
|
return switch (field) {
|
||||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
|
||||||
final JsonObject input = parseObject(body);
|
|
||||||
|
|
||||||
final String decision = strOr(input, "decision", "APPROVED");
|
|
||||||
require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED").contains(decision), "Unknown decision: " + decision);
|
|
||||||
|
|
||||||
final String expectedStage = switch (field) {
|
|
||||||
case "creditCommitteeApproval" -> "CREDIT_COMMITTEE";
|
case "creditCommitteeApproval" -> "CREDIT_COMMITTEE";
|
||||||
case "riskApproval" -> "RISK_APPROVAL";
|
case "riskApproval" -> "RISK_APPROVAL";
|
||||||
case "legalApproval" -> "LEGAL_APPROVAL";
|
case "legalApproval" -> "LEGAL_APPROVAL";
|
||||||
case "postCheckApproval" -> "POST_CHECK";
|
case "postCheckApproval" -> "POST_CHECK";
|
||||||
|
// Sending the disbursement for approval: it is asked at the Disbursement stage and does not move the application on.
|
||||||
|
case "disbursementApproval" -> "DISBURSED";
|
||||||
default -> throw badRequest("Unknown approval stage: " + field);
|
default -> throw badRequest("Unknown approval stage: " + field);
|
||||||
};
|
};
|
||||||
requireStage(app, expectedStage);
|
}
|
||||||
|
|
||||||
// Prerequisites are only enforced for a positive decision: a rejection has to remain possible
|
private void checkApprovalPrerequisites(final JsonObject app, final String field, final JsonObject input) {
|
||||||
// at any point, and blocking it on an incomplete checklist would trap the application.
|
|
||||||
if (!"REJECTED".equals(decision)) {
|
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input);
|
case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input);
|
||||||
case "riskApproval" -> requireRiskChecklistReady(app);
|
case "riskApproval" -> requireRiskChecklistReady(app);
|
||||||
case "legalApproval" -> requireLegalChecklistDone(app);
|
case "legalApproval" -> requireLegalChecklistDone(app);
|
||||||
case "postCheckApproval" -> requirePostCheckSignatures(app);
|
case "postCheckApproval" -> requirePostCheckSignatures(app);
|
||||||
|
case "disbursementApproval" -> requireSanctionApprovalsStanding(app);
|
||||||
default -> throw badRequest("Unknown approval stage: " + field);
|
default -> throw badRequest("Unknown approval stage: " + field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String remarks = strOr(input, "remarks", "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved.");
|
/** The four sanctioning approvals must all stand before a disbursement can be sent for approval. */
|
||||||
if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision)) {
|
private void requireSanctionApprovalsStanding(final JsonObject app) {
|
||||||
|
final List<String> fields = isDepositLoan(app) ? List.of("creditCommitteeApproval", "postCheckApproval")
|
||||||
|
: List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval");
|
||||||
|
for (final String field : fields) {
|
||||||
|
final JsonObject approval = obj(app, field);
|
||||||
|
final String decision = approval == null ? null : str(approval, "decision");
|
||||||
|
require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision), "Cannot send for disbursement approval: " + field + " is not approved.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject pendingRequest(final JsonObject app, final String field) {
|
||||||
|
final JsonObject requests = obj(app, "approvalRequests");
|
||||||
|
final JsonObject request = requests == null ? null : obj(requests, field);
|
||||||
|
return request != null && "PENDING".equals(str(request, "status")) ? request : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maker step: the stage's checklist is complete, and the decision is sent to someone else. Nothing advances until
|
||||||
|
* a different user approves it.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String submitApproval(final String applicationNo, final String field, final String body, final String user) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
final String expectedStage = approvalStage(field);
|
||||||
|
requireStage(app, expectedStage);
|
||||||
|
require(pendingRequest(app, field) == null, "This stage has already been sent for approval.");
|
||||||
|
|
||||||
|
final String requested = strOr(input, "decision", "APPROVED");
|
||||||
|
require(List.of("APPROVED", "EXCEPTION_APPROVED").contains(requested), "Unknown decision: " + requested);
|
||||||
|
checkApprovalPrerequisites(app, field, input);
|
||||||
|
|
||||||
|
final String remarks = strOr(input, "remarks", "Sent for approval.");
|
||||||
|
final JsonObject request = new JsonObject();
|
||||||
|
request.addProperty("status", "PENDING");
|
||||||
|
request.addProperty("submittedBy", user);
|
||||||
|
request.addProperty("submittedOn", today());
|
||||||
|
request.addProperty("requestedDecision", requested);
|
||||||
|
request.addProperty("remarks", remarks);
|
||||||
|
request.addProperty("resolutionNo", strOr(input, "resolutionNo", ""));
|
||||||
|
request.addProperty("resolutionDate", strOr(input, "resolutionDate", ""));
|
||||||
|
// What the sender had verified when sending: kept as it was, for the approver to review.
|
||||||
|
if (input.has("checklist") && input.get("checklist").isJsonArray()) {
|
||||||
|
request.add("checklist", input.getAsJsonArray("checklist"));
|
||||||
|
}
|
||||||
|
final JsonObject oldRequests = obj(app, "approvalRequests");
|
||||||
|
final JsonObject oldRequest = oldRequests == null ? null : obj(oldRequests, field);
|
||||||
|
if (oldRequest != null && "REVOKED".equals(str(oldRequest, "status"))) {
|
||||||
|
request.addProperty("previousRevokeRemarks", strOr(oldRequest, "revokeRemarks", str(oldRequest, "remarks")));
|
||||||
|
request.addProperty("previousRevokedBy", str(oldRequest, "decidedBy"));
|
||||||
|
request.addProperty("previousRevokedOn", str(oldRequest, "decidedOn"));
|
||||||
|
}
|
||||||
|
objOrCreate(app, "approvalRequests").add(field, request);
|
||||||
|
|
||||||
|
appendHistory(app, expectedStage, user, "Sent for Approval", remarks);
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String withdrawApproval(final String applicationNo, final String field, final String user) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final String expectedStage = approvalStage(field);
|
||||||
|
requireStage(app, expectedStage);
|
||||||
|
final JsonObject request = pendingRequest(app, field);
|
||||||
|
require(request != null, "There is no pending approval request on this stage.");
|
||||||
|
require(user.equalsIgnoreCase(str(request, "submittedBy")), "Only the person who sent the request can withdraw it.");
|
||||||
|
request.addProperty("status", "WITHDRAWN");
|
||||||
|
appendHistory(app, expectedStage, user, "Approval Request Withdrawn", "Withdrawn by " + user + ".");
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The approver has to have ticked every row the sender verified, and the sender must not have left any row
|
||||||
|
* unverified. Requests sent before the checklist was recorded are not held to this.
|
||||||
|
*/
|
||||||
|
private void requireCheckerVerified(final JsonObject request, final JsonObject input) {
|
||||||
|
if (!request.has("checklist") || !request.get("checklist").isJsonArray()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final java.util.Set<String> ticked = new java.util.HashSet<>();
|
||||||
|
if (input.has("checkerChecklist") && input.get("checkerChecklist").isJsonArray()) {
|
||||||
|
for (final JsonElement element : input.getAsJsonArray("checkerChecklist")) {
|
||||||
|
final JsonObject row = element.getAsJsonObject();
|
||||||
|
if (bool(row, "ok")) {
|
||||||
|
ticked.add(str(row, "label"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final JsonElement element : request.getAsJsonArray("checklist")) {
|
||||||
|
final JsonObject row = element.getAsJsonObject();
|
||||||
|
final String label = strOr(row, "label", "item");
|
||||||
|
require(bool(row, "ok"), "Cannot approve: the sender did not verify \"" + label + "\".");
|
||||||
|
require(ticked.contains(label), "Tick every row you have verified before approving: \"" + label + "\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checker step: a different user approves, rejects or revokes (returns for rectification) what was sent. */
|
||||||
|
@Transactional
|
||||||
|
public String decideApproval(final String applicationNo, final String field, final String body, final String user) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
|
||||||
|
String decision = strOr(input, "decision", "APPROVED");
|
||||||
|
require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED", "REVOKED").contains(decision), "Unknown decision: " + decision);
|
||||||
|
|
||||||
|
final String expectedStage = approvalStage(field);
|
||||||
|
requireStage(app, expectedStage);
|
||||||
|
|
||||||
|
final JsonObject request = pendingRequest(app, field);
|
||||||
|
require(request != null, "This stage has not been sent for approval yet.");
|
||||||
|
require(!user.equalsIgnoreCase(str(request, "submittedBy")),
|
||||||
|
"The person who sent this for approval cannot approve, reject or revoke it. Ask another user.");
|
||||||
|
|
||||||
|
String remarks = strOr(input, "remarks", "").trim();
|
||||||
|
if ("REVOKED".equals(decision)) {
|
||||||
|
require(!remarks.isBlank(), "Remarks are required when returning an approval request for correction.");
|
||||||
|
} else if ("REJECTED".equals(decision)) {
|
||||||
|
require(!remarks.isBlank(), "Give the reason for rejecting in the remarks box.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"REJECTED".equals(decision) && !"REVOKED".equals(decision)) {
|
||||||
|
decision = strOr(request, "requestedDecision", "APPROVED");
|
||||||
|
requireCheckerVerified(request, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prerequisites are only enforced for a positive decision: a rejection or revocation has to remain possible
|
||||||
|
// at any point, and blocking it on an incomplete checklist would trap the application.
|
||||||
|
final JsonObject resolution = new JsonObject();
|
||||||
|
resolution.addProperty("resolutionNo", strOr(request, "resolutionNo", ""));
|
||||||
|
resolution.addProperty("resolutionDate", strOr(request, "resolutionDate", ""));
|
||||||
|
if (!"REJECTED".equals(decision) && !"REVOKED".equals(decision)) {
|
||||||
|
checkApprovalPrerequisites(app, field, resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remarks.isBlank()) {
|
||||||
|
remarks = "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision) && !"REVOKED".equals(decision)) {
|
||||||
// The resolution number and date are what the sanction letter later quotes, so they are
|
// The resolution number and date are what the sanction letter later quotes, so they are
|
||||||
// recorded on the application and restated in the audit trail.
|
// recorded on the application and restated in the audit trail.
|
||||||
final String resolutionNo = strOr(input, "resolutionNo", "");
|
final boolean depositSanction = isDepositLoan(app);
|
||||||
final String resolutionDate = strOr(input, "resolutionDate", "");
|
final String resolutionNo = depositSanction ? applicationNo : strOr(resolution, "resolutionNo", "");
|
||||||
|
final String resolutionDate = depositSanction ? today() : strOr(resolution, "resolutionDate", "");
|
||||||
app.addProperty("committeeResolutionNo", resolutionNo);
|
app.addProperty("committeeResolutionNo", resolutionNo);
|
||||||
app.addProperty("committeeResolutionDate", resolutionDate);
|
app.addProperty("committeeResolutionDate", resolutionDate);
|
||||||
remarks = remarks + " (Resolution No. " + resolutionNo + " dated " + resolutionDate + ")";
|
remarks = remarks + (depositSanction ? " (Sanction No. " : " (Resolution No. ") + resolutionNo + " dated " + resolutionDate + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("REVOKED".equals(decision)) {
|
||||||
|
request.addProperty("status", "REVOKED");
|
||||||
|
request.addProperty("decidedBy", user);
|
||||||
|
request.addProperty("decidedOn", today());
|
||||||
|
request.addProperty("revokeRemarks", remarks);
|
||||||
|
request.addProperty("remarks", remarks);
|
||||||
|
if (app.has(field)) {
|
||||||
|
app.remove(field);
|
||||||
|
}
|
||||||
|
appendHistory(app, expectedStage, user, "Returned for Rectification", remarks);
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
final JsonObject approval = objOrCreate(app, field);
|
final JsonObject approval = objOrCreate(app, field);
|
||||||
approval.addProperty("decision", decision);
|
approval.addProperty("decision", decision);
|
||||||
approval.addProperty("approverName", strOr(input, "approverName", "Bank Staff"));
|
approval.addProperty("approverName", user);
|
||||||
approval.addProperty("approverRole", strOr(input, "approverRole", expectedStage));
|
approval.addProperty("approverRole", strOr(input, "approverRole", expectedStage));
|
||||||
approval.addProperty("date", today());
|
approval.addProperty("date", today());
|
||||||
approval.addProperty("remarks", remarks);
|
approval.addProperty("remarks", remarks);
|
||||||
|
|
||||||
|
if (input.has("checkerChecklist") && input.get("checkerChecklist").isJsonArray()) {
|
||||||
|
request.add("checkerChecklist", input.getAsJsonArray("checkerChecklist"));
|
||||||
|
}
|
||||||
|
request.addProperty("status", decision);
|
||||||
|
request.addProperty("decidedBy", user);
|
||||||
|
request.addProperty("decidedOn", today());
|
||||||
|
|
||||||
|
if ("disbursementApproval".equals(field)) {
|
||||||
|
// Approving the disbursement does not move the stage (the loan is disbursed next); declining it only sends it back.
|
||||||
if ("REJECTED".equals(decision)) {
|
if ("REJECTED".equals(decision)) {
|
||||||
app.addProperty("overallStatus", "REJECTED");
|
app.remove(field);
|
||||||
appendHistory(app, str(app, "currentStage"), actor(input), "Rejected", remarks);
|
appendHistory(app, expectedStage, user, "Disbursement Declined", remarks);
|
||||||
} else {
|
} else {
|
||||||
advance(app, actor(input), "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
|
appendHistory(app, expectedStage, user, "Disbursement Approved", remarks);
|
||||||
|
}
|
||||||
|
} else if ("REJECTED".equals(decision)) {
|
||||||
|
app.addProperty("overallStatus", "REJECTED");
|
||||||
|
appendHistory(app, str(app, "currentStage"), user, "Rejected", remarks);
|
||||||
|
} else {
|
||||||
|
advance(app, user, "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
|
||||||
|
if ("creditCommitteeApproval".equals(field) && isDepositLoan(app)) {
|
||||||
|
// Sanctioned: no risk approval and no legal approval on a loan against a deposit (the legal documents are taken at signing).
|
||||||
|
advance(app, user, "Risk Approval Not Required", "Loan against a deposit - sanctioned by the Sanction step.");
|
||||||
|
advance(app, user, "Legal Approval Not Required", "Loan against a deposit - the legal documents are taken at Customer Signing.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return pipelineService.storeApplication(applicationNo, app);
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Signing / Cheque-ACH / Disbursement ──────────────────────────────────
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public String markSigned(final String applicationNo, final String body) {
|
public String markSigned(final String applicationNo, final String body) {
|
||||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
final JsonObject input = parseObject(body);
|
final JsonObject input = parseObject(body);
|
||||||
requireStage(app, "CUSTOMER_SIGNING");
|
requireStage(app, "CUSTOMER_SIGNING");
|
||||||
require(filled(app, "agreementSigningSlot"), "No agreement signing slot has been recorded.");
|
require(filled(app, "agreementSigningSlot"), "No agreement signing slot has been recorded.");
|
||||||
|
if (isDepositLoan(app)) {
|
||||||
|
// No legal approval stage on a loan against a deposit: its legal documents (stamp duty, DP note, lien letter ...) are taken here.
|
||||||
|
requireLegalChecklistDone(app);
|
||||||
|
}
|
||||||
|
|
||||||
app.addProperty("customerSigningDate", today());
|
app.addProperty("customerSigningDate", today());
|
||||||
advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed",
|
advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed",
|
||||||
strOr(input, "remarks", "Loan agreement signed by the customer."));
|
strOr(input, "remarks", "Loan agreement signed by the customer."));
|
||||||
|
if (isDepositLoan(app)) {
|
||||||
|
// Recovery of a loan against a deposit is by set-off against the lien (policy 9.5), so no security cheque / ACH mandate.
|
||||||
|
advance(app, actor(input), "Cheque & ACH Not Required",
|
||||||
|
"Loan against a deposit - recovery by set-off against the lien.");
|
||||||
|
}
|
||||||
return pipelineService.storeApplication(applicationNo, app);
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +611,12 @@ public class LoanOriginationStageService {
|
|||||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
final JsonObject input = parseObject(body);
|
final JsonObject input = parseObject(body);
|
||||||
requireStage(app, "CHEQUE_ACH");
|
requireStage(app, "CHEQUE_ACH");
|
||||||
|
if (isDepositLoan(app)) {
|
||||||
|
// An application that was already waiting at this stage before it was made optional for deposit loans.
|
||||||
|
advance(app, actor(input), "Cheque & ACH Not Required",
|
||||||
|
"Loan against a deposit - recovery by set-off against the lien.");
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
require(bool(app, "chequeReceived"), "Security cheques have not been received.");
|
require(bool(app, "chequeReceived"), "Security cheques have not been received.");
|
||||||
require(bool(app, "achSigned"), "The ACH/NACH mandate has not been signed.");
|
require(bool(app, "achSigned"), "The ACH/NACH mandate has not been signed.");
|
||||||
|
|
||||||
@@ -343,13 +637,23 @@ public class LoanOriginationStageService {
|
|||||||
requireStage(app, "DISBURSED");
|
requireStage(app, "DISBURSED");
|
||||||
require(!"DISBURSED".equals(str(app, "overallStatus")), "This application has already been disbursed.");
|
require(!"DISBURSED".equals(str(app, "overallStatus")), "This application has already been disbursed.");
|
||||||
|
|
||||||
for (final String field : List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval")) {
|
final List<String> mustStand = isDepositLoan(app) ? List.of("creditCommitteeApproval", "postCheckApproval")
|
||||||
|
: List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval");
|
||||||
|
for (final String field : mustStand) {
|
||||||
final JsonObject approval = obj(app, field);
|
final JsonObject approval = obj(app, field);
|
||||||
final String decision = approval == null ? null : str(approval, "decision");
|
final String decision = approval == null ? null : str(approval, "decision");
|
||||||
require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision),
|
require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision),
|
||||||
"Cannot disburse: " + field + " is not approved.");
|
"Cannot disburse: " + field + " is not approved.");
|
||||||
}
|
}
|
||||||
require(filled(app, "customerSigningDate"), "The customer has not signed the loan agreement.");
|
require(filled(app, "customerSigningDate"), "The customer has not signed the loan agreement.");
|
||||||
|
if (isDepositLoan(app)) {
|
||||||
|
final JsonObject selection = obj(app, "depositSelection");
|
||||||
|
// The product decides whether a lien is required (Product Creation > Security & Lien); it defaults to required.
|
||||||
|
final boolean lienRequired = selection == null || !selection.has("lienRequired") || bool(selection, "lienRequired");
|
||||||
|
final JsonObject lien = obj(app, "lien");
|
||||||
|
require(!lienRequired || (lien != null && bool(lien, "marked")),
|
||||||
|
"Cannot disburse: the lien on the pledged deposit has not been marked.");
|
||||||
|
}
|
||||||
|
|
||||||
final String mode = strOr(input, "disbursementMode", "Account Credit");
|
final String mode = strOr(input, "disbursementMode", "Account Credit");
|
||||||
app.addProperty("disbursementDate", today());
|
app.addProperty("disbursementDate", today());
|
||||||
@@ -365,12 +669,38 @@ public class LoanOriginationStageService {
|
|||||||
app.addProperty("fineractLoanId", LoanOriginationJson.lng(input, "fineractLoanId"));
|
app.addProperty("fineractLoanId", LoanOriginationJson.lng(input, "fineractLoanId"));
|
||||||
app.addProperty("fineractLoanAccountNo", str(input, "fineractLoanAccountNo"));
|
app.addProperty("fineractLoanAccountNo", str(input, "fineractLoanAccountNo"));
|
||||||
}
|
}
|
||||||
|
holdDdsAccounts(applicationNo, app);
|
||||||
|
|
||||||
appendHistory(app, "DISBURSED", actor(input), "Disbursed", strOr(input, "remarks",
|
appendHistory(app, "DISBURSED", actor(input), "Disbursed", strOr(input, "remarks",
|
||||||
"Disbursed via " + mode + " — ACH confirmation " + strOr(app, "achConfirmationRef", "—") + ". EMI activation confirmed."));
|
"Disbursed via " + mode + " — ACH confirmation " + strOr(app, "achConfirmationRef", "—") + ". EMI activation confirmed."));
|
||||||
return pipelineService.storeApplication(applicationNo, app);
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A loan against a Daily Deposit Scheme account puts the account on hold (its lien flag) as it is disbursed, naming the loan
|
||||||
|
* in the reason; {@link DdsLienReleaseService} lifts it when that loan ends. The DDS module is the bank's own, outside
|
||||||
|
* Fineract's deposit accounts, so there is no Fineract call for this. The outcome is written on the application's lien.
|
||||||
|
*/
|
||||||
|
private void holdDdsAccounts(final String applicationNo, final JsonObject app) {
|
||||||
|
if (!"DS".equals(str(app, "productCode"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final JsonObject selection = obj(app, "depositSelection");
|
||||||
|
final JsonObject lien = obj(app, "lien");
|
||||||
|
if (selection == null || lien == null || !bool(lien, "marked")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String loanNo = filled(app, "fineractLoanAccountNo") ? str(app, "fineractLoanAccountNo") : applicationNo;
|
||||||
|
final String reason = DdsLienReleaseService.reasonFor(loanNo, applicationNo);
|
||||||
|
int held = 0;
|
||||||
|
for (final JsonElement id : arr(selection, "accountIds")) {
|
||||||
|
held += jdbcTemplate.update("UPDATE m_dds_party SET lien = TRUE, lien_reason = ? WHERE id = ? AND status <> 'CLOSED'", reason,
|
||||||
|
id.getAsLong());
|
||||||
|
}
|
||||||
|
lien.addProperty("coreStatus", held > 0 ? "PLACED" : "FAILED");
|
||||||
|
lien.addProperty("coreNote", held > 0 ? "DDS account held: " + reason : "No open DDS account was found to hold.");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared checklist rules (mirror the Application Detail screen's gating) ─
|
// ── Shared checklist rules (mirror the Application Detail screen's gating) ─
|
||||||
|
|
||||||
private void requireFieldInvestigationComplete(final JsonObject app) {
|
private void requireFieldInvestigationComplete(final JsonObject app) {
|
||||||
@@ -390,30 +720,95 @@ public class LoanOriginationStageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void requireCommitteeReportsReady(final JsonObject app, final JsonObject input) {
|
private void requireCommitteeReportsReady(final JsonObject app, final JsonObject input) {
|
||||||
|
final boolean depositLoan = isDepositLoan(app);
|
||||||
|
if (!depositLoan) {
|
||||||
require(bool(app, "cicReportUploaded"), "The credit bureau report is not on file.");
|
require(bool(app, "cicReportUploaded"), "The credit bureau report is not on file.");
|
||||||
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
}
|
||||||
if (bool(app, "secured")) {
|
// On a loan against a deposit the deposit is the security: KYC and the deposit checks are enough, the background check is optional.
|
||||||
|
require(isDepositLoan(app) || bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||||
|
if (bool(app, "secured") && !depositLoan) {
|
||||||
require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done.");
|
require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done.");
|
||||||
}
|
}
|
||||||
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
||||||
require(bool(app, "panVerified"), "PAN has not been verified.");
|
require(bool(app, "panVerified"), "PAN has not been verified.");
|
||||||
|
if (!depositLoan) {
|
||||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||||
requireFieldInvestigationComplete(app);
|
requireFieldInvestigationComplete(app);
|
||||||
require(bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
|
}
|
||||||
|
require(depositLoan || bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
|
||||||
|
if (depositLoan) {
|
||||||
|
// A loan against a deposit has no committee, risk or legal approval: this one Sanction step is all, and it makes the
|
||||||
|
// checks Risk would have made (KYC, the deposit covers the loan, the declarations). No resolution is needed.
|
||||||
|
requireRiskChecklistReady(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required.");
|
require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required.");
|
||||||
require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required.");
|
require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireRiskChecklistReady(final JsonObject app) {
|
private void requireRiskChecklistReady(final JsonObject app) {
|
||||||
|
final boolean depositLoan = isDepositLoan(app);
|
||||||
|
if (depositLoan) {
|
||||||
|
// What Risk confirms on a loan against a deposit: KYC done, and the deposit still covers the loan.
|
||||||
|
require(bool(app, "aadhaarVerified") && bool(app, "panVerified"), "KYC (Aadhaar and PAN) is not verified.");
|
||||||
|
final JsonObject selection = obj(app, "depositSelection");
|
||||||
|
require(selection != null && bool(selection, "saved"), "No deposit has been selected and saved for this application.");
|
||||||
|
require(intOr(selection, "eligibleAmount", 0) >= intOr(app, "requestedAmount", 0),
|
||||||
|
"The loan is more than the eligible amount on the pledged deposit.");
|
||||||
|
require(intOr(app, "tenureMonths", 0) <= intOr(selection, "maxTenure", 0),
|
||||||
|
"The tenure runs past the maturity of the pledged deposit.");
|
||||||
|
requireWithinLimitOrException(app, selection);
|
||||||
|
}
|
||||||
|
if (!depositLoan) {
|
||||||
requireCheckVerified(app, "addressVerification", "Address verification");
|
requireCheckVerified(app, "addressVerification", "Address verification");
|
||||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||||
require(bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken.");
|
}
|
||||||
if (bool(app, "secured")) {
|
// A loan against a deposit takes no separate declaration: the deposit is the security.
|
||||||
|
require(depositLoan || bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken.");
|
||||||
|
if (bool(app, "secured") && !depositLoan) {
|
||||||
require(bool(app, "propertySearchReportDone"), "The property search report is not on file.");
|
require(bool(app, "propertySearchReportDone"), "The property search report is not on file.");
|
||||||
require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed.");
|
require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed.");
|
||||||
}
|
}
|
||||||
|
if (!depositLoan) {
|
||||||
requireFieldInvestigationComplete(app);
|
requireFieldInvestigationComplete(app);
|
||||||
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
}
|
||||||
|
// On a loan against a deposit the deposit is the security: KYC and the deposit checks are enough, the background check is optional.
|
||||||
|
require(isDepositLoan(app) || bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||||
|
requireGuarantorsNamed(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guarantors an application has to name. A product that says how many (Product Creation) is held to that count: each
|
||||||
|
* guarantor an onboarded client, none twice, none the borrower, and a bank member unless the product says otherwise. An
|
||||||
|
* application raised before products said so keeps the officer's own Guarantor Required tick and its single guarantor.
|
||||||
|
*/
|
||||||
|
private void requireGuarantorsNamed(final JsonObject app) {
|
||||||
|
final JsonElement count = app.get("guarantorsRequiredCount");
|
||||||
|
if (count != null && count.isJsonPrimitive() && count.getAsJsonPrimitive().isNumber()) {
|
||||||
|
final int need = count.getAsInt();
|
||||||
|
if (need <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean memberRequired = !app.has("guarantorMustBeMember") || bool(app, "guarantorMustBeMember");
|
||||||
|
final Long borrower = LoanOriginationJson.lng(app, "clientId");
|
||||||
|
final java.util.Set<Long> seen = new java.util.HashSet<>();
|
||||||
|
for (final JsonElement element : arr(app, "guarantors")) {
|
||||||
|
if (!element.isJsonObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final JsonObject guarantor = element.getAsJsonObject();
|
||||||
|
final Long clientId = LoanOriginationJson.lng(guarantor, "clientId");
|
||||||
|
if (clientId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
require(borrower == null || !borrower.equals(clientId), "The borrower cannot be their own guarantor.");
|
||||||
|
require(seen.add(clientId), "A guarantor has been named twice.");
|
||||||
|
require(!memberRequired || bool(guarantor, "hasShareAccount"),
|
||||||
|
strOr(guarantor, "name", "A guarantor") + " has no active share account: a guarantor must be a bank member.");
|
||||||
|
}
|
||||||
|
require(seen.size() >= need, "This product needs " + need + " guarantor(s); " + seen.size() + " named.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (bool(app, "guarantorRequired")) {
|
if (bool(app, "guarantorRequired")) {
|
||||||
// A guarantor at a co-operative bank has to be an existing member, not just a name: an
|
// A guarantor at a co-operative bank has to be an existing member, not just a name: an
|
||||||
// onboarded client id AND a live share account.
|
// onboarded client id AND a live share account.
|
||||||
@@ -424,21 +819,31 @@ public class LoanOriginationStageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void requireLegalChecklistDone(final JsonObject app) {
|
private void requireLegalChecklistDone(final JsonObject app) {
|
||||||
require(bool(app, "feesAndAccountOpened"), "Fees and account opening are not complete.");
|
// A loan against a deposit needs only the stamp duty at this stage (policy 9.5): no fee or file-filing proof.
|
||||||
|
final boolean depositLoan = isDepositLoan(app);
|
||||||
|
require(depositLoan || bool(app, "feesAndAccountOpened"), "The processing fee has not been collected.");
|
||||||
if (bool(app, "secured")) {
|
if (bool(app, "secured")) {
|
||||||
require(bool(app, "mortgageCreated"), "The mortgage has not been created.");
|
require(bool(app, "mortgageCreated"), "The mortgage has not been created.");
|
||||||
}
|
}
|
||||||
require(bool(app, "legalStampDone"), "Legal stamping is not complete.");
|
require(bool(app, "legalStampDone"), "Legal stamping is not complete.");
|
||||||
require(bool(app, "fileFilingDone"), "File filing is not complete.");
|
require(depositLoan || bool(app, "fileFilingDone"), "File filing is not complete.");
|
||||||
for (final JsonElement element : arr(app, "legalDocuments")) {
|
for (final JsonElement element : arr(app, "legalDocuments")) {
|
||||||
final JsonObject doc = element.getAsJsonObject();
|
final JsonObject doc = element.getAsJsonObject();
|
||||||
|
if (depositLoan && !bool(doc, "custom")
|
||||||
|
&& ("feeReceipt".equals(str(doc, "key")) || "fileFilingAck".equals(str(doc, "key")))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
require(bool(doc, "submitted"), "Legal document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
require(bool(doc, "submitted"), "Legal document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requirePostCheckSignatures(final JsonObject app) {
|
private void requirePostCheckSignatures(final JsonObject app) {
|
||||||
require(bool(app, "signedByAgent"), "The agent has not signed off.");
|
|
||||||
require(bool(app, "signedByManager"), "The manager has not signed off.");
|
require(bool(app, "signedByManager"), "The manager has not signed off.");
|
||||||
|
if (isDepositLoan(app)) {
|
||||||
|
// Committee, risk and legal already approved through their own maker-checker stages.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
require(bool(app, "signedByAgent"), "The agent has not signed off.");
|
||||||
require(bool(app, "signedByCommittee"), "The committee has not signed off.");
|
require(bool(app, "signedByCommittee"), "The committee has not signed off.");
|
||||||
require(bool(app, "signedByRiskApproval"), "Risk approval has not signed off.");
|
require(bool(app, "signedByRiskApproval"), "Risk approval has not signed off.");
|
||||||
require(bool(app, "signedByLegalApproval"), "Legal approval has not signed off.");
|
require(bool(app, "signedByLegalApproval"), "Legal approval has not signed off.");
|
||||||
|
|||||||
+3
-2
@@ -679,8 +679,9 @@ public final class LoanProductDataValidator {
|
|||||||
|
|
||||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
.extractLongNamed(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||||
|
// not mapped by the bank (only the nine GLs are); optional
|
||||||
baseDataValidator.reset().parameter(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
baseDataValidator.reset().parameter(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
.value(transfersInSuspenseAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||||
|
|
||||||
final Long incomeFromInterestId = this.fromApiJsonHelper
|
final Long incomeFromInterestId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(LoanProductAccountingParams.INTEREST_ON_LOANS.getValue(), element);
|
.extractLongNamed(LoanProductAccountingParams.INTEREST_ON_LOANS.getValue(), element);
|
||||||
@@ -700,7 +701,7 @@ public final class LoanProductDataValidator {
|
|||||||
final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper
|
final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element);
|
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue())
|
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue())
|
||||||
.value(incomeFromRecoveryAccountId).notNull().integerGreaterThanZero();
|
.value(incomeFromRecoveryAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||||
|
|
||||||
final Long writeOffAccountId = this.fromApiJsonHelper
|
final Long writeOffAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||||
|
|||||||
+332
-2
@@ -9,11 +9,15 @@ package org.apache.fineract.portfolio.loanproduct.service;
|
|||||||
|
|
||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
|
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
|
||||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
|
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
|
||||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
|
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
|
||||||
@@ -43,7 +47,8 @@ public class LoanProductAdvanceExtService {
|
|||||||
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
|
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
|
||||||
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
|
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
|
||||||
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
|
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
|
||||||
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
|
"penalAppliedOn", "penalGraceDays", "npaTrigger", "smaStartAfterMonths", "stockStatementValidityDays", "memberRequired", "guarantorMustBeMember",
|
||||||
|
"incomeMultiple", "minVintageMonths", "requiredDocuments", "interestAfterNpa", "undebitedInterestGlId",
|
||||||
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
|
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
|
||||||
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
|
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
|
||||||
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
|
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
|
||||||
@@ -76,7 +81,17 @@ public class LoanProductAdvanceExtService {
|
|||||||
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
|
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
|
||||||
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
|
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
|
||||||
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
|
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
|
||||||
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed" };
|
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed",
|
||||||
|
// SICR & LGD (RBI 2026 ECL framework)
|
||||||
|
"sicrDaysThreshold", "sicrRevolvingDaysThreshold", "sicrRatingDowngradeNotches", "sicrPricingIncreaseTriggerPct",
|
||||||
|
"sicrAssessmentBasis", "sicrRebuttalAllowed", "npaOutOfOrderBasis", "cropDuration", "useOwnLgdModel", "lgdSecuredPct",
|
||||||
|
"lgdUnsecuredPct", "eligibleCollateralType",
|
||||||
|
// Loan against Gold
|
||||||
|
"goldMaxLtv", "goldMinPurityCarat", "goldRateSource", "goldMaxPerGram", "goldMinNetWeight", "goldMaxNetWeightPerCustomer", "goldAllowedItems", "goldAppraiserRequired", "goldValuationCertificateRequired", "goldMaxValuationPerAppraiser", "goldPacketPrefix", "goldAuctionNoticeDays", "goldRevalueOnNpa", "goldRevaluationChargeApplicable",
|
||||||
|
"goldPartialReleaseAllowed", "goldThirdPartyAllowed", "goldDisbursementMode", "goldCashDisbursementLimit",
|
||||||
|
"goldLtvBreachAlert", "goldAuctionReservePct", "goldRedeemBeforeAuction",
|
||||||
|
// Loan against Warehouse Receipt
|
||||||
|
"wrMaxLtv", "wrReceiptType", "wrWdraRegistered", "wrAssayCertificateRequired", "wrTenureWithinReceiptValidity", "wrPledgeMarkedBeforeDisbursement", "wrCommodityGroups", "wrValuationPriceSource", "wrMaxLoanPerQuintal", "wrStockInspectionMonths", "wrLtvBreachAlert", "wrPartialReleaseAllowed", "wrDisposalNoticeDays", "wrDisposalReservePct", "wrRedeemBeforeDisposal" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
|
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
|
||||||
@@ -90,10 +105,205 @@ public class LoanProductAdvanceExtService {
|
|||||||
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
|
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
|
||||||
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
|
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
|
||||||
applyScalarFields(ext, command, changes);
|
applyScalarFields(ext, command, changes);
|
||||||
|
validateGold(ext, command);
|
||||||
|
validateWarehouse(ext, command);
|
||||||
this.repository.save(ext);
|
this.repository.save(ext);
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Longest tenure of a bullet Gold loan, in months. */
|
||||||
|
private static final int GOLD_MAX_TENURE_MONTHS = 12;
|
||||||
|
|
||||||
|
/** The most that may be paid out in cash against one gold loan (Income Tax Sec 269SS). */
|
||||||
|
private static final BigDecimal GOLD_CASH_LIMIT = BigDecimal.valueOf(20000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loan against Gold rules, checked on the merged state (what is stored plus what this command changes), so a
|
||||||
|
* partial edit cannot leave the product inconsistent: the LTV cap must cover the LTV the margin gives, the
|
||||||
|
* purity floor is 18 to 24 carat, and a bullet gold loan runs at most 12 months.
|
||||||
|
*/
|
||||||
|
private void validateGold(final LoanProductAdvanceExt ext, final JsonCommand command) {
|
||||||
|
if (!"gold".equals(ext.getProductTypeSubGroup())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final List<ApiParameterError> errors = new ArrayList<>();
|
||||||
|
final BigDecimal hundred = BigDecimal.valueOf(100);
|
||||||
|
final BigDecimal cap = ext.getGoldMaxLtvPct();
|
||||||
|
if (cap == null || cap.signum() <= 0 || cap.compareTo(hundred) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.maxLtv.invalid",
|
||||||
|
"Maximum LTV cap is required and must be above 0 and at most 100", "goldMaxLtv"));
|
||||||
|
} else if (ext.getMarginPct() != null && hundred.subtract(ext.getMarginPct()).compareTo(cap) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.margin.ltvAboveCap",
|
||||||
|
"Margin gives an LTV above the Maximum LTV cap; raise the margin or the cap", "margin"));
|
||||||
|
}
|
||||||
|
final Integer purity = ext.getGoldMinPurityCarat();
|
||||||
|
if (purity == null || purity < 18 || purity > 24) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.minPurity.invalid",
|
||||||
|
"Minimum purity must be between 18 and 24 carat", "goldMinPurityCarat"));
|
||||||
|
}
|
||||||
|
if (isBlank(ext.getGoldRateSource())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.rateSource.required",
|
||||||
|
"Valuation rate source is required", "goldRateSource"));
|
||||||
|
}
|
||||||
|
if (isBlank(ext.getGoldAllowedItems())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.allowedItems.required",
|
||||||
|
"Allowed items is required", "goldAllowedItems"));
|
||||||
|
}
|
||||||
|
if (ext.getGoldAuctionNoticeDays() == null || ext.getGoldAuctionNoticeDays() < 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.auctionNotice.invalid",
|
||||||
|
"Auction notice period is required and cannot be negative", "goldAuctionNoticeDays"));
|
||||||
|
}
|
||||||
|
if (ext.getGoldMinNetWeightG() != null && ext.getGoldMaxNetWeightPerCustomerG() != null
|
||||||
|
&& ext.getGoldMinNetWeightG().compareTo(ext.getGoldMaxNetWeightPerCustomerG()) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.netWeight.range",
|
||||||
|
"Minimum net weight cannot exceed the maximum per customer", "goldMinNetWeight"));
|
||||||
|
}
|
||||||
|
final BigDecimal alert = ext.getGoldLtvBreachAlertPct();
|
||||||
|
if (alert == null || alert.signum() <= 0 || alert.compareTo(hundred) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.ltvAlert.invalid",
|
||||||
|
"LTV breach alert is required and must be above 0 and at most 100", "goldLtvBreachAlert"));
|
||||||
|
} else if (cap != null && alert.compareTo(cap) < 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.ltvAlert.belowCap",
|
||||||
|
"LTV breach alert cannot be below the Maximum LTV cap", "goldLtvBreachAlert"));
|
||||||
|
}
|
||||||
|
final BigDecimal reserve = ext.getGoldAuctionReservePct();
|
||||||
|
if (reserve == null || reserve.signum() <= 0 || reserve.compareTo(hundred) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.auctionReserve.invalid",
|
||||||
|
"Auction reserve price is required and must be above 0 and at most 100 percent of market value",
|
||||||
|
"goldAuctionReservePct"));
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(ext.getGoldRedeemBeforeAuction())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.redeem.mandatory",
|
||||||
|
"The customer's right to redeem before the auction cannot be switched off", "goldRedeemBeforeAuction"));
|
||||||
|
}
|
||||||
|
final String mode = ext.getGoldDisbursementMode();
|
||||||
|
if (!"account_credit".equals(mode) && !"account_credit_cash_limit".equals(mode)) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.disbursementMode.invalid",
|
||||||
|
"Disbursement mode must be account credit, or account credit with a cash limit",
|
||||||
|
"goldDisbursementMode"));
|
||||||
|
} else if ("account_credit_cash_limit".equals(mode)) {
|
||||||
|
final BigDecimal cashLimit = ext.getGoldCashDisbursementLimit();
|
||||||
|
if (cashLimit == null || cashLimit.signum() < 0 || cashLimit.compareTo(GOLD_CASH_LIMIT) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.cashLimit.invalid",
|
||||||
|
"Cash disbursement limit must be between 0 and 20,000 (Income Tax Sec 269SS)",
|
||||||
|
"goldCashDisbursementLimit"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command.parameterExists("maxNumberOfRepayments")) {
|
||||||
|
final Integer maxTenure = command.integerValueOfParameterNamed("maxNumberOfRepayments");
|
||||||
|
if (maxTenure != null && maxTenure > GOLD_MAX_TENURE_MONTHS) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.tenure.exceeds",
|
||||||
|
"A gold loan runs at most " + GOLD_MAX_TENURE_MONTHS + " months", "maxNumberOfRepayments"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
throw new PlatformApiDataValidationException(errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest tenure of a warehouse receipt loan, in months. */
|
||||||
|
private static final int WAREHOUSE_MAX_TENURE_MONTHS = 12;
|
||||||
|
|
||||||
|
private static final java.util.Set<String> WR_RECEIPT_TYPES = java.util.Set.of("e_nwr", "paper_nwr", "both");
|
||||||
|
private static final java.util.Set<String> WR_COMMODITY_GROUPS = java.util.Set.of("cereals", "pulses", "oilseeds", "spices",
|
||||||
|
"others");
|
||||||
|
private static final java.util.Set<String> WR_PRICE_SOURCES = java.util.Set.of("mandi_apmc", "msp", "exchange_ncdex",
|
||||||
|
"bank_assessed");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loan against Warehouse Receipt rules, checked on the merged state like the gold rules: the LTV cap must cover the
|
||||||
|
* LTV the margin gives, the alert level must not sit below the cap, the receipt must come from a WDRA-registered
|
||||||
|
* warehouse with an assay certificate, the customer's right to redeem stays on, and the loan runs at most 12 months.
|
||||||
|
*/
|
||||||
|
private void validateWarehouse(final LoanProductAdvanceExt ext, final JsonCommand command) {
|
||||||
|
if (!"warehouse".equals(ext.getProductTypeSubGroup())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final List<ApiParameterError> errors = new ArrayList<>();
|
||||||
|
final BigDecimal hundred = BigDecimal.valueOf(100);
|
||||||
|
final BigDecimal cap = ext.getWrMaxLtvPct();
|
||||||
|
if (cap == null || cap.signum() <= 0 || cap.compareTo(hundred) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.maxLtv.invalid",
|
||||||
|
"Maximum LTV cap is required and must be above 0 and at most 100", "wrMaxLtv"));
|
||||||
|
} else if (ext.getMarginPct() != null && hundred.subtract(ext.getMarginPct()).compareTo(cap) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.margin.ltvAboveCap",
|
||||||
|
"Margin gives an LTV above the Maximum LTV cap; raise the margin or the cap", "margin"));
|
||||||
|
}
|
||||||
|
if (!WR_RECEIPT_TYPES.contains(ext.getWrReceiptType())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.receiptType.invalid",
|
||||||
|
"Receipt type must be e-NWR, paper NWR or both", "wrReceiptType"));
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(ext.getWrWdraRegistered())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.wdra.mandatory",
|
||||||
|
"The warehouse must be WDRA-registered; this cannot be switched off", "wrWdraRegistered"));
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(ext.getWrAssayCertificateRequired())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.assay.mandatory",
|
||||||
|
"A quality / assay certificate is mandatory; this cannot be switched off", "wrAssayCertificateRequired"));
|
||||||
|
}
|
||||||
|
if (isBlank(ext.getWrCommodityGroups())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.commodityGroups.required",
|
||||||
|
"Pick at least one eligible commodity group", "wrCommodityGroups"));
|
||||||
|
} else {
|
||||||
|
for (final String group : ext.getWrCommodityGroups().split("\\|")) {
|
||||||
|
if (!WR_COMMODITY_GROUPS.contains(group)) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.commodityGroups.invalid",
|
||||||
|
"Unknown commodity group: " + group, "wrCommodityGroups"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!WR_PRICE_SOURCES.contains(ext.getWrValuationPriceSource())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.priceSource.invalid",
|
||||||
|
"Valuation price source is required", "wrValuationPriceSource"));
|
||||||
|
}
|
||||||
|
if (ext.getWrMaxLoanPerQuintal() != null && ext.getWrMaxLoanPerQuintal().signum() <= 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.maxPerQuintal.invalid",
|
||||||
|
"Max loan per quintal must be above 0", "wrMaxLoanPerQuintal"));
|
||||||
|
}
|
||||||
|
if (ext.getWrStockInspectionMonths() == null || ext.getWrStockInspectionMonths() < 1) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.inspection.invalid",
|
||||||
|
"Stock inspection frequency must be at least 1 month", "wrStockInspectionMonths"));
|
||||||
|
}
|
||||||
|
final BigDecimal alert = ext.getWrLtvBreachAlertPct();
|
||||||
|
if (alert == null || alert.signum() <= 0 || alert.compareTo(hundred) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.ltvAlert.invalid",
|
||||||
|
"LTV breach alert is required and must be above 0 and at most 100", "wrLtvBreachAlert"));
|
||||||
|
} else if (cap != null && alert.compareTo(cap) < 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.ltvAlert.belowCap",
|
||||||
|
"LTV breach alert cannot be below the Maximum LTV cap", "wrLtvBreachAlert"));
|
||||||
|
}
|
||||||
|
if (ext.getWrDisposalNoticeDays() == null || ext.getWrDisposalNoticeDays() < 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.disposalNotice.invalid",
|
||||||
|
"Disposal notice period is required and cannot be negative", "wrDisposalNoticeDays"));
|
||||||
|
}
|
||||||
|
final BigDecimal reserve = ext.getWrDisposalReservePct();
|
||||||
|
if (reserve == null || reserve.signum() <= 0 || reserve.compareTo(hundred) > 0) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.disposalReserve.invalid",
|
||||||
|
"Disposal reserve price is required and must be above 0 and at most 100 percent of market value",
|
||||||
|
"wrDisposalReservePct"));
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(ext.getWrRedeemBeforeDisposal())) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.redeem.mandatory",
|
||||||
|
"The customer's right to redeem before the disposal cannot be switched off", "wrRedeemBeforeDisposal"));
|
||||||
|
}
|
||||||
|
if (command.parameterExists("maxNumberOfRepayments")) {
|
||||||
|
final Integer maxTenure = command.integerValueOfParameterNamed("maxNumberOfRepayments");
|
||||||
|
if (maxTenure != null && maxTenure > WAREHOUSE_MAX_TENURE_MONTHS) {
|
||||||
|
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.tenure.exceeds",
|
||||||
|
"A warehouse receipt loan runs at most " + WAREHOUSE_MAX_TENURE_MONTHS + " months",
|
||||||
|
"maxNumberOfRepayments"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
throw new PlatformApiDataValidationException(errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(final String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
|
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
|
||||||
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
|
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
|
||||||
.orElseGet(LinkedHashMap::new);
|
.orElseGet(LinkedHashMap::new);
|
||||||
@@ -155,6 +365,14 @@ public class LoanProductAdvanceExtService {
|
|||||||
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
|
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
|
||||||
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
|
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
|
||||||
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
|
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
|
||||||
|
setInteger(command, "smaStartAfterMonths", ext::getSmaStartAfterMonths, ext::setSmaStartAfterMonths, changes);
|
||||||
|
setBoolean(command, "memberRequired", ext::getMemberRequired, ext::setMemberRequired, changes);
|
||||||
|
setBoolean(command, "guarantorMustBeMember", ext::getGuarantorMustBeMember, ext::setGuarantorMustBeMember, changes);
|
||||||
|
setDecimal(command, "incomeMultiple", ext::getIncomeMultiple, ext::setIncomeMultiple, changes);
|
||||||
|
setInteger(command, "minVintageMonths", ext::getMinVintageMonths, ext::setMinVintageMonths, changes);
|
||||||
|
setString(command, "requiredDocuments", ext::getRequiredDocuments, ext::setRequiredDocuments, changes);
|
||||||
|
setInteger(command, "stockStatementValidityDays", ext::getStockStatementValidityDays,
|
||||||
|
ext::setStockStatementValidityDays, changes);
|
||||||
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
|
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
|
||||||
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
|
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
|
||||||
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
|
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
|
||||||
@@ -163,6 +381,48 @@ public class LoanProductAdvanceExtService {
|
|||||||
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
|
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
|
||||||
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
|
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
|
||||||
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
|
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
|
||||||
|
setDecimal(command, "goldMaxLtv", ext::getGoldMaxLtvPct, ext::setGoldMaxLtvPct, changes);
|
||||||
|
setInteger(command, "goldMinPurityCarat", ext::getGoldMinPurityCarat, ext::setGoldMinPurityCarat, changes);
|
||||||
|
setString(command, "goldRateSource", ext::getGoldRateSource, ext::setGoldRateSource, changes);
|
||||||
|
setDecimal(command, "goldMaxPerGram", ext::getGoldMaxPerGram, ext::setGoldMaxPerGram, changes);
|
||||||
|
setDecimal(command, "goldMinNetWeight", ext::getGoldMinNetWeightG, ext::setGoldMinNetWeightG, changes);
|
||||||
|
setDecimal(command, "goldMaxNetWeightPerCustomer", ext::getGoldMaxNetWeightPerCustomerG, ext::setGoldMaxNetWeightPerCustomerG, changes);
|
||||||
|
setString(command, "goldAllowedItems", ext::getGoldAllowedItems, ext::setGoldAllowedItems, changes);
|
||||||
|
setBoolean(command, "goldAppraiserRequired", ext::getGoldAppraiserRequired, ext::setGoldAppraiserRequired, changes);
|
||||||
|
setBoolean(command, "goldValuationCertificateRequired", ext::getGoldValuationCertificateRequired, ext::setGoldValuationCertificateRequired, changes);
|
||||||
|
setDecimal(command, "goldMaxValuationPerAppraiser", ext::getGoldMaxValuationPerAppraiser, ext::setGoldMaxValuationPerAppraiser, changes);
|
||||||
|
setString(command, "goldPacketPrefix", ext::getGoldPacketPrefix, ext::setGoldPacketPrefix, changes);
|
||||||
|
setInteger(command, "goldAuctionNoticeDays", ext::getGoldAuctionNoticeDays, ext::setGoldAuctionNoticeDays, changes);
|
||||||
|
setBoolean(command, "goldRevalueOnNpa", ext::getGoldRevalueOnNpa, ext::setGoldRevalueOnNpa, changes);
|
||||||
|
setBoolean(command, "goldRevaluationChargeApplicable", ext::getGoldRevaluationChargeApplicable,
|
||||||
|
ext::setGoldRevaluationChargeApplicable, changes);
|
||||||
|
setBoolean(command, "goldPartialReleaseAllowed", ext::getGoldPartialReleaseAllowed,
|
||||||
|
ext::setGoldPartialReleaseAllowed, changes);
|
||||||
|
setBoolean(command, "goldThirdPartyAllowed", ext::getGoldThirdPartyAllowed, ext::setGoldThirdPartyAllowed,
|
||||||
|
changes);
|
||||||
|
setString(command, "goldDisbursementMode", ext::getGoldDisbursementMode, ext::setGoldDisbursementMode, changes);
|
||||||
|
setDecimal(command, "goldCashDisbursementLimit", ext::getGoldCashDisbursementLimit,
|
||||||
|
ext::setGoldCashDisbursementLimit, changes);
|
||||||
|
setDecimal(command, "goldLtvBreachAlert", ext::getGoldLtvBreachAlertPct, ext::setGoldLtvBreachAlertPct, changes);
|
||||||
|
setDecimal(command, "goldAuctionReservePct", ext::getGoldAuctionReservePct, ext::setGoldAuctionReservePct,
|
||||||
|
changes);
|
||||||
|
setBoolean(command, "goldRedeemBeforeAuction", ext::getGoldRedeemBeforeAuction, ext::setGoldRedeemBeforeAuction,
|
||||||
|
changes);
|
||||||
|
setDecimal(command, "wrMaxLtv", ext::getWrMaxLtvPct, ext::setWrMaxLtvPct, changes);
|
||||||
|
setString(command, "wrReceiptType", ext::getWrReceiptType, ext::setWrReceiptType, changes);
|
||||||
|
setBoolean(command, "wrWdraRegistered", ext::getWrWdraRegistered, ext::setWrWdraRegistered, changes);
|
||||||
|
setBoolean(command, "wrAssayCertificateRequired", ext::getWrAssayCertificateRequired, ext::setWrAssayCertificateRequired, changes);
|
||||||
|
setBoolean(command, "wrTenureWithinReceiptValidity", ext::getWrTenureWithinReceiptValidity, ext::setWrTenureWithinReceiptValidity, changes);
|
||||||
|
setBoolean(command, "wrPledgeMarkedBeforeDisbursement", ext::getWrPledgeMarkedBeforeDisbursement, ext::setWrPledgeMarkedBeforeDisbursement, changes);
|
||||||
|
setString(command, "wrCommodityGroups", ext::getWrCommodityGroups, ext::setWrCommodityGroups, changes);
|
||||||
|
setString(command, "wrValuationPriceSource", ext::getWrValuationPriceSource, ext::setWrValuationPriceSource, changes);
|
||||||
|
setDecimal(command, "wrMaxLoanPerQuintal", ext::getWrMaxLoanPerQuintal, ext::setWrMaxLoanPerQuintal, changes);
|
||||||
|
setInteger(command, "wrStockInspectionMonths", ext::getWrStockInspectionMonths, ext::setWrStockInspectionMonths, changes);
|
||||||
|
setDecimal(command, "wrLtvBreachAlert", ext::getWrLtvBreachAlertPct, ext::setWrLtvBreachAlertPct, changes);
|
||||||
|
setBoolean(command, "wrPartialReleaseAllowed", ext::getWrPartialReleaseAllowed, ext::setWrPartialReleaseAllowed, changes);
|
||||||
|
setInteger(command, "wrDisposalNoticeDays", ext::getWrDisposalNoticeDays, ext::setWrDisposalNoticeDays, changes);
|
||||||
|
setDecimal(command, "wrDisposalReservePct", ext::getWrDisposalReservePct, ext::setWrDisposalReservePct, changes);
|
||||||
|
setBoolean(command, "wrRedeemBeforeDisposal", ext::getWrRedeemBeforeDisposal, ext::setWrRedeemBeforeDisposal, changes);
|
||||||
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
|
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
|
||||||
changes);
|
changes);
|
||||||
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
|
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
|
||||||
@@ -289,6 +549,20 @@ public class LoanProductAdvanceExtService {
|
|||||||
setBoolean(command, "releaseCollateralOnClosure", ext::getReleaseCollateralOnClosure, ext::setReleaseCollateralOnClosure, changes);
|
setBoolean(command, "releaseCollateralOnClosure", ext::getReleaseCollateralOnClosure, ext::setReleaseCollateralOnClosure, changes);
|
||||||
setBoolean(command, "topUpAllowed", ext::getTopUpAllowed, ext::setTopUpAllowed, changes);
|
setBoolean(command, "topUpAllowed", ext::getTopUpAllowed, ext::setTopUpAllowed, changes);
|
||||||
setBoolean(command, "balanceTransferOutAllowed", ext::getBalanceTransferOutAllowed, ext::setBalanceTransferOutAllowed, changes);
|
setBoolean(command, "balanceTransferOutAllowed", ext::getBalanceTransferOutAllowed, ext::setBalanceTransferOutAllowed, changes);
|
||||||
|
// SICR & LGD (RBI 2026 ECL framework)
|
||||||
|
setInteger(command, "sicrDaysThreshold", ext::getSicrDaysThreshold, ext::setSicrDaysThreshold, changes);
|
||||||
|
setInteger(command, "sicrRevolvingDaysThreshold", ext::getSicrRevolvingDaysThreshold, ext::setSicrRevolvingDaysThreshold, changes);
|
||||||
|
setInteger(command, "sicrRatingDowngradeNotches", ext::getSicrRatingDowngradeNotches, ext::setSicrRatingDowngradeNotches, changes);
|
||||||
|
setDecimal(command, "sicrPricingIncreaseTriggerPct", ext::getSicrPricingIncreaseTriggerPct, ext::setSicrPricingIncreaseTriggerPct,
|
||||||
|
changes);
|
||||||
|
setString(command, "sicrAssessmentBasis", ext::getSicrAssessmentBasis, ext::setSicrAssessmentBasis, changes);
|
||||||
|
setBoolean(command, "sicrRebuttalAllowed", ext::getSicrRebuttalAllowed, ext::setSicrRebuttalAllowed, changes);
|
||||||
|
setString(command, "npaOutOfOrderBasis", ext::getNpaOutOfOrderBasis, ext::setNpaOutOfOrderBasis, changes);
|
||||||
|
setString(command, "cropDuration", ext::getCropDuration, ext::setCropDuration, changes);
|
||||||
|
setBoolean(command, "useOwnLgdModel", ext::getUseOwnLgdModel, ext::setUseOwnLgdModel, changes);
|
||||||
|
setDecimal(command, "lgdSecuredPct", ext::getLgdSecuredPct, ext::setLgdSecuredPct, changes);
|
||||||
|
setDecimal(command, "lgdUnsecuredPct", ext::getLgdUnsecuredPct, ext::setLgdUnsecuredPct, changes);
|
||||||
|
setString(command, "eligibleCollateralType", ext::getEligibleCollateralType, ext::setEligibleCollateralType, changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- typed setters: only write params that are present, and only report real changes ----------
|
// ---- typed setters: only write params that are present, and only report real changes ----------
|
||||||
@@ -403,6 +677,13 @@ public class LoanProductAdvanceExtService {
|
|||||||
map.put("penalAppliedOn", ext.getPenalAppliedOn());
|
map.put("penalAppliedOn", ext.getPenalAppliedOn());
|
||||||
map.put("penalGraceDays", ext.getPenalGraceDays());
|
map.put("penalGraceDays", ext.getPenalGraceDays());
|
||||||
map.put("npaTrigger", ext.getNpaTrigger());
|
map.put("npaTrigger", ext.getNpaTrigger());
|
||||||
|
map.put("smaStartAfterMonths", ext.getSmaStartAfterMonths());
|
||||||
|
map.put("memberRequired", ext.getMemberRequired());
|
||||||
|
map.put("guarantorMustBeMember", ext.getGuarantorMustBeMember());
|
||||||
|
map.put("incomeMultiple", ext.getIncomeMultiple());
|
||||||
|
map.put("minVintageMonths", ext.getMinVintageMonths());
|
||||||
|
map.put("requiredDocuments", ext.getRequiredDocuments());
|
||||||
|
map.put("stockStatementValidityDays", ext.getStockStatementValidityDays());
|
||||||
map.put("interestAfterNpa", ext.getInterestAfterNpa());
|
map.put("interestAfterNpa", ext.getInterestAfterNpa());
|
||||||
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
|
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
|
||||||
map.put("renewalAllowed", ext.getRenewalAllowed());
|
map.put("renewalAllowed", ext.getRenewalAllowed());
|
||||||
@@ -411,6 +692,42 @@ public class LoanProductAdvanceExtService {
|
|||||||
map.put("preclosureAllowed", ext.getPreclosureAllowed());
|
map.put("preclosureAllowed", ext.getPreclosureAllowed());
|
||||||
map.put("lockInMonths", ext.getLockInMonths());
|
map.put("lockInMonths", ext.getLockInMonths());
|
||||||
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
|
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
|
||||||
|
map.put("goldMaxLtv", ext.getGoldMaxLtvPct());
|
||||||
|
map.put("goldMinPurityCarat", ext.getGoldMinPurityCarat());
|
||||||
|
map.put("goldRateSource", ext.getGoldRateSource());
|
||||||
|
map.put("goldMaxPerGram", ext.getGoldMaxPerGram());
|
||||||
|
map.put("goldMinNetWeight", ext.getGoldMinNetWeightG());
|
||||||
|
map.put("goldMaxNetWeightPerCustomer", ext.getGoldMaxNetWeightPerCustomerG());
|
||||||
|
map.put("goldAllowedItems", ext.getGoldAllowedItems());
|
||||||
|
map.put("goldAppraiserRequired", ext.getGoldAppraiserRequired());
|
||||||
|
map.put("goldValuationCertificateRequired", ext.getGoldValuationCertificateRequired());
|
||||||
|
map.put("goldMaxValuationPerAppraiser", ext.getGoldMaxValuationPerAppraiser());
|
||||||
|
map.put("goldPacketPrefix", ext.getGoldPacketPrefix());
|
||||||
|
map.put("goldAuctionNoticeDays", ext.getGoldAuctionNoticeDays());
|
||||||
|
map.put("goldRevalueOnNpa", ext.getGoldRevalueOnNpa());
|
||||||
|
map.put("goldRevaluationChargeApplicable", ext.getGoldRevaluationChargeApplicable());
|
||||||
|
map.put("goldPartialReleaseAllowed", ext.getGoldPartialReleaseAllowed());
|
||||||
|
map.put("goldThirdPartyAllowed", ext.getGoldThirdPartyAllowed());
|
||||||
|
map.put("goldDisbursementMode", ext.getGoldDisbursementMode());
|
||||||
|
map.put("goldCashDisbursementLimit", ext.getGoldCashDisbursementLimit());
|
||||||
|
map.put("goldLtvBreachAlert", ext.getGoldLtvBreachAlertPct());
|
||||||
|
map.put("goldAuctionReservePct", ext.getGoldAuctionReservePct());
|
||||||
|
map.put("goldRedeemBeforeAuction", ext.getGoldRedeemBeforeAuction());
|
||||||
|
map.put("wrMaxLtv", ext.getWrMaxLtvPct());
|
||||||
|
map.put("wrReceiptType", ext.getWrReceiptType());
|
||||||
|
map.put("wrWdraRegistered", ext.getWrWdraRegistered());
|
||||||
|
map.put("wrAssayCertificateRequired", ext.getWrAssayCertificateRequired());
|
||||||
|
map.put("wrTenureWithinReceiptValidity", ext.getWrTenureWithinReceiptValidity());
|
||||||
|
map.put("wrPledgeMarkedBeforeDisbursement", ext.getWrPledgeMarkedBeforeDisbursement());
|
||||||
|
map.put("wrCommodityGroups", ext.getWrCommodityGroups());
|
||||||
|
map.put("wrValuationPriceSource", ext.getWrValuationPriceSource());
|
||||||
|
map.put("wrMaxLoanPerQuintal", ext.getWrMaxLoanPerQuintal());
|
||||||
|
map.put("wrStockInspectionMonths", ext.getWrStockInspectionMonths());
|
||||||
|
map.put("wrLtvBreachAlert", ext.getWrLtvBreachAlertPct());
|
||||||
|
map.put("wrPartialReleaseAllowed", ext.getWrPartialReleaseAllowed());
|
||||||
|
map.put("wrDisposalNoticeDays", ext.getWrDisposalNoticeDays());
|
||||||
|
map.put("wrDisposalReservePct", ext.getWrDisposalReservePct());
|
||||||
|
map.put("wrRedeemBeforeDisposal", ext.getWrRedeemBeforeDisposal());
|
||||||
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
|
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
|
||||||
map.put("insuranceRequired", ext.getInsuranceRequired());
|
map.put("insuranceRequired", ext.getInsuranceRequired());
|
||||||
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
|
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
|
||||||
@@ -509,6 +826,19 @@ public class LoanProductAdvanceExtService {
|
|||||||
map.put("releaseCollateralOnClosure", ext.getReleaseCollateralOnClosure());
|
map.put("releaseCollateralOnClosure", ext.getReleaseCollateralOnClosure());
|
||||||
map.put("topUpAllowed", ext.getTopUpAllowed());
|
map.put("topUpAllowed", ext.getTopUpAllowed());
|
||||||
map.put("balanceTransferOutAllowed", ext.getBalanceTransferOutAllowed());
|
map.put("balanceTransferOutAllowed", ext.getBalanceTransferOutAllowed());
|
||||||
|
// SICR & LGD (RBI 2026 ECL framework)
|
||||||
|
map.put("sicrDaysThreshold", ext.getSicrDaysThreshold());
|
||||||
|
map.put("sicrRevolvingDaysThreshold", ext.getSicrRevolvingDaysThreshold());
|
||||||
|
map.put("sicrRatingDowngradeNotches", ext.getSicrRatingDowngradeNotches());
|
||||||
|
map.put("sicrPricingIncreaseTriggerPct", ext.getSicrPricingIncreaseTriggerPct());
|
||||||
|
map.put("sicrAssessmentBasis", ext.getSicrAssessmentBasis());
|
||||||
|
map.put("sicrRebuttalAllowed", ext.getSicrRebuttalAllowed());
|
||||||
|
map.put("npaOutOfOrderBasis", ext.getNpaOutOfOrderBasis());
|
||||||
|
map.put("cropDuration", ext.getCropDuration());
|
||||||
|
map.put("useOwnLgdModel", ext.getUseOwnLgdModel());
|
||||||
|
map.put("lgdSecuredPct", ext.getLgdSecuredPct());
|
||||||
|
map.put("lgdUnsecuredPct", ext.getLgdUnsecuredPct());
|
||||||
|
map.put("eligibleCollateralType", ext.getEligibleCollateralType());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import org.apache.commons.lang3.StringUtils;
|
|||||||
import org.apache.commons.lang3.Strings;
|
import org.apache.commons.lang3.Strings;
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
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.group.domain.Group;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
|
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
|
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)
|
@JoinColumn(name = "share_account_id", nullable = true)
|
||||||
private ShareAccount shareAccount;
|
private ShareAccount shareAccount;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "current_account_id", nullable = true)
|
||||||
|
private CurrentAccount currentAccount;
|
||||||
|
|
||||||
public static Note clientNote(final Client client, final String note) {
|
public static Note clientNote(final Client client, final String note) {
|
||||||
return new Note(client, note);
|
return new Note(client, note);
|
||||||
}
|
}
|
||||||
@@ -110,6 +115,10 @@ public class Note extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return new Note(account, note);
|
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) {
|
public Note(final Client client, final String note) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.note = note;
|
this.note = note;
|
||||||
@@ -161,6 +170,13 @@ public class Note extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
this.noteTypeId = NoteType.SHARE_ACCOUNT.getValue();
|
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) {
|
public Map<String, Object> update(final String note) {
|
||||||
if (!Strings.CI.equals(note, this.note)) {
|
if (!Strings.CI.equals(note, this.note)) {
|
||||||
this.note = StringUtils.defaultIfEmpty(note, null);
|
this.note = StringUtils.defaultIfEmpty(note, null);
|
||||||
|
|||||||
+3
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.note.domain;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
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.group.domain.Group;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
|
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
|
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 findBySavingsAccountAndId(SavingsAccount savingAccount, Long id);
|
||||||
|
|
||||||
|
Note findByCurrentAccountAndId(CurrentAccount currentAccount, Long id);
|
||||||
|
|
||||||
@Query("select note from Note note where note.savingsTransaction.id = :savingsTransactionId")
|
@Query("select note from Note note where note.savingsTransaction.id = :savingsTransactionId")
|
||||||
List<Note> findBySavingsTransactionId(@Param("savingsTransactionId") Long savingsTransactionId);
|
List<Note> findBySavingsTransactionId(@Param("savingsTransactionId") Long savingsTransactionId);
|
||||||
|
|
||||||
|
|||||||
+4
@@ -137,6 +137,10 @@ public class NoteReadPlatformServiceImpl implements NoteReadPlatformService {
|
|||||||
case GROUP:
|
case GROUP:
|
||||||
conditionSql = " n.group_id = ? ";
|
conditionSql = " n.group_id = ? ";
|
||||||
break;
|
break;
|
||||||
|
case CURRENT_ACCOUNT:
|
||||||
|
paramList.add(NoteType.CURRENT_ACCOUNT.getValue());
|
||||||
|
conditionSql = " n.current_account_id = ? and n.note_type_enum = ? ";
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.apache.commons.lang3.Strings;
|
import org.apache.commons.lang3.Strings;
|
||||||
import org.apache.commons.lang3.tuple.Pair;
|
import org.apache.commons.lang3.tuple.Pair;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
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.domain.GroupRepository;
|
||||||
import org.apache.fineract.portfolio.group.exception.GroupNotFoundException;
|
import org.apache.fineract.portfolio.group.exception.GroupNotFoundException;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
|
||||||
@@ -50,16 +51,19 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf
|
|||||||
private final LoanRepositoryWrapper loanRepository;
|
private final LoanRepositoryWrapper loanRepository;
|
||||||
private final LoanTransactionRepository loanTransactionRepository;
|
private final LoanTransactionRepository loanTransactionRepository;
|
||||||
private final SavingsAccountRepository savingsAccountRepository;
|
private final SavingsAccountRepository savingsAccountRepository;
|
||||||
|
private final CurrentAccountRepositoryWrapper currentAccountRepository;
|
||||||
|
|
||||||
public NoteWritePlatformServiceJpaRepositoryImpl(final NoteRepository noteRepository, final ClientRepositoryWrapper clientRepository,
|
public NoteWritePlatformServiceJpaRepositoryImpl(final NoteRepository noteRepository, final ClientRepositoryWrapper clientRepository,
|
||||||
final GroupRepository groupRepository, final LoanRepositoryWrapper loanRepository,
|
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.noteRepository = noteRepository;
|
||||||
this.clientRepository = clientRepository;
|
this.clientRepository = clientRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.loanRepository = loanRepository;
|
this.loanRepository = loanRepository;
|
||||||
this.loanTransactionRepository = loanTransactionRepository;
|
this.loanTransactionRepository = loanTransactionRepository;
|
||||||
this.savingsAccountRepository = savingsAccountRepository;
|
this.savingsAccountRepository = savingsAccountRepository;
|
||||||
|
this.currentAccountRepository = currentAccountRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -101,6 +105,12 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf
|
|||||||
officeId = savingAccount.getClient().getOffice().getId();
|
officeId = savingAccount.getClient().getOffice().getId();
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
throw new NoteResourceNotSupportedException(request.getType().getApiUrl());
|
throw new NoteResourceNotSupportedException(request.getType().getApiUrl());
|
||||||
}
|
}
|
||||||
@@ -168,6 +178,12 @@ public class NoteWritePlatformServiceJpaRepositoryImpl implements NoteWritePlatf
|
|||||||
officeId = savingAccount.getClient().getOffice().getId();
|
officeId = savingAccount.getClient().getOffice().getId();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case CURRENT_ACCOUNT: {
|
||||||
|
final var currentAccount = currentAccountRepository.findOneWithNotFoundDetection(resourceId);
|
||||||
|
note = noteRepository.findByCurrentAccountAndId(currentAccount, noteId);
|
||||||
|
officeId = currentAccount.getOffice().getId();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case SHARE_ACCOUNT:
|
case SHARE_ACCOUNT:
|
||||||
case SAVINGS_TRANSACTION:
|
case SAVINGS_TRANSACTION:
|
||||||
log.error("Not yet implemented: {}", type);
|
log.error("Not yet implemented: {}", type);
|
||||||
|
|||||||
+3
-2
@@ -19,6 +19,7 @@
|
|||||||
package org.apache.fineract.portfolio.note.starter;
|
package org.apache.fineract.portfolio.note.starter;
|
||||||
|
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
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.domain.GroupRepository;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||||
@@ -46,8 +47,8 @@ public class NoteAutoConfiguration {
|
|||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
public NoteWritePlatformService noteWritePlatformService(NoteRepository noteRepository, ClientRepositoryWrapper clientRepository,
|
public NoteWritePlatformService noteWritePlatformService(NoteRepository noteRepository, ClientRepositoryWrapper clientRepository,
|
||||||
GroupRepository groupRepository, LoanRepositoryWrapper loanRepository, LoanTransactionRepository loanTransactionRepository,
|
GroupRepository groupRepository, LoanRepositoryWrapper loanRepository, LoanTransactionRepository loanTransactionRepository,
|
||||||
SavingsAccountRepository savingsAccountRepository) {
|
SavingsAccountRepository savingsAccountRepository, CurrentAccountRepositoryWrapper currentAccountRepository) {
|
||||||
return new NoteWritePlatformServiceJpaRepositoryImpl(noteRepository, clientRepository, groupRepository, loanRepository,
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user