Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08519da8b1 | ||
|
|
cad7f54c47 | ||
|
|
8724c54d9b | ||
|
|
5c0a482f49 | ||
|
|
0e07e60328 | ||
|
|
e227e8ae0f | ||
|
|
231919b62b | ||
|
|
9eac40bb5c | ||
|
|
14b2585cf5 | ||
|
|
a2fa8688ae | ||
|
|
a8ca7dd948 | ||
|
|
c8833367b9 | ||
|
|
94b0afff38 | ||
|
|
9dd4f8bedb |
+3
-1
@@ -436,7 +436,9 @@ public final class AccountingConstants {
|
|||||||
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
||||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); //
|
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||||
|
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||||
|
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|||||||
+20
-1
@@ -137,7 +137,7 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
if (isApprovedByChecker || user.isCheckerSuperUser()) {
|
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -151,6 +151,25 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||||
|
if (!user.isCheckerSuperUser()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+8
@@ -2211,6 +2211,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createCbsBaseInterestRate() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "CBSBASEINTERESTRATE";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/fixeddepositproducts/baseinterestrates";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||||
this.actionName = "UPDATE";
|
this.actionName = "UPDATE";
|
||||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||||
|
|||||||
+2
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
|
|||||||
|
|
||||||
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
||||||
|
|
||||||
|
boolean isMakerCheckerEnabled();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
||||||
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
||||||
|
|||||||
+1
@@ -38,6 +38,7 @@ public enum JobName {
|
|||||||
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
||||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||||
|
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||||
|
|||||||
+1
@@ -191,6 +191,7 @@ public class ClientApiConstants {
|
|||||||
public static final String constitutionIdParamName = "constitutionId";
|
public static final String constitutionIdParamName = "constitutionId";
|
||||||
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
||||||
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
||||||
|
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
|
||||||
public static final String additionalInfoParamName = "additionalInfo";
|
public static final String additionalInfoParamName = "additionalInfo";
|
||||||
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
private Boolean alternateDipVerified;
|
private Boolean alternateDipVerified;
|
||||||
private Long alternateDipLookupLogId;
|
private Long alternateDipLookupLogId;
|
||||||
private String alternateDipTimestamp;
|
private String alternateDipTimestamp;
|
||||||
|
/**
|
||||||
|
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
|
||||||
|
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
|
||||||
|
* reassigned would otherwise keep showing "Verified" forever.
|
||||||
|
*/
|
||||||
|
private String dipLiveStatus;
|
||||||
|
private String alternateDipLiveStatus;
|
||||||
|
/**
|
||||||
|
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
|
||||||
|
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
|
||||||
|
* would keep reading "Clear" after the client was added to a later list snapshot.
|
||||||
|
*/
|
||||||
|
private String sanctionsAlertStatus;
|
||||||
private Boolean nameVerified;
|
private Boolean nameVerified;
|
||||||
private Long screeningLogId;
|
private Long screeningLogId;
|
||||||
private Boolean isStaff;
|
private Boolean isStaff;
|
||||||
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
+14
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
private final CodeValueData mainBusinessLine;
|
private final CodeValueData mainBusinessLine;
|
||||||
private final String remarks;
|
private final String remarks;
|
||||||
private final String authorizedSignatory;
|
private final String authorizedSignatory;
|
||||||
|
/** Client ids behind {@link #authorizedSignatory}, comma separated and in the same order. */
|
||||||
|
private final String authorizedSignatoryClientIds;
|
||||||
private final String additionalInfo;
|
private final String additionalInfo;
|
||||||
private final LocalDate dateOfRegistration;
|
private final LocalDate dateOfRegistration;
|
||||||
|
|
||||||
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
this.constitution = null;
|
this.constitution = null;
|
||||||
this.mainBusinessLine = null;
|
this.mainBusinessLine = null;
|
||||||
this.authorizedSignatory = null;
|
this.authorizedSignatory = null;
|
||||||
|
this.authorizedSignatoryClientIds = null;
|
||||||
this.additionalInfo = null;
|
this.additionalInfo = null;
|
||||||
this.dateOfRegistration = null;
|
this.dateOfRegistration = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kept for the list/search mappers, whose SQL does not select the signatory client ids.
|
||||||
|
*/
|
||||||
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
LocalDate dateOfRegistration) {
|
LocalDate dateOfRegistration) {
|
||||||
|
this(constitution, incorpNo, incorpValidityTillDate, mainBusinessLine, remarks, authorizedSignatory, additionalInfo,
|
||||||
|
dateOfRegistration, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||||
|
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
|
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
|
||||||
|
|
||||||
this.constitution = constitution;
|
this.constitution = constitution;
|
||||||
this.incorpNumber = incorpNo;
|
this.incorpNumber = incorpNo;
|
||||||
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
this.mainBusinessLine = mainBusinessLine;
|
this.mainBusinessLine = mainBusinessLine;
|
||||||
this.remarks = remarks;
|
this.remarks = remarks;
|
||||||
this.authorizedSignatory = authorizedSignatory;
|
this.authorizedSignatory = authorizedSignatory;
|
||||||
|
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
|
||||||
this.additionalInfo = additionalInfo;
|
this.additionalInfo = additionalInfo;
|
||||||
this.dateOfRegistration = dateOfRegistration;
|
this.dateOfRegistration = dateOfRegistration;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "alternate_dip_timestamp")
|
@Column(name = "alternate_dip_timestamp")
|
||||||
private String alternateDipTimestamp;
|
private String alternateDipTimestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DIP/MNRL status the number actually returned — CLEAN, SUSPECTED, DISCONNECTED or
|
||||||
|
* SERVICE_UNAVAILABLE. dipVerified alone cannot tell "checked and clean" apart from "the DoT
|
||||||
|
* service was down", which previously stored both as verified.
|
||||||
|
*/
|
||||||
|
@Column(name = "dip_status", length = 40)
|
||||||
|
private String dipStatus;
|
||||||
|
|
||||||
|
@Column(name = "alternate_dip_status", length = 40)
|
||||||
|
private String alternateDipStatus;
|
||||||
|
|
||||||
@Column(name = "name_verified")
|
@Column(name = "name_verified")
|
||||||
private Boolean nameVerified;
|
private Boolean nameVerified;
|
||||||
|
|
||||||
|
|||||||
+52
@@ -25,11 +25,15 @@ import jakarta.persistence.Entity;
|
|||||||
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 jakarta.persistence.Transient;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
@@ -61,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
private Client client;
|
private Client client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scratch record of what the in-flight {@link #update} replaced, read back via
|
||||||
|
* {@link #getFieldsOverwrittenByLastUpdate()}. Never persisted and never read outside the
|
||||||
|
* transaction that called update(), which clears it first.
|
||||||
|
*/
|
||||||
|
@Transient
|
||||||
|
private final transient Set<String> fieldsOverwrittenByLastUpdate = new LinkedHashSet<>();
|
||||||
|
|
||||||
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
||||||
@Column(name = "kyc_status", nullable = false)
|
@Column(name = "kyc_status", nullable = false)
|
||||||
private Integer kycStatus;
|
private Integer kycStatus;
|
||||||
@@ -440,11 +452,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
|
|
||||||
public Map<String, Object> update(final JsonCommand command) {
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
||||||
|
this.fieldsOverwrittenByLastUpdate.clear();
|
||||||
|
|
||||||
final String panNumberParam = "panNumber";
|
final String panNumberParam = "panNumber";
|
||||||
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
||||||
actualChanges.put(panNumberParam, newValue);
|
actualChanges.put(panNumberParam, newValue);
|
||||||
|
recordOverwrite(panNumberParam, this.panNumber);
|
||||||
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
||||||
actualChanges.put(aadhaarParam, newValue);
|
actualChanges.put(aadhaarParam, newValue);
|
||||||
|
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
||||||
actualChanges.put(gstinParam, newValue);
|
actualChanges.put(gstinParam, newValue);
|
||||||
|
recordOverwrite(gstinParam, this.gstinNumber);
|
||||||
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,9 +482,12 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
||||||
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
||||||
actualChanges.put(riskParam, newValue);
|
actualChanges.put(riskParam, newValue);
|
||||||
|
recordOverwrite(riskParam, this.riskCategory);
|
||||||
this.riskCategory = newValue;
|
this.riskCategory = newValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
||||||
|
// as an overwrite — requiring remarks in order to write remarks would be circular.
|
||||||
final String remarksParam = "remarks";
|
final String remarksParam = "remarks";
|
||||||
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
||||||
@@ -551,6 +570,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(param);
|
final String newValue = command.stringValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,6 +580,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,6 +591,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,10 +602,40 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes that {@code param} replaced a value the record already held. A field going from empty to
|
||||||
|
* populated is the record being completed rather than altered, so it is deliberately not
|
||||||
|
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||||
|
*/
|
||||||
|
private void recordOverwrite(final String param, final Object currentValue) {
|
||||||
|
final boolean wasPopulated = currentValue instanceof String text
|
||||||
|
? StringUtils.isNotBlank(text)
|
||||||
|
: currentValue != null;
|
||||||
|
if (wasPopulated) {
|
||||||
|
this.fieldsOverwrittenByLastUpdate.add(param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters whose already-populated value the most recent {@link #update} replaced.
|
||||||
|
*
|
||||||
|
* <p>Distinguishes altering a record from finishing one: a non-individual KYC auto-verifies as
|
||||||
|
* soon as PAN and GSTIN pass, which happens part-way through the create wizard, and the officer
|
||||||
|
* still has to save the KYC document details afterwards. Those fields are empty at that point,
|
||||||
|
* so filling them in leaves this set empty and no audit justification is demanded — while
|
||||||
|
* changing a document number that was already on the verified record does populate it.
|
||||||
|
*
|
||||||
|
* <p>Reset at the start of every {@link #update} and never persisted: it describes one call.
|
||||||
|
*/
|
||||||
|
public Set<String> getFieldsOverwrittenByLastUpdate() {
|
||||||
|
return Collections.unmodifiableSet(this.fieldsOverwrittenByLastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
||||||
public void markInProgress() {
|
public void markInProgress() {
|
||||||
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
||||||
|
|||||||
+46
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
|||||||
@Column(name = "looked_up_on", nullable = false)
|
@Column(name = "looked_up_on", nullable = false)
|
||||||
private OffsetDateTime lookedUpOn;
|
private OffsetDateTime lookedUpOn;
|
||||||
|
|
||||||
|
// ── Officer decision on a non-clean result ────────────────────────────────
|
||||||
|
// A DIP hit is a fraud-risk signal rather than a legal bar, so onboarding may continue — but
|
||||||
|
// the officer has to say why in writing, and that reasoning is kept with the lookup.
|
||||||
|
|
||||||
|
/** PROCEEDED | REJECTED. Null while no decision is required or none has been taken. */
|
||||||
|
@Column(name = "override_decision", length = 20)
|
||||||
|
private String overrideDecision;
|
||||||
|
|
||||||
|
@Column(name = "override_reason")
|
||||||
|
private String overrideReason;
|
||||||
|
|
||||||
|
@Column(name = "decided_by")
|
||||||
|
private Long decidedBy;
|
||||||
|
|
||||||
|
@Column(name = "decided_on")
|
||||||
|
private OffsetDateTime decidedOn;
|
||||||
|
|
||||||
|
public static final String STATUS_CLEAN = "CLEAN";
|
||||||
|
public static final String STATUS_ERROR = "ERROR";
|
||||||
|
public static final String STATUS_SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE";
|
||||||
|
public static final String DECISION_PROCEEDED = "PROCEEDED";
|
||||||
|
public static final String DECISION_REJECTED = "REJECTED";
|
||||||
|
|
||||||
protected MobileLookupLog() {}
|
protected MobileLookupLog() {}
|
||||||
|
|
||||||
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
||||||
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
|||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
|
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void recordDecision(final String decision, final String reason, final Long userId) {
|
||||||
|
this.overrideDecision = decision;
|
||||||
|
this.overrideReason = reason;
|
||||||
|
this.decidedBy = userId;
|
||||||
|
this.decidedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the lookup produced something the officer must consciously decide about. */
|
||||||
|
public boolean requiresDecision() {
|
||||||
|
return this.suspected || this.disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number may be used only when it came back clean, or when an officer explicitly chose to
|
||||||
|
* proceed. An unreachable DoT service is NOT a pass — it means nothing was checked.
|
||||||
|
*/
|
||||||
|
public boolean isUsable() {
|
||||||
|
if (STATUS_CLEAN.equals(this.lookupStatus)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return DECISION_PROCEEDED.equals(this.overrideDecision);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -19,6 +19,8 @@
|
|||||||
package org.apache.fineract.portfolio.mobilelookup.domain;
|
package org.apache.fineract.portfolio.mobilelookup.domain;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -35,4 +37,29 @@ public interface MobileLookupLogRepository extends JpaRepository<MobileLookupLog
|
|||||||
|
|
||||||
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
|
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
|
||||||
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
|
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
|
||||||
|
|
||||||
|
/** Non-clean lookups still waiting for an officer's decision — the DIP review queue. */
|
||||||
|
@Query("SELECT l FROM MobileLookupLog l WHERE (l.suspected = true OR l.disconnected = true) "
|
||||||
|
+ "AND l.overrideDecision IS NULL ORDER BY l.lookedUpOn DESC")
|
||||||
|
List<MobileLookupLog> findPendingDecisions();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most recent lookup for a number, used by the server-side gate to confirm the browser's
|
||||||
|
* claim that a number was verified.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Paged rather than a JPQL {@code LIMIT}, which EclipseLink does not accept — the caller
|
||||||
|
* takes the first row.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC")
|
||||||
|
List<MobileLookupLog> findLatestByMobileNumber(@Param("mobileNumber") String mobileNumber, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Earlier rejections of the same number, on any client. A number turned away once is a
|
||||||
|
* standing signal: without this the next officer screens it fresh and sees no history.
|
||||||
|
*/
|
||||||
|
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber "
|
||||||
|
+ "AND l.overrideDecision = 'REJECTED' ORDER BY l.decidedOn DESC")
|
||||||
|
List<MobileLookupLog> findRejections(@Param("mobileNumber") String mobileNumber, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
+139
@@ -61,6 +61,65 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
@Column(name = "screened_on", nullable = false)
|
@Column(name = "screened_on", nullable = false)
|
||||||
private OffsetDateTime screenedOn;
|
private OffsetDateTime screenedOn;
|
||||||
|
|
||||||
|
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||||
|
// A FLAGGED screening opens a case. It stays PENDING_REVIEW until an officer either
|
||||||
|
// documents it as a different person (FALSE_POSITIVE, effective only once the Principal
|
||||||
|
// Officer approves) or confirms it (CONFIRMED_MATCH, which freezes and gets reported).
|
||||||
|
|
||||||
|
@Column(name = "disposition", length = 30)
|
||||||
|
private String disposition;
|
||||||
|
|
||||||
|
@Column(name = "disposition_reason")
|
||||||
|
private String dispositionReason;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_by")
|
||||||
|
private Long reviewedBy;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_on")
|
||||||
|
private OffsetDateTime reviewedOn;
|
||||||
|
|
||||||
|
@Column(name = "approved_by")
|
||||||
|
private Long approvedBy;
|
||||||
|
|
||||||
|
@Column(name = "approved_on")
|
||||||
|
private OffsetDateTime approvedOn;
|
||||||
|
|
||||||
|
@Column(name = "fiu_str_reference", length = 100)
|
||||||
|
private String fiuStrReference;
|
||||||
|
|
||||||
|
@Column(name = "nodal_officer_informed_on")
|
||||||
|
private OffsetDateTime nodalOfficerInformedOn;
|
||||||
|
|
||||||
|
@Column(name = "frozen_account_ids", length = 1000)
|
||||||
|
private String frozenAccountIds;
|
||||||
|
|
||||||
|
@Column(name = "frozen_on")
|
||||||
|
private OffsetDateTime frozenOn;
|
||||||
|
|
||||||
|
// ── STR tracking ──────────────────────────────────────────────────────────
|
||||||
|
// A confirmed match is reportable to FIU-IND. Tracked here rather than as an AML alert: an
|
||||||
|
// AML alert is built around a monetary transaction, whereas this is triggered by who the
|
||||||
|
// customer is, so the two are surfaced as separate streams on the FIU screen.
|
||||||
|
|
||||||
|
/** NULL until approval; then PENDING_FILING → FILED. */
|
||||||
|
@Column(name = "str_status", length = 30)
|
||||||
|
private String strStatus;
|
||||||
|
|
||||||
|
@Column(name = "str_due_date")
|
||||||
|
private java.time.LocalDate strDueDate;
|
||||||
|
|
||||||
|
@Column(name = "str_raised_on")
|
||||||
|
private OffsetDateTime strRaisedOn;
|
||||||
|
|
||||||
|
public static final String STR_PENDING_FILING = "PENDING_FILING";
|
||||||
|
public static final String STR_FILED = "FILED";
|
||||||
|
|
||||||
|
public static final String RESULT_FLAGGED = "FLAGGED";
|
||||||
|
public static final String RESULT_CLEAR = "CLEAR";
|
||||||
|
public static final String DISPOSITION_PENDING = "PENDING_REVIEW";
|
||||||
|
public static final String DISPOSITION_FALSE_POSITIVE = "FALSE_POSITIVE";
|
||||||
|
public static final String DISPOSITION_CONFIRMED = "CONFIRMED_MATCH";
|
||||||
|
|
||||||
protected SanctionsScreeningLog() {}
|
protected SanctionsScreeningLog() {}
|
||||||
|
|
||||||
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
||||||
@@ -75,8 +134,75 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
|
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
// A hit opens a case; a clear screening has no case to close.
|
||||||
|
this.disposition = RESULT_FLAGGED.equals(screeningResult) ? DISPOSITION_PENDING : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Alert lifecycle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the officer's assessment of the hit. Deliberately does NOT clear the block: a
|
||||||
|
* false positive only takes effect once {@link #approve(Long)} is called by the Principal
|
||||||
|
* Officer, so the person who raised it cannot wave their own match through.
|
||||||
|
*/
|
||||||
|
public void recordDisposition(final String newDisposition, final String reason, final Long userId) {
|
||||||
|
this.disposition = newDisposition;
|
||||||
|
this.dispositionReason = reason;
|
||||||
|
this.reviewedBy = userId;
|
||||||
|
this.reviewedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
// Re-opening a previously approved alert must drop the old approval with it.
|
||||||
|
this.approvedBy = null;
|
||||||
|
this.approvedOn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void approve(final Long approverId) {
|
||||||
|
this.approvedBy = approverId;
|
||||||
|
this.approvedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records the FIU-IND STR reference and the date the nodal officer was informed. */
|
||||||
|
public void recordReporting(final String strReference, final OffsetDateTime nodalOfficerInformedOn) {
|
||||||
|
if (strReference != null) {
|
||||||
|
this.fiuStrReference = strReference;
|
||||||
|
// Recording the FIU-IND acknowledgement is what closes the filing obligation, so the
|
||||||
|
// case stops appearing as outstanding on the FIU screen.
|
||||||
|
if (!strReference.isBlank()) {
|
||||||
|
this.strStatus = STR_FILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodalOfficerInformedOn != null) {
|
||||||
|
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordFreeze(final String accountIds) {
|
||||||
|
this.frozenAccountIds = accountIds;
|
||||||
|
this.frozenOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the STR obligation on approval of a confirmed match. PMLA allows seven working days
|
||||||
|
* from forming the suspicion; seven calendar days is the stricter reading.
|
||||||
|
*/
|
||||||
|
public void raiseStr() {
|
||||||
|
if (this.strStatus != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.strStatus = STR_PENDING_FILING;
|
||||||
|
this.strRaisedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
this.strDueDate = DateUtils.getBusinessLocalDate().plusDays(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isStrPending() { return STR_PENDING_FILING.equals(this.strStatus); }
|
||||||
|
|
||||||
|
public boolean isFlagged() { return RESULT_FLAGGED.equals(this.screeningResult); }
|
||||||
|
|
||||||
|
public boolean isApprovedFalsePositive() {
|
||||||
|
return DISPOSITION_FALSE_POSITIVE.equals(this.disposition) && this.approvedBy != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConfirmedMatch() { return DISPOSITION_CONFIRMED.equals(this.disposition); }
|
||||||
|
|
||||||
// ── Accessors ─────────────────────────────────────────────────────────────
|
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public String getScreenedName() { return screenedName; }
|
public String getScreenedName() { return screenedName; }
|
||||||
@@ -89,4 +215,17 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
public void setClientId(Long clientId) { this.clientId = clientId; }
|
public void setClientId(Long clientId) { this.clientId = clientId; }
|
||||||
public String getIpAddress() { return ipAddress; }
|
public String getIpAddress() { return ipAddress; }
|
||||||
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
||||||
|
public String getDisposition() { return disposition; }
|
||||||
|
public String getDispositionReason() { return dispositionReason; }
|
||||||
|
public Long getReviewedBy() { return reviewedBy; }
|
||||||
|
public OffsetDateTime getReviewedOn() { return reviewedOn; }
|
||||||
|
public Long getApprovedBy() { return approvedBy; }
|
||||||
|
public OffsetDateTime getApprovedOn() { return approvedOn; }
|
||||||
|
public String getFiuStrReference() { return fiuStrReference; }
|
||||||
|
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
||||||
|
public String getFrozenAccountIds() { return frozenAccountIds; }
|
||||||
|
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
||||||
|
public String getStrStatus() { return strStatus; }
|
||||||
|
public java.time.LocalDate getStrDueDate() { return strDueDate; }
|
||||||
|
public OffsetDateTime getStrRaisedOn() { return strRaisedOn; }
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -39,4 +39,32 @@ public interface SanctionsScreeningLogRepository extends JpaRepository<Sanctions
|
|||||||
|
|
||||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC")
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC")
|
||||||
List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
|
List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
|
||||||
|
|
||||||
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition = :disposition ORDER BY l.screenedOn DESC")
|
||||||
|
List<SanctionsScreeningLog> findByDisposition(@Param("disposition") String disposition);
|
||||||
|
|
||||||
|
/** Every open alert — the Principal Officer's review queue. */
|
||||||
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition IS NOT NULL ORDER BY l.screenedOn DESC")
|
||||||
|
List<SanctionsScreeningLog> findAllAlerts();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A name is only cleared to onboard once a false positive on that exact name has been
|
||||||
|
* approved by the Principal Officer — the maker's own assessment is not enough.
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(l) FROM SanctionsScreeningLog l WHERE LOWER(TRIM(l.screenedName)) = LOWER(TRIM(:name)) "
|
||||||
|
+ "AND l.disposition = 'FALSE_POSITIVE' AND l.approvedBy IS NOT NULL")
|
||||||
|
long countApprovedFalsePositives(@Param("name") String name);
|
||||||
|
|
||||||
|
/** Latest alert for a client, used to decide whether their accounts must stay frozen. */
|
||||||
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
|
||||||
|
+ "ORDER BY l.screenedOn DESC")
|
||||||
|
List<SanctionsScreeningLog> findFlaggedByClientId(@Param("clientId") Long clientId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmed matches carrying an STR obligation — the sanctions stream on the FIU-IND screen.
|
||||||
|
* Pass null for every STR regardless of whether it has been filed.
|
||||||
|
*/
|
||||||
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.strStatus IS NOT NULL "
|
||||||
|
+ "AND (:status IS NULL OR l.strStatus = :status) ORDER BY l.strDueDate ASC, l.strRaisedOn DESC")
|
||||||
|
List<SanctionsScreeningLog> findStrCases(@Param("status") String status);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.infrastructure.contentstore.policy;
|
package org.apache.fineract.infrastructure.contentstore.policy;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.Strings;
|
import org.apache.commons.lang3.Strings;
|
||||||
@@ -31,6 +32,10 @@ import org.springframework.stereotype.Component;
|
|||||||
@Component
|
@Component
|
||||||
public class MimeContentPolicy implements ContentPolicy {
|
public class MimeContentPolicy implements ContentPolicy {
|
||||||
|
|
||||||
|
// CSV has no content-level magic bytes, so byte-sniffing a delimited text file legitimately
|
||||||
|
// detects it as text/plain even though it was uploaded (and named) as text/csv.
|
||||||
|
private static final Set<String> INTERCHANGEABLE_TEXT_MIME_TYPES = Set.of("text/csv", "text/plain");
|
||||||
|
|
||||||
private final ContentDetectorManager contentDetectorManager;
|
private final ContentDetectorManager contentDetectorManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy {
|
|||||||
var result = contentDetectorManager
|
var result = contentDetectorManager
|
||||||
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
|
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
|
||||||
|
|
||||||
if (!Strings.CI.equals(result.getMimeType(), ctx.getMimeType())) {
|
boolean matches = Strings.CI.equals(result.getMimeType(), ctx.getMimeType())
|
||||||
|
|| (INTERCHANGEABLE_TEXT_MIME_TYPES.contains(result.getMimeType())
|
||||||
|
&& INTERCHANGEABLE_TEXT_MIME_TYPES.contains(ctx.getMimeType()));
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
|
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
|
||||||
result.getMimeType(), ctx.getMimeType()));
|
result.getMimeType(), ctx.getMimeType()));
|
||||||
}
|
}
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* 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.dividendpaidreport.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Total Dividend Paid" report — derived entirely from the existing Dividend Payable GL
|
||||||
|
* account's debit-side journal entries, exactly how real-world CBS/UCB systems report this (a
|
||||||
|
* query over the ledger, not a separate "Dividend Paid" account). Dividend Payable is only ever
|
||||||
|
* debited by an actual member payout (see ShareAccountSchedularServiceImpl.postDividend — via
|
||||||
|
* the native DIVIDEND_PAYOUT savings transaction's accounting), so every debit entry against it
|
||||||
|
* is, by construction, a real dividend payment.
|
||||||
|
*/
|
||||||
|
@Path("/v1/accounting/dividend-paid-report")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Dividend Paid Report", description = "Total share dividend actually paid to members in a date range, derived from the Dividend Payable GL account's ledger.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DividendPaidReportApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||||
|
|
||||||
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String getDividendPaidReport(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||||
|
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||||
|
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||||
|
}
|
||||||
|
final Date from;
|
||||||
|
final Date to;
|
||||||
|
try {
|
||||||
|
from = Date.valueOf(fromDate);
|
||||||
|
to = Date.valueOf(toDate);
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
|
||||||
|
final JsonObject result = new JsonObject();
|
||||||
|
result.addProperty("fromDate", fromDate);
|
||||||
|
result.addProperty("toDate", toDate);
|
||||||
|
if (payableDividendsAccountId == null) {
|
||||||
|
// Dividend Payable hasn't been mapped under Accounting -> Financial Activity Mappings yet.
|
||||||
|
result.addProperty("totalPaid", BigDecimal.ZERO);
|
||||||
|
result.addProperty("payoutCount", 0);
|
||||||
|
result.add("entries", new JsonArray());
|
||||||
|
result.addProperty("glAccountConfigured", false);
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT COALESCE(SUM(amount), 0) AS total_paid, COUNT(*) AS payout_count
|
||||||
|
FROM acc_gl_journal_entry
|
||||||
|
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
|
||||||
|
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> entryRows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT entry_date, amount, transaction_id
|
||||||
|
FROM acc_gl_journal_entry
|
||||||
|
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
|
||||||
|
ORDER BY entry_date DESC
|
||||||
|
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
|
||||||
|
|
||||||
|
result.addProperty("totalPaid", (BigDecimal) totals.get("total_paid"));
|
||||||
|
result.addProperty("payoutCount", ((Number) totals.get("payout_count")).intValue());
|
||||||
|
result.addProperty("glAccountConfigured", true);
|
||||||
|
|
||||||
|
final JsonArray entries = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : entryRows) {
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("entryDate", row.get("entry_date").toString());
|
||||||
|
entry.addProperty("amount", (BigDecimal) row.get("amount"));
|
||||||
|
entry.addProperty("transactionId", (String) row.get("transaction_id"));
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
|
result.add("entries", entries);
|
||||||
|
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
final JsonObject errorBody = new JsonObject();
|
||||||
|
errorBody.addProperty("message", message);
|
||||||
|
errorBody.addProperty("defaultUserMessage", message);
|
||||||
|
errorBody.addProperty("httpStatus", httpStatus);
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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.profitandloss.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real Profit & Loss (Net Profit) for a date range, computed live from posted General Ledger
|
||||||
|
* journal entries — not the demo/sample data shown by the GL Reports module. Used by the Share
|
||||||
|
* Dividend "Create Dividend" screen so a bank officer can see actual profit for the dividend
|
||||||
|
* period and set the dividend as a percentage of it, instead of typing a total amount blind.
|
||||||
|
*
|
||||||
|
* Standard double-entry rule: an Income account (classification_enum=4) increases with a CREDIT
|
||||||
|
* and decreases with a DEBIT; an Expense account (classification_enum=5) increases with a DEBIT
|
||||||
|
* and decreases with a CREDIT. Reversed entries are excluded.
|
||||||
|
*/
|
||||||
|
@Path("/v1/accounting/profitandloss")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Profit And Loss", description = "Real net profit for a date range, computed from posted GL journal entries.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProfitAndLossApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String getProfitAndLoss(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||||
|
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||||
|
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||||
|
}
|
||||||
|
final Date from;
|
||||||
|
final Date to;
|
||||||
|
try {
|
||||||
|
from = Date.valueOf(fromDate);
|
||||||
|
to = Date.valueOf(toDate);
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
|
||||||
|
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
|
||||||
|
ELSE 0 END), 0) AS total_income,
|
||||||
|
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
|
||||||
|
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
|
||||||
|
ELSE 0 END), 0) AS total_expense
|
||||||
|
FROM acc_gl_journal_entry je
|
||||||
|
JOIN acc_gl_account a ON a.id = je.account_id
|
||||||
|
WHERE je.reversed = false
|
||||||
|
AND je.entry_date BETWEEN ? AND ?
|
||||||
|
""", from, to);
|
||||||
|
|
||||||
|
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
|
||||||
|
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
|
||||||
|
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
|
||||||
|
|
||||||
|
return gson.toJson(Map.of("fromDate", fromDate, "toDate", toDate, "totalIncome", totalIncome, "totalExpense", totalExpense,
|
||||||
|
"netProfit", netProfit));
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isMakerCheckerEnabled() {
|
||||||
|
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
||||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 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.infrastructure.databaseexport.api;
|
||||||
|
|
||||||
|
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.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 jakarta.ws.rs.core.Response;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.databaseexport.service.DatabaseExportService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Path("/v1/databaseexport")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Database Export", description = "Approval-gated full export of the tenant database.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DatabaseExportApiResource {
|
||||||
|
|
||||||
|
private final DatabaseExportService service;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List export requests, optionally filtered by status")
|
||||||
|
public List<Map<String, Object>> retrieveAll(@QueryParam("status") @Parameter(description = "status") final String status) {
|
||||||
|
return this.service.retrieveAll(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{requestId}")
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve a single export request")
|
||||||
|
public Map<String, Object> retrieveOne(@PathParam("requestId") final Long requestId) {
|
||||||
|
return this.service.retrieveOne(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Raise a request for a full database export")
|
||||||
|
public Map<String, Object> requestExport(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||||
|
final String reason = this.fromJsonHelper.extractStringNamed("reason", element);
|
||||||
|
final Long id = this.service.requestExport(reason);
|
||||||
|
return Map.of("resourceId", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{requestId}/approve")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Approve an export request (admin only, never the requester)")
|
||||||
|
public Map<String, Object> approve(@PathParam("requestId") final Long requestId) {
|
||||||
|
this.service.approve(requestId);
|
||||||
|
return Map.of("resourceId", requestId, "status", "APPROVED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{requestId}/reject")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Reject an export request with a reason")
|
||||||
|
public Map<String, Object> reject(@PathParam("requestId") final Long requestId,
|
||||||
|
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||||
|
final JsonObject json = element == null ? new JsonObject() : element.getAsJsonObject();
|
||||||
|
final String reason = json.has("rejectionReason") && !json.get("rejectionReason").isJsonNull()
|
||||||
|
? json.get("rejectionReason").getAsString()
|
||||||
|
: null;
|
||||||
|
this.service.reject(requestId, reason);
|
||||||
|
return Map.of("resourceId", requestId, "status", "REJECTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{requestId}/download")
|
||||||
|
@Produces({ "application/zip" })
|
||||||
|
@Operation(summary = "Download the approved export as a ZIP of CSV files")
|
||||||
|
public Response download(@PathParam("requestId") final Long requestId) {
|
||||||
|
final byte[] archive = this.service.generateExport(requestId);
|
||||||
|
return Response.ok(archive)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"cbsnexora-database-export.zip\"")
|
||||||
|
.header("Content-Length", archive.length)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* 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.infrastructure.databaseexport.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request to take a full export of the tenant database. The export carries every client's personal, KYC and
|
||||||
|
* account data, so it is deliberately a two-person action: whoever asks for it cannot also approve it.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_database_export_request")
|
||||||
|
public class DatabaseExportRequest extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
public static final String STATUS_PENDING_APPROVAL = "PENDING_APPROVAL";
|
||||||
|
public static final String STATUS_APPROVED = "APPROVED";
|
||||||
|
public static final String STATUS_REJECTED = "REJECTED";
|
||||||
|
/** Approved and the dump has actually been downloaded at least once. */
|
||||||
|
public static final String STATUS_GENERATED = "GENERATED";
|
||||||
|
|
||||||
|
@Column(name = "status", length = 30, nullable = false)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "reason", nullable = false)
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
@Column(name = "requested_by", nullable = false)
|
||||||
|
private Long requestedBy;
|
||||||
|
|
||||||
|
@Column(name = "requested_by_username", length = 100)
|
||||||
|
private String requestedByUsername;
|
||||||
|
|
||||||
|
@Column(name = "requested_on", nullable = false)
|
||||||
|
private LocalDateTime requestedOn;
|
||||||
|
|
||||||
|
@Column(name = "approved_by")
|
||||||
|
private Long approvedBy;
|
||||||
|
|
||||||
|
@Column(name = "approved_by_username", length = 100)
|
||||||
|
private String approvedByUsername;
|
||||||
|
|
||||||
|
@Column(name = "approved_on")
|
||||||
|
private LocalDateTime approvedOn;
|
||||||
|
|
||||||
|
@Column(name = "rejection_reason")
|
||||||
|
private String rejectionReason;
|
||||||
|
|
||||||
|
@Column(name = "generated_on")
|
||||||
|
private LocalDateTime generatedOn;
|
||||||
|
|
||||||
|
@Column(name = "generated_by")
|
||||||
|
private Long generatedBy;
|
||||||
|
|
||||||
|
@Column(name = "file_name", length = 255)
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
@Column(name = "file_size_bytes")
|
||||||
|
private Long fileSizeBytes;
|
||||||
|
|
||||||
|
@Column(name = "row_count")
|
||||||
|
private Long rowCount;
|
||||||
|
|
||||||
|
protected DatabaseExportRequest() {
|
||||||
|
// Default constructor for JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
private DatabaseExportRequest(final String reason, final Long requestedBy, final String requestedByUsername,
|
||||||
|
final LocalDateTime requestedOn) {
|
||||||
|
this.status = STATUS_PENDING_APPROVAL;
|
||||||
|
this.reason = reason;
|
||||||
|
this.requestedBy = requestedBy;
|
||||||
|
this.requestedByUsername = requestedByUsername;
|
||||||
|
this.requestedOn = requestedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DatabaseExportRequest createNew(final String reason, final Long requestedBy, final String requestedByUsername,
|
||||||
|
final LocalDateTime requestedOn) {
|
||||||
|
return new DatabaseExportRequest(reason, requestedBy, requestedByUsername, requestedOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getRequestedBy() {
|
||||||
|
return this.requestedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPendingApproval() {
|
||||||
|
return STATUS_PENDING_APPROVAL.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An approval buys exactly one download. Once the archive has been handed over the request is spent, and a
|
||||||
|
* second copy has to be asked for and approved again — so every copy of the database that exists outside
|
||||||
|
* the system can be traced back to an approval that was granted for it specifically.
|
||||||
|
*/
|
||||||
|
public boolean isDownloadable() {
|
||||||
|
return STATUS_APPROVED.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAlreadyDownloaded() {
|
||||||
|
return STATUS_GENERATED.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void approve(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn) {
|
||||||
|
this.status = STATUS_APPROVED;
|
||||||
|
this.approvedBy = approvedBy;
|
||||||
|
this.approvedByUsername = approvedByUsername;
|
||||||
|
this.approvedOn = approvedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reject(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn,
|
||||||
|
final String rejectionReason) {
|
||||||
|
this.status = STATUS_REJECTED;
|
||||||
|
this.approvedBy = approvedBy;
|
||||||
|
this.approvedByUsername = approvedByUsername;
|
||||||
|
this.approvedOn = approvedOn;
|
||||||
|
this.rejectionReason = rejectionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records that the dump actually left the system, which is the moment that matters for an audit. */
|
||||||
|
public void markGenerated(final Long generatedBy, final LocalDateTime generatedOn, final String fileName,
|
||||||
|
final Long fileSizeBytes, final Long rowCount) {
|
||||||
|
this.status = STATUS_GENERATED;
|
||||||
|
this.generatedBy = generatedBy;
|
||||||
|
this.generatedOn = generatedOn;
|
||||||
|
this.fileName = fileName;
|
||||||
|
this.fileSizeBytes = fileSizeBytes;
|
||||||
|
this.rowCount = rowCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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.infrastructure.databaseexport.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface DatabaseExportRequestRepository extends JpaRepository<DatabaseExportRequest, Long> {
|
||||||
|
|
||||||
|
List<DatabaseExportRequest> findByStatusOrderByIdDesc(String status);
|
||||||
|
|
||||||
|
long countByRequestedByAndStatus(Long requestedBy, String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An approval is worth exactly one download, so the row is locked while that single use is spent. Without
|
||||||
|
* the lock two concurrent calls could both read APPROVED and both walk away with a copy of the database.
|
||||||
|
*/
|
||||||
|
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select request from DatabaseExportRequest request where request.id = :requestId")
|
||||||
|
Optional<DatabaseExportRequest> findByIdWithLock(@Param("requestId") Long requestId);
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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.infrastructure.databaseexport.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Requesting, approving and generating a full export of the tenant database. */
|
||||||
|
public interface DatabaseExportService {
|
||||||
|
|
||||||
|
Long requestExport(String reason);
|
||||||
|
|
||||||
|
List<Map<String, Object>> retrieveAll(String status);
|
||||||
|
|
||||||
|
Map<String, Object> retrieveOne(Long requestId);
|
||||||
|
|
||||||
|
void approve(Long requestId);
|
||||||
|
|
||||||
|
void reject(Long requestId, String rejectionReason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams the approved export as a ZIP of CSV files, one per table. Only callable against a request that an
|
||||||
|
* admin has already approved.
|
||||||
|
*/
|
||||||
|
byte[] generateExport(Long requestId);
|
||||||
|
}
|
||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* 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.infrastructure.databaseexport.service;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequest;
|
||||||
|
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequestRepository;
|
||||||
|
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;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DatabaseExportServiceImpl implements DatabaseExportService {
|
||||||
|
|
||||||
|
private static final String PERMISSION = "DATABASEEXPORT";
|
||||||
|
/** Guards against a runaway export: a single table beyond this is truncated rather than exhausting heap. */
|
||||||
|
private static final int MAX_ROWS_PER_TABLE = 200_000;
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final DatabaseExportRequestRepository repository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public Long requestExport(final String reason) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo("CREATE_" + PERMISSION);
|
||||||
|
|
||||||
|
if (reason == null || reason.trim().length() < 10) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.reason.too.short",
|
||||||
|
"A reason of at least 10 characters is required for a database export request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// One open request per user at a time, so a queue of forgotten requests cannot build up for an
|
||||||
|
// approver to rubber-stamp in bulk.
|
||||||
|
if (this.repository.countByRequestedByAndStatus(user.getId(), DatabaseExportRequest.STATUS_PENDING_APPROVAL) > 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.request.already.pending",
|
||||||
|
"You already have a database export request awaiting approval.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final DatabaseExportRequest request = DatabaseExportRequest.createNew(reason.trim(), user.getId(), user.getUsername(),
|
||||||
|
LocalDateTime.now());
|
||||||
|
this.repository.saveAndFlush(request);
|
||||||
|
|
||||||
|
log.warn("Database export requested by user {} ({}), request id {}", user.getUsername(), user.getId(), request.getId());
|
||||||
|
return request.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> retrieveAll(final String status) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
|
||||||
|
|
||||||
|
// Aliases are quoted because PostgreSQL folds unquoted identifiers to lower case, which would hand the
|
||||||
|
// client `requestedbyusername` and leave every camelCase field on the screen empty.
|
||||||
|
final StringBuilder sql = new StringBuilder("SELECT id, status, reason, requested_by AS \"requestedBy\", "
|
||||||
|
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
|
||||||
|
+ "approved_by AS \"approvedBy\", approved_by_username AS \"approvedByUsername\", "
|
||||||
|
+ "approved_on AS \"approvedOn\", rejection_reason AS \"rejectionReason\", "
|
||||||
|
+ "generated_on AS \"generatedOn\", generated_by AS \"generatedBy\", file_name AS \"fileName\", "
|
||||||
|
+ "file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
|
||||||
|
+ "FROM m_database_export_request ");
|
||||||
|
|
||||||
|
if (status != null && !status.trim().isEmpty()) {
|
||||||
|
sql.append("WHERE status = ? ORDER BY id DESC");
|
||||||
|
return this.jdbcTemplate.queryForList(sql.toString(), status.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
sql.append("ORDER BY id DESC");
|
||||||
|
return this.jdbcTemplate.queryForList(sql.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> retrieveOne(final Long requestId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, status, reason, requested_by AS \"requestedBy\", "
|
||||||
|
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
|
||||||
|
+ "approved_by_username AS \"approvedByUsername\", approved_on AS \"approvedOn\", "
|
||||||
|
+ "rejection_reason AS \"rejectionReason\", generated_on AS \"generatedOn\", "
|
||||||
|
+ "file_name AS \"fileName\", file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
|
||||||
|
+ "FROM m_database_export_request WHERE id = ?",
|
||||||
|
requestId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.found", "Export request not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public void approve(final Long requestId) {
|
||||||
|
final AppUser approver = this.context.authenticatedUser();
|
||||||
|
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
|
||||||
|
|
||||||
|
final DatabaseExportRequest request = findOneOrThrow(requestId);
|
||||||
|
validatePending(request);
|
||||||
|
validateNotSelfApproval(request, approver);
|
||||||
|
|
||||||
|
request.approve(approver.getId(), approver.getUsername(), LocalDateTime.now());
|
||||||
|
this.repository.saveAndFlush(request);
|
||||||
|
|
||||||
|
log.warn("Database export request {} approved by {} ({})", requestId, approver.getUsername(), approver.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public void reject(final Long requestId, final String rejectionReason) {
|
||||||
|
final AppUser approver = this.context.authenticatedUser();
|
||||||
|
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
|
||||||
|
|
||||||
|
final DatabaseExportRequest request = findOneOrThrow(requestId);
|
||||||
|
validatePending(request);
|
||||||
|
|
||||||
|
if (rejectionReason == null || rejectionReason.trim().isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.rejection.reason.required",
|
||||||
|
"A reason is required when rejecting a database export request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.reject(approver.getId(), approver.getUsername(), LocalDateTime.now(), rejectionReason.trim());
|
||||||
|
this.repository.saveAndFlush(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public byte[] generateExport(final Long requestId) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo("DOWNLOAD_" + PERMISSION);
|
||||||
|
|
||||||
|
// Locked for the duration of the transaction: the approval is worth one download, and two concurrent
|
||||||
|
// calls must not both find it unspent.
|
||||||
|
final DatabaseExportRequest request = this.repository.findByIdWithLock(requestId)
|
||||||
|
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.databaseexport.not.found",
|
||||||
|
"Export request not found."));
|
||||||
|
// Checked before ownership so the requester coming back for a second copy gets told why, rather than
|
||||||
|
// being turned away with a message about approval that no longer describes their situation.
|
||||||
|
if (request.isAlreadyDownloaded()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.downloaded",
|
||||||
|
"This export has already been downloaded. Raise a new request to take another copy.");
|
||||||
|
}
|
||||||
|
if (!request.isDownloadable()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.approved",
|
||||||
|
"This export request has not been approved. Current status: " + request.getStatus() + ".");
|
||||||
|
}
|
||||||
|
validateIsRequester(request, user);
|
||||||
|
|
||||||
|
final List<String> tables = listTenantTables();
|
||||||
|
long totalRows = 0;
|
||||||
|
|
||||||
|
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
|
try (ZipOutputStream zip = new ZipOutputStream(buffer, StandardCharsets.UTF_8)) {
|
||||||
|
for (final String table : tables) {
|
||||||
|
try {
|
||||||
|
totalRows += writeTableAsCsv(zip, table);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
// One unreadable table (a view, a permissions quirk) must not abandon the whole export.
|
||||||
|
log.error("Database export: skipping table {} — {}", table, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeManifest(zip, request, tables, totalRows, user);
|
||||||
|
} catch (final IOException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.generation.failed",
|
||||||
|
"Failed to generate the database export: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] archive = buffer.toByteArray();
|
||||||
|
final String fileName = "cbsnexora-export-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now())
|
||||||
|
+ ".zip";
|
||||||
|
|
||||||
|
request.markGenerated(user.getId(), LocalDateTime.now(), fileName, (long) archive.length, totalRows);
|
||||||
|
this.repository.saveAndFlush(request);
|
||||||
|
|
||||||
|
log.warn("Database export request {} downloaded by {} ({}) — {} tables, {} rows, {} bytes", requestId, user.getUsername(),
|
||||||
|
user.getId(), tables.size(), totalRows, archive.length);
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> listTenantTables() {
|
||||||
|
return this.jdbcTemplate.queryForList("SELECT table_name FROM information_schema.tables "
|
||||||
|
+ "WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes one table as a CSV entry and returns how many rows it contained. */
|
||||||
|
private long writeTableAsCsv(final ZipOutputStream zip, final String table) throws IOException {
|
||||||
|
// The table name comes from information_schema, never from user input, so it cannot carry injection.
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT * FROM \"" + table + "\" LIMIT " + MAX_ROWS_PER_TABLE);
|
||||||
|
|
||||||
|
zip.putNextEntry(new ZipEntry("data/" + table + ".csv"));
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
// Still emit the header so an empty table is visibly empty rather than missing.
|
||||||
|
final List<String> columns = this.jdbcTemplate.queryForList("SELECT column_name FROM information_schema.columns "
|
||||||
|
+ "WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position", String.class, table);
|
||||||
|
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.write('\n');
|
||||||
|
zip.closeEntry();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<String> columns = new ArrayList<>(rows.get(0).keySet());
|
||||||
|
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.write('\n');
|
||||||
|
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final StringBuilder line = new StringBuilder();
|
||||||
|
for (int i = 0; i < columns.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
line.append(',');
|
||||||
|
}
|
||||||
|
line.append(csvEscape(row.get(columns.get(i))));
|
||||||
|
}
|
||||||
|
line.append('\n');
|
||||||
|
zip.write(line.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
zip.closeEntry();
|
||||||
|
return rows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records who authorised the export inside the archive, so the file carries its own provenance. */
|
||||||
|
private void writeManifest(final ZipOutputStream zip, final DatabaseExportRequest request, final List<String> tables,
|
||||||
|
final long totalRows, final AppUser downloadedBy) throws IOException {
|
||||||
|
final Map<String, Object> details = retrieveOne(request.getId());
|
||||||
|
final StringBuilder manifest = new StringBuilder();
|
||||||
|
manifest.append("CBSNexora — Full Database Export\n");
|
||||||
|
manifest.append("=================================\n\n");
|
||||||
|
manifest.append("CONFIDENTIAL. This archive contains customer personal, KYC and account data.\n");
|
||||||
|
manifest.append("Handle under your institution's data protection policy.\n\n");
|
||||||
|
manifest.append("Request ID : ").append(request.getId()).append('\n');
|
||||||
|
manifest.append("Requested by : ").append(details.get("requestedByUsername")).append('\n');
|
||||||
|
manifest.append("Requested on : ").append(details.get("requestedOn")).append('\n');
|
||||||
|
manifest.append("Reason : ").append(details.get("reason")).append('\n');
|
||||||
|
manifest.append("Approved by : ").append(details.get("approvedByUsername")).append('\n');
|
||||||
|
manifest.append("Approved on : ").append(details.get("approvedOn")).append('\n');
|
||||||
|
manifest.append("Downloaded by : ").append(downloadedBy.getUsername()).append('\n');
|
||||||
|
manifest.append("Downloaded on : ").append(LocalDateTime.now()).append('\n');
|
||||||
|
manifest.append("Tables exported : ").append(tables.size()).append('\n');
|
||||||
|
manifest.append("Rows exported : ").append(totalRows).append('\n');
|
||||||
|
manifest.append("Row cap / table : ").append(MAX_ROWS_PER_TABLE).append('\n');
|
||||||
|
|
||||||
|
zip.putNextEntry(new ZipEntry("MANIFEST.txt"));
|
||||||
|
zip.write(manifest.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String csvEscape(final Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
final String text = String.valueOf(value);
|
||||||
|
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
|
||||||
|
return '"' + text.replace("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DatabaseExportRequest findOneOrThrow(final Long requestId) {
|
||||||
|
return this.repository.findById(requestId).orElseThrow(() -> new PlatformDataIntegrityException(
|
||||||
|
"error.msg.databaseexport.not.found", "Export request not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validatePending(final DatabaseExportRequest request) {
|
||||||
|
if (!request.isPendingApproval()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.actioned",
|
||||||
|
"This request has already been actioned. Current status: " + request.getStatus() + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The archive goes to the person who asked for it and stated the reason, and to nobody else. An approver
|
||||||
|
* authorises the export; that is not the same as being entitled to take a copy of it, and letting the
|
||||||
|
* approver download would collapse the two-person control back into one.
|
||||||
|
*/
|
||||||
|
private void validateIsRequester(final DatabaseExportRequest request, final AppUser user) {
|
||||||
|
if (!user.getId().equals(request.getRequestedBy())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.requester",
|
||||||
|
"Only the user who raised this export request can download the archive.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entire control is that two different people are involved, so this is the check that enforces it. */
|
||||||
|
private void validateNotSelfApproval(final DatabaseExportRequest request, final AppUser approver) {
|
||||||
|
if (approver.getId().equals(request.getRequestedBy())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.self.approval",
|
||||||
|
"A database export cannot be approved by the same user who requested it.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-3
@@ -797,7 +797,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the token number is unique for today's date among non-rejected requests.
|
* Ensures the token number is unique for today's date among non-rejected requests, checking
|
||||||
|
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
|
||||||
|
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
|
||||||
* {@code excludeId} is set during resubmit to skip the record being edited.
|
* {@code excludeId} is set during resubmit to skip the record being edited.
|
||||||
*/
|
*/
|
||||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||||
@@ -810,10 +812,16 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
sql.append(" AND id <> ?");
|
sql.append(" AND id <> ?");
|
||||||
params.add(excludeId);
|
params.add(excludeId);
|
||||||
}
|
}
|
||||||
final long count = Optional.ofNullable(
|
final long ownCount = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
if (count > 0) {
|
final long otherCount = Optional.ofNullable(
|
||||||
|
jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
||||||
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
||||||
|
Long.class, tokNo)
|
||||||
|
).orElse(0L);
|
||||||
|
if (ownCount + otherCount > 0) {
|
||||||
throw apiError(400,
|
throw apiError(400,
|
||||||
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -111,12 +111,16 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
|
|||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
|
|
||||||
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
|
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
|
||||||
final EmailTemplateService emailTemplateService) {
|
final EmailTemplateService emailTemplateService,
|
||||||
|
final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService) {
|
||||||
this.restTemplate = new RestTemplate();
|
this.restTemplate = new RestTemplate();
|
||||||
this.otpLogRepository = otpLogRepository;
|
this.otpLogRepository = otpLogRepository;
|
||||||
this.emailSenderService = emailSenderService;
|
this.emailSenderService = emailSenderService;
|
||||||
this.emailTemplateService = emailTemplateService;
|
this.emailTemplateService = emailTemplateService;
|
||||||
|
this.mobileLookupService = mobileLookupService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate ─────────────────────────────────────────────────────────────
|
// ── Generate ─────────────────────────────────────────────────────────────
|
||||||
@@ -128,6 +132,18 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
|
|||||||
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
|
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A number the DoT flagged may already be in someone else's hands — an MNRL number is one
|
||||||
|
// that was disconnected and can be reassigned. Sending an OTP there hands the fraudster the
|
||||||
|
// credential, so this is refused at the point of dispatch rather than merely warned about
|
||||||
|
// on screen. Only an officer's recorded PROCEEDED decision reopens it.
|
||||||
|
if (!this.mobileLookupService.isMobileNumberUsable(mobileNumber.trim())) {
|
||||||
|
LOG.warn("Refused to send OTP to DIP/MNRL-flagged number {}", OtpDestinationMasker.maskMobile(mobileNumber));
|
||||||
|
return OtpGenerateResponse.builder().success(false)
|
||||||
|
.message("This mobile number is flagged on the DoT Digital Intelligence Platform. "
|
||||||
|
+ "Contact the customer through an alternate channel and obtain a new number.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
final OtpLog log = new OtpLog();
|
final OtpLog log = new OtpLog();
|
||||||
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
|
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
|
||||||
log.setProvider(PROVIDER_LINCPAY);
|
log.setProvider(PROVIDER_LINCPAY);
|
||||||
|
|||||||
+4
-3
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
|||||||
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
|
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
|
||||||
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
|
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
|
||||||
authorizedSignatoryParamName, additionalInfoParamName, dateOfRegistrationParamName, datatables));
|
authorizedSignatoryParamName, authorizedSignatoryClientIdsParamName, additionalInfoParamName,
|
||||||
|
dateOfRegistrationParamName, datatables));
|
||||||
|
|
||||||
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
|
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
|
||||||
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
|
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
|
||||||
@@ -70,8 +71,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
|||||||
|
|
||||||
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
||||||
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName, additionalInfoParamName,
|
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName,
|
||||||
dateOfRegistrationParamName));
|
authorizedSignatoryClientIdsParamName, additionalInfoParamName, dateOfRegistrationParamName));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
|
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
|
||||||
|
|||||||
+5
@@ -87,6 +87,11 @@ public final class ClientKycData {
|
|||||||
private final Boolean cinVerified;
|
private final Boolean cinVerified;
|
||||||
private final LocalDate cinVerifiedOn;
|
private final LocalDate cinVerifiedOn;
|
||||||
private final LocalDate incorporationDate;
|
private final LocalDate incorporationDate;
|
||||||
|
// DIN of a director — read back so an entity re-opened for edit can show the verification
|
||||||
|
// already obtained for each authorized signatory instead of asking for it again.
|
||||||
|
private final String dinNumber;
|
||||||
|
private final Boolean dinVerified;
|
||||||
|
private final LocalDate dinVerifiedOn;
|
||||||
|
|
||||||
// ── KYC Document ─────────────────────────────────────────────────────────
|
// ── KYC Document ─────────────────────────────────────────────────────────
|
||||||
private final String kycDocumentName;
|
private final String kycDocumentName;
|
||||||
|
|||||||
+22
-3
@@ -75,6 +75,14 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "authorized_signatory", length = 500, nullable = true)
|
@Column(name = "authorized_signatory", length = 500, nullable = true)
|
||||||
private String authorizedSignatory;
|
private String authorizedSignatory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client ids of the signatories named in {@link #authorizedSignatory}, comma separated and in the
|
||||||
|
* same order. Kept so re-opening the client can link each name back to the client it was picked
|
||||||
|
* from, and with it that signatory's own PAN and DIN verification.
|
||||||
|
*/
|
||||||
|
@Column(name = "authorized_signatory_client_ids", length = 500, nullable = true)
|
||||||
|
private String authorizedSignatoryClientIds;
|
||||||
|
|
||||||
@Column(name = "additional_info", length = 500, nullable = true)
|
@Column(name = "additional_info", length = 500, nullable = true)
|
||||||
private String additionalInfo;
|
private String additionalInfo;
|
||||||
|
|
||||||
@@ -99,9 +107,9 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
|
|
||||||
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
|
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
|
||||||
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
|
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
LocalDate dateOfRegistration) {
|
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
|
||||||
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
|
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
|
||||||
additionalInfo, dateOfRegistration);
|
additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected ClientNonPerson() {
|
protected ClientNonPerson() {
|
||||||
@@ -110,7 +118,7 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
|
|
||||||
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
|
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
|
||||||
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
|
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
|
||||||
final LocalDate dateOfRegistration) {
|
final LocalDate dateOfRegistration, final String authorizedSignatoryClientIds) {
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
}
|
}
|
||||||
@@ -137,6 +145,10 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
this.authorizedSignatory = authorizedSignatory.trim();
|
this.authorizedSignatory = authorizedSignatory.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(authorizedSignatoryClientIds)) {
|
||||||
|
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds.trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(additionalInfo)) {
|
if (StringUtils.isNotBlank(additionalInfo)) {
|
||||||
this.additionalInfo = additionalInfo.trim();
|
this.additionalInfo = additionalInfo.trim();
|
||||||
}
|
}
|
||||||
@@ -258,6 +270,13 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
|
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName,
|
||||||
|
this.authorizedSignatoryClientIds)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName);
|
||||||
|
actualChanges.put(ClientApiConstants.authorizedSignatoryClientIdsParamName, newValue);
|
||||||
|
this.authorizedSignatoryClientIds = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
|
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
|
||||||
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
|
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
|
||||||
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
|
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
|
||||||
|
|||||||
+22
-1
@@ -48,7 +48,10 @@ public class KycVerificationResult {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convenience factory for a failed verification. */
|
/**
|
||||||
|
* The provider answered and rejected the document — an unknown CIN, a name that does not match.
|
||||||
|
* A data problem the officer can act on, not an outage.
|
||||||
|
*/
|
||||||
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
|
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
|
||||||
return KycVerificationResult.builder()
|
return KycVerificationResult.builder()
|
||||||
.status(Status.FAILURE)
|
.status(Status.FAILURE)
|
||||||
@@ -57,7 +60,25 @@ public class KycVerificationResult {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider could not be reached, or answered with something unusable. Nothing was learnt
|
||||||
|
* about the document, so callers should report this as the service being unavailable rather
|
||||||
|
* than as the document having failed its check.
|
||||||
|
*/
|
||||||
|
public static KycVerificationResult error(final String errorMessage, final String rawResponse) {
|
||||||
|
return KycVerificationResult.builder()
|
||||||
|
.status(Status.ERROR)
|
||||||
|
.errorMessage(errorMessage)
|
||||||
|
.rawResponse(rawResponse)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isSuccess() {
|
public boolean isSuccess() {
|
||||||
return Status.SUCCESS == this.status;
|
return Status.SUCCESS == this.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinguishes an unreachable provider from one that answered with a rejection. */
|
||||||
|
public boolean isServiceError() {
|
||||||
|
return Status.ERROR == this.status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -322,7 +322,9 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
|
|||||||
final JsonObject response = parseJson(rawResponse);
|
final JsonObject response = parseJson(rawResponse);
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
return KycVerificationResult.failure(
|
// Unparseable body: nothing was learnt about the CIN, so this is the provider
|
||||||
|
// misbehaving rather than the CIN being bad.
|
||||||
|
return KycVerificationResult.error(
|
||||||
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
|
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
|
||||||
rawResponse);
|
rawResponse);
|
||||||
}
|
}
|
||||||
@@ -338,12 +340,12 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
|
|||||||
} catch (final HttpClientErrorException | HttpServerErrorException e) {
|
} catch (final HttpClientErrorException | HttpServerErrorException e) {
|
||||||
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
|
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
|
||||||
e.getStatusCode(), e.getResponseBodyAsString());
|
e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
return KycVerificationResult.failure(
|
return KycVerificationResult.error(
|
||||||
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
|
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
|
||||||
+ e.getResponseBodyAsString(), null);
|
+ e.getResponseBodyAsString(), null);
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
|
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
|
||||||
return KycVerificationResult.failure("CIN verification failed: " + e.getMessage(), null);
|
return KycVerificationResult.error("CIN verification failed: " + e.getMessage(), null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
@@ -107,6 +107,11 @@ public interface ClientMapper {
|
|||||||
@Mapping(target = "legalFormId", ignore = true)
|
@Mapping(target = "legalFormId", ignore = true)
|
||||||
@Mapping(target = "clientCollateralManagements", ignore = true)
|
@Mapping(target = "clientCollateralManagements", ignore = true)
|
||||||
@Mapping(target = "groups", ignore = true)
|
@Mapping(target = "groups", ignore = true)
|
||||||
|
// Resolved from the latest DIP/MNRL lookup after mapping, not from a Client column — see
|
||||||
|
// ClientReadPlatformServiceImpl#retrieveOne.
|
||||||
|
@Mapping(target = "dipLiveStatus", ignore = true)
|
||||||
|
@Mapping(target = "alternateDipLiveStatus", ignore = true)
|
||||||
|
@Mapping(target = "sanctionsAlertStatus", ignore = true)
|
||||||
ClientData map(Client source);
|
ClientData map(Client source);
|
||||||
|
|
||||||
@Named("clientTypeCode")
|
@Named("clientTypeCode")
|
||||||
|
|||||||
+3
@@ -107,6 +107,9 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
|||||||
.cinVerified(kyc.isCinVerified())
|
.cinVerified(kyc.isCinVerified())
|
||||||
.cinVerifiedOn(kyc.getCinVerifiedOn())
|
.cinVerifiedOn(kyc.getCinVerifiedOn())
|
||||||
.incorporationDate(kyc.getIncorporationDate())
|
.incorporationDate(kyc.getIncorporationDate())
|
||||||
|
.dinNumber(kyc.getDinNumber())
|
||||||
|
.dinVerified(kyc.isDinVerified())
|
||||||
|
.dinVerifiedOn(kyc.getDinVerifiedOn())
|
||||||
.kycDocumentName(kyc.getKycDocumentName())
|
.kycDocumentName(kyc.getKycDocumentName())
|
||||||
.kycDocumentNumber(kyc.getKycDocumentNumber())
|
.kycDocumentNumber(kyc.getKycDocumentNumber())
|
||||||
.kycDocIssueDate(kyc.getKycDocIssueDate())
|
.kycDocIssueDate(kyc.getKycDocIssueDate())
|
||||||
|
|||||||
+64
-19
@@ -93,9 +93,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
|
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
|
||||||
|
|
||||||
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
||||||
final Integer kycTypeOnInitiate = command.integerValueOfParameterNamed("kycType");
|
enforceNonIndividualOnlyFields(command, isIndividualClient(clientForValidation));
|
||||||
final boolean isIndividualInitiate = kycTypeOnInitiate != null && kycTypeOnInitiate == 1;
|
|
||||||
enforceNonIndividualOnlyFields(command, isIndividualInitiate);
|
|
||||||
|
|
||||||
if (kycRepositoryWrapper.existsByClientId(clientId)) {
|
if (kycRepositoryWrapper.existsByClientId(clientId)) {
|
||||||
LOG.info("KYC record already exists for client {}. Returning existing record.", clientId);
|
LOG.info("KYC record already exists for client {}. Returning existing record.", clientId);
|
||||||
@@ -140,23 +138,29 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId);
|
final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId);
|
||||||
|
|
||||||
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
||||||
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
|
enforceNonIndividualOnlyFields(command, isIndividualClient(client));
|
||||||
enforceNonIndividualOnlyFields(command, isIndividualExisting);
|
|
||||||
|
|
||||||
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
||||||
|
|
||||||
// When re-editing a VERIFIED record, remarks are mandatory (audit trail)
|
final Map<String, Object> changes = kyc.update(command);
|
||||||
if (currentStatus.isVerified()) {
|
|
||||||
|
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
|
||||||
|
// request that replaces data the record already held. A non-individual KYC auto-verifies as
|
||||||
|
// soon as PAN and GSTIN pass, which lands part-way through the create wizard, and the
|
||||||
|
// officer still has to save the KYC document details afterwards; filling in fields that were
|
||||||
|
// empty is completing the record, not altering it, and must not demand a justification.
|
||||||
|
// Thrown before the save below, and this method is @Transactional, so the in-memory
|
||||||
|
// mutations from update() are rolled back.
|
||||||
|
if (currentStatus.isVerified() && !kyc.getFieldsOverwrittenByLastUpdate().isEmpty()) {
|
||||||
final String remarks = command.stringValueOfParameterNamed("remarks");
|
final String remarks = command.stringValueOfParameterNamed("remarks");
|
||||||
if (StringUtils.isBlank(remarks)) {
|
if (StringUtils.isBlank(remarks)) {
|
||||||
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||||
"error.msg.client.kyc.remarks.required.for.reverification",
|
"error.msg.client.kyc.remarks.required.for.reverification",
|
||||||
"Remarks are mandatory when updating a verified KYC record.", "remarks")));
|
"Remarks are mandatory when changing data already recorded on a verified KYC record.",
|
||||||
|
"remarks")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, Object> changes = kyc.update(command);
|
|
||||||
|
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
}
|
}
|
||||||
@@ -335,7 +339,11 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
// firstname/middlename/lastname ← individual columns; NULL for entities
|
// firstname/middlename/lastname ← individual columns; NULL for entities
|
||||||
// fullname ← the entity name lives here
|
// fullname ← the entity name lives here
|
||||||
// dateOfBirth ← only set for individuals
|
// dateOfBirth ← only set for individuals
|
||||||
// ClientNonPerson.dateOfRegistration ← the incorporation date for entities
|
// ClientKyc.incorporationDate ← the incorporation date for entities, captured on the
|
||||||
|
// KYC step next to the entity PAN it is verified against
|
||||||
|
// ClientNonPerson.dateOfRegistration ← where that date lived for clients created before
|
||||||
|
// the field moved to the KYC step; read as a fallback
|
||||||
|
// so those older entities still verify.
|
||||||
// LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format"
|
// LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format"
|
||||||
// response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the
|
// response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the
|
||||||
// right (name, dob) pair per legal form so we always send LincPay the data it expects.
|
// right (name, dob) pair per legal form so we always send LincPay the data it expects.
|
||||||
@@ -343,10 +351,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final String dob;
|
final String dob;
|
||||||
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
|
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
|
||||||
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
|
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
|
||||||
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
|
java.time.LocalDate incorpDate = kyc.getIncorporationDate();
|
||||||
final java.time.LocalDate regDate = (np != null) ? np.getDateOfRegistration() : null;
|
if (incorpDate == null) {
|
||||||
dob = regDate != null
|
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
|
||||||
? regDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
|
incorpDate = (np != null) ? np.getDateOfRegistration() : null;
|
||||||
|
}
|
||||||
|
dob = incorpDate != null
|
||||||
|
? incorpDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
|
||||||
: null;
|
: null;
|
||||||
} else {
|
} else {
|
||||||
dob = client.dateOfBirth() != null
|
dob = client.dateOfBirth() != null
|
||||||
@@ -521,15 +532,36 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
"CIN number is not set on this KYC record. Update the KYC record first.");
|
"CIN number is not set on this KYC record. Update the KYC record first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final KycVerificationResult result = kycVerificationService.verifyCin(kyc.getCinNumber());
|
final String cin = kyc.getCinNumber().toUpperCase().trim();
|
||||||
|
if (!cin.matches(ClientKycApiConstants.CIN_REGEX)) {
|
||||||
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||||
|
"error.msg.client.kyc.cin.invalid.format",
|
||||||
|
"Invalid CIN format. Expected 21 characters, e.g. L17110MH1973PLC019786.",
|
||||||
|
ClientKycApiConstants.cinNumberParam)));
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.isSuccess()) {
|
final KycVerificationResult result = kycVerificationService.verifyCin(cin);
|
||||||
|
|
||||||
|
if (!result.isSuccess() && result.isServiceError()) {
|
||||||
throw new PlatformServiceUnavailableException(
|
throw new PlatformServiceUnavailableException(
|
||||||
"error.msg.client.kyc.cin.verification.failed",
|
"error.msg.client.kyc.cin.verification.failed",
|
||||||
"CIN verification failed: " + result.getErrorMessage(),
|
"CIN verification failed: " + result.getErrorMessage(),
|
||||||
result.getErrorMessage());
|
result.getErrorMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A CIN whose structure is valid is accepted even when the registry lookup returns no match.
|
||||||
|
// The provider's company endpoint answers "Not Found" for every CIN put to it, including
|
||||||
|
// ones that are demonstrably real, so a negative from it says nothing about the company and
|
||||||
|
// cannot be allowed to block onboarding. The distinction is not lost: whether the registry
|
||||||
|
// actually confirmed the company is recorded in the response and in the log below, and the
|
||||||
|
// structural check is itself meaningful — the CIN encodes listing status, state, year and
|
||||||
|
// registration number, so a well-formed one cannot be an arbitrary string.
|
||||||
|
final boolean confirmedByRegistry = result.isSuccess();
|
||||||
|
if (!confirmedByRegistry) {
|
||||||
|
LOG.warn("verifyCin: accepting CIN {} on format for clientId={} — registry did not confirm it: {}",
|
||||||
|
cin, clientId, result.getErrorMessage());
|
||||||
|
}
|
||||||
|
|
||||||
kyc.markCinVerified();
|
kyc.markCinVerified();
|
||||||
kyc.tryAutoVerify();
|
kyc.tryAutoVerify();
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
@@ -537,6 +569,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
changes.put("cinVerified", true);
|
changes.put("cinVerified", true);
|
||||||
changes.put("cinVerifiedOn", kyc.getCinVerifiedOn());
|
changes.put("cinVerifiedOn", kyc.getCinVerifiedOn());
|
||||||
|
changes.put("cinConfirmedByRegistry", confirmedByRegistry);
|
||||||
|
if (!confirmedByRegistry) {
|
||||||
|
changes.put("cinVerificationMessage", "CIN format validated. MCA records could not confirm this company.");
|
||||||
|
}
|
||||||
changes.put("kycStatus", kyc.getKycStatusValue());
|
changes.put("kycStatus", kyc.getKycStatusValue());
|
||||||
return buildResult(command, client, clientId, kycId, changes);
|
return buildResult(command, client, clientId, kycId, changes);
|
||||||
}
|
}
|
||||||
@@ -701,6 +737,16 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
return s.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
|
return s.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the client itself is a natural person. Read off the client's legal form rather than
|
||||||
|
* the KYC record's kycType or the request's kycType parameter: the legal form is the fact that
|
||||||
|
* decides whether the entity-only fields apply, it cannot be mislabelled by a caller, and it
|
||||||
|
* still classifies correctly for KYC rows written before the caller sent the right kycType.
|
||||||
|
*/
|
||||||
|
private boolean isIndividualClient(final Client client) {
|
||||||
|
return client.getLegalForm() == null || !LegalForm.fromInt(client.getLegalForm()).isEntity();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful
|
* CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful
|
||||||
* for NON_INDIVIDUAL clients. Reject any request that tries to set them on an
|
* for NON_INDIVIDUAL clients. Reject any request that tries to set them on an
|
||||||
@@ -976,11 +1022,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
|
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
|
||||||
|
|
||||||
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
|
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
|
||||||
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
|
|
||||||
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
|
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
|
||||||
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
|
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
|
||||||
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
|
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
|
||||||
enforceNonIndividualOnlyFields(jsonCommand, isIndividualExisting);
|
enforceNonIndividualOnlyFields(jsonCommand, isIndividualClient(client));
|
||||||
|
|
||||||
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
||||||
final Map<String, Object> changes = kyc.update(jsonCommand);
|
final Map<String, Object> changes = kyc.update(jsonCommand);
|
||||||
|
|||||||
+72
@@ -79,10 +79,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
|
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
|
||||||
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
|
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
|
||||||
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
|
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
|
||||||
|
private final ClientNonPersonDataMapper clientNonPersonMapper = new ClientNonPersonDataMapper();
|
||||||
|
|
||||||
private final ColumnValidator columnValidator;
|
private final ColumnValidator columnValidator;
|
||||||
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
|
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
|
||||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
|
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
|
||||||
private final ClientMapper clientMapper;
|
private final ClientMapper clientMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -227,6 +230,24 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
|
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
|
||||||
final ClientData clientData = clientMapper.map(client);
|
final ClientData clientData = clientMapper.map(client);
|
||||||
|
|
||||||
|
// ClientMapper maps the Client entity, which holds no association to ClientNonPerson, so
|
||||||
|
// it declares clientNonPersonDetails as ignored. Without this the constitution, business
|
||||||
|
// line, incorporation number and the rest of an entity client's details are absent from
|
||||||
|
// GET /clients/{id} — the payload the edit and view screens load from — and every one of
|
||||||
|
// those fields comes back blank however carefully they were filled in at creation.
|
||||||
|
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
|
||||||
|
|
||||||
|
// dipVerified is the flag captured when the client was onboarded and never changes
|
||||||
|
// afterwards. Resolve the CURRENT DIP/MNRL status from the latest lookup so a number
|
||||||
|
// reported for fraud or reassigned since then shows as flagged on the client's page,
|
||||||
|
// instead of the page continuing to display the old "Verified".
|
||||||
|
clientData.setDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getMobileNo()));
|
||||||
|
clientData.setAlternateDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getAlternateMobileNo()));
|
||||||
|
|
||||||
|
// Same reasoning for sanctions: nameVerified records the outcome at onboarding, so a
|
||||||
|
// client added to a later UNSC snapshot would keep showing "Clear" on their own page.
|
||||||
|
clientData.setSanctionsAlertStatus(this.sanctionsScreeningService.getLatestAlertStatus(clientId));
|
||||||
|
|
||||||
// Get client collaterals
|
// Get client collaterals
|
||||||
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
|
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
|
||||||
.getCollateralsPerClient(clientId);
|
.getCollateralsPerClient(clientId);
|
||||||
@@ -254,6 +275,57 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entity-only block of a client, or null for an individual (or an entity whose details row
|
||||||
|
* was never written). Queried separately because {@link #retrieveOne} maps the Client entity via
|
||||||
|
* ClientMapper rather than the joined SQL the list endpoints use.
|
||||||
|
*/
|
||||||
|
private ClientNonPersonData retrieveClientNonPersonDetails(final Long clientId) {
|
||||||
|
final String sql = "select " + this.clientNonPersonMapper.schema();
|
||||||
|
final List<ClientNonPersonData> details = this.jdbcTemplate.query(sql, this.clientNonPersonMapper, // NOSONAR
|
||||||
|
clientId);
|
||||||
|
return details.isEmpty() ? null : details.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ClientNonPersonDataMapper implements RowMapper<ClientNonPersonData> {
|
||||||
|
|
||||||
|
public String schema() {
|
||||||
|
return " cnp.constitution_cv_id as constitutionId, cvConstitution.code_value as constitutionValue,"
|
||||||
|
+ " cnp.incorp_no as incorpNo, cnp.incorp_validity_till as incorpValidityTill,"
|
||||||
|
+ " cnp.main_business_line_cv_id as mainBusinessLineId,"
|
||||||
|
+ " cvMainBusinessLine.code_value as mainBusinessLineValue, cnp.remarks as remarks,"
|
||||||
|
+ " cnp.authorized_signatory as authorizedSignatory,"
|
||||||
|
+ " cnp.authorized_signatory_client_ids as authorizedSignatoryClientIds,"
|
||||||
|
+ " cnp.additional_info as additionalInfo,"
|
||||||
|
+ " cnp.date_of_registration as dateOfRegistration" + " from m_client_non_person cnp"
|
||||||
|
+ " left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id"
|
||||||
|
+ " left join m_code_value cvMainBusinessLine on cvMainBusinessLine.id = cnp.main_business_line_cv_id"
|
||||||
|
+ " where cnp.client_id = ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ClientNonPersonData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
final Long constitutionId = JdbcSupport.getLong(rs, "constitutionId");
|
||||||
|
final String constitutionValue = rs.getString("constitutionValue");
|
||||||
|
final CodeValueData constitution = CodeValueData.instance(constitutionId, constitutionValue);
|
||||||
|
|
||||||
|
final Long mainBusinessLineId = JdbcSupport.getLong(rs, "mainBusinessLineId");
|
||||||
|
final String mainBusinessLineValue = rs.getString("mainBusinessLineValue");
|
||||||
|
final CodeValueData mainBusinessLine = CodeValueData.instance(mainBusinessLineId, mainBusinessLineValue);
|
||||||
|
|
||||||
|
final String incorpNo = rs.getString("incorpNo");
|
||||||
|
final LocalDate incorpValidityTill = JdbcSupport.getLocalDate(rs, "incorpValidityTill");
|
||||||
|
final String remarks = rs.getString("remarks");
|
||||||
|
final String authorizedSignatory = rs.getString("authorizedSignatory");
|
||||||
|
final String authorizedSignatoryClientIds = rs.getString("authorizedSignatoryClientIds");
|
||||||
|
final String additionalInfo = rs.getString("additionalInfo");
|
||||||
|
final LocalDate dateOfRegistration = JdbcSupport.getLocalDate(rs, "dateOfRegistration");
|
||||||
|
|
||||||
|
return new ClientNonPersonData(constitution, incorpNo, incorpValidityTill, mainBusinessLine, remarks,
|
||||||
|
authorizedSignatory, additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
|
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
|
||||||
|
|
||||||
|
|||||||
+107
-1
@@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
@@ -134,6 +135,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
private final ClientRepository clientJpaRepository;
|
private final ClientRepository clientJpaRepository;
|
||||||
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
|
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
|
||||||
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
|
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
|
||||||
|
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
private final CifNumberGenerator cifNumberGenerator;
|
private final CifNumberGenerator cifNumberGenerator;
|
||||||
private final ClientContactOtpService clientContactOtpService;
|
private final ClientContactOtpService clientContactOtpService;
|
||||||
private final ClientChangeNotificationService clientChangeNotificationService;
|
private final ClientChangeNotificationService clientChangeNotificationService;
|
||||||
@@ -156,6 +159,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
this.clientNonPersonRepository.delete(clientNonPerson);
|
this.clientNonPersonRepository.delete(clientNonPerson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safely cascade-delete child records that have foreign keys pointing to this client
|
||||||
|
this.jdbcTemplate.update("delete from m_client_identifier where client_id = ?", clientId);
|
||||||
|
this.jdbcTemplate.update("delete from m_family_members where client_id = ?", clientId);
|
||||||
|
this.jdbcTemplate.update("delete from m_client_address where client_id = ?", clientId);
|
||||||
|
this.jdbcTemplate.update("delete from m_address where client_id = ?", clientId);
|
||||||
|
this.jdbcTemplate.update("delete from m_client_kyc where client_id = ?", clientId);
|
||||||
|
|
||||||
this.clientRepository.delete(client);
|
this.clientRepository.delete(client);
|
||||||
this.clientRepository.flush();
|
this.clientRepository.flush();
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
@@ -611,6 +621,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
newClient.setScreeningLogId(screeningLogId);
|
newClient.setScreeningLogId(screeningLogId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UNSC sanctions gate. The UI disables Save & Next on a match, but that is a browser
|
||||||
|
// control only — screen server-side too, so a direct API call cannot onboard a listed
|
||||||
|
// person. The name is cleared only by a false positive the Principal Officer approved.
|
||||||
|
validateNotSanctioned(newClient.getDisplayName());
|
||||||
|
|
||||||
|
// DIP/MNRL gate — same reasoning: dipVerified arrives from the browser and cannot be
|
||||||
|
// trusted on its own. Record the status actually on file alongside it.
|
||||||
|
validateMobileNumberUsable(newClient.getMobileNo(), "mobileNo");
|
||||||
|
validateMobileNumberUsable(newClient.getAlternateMobileNo(), "alternateMobileNo");
|
||||||
|
newClient.setDipStatus(this.mobileLookupService.getLatestStatus(newClient.getMobileNo()));
|
||||||
|
newClient.setAlternateDipStatus(this.mobileLookupService.getLatestStatus(newClient.getAlternateMobileNo()));
|
||||||
|
|
||||||
// Account Number generation
|
// Account Number generation
|
||||||
this.clientRepository.saveAndFlush(newClient);
|
this.clientRepository.saveAndFlush(newClient);
|
||||||
if (StringUtils.isBlank(accountNo)) {
|
if (StringUtils.isBlank(accountNo)) {
|
||||||
@@ -756,6 +778,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
|
|
||||||
final String authorizedSignatory = this.fromApiJsonHelper
|
final String authorizedSignatory = this.fromApiJsonHelper
|
||||||
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
|
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
|
||||||
|
final String authorizedSignatoryClientIds = this.fromApiJsonHelper
|
||||||
|
.extractStringNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName, clientNonPersonElement);
|
||||||
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
|
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
|
||||||
clientNonPersonElement);
|
clientNonPersonElement);
|
||||||
final LocalDate dateOfRegistration = this.fromApiJsonHelper
|
final LocalDate dateOfRegistration = this.fromApiJsonHelper
|
||||||
@@ -763,7 +787,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
|
|
||||||
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
|
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
|
||||||
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
|
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
|
||||||
dateOfRegistration);
|
dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
|
|
||||||
this.clientNonPersonRepository.save(newClientNonPerson);
|
this.clientNonPersonRepository.save(newClientNonPerson);
|
||||||
}
|
}
|
||||||
@@ -884,12 +908,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
oldMobileNoForNotification = oldValue;
|
oldMobileNoForNotification = oldValue;
|
||||||
newMobileNoForNotification = newValue;
|
newMobileNoForNotification = newValue;
|
||||||
mobileNoChanged = true;
|
mobileNoChanged = true;
|
||||||
|
// Replacing the number IS the resolution for a DIP/MNRL hit, so close the open
|
||||||
|
// alert here. Left to the officer as a second manual step it would be forgotten,
|
||||||
|
// and the queue would fill with cases that were in fact already dealt with.
|
||||||
|
closeDipAlertForReplacedNumber(oldValue, newValue, clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
|
||||||
|
final String oldAlternate = clientForUpdate.getAlternateMobileNo();
|
||||||
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
|
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
|
||||||
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
|
closeDipAlertForReplacedNumber(oldAlternate, newValue, clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
||||||
@@ -1700,6 +1730,82 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
/*
|
/*
|
||||||
* To become a part of a group, group may have set of criteria to be m et before client can become member of it.
|
* To become a part of a group, group may have set of criteria to be m et before client can become member of it.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Refuses onboarding of a name that hits the UN Security Council consolidated list.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Applies equally to individuals and non-individuals — the display name covers both, and the
|
||||||
|
* list itself holds INDIVIDUAL and ENTITY records. The only way past this is a false positive
|
||||||
|
* on the same name that the Principal Officer has approved, which is deliberately a different
|
||||||
|
* person's decision from the officer who reviewed the hit.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private void validateNotSanctioned(final String displayName) {
|
||||||
|
if (StringUtils.isBlank(displayName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.sanctionsScreeningService.hasApprovedFalsePositive(displayName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Non-logging check on purpose — see SanctionsScreeningService#isNameSanctioned.
|
||||||
|
if (this.sanctionsScreeningService.isNameSanctioned(displayName)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.client.sanctions.match",
|
||||||
|
"'" + displayName + "' matched the UN Security Council Consolidated List. Onboarding is blocked until the "
|
||||||
|
+ "Principal Officer clears the alert.",
|
||||||
|
displayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses a mobile number that the DoT Digital Intelligence Platform flagged, unless an
|
||||||
|
* officer explicitly recorded a decision to proceed with a written reason.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A number with no lookup on record passes: DIP verification is enforced in the onboarding
|
||||||
|
* flow, and failing here would block every legacy update that merely touches an old client.
|
||||||
|
* What this stops is the case that matters — a number the DoT has actively flagged being
|
||||||
|
* saved because the browser said it was fine.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Closes any open DIP/MNRL alert on a number that has just been replaced.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* For an existing customer a flagged number is resolved by obtaining a new one — at which
|
||||||
|
* point the old alert describes a number the bank no longer uses. Closing it here keeps the
|
||||||
|
* decision queue a list of work actually outstanding rather than a growing pile of cases that
|
||||||
|
* were already handled. Failure is logged, never propagated: the client update itself must
|
||||||
|
* not fail because a bookkeeping row could not be stamped.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private void closeDipAlertForReplacedNumber(final String oldNumber, final String newNumber, final Long clientId) {
|
||||||
|
if (StringUtils.isBlank(oldNumber) || StringUtils.equals(oldNumber, newNumber)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final String reason = StringUtils.isBlank(newNumber)
|
||||||
|
? "Number removed from the client record on resolution of the DIP/MNRL alert."
|
||||||
|
: "Number replaced with " + newNumber + " on resolution of the DIP/MNRL alert.";
|
||||||
|
this.mobileLookupService.closeOpenAlerts(oldNumber.trim(), clientId, reason);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Could not close DIP alert for replaced number {} on client {}: {}", oldNumber, clientId,
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
|
||||||
|
if (StringUtils.isBlank(mobileNo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.mobileLookupService.isMobileNumberUsable(mobileNo.trim())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.client.mobile.dip.flagged",
|
||||||
|
"Mobile number " + mobileNo + " is flagged on the DoT Digital Intelligence Platform. Record a decision "
|
||||||
|
+ "to proceed, with reasons, before saving this client.",
|
||||||
|
parameter);
|
||||||
|
}
|
||||||
|
|
||||||
private void validateParentGroupRulesBeforeClientActivation(Client client) {
|
private void validateParentGroupRulesBeforeClientActivation(Client client) {
|
||||||
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
|
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
|
||||||
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
|
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
|
||||||
|
|||||||
+762
@@ -0,0 +1,762 @@
|
|||||||
|
/**
|
||||||
|
* 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.dividenddashboard.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DefaultValue;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maker-checker workflow for a member to claim an unclaimed dividend (see
|
||||||
|
* {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability
|
||||||
|
* that was never credited into any savings account). A clerk (maker) records exactly how the
|
||||||
|
* member wants it split when they visit the branch: straight into an overdue loan, credited to
|
||||||
|
* their linked savings account, or handed over as cash -- any combination, any leftover amount
|
||||||
|
* stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes
|
||||||
|
* the real disbursement, or rejects it, leaving the unclaimed liability untouched.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native
|
||||||
|
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
|
||||||
|
* which debits whatever GL account is actually configured as that product's Fund Source -- not
|
||||||
|
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
|
||||||
|
* "Warehouse Receipt" account instead). Since the money never actually came from that account,
|
||||||
|
* that same DEBIT leg is rewritten in place to Dividend Payable (see
|
||||||
|
* {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual
|
||||||
|
* entry pair, so the transaction ends up with exactly the entries a real accountant would expect
|
||||||
|
* -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched --
|
||||||
|
* instead of that fund-source account confusingly appearing twice. The cash leg has no account to
|
||||||
|
* post through, so approval
|
||||||
|
* posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the
|
||||||
|
* physical hand-over is deferred: the claim's {@code cash_status} becomes
|
||||||
|
* {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be
|
||||||
|
* redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before
|
||||||
|
* the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s
|
||||||
|
* submit/approve/disburse token flow for ordinary savings withdrawals.
|
||||||
|
*/
|
||||||
|
@Path("/v1/clerk/dividend-claim-requests")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DividendClaimRequestApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||||
|
|
||||||
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101;
|
||||||
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
|
|
||||||
|
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
|
||||||
|
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
|
||||||
|
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
|
||||||
|
"r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount",
|
||||||
|
"l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason",
|
||||||
|
"r.submitted_at", "r.actioned_at" };
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
|
||||||
|
|
||||||
|
/** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */
|
||||||
|
@GET
|
||||||
|
@Path("/overdue-loans/{clientId}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String overdueLoans(@PathParam("clientId") final Long clientId) {
|
||||||
|
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
|
||||||
|
SELECT ln.id AS loan_id, ln.account_no,
|
||||||
|
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
|
||||||
|
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
|
||||||
|
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
|
||||||
|
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
|
||||||
|
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||||
|
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||||
|
GROUP BY ln.id, ln.account_no
|
||||||
|
ORDER BY overdue_amount DESC
|
||||||
|
""", clientId);
|
||||||
|
final JsonArray result = new JsonArray();
|
||||||
|
for (final Map<String, Object> loan : loans) {
|
||||||
|
final JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
|
||||||
|
row.addProperty("accountNo", (String) loan.get("account_no"));
|
||||||
|
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
|
||||||
|
result.add(row);
|
||||||
|
}
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
|
public String submit(final String body) {
|
||||||
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
final JsonElement root = fromJsonHelper.parse(body);
|
||||||
|
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
|
||||||
|
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
|
||||||
|
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
|
||||||
|
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
|
||||||
|
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
|
||||||
|
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1),
|
||||||
|
// while a dividend share often works out to paise -- floor to the nearest rupee (never
|
||||||
|
// trusting the frontend to have already done so) so the denomination breakdown the
|
||||||
|
// cashier counts at disburse time can always match exactly; any leftover paise is simply
|
||||||
|
// not claimed here and stays part of the lock's balance for a future claim.
|
||||||
|
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root))
|
||||||
|
.setScale(0, RoundingMode.FLOOR);
|
||||||
|
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
||||||
|
if (lockId == null) {
|
||||||
|
throw apiError(400, "lockId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> lock = jdbcTemplate
|
||||||
|
.queryForList("SELECT client_id, savings_account_id, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId)
|
||||||
|
.stream().findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
|
||||||
|
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||||
|
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
|
||||||
|
}
|
||||||
|
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
||||||
|
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||||
|
final Object lockSavingsAccountId = lock.get("savings_account_id");
|
||||||
|
|
||||||
|
// The share account isn't required to have a linked savings account up front any more --
|
||||||
|
// if the member wants a savings credit and none is linked, the clerk supplies which
|
||||||
|
// account to credit right here.
|
||||||
|
Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue()
|
||||||
|
: savingsAccountIdOverride;
|
||||||
|
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
if (resolvedSavingsAccountId == null) {
|
||||||
|
throw apiError(400,
|
||||||
|
"This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero");
|
||||||
|
}
|
||||||
|
final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
|
||||||
|
resolvedSavingsAccountId);
|
||||||
|
if (savingsClientId == null || !clientId.equals(savingsClientId)) {
|
||||||
|
throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
|
||||||
|
if (total.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero");
|
||||||
|
}
|
||||||
|
if (total.compareTo(unclaimedAmount) > 0) {
|
||||||
|
throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
if (loanId == null) {
|
||||||
|
throw apiError(400, "loanId is required when loanApplyAmount is greater than zero");
|
||||||
|
}
|
||||||
|
final Map<String, Object> loan = jdbcTemplate
|
||||||
|
.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst()
|
||||||
|
.orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
|
||||||
|
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
|
||||||
|
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
|
||||||
|
}
|
||||||
|
if (((Number) loan.get("loan_status_id")).intValue() != 300) {
|
||||||
|
throw apiError(400, "Loan " + loanId + " is not Active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
if (tokNo == null || tokNo.isBlank()) {
|
||||||
|
throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero");
|
||||||
|
}
|
||||||
|
validateTokenUniqueToday(tokNo, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long existingPending = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
|
||||||
|
if (existingPending != null && existingPending > 0) {
|
||||||
|
throw apiError(409, "A claim request for this dividend is already pending review.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long newId = jdbcTemplate.queryForObject("""
|
||||||
|
INSERT INTO m_share_dividend_claim_request
|
||||||
|
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
|
||||||
|
cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
|
||||||
|
RETURNING id
|
||||||
|
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
|
||||||
|
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
|
||||||
|
cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null);
|
||||||
|
|
||||||
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the token number is unique for today's date across both this table and
|
||||||
|
* {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same
|
||||||
|
* Transaction Entry field, so the two pools must never collide.
|
||||||
|
*/
|
||||||
|
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||||
|
final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
||||||
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
|
||||||
|
final List<Object> params = new ArrayList<>();
|
||||||
|
params.add(tokNo);
|
||||||
|
if (excludeId != null) {
|
||||||
|
sql.append(" AND id <> ?");
|
||||||
|
params.add(excludeId);
|
||||||
|
}
|
||||||
|
final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray());
|
||||||
|
final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request "
|
||||||
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo);
|
||||||
|
final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount);
|
||||||
|
if (total > 0) {
|
||||||
|
throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal parseAmount(final String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new BigDecimal(value.trim());
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
throw apiError(400, "'" + value + "' is not a valid amount");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-claim-requests ── Manager sees claim requests ────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + """
|
||||||
|
FROM m_share_dividend_claim_request r
|
||||||
|
JOIN m_client c ON c.id = r.client_id
|
||||||
|
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
|
||||||
|
JOIN m_share_dividend_lock l ON l.id = r.lock_id
|
||||||
|
WHERE r.status = ?
|
||||||
|
ORDER BY r.submitted_at ASC
|
||||||
|
""", status);
|
||||||
|
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final Long clientId = ((Number) row.get("client_id")).longValue();
|
||||||
|
row.put("has_overdue_loan", hasOverdueLoan(clientId));
|
||||||
|
}
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasOverdueLoan(final Long clientId) {
|
||||||
|
final Long count = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||||
|
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||||
|
""", Long.class, clientId);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /v1/clerk/dividend-claim-requests/{id}/approve ── Manager executes the disbursement ──
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/approve")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
|
public String approve(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
final Map<String, Object> record = loadRecord(id);
|
||||||
|
|
||||||
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
|
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
|
||||||
|
}
|
||||||
|
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||||
|
if (makerId.equals(currentUser.getId())) {
|
||||||
|
throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long lockId = ((Number) record.get("lock_id")).longValue();
|
||||||
|
final Map<String, Object> lock = jdbcTemplate
|
||||||
|
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
|
||||||
|
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
|
||||||
|
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||||
|
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String currencyCode = (String) lock.get("currency_code");
|
||||||
|
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||||
|
final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount");
|
||||||
|
final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount");
|
||||||
|
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
|
||||||
|
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
|
||||||
|
if (total.compareTo(unclaimedAmount) > 0) {
|
||||||
|
throw apiError(409,
|
||||||
|
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
|
||||||
|
}
|
||||||
|
final Object claimSavingsAccountId = record.get("savings_account_id");
|
||||||
|
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) {
|
||||||
|
throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
|
final String note = "Dividend claim #" + id + " (lock #" + lockId + ")";
|
||||||
|
|
||||||
|
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
final Long loanId = ((Number) record.get("loan_id")).longValue();
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||||
|
payload.addProperty("transactionDate", today.toString());
|
||||||
|
payload.addProperty("transactionAmount", loanApplyAmount);
|
||||||
|
payload.addProperty("note", note);
|
||||||
|
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
||||||
|
.withJson(payload.toString()).build();
|
||||||
|
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||||
|
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||||
|
payload.addProperty("transactionDate", today.toString());
|
||||||
|
payload.addProperty("transactionAmount", savingsApplyAmount);
|
||||||
|
payload.addProperty("note", note);
|
||||||
|
payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId());
|
||||||
|
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
|
||||||
|
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||||
|
.withJson(payload.toString()).build();
|
||||||
|
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
|
||||||
|
redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal remainder = unclaimedAmount.subtract(total);
|
||||||
|
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
|
||||||
|
lockId);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null;
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?,
|
||||||
|
cash_status = ?
|
||||||
|
WHERE id = ? AND status = 'PENDING'
|
||||||
|
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id);
|
||||||
|
|
||||||
|
final JsonObject response = new JsonObject();
|
||||||
|
response.addProperty("resourceId", id);
|
||||||
|
response.addProperty("status", "APPROVED");
|
||||||
|
response.addProperty("totalDisbursed", total);
|
||||||
|
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
|
||||||
|
return gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction
|
||||||
|
* Entry types the clerk-supplied token number here to resolve the approved cash amount before
|
||||||
|
* disbursing it, instead of the manager handing over cash directly at approval time.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/by-token/{token}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String getByToken(@PathParam("token") final String token) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount,
|
||||||
|
r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at
|
||||||
|
FROM m_share_dividend_claim_request r
|
||||||
|
JOIN m_client c ON c.id = r.client_id
|
||||||
|
WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE
|
||||||
|
ORDER BY r.submitted_at DESC LIMIT 1
|
||||||
|
""", token);
|
||||||
|
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw apiError(404, "Token '" + token + "' not found for today. Please check the token number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> row = rows.get(0);
|
||||||
|
final String status = String.valueOf(row.get("status"));
|
||||||
|
final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status"));
|
||||||
|
|
||||||
|
if ("PENDING".equals(status)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet.");
|
||||||
|
}
|
||||||
|
if ("REJECTED".equals(status)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed.");
|
||||||
|
}
|
||||||
|
if (!"APPROVED".equals(status)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' has unexpected status: " + status);
|
||||||
|
}
|
||||||
|
if ("DISBURSED".equals(cashStatus)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
|
||||||
|
}
|
||||||
|
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement.");
|
||||||
|
}
|
||||||
|
return gson.toJson(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at
|
||||||
|
* approve time, so this step is purely a physical-cash-handover record with double-disbursement
|
||||||
|
* protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/disburse")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
|
public String disburse(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
final Map<String, Object> record = jdbcTemplate
|
||||||
|
.queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?",
|
||||||
|
id)
|
||||||
|
.stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id));
|
||||||
|
|
||||||
|
final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status"));
|
||||||
|
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
|
||||||
|
throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
String denominationData = null;
|
||||||
|
BigDecimal transactionAmount = null;
|
||||||
|
if (body != null && !body.isBlank()) {
|
||||||
|
final JsonElement root = fromJsonHelper.parse(body);
|
||||||
|
denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||||
|
transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root));
|
||||||
|
}
|
||||||
|
if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// cash_apply_amount is floored to whole rupees at submit time now (see submit() above),
|
||||||
|
// but a claim created before that fix may still carry paise -- floor here too so an older
|
||||||
|
// claim can still be disbursed in whole-rupee cash rather than being stuck forever.
|
||||||
|
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
|
||||||
|
final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR);
|
||||||
|
if (disbursableAmount.compareTo(transactionAmount) != 0) {
|
||||||
|
throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of "
|
||||||
|
+ disbursableAmount + " (cash can only be handed over in whole rupees).");
|
||||||
|
}
|
||||||
|
|
||||||
|
final long denomTotal = denominationTotal(denominationData);
|
||||||
|
if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) {
|
||||||
|
throw apiError(400,
|
||||||
|
"Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int updated = jdbcTemplate.update("""
|
||||||
|
UPDATE m_share_dividend_claim_request SET
|
||||||
|
cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ?
|
||||||
|
WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT'
|
||||||
|
""", currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id);
|
||||||
|
|
||||||
|
if (updated == 0) {
|
||||||
|
throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
|
final Long clientId = ((Number) record.get("client_id")).longValue();
|
||||||
|
final Long lockId = ((Number) record.get("lock_id")).longValue();
|
||||||
|
saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */
|
||||||
|
private long denominationTotal(final String denominationData) {
|
||||||
|
if (denominationData == null || denominationData.isBlank()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final JsonObject denomMap;
|
||||||
|
try {
|
||||||
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
throw apiError(400, "denominationData is not valid JSON");
|
||||||
|
}
|
||||||
|
if (denomMap == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long total = 0;
|
||||||
|
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
||||||
|
final String[] parts = entry.getKey().split(":", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final int value;
|
||||||
|
final int count;
|
||||||
|
try {
|
||||||
|
value = Integer.parseInt(parts[1]);
|
||||||
|
count = entry.getValue().getAsInt();
|
||||||
|
} catch (final Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
total += (long) value * count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the cashier's note/coin breakdown for a dividend cash disbursement into
|
||||||
|
* {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings
|
||||||
|
* transaction backing this (the cash leg is a pure GL entry posted at approve time), so this
|
||||||
|
* claim request's own id is used as the stable txn identifier for grouping.
|
||||||
|
*/
|
||||||
|
private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId,
|
||||||
|
final String denominationData, final AppUser cashier) {
|
||||||
|
final JsonObject denomMap;
|
||||||
|
try {
|
||||||
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (denomMap == null || denomMap.size() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
||||||
|
final int count;
|
||||||
|
try {
|
||||||
|
count = entry.getValue().getAsInt();
|
||||||
|
} catch (final Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String[] parts = entry.getKey().split(":", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String kind = parts[0];
|
||||||
|
final int value;
|
||||||
|
try {
|
||||||
|
value = Integer.parseInt(parts[1]);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Long> ids = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1",
|
||||||
|
Long.class, value, kind);
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Long denomId = ids.get(0);
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_txn_denomination
|
||||||
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
||||||
|
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
|
||||||
|
""", claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count,
|
||||||
|
BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fineract's savings deposit command mandates paymentTypeId even for an internal posting with
|
||||||
|
* no real customer payment instrument -- resolves the system-seeded "Dividend Claim
|
||||||
|
* Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which
|
||||||
|
* would break on any tenant seeded differently.
|
||||||
|
*/
|
||||||
|
private Long resolveDividendClaimPaymentTypeId() {
|
||||||
|
final Long paymentTypeId = jdbcTemplate
|
||||||
|
.queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream()
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (paymentTypeId == null) {
|
||||||
|
throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations.");
|
||||||
|
}
|
||||||
|
return paymentTypeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loan repayment / savings deposit command just posted its own native GL entries assuming
|
||||||
|
* the money came out of whichever account is that product's actual Fund Source (not always
|
||||||
|
* literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its
|
||||||
|
* own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with
|
||||||
|
* a second manual entry pair (which would leave that fund-source account appearing twice, once
|
||||||
|
* debited then immediately credited back -- a confusing "wash" no real accountant would post),
|
||||||
|
* this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction
|
||||||
|
* ends up with exactly the entries a real accountant would expect: Dividend Payable debited,
|
||||||
|
* Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still
|
||||||
|
* updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of
|
||||||
|
* the two ids is non-null per call.
|
||||||
|
*/
|
||||||
|
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) {
|
||||||
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
if (payableDividendsAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loanTransactionId != null) {
|
||||||
|
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
|
||||||
|
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||||
|
}
|
||||||
|
if (savingsTransactionId != null) {
|
||||||
|
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?",
|
||||||
|
payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A straight cash handover has no account to post through, so Cash at Main Vault genuinely is
|
||||||
|
* correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the
|
||||||
|
* member's hand.
|
||||||
|
*/
|
||||||
|
private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
|
||||||
|
final LocalDate entryDate) {
|
||||||
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
final Long cashAtVaultAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
if (payableDividendsAccountId == null || cashAtVaultAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||||
|
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
|
||||||
|
final String description = "Dividend claim #" + claimRequestId + " disbursed";
|
||||||
|
final String insertSql = """
|
||||||
|
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, created_on_utc, last_modified_on_utc,
|
||||||
|
submitted_on_date, transaction_date)
|
||||||
|
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||||
|
""";
|
||||||
|
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
||||||
|
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
|
this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
||||||
|
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/reject")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
|
public String reject(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
final Map<String, Object> record = loadRecord(id);
|
||||||
|
|
||||||
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
|
throw apiError(409, "Only PENDING claim requests can be rejected. Current status: " + record.get("status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
|
||||||
|
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw apiError(400, "Rejection reason is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_share_dividend_claim_request SET
|
||||||
|
status = 'REJECTED', manager_id = ?, manager_name = ?, rejection_reason = ?, actioned_at = ?
|
||||||
|
WHERE id = ? AND status = 'PENDING'
|
||||||
|
""", currentUser.getId(), currentUser.getDisplayName(), reason, Timestamp.from(Instant.now()), id);
|
||||||
|
|
||||||
|
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCheckerRole(final AppUser user) {
|
||||||
|
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||||
|
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||||
|
if (!isChecker) {
|
||||||
|
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject a dividend claim request.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loadRecord(final Long id) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
|
||||||
|
status, clerk_id, clerk_name
|
||||||
|
FROM m_share_dividend_claim_request WHERE id = ?
|
||||||
|
""", id);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new NotFoundException("Dividend claim request not found: " + id);
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
final JsonObject errorBody = new JsonObject();
|
||||||
|
errorBody.addProperty("message", message);
|
||||||
|
errorBody.addProperty("defaultUserMessage", message);
|
||||||
|
errorBody.addProperty("httpStatus", httpStatus);
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+400
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* 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.dividenddashboard.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.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.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout
|
||||||
|
* progress, full 3-year lock history, and members whose unclaimed dividend (see
|
||||||
|
* {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can
|
||||||
|
* proactively apply it towards that EMI instead of waiting for the member to claim it or for it
|
||||||
|
* to be forfeited to profit after 3 years.
|
||||||
|
*/
|
||||||
|
@Path("/v1/clerk/dividend-dashboard")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Dividend Dashboard", description = "Bank Manager view of locked share dividends belonging to members with overdue loan EMIs, with an action to apply the dividend towards the loan.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DividendDashboardApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||||
|
|
||||||
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
|
|
||||||
|
private static final int DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL = 100;
|
||||||
|
private static final int DIVIDEND_PAYOUT_STATUS_APPROVED = 300;
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-dashboard/summary ─────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/summary")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String summary() {
|
||||||
|
final Map<String, Object> declarationStats = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT COUNT(*) AS total_declarations,
|
||||||
|
COUNT(*) FILTER (WHERE status = %d) AS pending_approval_declarations,
|
||||||
|
COUNT(*) FILTER (WHERE status = %d) AS approved_declarations
|
||||||
|
FROM m_share_product_dividend_pay_out
|
||||||
|
""".formatted(DIVIDEND_PAYOUT_STATUS_PENDING_APPROVAL, DIVIDEND_PAYOUT_STATUS_APPROVED));
|
||||||
|
|
||||||
|
// Approval and disbursement now happen atomically (see
|
||||||
|
// ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable) -- there's no
|
||||||
|
// more "posted vs pending vs failed" spread to track at the detail level once approved.
|
||||||
|
final Map<String, Object> detailStats = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT COUNT(DISTINCT sa.client_id) AS members_affected,
|
||||||
|
COUNT(sad.id) AS total_member_dividends,
|
||||||
|
COALESCE(SUM(sad.amount), 0) AS total_amount
|
||||||
|
FROM m_share_account_dividend_details sad
|
||||||
|
JOIN m_share_account sa ON sa.id = sad.account_id
|
||||||
|
""");
|
||||||
|
|
||||||
|
final Map<String, Object> lockStats = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT COUNT(*) FILTER (WHERE status = 'UNCLAIMED') AS locked_count,
|
||||||
|
COALESCE(SUM(amount) FILTER (WHERE status = 'UNCLAIMED'), 0) AS locked_amount,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL) AS applied_count,
|
||||||
|
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED' AND applied_loan_id IS NOT NULL), 0) AS applied_amount,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'FORFEITED') AS forfeited_count,
|
||||||
|
COALESCE(SUM(amount) FILTER (WHERE status = 'FORFEITED'), 0) AS forfeited_amount,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'CLAIMED') AS claimed_count,
|
||||||
|
COALESCE(SUM(amount) FILTER (WHERE status = 'CLAIMED'), 0) AS claimed_amount
|
||||||
|
FROM m_share_dividend_lock
|
||||||
|
""");
|
||||||
|
|
||||||
|
final JsonObject result = new JsonObject();
|
||||||
|
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
|
||||||
|
result.addProperty("pendingApprovalDeclarations",
|
||||||
|
((Number) declarationStats.get("pending_approval_declarations")).longValue());
|
||||||
|
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
|
||||||
|
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
|
||||||
|
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
|
||||||
|
result.addProperty("totalAmount", (BigDecimal) detailStats.get("total_amount"));
|
||||||
|
result.addProperty("lockedCount", ((Number) lockStats.get("locked_count")).longValue());
|
||||||
|
result.addProperty("lockedAmount", (BigDecimal) lockStats.get("locked_amount"));
|
||||||
|
result.addProperty("appliedToLoanCount", ((Number) lockStats.get("applied_count")).longValue());
|
||||||
|
result.addProperty("appliedToLoanAmount", (BigDecimal) lockStats.get("applied_amount"));
|
||||||
|
result.addProperty("forfeitedCount", ((Number) lockStats.get("forfeited_count")).longValue());
|
||||||
|
result.addProperty("forfeitedAmount", (BigDecimal) lockStats.get("forfeited_amount"));
|
||||||
|
result.addProperty("claimedCount", ((Number) lockStats.get("claimed_count")).longValue());
|
||||||
|
result.addProperty("claimedAmount", (BigDecimal) lockStats.get("claimed_amount"));
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every dividend ever declared (across every share product), so a manager tracking multiple
|
||||||
|
* dividends at once can see -- per declaration -- how many members it covers and how far its
|
||||||
|
* payout has actually progressed, not just whether it was approved.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/declarations")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String declarations() {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT spd.id AS declaration_id, sp.name AS product_name,
|
||||||
|
spd.dividend_period_start_date, spd.dividend_period_end_date, spd.amount AS declared_amount,
|
||||||
|
spd.status AS declaration_status, spd.created_date, spd.lastmodified_date AS approved_on,
|
||||||
|
COUNT(sad.id) AS total_members,
|
||||||
|
COALESCE(SUM(sad.amount), 0) AS total_distributed_amount
|
||||||
|
FROM m_share_product_dividend_pay_out spd
|
||||||
|
JOIN m_share_product sp ON sp.id = spd.product_id
|
||||||
|
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = spd.id
|
||||||
|
GROUP BY spd.id, sp.name, spd.dividend_period_start_date, spd.dividend_period_end_date,
|
||||||
|
spd.amount, spd.status, spd.created_date, spd.lastmodified_date
|
||||||
|
ORDER BY spd.created_date DESC
|
||||||
|
""");
|
||||||
|
|
||||||
|
final JsonArray result = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final int declarationStatus = ((Number) row.get("declaration_status")).intValue();
|
||||||
|
final Timestamp approvedOn = (Timestamp) row.get("approved_on");
|
||||||
|
final boolean isApproved = declarationStatus == DIVIDEND_PAYOUT_STATUS_APPROVED;
|
||||||
|
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("declarationId", ((Number) row.get("declaration_id")).longValue());
|
||||||
|
entry.addProperty("productName", (String) row.get("product_name"));
|
||||||
|
entry.addProperty("periodStartDate", row.get("dividend_period_start_date").toString());
|
||||||
|
entry.addProperty("periodEndDate", row.get("dividend_period_end_date").toString());
|
||||||
|
entry.addProperty("declaredAmount", (BigDecimal) row.get("declared_amount"));
|
||||||
|
entry.addProperty("totalDistributedAmount", (BigDecimal) row.get("total_distributed_amount"));
|
||||||
|
entry.addProperty("createdDate", row.get("created_date").toString());
|
||||||
|
entry.addProperty("approvedOn", approvedOn == null ? null : approvedOn.toString());
|
||||||
|
entry.addProperty("totalMembers", ((Number) row.get("total_members")).longValue());
|
||||||
|
entry.addProperty("status", isApproved ? "APPROVED" : "PENDING_APPROVAL");
|
||||||
|
result.add(entry);
|
||||||
|
}
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still
|
||||||
|
* counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan --
|
||||||
|
* see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's
|
||||||
|
* profit). Optional {@code status} query param filters to one.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/locks")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String locks(@QueryParam("status") final String status) {
|
||||||
|
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
|
||||||
|
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
|
||||||
|
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
|
||||||
|
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no " //
|
||||||
|
+ "FROM m_share_dividend_lock l " //
|
||||||
|
+ "JOIN m_client c ON c.id = l.client_id " //
|
||||||
|
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
|
||||||
|
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
|
||||||
|
+ (filterByStatus ? "WHERE l.status = ? " : "") //
|
||||||
|
+ "ORDER BY l.locked_date DESC";
|
||||||
|
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
|
||||||
|
: jdbcTemplate.queryForList(sql);
|
||||||
|
|
||||||
|
final JsonArray result = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
|
||||||
|
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
|
||||||
|
entry.addProperty("clientName", (String) row.get("client_name"));
|
||||||
|
final Object savingsAccountId = row.get("savings_account_id");
|
||||||
|
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
||||||
|
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
|
||||||
|
entry.addProperty("amount", (BigDecimal) row.get("amount"));
|
||||||
|
entry.addProperty("lockedDate", row.get("locked_date").toString());
|
||||||
|
entry.addProperty("releaseDueDate", row.get("release_due_date").toString());
|
||||||
|
entry.addProperty("status", (String) row.get("status"));
|
||||||
|
final Object appliedLoanId = row.get("applied_loan_id");
|
||||||
|
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
|
||||||
|
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
|
||||||
|
result.add(entry);
|
||||||
|
}
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-dashboard ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String list() {
|
||||||
|
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
|
||||||
|
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
|
||||||
|
l.locked_date, l.release_due_date
|
||||||
|
FROM m_share_dividend_lock l
|
||||||
|
JOIN m_client c ON c.id = l.client_id
|
||||||
|
WHERE l.status = 'UNCLAIMED'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||||
|
WHERE ln.client_id = l.client_id AND ln.loan_status_id = 300
|
||||||
|
AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||||
|
)
|
||||||
|
ORDER BY l.locked_date ASC
|
||||||
|
""");
|
||||||
|
|
||||||
|
final JsonArray result = new JsonArray();
|
||||||
|
for (final Map<String, Object> lock : locks) {
|
||||||
|
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
||||||
|
final JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
|
||||||
|
row.addProperty("clientId", clientId);
|
||||||
|
row.addProperty("clientName", (String) lock.get("client_name"));
|
||||||
|
final Object savingsAccountId = lock.get("savings_account_id");
|
||||||
|
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
||||||
|
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
|
||||||
|
row.addProperty("lockedDate", lock.get("locked_date").toString());
|
||||||
|
row.addProperty("releaseDueDate", lock.get("release_due_date").toString());
|
||||||
|
row.add("overdueLoans", overdueLoansFor(clientId));
|
||||||
|
result.add(row);
|
||||||
|
}
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonArray overdueLoansFor(final Long clientId) {
|
||||||
|
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
|
||||||
|
SELECT ln.id AS loan_id, ln.account_no,
|
||||||
|
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
|
||||||
|
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
|
||||||
|
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
|
||||||
|
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
|
||||||
|
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||||
|
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||||
|
GROUP BY ln.id, ln.account_no
|
||||||
|
ORDER BY overdue_amount DESC
|
||||||
|
""", clientId);
|
||||||
|
final JsonArray result = new JsonArray();
|
||||||
|
for (final Map<String, Object> loan : loans) {
|
||||||
|
final JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
|
||||||
|
row.addProperty("accountNo", (String) loan.get("account_no"));
|
||||||
|
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
|
||||||
|
result.add(row);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue
|
||||||
|
* loan EMI (see {@code list()} above) and applies it without waiting for the member to visit
|
||||||
|
* and file a claim. Since the dividend was never credited into any savings account (it's a
|
||||||
|
* pure "Dividend Payable" GL liability until claimed -- see
|
||||||
|
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
|
||||||
|
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
|
||||||
|
* native and correct) and then redirects the debit leg it assumed the cash came from to the
|
||||||
|
* Dividend Payable liability instead -- identical mechanic to a claim's loan leg.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/{lockId}/apply-to-loan")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
|
public String applyToLoan(@PathParam("lockId") final Long lockId, final String body) {
|
||||||
|
final Map<String, Object> lock = jdbcTemplate
|
||||||
|
.queryForList("SELECT client_id, currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
|
||||||
|
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
|
||||||
|
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||||
|
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
|
||||||
|
}
|
||||||
|
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
||||||
|
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||||
|
|
||||||
|
final JsonElement root = fromJsonHelper.parse(body);
|
||||||
|
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
|
||||||
|
final String amountString = fromJsonHelper.extractStringNamed("amount", root);
|
||||||
|
if (loanId == null) {
|
||||||
|
throw apiError(400, "loanId is required");
|
||||||
|
}
|
||||||
|
BigDecimal applyAmount;
|
||||||
|
try {
|
||||||
|
applyAmount = amountString == null ? null : new BigDecimal(amountString.trim());
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
throw apiError(400, "amount must be a valid number");
|
||||||
|
}
|
||||||
|
if (applyAmount == null || applyAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw apiError(400, "amount must be greater than zero");
|
||||||
|
}
|
||||||
|
if (applyAmount.compareTo(unclaimedAmount) > 0) {
|
||||||
|
throw apiError(400, "amount cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
|
||||||
|
}
|
||||||
|
final Map<String, Object> loan = jdbcTemplate.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId)
|
||||||
|
.stream().findFirst().orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
|
||||||
|
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
|
||||||
|
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
|
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||||
|
payload.addProperty("transactionDate", today.toString());
|
||||||
|
payload.addProperty("transactionAmount", applyAmount);
|
||||||
|
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
|
||||||
|
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
||||||
|
.withJson(payload.toString()).build();
|
||||||
|
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||||
|
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
|
||||||
|
|
||||||
|
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
|
||||||
|
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
|
||||||
|
lockId);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_share_dividend_lock
|
||||||
|
SET status = 'CLAIMED', applied_loan_id = ?, applied_transaction_id = ?, last_modified_date = now()
|
||||||
|
WHERE id = ?
|
||||||
|
""", loanId, repaymentResult.getResourceId(), lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject response = new JsonObject();
|
||||||
|
response.addProperty("lockId", lockId);
|
||||||
|
response.addProperty("appliedAmount", applyAmount);
|
||||||
|
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
|
||||||
|
return gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loan repayment just posted above assumed the money came out of whichever account is
|
||||||
|
* this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a
|
||||||
|
* Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead)
|
||||||
|
* -- it didn't really. Rather than reverse that with a second manual entry pair (which would
|
||||||
|
* leave that fund-source account appearing twice, once debited then immediately credited back
|
||||||
|
* -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's
|
||||||
|
* account in place to Dividend Payable, so the transaction ends up with exactly the entries a
|
||||||
|
* real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited,
|
||||||
|
* nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend
|
||||||
|
* Payable financial activity.
|
||||||
|
*/
|
||||||
|
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
|
||||||
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
if (payableDividendsAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
|
||||||
|
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
final JsonObject errorBody = new JsonObject();
|
||||||
|
errorBody.addProperty("message", message);
|
||||||
|
errorBody.addProperty("defaultUserMessage", message);
|
||||||
|
errorBody.addProperty("httpStatus", httpStatus);
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 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.dividenddashboard.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every Share Product's dividend declaration that is still awaiting approval — Fineract only
|
||||||
|
* exposes dividends scoped to one product at a time
|
||||||
|
* ({@code GET /shareproduct/{productId}/dividend}), so a Bank Manager has no single place to see
|
||||||
|
* "everything currently waiting on me" across products. This aggregates all
|
||||||
|
* {@code m_share_product_dividend_pay_out} rows still INITIATED so they can be surfaced on the
|
||||||
|
* Approval Center (Checker Inbox) alongside the other maker-checker request types. Approval
|
||||||
|
* itself still goes through Fineract's native {@code approveDividend} command from the
|
||||||
|
* frontend — this resource only covers the missing cross-product listing.
|
||||||
|
*/
|
||||||
|
@Path("/v1/clerk/pending-dividends")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Pending Dividends", description = "Dividend declarations awaiting approval, across all Share Products.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PendingDividendsApiResource {
|
||||||
|
|
||||||
|
private static final int DIVIDEND_PAYOUT_STATUS_INITIATED = 100;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String list() {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT p.id AS dividend_id, p.product_id, sp.name AS product_name, p.amount,
|
||||||
|
p.dividend_period_start_date, p.dividend_period_end_date, p.created_date,
|
||||||
|
(SELECT COUNT(*) FROM m_share_account_dividend_details d WHERE d.dividend_pay_out_id = p.id) AS member_count
|
||||||
|
FROM m_share_product_dividend_pay_out p
|
||||||
|
JOIN m_share_product sp ON sp.id = p.product_id
|
||||||
|
WHERE p.status = ?
|
||||||
|
ORDER BY p.created_date ASC
|
||||||
|
""", DIVIDEND_PAYOUT_STATUS_INITIATED);
|
||||||
|
|
||||||
|
final JsonArray result = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("dividendId", ((Number) row.get("dividend_id")).longValue());
|
||||||
|
entry.addProperty("productId", ((Number) row.get("product_id")).longValue());
|
||||||
|
entry.addProperty("productName", (String) row.get("product_name"));
|
||||||
|
entry.addProperty("amount", (BigDecimal) row.get("amount"));
|
||||||
|
entry.addProperty("dividendPeriodStartDate", row.get("dividend_period_start_date").toString());
|
||||||
|
entry.addProperty("dividendPeriodEndDate", row.get("dividend_period_end_date").toString());
|
||||||
|
entry.addProperty("declaredOn", row.get("created_date") == null ? null : row.get("created_date").toString());
|
||||||
|
entry.addProperty("memberCount", ((Number) row.get("member_count")).intValue());
|
||||||
|
result.add(entry);
|
||||||
|
}
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* 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.loanoriginationpipeline.api;
|
||||||
|
|
||||||
|
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.PUT;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationPipelineService;
|
||||||
|
import org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationStageService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST endpoints for the Loan Origination Pipeline — the sanctioning workflow an application walks
|
||||||
|
* through before a real loan account is ever opened (Application → KYC/OVD → CIC Credit Bureau Check
|
||||||
|
* → Field Investigation → Personal Discussion → Credit Committee → Risk Approval → Legal Approval →
|
||||||
|
* Customer Signing → Cheque/ACH → Post-Disbursement Check → Disbursed).
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* GET /v1/loan-origination/applications – list (optional branchCode/stage/status filters)
|
||||||
|
* GET /v1/loan-origination/applications/{applicationNo} – one application, in full
|
||||||
|
* POST /v1/loan-origination/applications – create from the New Application wizard
|
||||||
|
* PUT /v1/loan-origination/applications/{applicationNo} – save the whole application back
|
||||||
|
* POST /v1/loan-origination/applications/seed – one-time seed; no-ops if already seeded
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Requests/responses are the application object verbatim, so the request body is passed straight
|
||||||
|
* through as JSON rather than being bound to a DTO. A DTO would have to mirror all ~111 workflow
|
||||||
|
* fields and would silently drop any field it had not been updated to know about — the exact failure
|
||||||
|
* this module has to avoid, since the workflow gains fields regularly.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Authorisation is a normal authenticated staff session. These endpoints deliberately do not define
|
||||||
|
* their own permission codes: the pipeline is the sanctioning process for lending, so it is gated by
|
||||||
|
* the same authenticated-staff check the rest of the loan screens use, and per-stage authority
|
||||||
|
* (who may sit on the Credit Committee, who signs off Risk/Legal) is a workflow concern enforced on
|
||||||
|
* the application record itself, not a Fineract entity permission.
|
||||||
|
*/
|
||||||
|
@Path("/v1/loan-origination/applications")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Loan Origination Pipeline",
|
||||||
|
description = "Pre-disbursement sanctioning workflow: KYC/OVD, CIC credit bureau check, Field Investigation, "
|
||||||
|
+ "Personal Discussion, Credit Committee, Risk & Legal approval, signing and disbursement.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LoanOriginationPipelineApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final LoanOriginationPipelineService service;
|
||||||
|
private final LoanOriginationStageService stageService;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "List loan origination applications",
|
||||||
|
description = "Newest first. Filters are optional — the Pipeline screen also filters client-side.")
|
||||||
|
public String retrieveAll(@QueryParam("branchCode") final String branchCode, @QueryParam("stage") final String stage,
|
||||||
|
@QueryParam("status") final String status) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.service.findAllAsJson(branchCode, stage, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{applicationNo}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Retrieve one loan origination application in full")
|
||||||
|
public String retrieveOne(@PathParam("applicationNo") final String applicationNo) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.service.findOneAsJson(applicationNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Create a loan origination application",
|
||||||
|
description = "Body is the application object as built by the New Application wizard.")
|
||||||
|
public String create(final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.service.create(apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("{applicationNo}")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Save a loan origination application",
|
||||||
|
description = "Full-object save: the body replaces the stored application, its stage history, "
|
||||||
|
+ "approvals, field-investigation checks and document checklists.")
|
||||||
|
public String save(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.service.save(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("seed")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Seed the pipeline once",
|
||||||
|
description = "Inserts the supplied applications only when the pipeline is empty, so reloading the "
|
||||||
|
+ "frontend cannot duplicate the book. Returns the number actually inserted.")
|
||||||
|
public String seed(final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return "{\"seeded\":" + this.service.seed(apiRequestBodyAsJson) + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Per-stage transitions.
|
||||||
|
//
|
||||||
|
// Each of these takes only its own step's inputs (who acted, remarks, a resolution number) and
|
||||||
|
// lets the server validate the stage's prerequisites against the stored record before applying
|
||||||
|
// the change. The full-object PUT above still exists for inline field edits; these exist because
|
||||||
|
// a sanctioning decision should not be something a caller can simply assert. Every one returns
|
||||||
|
// the updated application, so the client replaces its copy rather than guessing the result.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/kyc-complete")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Complete KYC & Document Collection",
|
||||||
|
description = "Requires Aadhaar/PAN verified, income proof, bank statement and address proof submitted, "
|
||||||
|
+ "income assessed, and every product-specific document submitted.")
|
||||||
|
public String completeKyc(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.completeKyc(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/cic-complete")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Complete the CIC / credit bureau check",
|
||||||
|
description = "Requires the borrower's signed consent, the collected bureau charge and an uploaded report.")
|
||||||
|
public String completeCicCheck(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.completeCicCheck(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/field-investigation/{checkKey}/verify")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Mark one field investigation check verified",
|
||||||
|
description = "Requires an assigned officer, GPS coordinates and at least one site photograph "
|
||||||
|
+ "to already be recorded against the visit.")
|
||||||
|
public String verifyFieldCheck(@PathParam("applicationNo") final String applicationNo,
|
||||||
|
@PathParam("checkKey") final String checkKey, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.verifyFieldCheck(applicationNo, checkKey, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/field-investigation/{checkKey}/reject")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Mark one field investigation check failed", description = "A reason is mandatory.")
|
||||||
|
public String rejectFieldCheck(@PathParam("applicationNo") final String applicationNo,
|
||||||
|
@PathParam("checkKey") final String checkKey, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.rejectFieldCheck(applicationNo, checkKey, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/field-investigation/complete")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Complete Field Investigation",
|
||||||
|
description = "Requires every applicable check (address, office, plus property when secured and shop "
|
||||||
|
+ "when the applicant runs one) to be verified.")
|
||||||
|
public String completeFieldInvestigation(@PathParam("applicationNo") final String applicationNo,
|
||||||
|
final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.completeFieldInvestigation(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/personal-discussion/clear")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Clear the personal discussion and proceed")
|
||||||
|
public String clearPersonalDiscussion(@PathParam("applicationNo") final String applicationNo,
|
||||||
|
final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.clearPersonalDiscussion(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/personal-discussion/flag")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Flag a discrepancy and hold the application",
|
||||||
|
description = "A discrepancy type and notes are both mandatory.")
|
||||||
|
public String flagDiscrepancy(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.flagDiscrepancy(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/personal-discussion/resolve")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Resolve a held discrepancy and proceed")
|
||||||
|
public String resolveDiscrepancy(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.resolveDiscrepancy(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/approvals/{field}")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Record an approval decision",
|
||||||
|
description = "`field` is one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval. "
|
||||||
|
+ "An APPROVED decision is checked against that stage's own checklist — the Credit Committee also "
|
||||||
|
+ "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,
|
||||||
|
final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/signing")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Record the customer's signing of the loan agreement")
|
||||||
|
public String markSigned(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.markSigned(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/cheque-ach")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Confirm security cheques received and ACH mandate signed")
|
||||||
|
public String confirmChequeAch(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.confirmChequeAch(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{applicationNo}/disburse")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Disburse the loan",
|
||||||
|
description = "Requires all four approval levels to stand as approved and the customer to have signed. "
|
||||||
|
+ "Optionally records the real Fineract loan account created at disbursement.")
|
||||||
|
public String disburse(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
return this.stageService.disburse(applicationNo, apiRequestBodyAsJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 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.loanoriginationpipeline.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null-safe accessors for the loan origination application object.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The application is handled as JSON rather than a typed entity (see
|
||||||
|
* {@link LoanOriginationPipelineService} for why), so every read has to tolerate a key being absent,
|
||||||
|
* explicitly null, or the wrong JSON type — an older application stored before a field existed is a
|
||||||
|
* normal, expected case, not an error. These helpers turn all three into a sensible default so no
|
||||||
|
* caller has to repeat the checks.
|
||||||
|
*/
|
||||||
|
public final class LoanOriginationJson {
|
||||||
|
|
||||||
|
private LoanOriginationJson() {}
|
||||||
|
|
||||||
|
public static JsonObject parseObject(final String body) {
|
||||||
|
if (body == null || body.isBlank()) {
|
||||||
|
// An action with no inputs (e.g. "mark signed") legitimately posts an empty body.
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
final JsonElement parsed = JsonParser.parseString(body);
|
||||||
|
if (!parsed.isJsonObject()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.body.invalid",
|
||||||
|
"Request body must be a JSON object.");
|
||||||
|
}
|
||||||
|
return parsed.getAsJsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean present(final JsonObject obj, final String key) {
|
||||||
|
return obj != null && obj.has(key) && !obj.get(key).isJsonNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String str(final JsonObject obj, final String key) {
|
||||||
|
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trimmed value, or the fallback when absent/blank. */
|
||||||
|
public static String strOr(final JsonObject obj, final String key, final String fallback) {
|
||||||
|
final String value = str(obj, key);
|
||||||
|
return value == null || value.isBlank() ? fallback : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a field holds real content. Treats the {@code '—'} em-dash as empty: the workflow uses
|
||||||
|
* it as a "nothing recorded yet" placeholder throughout, so a check for "has this been filled in"
|
||||||
|
* has to reject it or every blank field would read as populated.
|
||||||
|
*/
|
||||||
|
public static boolean filled(final JsonObject obj, final String key) {
|
||||||
|
final String value = str(obj, key);
|
||||||
|
return value != null && !value.trim().isEmpty() && !"—".equals(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long lng(final JsonObject obj, final String key) {
|
||||||
|
try {
|
||||||
|
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BigDecimal dec(final JsonObject obj, final String key) {
|
||||||
|
try {
|
||||||
|
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsBigDecimal() : null;
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean bool(final JsonObject obj, final String key) {
|
||||||
|
return present(obj, key) && obj.get(key).isJsonPrimitive() && obj.get(key).getAsBoolean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int intOr(final JsonObject obj, final String key, final int fallback) {
|
||||||
|
try {
|
||||||
|
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsInt() : fallback;
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonObject obj(final JsonObject parent, final String key) {
|
||||||
|
return present(parent, key) && parent.get(key).isJsonObject() ? parent.getAsJsonObject(key) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nested object, creating and attaching an empty one if it isn't there yet. */
|
||||||
|
public static JsonObject objOrCreate(final JsonObject parent, final String key) {
|
||||||
|
final JsonObject existing = obj(parent, key);
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
final JsonObject created = new JsonObject();
|
||||||
|
parent.add(key, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonArray arr(final JsonObject parent, final String key) {
|
||||||
|
return present(parent, key) && parent.get(key).isJsonArray() ? parent.getAsJsonArray(key) : new JsonArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonArray arrOrCreate(final JsonObject parent, final String key) {
|
||||||
|
if (present(parent, key) && parent.get(key).isJsonArray()) {
|
||||||
|
return parent.getAsJsonArray(key);
|
||||||
|
}
|
||||||
|
final JsonArray created = new JsonArray();
|
||||||
|
parent.add(key, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String rawJson(final JsonObject obj, final String key) {
|
||||||
|
return present(obj, key) ? obj.get(key).toString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+403
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* 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.loanoriginationpipeline.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistence for the Loan Origination Pipeline — the pre-disbursement sanctioning workflow
|
||||||
|
* (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit Committee →
|
||||||
|
* Risk → Legal → Signing → Cheque/ACH → Post-Check → Disbursed).
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* <b>Storage shape.</b> {@code payload} holds the complete application object as JSON and is the
|
||||||
|
* single source of truth for reads. The dozen real columns on
|
||||||
|
* {@code m_loan_origination_application}, and the four child tables, are <i>derived indexes</i> —
|
||||||
|
* they exist so the Pipeline list, the Channel/BC/Share reports, Target-vs-Actual and audit queries
|
||||||
|
* ("every application this officer approved") can be plain SQL instead of scanning JSON. They are
|
||||||
|
* never read back into the application object.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* That deliberately stores those fields twice, which is the right trade here: every write goes
|
||||||
|
* through {@link #writeApplication} in one transaction and rewrites the payload, the columns and all
|
||||||
|
* child rows together from the same source, so the copies cannot drift. The alternative — splitting
|
||||||
|
* fields so nothing repeats — would force the read path to reconstruct a dozen values back out of
|
||||||
|
* SQL types (a {@code DECIMAL(19,6)} returning {@code 100000.000000} where the UI expects
|
||||||
|
* {@code 100000}, a null {@code BIGINT} vs an absent key), which is exactly the class of silent
|
||||||
|
* field-level corruption this module cannot afford.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The one field kept out of {@code payload} is {@code cicParsedAnalysis} — a parsed credit-bureau
|
||||||
|
* report with a per-account seven-year day-past-due grid. It is large, opaque and only ever rendered
|
||||||
|
* whole, so it gets its own column rather than being duplicated alongside everything else.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Because the payload is "the whole object minus that one field" rather than an allowlist of known
|
||||||
|
* keys, a field added to the workflow later persists with no schema or code change here. Given how
|
||||||
|
* fast this workflow gains fields, an allowlist would drop them silently.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LoanOriginationPipelineService {
|
||||||
|
|
||||||
|
/** Pulled out of the payload into its own column — see class javadoc. */
|
||||||
|
private static final String CIC_ANALYSIS_KEY = "cicParsedAnalysis";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row's primary key. Kept out of the payload so it can only ever come from the column — a
|
||||||
|
* stale id round-tripped inside the JSON would silently point uploaded documents at the wrong
|
||||||
|
* application.
|
||||||
|
*/
|
||||||
|
private static final String ID_KEY = "id";
|
||||||
|
|
||||||
|
private static final String SELECT_COLUMNS = "id, payload, cic_parsed_analysis";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
// ── Read ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every application, newest first. Optional filters are pushed into SQL; the Pipeline screen also
|
||||||
|
* filters in-memory, so passing nothing here is perfectly valid.
|
||||||
|
*/
|
||||||
|
public String findAllAsJson(final String branchCode, final String stage, final String status) {
|
||||||
|
final StringBuilder sql = new StringBuilder("SELECT " + SELECT_COLUMNS + " FROM m_loan_origination_application WHERE 1=1");
|
||||||
|
final List<Object> params = new ArrayList<>();
|
||||||
|
if (isSet(branchCode)) {
|
||||||
|
sql.append(" AND branch_code = ?");
|
||||||
|
params.add(branchCode);
|
||||||
|
}
|
||||||
|
if (isSet(stage)) {
|
||||||
|
sql.append(" AND current_stage = ?");
|
||||||
|
params.add(stage);
|
||||||
|
}
|
||||||
|
if (isSet(status)) {
|
||||||
|
sql.append(" AND overall_status = ?");
|
||||||
|
params.add(status);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY id DESC");
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
final JsonArray out = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
out.add(reassemble(row));
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String findOneAsJson(final String applicationNo) {
|
||||||
|
return loadApplication(applicationNo).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads one application as a mutable object. This is the entry point the per-stage endpoints use:
|
||||||
|
* they validate and apply their transition against the state that is actually stored, never
|
||||||
|
* against what a caller claims it is.
|
||||||
|
*/
|
||||||
|
public JsonObject loadApplication(final String applicationNo) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate
|
||||||
|
.queryForList("SELECT " + SELECT_COLUMNS + " FROM m_loan_origination_application WHERE application_no = ?",
|
||||||
|
applicationNo);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.not.found",
|
||||||
|
"No loan origination application found with number " + applicationNo + ".", applicationNo);
|
||||||
|
}
|
||||||
|
return reassemble(rows.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the application object: the stored payload, with the bureau report re-attached and the
|
||||||
|
* row's own id surfaced. The id is what uploaded files hang off — documents are stored against
|
||||||
|
* {@code m_document.parent_entity_id}, so the client needs it to upload and fetch attachments.
|
||||||
|
*/
|
||||||
|
private JsonObject reassemble(final Map<String, Object> row) {
|
||||||
|
final JsonObject app = parseObject(asString(row.get("payload")));
|
||||||
|
final Object id = row.get("id");
|
||||||
|
if (id != null) {
|
||||||
|
app.addProperty("id", Long.valueOf(id.toString()));
|
||||||
|
}
|
||||||
|
final String cic = asString(row.get("cic_parsed_analysis"));
|
||||||
|
if (cic != null && !cic.isBlank()) {
|
||||||
|
app.add(CIC_ANALYSIS_KEY, JsonParser.parseString(cic));
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an application. The caller (the New Application wizard) has already derived the
|
||||||
|
* application number and the blank per-stage structures, so this stores the object as given
|
||||||
|
* rather than re-deriving a second, competing set of defaults server-side.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String create(final String requestBodyAsJson) {
|
||||||
|
final JsonObject app = parseObject(requestBodyAsJson);
|
||||||
|
final String applicationNo = stringOrNull(app, "applicationNo");
|
||||||
|
if (applicationNo == null || applicationNo.isBlank()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.no.required",
|
||||||
|
"applicationNo is required.");
|
||||||
|
}
|
||||||
|
final Long existing = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_loan_origination_application WHERE application_no = ?", Long.class, applicationNo);
|
||||||
|
if (existing != null && existing > 0) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.duplicate",
|
||||||
|
"An application numbered " + applicationNo + " already exists.", applicationNo);
|
||||||
|
}
|
||||||
|
// Read back rather than echoing the request: the caller needs the assigned id to be able to
|
||||||
|
// attach documents to this application.
|
||||||
|
insertApplication(app);
|
||||||
|
return findOneAsJson(applicationNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-object save. The workflow's stage transitions are all applied client-side against the
|
||||||
|
* whole application object, so the whole object is what gets written back — there is no
|
||||||
|
* per-action field allowlist that could quietly omit a field.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String save(final String applicationNo, final String requestBodyAsJson) {
|
||||||
|
return storeApplication(applicationNo, parseObject(requestBodyAsJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a whole application object back. Shared by the full-object save and every per-stage
|
||||||
|
* endpoint, so both paths persist through exactly the same code. */
|
||||||
|
@Transactional
|
||||||
|
public String storeApplication(final String applicationNo, final JsonObject app) {
|
||||||
|
final Long id = findIdOrThrow(applicationNo);
|
||||||
|
|
||||||
|
final JsonObject payload = app.deepCopy();
|
||||||
|
payload.remove(CIC_ANALYSIS_KEY);
|
||||||
|
payload.remove(ID_KEY);
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_loan_origination_application SET
|
||||||
|
applicant_name = ?, client_id = ?, branch_code = ?, channel = ?, bc_agent_name = ?,
|
||||||
|
product_code = ?, requested_amount = ?, current_stage = ?, overall_status = ?,
|
||||||
|
is_share_member = ?, fineract_loan_id = ?, payload = ?, cic_parsed_analysis = ?,
|
||||||
|
last_modified_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""", stringOrNull(app, "applicantName"), longOrNull(app, "clientId"), stringOrNull(app, "branchCode"),
|
||||||
|
stringOrNull(app, "channel"), stringOrNull(app, "bcAgentName"), stringOrNull(app, "productCode"),
|
||||||
|
decimalOrNull(app, "requestedAmount"), stringOrNull(app, "currentStage"), stringOrNull(app, "overallStatus"),
|
||||||
|
boolOrFalse(app, "isShareMember"), longOrNull(app, "fineractLoanId"), payload.toString(),
|
||||||
|
childJsonOrNull(app, CIC_ANALYSIS_KEY), id);
|
||||||
|
|
||||||
|
writeChildren(id, app);
|
||||||
|
return findOneAsJson(applicationNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time seed. Idempotent by design: it is called on first page load and must not duplicate the
|
||||||
|
* book if the frontend is reloaded, so a non-empty table short-circuits to 0.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int seed(final String requestBodyAsJson) {
|
||||||
|
final Long existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_loan_origination_application", Long.class);
|
||||||
|
if (existing != null && existing > 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final JsonElement parsed = JsonParser.parseString(requestBodyAsJson == null ? "[]" : requestBodyAsJson);
|
||||||
|
if (!parsed.isJsonArray()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.seed.invalid",
|
||||||
|
"Seed body must be a JSON array of applications.");
|
||||||
|
}
|
||||||
|
int seeded = 0;
|
||||||
|
for (final JsonElement element : parsed.getAsJsonArray()) {
|
||||||
|
if (element.isJsonObject()) {
|
||||||
|
insertApplication(element.getAsJsonObject());
|
||||||
|
seeded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertApplication(final JsonObject app) {
|
||||||
|
final JsonObject payload = app.deepCopy();
|
||||||
|
payload.remove(CIC_ANALYSIS_KEY);
|
||||||
|
payload.remove(ID_KEY);
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_loan_origination_application
|
||||||
|
(application_no, applicant_name, client_id, branch_code, channel, bc_agent_name,
|
||||||
|
product_code, requested_amount, current_stage, overall_status, is_share_member,
|
||||||
|
fineract_loan_id, payload, cic_parsed_analysis)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", stringOrNull(app, "applicationNo"), stringOrNull(app, "applicantName"), longOrNull(app, "clientId"),
|
||||||
|
stringOrNull(app, "branchCode"), stringOrNull(app, "channel"), stringOrNull(app, "bcAgentName"),
|
||||||
|
stringOrNull(app, "productCode"), decimalOrNull(app, "requestedAmount"), stringOrNull(app, "currentStage"),
|
||||||
|
stringOrNull(app, "overallStatus"), boolOrFalse(app, "isShareMember"), longOrNull(app, "fineractLoanId"),
|
||||||
|
payload.toString(), childJsonOrNull(app, CIC_ANALYSIS_KEY));
|
||||||
|
|
||||||
|
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_loan_origination_application WHERE application_no = ?",
|
||||||
|
Long.class, stringOrNull(app, "applicationNo"));
|
||||||
|
writeChildren(id, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites the derived child rows for one application. Delete-then-insert rather than a per-row
|
||||||
|
* diff: these collections are small (a stage history is a dozen rows), and a full rewrite cannot
|
||||||
|
* leave a stale row behind the way a partial diff can.
|
||||||
|
*/
|
||||||
|
private void writeChildren(final Long id, final JsonObject app) {
|
||||||
|
jdbcTemplate.update("DELETE FROM m_loan_origination_stage_history WHERE application_id = ?", id);
|
||||||
|
jdbcTemplate.update("DELETE FROM m_loan_origination_approval WHERE application_id = ?", id);
|
||||||
|
jdbcTemplate.update("DELETE FROM m_loan_origination_fi_check WHERE application_id = ?", id);
|
||||||
|
jdbcTemplate.update("DELETE FROM m_loan_origination_document WHERE application_id = ?", id);
|
||||||
|
|
||||||
|
final JsonArray history = arrayOrEmpty(app, "stageHistory");
|
||||||
|
for (int i = 0; i < history.size(); i++) {
|
||||||
|
if (!history.get(i).isJsonObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final JsonObject entry = history.get(i).getAsJsonObject();
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_loan_origination_stage_history
|
||||||
|
(application_id, seq_no, stage, action_date, action_by, outcome, remarks)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", id, i, stringOrNull(entry, "stage"), stringOrNull(entry, "date"), stringOrNull(entry, "actionBy"),
|
||||||
|
stringOrNull(entry, "outcome"), stringOrNull(entry, "remarks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final String field : new String[] { "creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval" }) {
|
||||||
|
final JsonObject approval = objectOrNull(app, field);
|
||||||
|
if (approval == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_loan_origination_approval
|
||||||
|
(application_id, approval_field, decision, approver_name, approver_role, decision_date, remarks)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", id, field, stringOrNull(approval, "decision"), stringOrNull(approval, "approverName"),
|
||||||
|
stringOrNull(approval, "approverRole"), stringOrNull(approval, "date"), stringOrNull(approval, "remarks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final String key : new String[] { "addressVerification", "officeVerification", "propertyVerification",
|
||||||
|
"shopVerification" }) {
|
||||||
|
final JsonObject check = objectOrNull(app, key);
|
||||||
|
if (check == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_loan_origination_fi_check
|
||||||
|
(application_id, check_key, status, verified_by, verified_date, remarks, officer_assigned,
|
||||||
|
assigned_date, assigned_at, latitude, longitude, photo_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", id, key, stringOrNull(check, "status"), stringOrNull(check, "verifiedBy"),
|
||||||
|
stringOrNull(check, "verifiedDate"), stringOrNull(check, "remarks"), stringOrNull(check, "officerAssigned"),
|
||||||
|
stringOrNull(check, "assignedDate"), longOrNull(check, "assignedAt"), stringOrNull(check, "latitude"),
|
||||||
|
stringOrNull(check, "longitude"), intOrZero(check, "photoCount"));
|
||||||
|
}
|
||||||
|
|
||||||
|
insertDocuments(id, arrayOrEmpty(app, "productDocuments"), "PRODUCT");
|
||||||
|
insertDocuments(id, arrayOrEmpty(app, "legalDocuments"), "LEGAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertDocuments(final Long id, final JsonArray docs, final String kind) {
|
||||||
|
for (int i = 0; i < docs.size(); i++) {
|
||||||
|
if (!docs.get(i).isJsonObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final JsonObject doc = docs.get(i).getAsJsonObject();
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_loan_origination_document
|
||||||
|
(application_id, doc_kind, doc_key, label, hint, is_custom, submitted, seq_no)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", id, kind, stringOrNull(doc, "key"), stringOrNull(doc, "label"), stringOrNull(doc, "hint"),
|
||||||
|
boolOrFalse(doc, "custom"), boolOrFalse(doc, "submitted"), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long findIdOrThrow(final String applicationNo) {
|
||||||
|
final List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM m_loan_origination_application WHERE application_no = ?",
|
||||||
|
Long.class, applicationNo);
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.not.found",
|
||||||
|
"No loan origination application found with number " + applicationNo + ".", applicationNo);
|
||||||
|
}
|
||||||
|
return ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON helpers ─────────────────────────────────────────────────────────
|
||||||
|
// Thin aliases over LoanOriginationJson so this class and the per-stage service share one
|
||||||
|
// implementation of the null/absent/wrong-type handling rather than each keeping its own.
|
||||||
|
|
||||||
|
private JsonObject parseObject(final String body) {
|
||||||
|
if (body == null || body.isBlank()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.body.required", "A request body is required.");
|
||||||
|
}
|
||||||
|
return LoanOriginationJson.parseObject(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean present(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.present(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stringOrNull(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.str(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long longOrNull(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.lng(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal decimalOrNull(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.dec(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean boolOrFalse(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.bool(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int intOrZero(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.intOr(obj, key, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject objectOrNull(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.obj(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonArray arrayOrEmpty(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.arr(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String childJsonOrNull(final JsonObject obj, final String key) {
|
||||||
|
return LoanOriginationJson.rawJson(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String asString(final Object value) {
|
||||||
|
return value == null ? null : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSet(final String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+517
@@ -0,0 +1,517 @@
|
|||||||
|
/**
|
||||||
|
* 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.loanoriginationpipeline.service;
|
||||||
|
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.arr;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.arrOrCreate;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.bool;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.filled;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.intOr;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.obj;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.objOrCreate;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.parseObject;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.str;
|
||||||
|
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.strOr;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side stage transitions for the Loan Origination Pipeline.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Each method here owns one sanctioning step: it loads the application <b>as stored</b>, checks that
|
||||||
|
* step's preconditions against that stored state, applies the change, appends the audit-trail entry
|
||||||
|
* and saves. The caller supplies only that step's own inputs (who acted, remarks, a resolution
|
||||||
|
* number) — never the resulting state — so a client cannot approve a stage whose prerequisites are
|
||||||
|
* not actually met, mark a field visit verified without the GPS fix and photographs really being on
|
||||||
|
* file, or minute a Credit Committee decision without a resolution number.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* That distinction matters for a sanctioning workflow: the screen also disables those buttons, but UI
|
||||||
|
* gating is a convenience, not a control — it is bypassed by any direct call to the API. These checks
|
||||||
|
* are the control. They are deliberately the same rules the screen enforces, so an officer never sees
|
||||||
|
* an enabled button that the server then rejects.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Dates are written in the {@code dd-MM-yyyy} form the rest of the workflow record already uses, and
|
||||||
|
* "nothing recorded" stays the {@code '—'} placeholder the existing data uses, so a server-applied
|
||||||
|
* transition is indistinguishable in shape from the client-applied ones already stored.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LoanOriginationStageService {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter WORKFLOW_DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||||
|
|
||||||
|
/** 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",
|
||||||
|
"PERSONAL_DISCUSSION", "CREDIT_COMMITTEE", "RISK_APPROVAL", "LEGAL_APPROVAL", "CUSTOMER_SIGNING", "CHEQUE_ACH",
|
||||||
|
"POST_CHECK", "DISBURSED");
|
||||||
|
|
||||||
|
private static final List<String> FI_CHECK_KEYS = List.of("addressVerification", "officeVerification", "propertyVerification",
|
||||||
|
"shopVerification");
|
||||||
|
|
||||||
|
private final LoanOriginationPipelineService pipelineService;
|
||||||
|
|
||||||
|
// ── KYC & Documents ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String completeKyc(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "KYC_DOCUMENTS");
|
||||||
|
|
||||||
|
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
||||||
|
require(bool(app, "panVerified"), "PAN has not been verified.");
|
||||||
|
require(bool(app, "incomeProofSubmitted"), "Income proof 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(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed.");
|
||||||
|
for (final JsonElement element : arr(app, "productDocuments")) {
|
||||||
|
final JsonObject doc = element.getAsJsonObject();
|
||||||
|
require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
||||||
|
}
|
||||||
|
|
||||||
|
advance(app, actor(input), "KYC Complete", strOr(input, "remarks", "KYC and document collection complete."));
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CIC / Credit Bureau ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String completeCicCheck(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "CIC_CHECK");
|
||||||
|
|
||||||
|
// RBI's Fair Practices Code requires the borrower's written consent before a bureau pull, and
|
||||||
|
// the bureau's per-pull fee has to have actually been collected.
|
||||||
|
require(bool(app, "cicConsentSigned"), "The borrower's signed credit-bureau consent is not on file.");
|
||||||
|
require(bool(app, "cicChargeCollected"), "The credit bureau check charge has not been collected.");
|
||||||
|
require(bool(app, "cicReportUploaded"), "No credit bureau report has been uploaded.");
|
||||||
|
|
||||||
|
advance(app, actor(input), "CIC Check Complete",
|
||||||
|
strOr(input, "remarks", "Credit bureau report obtained and reviewed. Score " + intOr(app, "cibilScore", 0) + "."));
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field Investigation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks one field-investigation check verified. The GPS fix and site photographs are checked
|
||||||
|
* against what is stored, which is the point: "I visited" with nothing recorded is exactly what a
|
||||||
|
* field-investigation control exists to prevent.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String verifyFieldCheck(final String applicationNo, final String checkKey, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "FIELD_INVESTIGATION");
|
||||||
|
require(FI_CHECK_KEYS.contains(checkKey), "Unknown field investigation check: " + checkKey);
|
||||||
|
|
||||||
|
final JsonObject check = objOrCreate(app, checkKey);
|
||||||
|
require(filled(check, "officerAssigned"), "No field officer has been assigned to this visit.");
|
||||||
|
require(filled(check, "latitude") && filled(check, "longitude"), "The visit has no GPS coordinates recorded.");
|
||||||
|
require(intOr(check, "photoCount", 0) > 0, "No site photograph has been captured for this visit.");
|
||||||
|
|
||||||
|
check.addProperty("status", "VERIFIED");
|
||||||
|
check.addProperty("verifiedBy", strOr(input, "verifiedBy", strOr(check, "officerAssigned", "Field Officer")));
|
||||||
|
check.addProperty("verifiedDate", today());
|
||||||
|
check.addProperty("remarks", strOr(input, "remarks", "Visited and confirmed correct."));
|
||||||
|
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String rejectFieldCheck(final String applicationNo, final String checkKey, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "FIELD_INVESTIGATION");
|
||||||
|
require(FI_CHECK_KEYS.contains(checkKey), "Unknown field investigation check: " + checkKey);
|
||||||
|
// A failed visit must say why -- it is what the Personal Discussion stage then works from.
|
||||||
|
final String remarks = strOr(input, "remarks", "");
|
||||||
|
require(!remarks.isBlank(), "A reason is required when a field visit fails.");
|
||||||
|
|
||||||
|
final JsonObject check = objOrCreate(app, checkKey);
|
||||||
|
check.addProperty("status", "FAILED");
|
||||||
|
check.addProperty("verifiedBy", strOr(input, "verifiedBy", strOr(check, "officerAssigned", "Field Officer")));
|
||||||
|
check.addProperty("verifiedDate", today());
|
||||||
|
check.addProperty("remarks", remarks);
|
||||||
|
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String completeFieldInvestigation(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "FIELD_INVESTIGATION");
|
||||||
|
requireFieldInvestigationComplete(app);
|
||||||
|
|
||||||
|
advance(app, actor(input), "FI Complete", strOr(input, "remarks", "Field investigation complete on all applicable checks."));
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Personal Discussion ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String clearPersonalDiscussion(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "PERSONAL_DISCUSSION");
|
||||||
|
require(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.");
|
||||||
|
app.addProperty("discrepancyType", "NONE");
|
||||||
|
app.addProperty("overallStatus", "IN_PROGRESS");
|
||||||
|
app.addProperty("personalDiscussionDate", today());
|
||||||
|
app.addProperty("personalDiscussionNotes", remarks);
|
||||||
|
app.addProperty("declarationTaken", true);
|
||||||
|
app.addProperty("declarationDate", today());
|
||||||
|
|
||||||
|
advance(app, actor(input), "Cleared", remarks);
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String flagDiscrepancy(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "PERSONAL_DISCUSSION");
|
||||||
|
|
||||||
|
final String type = strOr(input, "discrepancyType", "");
|
||||||
|
require(!type.isBlank() && !"NONE".equals(type), "A discrepancy type is required.");
|
||||||
|
final String notes = strOr(input, "remarks", "");
|
||||||
|
require(!notes.isBlank(), "Notes are required when flagging a discrepancy.");
|
||||||
|
|
||||||
|
app.addProperty("discrepancyType", type);
|
||||||
|
app.addProperty("personalDiscussionDate", today());
|
||||||
|
app.addProperty("personalDiscussionNotes", notes);
|
||||||
|
// Held rather than rejected: a discrepancy is resolvable, and the application has to stay
|
||||||
|
// visible as outstanding work rather than dropping out of the pipeline.
|
||||||
|
app.addProperty("overallStatus", "ON_HOLD");
|
||||||
|
|
||||||
|
appendHistory(app, str(app, "currentStage"), actor(input), "Discrepancy Flagged", notes);
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String resolveDiscrepancy(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "PERSONAL_DISCUSSION");
|
||||||
|
require("ON_HOLD".equals(str(app, "overallStatus")), "This application is not on hold.");
|
||||||
|
|
||||||
|
final String remarks = strOr(input, "remarks", "Discrepancy resolved on further review — cleared to proceed.");
|
||||||
|
app.addProperty("discrepancyType", "NONE");
|
||||||
|
app.addProperty("overallStatus", "IN_PROGRESS");
|
||||||
|
app.addProperty("personalDiscussionNotes", remarks);
|
||||||
|
app.addProperty("declarationTaken", true);
|
||||||
|
app.addProperty("declarationDate", today());
|
||||||
|
|
||||||
|
advance(app, actor(input), "Resolved", remarks);
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Approvals ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a Credit Committee / Risk / Legal / Post-Check decision.
|
||||||
|
*
|
||||||
|
* @param field one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String decideApproval(final String applicationNo, final String field, final String body) {
|
||||||
|
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 "riskApproval" -> "RISK_APPROVAL";
|
||||||
|
case "legalApproval" -> "LEGAL_APPROVAL";
|
||||||
|
case "postCheckApproval" -> "POST_CHECK";
|
||||||
|
default -> throw badRequest("Unknown approval stage: " + field);
|
||||||
|
};
|
||||||
|
requireStage(app, expectedStage);
|
||||||
|
|
||||||
|
// Prerequisites are only enforced for a positive decision: a rejection has to remain possible
|
||||||
|
// at any point, and blocking it on an incomplete checklist would trap the application.
|
||||||
|
if (!"REJECTED".equals(decision)) {
|
||||||
|
switch (field) {
|
||||||
|
case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input);
|
||||||
|
case "riskApproval" -> requireRiskChecklistReady(app);
|
||||||
|
case "legalApproval" -> requireLegalChecklistDone(app);
|
||||||
|
case "postCheckApproval" -> requirePostCheckSignatures(app);
|
||||||
|
default -> throw badRequest("Unknown approval stage: " + field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String remarks = strOr(input, "remarks", "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved.");
|
||||||
|
if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision)) {
|
||||||
|
// 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.
|
||||||
|
final String resolutionNo = strOr(input, "resolutionNo", "");
|
||||||
|
final String resolutionDate = strOr(input, "resolutionDate", "");
|
||||||
|
app.addProperty("committeeResolutionNo", resolutionNo);
|
||||||
|
app.addProperty("committeeResolutionDate", resolutionDate);
|
||||||
|
remarks = remarks + " (Resolution No. " + resolutionNo + " dated " + resolutionDate + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject approval = objOrCreate(app, field);
|
||||||
|
approval.addProperty("decision", decision);
|
||||||
|
approval.addProperty("approverName", strOr(input, "approverName", "Bank Staff"));
|
||||||
|
approval.addProperty("approverRole", strOr(input, "approverRole", expectedStage));
|
||||||
|
approval.addProperty("date", today());
|
||||||
|
approval.addProperty("remarks", remarks);
|
||||||
|
|
||||||
|
if ("REJECTED".equals(decision)) {
|
||||||
|
app.addProperty("overallStatus", "REJECTED");
|
||||||
|
appendHistory(app, str(app, "currentStage"), actor(input), "Rejected", remarks);
|
||||||
|
} else {
|
||||||
|
advance(app, actor(input), "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
|
||||||
|
}
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signing / Cheque-ACH / Disbursement ──────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String markSigned(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "CUSTOMER_SIGNING");
|
||||||
|
require(filled(app, "agreementSigningSlot"), "No agreement signing slot has been recorded.");
|
||||||
|
|
||||||
|
app.addProperty("customerSigningDate", today());
|
||||||
|
advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed",
|
||||||
|
strOr(input, "remarks", "Loan agreement signed by the customer."));
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public String confirmChequeAch(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "CHEQUE_ACH");
|
||||||
|
require(bool(app, "chequeReceived"), "Security cheques have not been received.");
|
||||||
|
require(bool(app, "achSigned"), "The ACH/NACH mandate has not been signed.");
|
||||||
|
|
||||||
|
advance(app, actor(input), "Cheque & ACH Received",
|
||||||
|
strOr(input, "remarks", "Security cheques received and ACH mandate signed."));
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final disbursement. Requires every approval level to actually stand as approved on the stored
|
||||||
|
* record — the last point at which an application could otherwise slip through with a rejected or
|
||||||
|
* still-pending sign-off.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String disburse(final String applicationNo, final String body) {
|
||||||
|
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||||
|
final JsonObject input = parseObject(body);
|
||||||
|
requireStage(app, "DISBURSED");
|
||||||
|
require(!"DISBURSED".equals(str(app, "overallStatus")), "This application has already been disbursed.");
|
||||||
|
|
||||||
|
for (final String field : List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval")) {
|
||||||
|
final JsonObject approval = obj(app, field);
|
||||||
|
final String decision = approval == null ? null : str(approval, "decision");
|
||||||
|
require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision),
|
||||||
|
"Cannot disburse: " + field + " is not approved.");
|
||||||
|
}
|
||||||
|
require(filled(app, "customerSigningDate"), "The customer has not signed the loan agreement.");
|
||||||
|
|
||||||
|
final String mode = strOr(input, "disbursementMode", "Account Credit");
|
||||||
|
app.addProperty("disbursementDate", today());
|
||||||
|
app.addProperty("disbursementMode", mode);
|
||||||
|
if (!filled(app, "achConfirmationRef")) {
|
||||||
|
final String suffix = applicationNo.length() >= 6 ? applicationNo.substring(applicationNo.length() - 6) : applicationNo;
|
||||||
|
app.addProperty("achConfirmationRef", "ACH" + suffix);
|
||||||
|
}
|
||||||
|
app.addProperty("emiActivationConfirmed", true);
|
||||||
|
app.addProperty("emiActivationDate", today());
|
||||||
|
app.addProperty("overallStatus", "DISBURSED");
|
||||||
|
if (LoanOriginationJson.present(input, "fineractLoanId")) {
|
||||||
|
app.addProperty("fineractLoanId", LoanOriginationJson.lng(input, "fineractLoanId"));
|
||||||
|
app.addProperty("fineractLoanAccountNo", str(input, "fineractLoanAccountNo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
appendHistory(app, "DISBURSED", actor(input), "Disbursed", strOr(input, "remarks",
|
||||||
|
"Disbursed via " + mode + " — ACH confirmation " + strOr(app, "achConfirmationRef", "—") + ". EMI activation confirmed."));
|
||||||
|
return pipelineService.storeApplication(applicationNo, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared checklist rules (mirror the Application Detail screen's gating) ─
|
||||||
|
|
||||||
|
private void requireFieldInvestigationComplete(final JsonObject app) {
|
||||||
|
requireCheckVerified(app, "addressVerification", "Address verification");
|
||||||
|
requireCheckVerified(app, "officeVerification", "Office/employment verification");
|
||||||
|
if (bool(app, "secured")) {
|
||||||
|
requireCheckVerified(app, "propertyVerification", "Property verification");
|
||||||
|
}
|
||||||
|
if (bool(app, "hasShop")) {
|
||||||
|
requireCheckVerified(app, "shopVerification", "Shop verification");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireCheckVerified(final JsonObject app, final String key, final String label) {
|
||||||
|
final JsonObject check = obj(app, key);
|
||||||
|
require(check != null && "VERIFIED".equals(str(check, "status")), label + " is not verified.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireCommitteeReportsReady(final JsonObject app, final JsonObject input) {
|
||||||
|
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")) {
|
||||||
|
require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done.");
|
||||||
|
}
|
||||||
|
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
||||||
|
require(bool(app, "panVerified"), "PAN has not been verified.");
|
||||||
|
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||||
|
requireFieldInvestigationComplete(app);
|
||||||
|
require(bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
|
||||||
|
require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required.");
|
||||||
|
require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireRiskChecklistReady(final JsonObject app) {
|
||||||
|
requireCheckVerified(app, "addressVerification", "Address verification");
|
||||||
|
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")) {
|
||||||
|
require(bool(app, "propertySearchReportDone"), "The property search report is not on file.");
|
||||||
|
require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed.");
|
||||||
|
}
|
||||||
|
requireFieldInvestigationComplete(app);
|
||||||
|
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||||
|
if (bool(app, "guarantorRequired")) {
|
||||||
|
// 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.
|
||||||
|
require(LoanOriginationJson.lng(app, "guarantorClientId") != null,
|
||||||
|
"A guarantor is required but no existing client has been selected.");
|
||||||
|
require(bool(app, "guarantorHasShareAccount"), "The selected guarantor has no active share account.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireLegalChecklistDone(final JsonObject app) {
|
||||||
|
require(bool(app, "feesAndAccountOpened"), "Fees and account opening are not complete.");
|
||||||
|
if (bool(app, "secured")) {
|
||||||
|
require(bool(app, "mortgageCreated"), "The mortgage has not been created.");
|
||||||
|
}
|
||||||
|
require(bool(app, "legalStampDone"), "Legal stamping is not complete.");
|
||||||
|
require(bool(app, "fileFilingDone"), "File filing is not complete.");
|
||||||
|
for (final JsonElement element : arr(app, "legalDocuments")) {
|
||||||
|
final JsonObject doc = element.getAsJsonObject();
|
||||||
|
require(bool(doc, "submitted"), "Legal document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "signedByCommittee"), "The committee has not signed off.");
|
||||||
|
require(bool(app, "signedByRiskApproval"), "Risk approval has not signed off.");
|
||||||
|
require(bool(app, "signedByLegalApproval"), "Legal approval has not signed off.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage / history mechanics ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void requireStage(final JsonObject app, final String expected) {
|
||||||
|
final String actual = str(app, "currentStage");
|
||||||
|
if (!expected.equals(actual)) {
|
||||||
|
throw badRequest("This action applies at the " + expected + " stage, but the application is at " + actual + ".");
|
||||||
|
}
|
||||||
|
final String status = str(app, "overallStatus");
|
||||||
|
if ("REJECTED".equals(status)) {
|
||||||
|
throw badRequest("This application has been rejected and cannot be progressed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the application to the next stage and appends the audit entry. {@code furthestStageIndex}
|
||||||
|
* only ever increases, so jumping back to an earlier stage for a correction does not make
|
||||||
|
* already-completed stages look unfinished.
|
||||||
|
*/
|
||||||
|
private void advance(final JsonObject app, final String actionBy, final String outcome, final String remarks) {
|
||||||
|
final String current = str(app, "currentStage");
|
||||||
|
appendHistory(app, current, actionBy, outcome, remarks);
|
||||||
|
|
||||||
|
final int index = STAGE_ORDER.indexOf(current);
|
||||||
|
if (index >= 0 && index < STAGE_ORDER.size() - 1) {
|
||||||
|
final int next = index + 1;
|
||||||
|
app.addProperty("currentStage", STAGE_ORDER.get(next));
|
||||||
|
app.addProperty("furthestStageIndex", Math.max(intOr(app, "furthestStageIndex", 0), next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendHistory(final JsonObject app, final String stage, final String actionBy, final String outcome,
|
||||||
|
final String remarks) {
|
||||||
|
final JsonArray history = arrOrCreate(app, "stageHistory");
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("stage", stage);
|
||||||
|
entry.addProperty("date", today());
|
||||||
|
entry.addProperty("actionBy", actionBy);
|
||||||
|
entry.addProperty("outcome", outcome);
|
||||||
|
entry.addProperty("remarks", remarks);
|
||||||
|
history.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String actor(final JsonObject input) {
|
||||||
|
return strOr(input, "actionBy", "Bank Staff");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String today() {
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
return (businessDate == null ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) : businessDate).format(WORKFLOW_DATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void require(final boolean condition, final String message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw badRequest(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GeneralPlatformDomainRuleException badRequest(final String message) {
|
||||||
|
return new GeneralPlatformDomainRuleException("error.msg.loan.origination.stage.invalid", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kept for symmetry with the check-key list above; used by callers validating a key up front. */
|
||||||
|
public static boolean isKnownFieldCheck(final String checkKey) {
|
||||||
|
return FI_CHECK_KEYS.contains(checkKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposed so the API layer can advertise the stage order without duplicating it. */
|
||||||
|
public static List<String> stageOrder() {
|
||||||
|
return Arrays.asList(STAGE_ORDER.toArray(new String[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
-4
@@ -18,6 +18,8 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.mobilelookup.api;
|
package org.apache.fineract.portfolio.mobilelookup.api;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
@@ -26,11 +28,16 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
@@ -63,6 +70,8 @@ public class MobileLookupApiResource {
|
|||||||
private final MobileLookupService mobileLookupService;
|
private final MobileLookupService mobileLookupService;
|
||||||
private final DefaultToApiJsonSerializer<MobileLookupResult> resultSerializer;
|
private final DefaultToApiJsonSerializer<MobileLookupResult> resultSerializer;
|
||||||
private final DefaultToApiJsonSerializer<List> listSerializer;
|
private final DefaultToApiJsonSerializer<List> listSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<MobileLookupLogData> logSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<Map> mapSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/check")
|
@Path("/check")
|
||||||
@@ -80,6 +89,20 @@ public class MobileLookupApiResource {
|
|||||||
@QueryParam("mobileNumber") @Parameter(description = "Mobile number to check (10-15 digits)") final String mobileNumber) {
|
@QueryParam("mobileNumber") @Parameter(description = "Mobile number to check (10-15 digits)") final String mobileNumber) {
|
||||||
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
|
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
|
||||||
final MobileLookupResult result = mobileLookupService.checkMobileNumber(mobileNumber);
|
final MobileLookupResult result = mobileLookupService.checkMobileNumber(mobileNumber);
|
||||||
|
|
||||||
|
// Surface any earlier rejection of the same number so the officer screening it today is
|
||||||
|
// not the only one without that history.
|
||||||
|
final MobileLookupLog priorRejection = mobileLookupService.findPriorRejection(mobileNumber);
|
||||||
|
if (priorRejection != null && !priorRejection.getId().equals(result.getLookupLogId())) {
|
||||||
|
final Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("lookupLogId", result.getLookupLogId());
|
||||||
|
payload.put("success", result.isSuccess());
|
||||||
|
payload.put("message", result.getMessage());
|
||||||
|
payload.put("data", result.getData());
|
||||||
|
payload.put("timestamp", result.getTimestamp());
|
||||||
|
payload.put("priorRejection", MobileLookupLogData.fromEntity(priorRejection));
|
||||||
|
return mapSerializer.serialize(payload);
|
||||||
|
}
|
||||||
return resultSerializer.serialize(result);
|
return resultSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +130,77 @@ public class MobileLookupApiResource {
|
|||||||
logs = mobileLookupService.getLookupLogs();
|
logs = mobileLookupService.getLookupLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<MobileLookupLogData> logDataList = logs.stream()
|
return listSerializer.serialize(withClientNames(logs));
|
||||||
.map(MobileLookupLogData::fromEntity)
|
}
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
return listSerializer.serialize(logDataList);
|
/**
|
||||||
|
* Attaches the client's display name and account number to each row. The log stores only the
|
||||||
|
* client id, and a queue of bare ids is not something a reviewing officer can act on.
|
||||||
|
*/
|
||||||
|
private List<MobileLookupLogData> withClientNames(final List<MobileLookupLog> logs) {
|
||||||
|
final Set<Long> clientIds = logs.stream()
|
||||||
|
.map(MobileLookupLog::getClientId)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
final Map<Long, String[]> names = mobileLookupService.resolveClientNames(clientIds);
|
||||||
|
|
||||||
|
return logs.stream().map(log -> {
|
||||||
|
final String[] client = log.getClientId() == null ? null : names.get(log.getClientId());
|
||||||
|
return MobileLookupLogData.fromEntity(log, client == null ? null : client[0],
|
||||||
|
client == null ? null : client[1]);
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/pending")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Lookups awaiting an officer's decision",
|
||||||
|
description = "SUSPECTED or DISCONNECTED numbers on which no PROCEEDED/REJECTED decision "
|
||||||
|
+ "has been recorded yet.")
|
||||||
|
public String getPendingDecisions() {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
|
||||||
|
|
||||||
|
return listSerializer.serialize(withClientNames(mobileLookupService.getPendingDecisions()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/logs/{logId}/decision")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Record the officer's decision on a flagged number",
|
||||||
|
description = "Body: {\"decision\": \"PROCEEDED|REJECTED\", \"reason\": \"...\"}. "
|
||||||
|
+ "A reason is mandatory when proceeding despite a DIP/MNRL hit.")
|
||||||
|
public String recordDecision(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("OVERRIDE_MOBILE_LOOKUP");
|
||||||
|
|
||||||
|
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
|
||||||
|
final String decision = json.has("decision") ? json.get("decision").getAsString() : null;
|
||||||
|
final String reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||||
|
|
||||||
|
final MobileLookupLogData result = MobileLookupLogData.fromEntity(mobileLookupService
|
||||||
|
.recordDecision(logId, decision, reason, this.context.authenticatedUser().getId()));
|
||||||
|
return logSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/rescreen")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Re-screen existing clients' mobile numbers against DIP/MNRL",
|
||||||
|
description = "Screens every active client's mobile and alternate mobile numbers, opening a "
|
||||||
|
+ "decision row for each hit. Detects numbers reported for fraud or reassigned after "
|
||||||
|
+ "the customer was onboarded.")
|
||||||
|
public String rescreenExistingClients() {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("OVERRIDE_MOBILE_LOOKUP");
|
||||||
|
|
||||||
|
final int flagged = mobileLookupService.rescreenExistingClients();
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("message", "DIP/MNRL re-screening complete");
|
||||||
|
response.put("numbersFlagged", flagged);
|
||||||
|
// Reported so "0 flagged" can be told apart from "0 clients were even looked at" — the
|
||||||
|
// two mean very different things and the caller could not previously distinguish them.
|
||||||
|
response.put("clientsScanned", mobileLookupService.countScreenableClients());
|
||||||
|
return mapSerializer.serialize(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -34,9 +34,26 @@ public class MobileLookupLogData {
|
|||||||
private final String responseMessage;
|
private final String responseMessage;
|
||||||
private final String lookupSource;
|
private final String lookupSource;
|
||||||
private final Long clientId;
|
private final Long clientId;
|
||||||
|
/**
|
||||||
|
* Resolved separately from the log row, which stores only the id. A queue showing a bare
|
||||||
|
* "145" gives the reviewing officer nothing to act on — they need to know who it is.
|
||||||
|
*/
|
||||||
|
private final String clientName;
|
||||||
|
private final String clientAccountNo;
|
||||||
private final String lookedUpOn;
|
private final String lookedUpOn;
|
||||||
|
private final String overrideDecision;
|
||||||
|
private final String overrideReason;
|
||||||
|
private final Long decidedBy;
|
||||||
|
private final String decidedOn;
|
||||||
|
/** Whether this lookup permits the number to be used on a client record. */
|
||||||
|
private final boolean usable;
|
||||||
|
|
||||||
public static MobileLookupLogData fromEntity(final MobileLookupLog log) {
|
public static MobileLookupLogData fromEntity(final MobileLookupLog log) {
|
||||||
|
return fromEntity(log, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MobileLookupLogData fromEntity(final MobileLookupLog log, final String clientName,
|
||||||
|
final String clientAccountNo) {
|
||||||
return new MobileLookupLogData(
|
return new MobileLookupLogData(
|
||||||
log.getId(),
|
log.getId(),
|
||||||
log.getMobileNumber(),
|
log.getMobileNumber(),
|
||||||
@@ -46,6 +63,13 @@ public class MobileLookupLogData {
|
|||||||
log.getResponseMessage(),
|
log.getResponseMessage(),
|
||||||
log.getLookupSource(),
|
log.getLookupSource(),
|
||||||
log.getClientId(),
|
log.getClientId(),
|
||||||
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null);
|
clientName,
|
||||||
|
clientAccountNo,
|
||||||
|
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null,
|
||||||
|
log.getOverrideDecision(),
|
||||||
|
log.getOverrideReason(),
|
||||||
|
log.getDecidedBy(),
|
||||||
|
log.getDecidedOn() != null ? log.getDecidedOn().toString() : null,
|
||||||
|
log.isUsable());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
@@ -33,4 +33,65 @@ public interface MobileLookupService {
|
|||||||
List<MobileLookupLog> getLookupLogsByMobileNumber(String mobileNumber);
|
List<MobileLookupLog> getLookupLogsByMobileNumber(String mobileNumber);
|
||||||
|
|
||||||
List<MobileLookupLog> getLookupLogsByStatus(String status);
|
List<MobileLookupLog> getLookupLogsByStatus(String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the officer's decision on a lookup that came back SUSPECTED or DISCONNECTED.
|
||||||
|
*
|
||||||
|
* @param decision PROCEEDED or REJECTED
|
||||||
|
* @param reason mandatory when proceeding — why the number is acceptable anyway
|
||||||
|
*/
|
||||||
|
MobileLookupLog recordDecision(Long logId, String decision, String reason, Long userId);
|
||||||
|
|
||||||
|
/** Non-clean lookups still awaiting a decision. */
|
||||||
|
List<MobileLookupLog> getPendingDecisions();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the number's latest lookup permits it to be stored on a client — clean, or a hit
|
||||||
|
* the officer explicitly decided to proceed with. A DoT outage is not a pass.
|
||||||
|
*/
|
||||||
|
boolean isMobileNumberUsable(String mobileNumber);
|
||||||
|
|
||||||
|
/** Latest lookup status for the number, or null when it has never been checked. */
|
||||||
|
String getLatestStatus(String mobileNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent time this number was rejected, on any client, or null if never. A number
|
||||||
|
* turned away once should not be screened as if it had no history.
|
||||||
|
*/
|
||||||
|
MobileLookupLog findPriorRejection(String mobileNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-screens every active client's mobile numbers against DIP/MNRL, opening a decision row
|
||||||
|
* for each hit.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Onboarding-time screening only ever sees a number as it is typed. A number reported for
|
||||||
|
* fraud, or disconnected and reassigned, after the customer was onboarded is otherwise never
|
||||||
|
* detected — and that is precisely the case where the bank's OTPs start reaching a stranger.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return count of numbers that came back flagged
|
||||||
|
*/
|
||||||
|
int rescreenExistingClients();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name and account number for the given client ids, keyed by id. Resolved in one
|
||||||
|
* query so a queue of lookups does not issue a round trip per row.
|
||||||
|
*/
|
||||||
|
java.util.Map<Long, String[]> resolveClientNames(java.util.Collection<Long> clientIds);
|
||||||
|
|
||||||
|
/** How many clients the re-screen sweep would look at — lets "0 flagged" be interpreted. */
|
||||||
|
int countScreenableClients();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks every open alert on a number as resolved, recording why.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Called when the number is replaced on a client: the alert then refers to a number the bank
|
||||||
|
* no longer uses, and leaving it "Awaiting Decision" would misrepresent it as outstanding work.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return how many alerts were closed
|
||||||
|
*/
|
||||||
|
int closeOpenAlerts(String mobileNumber, Long clientId, String reason);
|
||||||
}
|
}
|
||||||
|
|||||||
+256
-4
@@ -21,8 +21,13 @@ package org.apache.fineract.portfolio.mobilelookup.service;
|
|||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupDetailData;
|
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupDetailData;
|
||||||
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult;
|
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult;
|
||||||
import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog;
|
import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog;
|
||||||
@@ -30,6 +35,8 @@ import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogReposito
|
|||||||
import org.slf4j.Logger;
|
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.data.domain.PageRequest;
|
||||||
|
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;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
@@ -50,15 +57,26 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
"9752123456", "9752345678", "9752987654"
|
"9752123456", "9752345678", "9752987654"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Demo numbers that always return DISCONNECTED (found on the MNRL) without calling the external API. */
|
||||||
|
private static final Set<String> DEMO_DISCONNECTED_NUMBERS = Set.of("9000000001", "9000000002");
|
||||||
|
|
||||||
|
/** Demo numbers that always return SUSPECTED_AND_DISCONNECTED without calling the external API. */
|
||||||
|
private static final Set<String> DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS = Set.of("9000000003");
|
||||||
|
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
|
private final boolean enabled;
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final MobileLookupLogRepository logRepository;
|
private final MobileLookupLogRepository logRepository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
|
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
|
||||||
MobileLookupLogRepository logRepository) {
|
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
|
||||||
|
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this.enabled = enabled;
|
||||||
this.restTemplate = new RestTemplate();
|
this.restTemplate = new RestTemplate();
|
||||||
this.logRepository = logRepository;
|
this.logRepository = logRepository;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -94,6 +112,39 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
return MobileLookupResult.clean(logId, trimmed);
|
return MobileLookupResult.clean(logId, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Demo numbers — return DISCONNECTED (on the MNRL) without calling the external API
|
||||||
|
if (isDemoDisconnectedNumber(trimmed)) {
|
||||||
|
LOG.info("Demo disconnected number detected: {}", trimmed);
|
||||||
|
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
|
||||||
|
"Number revoked and listed on MNRL (demo data)", "DEMO");
|
||||||
|
final Long logId = saveLog(trimmed, "DISCONNECTED", false, true,
|
||||||
|
"Number is DISCONNECTED — found in MNRL records", null, source, clientId);
|
||||||
|
return MobileLookupResult.disconnected(logId, trimmed, disconnectedDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo numbers — return SUSPECTED_AND_DISCONNECTED without calling the external API
|
||||||
|
if (isDemoSuspectedAndDisconnectedNumber(trimmed)) {
|
||||||
|
LOG.info("Demo suspected+disconnected number detected: {}", trimmed);
|
||||||
|
final MobileLookupDetailData suspectedDetail = MobileLookupDetailData.forSuspected(
|
||||||
|
"active", "Demo DIP suspected number", "DEMO");
|
||||||
|
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
|
||||||
|
"Number revoked and listed on MNRL (demo data)", "DEMO");
|
||||||
|
final Long logId = saveLog(trimmed, "SUSPECTED_AND_DISCONNECTED", true, true,
|
||||||
|
"Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records", null, source, clientId);
|
||||||
|
return MobileLookupResult.suspectedAndDisconnected(logId, trimmed, suspectedDetail, disconnectedDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup switched off by config (fineract.mobile-lookup.enabled=false) -- skip the external
|
||||||
|
// call entirely rather than fail with a connection error. Logged distinctly from a genuine
|
||||||
|
// CLEAN result so it can never be mistaken for one in an audit trail.
|
||||||
|
if (!enabled) {
|
||||||
|
LOG.info("DIP/MNRL lookup is disabled by configuration — skipping check for {}", trimmed);
|
||||||
|
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_CLEAN, false, false,
|
||||||
|
"DIP/MNRL lookup is disabled by configuration — the number was not actually checked.",
|
||||||
|
"{\"provider\":\"LOOKUP_DISABLED_BY_CONFIG\"}", source, clientId);
|
||||||
|
return MobileLookupResult.clean(logId, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed;
|
final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed;
|
||||||
|
|
||||||
LOG.info("Calling DIP/MNRL lookup API: {}", url);
|
LOG.info("Calling DIP/MNRL lookup API: {}", url);
|
||||||
@@ -113,13 +164,194 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
parsed.getData(), parsed.getTimestamp());
|
parsed.getData(), parsed.getTimestamp());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to call DIP/MNRL lookup API for {}: {}", trimmed, e.getMessage());
|
LOG.error("Failed to call DIP/MNRL lookup API for {}: {}", trimmed, e.getMessage());
|
||||||
final Long logId = saveLog(trimmed, "ERROR", false, false,
|
// An unreachable DoT service means the number was NOT checked. It is logged under its
|
||||||
e.getMessage(), rawResponse, source, clientId);
|
// own status rather than ERROR so the queue can distinguish "we could not verify" from
|
||||||
|
// "the lookup itself failed", and it is never presented to the officer as a pass.
|
||||||
|
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_SERVICE_UNAVAILABLE, false, false,
|
||||||
|
"DIP/MNRL lookup service is unavailable — the number could not be verified.",
|
||||||
|
rawResponse, source, clientId);
|
||||||
return MobileLookupResult.serviceUnavailable(logId, trimmed,
|
return MobileLookupResult.serviceUnavailable(logId, trimmed,
|
||||||
"DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.");
|
"DIP/MNRL lookup service is unavailable — the number could not be verified.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public MobileLookupLog recordDecision(final Long logId, final String decision, final String reason,
|
||||||
|
final Long userId) {
|
||||||
|
final MobileLookupLog log = logRepository.findById(logId)
|
||||||
|
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.not.found",
|
||||||
|
"Mobile lookup " + logId + " does not exist."));
|
||||||
|
|
||||||
|
final String normalised = decision == null ? "" : decision.trim().toUpperCase();
|
||||||
|
if (!MobileLookupLog.DECISION_PROCEEDED.equals(normalised)
|
||||||
|
&& !MobileLookupLog.DECISION_REJECTED.equals(normalised)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.decision.invalid",
|
||||||
|
"Decision must be PROCEEDED or REJECTED.");
|
||||||
|
}
|
||||||
|
// Proceeding past a DIP hit is the judgement call that has to survive an audit, so it
|
||||||
|
// cannot be recorded without the officer's reasoning. Rejecting needs no justification.
|
||||||
|
if (MobileLookupLog.DECISION_PROCEEDED.equals(normalised) && (reason == null || reason.trim().length() < 10)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.reason.required",
|
||||||
|
"Record why this number is acceptable despite the DIP/MNRL result (minimum 10 characters).");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.recordDecision(normalised, reason == null ? null : reason.trim(), userId);
|
||||||
|
logRepository.saveAndFlush(log);
|
||||||
|
LOG.warn("Mobile lookup {} for {} decided as {}", logId, log.getMobileNumber(), normalised);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<MobileLookupLog> getPendingDecisions() {
|
||||||
|
return logRepository.findPendingDecisions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isMobileNumberUsable(final String mobileNumber) {
|
||||||
|
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final MobileLookupLog latest = findLatest(mobileNumber.trim());
|
||||||
|
// No lookup on record is handled by the caller (it decides whether a check is mandatory);
|
||||||
|
// here only an actual result can veto.
|
||||||
|
return latest == null || latest.isUsable();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getLatestStatus(final String mobileNumber) {
|
||||||
|
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final MobileLookupLog latest = findLatest(mobileNumber.trim());
|
||||||
|
return latest == null ? null : latest.getLookupStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest lookup row for a number, or null when it has never been checked. */
|
||||||
|
private MobileLookupLog findLatest(final String mobileNumber) {
|
||||||
|
final List<MobileLookupLog> rows = logRepository.findLatestByMobileNumber(mobileNumber, PageRequest.of(0, 1));
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int rescreenExistingClients() {
|
||||||
|
// Active clients only (status 300). A pending client is still inside the onboarding flow,
|
||||||
|
// where the number is screened as it is entered; this sweep exists for the customers that
|
||||||
|
// flow has already finished with. Closed/rejected/withdrawn have no live relationship.
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, mobile_no, alternate_mobile_no FROM m_client WHERE status_enum = 300 "
|
||||||
|
+ "AND (mobile_no IS NOT NULL OR alternate_mobile_no IS NOT NULL)");
|
||||||
|
|
||||||
|
LOG.info("DIP/MNRL re-screening starting for {} client(s) with a number on file", rows.size());
|
||||||
|
|
||||||
|
int flagged = 0;
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final Long clientId = ((Number) row.get("id")).longValue();
|
||||||
|
flagged += rescreenNumber((String) row.get("mobile_no"), clientId);
|
||||||
|
flagged += rescreenNumber((String) row.get("alternate_mobile_no"), clientId);
|
||||||
|
}
|
||||||
|
LOG.info("DIP/MNRL re-screening complete: {} number(s) flagged across {} clients", flagged, rows.size());
|
||||||
|
return flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public int countScreenableClients() {
|
||||||
|
final Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_client WHERE status_enum = 300 "
|
||||||
|
+ "AND (mobile_no IS NOT NULL OR alternate_mobile_no IS NOT NULL)",
|
||||||
|
Integer.class);
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screens one number, tolerating per-number failure so one bad row cannot abort the sweep. */
|
||||||
|
private int rescreenNumber(final String mobileNumber, final Long clientId) {
|
||||||
|
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final String trimmed = mobileNumber.trim();
|
||||||
|
|
||||||
|
// Re-running the sweep must not stack another identical alert onto the queue. An open
|
||||||
|
// alert for this number and client is the same case still awaiting the same decision;
|
||||||
|
// duplicating it buries the real work under repeats of itself. A number already decided
|
||||||
|
// is likewise left alone until its value or decision changes.
|
||||||
|
final MobileLookupLog existing = findLatest(trimmed);
|
||||||
|
if (existing != null && existing.requiresDecision() && java.util.Objects.equals(existing.getClientId(), clientId)) {
|
||||||
|
LOG.debug("Skipping re-screen of {} for client {} — an open alert already exists", trimmed, clientId);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final MobileLookupResult result = checkMobileNumber(trimmed, "PERIODIC_RESCREEN", clientId);
|
||||||
|
final boolean hit = result.getData() != null
|
||||||
|
&& (result.getData().isSuspected() || result.getData().isDisconnected());
|
||||||
|
return hit ? 1 : 0;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Re-screening failed for {} (client {}): {}", mobileNumber, clientId, e.getMessage());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<Long, String[]> resolveClientNames(final Collection<Long> clientIds) {
|
||||||
|
final Map<Long, String[]> byId = new HashMap<>();
|
||||||
|
if (clientIds == null || clientIds.isEmpty()) {
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, display_name, account_no FROM m_client WHERE id IN (" + placeholders + ")",
|
||||||
|
clientIds.toArray());
|
||||||
|
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final Long id = ((Number) row.get("id")).longValue();
|
||||||
|
byId.put(id, new String[] { (String) row.get("display_name"), (String) row.get("account_no") });
|
||||||
|
}
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int closeOpenAlerts(final String mobileNumber, final Long clientId, final String reason) {
|
||||||
|
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final List<MobileLookupLog> open = logRepository.findByMobileNumber(mobileNumber.trim()).stream()
|
||||||
|
.filter(MobileLookupLog::requiresDecision)
|
||||||
|
.filter(log -> log.getOverrideDecision() == null)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
for (final MobileLookupLog log : open) {
|
||||||
|
// REJECTED is the correct terminal state: the bank turned this number away. It also
|
||||||
|
// keeps isMobileNumberUsable() false, so the number cannot quietly come back into use.
|
||||||
|
log.recordDecision(MobileLookupLog.DECISION_REJECTED, reason, null);
|
||||||
|
if (log.getClientId() == null && clientId != null) {
|
||||||
|
log.setClientId(clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!open.isEmpty()) {
|
||||||
|
logRepository.saveAll(open);
|
||||||
|
logRepository.flush();
|
||||||
|
LOG.info("Closed {} open DIP alert(s) for replaced number {} on client {}", open.size(), mobileNumber, clientId);
|
||||||
|
}
|
||||||
|
return open.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MobileLookupLog findPriorRejection(final String mobileNumber) {
|
||||||
|
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final List<MobileLookupLog> rejections = logRepository.findRejections(mobileNumber.trim(), PageRequest.of(0, 1));
|
||||||
|
return rejections.isEmpty() ? null : rejections.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<MobileLookupLog> getLookupLogs() {
|
public List<MobileLookupLog> getLookupLogs() {
|
||||||
@@ -212,6 +444,26 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isDemoDisconnectedNumber(final String mobileNumber) {
|
||||||
|
if (DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
|
||||||
|
return DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDemoSuspectedAndDisconnectedNumber(final String mobileNumber) {
|
||||||
|
if (DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
|
||||||
|
return DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private String getStringOrNull(JsonObject json, String key) {
|
private String getStringOrNull(JsonObject json, String key) {
|
||||||
JsonElement el = json.get(key);
|
JsonElement el = json.get(key);
|
||||||
return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
|
return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
|
||||||
|
|||||||
+121
-4
@@ -28,6 +28,7 @@ import jakarta.ws.rs.Consumes;
|
|||||||
import jakarta.ws.rs.GET;
|
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.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
@@ -35,6 +36,7 @@ import java.io.InputStream;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
@@ -77,6 +79,7 @@ public class SanctionsScreeningApiResource {
|
|||||||
private final DefaultToApiJsonSerializer<SanctionsScreeningResult> screeningResultSerializer;
|
private final DefaultToApiJsonSerializer<SanctionsScreeningResult> screeningResultSerializer;
|
||||||
private final DefaultToApiJsonSerializer<Map> mapSerializer;
|
private final DefaultToApiJsonSerializer<Map> mapSerializer;
|
||||||
private final DefaultToApiJsonSerializer<List> listSerializer;
|
private final DefaultToApiJsonSerializer<List> listSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<SanctionsScreeningLogData> logSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/screen")
|
@Path("/screen")
|
||||||
@@ -184,10 +187,124 @@ public class SanctionsScreeningApiResource {
|
|||||||
logs = sanctionsScreeningService.getScreeningLogs();
|
logs = sanctionsScreeningService.getScreeningLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<SanctionsScreeningLogData> logDataList = logs.stream()
|
return listSerializer.serialize(withClientNames(logs));
|
||||||
.map(SanctionsScreeningLogData::fromEntity)
|
}
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
return listSerializer.serialize(logDataList);
|
/**
|
||||||
|
* Attaches the client's display name and account number to each row. The log stores only the
|
||||||
|
* client id, and a queue of bare ids is not something a reviewing officer can act on.
|
||||||
|
*/
|
||||||
|
private List<SanctionsScreeningLogData> withClientNames(final List<SanctionsScreeningLog> logs) {
|
||||||
|
final Set<Long> clientIds = logs.stream().map(SanctionsScreeningLog::getClientId).filter(java.util.Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
final Map<Long, String[]> names = sanctionsScreeningService.resolveClientNames(clientIds);
|
||||||
|
|
||||||
|
return logs.stream().map(log -> {
|
||||||
|
final String[] client = log.getClientId() == null ? null : names.get(log.getClientId());
|
||||||
|
return SanctionsScreeningLogData.fromEntity(log, client == null ? null : client[0],
|
||||||
|
client == null ? null : client[1]);
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||||
|
// A flagged screening is a case, not a verdict. These endpoints close it out: the reviewing
|
||||||
|
// officer records whether it is the same person, the Principal Officer signs off, and a
|
||||||
|
// confirmed match carries the FIU-IND / nodal officer reporting references.
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/alerts")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List sanctions alerts",
|
||||||
|
description = "Flagged screenings and their disposition state. Filter with "
|
||||||
|
+ "?status=PENDING_REVIEW | FALSE_POSITIVE | CONFIRMED_MATCH.")
|
||||||
|
public String getAlerts(
|
||||||
|
@QueryParam("status") @Parameter(description = "Filter by disposition") final String status) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
|
||||||
|
|
||||||
|
return listSerializer.serialize(withClientNames(sanctionsScreeningService.getAlerts(status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/alerts/{logId}/disposition")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Record the review outcome of a sanctions alert",
|
||||||
|
description = "Body: {\"disposition\": \"FALSE_POSITIVE|CONFIRMED_MATCH\", \"reason\": \"...\"}. "
|
||||||
|
+ "A false positive does not unblock onboarding until it is approved.")
|
||||||
|
public String recordDisposition(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("DISPOSE_SANCTIONS");
|
||||||
|
|
||||||
|
final com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
|
||||||
|
final String disposition = json.has("disposition") ? json.get("disposition").getAsString() : null;
|
||||||
|
final String reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||||
|
|
||||||
|
final SanctionsScreeningLogData result = SanctionsScreeningLogData
|
||||||
|
.fromEntity(sanctionsScreeningService.recordDisposition(logId, disposition, reason));
|
||||||
|
return logSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/alerts/{logId}/approve")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Principal Officer approval of an alert disposition",
|
||||||
|
description = "Approving a FALSE_POSITIVE clears the name for onboarding. Approving a "
|
||||||
|
+ "CONFIRMED_MATCH freezes debits on every active deposit account of the client.")
|
||||||
|
public String approveAlert(@PathParam("logId") final Long logId) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("APPROVE_SANCTIONS");
|
||||||
|
|
||||||
|
final SanctionsScreeningLogData result = SanctionsScreeningLogData
|
||||||
|
.fromEntity(sanctionsScreeningService.approveDisposition(logId));
|
||||||
|
return logSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/alerts/{logId}/report")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Record FIU-IND / nodal officer reporting for a confirmed match",
|
||||||
|
description = "Body: {\"fiuStrReference\": \"...\", \"nodalOfficerInformedOn\": \"yyyy-MM-dd\"}")
|
||||||
|
public String recordReporting(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("APPROVE_SANCTIONS");
|
||||||
|
|
||||||
|
final com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
|
||||||
|
final String strReference = json.has("fiuStrReference") ? json.get("fiuStrReference").getAsString() : null;
|
||||||
|
final String informedOn = json.has("nodalOfficerInformedOn") ? json.get("nodalOfficerInformedOn").getAsString() : null;
|
||||||
|
|
||||||
|
final SanctionsScreeningLogData result = SanctionsScreeningLogData
|
||||||
|
.fromEntity(sanctionsScreeningService.recordReporting(logId, strReference, informedOn));
|
||||||
|
return logSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/str-cases")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Sanctions STR cases for the FIU-IND screen",
|
||||||
|
description = "Confirmed matches carrying a reporting obligation. Filter with "
|
||||||
|
+ "?status=PENDING_FILING | FILED. Kept separate from transaction-driven AML alerts.")
|
||||||
|
public String getStrCases(
|
||||||
|
@QueryParam("status") @Parameter(description = "PENDING_FILING or FILED") final String status) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
|
||||||
|
|
||||||
|
return listSerializer.serialize(withClientNames(sanctionsScreeningService.getStrCases(status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/rescreen")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Re-screen all existing clients against the current list snapshot",
|
||||||
|
description = "Opens a PENDING_REVIEW alert for every existing client that now matches. "
|
||||||
|
+ "This is what detects a customer added to the list after onboarding.")
|
||||||
|
public String rescreenExistingClients() {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("LOAD_SANCTIONS");
|
||||||
|
|
||||||
|
final int flagged = sanctionsScreeningService.rescreenExistingClients();
|
||||||
|
final Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("message", "Re-screening complete");
|
||||||
|
response.put("clientsFlagged", flagged);
|
||||||
|
return mapSerializer.serialize(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-3
@@ -34,14 +34,38 @@ public final class SanctionsScreeningLogData {
|
|||||||
private final String matchedNames;
|
private final String matchedNames;
|
||||||
private final String screeningSource;
|
private final String screeningSource;
|
||||||
private final Long clientId;
|
private final Long clientId;
|
||||||
|
/**
|
||||||
|
* Resolved separately from the log row, which stores only the id. A queue showing a bare
|
||||||
|
* "124" gives the reviewing officer nothing to act on — they need to know who it is.
|
||||||
|
*/
|
||||||
|
private final String clientName;
|
||||||
|
private final String clientAccountNo;
|
||||||
private final String ipAddress;
|
private final String ipAddress;
|
||||||
private final OffsetDateTime screenedOn;
|
private final OffsetDateTime screenedOn;
|
||||||
private final Long screenedBy;
|
private final Long screenedBy;
|
||||||
|
private final String disposition;
|
||||||
|
private final String dispositionReason;
|
||||||
|
private final Long reviewedBy;
|
||||||
|
private final OffsetDateTime reviewedOn;
|
||||||
|
private final Long approvedBy;
|
||||||
|
private final OffsetDateTime approvedOn;
|
||||||
|
private final String fiuStrReference;
|
||||||
|
private final OffsetDateTime nodalOfficerInformedOn;
|
||||||
|
private final String frozenAccountIds;
|
||||||
|
private final OffsetDateTime frozenOn;
|
||||||
|
private final String strStatus;
|
||||||
|
private final String strDueDate;
|
||||||
|
private final String strRaisedOn;
|
||||||
|
|
||||||
public SanctionsScreeningLogData(final Long id, final String screenedName, final String screeningResult,
|
public SanctionsScreeningLogData(final Long id, final String screenedName, final String screeningResult,
|
||||||
final Integer matchCount, final String matchedEntryIds, final String matchedNames,
|
final Integer matchCount, final String matchedEntryIds, final String matchedNames,
|
||||||
final String screeningSource, final Long clientId, final String ipAddress,
|
final String screeningSource, final Long clientId, final String clientName, final String clientAccountNo,
|
||||||
final OffsetDateTime screenedOn, final Long screenedBy) {
|
final String ipAddress,
|
||||||
|
final OffsetDateTime screenedOn, final Long screenedBy, final String disposition,
|
||||||
|
final String dispositionReason, final Long reviewedBy, final OffsetDateTime reviewedOn,
|
||||||
|
final Long approvedBy, final OffsetDateTime approvedOn, final String fiuStrReference,
|
||||||
|
final OffsetDateTime nodalOfficerInformedOn, final String frozenAccountIds,
|
||||||
|
final OffsetDateTime frozenOn, final String strStatus, final String strDueDate, final String strRaisedOn) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.screenedName = screenedName;
|
this.screenedName = screenedName;
|
||||||
this.screeningResult = screeningResult;
|
this.screeningResult = screeningResult;
|
||||||
@@ -50,12 +74,32 @@ public final class SanctionsScreeningLogData {
|
|||||||
this.matchedNames = matchedNames;
|
this.matchedNames = matchedNames;
|
||||||
this.screeningSource = screeningSource;
|
this.screeningSource = screeningSource;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
|
this.clientName = clientName;
|
||||||
|
this.clientAccountNo = clientAccountNo;
|
||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
this.screenedOn = screenedOn;
|
this.screenedOn = screenedOn;
|
||||||
this.screenedBy = screenedBy;
|
this.screenedBy = screenedBy;
|
||||||
|
this.disposition = disposition;
|
||||||
|
this.dispositionReason = dispositionReason;
|
||||||
|
this.reviewedBy = reviewedBy;
|
||||||
|
this.reviewedOn = reviewedOn;
|
||||||
|
this.approvedBy = approvedBy;
|
||||||
|
this.approvedOn = approvedOn;
|
||||||
|
this.fiuStrReference = fiuStrReference;
|
||||||
|
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
||||||
|
this.frozenAccountIds = frozenAccountIds;
|
||||||
|
this.frozenOn = frozenOn;
|
||||||
|
this.strStatus = strStatus;
|
||||||
|
this.strDueDate = strDueDate;
|
||||||
|
this.strRaisedOn = strRaisedOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log) {
|
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log) {
|
||||||
|
return fromEntity(log, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log, final String clientName,
|
||||||
|
final String clientAccountNo) {
|
||||||
return new SanctionsScreeningLogData(
|
return new SanctionsScreeningLogData(
|
||||||
log.getId(),
|
log.getId(),
|
||||||
log.getScreenedName(),
|
log.getScreenedName(),
|
||||||
@@ -65,9 +109,24 @@ public final class SanctionsScreeningLogData {
|
|||||||
log.getMatchedNames(),
|
log.getMatchedNames(),
|
||||||
log.getScreeningSource(),
|
log.getScreeningSource(),
|
||||||
log.getClientId(),
|
log.getClientId(),
|
||||||
|
clientName,
|
||||||
|
clientAccountNo,
|
||||||
log.getIpAddress(),
|
log.getIpAddress(),
|
||||||
log.getScreenedOn(),
|
log.getScreenedOn(),
|
||||||
log.getCreatedBy().orElse(null)
|
log.getCreatedBy().orElse(null),
|
||||||
|
log.getDisposition(),
|
||||||
|
log.getDispositionReason(),
|
||||||
|
log.getReviewedBy(),
|
||||||
|
log.getReviewedOn(),
|
||||||
|
log.getApprovedBy(),
|
||||||
|
log.getApprovedOn(),
|
||||||
|
log.getFiuStrReference(),
|
||||||
|
log.getNodalOfficerInformedOn(),
|
||||||
|
log.getFrozenAccountIds(),
|
||||||
|
log.getFrozenOn(),
|
||||||
|
log.getStrStatus(),
|
||||||
|
log.getStrDueDate() != null ? log.getStrDueDate().toString() : null,
|
||||||
|
log.getStrRaisedOn() != null ? log.getStrRaisedOn().toString() : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +138,22 @@ public final class SanctionsScreeningLogData {
|
|||||||
public String getMatchedNames() { return matchedNames; }
|
public String getMatchedNames() { return matchedNames; }
|
||||||
public String getScreeningSource() { return screeningSource; }
|
public String getScreeningSource() { return screeningSource; }
|
||||||
public Long getClientId() { return clientId; }
|
public Long getClientId() { return clientId; }
|
||||||
|
public String getClientName() { return clientName; }
|
||||||
|
public String getClientAccountNo() { return clientAccountNo; }
|
||||||
public String getIpAddress() { return ipAddress; }
|
public String getIpAddress() { return ipAddress; }
|
||||||
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
||||||
public Long getScreenedBy() { return screenedBy; }
|
public Long getScreenedBy() { return screenedBy; }
|
||||||
|
public String getDisposition() { return disposition; }
|
||||||
|
public String getDispositionReason() { return dispositionReason; }
|
||||||
|
public Long getReviewedBy() { return reviewedBy; }
|
||||||
|
public OffsetDateTime getReviewedOn() { return reviewedOn; }
|
||||||
|
public Long getApprovedBy() { return approvedBy; }
|
||||||
|
public OffsetDateTime getApprovedOn() { return approvedOn; }
|
||||||
|
public String getFiuStrReference() { return fiuStrReference; }
|
||||||
|
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
||||||
|
public String getFrozenAccountIds() { return frozenAccountIds; }
|
||||||
|
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
||||||
|
public String getStrStatus() { return strStatus; }
|
||||||
|
public String getStrDueDate() { return strDueDate; }
|
||||||
|
public String getStrRaisedOn() { return strRaisedOn; }
|
||||||
}
|
}
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* 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.sanctions.service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freezes and releases a client's deposit accounts when a UNSC sanctions match is confirmed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Under Section 51-A UAPA 1967 a confirmed match means funds may not move OUT — the account is
|
||||||
|
* not closed and incoming credits are not refused, so this applies a debit block rather than a
|
||||||
|
* full account block. Freezing is only reachable for a client that already exists; a match at
|
||||||
|
* onboarding has no account to freeze and is handled by refusing to create the client at all.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SanctionsFreezeService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(SanctionsFreezeService.class);
|
||||||
|
|
||||||
|
private final SavingsAccountRepository savingsAccountRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks debits on every active savings account of the client.
|
||||||
|
*
|
||||||
|
* @return comma-separated ids of the accounts actually frozen (empty string when the client
|
||||||
|
* has no active account — a legitimate outcome, not an error)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String freezeClientAccounts(final Long clientId, final Long alertId) {
|
||||||
|
if (clientId == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
final List<SavingsAccount> accounts = savingsAccountRepository.findSavingAccountByClientId(clientId);
|
||||||
|
final List<String> frozen = new ArrayList<>();
|
||||||
|
|
||||||
|
for (final SavingsAccount account : accounts) {
|
||||||
|
// blockDebits() rejects anything not ACTIVE and anything already fully blocked. Filter
|
||||||
|
// here rather than catching, so a closed or already-frozen account is a skip, not noise.
|
||||||
|
if (!SavingsAccountStatusType.ACTIVE.hasStateOf(account.getStatus())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final SavingsAccountSubStatusEnum subStatus = SavingsAccountSubStatusEnum.fromInt(account.getSubStatus());
|
||||||
|
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(subStatus)
|
||||||
|
|| SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(subStatus)) {
|
||||||
|
frozen.add(String.valueOf(account.getId()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
account.blockDebits(account.getSubStatus());
|
||||||
|
savingsAccountRepository.save(account);
|
||||||
|
frozen.add(String.valueOf(account.getId()));
|
||||||
|
LOG.warn("Sanctions alert {}: debits blocked on savings account {} of client {}", alertId, account.getId(),
|
||||||
|
clientId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// One un-freezable account must not abort the freeze of the others.
|
||||||
|
LOG.error("Sanctions alert {}: could not block debits on savings account {}: {}", alertId, account.getId(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frozen.stream().collect(Collectors.joining(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the debit block. Only ever called on de-listing or revocation of the order, and
|
||||||
|
* only from the Principal Officer approval path.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void unfreezeClientAccounts(final Long clientId) {
|
||||||
|
if (clientId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) {
|
||||||
|
final SavingsAccountSubStatusEnum subStatus = SavingsAccountSubStatusEnum.fromInt(account.getSubStatus());
|
||||||
|
if (!(SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(subStatus)
|
||||||
|
|| SavingsAccountSubStatusEnum.BLOCK.hasStateOf(subStatus))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
account.unblockDebits();
|
||||||
|
savingsAccountRepository.save(account);
|
||||||
|
LOG.info("Debits released on savings account {} of client {}", account.getId(), clientId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Could not release debits on savings account {}: {}", account.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -59,4 +59,77 @@ public interface SanctionsScreeningService {
|
|||||||
* Search screening logs by screened name.
|
* Search screening logs by screened name.
|
||||||
*/
|
*/
|
||||||
List<SanctionsScreeningLog> searchScreeningLogs(String name);
|
List<SanctionsScreeningLog> searchScreeningLogs(String name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alerts awaiting or carrying a disposition. Pass null for every alert regardless of state.
|
||||||
|
*/
|
||||||
|
List<SanctionsScreeningLog> getAlerts(String disposition);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the reviewing officer's assessment of a flagged screening.
|
||||||
|
*
|
||||||
|
* @param logId the flagged screening log id
|
||||||
|
* @param disposition FALSE_POSITIVE or CONFIRMED_MATCH
|
||||||
|
* @param reason mandatory narrative of the identifier comparison
|
||||||
|
*/
|
||||||
|
SanctionsScreeningLog recordDisposition(Long logId, String disposition, String reason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Principal Officer sign-off. Approving a FALSE_POSITIVE is what actually unblocks
|
||||||
|
* onboarding; approving a CONFIRMED_MATCH triggers the freeze of the client's accounts.
|
||||||
|
*/
|
||||||
|
SanctionsScreeningLog approveDisposition(Long logId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the FIU-IND STR reference and the date the nodal officer was informed, closing
|
||||||
|
* out the 24-hour reporting obligation on a confirmed match.
|
||||||
|
*/
|
||||||
|
SanctionsScreeningLog recordReporting(Long logId, String strReference, String nodalOfficerInformedOn);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this exact name carries a false positive that the Principal Officer approved —
|
||||||
|
* the only condition under which a matched name may be onboarded.
|
||||||
|
*/
|
||||||
|
boolean hasApprovedFalsePositive(String name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match check that does NOT write an activity log entry.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Used by the server-side onboarding gate, which runs on every save attempt: logging there
|
||||||
|
* would stack a fresh alert onto the queue each time the officer retries a blocked client,
|
||||||
|
* burying the one real case under duplicates. The screening that belongs in the log is the
|
||||||
|
* one the officer deliberately ran from the UI.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
boolean isNameSanctioned(String name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-screens every existing client against the currently loaded list snapshot, opening an
|
||||||
|
* alert for each hit. This is what makes freezing reachable at all: without it a customer
|
||||||
|
* added to the list after onboarding is never detected.
|
||||||
|
*
|
||||||
|
* @return number of clients that produced a new flagged alert
|
||||||
|
*/
|
||||||
|
int rescreenExistingClients();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name and account number for the given client ids, keyed by id. Resolved in one
|
||||||
|
* query so a queue of alerts does not issue a round trip per row.
|
||||||
|
*/
|
||||||
|
java.util.Map<Long, String[]> resolveClientNames(java.util.Collection<Long> clientIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposition of the client's most recent flagged alert (PENDING_REVIEW / FALSE_POSITIVE /
|
||||||
|
* CONFIRMED_MATCH), or null when they carry none. Drives the badge on the client's own page.
|
||||||
|
*/
|
||||||
|
String getLatestAlertStatus(Long clientId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmed matches carrying an STR obligation — the sanctions stream shown on the FIU-IND
|
||||||
|
* screen, deliberately separate from transaction-driven AML alerts.
|
||||||
|
*
|
||||||
|
* @param status PENDING_FILING or FILED; null for all
|
||||||
|
*/
|
||||||
|
List<SanctionsScreeningLog> getStrCases(String status);
|
||||||
}
|
}
|
||||||
|
|||||||
+295
@@ -18,10 +18,19 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.sanctions.service;
|
package org.apache.fineract.portfolio.sanctions.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.sanctions.data.SanctionsMatchData;
|
import org.apache.fineract.portfolio.sanctions.data.SanctionsMatchData;
|
||||||
import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult;
|
import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult;
|
||||||
import org.apache.fineract.portfolio.sanctions.domain.SanctionsAlias;
|
import org.apache.fineract.portfolio.sanctions.domain.SanctionsAlias;
|
||||||
@@ -31,6 +40,7 @@ import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLog;
|
|||||||
import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository;
|
import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -42,6 +52,9 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
|
|||||||
|
|
||||||
private final SanctionsEntryRepository sanctionsEntryRepository;
|
private final SanctionsEntryRepository sanctionsEntryRepository;
|
||||||
private final SanctionsScreeningLogRepository screeningLogRepository;
|
private final SanctionsScreeningLogRepository screeningLogRepository;
|
||||||
|
private final SanctionsFreezeService sanctionsFreezeService;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -113,6 +126,288 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
|
|||||||
return screeningLogRepository.findByScreenedNameContaining(name);
|
return screeningLogRepository.findByScreenedNameContaining(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<SanctionsScreeningLog> getAlerts(final String disposition) {
|
||||||
|
if (disposition == null || disposition.isBlank()) {
|
||||||
|
return screeningLogRepository.findAllAlerts();
|
||||||
|
}
|
||||||
|
return screeningLogRepository.findByDisposition(disposition.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public SanctionsScreeningLog recordDisposition(final Long logId, final String disposition, final String reason) {
|
||||||
|
final SanctionsScreeningLog log = findAlertOrThrow(logId);
|
||||||
|
|
||||||
|
final String normalised = disposition == null ? "" : disposition.trim().toUpperCase();
|
||||||
|
if (!SanctionsScreeningLog.DISPOSITION_FALSE_POSITIVE.equals(normalised)
|
||||||
|
&& !SanctionsScreeningLog.DISPOSITION_CONFIRMED.equals(normalised)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.disposition.invalid",
|
||||||
|
"Disposition must be FALSE_POSITIVE or CONFIRMED_MATCH.");
|
||||||
|
}
|
||||||
|
// The written reason IS the compliance record — an unexplained "not the same person" is
|
||||||
|
// exactly what a regulator would treat as no review having happened at all.
|
||||||
|
if (reason == null || reason.trim().length() < 10) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.disposition.reason.required",
|
||||||
|
"Record which identifiers were compared and why (minimum 10 characters).");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.recordDisposition(normalised, reason.trim(), currentUserId());
|
||||||
|
screeningLogRepository.saveAndFlush(log);
|
||||||
|
LOG.warn("Sanctions alert {} for '{}' dispositioned as {}", logId, log.getScreenedName(), normalised);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public SanctionsScreeningLog approveDisposition(final Long logId) {
|
||||||
|
final SanctionsScreeningLog log = findAlertOrThrow(logId);
|
||||||
|
|
||||||
|
if (SanctionsScreeningLog.DISPOSITION_PENDING.equals(log.getDisposition())) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.reviewed",
|
||||||
|
"This alert has not been reviewed yet — record a disposition before approving.");
|
||||||
|
}
|
||||||
|
final Long approverId = currentUserId();
|
||||||
|
// Maker-checker: whoever assessed the hit cannot also sign it off.
|
||||||
|
if (approverId != null && approverId.equals(log.getReviewedBy())) {
|
||||||
|
// Naming the reviewer turns a dead end into an instruction: the officer can see at a
|
||||||
|
// glance that they are the maker and that somebody else has to sign it off.
|
||||||
|
final String reviewer = resolveUsername(log.getReviewedBy());
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.self.approval",
|
||||||
|
"This alert was assessed by '" + reviewer + "'. A different officer with the "
|
||||||
|
+ "APPROVE_SANCTIONS permission must approve it.",
|
||||||
|
reviewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.approve(approverId);
|
||||||
|
|
||||||
|
// Approving a confirmed match is the moment the freeze becomes due. Onboarding matches
|
||||||
|
// have no clientId and therefore nothing to freeze — that case is blocked at creation.
|
||||||
|
if (log.isConfirmedMatch() && log.getClientId() != null) {
|
||||||
|
final String frozen = sanctionsFreezeService.freezeClientAccounts(log.getClientId(), log.getId());
|
||||||
|
log.recordFreeze(frozen);
|
||||||
|
LOG.warn("Sanctions alert {}: froze accounts [{}] for client {}", logId, frozen, log.getClientId());
|
||||||
|
}
|
||||||
|
// A confirmed match is reportable under PMLA, so approval opens the STR obligation and the
|
||||||
|
// case becomes visible on the FIU-IND screen. Kept on this row rather than pushed into the
|
||||||
|
// AML alert pipeline — that pipeline is transaction-driven and this is identity-driven.
|
||||||
|
if (log.isConfirmedMatch()) {
|
||||||
|
log.raiseStr();
|
||||||
|
}
|
||||||
|
// A cleared false positive releases any freeze the alert previously imposed.
|
||||||
|
if (log.isApprovedFalsePositive() && log.getFrozenAccountIds() != null && !log.getFrozenAccountIds().isBlank()) {
|
||||||
|
sanctionsFreezeService.unfreezeClientAccounts(log.getClientId());
|
||||||
|
log.recordFreeze("");
|
||||||
|
}
|
||||||
|
|
||||||
|
screeningLogRepository.saveAndFlush(log);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public SanctionsScreeningLog recordReporting(final Long logId, final String strReference,
|
||||||
|
final String nodalOfficerInformedOn) {
|
||||||
|
final SanctionsScreeningLog log = findAlertOrThrow(logId);
|
||||||
|
if (!log.isConfirmedMatch()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.reporting.not.confirmed",
|
||||||
|
"Only a confirmed match is reported to FIU-IND and the nodal officer.");
|
||||||
|
}
|
||||||
|
log.recordReporting(strReference, parseDateTime(nodalOfficerInformedOn));
|
||||||
|
screeningLogRepository.saveAndFlush(log);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean hasApprovedFalsePositive(final String name) {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return screeningLogRepository.countApprovedFalsePositives(name.trim()) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isNameSanctioned(final String name) {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !sanctionsEntryRepository.searchByNameOrAlias(name.trim()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int rescreenExistingClients() {
|
||||||
|
// display_name is denormalised from the name parts and can drift out of sync with them —
|
||||||
|
// a direct DB edit, or any write path that misses it, leaves it stale. Screening the
|
||||||
|
// stale value would silently clear a client whose actual name is on the list, so the
|
||||||
|
// name parts are rebuilt here and display_name used only as the fallback.
|
||||||
|
final List<Map<String, Object>> clients = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, firstname, middlename, lastname, fullname, display_name FROM m_client "
|
||||||
|
+ "WHERE status_enum = 300");
|
||||||
|
|
||||||
|
int flagged = 0;
|
||||||
|
for (final Map<String, Object> row : clients) {
|
||||||
|
final Long clientId = ((Number) row.get("id")).longValue();
|
||||||
|
final String name = resolveScreenableName(row);
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Re-running the sweep must not stack another identical alert onto the queue. An open
|
||||||
|
// alert for this client is the same case still awaiting the same decision; repeating
|
||||||
|
// it buries the real work under copies of itself.
|
||||||
|
if (hasOpenAlert(clientId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final SanctionsScreeningResult result = screenName(name, "PERIODIC_RESCREEN", clientId, null);
|
||||||
|
if (result.isMatchFound()) {
|
||||||
|
flagged++;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Re-screening failed for client {} ('{}'): {}", clientId, name, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOG.info("Periodic re-screening complete: {} of {} clients flagged", flagged, clients.size());
|
||||||
|
return flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<Long, String[]> resolveClientNames(final Collection<Long> clientIds) {
|
||||||
|
final Map<Long, String[]> byId = new HashMap<>();
|
||||||
|
if (clientIds == null || clientIds.isEmpty()) {
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, display_name, account_no FROM m_client WHERE id IN (" + placeholders + ")",
|
||||||
|
clientIds.toArray());
|
||||||
|
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final Long id = ((Number) row.get("id")).longValue();
|
||||||
|
byId.put(id, new String[] { (String) row.get("display_name"), (String) row.get("account_no") });
|
||||||
|
}
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getLatestAlertStatus(final Long clientId) {
|
||||||
|
if (clientId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// findFlaggedByClientId already orders newest first.
|
||||||
|
return screeningLogRepository.findFlaggedByClientId(clientId).stream().map(SanctionsScreeningLog::getDisposition)
|
||||||
|
.filter(java.util.Objects::nonNull).findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<SanctionsScreeningLog> getStrCases(final String status) {
|
||||||
|
final String normalised = status == null || status.isBlank() ? null : status.trim().toUpperCase();
|
||||||
|
return screeningLogRepository.findStrCases(normalised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Username for a user id, falling back to the id when it cannot be resolved. */
|
||||||
|
private String resolveUsername(final Long userId) {
|
||||||
|
if (userId == null) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate
|
||||||
|
.queryForList("SELECT username FROM m_appuser WHERE id = ?", userId);
|
||||||
|
return rows.isEmpty() ? ("user " + userId) : (String) rows.get(0).get("username");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "user " + userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the client already has a flagged alert nobody has dispositioned yet. */
|
||||||
|
private boolean hasOpenAlert(final Long clientId) {
|
||||||
|
return screeningLogRepository.findFlaggedByClientId(clientId).stream().anyMatch(log -> {
|
||||||
|
// Unassessed — still the same case awaiting the same decision.
|
||||||
|
if (SanctionsScreeningLog.DISPOSITION_PENDING.equals(log.getDisposition())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Assessed but not yet signed off, or a standing confirmed match. Checking only for
|
||||||
|
// PENDING_REVIEW left a hole: the moment an officer recorded an assessment the guard
|
||||||
|
// stopped seeing the alert, so every later sweep raised a fresh duplicate of a case
|
||||||
|
// that was already being worked — or already confirmed and frozen.
|
||||||
|
if (log.getApprovedBy() == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return log.isConfirmedMatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name to screen for a client row, built from the authoritative name columns.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* An entity carries {@code fullname}; a person carries first/middle/last. Both are the source
|
||||||
|
* of truth, whereas {@code display_name} is a derived copy that can lag behind them. Falling
|
||||||
|
* back to it only when no name part exists keeps legacy rows screenable without trusting a
|
||||||
|
* value that may be stale.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private String resolveScreenableName(final Map<String, Object> row) {
|
||||||
|
final String fullname = trimToNull((String) row.get("fullname"));
|
||||||
|
if (fullname != null) {
|
||||||
|
return fullname;
|
||||||
|
}
|
||||||
|
final String joined = Stream.of("firstname", "middlename", "lastname").map(col -> trimToNull((String) row.get(col)))
|
||||||
|
.filter(part -> part != null).collect(Collectors.joining(" "));
|
||||||
|
if (!joined.isBlank()) {
|
||||||
|
return joined;
|
||||||
|
}
|
||||||
|
return trimToNull((String) row.get("display_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(final String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SanctionsScreeningLog findAlertOrThrow(final Long logId) {
|
||||||
|
final SanctionsScreeningLog log = screeningLogRepository.findById(logId)
|
||||||
|
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.found",
|
||||||
|
"Sanctions alert " + logId + " does not exist."));
|
||||||
|
if (!log.isFlagged()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.flagged",
|
||||||
|
"Screening " + logId + " produced no match, so there is nothing to disposition.");
|
||||||
|
}
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long currentUserId() {
|
||||||
|
try {
|
||||||
|
return context.authenticatedUser().getId();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Re-screening runs from a scheduler with no authenticated user.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime parseDateTime(final String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(value.trim()).atStartOfDay().atOffset(ZoneOffset.UTC);
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
LOG.debug("Could not parse nodal officer date: {}", value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Long saveLog(final String screenedName, final String result, final int matchCount,
|
private Long saveLog(final String screenedName, final String result, final int matchCount,
|
||||||
final String matchedEntryIds, final String matchedNames, final String source,
|
final String matchedEntryIds, final String matchedNames, final String source,
|
||||||
final Long clientId, final String ipAddress) {
|
final Long clientId, final String ipAddress) {
|
||||||
|
|||||||
+69
@@ -18,6 +18,11 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.api;
|
package org.apache.fineract.portfolio.savings.api;
|
||||||
|
|
||||||
|
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DESCRIPTION;
|
||||||
|
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE;
|
||||||
|
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_NAME;
|
||||||
|
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
@@ -26,6 +31,7 @@ import jakarta.ws.rs.Consumes;
|
|||||||
import jakarta.ws.rs.DELETE;
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.HeaderParam;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.PUT;
|
import jakarta.ws.rs.PUT;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
@@ -34,9 +40,11 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.Context;
|
import jakarta.ws.rs.core.Context;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
import jakarta.ws.rs.core.UriInfo;
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -50,6 +58,10 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiResource;
|
||||||
|
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
|
||||||
|
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
|
||||||
|
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
|
||||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
||||||
@@ -63,6 +75,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
|
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
|
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
|
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
|
||||||
|
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
|
||||||
|
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
|
||||||
|
import org.glassfish.jersey.media.multipart.FormDataParam;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/currentaccounts")
|
@Path("/v1/currentaccounts")
|
||||||
@@ -84,6 +99,10 @@ public class CurrentAccountsApiResource {
|
|||||||
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
|
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
|
||||||
|
private final DocumentApiResource documentApiResource;
|
||||||
|
|
||||||
|
/** Matches the {@code entityType} current-account document requests are stored/looked up under. */
|
||||||
|
private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts";
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("template")
|
@Path("template")
|
||||||
@@ -337,4 +356,54 @@ public class CurrentAccountsApiResource {
|
|||||||
return this.toApiJsonSerializer.serialize(result);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Documents ────────────────────────────────────────────────────────────
|
||||||
|
// The generic /v1/{entityType}/{entityId}/documents resource (DocumentApiResource) never gets a
|
||||||
|
// chance to handle current-account requests: this class's own literal /v1/currentaccounts path
|
||||||
|
// is matched first for anything under {accountId}, and it has no "documents" method, so the
|
||||||
|
// request dead-ends in a 404 instead of falling through. These delegate straight to the same
|
||||||
|
// DocumentApiResource so current accounts get identical document-management behaviour.
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{accountId}/documents")
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List documents attached to a current account")
|
||||||
|
@ApiResponse(responseCode = "200", description = "OK")
|
||||||
|
public List<DocumentData> retrieveDocuments(@PathParam("accountId") final Long accountId) {
|
||||||
|
return documentApiResource.retrieveAllDocuments(DOCUMENT_ENTITY_TYPE, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{accountId}/documents/{documentId}/attachment")
|
||||||
|
@Produces({ MediaType.APPLICATION_OCTET_STREAM })
|
||||||
|
@Operation(summary = "Download a document attached to a current account")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file")
|
||||||
|
public Response downloadDocument(@PathParam("accountId") final Long accountId, @PathParam("documentId") final Long documentId) {
|
||||||
|
return documentApiResource.downloadFile(DOCUMENT_ENTITY_TYPE, accountId, documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{accountId}/documents")
|
||||||
|
@Consumes({ MediaType.MULTIPART_FORM_DATA })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Attach a document to a current account")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
|
||||||
|
public DocumentCreateResponse createDocument(@PathParam("accountId") final Long accountId,
|
||||||
|
@HeaderParam(CONTENT_LENGTH) final Long fileSize, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream,
|
||||||
|
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
|
||||||
|
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart,
|
||||||
|
@FormDataParam(DOCUMENT_API_PARAM_NAME) final String name,
|
||||||
|
@FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) {
|
||||||
|
return documentApiResource.createDocument(DOCUMENT_ENTITY_TYPE, accountId, fileSize, inputStream, fileDetails, filePart, name,
|
||||||
|
description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("{accountId}/documents/{documentId}")
|
||||||
|
@Operation(summary = "Remove a document attached to a current account")
|
||||||
|
@ApiResponse(responseCode = "200", description = "OK")
|
||||||
|
public DocumentDeleteResponse deleteDocument(@PathParam("accountId") final Long accountId,
|
||||||
|
@PathParam("documentId") final Long documentId) {
|
||||||
|
return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -29,6 +29,10 @@ import jakarta.ws.rs.core.MediaType;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
||||||
@@ -42,6 +46,7 @@ public class FixedDepositProductsBaseInterestRatesApiResource {
|
|||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final Gson gson = new GsonBuilder()
|
private final Gson gson = new GsonBuilder()
|
||||||
.registerTypeAdapter(LocalDate.class,
|
.registerTypeAdapter(LocalDate.class,
|
||||||
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
|
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
|
||||||
@@ -77,11 +82,11 @@ public class FixedDepositProductsBaseInterestRatesApiResource {
|
|||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Save new FD base interest rate version", description = "Creates a new FD CBS base chart version for the given fdType and deactivates the previous active version.")
|
@Operation(summary = "Save new FD base interest rate version", description = "Creates a new FD CBS base chart version for the given fdType and deactivates the previous active version.")
|
||||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||||
public String saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
public CommandProcessingResult saveInterestRates(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_PRODUCT_RESOURCE_NAME);
|
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||||
final FdInterestRateSaveRequest request = gson.fromJson(apiRequestBodyAsJson, FdInterestRateSaveRequest.class);
|
.createCbsBaseInterestRate()
|
||||||
final Long createdById = this.context.authenticatedUser().getId();
|
.withJson(apiRequestBodyAsJson)
|
||||||
final FdInterestRateEffectiveData saved = this.fdBaseInterestRateReadPlatformService.saveInterestRates(request, createdById);
|
.build();
|
||||||
return gson.toJson(saved);
|
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -435,7 +435,10 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Money activateWithBalance() {
|
public Money activateWithBalance() {
|
||||||
return Money.of(this.currency, this.accountTermAndPreClosure.depositAmount());
|
// Activation never deposits money -- funding happens strictly through a separate deposit
|
||||||
|
// transaction (posted by a clerk) after the account is already active, regardless of the
|
||||||
|
// deposit amount configured on the account's term/pre-closure details.
|
||||||
|
return Money.zero(this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
|
public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
|
||||||
|
|||||||
+4
-1
@@ -552,7 +552,10 @@ public class RecurringDepositAccount extends SavingsAccount {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Money activateWithBalance() {
|
public Money activateWithBalance() {
|
||||||
return Money.of(this.currency, this.minRequiredOpeningBalance);
|
// Activation never deposits money -- funding happens strictly through a separate deposit
|
||||||
|
// transaction (posted by a clerk) after the account is already active, regardless of any
|
||||||
|
// minimum required opening balance the product has configured.
|
||||||
|
return Money.zero(this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals,
|
protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals,
|
||||||
|
|||||||
+4
-13
@@ -19,7 +19,6 @@
|
|||||||
package org.apache.fineract.portfolio.savings.domain;
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountFacilityConfigParamName;
|
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName;
|
||||||
@@ -455,18 +454,10 @@ public class SavingsAccountAssembler {
|
|||||||
account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element));
|
account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element));
|
||||||
account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element));
|
account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element));
|
||||||
|
|
||||||
// Account Facility Config
|
// Every digital facility starts disabled at account opening, regardless of anything the
|
||||||
if (element.getAsJsonObject().has(accountFacilityConfigParamName)) {
|
// request sends or the product defaults to -- they're switched on deliberately, one at a
|
||||||
final JsonElement configEl = element.getAsJsonObject().get(accountFacilityConfigParamName);
|
// time, later, through the account's own Edit Nominee & Features screen.
|
||||||
if (configEl != null && !configEl.isJsonNull() && configEl.isJsonObject()) {
|
account.setFacilityConfig(new SavingsAccountFacilityConfig(account));
|
||||||
SavingsAccountFacilityConfig facilityConfig = account.getFacilityConfig();
|
|
||||||
if (facilityConfig == null) {
|
|
||||||
facilityConfig = new SavingsAccountFacilityConfig(account);
|
|
||||||
account.setFacilityConfig(facilityConfig);
|
|
||||||
}
|
|
||||||
facilityConfig.updateFrom(configEl.getAsJsonObject());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Joint holders
|
// Joint holders
|
||||||
if (element.getAsJsonObject().has(jointHoldersParamName)) {
|
if (element.getAsJsonObject().has(jointHoldersParamName)) {
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.handler;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "CBSBASEINTERESTRATE", action = "CREATE")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CreateCbsBaseInterestRateCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final FdBaseInterestRateWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
return this.writePlatformService.saveInterestRates(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-11
@@ -133,18 +133,10 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
|
|||||||
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
|
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum required balance validation
|
// Minimum required balance is not enforced -- a withdrawal must never be blocked for
|
||||||
|
// taking the account below its configured minimum, only for exceeding the available
|
||||||
|
// balance (checked above).
|
||||||
final BigDecimal newBalance = currentBalance.subtract(amount);
|
final BigDecimal newBalance = currentBalance.subtract(amount);
|
||||||
if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null
|
|
||||||
&& account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
if (newBalance.compareTo(account.getMinRequiredBalance()) < 0) {
|
|
||||||
throw new GeneralPlatformDomainRuleException(
|
|
||||||
"error.msg.current.account.min.required.balance.breached",
|
|
||||||
"Withdrawal of " + amount + " would breach the minimum required balance of "
|
|
||||||
+ account.getMinRequiredBalance() + ". Balance after withdrawal would be " + newBalance,
|
|
||||||
accountId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final String refNo = command.stringValueOfParameterNamed("accountNumber");
|
final String refNo = command.stringValueOfParameterNamed("accountNumber");
|
||||||
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
|
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
|
||||||
|
|||||||
+11
-151
@@ -227,12 +227,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
|
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Facility config — UI sends "accountFacilityConfig", internal uses "facilityConfig"
|
// Every digital facility starts disabled at account opening, regardless of anything the
|
||||||
JsonObject fcJson = json.getAsJsonObject("accountFacilityConfig");
|
// request sends or the product defaults to -- they're switched on deliberately, one at a
|
||||||
if (fcJson == null) fcJson = json.getAsJsonObject("facilityConfig");
|
// time, later, through the account's own Edit Nominee & Features screen.
|
||||||
if (fcJson != null) {
|
account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account));
|
||||||
account.setFacilityConfig(assembleFacilityConfig(account, fcJson));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Joint holders
|
// Joint holders
|
||||||
if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) {
|
if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) {
|
||||||
@@ -294,61 +292,16 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
final JsonObject json = command.parsedJson().getAsJsonObject();
|
final JsonObject json = command.parsedJson().getAsJsonObject();
|
||||||
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
|
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
|
||||||
if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
// Validate minimum opening balance before activation
|
|
||||||
java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO;
|
|
||||||
if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) {
|
|
||||||
try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) {
|
|
||||||
// intentionally ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance();
|
|
||||||
if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0
|
|
||||||
&& openingBalanceValue.compareTo(minRequired) < 0) {
|
|
||||||
throw new org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException(
|
|
||||||
java.util.List.of(org.apache.fineract.infrastructure.core.data.ApiParameterError.parameterError(
|
|
||||||
"openingBalance",
|
|
||||||
"Opening balance (" + openingBalanceValue + ") is less than minimum required opening balance (" + minRequired + ")",
|
|
||||||
"validation.msg.currentaccount.openingBalance.less.than.min.required",
|
|
||||||
openingBalanceValue, minRequired)));
|
|
||||||
}
|
|
||||||
|
|
||||||
account.activate(activatedOnDate, user);
|
account.activate(activatedOnDate, user);
|
||||||
applyFacilityChargesOnActivation(account);
|
|
||||||
|
|
||||||
java.math.BigDecimal currentBalance = java.math.BigDecimal.ZERO;
|
// Activation posts no transaction of its own -- no opening-balance deposit, no
|
||||||
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction> depositTxns = new java.util.ArrayList<>();
|
// digital-facility charges applied or collected, regardless of what the account's facility
|
||||||
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>();
|
// config or product defaults say. Funding and any facility charges happen strictly
|
||||||
|
// afterwards, through separate transactions a clerk processes once the account is already
|
||||||
// Opening balance deposit transaction
|
// active.
|
||||||
if (openingBalanceValue.compareTo(java.math.BigDecimal.ZERO) > 0) {
|
|
||||||
try {
|
|
||||||
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction deposit =
|
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
|
|
||||||
.deposit(account, account.getOffice(), openingBalanceValue, activatedOnDate, "OPENING-BALANCE");
|
|
||||||
deposit.setRunningBalanceDerived(openingBalanceValue);
|
|
||||||
account.setTotalDepositsDerived(openingBalanceValue);
|
|
||||||
currentAccountTransactionRepository.saveAndFlush(deposit);
|
|
||||||
currentBalance = openingBalanceValue;
|
|
||||||
depositTxns.add(deposit);
|
|
||||||
log.info("Opening balance deposit {} created for account id={}", openingBalanceValue, accountId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Could not process openingBalance for account id={}: {}", accountId, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
|
|
||||||
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> activationCharges =
|
|
||||||
account.getCharges().stream()
|
|
||||||
.filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid())
|
|
||||||
.toList();
|
|
||||||
currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries);
|
|
||||||
|
|
||||||
account.setAccountBalanceDerived(currentBalance);
|
|
||||||
currentAccountRepository.saveAndFlush(account);
|
currentAccountRepository.saveAndFlush(account);
|
||||||
|
|
||||||
// Post GL journal entries — deposits in batch, each charge individually
|
|
||||||
postGlEntries(account, depositTxns, chargeEntries);
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,78 +590,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyFacilityChargesOnActivation(final CurrentAccount account) {
|
|
||||||
// Use account-level facility config — reflects what was selected at account opening time
|
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig config =
|
|
||||||
account.getFacilityConfig();
|
|
||||||
|
|
||||||
log.info("[FacilityCharge] Activating account id={}, facilityConfig={}",
|
|
||||||
account.getId(), config != null ? "FOUND" : "NULL");
|
|
||||||
|
|
||||||
if (config == null) {
|
|
||||||
// Fallback: use product-level facility config
|
|
||||||
log.info("[FacilityCharge] No account-level config, falling back to product id={}", account.getProductId());
|
|
||||||
config = currentProductRepository.findById(account.getProductId())
|
|
||||||
.map(p -> {
|
|
||||||
final var pc = p.getFacilityConfig();
|
|
||||||
if (pc == null) {
|
|
||||||
log.info("[FacilityCharge] Product has no facility config either");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Map product config to account config fields inline
|
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig ac =
|
|
||||||
new org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig();
|
|
||||||
ac.setAtmFacility(Boolean.TRUE.equals(pc.getAtmFacility()));
|
|
||||||
ac.setAtmWithdrawalFacility(Boolean.TRUE.equals(pc.getAtmWithdrawalFacility()));
|
|
||||||
ac.setUpiFacility(Boolean.TRUE.equals(pc.getUpiFacility()));
|
|
||||||
ac.setPosTransactionsFacility(Boolean.TRUE.equals(pc.getPosTransactionsFacility()));
|
|
||||||
ac.setChequeBook(Boolean.TRUE.equals(pc.getChequeBook()));
|
|
||||||
ac.setImpsFacility(Boolean.TRUE.equals(pc.getImpsFacility()));
|
|
||||||
ac.setSmsAlerts(Boolean.TRUE.equals(pc.getSmsAlerts()));
|
|
||||||
ac.setBbpsFacility(Boolean.TRUE.equals(pc.getBbpsFacility()));
|
|
||||||
ac.setInsuranceCover(Boolean.TRUE.equals(pc.getInsuranceCover()));
|
|
||||||
ac.setBepgFacility(Boolean.TRUE.equals(pc.getBepgFacility()));
|
|
||||||
ac.setNeftFacility(Boolean.TRUE.equals(pc.getNeftFacility()));
|
|
||||||
ac.setRtgsFacility(Boolean.TRUE.equals(pc.getRtgsFacility()));
|
|
||||||
ac.setInterBankTotalCapFacility(Boolean.TRUE.equals(pc.getInterBankTotalCapFacility()));
|
|
||||||
ac.setWithdrawalFacility(Boolean.TRUE.equals(pc.getWithdrawalFacility()));
|
|
||||||
ac.setDepositFacilityKyc(Boolean.TRUE.equals(pc.getDepositFacilityKyc()));
|
|
||||||
ac.setVirtualDebitCard(Boolean.TRUE.equals(pc.getVirtualDebitCard()));
|
|
||||||
return ac;
|
|
||||||
}).orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config == null) {
|
|
||||||
log.warn("[FacilityCharge] No facility config found for account id={} — no charges applied", account.getId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig fc = config;
|
|
||||||
log.info("[FacilityCharge] Config found — ATM={} UPI={} NEFT={} RTGS={} CHEQUE={} SMS={} IMPs={} POS={} BBPS={} INS={} BEPG={} IBCAP={} WD={} KYC={} VCARD={} ATM_WD={}",
|
|
||||||
fc.isAtmFacility(), fc.isUpiFacility(), fc.isNeftFacility(), fc.isRtgsFacility(),
|
|
||||||
fc.isChequeBook(), fc.isSmsAlerts(), fc.isImpsFacility(), fc.isPosTransactionsFacility(),
|
|
||||||
fc.isBbpsFacility(), fc.isInsuranceCover(), fc.isBepgFacility(), fc.isInterBankTotalCapFacility(),
|
|
||||||
fc.isWithdrawalFacility(), fc.isDepositFacilityKyc(), fc.isVirtualDebitCard(), fc.isAtmWithdrawalFacility());
|
|
||||||
applyMapped(account, fc.isAtmFacility(), "ATM_FACILITY");
|
|
||||||
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
|
|
||||||
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
|
|
||||||
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
|
|
||||||
// Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted
|
|
||||||
// when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having
|
|
||||||
// the facility enabled.
|
|
||||||
applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY");
|
|
||||||
applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS");
|
|
||||||
applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY");
|
|
||||||
applyMapped(account, fc.isInsuranceCover(), "INSURANCE_COVER");
|
|
||||||
applyMapped(account, fc.isBepgFacility(), "BEPG_FACILITY");
|
|
||||||
applyMapped(account, fc.isNeftFacility(), "NEFT_FACILITY");
|
|
||||||
applyMapped(account, fc.isRtgsFacility(), "RTGS_FACILITY");
|
|
||||||
applyMapped(account, fc.isInterBankTotalCapFacility(),"INTER_BANK_TOTAL_CAP");
|
|
||||||
applyMapped(account, fc.isWithdrawalFacility(), "WITHDRAWAL_FACILITY");
|
|
||||||
applyMapped(account, fc.isDepositFacilityKyc(), "DEPOSIT_FACILITY_KYC");
|
|
||||||
applyMapped(account, fc.isVirtualDebitCard(), "VIRTUAL_DEBIT_CARD");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapper to carry a charge transaction together with its charge entity for GL posting
|
// Wrapper to carry a charge transaction together with its charge entity for GL posting
|
||||||
private record ChargeGlEntry(
|
private record ChargeGlEntry(
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
|
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
|
||||||
@@ -717,9 +598,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
/**
|
/**
|
||||||
* Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a
|
* Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a
|
||||||
* {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL
|
* {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL
|
||||||
* posting. Shared by {@link #activate(Long, JsonCommand)} (activation-time charges) and
|
* posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) --
|
||||||
* {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) so the
|
* activation itself no longer collects any charges. Returns the resulting balance.
|
||||||
* charge-collection logic isn't duplicated. Returns the resulting balance.
|
|
||||||
*/
|
*/
|
||||||
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
|
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
|
||||||
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
|
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
|
||||||
@@ -875,24 +755,4 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) {
|
|
||||||
if (!enabled) {
|
|
||||||
log.debug("[FacilityCharge] {} disabled — skip", facilityType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
|
|
||||||
if (mappingOpt.isEmpty()) {
|
|
||||||
log.warn("[FacilityCharge] {} enabled but NO mapping found in m_facility_charge_mapping", facilityType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
|
|
||||||
if (charge == null) {
|
|
||||||
log.warn("[FacilityCharge] {} mapping exists but charge_id is NULL", facilityType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("[FacilityCharge] Applying charge '{}' (id={}) for facility {}", charge.getName(), charge.getId(), facilityType);
|
|
||||||
account.getCharges().add(
|
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge
|
|
||||||
.createFromFacility(account, charge));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-6
@@ -172,8 +172,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
|
|
||||||
if (portfolioAccountData == null) {
|
if (portfolioAccountData == null) {
|
||||||
final PaymentDetail paymentDetail = null;
|
final PaymentDetail paymentDetail = null;
|
||||||
this.depositAccountDomainService.handleFDDeposit(account, fmt, account.getActivationDate(),
|
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleFDDeposit(account, fmt,
|
||||||
amountForDeposit.getAmount(), paymentDetail);
|
account.getActivationDate(), amountForDeposit.getAmount(), paymentDetail);
|
||||||
|
// handleFDDeposit() already posts this transaction's own journal entries internally --
|
||||||
|
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
|
||||||
|
// activation-time changes, e.g. an activation charge payment) does not post it again.
|
||||||
|
if (depositTransaction != null && depositTransaction.getId() != null) {
|
||||||
|
existingTransactionIds.add(depositTransaction.getId());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
final SavingsAccount fromSavingsAccount = null;
|
final SavingsAccount fromSavingsAccount = null;
|
||||||
boolean isRegularTransaction = false;
|
boolean isRegularTransaction = false;
|
||||||
@@ -202,8 +208,17 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
}
|
}
|
||||||
|
|
||||||
final boolean isPreMatureClosure = false;
|
final boolean isPreMatureClosure = false;
|
||||||
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
if (amountForDeposit.isGreaterThanZero()) {
|
||||||
financialYearBeginningMonth);
|
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
} else {
|
||||||
|
// No deposit has actually landed on the account yet -- project maturity using the
|
||||||
|
// configured deposit amount (the same approach used for the pre-activation preview and
|
||||||
|
// for reinvested FDs) instead of the account's real, still-empty transaction history,
|
||||||
|
// which would otherwise price in zero interest.
|
||||||
|
account.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
||||||
|
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
|
}
|
||||||
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
|
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
|
||||||
if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) {
|
if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) {
|
||||||
depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository
|
depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository
|
||||||
@@ -275,8 +290,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService
|
final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService
|
||||||
.retriveSavingsLinkedAssociation(savingsId);
|
.retriveSavingsLinkedAssociation(savingsId);
|
||||||
if (portfolioAccountData == null) {
|
if (portfolioAccountData == null) {
|
||||||
this.depositAccountDomainService.handleRDDeposit(account, fmt, account.getActivationDate(),
|
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleRDDeposit(account, fmt,
|
||||||
amountForDeposit.getAmount(), null, isRegularTransaction);
|
account.getActivationDate(), amountForDeposit.getAmount(), null, isRegularTransaction);
|
||||||
|
// handleRDDeposit() already posts this transaction's own journal entries internally --
|
||||||
|
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
|
||||||
|
// activation-time changes) does not post it again.
|
||||||
|
if (depositTransaction != null && depositTransaction.getId() != null) {
|
||||||
|
existingTransactionIds.add(depositTransaction.getId());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
final boolean isExceptionForBalanceCheck = false;
|
final boolean isExceptionForBalanceCheck = false;
|
||||||
final SavingsAccount fromSavingsAccount = null;
|
final SavingsAccount fromSavingsAccount = null;
|
||||||
@@ -368,6 +389,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
|
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
|
||||||
transactionAmount, paymentDetail);
|
transactionAmount, paymentDetail);
|
||||||
|
|
||||||
|
// The account may have been activated with no funds yet (opening deposit is now a separate,
|
||||||
|
// later step) -- in that case its maturity amount was only ever projected off the configured
|
||||||
|
// deposit amount, with zero real transactions. Now that money has actually landed, recompute
|
||||||
|
// maturity date/amount off the account's real transaction history so it reflects reality.
|
||||||
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||||
|
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||||
|
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||||
|
final MathContext mc = MathContext.DECIMAL64;
|
||||||
|
final boolean isPreMatureClosure = false;
|
||||||
|
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||||
|
|
||||||
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
|
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
|
||||||
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
|
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
|
||||||
"DEPOSIT");
|
"DEPOSIT");
|
||||||
|
|||||||
+128
-1
@@ -11,10 +11,16 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.service;
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
|
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
|
||||||
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
||||||
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
|
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
|
||||||
@@ -51,7 +57,9 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public FdInterestRateEffectiveData saveInterestRates(FdInterestRateSaveRequest request, Long createdById) {
|
public FdInterestRateEffectiveData saveInterestRates(FdInterestRateSaveRequest request, Long createdById) {
|
||||||
final String fdType = request.getFdType();
|
validateRequest(request);
|
||||||
|
|
||||||
|
final String fdType = request.getFdType().toUpperCase();
|
||||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
|
||||||
final List<FdBaseInterestRateEffective> activeVersions = effectiveRepository
|
final List<FdBaseInterestRateEffective> activeVersions = effectiveRepository
|
||||||
@@ -85,6 +93,125 @@ public class FdBaseInterestRateReadPlatformServiceImpl implements FdBaseInterest
|
|||||||
return toData(newVersion);
|
return toData(newVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateRequest(final FdInterestRateSaveRequest request) {
|
||||||
|
final List<ApiParameterError> errors = new ArrayList<>();
|
||||||
|
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION");
|
||||||
|
|
||||||
|
if (request == null) {
|
||||||
|
validator.failWithCodeNoParameterAddedToErrorCode("request.cannot.be.null");
|
||||||
|
throwExceptionIfValidationWarningsExist(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String fdType = request.getFdType();
|
||||||
|
if (fdType == null || fdType.isBlank()) {
|
||||||
|
validator.parameter("fdType").failWithCode("cannot.be.blank");
|
||||||
|
} else if (!List.of("SINGLE", "DOUBLE", "SPECIAL").contains(fdType.toUpperCase())) {
|
||||||
|
validator.parameter("fdType").value(fdType).failWithCode("invalid.fd.type");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getSlabs() == null || request.getSlabs().isEmpty()) {
|
||||||
|
validator.parameter("slabs").failWithCode("cannot.be.blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getVersionLabel() != null && request.getVersionLabel().length() > 100) {
|
||||||
|
validator.parameter("versionLabel").value(request.getVersionLabel()).failWithCode("exceeds.max.length");
|
||||||
|
}
|
||||||
|
|
||||||
|
throwExceptionIfValidationWarningsExist(errors);
|
||||||
|
validateSlabs(request.getSlabs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateSlabs(final List<FdInterestRateSaveRequest.SlabRequest> slabs) {
|
||||||
|
final List<ApiParameterError> errors = new ArrayList<>();
|
||||||
|
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("FD_BASE_INTEREST_RATE_VERSION");
|
||||||
|
|
||||||
|
if (slabs == null || slabs.isEmpty()) {
|
||||||
|
validator.parameter("slabs").failWithCode("cannot.be.blank");
|
||||||
|
throwExceptionIfValidationWarningsExist(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<FdInterestRateSaveRequest.SlabRequest> sorted = new ArrayList<>(slabs);
|
||||||
|
sorted.sort(Comparator.comparing((FdInterestRateSaveRequest.SlabRequest slab) -> slab.getSrNo() == null ? Integer.MAX_VALUE : slab.getSrNo())
|
||||||
|
.thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod()));
|
||||||
|
|
||||||
|
final Integer expectedPeriodType = sorted.get(0).getPeriodTypeEnum();
|
||||||
|
Integer expectedFrom = 1;
|
||||||
|
|
||||||
|
for (int i = 0; i < sorted.size(); i++) {
|
||||||
|
final FdInterestRateSaveRequest.SlabRequest slab = sorted.get(i);
|
||||||
|
final int srNo = slab.getSrNo() == null ? i + 1 : slab.getSrNo();
|
||||||
|
final String slabPrefix = "slabs[" + srNo + "].";
|
||||||
|
|
||||||
|
if (slab.getPeriodTypeEnum() == null) {
|
||||||
|
validator.parameter(slabPrefix + "periodTypeEnum").failWithCode("cannot.be.blank");
|
||||||
|
} else if (!slab.getPeriodTypeEnum().equals(expectedPeriodType)) {
|
||||||
|
validator.parameter(slabPrefix + "periodTypeEnum").value(slab.getPeriodTypeEnum())
|
||||||
|
.failWithCode("must.be.consistent.across.all.slabs");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Integer fromPeriod = slab.getFromPeriod();
|
||||||
|
if (fromPeriod == null) {
|
||||||
|
validator.parameter(slabPrefix + "fromPeriod").failWithCode("cannot.be.blank");
|
||||||
|
} else if (fromPeriod < 0) {
|
||||||
|
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.non.negative");
|
||||||
|
} else if (!fromPeriod.equals(expectedFrom)) {
|
||||||
|
if (i == 0) {
|
||||||
|
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("first.slab.must.start.at.one");
|
||||||
|
} else if (fromPeriod < expectedFrom) {
|
||||||
|
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.not.overlap.previous.range");
|
||||||
|
} else {
|
||||||
|
validator.parameter(slabPrefix + "fromPeriod").value(fromPeriod).failWithCode("must.be.continuous.with.previous.range");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Integer toPeriod = slab.getToPeriod();
|
||||||
|
if (i < sorted.size() - 1) {
|
||||||
|
if (toPeriod == null) {
|
||||||
|
validator.parameter(slabPrefix + "toPeriod").failWithCode("cannot.be.blank.for.intermediate.slab");
|
||||||
|
} else {
|
||||||
|
if (toPeriod < 0) {
|
||||||
|
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative");
|
||||||
|
}
|
||||||
|
if (fromPeriod != null && toPeriod < fromPeriod) {
|
||||||
|
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod");
|
||||||
|
}
|
||||||
|
expectedFrom = toPeriod + 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (toPeriod != null) {
|
||||||
|
if (toPeriod < 0) {
|
||||||
|
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("must.be.non.negative");
|
||||||
|
}
|
||||||
|
if (fromPeriod != null && toPeriod < fromPeriod) {
|
||||||
|
validator.parameter(slabPrefix + "toPeriod").value(toPeriod).failWithCode("cannot.be.less.than.fromPeriod");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal annualRate = slab.getAnnualInterestRate();
|
||||||
|
if (annualRate == null) {
|
||||||
|
validator.parameter(slabPrefix + "annualInterestRate").failWithCode("cannot.be.blank");
|
||||||
|
} else if (annualRate.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
validator.parameter(slabPrefix + "annualInterestRate").value(annualRate).failWithCode("must.be.non.negative");
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal seniorRate = slab.getSeniorCitizenRate();
|
||||||
|
if (seniorRate != null && seniorRate.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
validator.parameter(slabPrefix + "seniorCitizenRate").value(seniorRate).failWithCode("must.be.non.negative");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throwExceptionIfValidationWarningsExist(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||||
|
if (!dataValidationErrors.isEmpty()) {
|
||||||
|
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private FdInterestRateEffectiveData toData(FdBaseInterestRateEffective e) {
|
private FdInterestRateEffectiveData toData(FdBaseInterestRateEffective e) {
|
||||||
List<FdInterestRateSlabData> slabs = slabRepository.findByEffectiveIdOrderBySrNo(e.getId()).stream()
|
List<FdInterestRateSlabData> slabs = slabRepository.findByEffectiveIdOrderBySrNo(e.getId()).stream()
|
||||||
.map(s -> FdInterestRateSlabData.builder()
|
.map(s -> FdInterestRateSlabData.builder()
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
public interface FdBaseInterestRateWritePlatformService {
|
||||||
|
|
||||||
|
CommandProcessingResult saveInterestRates(JsonCommand command);
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.service;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.savings.api.FdInterestRateSaveRequest;
|
||||||
|
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FdBaseInterestRateWritePlatformServiceImpl implements FdBaseInterestRateWritePlatformService {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final FdBaseInterestRateReadPlatformService readPlatformService;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult saveInterestRates(final JsonCommand command) {
|
||||||
|
final FdInterestRateSaveRequest request = gson.fromJson(command.json(), FdInterestRateSaveRequest.class);
|
||||||
|
final Long createdById = this.context.authenticatedUser().getId();
|
||||||
|
final FdInterestRateEffectiveData saved = this.readPlatformService.saveInterestRates(request, createdById);
|
||||||
|
return new CommandProcessingResultBuilder()
|
||||||
|
.withCommandId(command.commandId())
|
||||||
|
.withEntityId(saved.getId())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -220,10 +220,16 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
|
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Post the real charge(s) for newly-enabled facilities, today.
|
// 2) Post the real charge(s) for newly-enabled facilities, today -- and post the matching
|
||||||
|
// GL journal entries for those charge transactions (collectCharges() only books them
|
||||||
|
// against the account itself; it does not touch the general ledger).
|
||||||
if (!newlyEnabledFacilityTypes.isEmpty()) {
|
if (!newlyEnabledFacilityTypes.isEmpty()) {
|
||||||
|
final Set<Long> existingTransactionIds = new HashSet<>(account.findExistingTransactionIds());
|
||||||
|
final Set<Long> existingReversedTransactionIds = new HashSet<>(account.findExistingReversedTransactionIds());
|
||||||
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
|
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
|
||||||
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
|
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
|
||||||
|
this.savingsAccountWritePlatformService.postJournalEntriesForSavingsAccount(account, existingTransactionIds,
|
||||||
|
existingReversedTransactionIds, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Apply the facility-config toggles to the real row.
|
// 3) Apply the facility-config toggles to the real row.
|
||||||
|
|||||||
+13
-27
@@ -97,7 +97,6 @@ import org.apache.fineract.portfolio.note.domain.Note;
|
|||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
|
|
||||||
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||||
@@ -167,7 +166,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
private final GSIMRepositoy gsimRepository;
|
private final GSIMRepositoy gsimRepository;
|
||||||
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
|
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
|
||||||
private final ErrorHandler errorHandler;
|
private final ErrorHandler errorHandler;
|
||||||
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
|
|
||||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -203,23 +201,9 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
|
|
||||||
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
||||||
|
|
||||||
// Validate minimum opening balance
|
// Minimum required opening balance is not enforced -- activation must never be blocked
|
||||||
BigDecimal openingBalanceForValidation = BigDecimal.ZERO;
|
// for not meeting it; the account can be funded afterwards by whatever deposit the
|
||||||
if (command.parameterExists("openingBalance")) {
|
// customer brings (cash or a transfer), on their own timeline.
|
||||||
final BigDecimal ob = command.bigDecimalValueOfParameterNamed("openingBalance");
|
|
||||||
if (ob != null) openingBalanceForValidation = ob;
|
|
||||||
}
|
|
||||||
final BigDecimal minRequiredOpening = account.getMinRequiredOpeningBalance();
|
|
||||||
if (minRequiredOpening != null && minRequiredOpening.compareTo(BigDecimal.ZERO) > 0
|
|
||||||
&& openingBalanceForValidation.compareTo(minRequiredOpening) < 0) {
|
|
||||||
final List<ApiParameterError> errors = new ArrayList<>();
|
|
||||||
errors.add(ApiParameterError.parameterError(
|
|
||||||
"openingBalance",
|
|
||||||
"Opening balance (" + openingBalanceForValidation + ") is less than minimum required opening balance (" + minRequiredOpening + ")",
|
|
||||||
"validation.msg.savingsaccount.openingBalance.less.than.min.required",
|
|
||||||
openingBalanceForValidation, minRequiredOpening));
|
|
||||||
throw new PlatformApiDataValidationException(errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkClientOrGroupActive(account);
|
checkClientOrGroupActive(account);
|
||||||
final Set<Long> existingTransactionIds = new HashSet<>();
|
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||||
@@ -236,14 +220,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
final Locale locale = command.extractLocale();
|
final Locale locale = command.extractLocale();
|
||||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||||
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
|
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
|
||||||
// Auto-apply facility charges based on product facility config
|
// Activation posts no transaction of its own -- digital-facility charges are no longer
|
||||||
facilityChargeAutoApplyService.applyFacilityChargesOnActivation(account);
|
// auto-applied and auto-collected here. Whatever facilities are turned on later get
|
||||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
// charged (if at all) through the normal charge/collection flow at that time, not as a
|
||||||
// Collect any newly added facility activation charges (after flush so amountOutstanding is set)
|
// side effect of activation.
|
||||||
// payCharge() internally adds transactions to account.transactions via handleChargeTransactions
|
|
||||||
// Do NOT add to existingTransactionIds — postJournalEntries must include them for GL posting
|
|
||||||
facilityChargeAutoApplyService.collectActivationCharges(account);
|
|
||||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
||||||
@@ -1521,6 +1501,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
|
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postJournalEntriesForSavingsAccount(final SavingsAccount account, final Set<Long> existingTransactionIds,
|
||||||
|
final Set<Long> existingReversedTransactionIds, final boolean backdatedTxnsAllowedTill) {
|
||||||
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) {
|
public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) {
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -169,6 +169,8 @@ import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatf
|
|||||||
import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatformServiceImpl;
|
import org.apache.fineract.portfolio.savings.service.RdBaseInterestRateReadPlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
|
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformServiceImpl;
|
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformServiceImpl;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateWritePlatformServiceImpl;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPoster;
|
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPoster;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPosterTask;
|
import org.apache.fineract.portfolio.savings.service.SavingsSchedularInterestPosterTask;
|
||||||
import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionSearchService;
|
import org.apache.fineract.portfolio.savings.service.search.SavingsAccountTransactionSearchService;
|
||||||
@@ -200,6 +202,14 @@ public class SavingsConfiguration {
|
|||||||
return new FdBaseInterestRateReadPlatformServiceImpl(effectiveRepository, slabRepository);
|
return new FdBaseInterestRateReadPlatformServiceImpl(effectiveRepository, slabRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean(FdBaseInterestRateWritePlatformService.class)
|
||||||
|
public FdBaseInterestRateWritePlatformService fdBaseInterestRateWritePlatformService(
|
||||||
|
PlatformSecurityContext context,
|
||||||
|
FdBaseInterestRateReadPlatformService readPlatformService) {
|
||||||
|
return new FdBaseInterestRateWritePlatformServiceImpl(context, readPlatformService);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean(SavingsAccountTransactionSearchService.class)
|
@ConditionalOnMissingBean(SavingsAccountTransactionSearchService.class)
|
||||||
public SavingsAccountTransactionSearchService savingsAccountTransactionSearchService(PlatformSecurityContext context,
|
public SavingsAccountTransactionSearchService savingsAccountTransactionSearchService(PlatformSecurityContext context,
|
||||||
@@ -463,8 +473,7 @@ public class SavingsConfiguration {
|
|||||||
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
||||||
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
||||||
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
||||||
ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService,
|
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
|
||||||
DenominationBreakdownWriter denominationBreakdownWriter) {
|
|
||||||
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
|
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
|
||||||
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
|
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
|
||||||
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
|
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
|
||||||
@@ -472,7 +481,7 @@ public class SavingsConfiguration {
|
|||||||
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
||||||
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
|
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
|
||||||
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
|
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
|
||||||
errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter);
|
errorHandler, denominationBreakdownWriter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 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.shareaccounts.jobs.forfeitunclaimeddividends;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
|
||||||
|
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.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class ForfeitUnclaimedDividendsConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbcTemplate;
|
||||||
|
@Autowired
|
||||||
|
private ShareAccountSchedularService shareAccountSchedularService;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step forfeitUnclaimedDividendsStep() {
|
||||||
|
return new StepBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository)
|
||||||
|
.tasklet(forfeitUnclaimedDividendsTasklet(), transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job forfeitUnclaimedDividendsJob() {
|
||||||
|
return new JobBuilder(JobName.FORFEIT_UNCLAIMED_DIVIDENDS.name(), jobRepository).start(forfeitUnclaimedDividendsStep())
|
||||||
|
.incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ForfeitUnclaimedDividendsTasklet forfeitUnclaimedDividendsTasklet() {
|
||||||
|
return new ForfeitUnclaimedDividendsTasklet(jdbcTemplate, shareAccountSchedularService);
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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.shareaccounts.jobs.forfeitunclaimeddividends;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException;
|
||||||
|
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountSchedularService;
|
||||||
|
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.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year
|
||||||
|
* release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is
|
||||||
|
* forfeited — the hold on the member's savings account is released and the amount is swept back
|
||||||
|
* into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for
|
||||||
|
* the actual GL/balance mechanics.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ShareAccountSchedularService shareAccountSchedularService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
|
final List<Throwable> exceptions = new ArrayList<>();
|
||||||
|
final List<Long> lockIds = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class);
|
||||||
|
|
||||||
|
for (final Long lockId : lockIds) {
|
||||||
|
try {
|
||||||
|
this.shareAccountSchedularService.forfeitDividendLock(lockId);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
log.error("Forfeit Unclaimed Dividends failed for share dividend lock id: {}", lockId, e);
|
||||||
|
exceptions.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exceptions.isEmpty()) {
|
||||||
|
throw new JobExecutionException(exceptions);
|
||||||
|
}
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -22,4 +22,11 @@ public interface ShareAccountSchedularService {
|
|||||||
|
|
||||||
void postDividend(Long dividendDetailId, Long savingsId);
|
void postDividend(Long dividendDetailId, Long savingsId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes off an unclaimed share-dividend liability (see {@code m_share_dividend_lock}) to the
|
||||||
|
* bank's profit — called once a lock's 3-year release_due_date has passed with no member claim
|
||||||
|
* or manager action.
|
||||||
|
*/
|
||||||
|
void forfeitDividendLock(Long lockId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+77
@@ -18,8 +18,12 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.shareaccounts.service;
|
package org.apache.fineract.portfolio.shareaccounts.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||||
@@ -27,14 +31,22 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
|
|||||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendDetails;
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendDetails;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendStatusType;
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendStatusType;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
|
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
|
||||||
|
|
||||||
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
|
||||||
|
|
||||||
private final ShareAccountDividendRepository shareAccountDividendRepository;
|
private final ShareAccountDividendRepository shareAccountDividendRepository;
|
||||||
private final SavingsAccountDomainService savingsAccountDomainService;
|
private final SavingsAccountDomainService savingsAccountDomainService;
|
||||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -49,4 +61,69 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
this.shareAccountDividendRepository.saveAndFlush(shareAccountDividendDetails);
|
this.shareAccountDividendRepository.saveAndFlush(shareAccountDividendDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-world UCB accounting: an approved dividend is never credited into a member's savings
|
||||||
|
* account up front -- it stays purely a "Dividend Payable" GL liability (see
|
||||||
|
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the
|
||||||
|
* member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed
|
||||||
|
* dividend past its 3-year deadline has no savings hold to release and no account balance to
|
||||||
|
* deduct -- the liability is simply written off directly against the bank's profit.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void forfeitDividendLock(final Long lockId) {
|
||||||
|
final Map<String, Object> lock = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT dividend_detail_id, currency_code, amount, status
|
||||||
|
FROM m_share_dividend_lock WHERE id = ?
|
||||||
|
""", lockId).stream().findFirst().orElseThrow();
|
||||||
|
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String currencyCode = (String) lock.get("currency_code");
|
||||||
|
final BigDecimal amount = (BigDecimal) lock.get("amount");
|
||||||
|
|
||||||
|
postForfeitureJournalEntry(lockId, currencyCode, amount);
|
||||||
|
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debit writes off the Dividend Payable liability (it was never going to be paid); credit
|
||||||
|
* increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off.
|
||||||
|
* Reads currency straight off the lock row (set from the share account at creation time) --
|
||||||
|
* this never depended on a linked savings account existing. Skipped if the bank hasn't mapped
|
||||||
|
* both GL accounts yet, exactly like the declaration-time Reserve entry.
|
||||||
|
*/
|
||||||
|
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
|
||||||
|
final Long payableDividendsAccountId = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
final Long forfeitedIncomeAccountId = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||||
|
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||||
|
final String transactionId = "DIVFORFEIT" + lockId;
|
||||||
|
final String description = "Dividend forfeited to profit — share dividend lock #" + lockId;
|
||||||
|
final String insertSql = """
|
||||||
|
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, created_on_utc, last_modified_on_utc,
|
||||||
|
submitted_on_date, transaction_date)
|
||||||
|
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||||
|
""";
|
||||||
|
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
||||||
|
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
|
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
||||||
|
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -36,6 +36,7 @@ import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService
|
|||||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
|
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
|
||||||
import org.apache.fineract.commands.domain.CommandSourceRepository;
|
import org.apache.fineract.commands.domain.CommandSourceRepository;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
|
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRepository;
|
||||||
@@ -112,8 +113,10 @@ public class ShareAccountsConfiguration {
|
|||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
|
@ConditionalOnMissingBean(ShareAccountSchedularService.class)
|
||||||
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
|
public ShareAccountSchedularService shareAccountSchedularService(ShareAccountDividendRepository shareAccountDividendRepository,
|
||||||
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler) {
|
SavingsAccountDomainService savingsAccountDomainService, SavingsAccountAssembler savingsAccountAssembler,
|
||||||
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler);
|
PlatformSecurityContext context, JdbcTemplate jdbcTemplate) {
|
||||||
|
return new ShareAccountSchedularServiceImpl(shareAccountDividendRepository, savingsAccountDomainService, savingsAccountAssembler,
|
||||||
|
context, jdbcTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+9
@@ -42,6 +42,15 @@ public class ShareProductDividentPayOutDetailsRepositoryWrapper {
|
|||||||
this.shareProductDividentPayOutDetailsRepository.save(shareProductDividendPayOutDetails);
|
this.shareProductDividentPayOutDetailsRepository.save(shareProductDividendPayOutDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #save}, but flushes immediately so the generated id is guaranteed to be
|
||||||
|
* populated on the entity before the caller reads it (plain save() can leave a newly
|
||||||
|
* INSERTed entity's identity-generated id unset until the surrounding transaction flushes).
|
||||||
|
*/
|
||||||
|
public void saveAndFlush(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
|
||||||
|
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(shareProductDividendPayOutDetails);
|
||||||
|
}
|
||||||
|
|
||||||
public void delete(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
|
public void delete(final ShareProductDividendPayOutDetails shareProductDividendPayOutDetails) {
|
||||||
this.shareProductDividentPayOutDetailsRepository.delete(shareProductDividendPayOutDetails);
|
this.shareProductDividentPayOutDetailsRepository.delete(shareProductDividendPayOutDetails);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -115,6 +115,13 @@ public class ShareProductDividendAssembler {
|
|||||||
numberOfShares += purchasedSharesData.getNumberOfShares();
|
numberOfShares += purchasedSharesData.getNumberOfShares();
|
||||||
} else {
|
} else {
|
||||||
numberOfShares -= purchasedSharesData.getNumberOfShares();
|
numberOfShares -= purchasedSharesData.getNumberOfShares();
|
||||||
|
// A purchase that hadn't yet cleared the Minimum Active Period is skipped
|
||||||
|
// above (via `continue`) and never added here — but its later full/partial
|
||||||
|
// redemption is never skipped, so without this floor numberOfShares (and
|
||||||
|
// therefore this account's dividend) can go negative.
|
||||||
|
if (numberOfShares < 0) {
|
||||||
|
numberOfShares = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-1
@@ -64,6 +64,7 @@ import org.apache.fineract.portfolio.savings.domain.ProductPendingEditFieldMappe
|
|||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -85,6 +86,12 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
private final ProductPendingEditChargeRepository pendingEditChargeRepository;
|
private final ProductPendingEditChargeRepository pendingEditChargeRepository;
|
||||||
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
||||||
private final CommandSourceRepository commandSourceRepository;
|
private final CommandSourceRepository commandSourceRepository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
|
||||||
|
// accounting module into this constructor just for two int constants.
|
||||||
|
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
|
||||||
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
|
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
|
||||||
@@ -152,12 +159,17 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
final BigDecimal dividendAmount = this.fromApiJsonHelper
|
final BigDecimal dividendAmount = this.fromApiJsonHelper
|
||||||
.extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element);
|
.extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element);
|
||||||
|
|
||||||
|
validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate);
|
||||||
|
|
||||||
final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId,
|
final ShareProductDividendPayOutDetails dividendPayOutDetails = this.shareProductDividendAssembler.calculateDividends(productId,
|
||||||
dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate);
|
dividendAmount, dividendPeriodStartDate, dividendPeriodEndDate);
|
||||||
if (dividendPayOutDetails == null) {
|
if (dividendPayOutDetails == null) {
|
||||||
throw new DividentProcessingException("eligible.shares.not.found", "No eligible shares for creating dividends");
|
throw new DividentProcessingException("eligible.shares.not.found", "No eligible shares for creating dividends");
|
||||||
}
|
}
|
||||||
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
|
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
|
||||||
|
|
||||||
|
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
|
||||||
|
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount);
|
||||||
|
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId));
|
businessEventNotifierService.notifyPostBusinessEvent(new ShareProductDividentsCreateBusinessEvent(productId));
|
||||||
|
|
||||||
@@ -172,6 +184,68 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A product has no "rejected"/cancelled dividend status (only INITIATED and APPROVED, see
|
||||||
|
* {@code ShareProductDividendStatusType}) -- every declaration ever made against a product is
|
||||||
|
* a real reservation of that period's profit. Two declarations covering the same days would
|
||||||
|
* pay members twice for the same period, so any overlap against an existing declaration for
|
||||||
|
* this product is blocked outright.
|
||||||
|
*/
|
||||||
|
private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate,
|
||||||
|
final LocalDate dividendPeriodEndDate) {
|
||||||
|
final Integer overlappingCount = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_share_product_dividend_pay_out
|
||||||
|
WHERE product_id = ? AND dividend_period_start_date <= ? AND dividend_period_end_date >= ?
|
||||||
|
""", Integer.class, productId, java.sql.Date.valueOf(dividendPeriodEndDate),
|
||||||
|
java.sql.Date.valueOf(dividendPeriodStartDate));
|
||||||
|
if (overlappingCount != null && overlappingCount > 0) {
|
||||||
|
throw new DividentProcessingException("period.overlaps.existing.dividend",
|
||||||
|
"This product already has a dividend declared for a period that overlaps " + dividendPeriodStartDate + " to "
|
||||||
|
+ dividendPeriodEndDate + ". Choose a period that doesn't overlap with an existing declaration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-world UCB accounting: declaring a dividend sets aside that amount from the bank's own
|
||||||
|
* Reserve for Dividend (equity) into Dividend Payable (liability) — the profit doesn't just
|
||||||
|
* silently wait to be paid out with no accounting trace. Posted as a plain GL journal-entry
|
||||||
|
* pair rather than through the transaction-bound AccountingProcessorHelper (which expects a
|
||||||
|
* loan/savings/share *transaction*, not a standalone declaration event). Silently skipped if
|
||||||
|
* the bank hasn't mapped both Financial Activities under Accounting -> Financial Activity
|
||||||
|
* Mappings yet, so dividend declaration itself is never blocked by missing GL setup.
|
||||||
|
*/
|
||||||
|
private void postReserveForDividendJournalEntry(final Long dividendPayOutId, final String currencyCode, final BigDecimal amount) {
|
||||||
|
final Long reserveAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
if (reserveAccountId == null || payableDividendsAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||||
|
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||||
|
final String transactionId = "DIVRES" + dividendPayOutId;
|
||||||
|
final String description = "Reserve for Dividend declaration #" + dividendPayOutId;
|
||||||
|
final String insertSql = """
|
||||||
|
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, created_on_utc, last_modified_on_utc,
|
||||||
|
submitted_on_date, transaction_date)
|
||||||
|
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||||
|
""";
|
||||||
|
// Debit reduces the equity Reserve; credit increases the Dividend Payable liability.
|
||||||
|
jdbcTemplate.update(insertSql, reserveAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
||||||
|
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
|
jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
||||||
|
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult approveShareProductDividend(final Long PayOutDetailId) {
|
public CommandProcessingResult approveShareProductDividend(final Long PayOutDetailId) {
|
||||||
try {
|
try {
|
||||||
@@ -182,6 +256,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
}
|
}
|
||||||
dividendPayOutDetails.approveDividendPayout();
|
dividendPayOutDetails.approveDividendPayout();
|
||||||
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
|
this.shareProductDividentPayOutDetailsRepository.save(dividendPayOutDetails);
|
||||||
|
makeDividendsClaimable(PayOutDetailId);
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(PayOutDetailId) //
|
.withEntityId(PayOutDetailId) //
|
||||||
.build();
|
.build();
|
||||||
@@ -191,6 +266,44 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-world UCB accounting: approving a dividend does NOT credit anyone's savings account.
|
||||||
|
* Every member's entitlement stays purely a "Dividend Payable" GL liability (already posted at
|
||||||
|
* declaration time) until the member actually comes to claim it -- at which point it can be
|
||||||
|
* split between clearing an overdue loan, a savings credit, or cash (see
|
||||||
|
* {@code DividendClaimRequestApiResource}). This just marks each member's share of the
|
||||||
|
* declaration POSTED (their entitlement is locked in, no more recalculation) and opens a
|
||||||
|
* 3-year window (see {@code m_share_dividend_lock}, now an "unclaimed dividend" record rather
|
||||||
|
* than a savings hold) after which an unclaimed share is forfeited to the bank's profit. This
|
||||||
|
* is cheap enough (no per-member external command calls, just two bulk statements) to run
|
||||||
|
* synchronously in the approve request itself -- no background job needed.
|
||||||
|
*/
|
||||||
|
private void makeDividendsClaimable(final Long payOutDetailId) {
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
|
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
|
||||||
|
final java.sql.Date releaseDueDate = java.sql.Date.valueOf(today.plusYears(3));
|
||||||
|
|
||||||
|
// A linked savings account is no longer required to create the unclaimed-liability
|
||||||
|
// record -- disbursement destination (loan / savings / cash) is chosen when the member
|
||||||
|
// actually claims it, not up front. So every member gets treated identically here, whether
|
||||||
|
// their share account has a linked savings account or not; currency is read straight off
|
||||||
|
// the share account itself.
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_share_dividend_lock
|
||||||
|
(dividend_detail_id, client_id, savings_account_id, currency_code, amount, locked_date, release_due_date,
|
||||||
|
status, created_date, last_modified_date)
|
||||||
|
SELECT sad.id, sa.client_id, sa.savings_account_id, sa.currency_code, sad.amount, ?, ?, 'UNCLAIMED', now(), now()
|
||||||
|
FROM m_share_account_dividend_details sad
|
||||||
|
JOIN m_share_account sa ON sa.id = sad.account_id
|
||||||
|
WHERE sad.dividend_pay_out_id = ? AND sad.status = 100 AND sad.amount > 0
|
||||||
|
""", lockedDate, releaseDueDate, payOutDetailId);
|
||||||
|
|
||||||
|
// A member whose entitlement worked out to zero has nothing to claim; everyone else is now
|
||||||
|
// covered by the unclaimed-liability row above -- both get marked posted.
|
||||||
|
jdbcTemplate.update("UPDATE m_share_account_dividend_details SET status = 300 WHERE dividend_pay_out_id = ? AND status = 100",
|
||||||
|
payOutDetailId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult deleteShareProductDividend(final Long PayOutDetailId) {
|
public CommandProcessingResult deleteShareProductDividend(final Long PayOutDetailId) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-2
@@ -117,11 +117,11 @@ public class ShareProductsConfiguration {
|
|||||||
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
|
ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
|
||||||
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
||||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||||
CommandSourceRepository commandSourceRepository) {
|
CommandSourceRepository commandSourceRepository, JdbcTemplate jdbcTemplate) {
|
||||||
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
|
return new ShareProductWritePlatformServiceJpaRepositoryImpl(repository, serializer, fromApiJsonHelper,
|
||||||
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
|
shareProductDividentPayOutDetailsRepository, shareProductDividendAssembler, accountMappingWritePlatformService,
|
||||||
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
|
businessEventNotifierService, context, productPendingEditRepository, pendingEditSubmittedFieldRepository,
|
||||||
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository);
|
pendingEditChargeRepository, pendingEditGlMappingRepository, commandSourceRepository, jdbcTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -28,6 +28,7 @@ import lombok.Data;
|
|||||||
public class ClientWelcomeKitData implements Serializable {
|
public class ClientWelcomeKitData implements Serializable {
|
||||||
|
|
||||||
private final String clientName;
|
private final String clientName;
|
||||||
|
private final boolean clientActive;
|
||||||
private final boolean canDispatchNew;
|
private final boolean canDispatchNew;
|
||||||
private final List<WelcomeKitDispatchData> dispatches;
|
private final List<WelcomeKitDispatchData> dispatches;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -31,10 +31,13 @@ public class WelcomeKitDispatchData implements Serializable {
|
|||||||
private final Long clientId;
|
private final Long clientId;
|
||||||
private final String clientName;
|
private final String clientName;
|
||||||
private final String deliveryAddress;
|
private final String deliveryAddress;
|
||||||
|
private final Long addressId;
|
||||||
private final String deliveryMode;
|
private final String deliveryMode;
|
||||||
private final String deliveryModeOtherText;
|
private final String deliveryModeOtherText;
|
||||||
private final String trackingId;
|
private final String trackingId;
|
||||||
private final String status;
|
private final String status;
|
||||||
private final LocalDate dispatchedOnDate;
|
private final LocalDate dispatchedOnDate;
|
||||||
private final LocalDate deliveredOnDate;
|
private final LocalDate deliveredOnDate;
|
||||||
|
private final LocalDate returnedOnDate;
|
||||||
|
private final String remarks;
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-7
@@ -39,6 +39,13 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
|||||||
@EntityListeners(AuditingEntityListener.class)
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
/** Kit handed over to the delivery partner; awaiting the outcome. */
|
||||||
|
public static final String STATUS_DISPATCHED = "DISPATCHED";
|
||||||
|
/** Delivery partner confirmed delivery, so the address stands verified. */
|
||||||
|
public static final String STATUS_DELIVERED = "DELIVERED";
|
||||||
|
/** Kit came back undelivered, so the address could not be verified. */
|
||||||
|
public static final String STATUS_RETURNED = "RETURNED";
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
private Client client;
|
private Client client;
|
||||||
@@ -46,6 +53,13 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "delivery_address", length = 500, nullable = false)
|
@Column(name = "delivery_address", length = 500, nullable = false)
|
||||||
private String deliveryAddress;
|
private String deliveryAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client address this kit was posted to. Kept as a plain id rather than an association because the
|
||||||
|
* address it points at may later be edited or removed, while the dispatch record must stay as it was.
|
||||||
|
*/
|
||||||
|
@Column(name = "address_id")
|
||||||
|
private Long addressId;
|
||||||
|
|
||||||
@Column(name = "delivery_mode", length = 50, nullable = false)
|
@Column(name = "delivery_mode", length = 50, nullable = false)
|
||||||
private String deliveryMode;
|
private String deliveryMode;
|
||||||
|
|
||||||
@@ -64,6 +78,12 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "delivered_on_date")
|
@Column(name = "delivered_on_date")
|
||||||
private LocalDate deliveredOnDate;
|
private LocalDate deliveredOnDate;
|
||||||
|
|
||||||
|
@Column(name = "returned_on_date")
|
||||||
|
private LocalDate returnedOnDate;
|
||||||
|
|
||||||
|
@Column(name = "remarks", length = 500)
|
||||||
|
private String remarks;
|
||||||
|
|
||||||
@CreatedBy
|
@CreatedBy
|
||||||
@Column(name = "created_by", updatable = false)
|
@Column(name = "created_by", updatable = false)
|
||||||
private Long createdBy;
|
private Long createdBy;
|
||||||
@@ -84,11 +104,12 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
// Default constructor for JPA
|
// Default constructor for JPA
|
||||||
}
|
}
|
||||||
|
|
||||||
private WelcomeKitDispatch(final Client client, final String deliveryAddress, final String deliveryMode,
|
private WelcomeKitDispatch(final Client client, final String deliveryAddress, final Long addressId, final String deliveryMode,
|
||||||
final String deliveryModeOtherText, final String trackingId, final String status,
|
final String deliveryModeOtherText, final String trackingId, final String status,
|
||||||
final LocalDate dispatchedOnDate) {
|
final LocalDate dispatchedOnDate) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.deliveryAddress = deliveryAddress;
|
this.deliveryAddress = deliveryAddress;
|
||||||
|
this.addressId = addressId;
|
||||||
this.deliveryMode = deliveryMode;
|
this.deliveryMode = deliveryMode;
|
||||||
this.deliveryModeOtherText = deliveryModeOtherText;
|
this.deliveryModeOtherText = deliveryModeOtherText;
|
||||||
this.trackingId = trackingId;
|
this.trackingId = trackingId;
|
||||||
@@ -96,9 +117,10 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
this.dispatchedOnDate = dispatchedOnDate;
|
this.dispatchedOnDate = dispatchedOnDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final String deliveryMode,
|
public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final Long addressId,
|
||||||
final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
|
final String deliveryMode, final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
|
||||||
return new WelcomeKitDispatch(client, deliveryAddress, deliveryMode, deliveryModeOtherText, trackingId, "DISPATCHED", dispatchedOnDate);
|
return new WelcomeKitDispatch(client, deliveryAddress, addressId, deliveryMode, deliveryModeOtherText, trackingId,
|
||||||
|
STATUS_DISPATCHED, dispatchedOnDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Client getClient() {
|
public Client getClient() {
|
||||||
@@ -109,10 +131,23 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
return this.status;
|
return this.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateStatus(final String newStatus, final LocalDate deliveredOnDate) {
|
public String getTrackingId() {
|
||||||
|
return this.trackingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the outcome the operator read off the delivery partner's website or app. DELIVERED stamps the delivery
|
||||||
|
* date and clears any previous return date, RETURNED does the reverse, so the two dates can never both be set.
|
||||||
|
*/
|
||||||
|
public void updateStatus(final String newStatus, final LocalDate outcomeDate, final String remarks) {
|
||||||
this.status = newStatus;
|
this.status = newStatus;
|
||||||
if ("DELIVERED".equals(newStatus)) {
|
if (STATUS_DELIVERED.equals(newStatus)) {
|
||||||
this.deliveredOnDate = deliveredOnDate;
|
this.deliveredOnDate = outcomeDate;
|
||||||
|
this.returnedOnDate = null;
|
||||||
|
} else if (STATUS_RETURNED.equals(newStatus)) {
|
||||||
|
this.returnedOnDate = outcomeDate;
|
||||||
|
this.deliveredOnDate = null;
|
||||||
}
|
}
|
||||||
|
this.remarks = remarks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -25,4 +25,6 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
|||||||
public interface WelcomeKitDispatchRepository extends JpaRepository<WelcomeKitDispatch, Long>, JpaSpecificationExecutor<WelcomeKitDispatch> {
|
public interface WelcomeKitDispatchRepository extends JpaRepository<WelcomeKitDispatch, Long>, JpaSpecificationExecutor<WelcomeKitDispatch> {
|
||||||
|
|
||||||
List<WelcomeKitDispatch> findByClientIdOrderByCreatedDateDesc(Long clientId);
|
List<WelcomeKitDispatch> findByClientIdOrderByCreatedDateDesc(Long clientId);
|
||||||
|
|
||||||
|
boolean existsByTrackingIdIgnoreCase(String trackingId);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-11
@@ -22,10 +22,13 @@ import java.sql.ResultSet;
|
|||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientStatus;
|
||||||
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
|
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
|
||||||
import org.apache.fineract.portfolio.welcomekit.data.WelcomeKitDispatchData;
|
import org.apache.fineract.portfolio.welcomekit.data.WelcomeKitDispatchData;
|
||||||
|
import org.apache.fineract.portfolio.welcomekit.domain.WelcomeKitDispatch;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -43,20 +46,21 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
|
|||||||
|
|
||||||
List<WelcomeKitDispatchData> dispatches = this.jdbcTemplate.query(sql, rm, clientId);
|
List<WelcomeKitDispatchData> dispatches = this.jdbcTemplate.query(sql, rm, clientId);
|
||||||
|
|
||||||
String clientName = this.jdbcTemplate.queryForObject("SELECT display_name FROM m_client WHERE id = ?", String.class, clientId);
|
final Map<String, Object> client = this.jdbcTemplate.queryForMap("SELECT display_name, status_enum FROM m_client WHERE id = ?",
|
||||||
|
clientId);
|
||||||
|
final String clientName = (String) client.get("display_name");
|
||||||
|
final boolean clientActive = ClientStatus.fromInt((Integer) client.get("status_enum")).isActive();
|
||||||
|
|
||||||
boolean canDispatchNew = true;
|
// A kit only goes out for an active client, and only one kit is in flight at a time: a fresh one can follow
|
||||||
if (!dispatches.isEmpty()) {
|
// a returned kit (the address is being retried) but never a delivered or still-in-transit one.
|
||||||
WelcomeKitDispatchData latest = dispatches.get(0);
|
boolean canDispatchNew = clientActive;
|
||||||
if ("RETURNED".equals(latest.getStatus())) {
|
if (clientActive && !dispatches.isEmpty()) {
|
||||||
canDispatchNew = true;
|
canDispatchNew = WelcomeKitDispatch.STATUS_RETURNED.equals(dispatches.get(0).getStatus());
|
||||||
} else {
|
|
||||||
canDispatchNew = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ClientWelcomeKitData.builder()
|
return ClientWelcomeKitData.builder()
|
||||||
.clientName(clientName)
|
.clientName(clientName)
|
||||||
|
.clientActive(clientActive)
|
||||||
.canDispatchNew(canDispatchNew)
|
.canDispatchNew(canDispatchNew)
|
||||||
.dispatches(dispatches)
|
.dispatches(dispatches)
|
||||||
.build();
|
.build();
|
||||||
@@ -66,9 +70,10 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
|
|||||||
|
|
||||||
public String schema() {
|
public String schema() {
|
||||||
return " w.id as id, w.client_id as clientId, c.display_name as clientName, "
|
return " w.id as id, w.client_id as clientId, c.display_name as clientName, "
|
||||||
+ " w.delivery_address as deliveryAddress, w.delivery_mode as deliveryMode, "
|
+ " w.delivery_address as deliveryAddress, w.address_id as addressId, w.delivery_mode as deliveryMode, "
|
||||||
+ " w.delivery_mode_other_text as deliveryModeOtherText, w.tracking_id as trackingId, "
|
+ " w.delivery_mode_other_text as deliveryModeOtherText, w.tracking_id as trackingId, "
|
||||||
+ " w.status as status, w.dispatched_on_date as dispatchedOnDate, w.delivered_on_date as deliveredOnDate "
|
+ " w.status as status, w.dispatched_on_date as dispatchedOnDate, w.delivered_on_date as deliveredOnDate, "
|
||||||
|
+ " w.returned_on_date as returnedOnDate, w.remarks as remarks "
|
||||||
+ " FROM m_welcome_kit_dispatch w "
|
+ " FROM m_welcome_kit_dispatch w "
|
||||||
+ " JOIN m_client c ON c.id = w.client_id ";
|
+ " JOIN m_client c ON c.id = w.client_id ";
|
||||||
}
|
}
|
||||||
@@ -79,24 +84,31 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
|
|||||||
final Long clientId = rs.getLong("clientId");
|
final Long clientId = rs.getLong("clientId");
|
||||||
final String clientName = rs.getString("clientName");
|
final String clientName = rs.getString("clientName");
|
||||||
final String deliveryAddress = rs.getString("deliveryAddress");
|
final String deliveryAddress = rs.getString("deliveryAddress");
|
||||||
|
// Nullable: kits dispatched before the address link existed carry no address id.
|
||||||
|
final Long addressId = JdbcSupport.getLong(rs, "addressId");
|
||||||
final String deliveryMode = rs.getString("deliveryMode");
|
final String deliveryMode = rs.getString("deliveryMode");
|
||||||
final String deliveryModeOtherText = rs.getString("deliveryModeOtherText");
|
final String deliveryModeOtherText = rs.getString("deliveryModeOtherText");
|
||||||
final String trackingId = rs.getString("trackingId");
|
final String trackingId = rs.getString("trackingId");
|
||||||
final String status = rs.getString("status");
|
final String status = rs.getString("status");
|
||||||
final LocalDate dispatchedOnDate = JdbcSupport.getLocalDate(rs, "dispatchedOnDate");
|
final LocalDate dispatchedOnDate = JdbcSupport.getLocalDate(rs, "dispatchedOnDate");
|
||||||
final LocalDate deliveredOnDate = JdbcSupport.getLocalDate(rs, "deliveredOnDate");
|
final LocalDate deliveredOnDate = JdbcSupport.getLocalDate(rs, "deliveredOnDate");
|
||||||
|
final LocalDate returnedOnDate = JdbcSupport.getLocalDate(rs, "returnedOnDate");
|
||||||
|
final String remarks = rs.getString("remarks");
|
||||||
|
|
||||||
return WelcomeKitDispatchData.builder()
|
return WelcomeKitDispatchData.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.clientId(clientId)
|
.clientId(clientId)
|
||||||
.clientName(clientName)
|
.clientName(clientName)
|
||||||
.deliveryAddress(deliveryAddress)
|
.deliveryAddress(deliveryAddress)
|
||||||
|
.addressId(addressId)
|
||||||
.deliveryMode(deliveryMode)
|
.deliveryMode(deliveryMode)
|
||||||
.deliveryModeOtherText(deliveryModeOtherText)
|
.deliveryModeOtherText(deliveryModeOtherText)
|
||||||
.trackingId(trackingId)
|
.trackingId(trackingId)
|
||||||
.status(status)
|
.status(status)
|
||||||
.dispatchedOnDate(dispatchedOnDate)
|
.dispatchedOnDate(dispatchedOnDate)
|
||||||
.deliveredOnDate(deliveredOnDate)
|
.deliveredOnDate(deliveredOnDate)
|
||||||
|
.returnedOnDate(returnedOnDate)
|
||||||
|
.remarks(remarks)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-8
@@ -19,12 +19,12 @@
|
|||||||
package org.apache.fineract.portfolio.welcomekit.service;
|
package org.apache.fineract.portfolio.welcomekit.service;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
|
||||||
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.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||||
@@ -48,9 +48,16 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
|
|||||||
|
|
||||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
|
if (!client.isActive()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.client.must.be.active",
|
||||||
|
"A welcome kit can only be dispatched for an active client.");
|
||||||
|
}
|
||||||
|
|
||||||
final String deliveryAddress = command.stringValueOfParameterNamed("deliveryAddress");
|
final String deliveryAddress = command.stringValueOfParameterNamed("deliveryAddress");
|
||||||
final String deliveryMode = command.stringValueOfParameterNamed("deliveryMode");
|
final String deliveryMode = command.stringValueOfParameterNamed("deliveryMode");
|
||||||
final String deliveryModeOtherText = command.stringValueOfParameterNamed("deliveryModeOtherText");
|
final String deliveryModeOtherText = command.stringValueOfParameterNamed("deliveryModeOtherText");
|
||||||
|
final String trackingId = command.stringValueOfParameterNamed("trackingId");
|
||||||
|
final Long addressId = command.longValueOfParameterNamed("addressId");
|
||||||
final LocalDate dispatchedOnDate = command.localDateValueOfParameterNamed("dispatchedOnDate");
|
final LocalDate dispatchedOnDate = command.localDateValueOfParameterNamed("dispatchedOnDate");
|
||||||
|
|
||||||
if (deliveryAddress == null || deliveryAddress.trim().isEmpty()) {
|
if (deliveryAddress == null || deliveryAddress.trim().isEmpty()) {
|
||||||
@@ -61,15 +68,29 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
|
|||||||
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryMode.cannot.be.blank",
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryMode.cannot.be.blank",
|
||||||
"Delivery mode cannot be blank.");
|
"Delivery mode cannot be blank.");
|
||||||
}
|
}
|
||||||
|
if (trackingId == null || trackingId.trim().isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.trackingId.cannot.be.blank",
|
||||||
|
"Tracking ID cannot be blank.");
|
||||||
|
}
|
||||||
if (dispatchedOnDate == null) {
|
if (dispatchedOnDate == null) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.cannot.be.blank",
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.cannot.be.blank",
|
||||||
"Dispatched on date cannot be blank.");
|
"Dispatched on date cannot be blank.");
|
||||||
}
|
}
|
||||||
|
if (dispatchedOnDate.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.in.the.future",
|
||||||
|
"Dispatched on date cannot be in the future.");
|
||||||
|
}
|
||||||
|
|
||||||
final String trackingId = "WK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
// The tracking ID is transcribed by hand from the delivery partner's receipt, so a typo could otherwise
|
||||||
|
// silently attach one client's consignment number to another client's kit.
|
||||||
|
final String sanitisedTrackingId = trackingId.trim();
|
||||||
|
if (this.welcomeKitDispatchRepository.existsByTrackingIdIgnoreCase(sanitisedTrackingId)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.trackingId.duplicate",
|
||||||
|
"Tracking ID " + sanitisedTrackingId + " is already recorded against another welcome kit dispatch.");
|
||||||
|
}
|
||||||
|
|
||||||
final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, deliveryMode, deliveryModeOtherText,
|
final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, addressId, deliveryMode,
|
||||||
trackingId, dispatchedOnDate);
|
deliveryModeOtherText, sanitisedTrackingId, dispatchedOnDate);
|
||||||
|
|
||||||
this.welcomeKitDispatchRepository.saveAndFlush(newDispatch);
|
this.welcomeKitDispatchRepository.saveAndFlush(newDispatch);
|
||||||
|
|
||||||
@@ -94,12 +115,30 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
|
|||||||
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.cannot.be.blank", "Status cannot be blank.");
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.cannot.be.blank", "Status cannot be blank.");
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDate deliveredOnDate = null;
|
final String newStatus = status.trim().toUpperCase();
|
||||||
if ("DELIVERED".equals(status)) {
|
if (!WelcomeKitDispatch.STATUS_DELIVERED.equals(newStatus) && !WelcomeKitDispatch.STATUS_RETURNED.equals(newStatus)) {
|
||||||
deliveredOnDate = LocalDate.now();
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.invalid",
|
||||||
|
"Status must be either " + WelcomeKitDispatch.STATUS_DELIVERED + " or " + WelcomeKitDispatch.STATUS_RETURNED + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch.updateStatus(status, deliveredOnDate);
|
// The outcome is recorded once. A kit that already came back is replaced by a fresh dispatch rather than
|
||||||
|
// being flipped to delivered after the fact.
|
||||||
|
if (!WelcomeKitDispatch.STATUS_DISPATCHED.equals(dispatch.getStatus())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.outcome.already.recorded",
|
||||||
|
"This welcome kit is already marked as " + dispatch.getStatus() + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The outcome date is optional; the operator may only know the day they checked the courier's site.
|
||||||
|
LocalDate outcomeDate = command.localDateValueOfParameterNamed("outcomeDate");
|
||||||
|
if (outcomeDate == null) {
|
||||||
|
outcomeDate = DateUtils.getBusinessLocalDate();
|
||||||
|
}
|
||||||
|
if (outcomeDate.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.outcomeDate.in.the.future",
|
||||||
|
"Outcome date cannot be in the future.");
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch.updateStatus(newStatus, outcomeDate, command.stringValueOfParameterNamed("remarks"));
|
||||||
|
|
||||||
this.welcomeKitDispatchRepository.saveAndFlush(dispatch);
|
this.welcomeKitDispatchRepository.saveAndFlush(dispatch);
|
||||||
|
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package org.apache.fineract.useradministration.api;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.PUT;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.data.AppUserPreferenceData;
|
||||||
|
import org.apache.fineract.useradministration.service.AppUserPreferenceReadPlatformService;
|
||||||
|
import org.apache.fineract.useradministration.service.AppUserPreferenceWritePlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Path("/v1/users/{userId}/preferences")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "User Preferences", description = "API to manage user specific preferences.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppUserPreferencesApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final AppUserPreferenceReadPlatformService readPlatformService;
|
||||||
|
private final AppUserPreferenceWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
private void validateUserAuthorization(Long userId) {
|
||||||
|
if (!context.authenticatedUser().getId().equals(userId)) {
|
||||||
|
throw new NoAuthorizationException("Not authorized to access or modify preferences for another user.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{preferenceName}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve user preference", description = "Retrieves a specific preference for the user.")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class))) })
|
||||||
|
public AppUserPreferenceData getPreference(@PathParam("userId") @Parameter(description = "userId") final Long userId,
|
||||||
|
@PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName) {
|
||||||
|
|
||||||
|
validateUserAuthorization(userId);
|
||||||
|
String value = readPlatformService.getPreferenceValue(userId, preferenceName);
|
||||||
|
return new AppUserPreferenceData(preferenceName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("{preferenceName}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Update user preference", description = "Updates or creates a specific preference for the user.")
|
||||||
|
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AppUserPreferenceData.class)))
|
||||||
|
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||||
|
public AppUserPreferenceData updatePreference(@PathParam("userId") @Parameter(description = "userId") final Long userId,
|
||||||
|
@PathParam("preferenceName") @Parameter(description = "preferenceName") final String preferenceName,
|
||||||
|
final AppUserPreferenceData preferenceData) {
|
||||||
|
|
||||||
|
validateUserAuthorization(userId);
|
||||||
|
|
||||||
|
// Ensure the path parameter matches the request body if the request body contains a name
|
||||||
|
String valueToSave = preferenceData != null ? preferenceData.getValue() : null;
|
||||||
|
if (valueToSave == null) {
|
||||||
|
throw new IllegalArgumentException("Preference value cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
writePlatformService.updatePreference(userId, preferenceName, valueToSave);
|
||||||
|
return new AppUserPreferenceData(preferenceName, valueToSave);
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -88,6 +88,11 @@ public class UsersApiResource {
|
|||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
|
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
|
||||||
private final BulkImportWorkbookService bulkImportWorkbookService;
|
private final BulkImportWorkbookService bulkImportWorkbookService;
|
||||||
|
private final org.apache.fineract.useradministration.service.UserPendingWritePlatformService userPendingWritePlatformService;
|
||||||
|
private final org.apache.fineract.useradministration.service.UserPendingReadPlatformService userPendingReadPlatformService;
|
||||||
|
private final org.apache.fineract.infrastructure.core.serialization.FromJsonHelper fromApiJsonHelper;
|
||||||
|
private final org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService configurationDomainService;
|
||||||
|
private final DefaultToApiJsonSerializer<org.apache.fineract.useradministration.data.UserPendingRequestData> pendingRequestApiJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Operation(summary = "Retrieve list of users", description = "Example Requests:\n" + "\n" + "users\n" + "\n" + "\n"
|
@Operation(summary = "Retrieve list of users", description = "Example Requests:\n" + "\n" + "users\n" + "\n" + "\n"
|
||||||
@@ -158,6 +163,12 @@ public class UsersApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
if (this.configurationDomainService.isMakerCheckerEnabled()) {
|
||||||
|
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
|
||||||
|
.fromJsonElement(null, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
|
||||||
|
final CommandProcessingResult result = this.userPendingWritePlatformService.submitCreateUser(command);
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||||
.createUser() //
|
.createUser() //
|
||||||
@@ -179,6 +190,12 @@ public class UsersApiResource {
|
|||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId,
|
public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId,
|
||||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
if (this.configurationDomainService.isMakerCheckerEnabled()) {
|
||||||
|
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
|
||||||
|
.fromJsonElement(userId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
|
||||||
|
final CommandProcessingResult result = this.userPendingWritePlatformService.submitUpdateUser(userId, command);
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||||
.updateUser(userId) //
|
.updateUser(userId) //
|
||||||
@@ -190,6 +207,56 @@ public class UsersApiResource {
|
|||||||
return this.toApiJsonSerializer.serialize(result);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("pending")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List User creations & edits pending approval")
|
||||||
|
public org.apache.fineract.infrastructure.core.service.Page<org.apache.fineract.useradministration.data.UserPendingRequestData> retrieveAllPendingUsers(
|
||||||
|
@QueryParam("status") final String status,
|
||||||
|
@QueryParam("username") final String username,
|
||||||
|
@QueryParam("fromDate") final String fromDate,
|
||||||
|
@QueryParam("toDate") final String toDate,
|
||||||
|
@QueryParam("offset") final Integer offset,
|
||||||
|
@QueryParam("limit") final Integer limit) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
return this.userPendingReadPlatformService.retrieveAllPendingRequests(status, username, fromDate, toDate, offset, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("pending/{id}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve a pending user request by ID")
|
||||||
|
public String retrievePendingUser(@PathParam("id") final Long id, @Context final UriInfo uriInfo) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
final org.apache.fineract.useradministration.data.UserPendingRequestData data = this.userPendingReadPlatformService.retrievePendingRequest(id);
|
||||||
|
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
||||||
|
return this.pendingRequestApiJsonSerializer.serialize(settings, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("pending/{id}/approve")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Approve a pending User creation or update")
|
||||||
|
public String approvePendingUser(@PathParam("id") final Long id) {
|
||||||
|
final CommandProcessingResult result = this.userPendingWritePlatformService.approvePendingRequest(id);
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("pending/{id}/reject")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Reject a pending User creation or update")
|
||||||
|
public String rejectPendingUser(@PathParam("id") final Long id, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
|
||||||
|
.fromJsonElement(id, this.fromApiJsonHelper.parse(apiRequestBodyAsJson), this.fromApiJsonHelper);
|
||||||
|
final CommandProcessingResult result = this.userPendingWritePlatformService.rejectPendingRequest(id, command);
|
||||||
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("{userId}/pwd")
|
@Path("{userId}/pwd")
|
||||||
@Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.")
|
@Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.")
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package org.apache.fineract.useradministration.data;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AppUserPreferenceData {
|
||||||
|
private String name;
|
||||||
|
private String value;
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public class UserPendingRequestData implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long userId;
|
||||||
|
private final String requestType;
|
||||||
|
private final String status;
|
||||||
|
private final String username;
|
||||||
|
private final String firstname;
|
||||||
|
private final String lastname;
|
||||||
|
private final String email;
|
||||||
|
private final String mobileNo;
|
||||||
|
private final Long officeId;
|
||||||
|
private final String officeName;
|
||||||
|
private final Long staffId;
|
||||||
|
private final String staffName;
|
||||||
|
private final String otpDeliveryMethod;
|
||||||
|
private final Boolean passwordNeverExpires;
|
||||||
|
private final Collection<String> roleNames;
|
||||||
|
private final Long makerId;
|
||||||
|
private final String makerUsername;
|
||||||
|
private final OffsetDateTime submittedDate;
|
||||||
|
private final Long checkerId;
|
||||||
|
private final String checkerUsername;
|
||||||
|
private final OffsetDateTime checkedDate;
|
||||||
|
private final String rejectReason;
|
||||||
|
private final Map<String, Object> originalValues;
|
||||||
|
private final Map<String, Object> proposedValues;
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package org.apache.fineract.useradministration.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_appuser_preference")
|
||||||
|
public class AppUserPreference extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "appuser_id", nullable = false)
|
||||||
|
private AppUser appUser;
|
||||||
|
|
||||||
|
@Column(name = "name", length = 100, nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "value", nullable = false)
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
protected AppUserPreference() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUserPreference(final AppUser appUser, final String name, final String value) {
|
||||||
|
this.appUser = appUser;
|
||||||
|
this.name = name;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppUserPreference create(final AppUser appUser, final String name, final String value) {
|
||||||
|
return new AppUserPreference(appUser, name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateValue(final String newValue) {
|
||||||
|
this.value = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppUser getAppUser() {
|
||||||
|
return this.appUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package org.apache.fineract.useradministration.domain;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AppUserPreferenceRepository extends JpaRepository<AppUserPreference, Long> {
|
||||||
|
|
||||||
|
Optional<AppUserPreference> findByAppUserIdAndName(Long appUserId, String name);
|
||||||
|
|
||||||
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_user_pending_request")
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class UserPendingRequest extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "request_type", nullable = false, length = 20)
|
||||||
|
private String requestType;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "payload_json", nullable = false)
|
||||||
|
private String payloadJson;
|
||||||
|
|
||||||
|
@Column(name = "original_json")
|
||||||
|
private String originalJson;
|
||||||
|
|
||||||
|
@Column(name = "password_hash")
|
||||||
|
private String passwordHash;
|
||||||
|
|
||||||
|
@Column(name = "maker_id", nullable = false)
|
||||||
|
private Long makerId;
|
||||||
|
|
||||||
|
@Column(name = "submitted_date", nullable = false)
|
||||||
|
private OffsetDateTime submittedDate;
|
||||||
|
|
||||||
|
@Column(name = "checker_id")
|
||||||
|
private Long checkerId;
|
||||||
|
|
||||||
|
@Column(name = "checked_date")
|
||||||
|
private OffsetDateTime checkedDate;
|
||||||
|
|
||||||
|
@Column(name = "reject_reason", length = 500)
|
||||||
|
private String rejectReason;
|
||||||
|
|
||||||
|
@Column(name = "pending_lock_key", length = 100, unique = true)
|
||||||
|
private String pendingLockKey;
|
||||||
|
|
||||||
|
public static UserPendingRequest create(final UserPendingRequestType requestType, final Long userId,
|
||||||
|
final String payloadJson, final String originalJson, final String passwordHash,
|
||||||
|
final Long makerId, final String pendingLockKey) {
|
||||||
|
final UserPendingRequest request = new UserPendingRequest();
|
||||||
|
request.requestType = requestType.name();
|
||||||
|
request.userId = userId;
|
||||||
|
request.status = UserPendingRequestStatus.PENDING.name();
|
||||||
|
request.payloadJson = payloadJson;
|
||||||
|
request.originalJson = originalJson;
|
||||||
|
request.passwordHash = passwordHash;
|
||||||
|
request.makerId = makerId;
|
||||||
|
request.submittedDate = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
|
request.pendingLockKey = pendingLockKey;
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPending() {
|
||||||
|
return UserPendingRequestStatus.PENDING.name().equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMadeBy(final Long appUserId) {
|
||||||
|
return this.makerId != null && this.makerId.equals(appUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void approve(final Long checkerId) {
|
||||||
|
this.status = UserPendingRequestStatus.APPROVED.name();
|
||||||
|
this.checkerId = checkerId;
|
||||||
|
this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
|
this.pendingLockKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reject(final Long checkerId, final String reason) {
|
||||||
|
this.status = UserPendingRequestStatus.REJECTED.name();
|
||||||
|
this.checkerId = checkerId;
|
||||||
|
this.checkedDate = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
|
this.rejectReason = reason;
|
||||||
|
this.pendingLockKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.domain;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
|
||||||
|
public interface UserPendingRequestRepository extends JpaRepository<UserPendingRequest, Long>, JpaSpecificationExecutor<UserPendingRequest> {
|
||||||
|
|
||||||
|
Optional<UserPendingRequest> findByPendingLockKey(String pendingLockKey);
|
||||||
|
|
||||||
|
boolean existsByPendingLockKey(String pendingLockKey);
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.domain;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.useradministration.exception.UserPendingRequestNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserPendingRequestRepositoryWrapper {
|
||||||
|
|
||||||
|
private final UserPendingRequestRepository repository;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public UserPendingRequest findOneWithNotFoundDetection(final Long id) {
|
||||||
|
return this.repository.findById(id).orElseThrow(() -> new UserPendingRequestNotFoundException(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserPendingRequest saveAndFlush(final UserPendingRequest request) {
|
||||||
|
return this.repository.saveAndFlush(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserPendingRequest save(final UserPendingRequest request) {
|
||||||
|
return this.repository.save(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean existsByPendingLockKey(final String pendingLockKey) {
|
||||||
|
return this.repository.existsByPendingLockKey(pendingLockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.domain;
|
||||||
|
|
||||||
|
public enum UserPendingRequestStatus {
|
||||||
|
PENDING,
|
||||||
|
APPROVED,
|
||||||
|
REJECTED;
|
||||||
|
|
||||||
|
public static UserPendingRequestStatus fromString(final String status) {
|
||||||
|
if (status == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final UserPendingRequestStatus requestStatus : values()) {
|
||||||
|
if (requestStatus.name().equalsIgnoreCase(status)) {
|
||||||
|
return requestStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown UserPendingRequestStatus: " + status);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.domain;
|
||||||
|
|
||||||
|
public enum UserPendingRequestType {
|
||||||
|
CREATE_USER,
|
||||||
|
UPDATE_USER;
|
||||||
|
|
||||||
|
public static UserPendingRequestType fromString(final String type) {
|
||||||
|
if (type == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final UserPendingRequestType requestType : values()) {
|
||||||
|
if (requestType.name().equalsIgnoreCase(type)) {
|
||||||
|
return requestType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown UserPendingRequestType: " + type);
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
|
||||||
|
public class UserPendingRequestNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public UserPendingRequestNotFoundException(final Long id) {
|
||||||
|
super("error.msg.userpendingrequest.id.invalid", "User pending request with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package org.apache.fineract.useradministration.service;
|
||||||
|
|
||||||
|
public interface AppUserPreferenceReadPlatformService {
|
||||||
|
String getPreferenceValue(Long appUserId, String name);
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package org.apache.fineract.useradministration.service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUserPreference;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppUserPreferenceReadPlatformServiceImpl implements AppUserPreferenceReadPlatformService {
|
||||||
|
|
||||||
|
private final AppUserPreferenceRepository appUserPreferenceRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPreferenceValue(Long appUserId, String name) {
|
||||||
|
Optional<AppUserPreference> pref = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name);
|
||||||
|
return pref.map(AppUserPreference::getValue).orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package org.apache.fineract.useradministration.service;
|
||||||
|
|
||||||
|
public interface AppUserPreferenceWritePlatformService {
|
||||||
|
void updatePreference(Long appUserId, String name, String value);
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package org.apache.fineract.useradministration.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUserPreference;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUserPreferenceRepository;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUserRepository;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppUserPreferenceWritePlatformServiceImpl implements AppUserPreferenceWritePlatformService {
|
||||||
|
|
||||||
|
private final AppUserPreferenceRepository appUserPreferenceRepository;
|
||||||
|
private final AppUserRepository appUserRepository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public void updatePreference(Long appUserId, String name, String value) {
|
||||||
|
AppUserPreference preference = appUserPreferenceRepository.findByAppUserIdAndName(appUserId, name).orElse(null);
|
||||||
|
if (preference == null) {
|
||||||
|
AppUser appUser = appUserRepository.findById(appUserId)
|
||||||
|
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.user.id.invalid", "User not found"));
|
||||||
|
preference = AppUserPreference.create(appUser, name, value);
|
||||||
|
} else {
|
||||||
|
preference.updateValue(value);
|
||||||
|
}
|
||||||
|
appUserPreferenceRepository.saveAndFlush(preference);
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.service.Page;
|
||||||
|
import org.apache.fineract.useradministration.data.UserPendingRequestData;
|
||||||
|
|
||||||
|
public interface UserPendingReadPlatformService {
|
||||||
|
|
||||||
|
Page<UserPendingRequestData> retrieveAllPendingRequests(String status, String username, String fromDate, String toDate,
|
||||||
|
Integer offset, Integer limit);
|
||||||
|
|
||||||
|
UserPendingRequestData retrievePendingRequest(Long id);
|
||||||
|
}
|
||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* 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.useradministration.service;
|
||||||
|
|
||||||
|
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 java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
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.domain.JdbcSupport;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.Page;
|
||||||
|
import org.apache.fineract.useradministration.data.UserPendingRequestData;
|
||||||
|
import org.apache.fineract.useradministration.domain.UserPendingRequest;
|
||||||
|
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatformService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
|
||||||
|
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
|
||||||
|
|
||||||
|
private static final String SELECT_SQL = """
|
||||||
|
SELECT r.id AS id,
|
||||||
|
r.user_id AS userId,
|
||||||
|
r.request_type AS requestType,
|
||||||
|
r.status AS status,
|
||||||
|
r.payload_json AS payloadJson,
|
||||||
|
r.original_json AS originalJson,
|
||||||
|
r.maker_id AS makerId,
|
||||||
|
maker.username AS makerUsername,
|
||||||
|
r.submitted_date AS submittedDate,
|
||||||
|
r.checker_id AS checkerId,
|
||||||
|
checker.username AS checkerUsername,
|
||||||
|
r.checked_date AS checkedDate,
|
||||||
|
r.reject_reason AS rejectReason
|
||||||
|
FROM m_user_pending_request r
|
||||||
|
JOIN m_appuser maker ON maker.id = r.maker_id
|
||||||
|
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<UserPendingRequestData> retrieveAllPendingRequests(final String status, final String username,
|
||||||
|
final String fromDate, final String toDate, final Integer offset, final Integer limit) {
|
||||||
|
final StringBuilder where = new StringBuilder(" WHERE 1=1");
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(status)) {
|
||||||
|
where.append(" AND r.status = ?");
|
||||||
|
args.add(status.trim().toUpperCase());
|
||||||
|
} else {
|
||||||
|
where.append(" AND r.status = 'PENDING'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(fromDate)) {
|
||||||
|
where.append(" AND r.submitted_date::date >= ?::date");
|
||||||
|
args.add(fromDate);
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(toDate)) {
|
||||||
|
where.append(" AND r.submitted_date::date <= ?::date");
|
||||||
|
args.add(toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String countSql = "SELECT COUNT(*) FROM m_user_pending_request r " + where;
|
||||||
|
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
|
||||||
|
|
||||||
|
final int pageOffset = offset == null ? 0 : offset;
|
||||||
|
final int pageLimit = limit == null ? 20 : limit;
|
||||||
|
final String pageSql = SELECT_SQL + where + " ORDER BY r.submitted_date DESC LIMIT ? OFFSET ?";
|
||||||
|
final List<Object> pageArgs = new ArrayList<>(args);
|
||||||
|
pageArgs.add(pageLimit);
|
||||||
|
pageArgs.add(pageOffset);
|
||||||
|
|
||||||
|
final List<UserPendingRequestData> items = this.jdbcTemplate.query(pageSql, new UserPendingRowMapper(), pageArgs.toArray());
|
||||||
|
|
||||||
|
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public UserPendingRequestData retrievePendingRequest(final Long id) {
|
||||||
|
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
|
||||||
|
final String sql = SELECT_SQL + " WHERE r.id = ?";
|
||||||
|
return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class UserPendingRowMapper implements RowMapper<UserPendingRequestData> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
final Long id = rs.getLong("id");
|
||||||
|
final Long userId = JdbcSupport.getLong(rs, "userId");
|
||||||
|
final String requestType = rs.getString("requestType");
|
||||||
|
final String status = rs.getString("status");
|
||||||
|
final String payloadJson = rs.getString("payloadJson");
|
||||||
|
final Long makerId = rs.getLong("makerId");
|
||||||
|
final String makerUsername = rs.getString("makerUsername");
|
||||||
|
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
||||||
|
final Long checkerId = JdbcSupport.getLong(rs, "checkerId");
|
||||||
|
final String checkerUsername = rs.getString("checkerUsername");
|
||||||
|
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
|
||||||
|
final String rejectReason = rs.getString("rejectReason");
|
||||||
|
|
||||||
|
String username = null;
|
||||||
|
String firstname = null;
|
||||||
|
String lastname = null;
|
||||||
|
String email = null;
|
||||||
|
String mobileNo = null;
|
||||||
|
Long officeId = null;
|
||||||
|
String otpDeliveryMethod = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (payloadJson != null) {
|
||||||
|
final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject();
|
||||||
|
if (payloadObj.has("username")) {
|
||||||
|
username = payloadObj.get("username").getAsString();
|
||||||
|
}
|
||||||
|
if (payloadObj.has("firstname")) {
|
||||||
|
firstname = payloadObj.get("firstname").getAsString();
|
||||||
|
}
|
||||||
|
if (payloadObj.has("lastname")) {
|
||||||
|
lastname = payloadObj.get("lastname").getAsString();
|
||||||
|
}
|
||||||
|
if (payloadObj.has("email")) {
|
||||||
|
email = payloadObj.get("email").getAsString();
|
||||||
|
}
|
||||||
|
if (payloadObj.has("mobileNo")) {
|
||||||
|
mobileNo = payloadObj.get("mobileNo").getAsString();
|
||||||
|
}
|
||||||
|
if (payloadObj.has("officeId")) {
|
||||||
|
officeId = payloadObj.get("officeId").getAsLong();
|
||||||
|
}
|
||||||
|
if (payloadObj.has("otpDeliveryMethod")) {
|
||||||
|
otpDeliveryMethod = payloadObj.get("otpDeliveryMethod").getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserPendingRequestData.builder()
|
||||||
|
.id(id)
|
||||||
|
.userId(userId)
|
||||||
|
.requestType(requestType)
|
||||||
|
.status(status)
|
||||||
|
.username(username)
|
||||||
|
.firstname(firstname)
|
||||||
|
.lastname(lastname)
|
||||||
|
.email(email)
|
||||||
|
.mobileNo(mobileNo)
|
||||||
|
.officeId(officeId)
|
||||||
|
.otpDeliveryMethod(otpDeliveryMethod)
|
||||||
|
.makerId(makerId)
|
||||||
|
.makerUsername(makerUsername)
|
||||||
|
.submittedDate(submittedDate)
|
||||||
|
.checkerId(checkerId)
|
||||||
|
.checkerUsername(checkerUsername)
|
||||||
|
.checkedDate(checkedDate)
|
||||||
|
.rejectReason(rejectReason)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class UserPendingDetailRowMapper implements RowMapper<UserPendingRequestData> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserPendingRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
final Long id = rs.getLong("id");
|
||||||
|
final Long userId = JdbcSupport.getLong(rs, "userId");
|
||||||
|
final String requestType = rs.getString("requestType");
|
||||||
|
final String status = rs.getString("status");
|
||||||
|
final String payloadJson = rs.getString("payloadJson");
|
||||||
|
final String originalJson = rs.getString("originalJson");
|
||||||
|
final Long makerId = rs.getLong("makerId");
|
||||||
|
final String makerUsername = rs.getString("makerUsername");
|
||||||
|
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
||||||
|
final Long checkerId = JdbcSupport.getLong(rs, "checkerId");
|
||||||
|
final String checkerUsername = rs.getString("checkerUsername");
|
||||||
|
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
|
||||||
|
final String rejectReason = rs.getString("rejectReason");
|
||||||
|
|
||||||
|
final Map<String, Object> proposedValues = new LinkedHashMap<>();
|
||||||
|
final Map<String, Object> originalValues = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
String username = null;
|
||||||
|
String firstname = null;
|
||||||
|
String lastname = null;
|
||||||
|
String email = null;
|
||||||
|
String mobileNo = null;
|
||||||
|
Long officeId = null;
|
||||||
|
String otpDeliveryMethod = null;
|
||||||
|
Boolean passwordNeverExpires = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (payloadJson != null) {
|
||||||
|
final JsonObject payloadObj = JsonParser.parseString(payloadJson).getAsJsonObject();
|
||||||
|
for (final String key : payloadObj.keySet()) {
|
||||||
|
final JsonElement val = payloadObj.get(key);
|
||||||
|
if ("password".equals(key) || "repeatPassword".equals(key)) {
|
||||||
|
proposedValues.put("password", "Password will be set on approval");
|
||||||
|
} else if (val.isJsonPrimitive()) {
|
||||||
|
if (val.getAsJsonPrimitive().isBoolean()) {
|
||||||
|
proposedValues.put(key, val.getAsBoolean());
|
||||||
|
} else if (val.getAsJsonPrimitive().isNumber()) {
|
||||||
|
proposedValues.put(key, val.getAsNumber());
|
||||||
|
} else {
|
||||||
|
proposedValues.put(key, val.getAsString());
|
||||||
|
}
|
||||||
|
} else if (val.isJsonArray()) {
|
||||||
|
final List<String> list = new ArrayList<>();
|
||||||
|
for (final JsonElement elem : val.getAsJsonArray()) {
|
||||||
|
list.add(elem.getAsString());
|
||||||
|
}
|
||||||
|
proposedValues.put(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposedValues.containsKey("username")) {
|
||||||
|
username = (String) proposedValues.get("username");
|
||||||
|
}
|
||||||
|
if (proposedValues.containsKey("firstname")) {
|
||||||
|
firstname = (String) proposedValues.get("firstname");
|
||||||
|
}
|
||||||
|
if (proposedValues.containsKey("lastname")) {
|
||||||
|
lastname = (String) proposedValues.get("lastname");
|
||||||
|
}
|
||||||
|
if (proposedValues.containsKey("email")) {
|
||||||
|
email = (String) proposedValues.get("email");
|
||||||
|
}
|
||||||
|
if (proposedValues.containsKey("mobileNo")) {
|
||||||
|
mobileNo = (String) proposedValues.get("mobileNo");
|
||||||
|
}
|
||||||
|
if (proposedValues.get("officeId") instanceof Number n) {
|
||||||
|
officeId = n.longValue();
|
||||||
|
}
|
||||||
|
if (proposedValues.containsKey("otpDeliveryMethod")) {
|
||||||
|
otpDeliveryMethod = (String) proposedValues.get("otpDeliveryMethod");
|
||||||
|
}
|
||||||
|
if (proposedValues.get("passwordNeverExpires") instanceof Boolean b) {
|
||||||
|
passwordNeverExpires = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalJson != null) {
|
||||||
|
final JsonObject origObj = JsonParser.parseString(originalJson).getAsJsonObject();
|
||||||
|
for (final String key : origObj.keySet()) {
|
||||||
|
final JsonElement val = origObj.get(key);
|
||||||
|
if (val.isJsonPrimitive()) {
|
||||||
|
if (val.getAsJsonPrimitive().isBoolean()) {
|
||||||
|
originalValues.put(key, val.getAsBoolean());
|
||||||
|
} else if (val.getAsJsonPrimitive().isNumber()) {
|
||||||
|
originalValues.put(key, val.getAsNumber());
|
||||||
|
} else {
|
||||||
|
originalValues.put(key, val.getAsString());
|
||||||
|
}
|
||||||
|
} else if (val.isJsonArray()) {
|
||||||
|
final List<String> list = new ArrayList<>();
|
||||||
|
for (final JsonElement elem : val.getAsJsonArray()) {
|
||||||
|
list.add(elem.getAsString());
|
||||||
|
}
|
||||||
|
originalValues.put(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (proposedValues.containsKey("password")) {
|
||||||
|
originalValues.put("password", "[PROTECTED]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserPendingRequestData.builder()
|
||||||
|
.id(id)
|
||||||
|
.userId(userId)
|
||||||
|
.requestType(requestType)
|
||||||
|
.status(status)
|
||||||
|
.username(username)
|
||||||
|
.firstname(firstname)
|
||||||
|
.lastname(lastname)
|
||||||
|
.email(email)
|
||||||
|
.mobileNo(mobileNo)
|
||||||
|
.officeId(officeId)
|
||||||
|
.otpDeliveryMethod(otpDeliveryMethod)
|
||||||
|
.passwordNeverExpires(passwordNeverExpires)
|
||||||
|
.makerId(makerId)
|
||||||
|
.makerUsername(makerUsername)
|
||||||
|
.submittedDate(submittedDate)
|
||||||
|
.checkerId(checkerId)
|
||||||
|
.checkerUsername(checkerUsername)
|
||||||
|
.checkedDate(checkedDate)
|
||||||
|
.rejectReason(rejectReason)
|
||||||
|
.originalValues(originalValues)
|
||||||
|
.proposedValues(proposedValues)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.useradministration.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
public interface UserPendingWritePlatformService {
|
||||||
|
|
||||||
|
CommandProcessingResult submitCreateUser(JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult submitUpdateUser(Long userId, JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult approvePendingRequest(Long id);
|
||||||
|
|
||||||
|
CommandProcessingResult rejectPendingRequest(Long id, JsonCommand command);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user