Compare commits
32
Commits
uate
...
1a2a945464
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a2a945464 | ||
|
|
73ec85cfd6 | ||
|
|
46f5a1babd | ||
|
|
20fe11e60d | ||
|
|
eb1aa767f7 | ||
|
|
8724c54d9b | ||
|
|
5c0a482f49 | ||
|
|
0e07e60328 | ||
|
|
e227e8ae0f | ||
|
|
231919b62b | ||
|
|
9eac40bb5c | ||
|
|
14b2585cf5 | ||
|
|
a2fa8688ae | ||
|
|
a8ca7dd948 | ||
|
|
c8833367b9 | ||
|
|
94b0afff38 | ||
|
|
9dd4f8bedb | ||
|
|
1107610c08 | ||
|
|
2213308254 | ||
|
|
c568eb0abf | ||
|
|
1426be9531 | ||
|
|
541c6ab062 | ||
|
|
66a6a4766f | ||
|
|
b9aa29fb35 | ||
|
|
1a9adaec24 | ||
|
|
2da5a25e04 | ||
|
|
baf30cff68 | ||
|
|
f1d68d17fe | ||
|
|
84f8aa2274 | ||
|
|
7773a9e9c6 | ||
|
|
c890faee78 | ||
|
|
abc7eb22a2 |
+1
-1
@@ -159,7 +159,7 @@ gitVersioning.apply {
|
||||
}
|
||||
|
||||
ext['groovy.version'] = '4.0.17'
|
||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString()
|
||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20")
|
||||
|
||||
// Apply static weaving configuration to all subprojects
|
||||
subprojects { subproject ->
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
|
||||
// Configure Java compilation
|
||||
tasks.named('compileJava') {
|
||||
outputs.cacheIf { true }
|
||||
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+4
-1
@@ -436,7 +436,10 @@ public final class AccountingConstants {
|
||||
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); //
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
|
||||
+9
@@ -2897,6 +2897,15 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
|
||||
this.actionName = "PAYMENT";
|
||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||
this.entityId = accountId;
|
||||
this.savingsId = accountId;
|
||||
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
|
||||
this.actionName = "CALCULATEINTEREST";
|
||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||
|
||||
+13
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
|
||||
this.dataValidationErrors.add(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
|
||||
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
|
||||
* args passed to it).
|
||||
*/
|
||||
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
|
||||
final Object... defaultUserMessageArgs) {
|
||||
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
|
||||
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
|
||||
this.value, defaultUserMessageArgs);
|
||||
this.dataValidationErrors.add(error);
|
||||
}
|
||||
|
||||
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
|
||||
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
|
||||
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
|
||||
|
||||
+2
@@ -38,6 +38,8 @@ public enum JobName {
|
||||
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||
|
||||
+16
-1
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
|
||||
// differentiate deposit accounts Savings, FD and RD accounts
|
||||
private final EnumOptionData depositType;
|
||||
|
||||
/**
|
||||
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
|
||||
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
|
||||
* for an FD that has never been renewed, and is null for non-deposit accounts.
|
||||
*/
|
||||
private final String fdAccountNumber;
|
||||
/**
|
||||
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
|
||||
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
|
||||
*/
|
||||
private final Integer renewalCycleNo;
|
||||
|
||||
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
||||
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
|
||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
||||
final String fdAccountNumber, final Integer renewalCycleNo) {
|
||||
this.fdAccountNumber = fdAccountNumber;
|
||||
this.renewalCycleNo = renewalCycleNo;
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.externalId = externalId;
|
||||
|
||||
+9
@@ -35,4 +35,13 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
||||
@Param("since") LocalDateTime since);
|
||||
|
||||
long countByClientId(Long clientId);
|
||||
|
||||
/** Alerts for one customer inside the recurring-behaviour observation window. */
|
||||
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
|
||||
|
||||
/**
|
||||
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
|
||||
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
||||
*/
|
||||
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
|
||||
/**
|
||||
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
|
||||
*
|
||||
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
|
||||
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
|
||||
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_aml_recurring_review")
|
||||
public class AmlRecurringReview {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
@Column(name = "alert_count_at_review", nullable = false)
|
||||
private Integer alertCountAtReview;
|
||||
|
||||
@Column(name = "outcome", nullable = false, length = 30)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "notes", nullable = false, length = 1000)
|
||||
private String notes;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "reviewed_by", nullable = false)
|
||||
private AppUser reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_on", nullable = false)
|
||||
private LocalDateTime reviewedOn;
|
||||
|
||||
protected AmlRecurringReview() {}
|
||||
|
||||
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
|
||||
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
|
||||
final AmlRecurringReview review = new AmlRecurringReview();
|
||||
review.client = client;
|
||||
review.alertCountAtReview = alertCountAtReview;
|
||||
review.outcome = outcome.name();
|
||||
review.notes = notes;
|
||||
review.reviewedBy = reviewedBy;
|
||||
review.reviewedOn = LocalDateTime.now();
|
||||
return review;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
public Integer getAlertCountAtReview() {
|
||||
return alertCountAtReview;
|
||||
}
|
||||
|
||||
public AmlRecurringReviewOutcome getOutcome() {
|
||||
return AmlRecurringReviewOutcome.valueOf(outcome);
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return notes;
|
||||
}
|
||||
|
||||
public AppUser getReviewedBy() {
|
||||
return reviewedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getReviewedOn() {
|
||||
return reviewedOn;
|
||||
}
|
||||
}
|
||||
+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.portfolio.aml.domain;
|
||||
|
||||
/** What a reviewer concluded about a customer's repeated AML activity. */
|
||||
public enum AmlRecurringReviewOutcome {
|
||||
|
||||
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
|
||||
CLEARED,
|
||||
/** Not conclusive; keep watching this customer more closely. */
|
||||
MONITOR,
|
||||
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
|
||||
ESCALATED
|
||||
}
|
||||
+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.portfolio.aml.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
|
||||
|
||||
/** Newest first, so the caller can take the head as the latest review for a customer. */
|
||||
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
|
||||
}
|
||||
+59
@@ -52,6 +52,26 @@ public class AmlTransactionRule {
|
||||
@Column(name = "threshold_amount", precision = 19, scale = 6)
|
||||
private BigDecimal thresholdAmount;
|
||||
|
||||
/** Risk-category-tiered overrides of {@link #thresholdAmount}. Null on both means the rule is
|
||||
* not tiered; {@link #thresholdAmount} is used for every client regardless of risk category. */
|
||||
@Column(name = "threshold_amount_medium_risk", precision = 19, scale = 6)
|
||||
private BigDecimal thresholdAmountMediumRisk;
|
||||
|
||||
@Column(name = "threshold_amount_high_risk", precision = 19, scale = 6)
|
||||
private BigDecimal thresholdAmountHighRisk;
|
||||
|
||||
/**
|
||||
* Declarative band the rule covers, shown on the AML Rules Master screen. Purely
|
||||
* descriptive: unlike the tiered columns above these never take part in matching, so
|
||||
* changing them cannot alter which transactions raise an alert. A null maximum means
|
||||
* the rule is genuinely open-ended.
|
||||
*/
|
||||
@Column(name = "min_limit_amount", precision = 19, scale = 6)
|
||||
private BigDecimal minLimitAmount;
|
||||
|
||||
@Column(name = "max_limit_amount", precision = 19, scale = 6)
|
||||
private BigDecimal maxLimitAmount;
|
||||
|
||||
@Column(name = "rule_type", nullable = false, length = 20)
|
||||
private String ruleType;
|
||||
|
||||
@@ -66,6 +86,29 @@ public class AmlTransactionRule {
|
||||
|
||||
protected AmlTransactionRule() {}
|
||||
|
||||
/**
|
||||
* Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML
|
||||
* Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so
|
||||
* the two rows are always created together and the rule code is derived from it.
|
||||
*/
|
||||
public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description,
|
||||
final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays,
|
||||
final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) {
|
||||
final AmlTransactionRule rule = new AmlTransactionRule();
|
||||
rule.sourceClientRiskRuleId = sourceClientRiskRuleId;
|
||||
// rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide.
|
||||
rule.ruleCode = "CRR-" + sourceClientRiskRuleId;
|
||||
rule.description = description;
|
||||
rule.appliesTo = appliesTo.name();
|
||||
rule.ruleType = ruleType.name();
|
||||
rule.windowDays = windowDays;
|
||||
rule.thresholdAmount = thresholdAmount;
|
||||
rule.minLimitAmount = minLimitAmount;
|
||||
rule.maxLimitAmount = maxLimitAmount;
|
||||
rule.active = true;
|
||||
return rule;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -86,6 +129,22 @@ public class AmlTransactionRule {
|
||||
return thresholdAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getThresholdAmountMediumRisk() {
|
||||
return thresholdAmountMediumRisk;
|
||||
}
|
||||
|
||||
public BigDecimal getThresholdAmountHighRisk() {
|
||||
return thresholdAmountHighRisk;
|
||||
}
|
||||
|
||||
public BigDecimal getMinLimitAmount() {
|
||||
return minLimitAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getMaxLimitAmount() {
|
||||
return maxLimitAmount;
|
||||
}
|
||||
|
||||
public AmlRuleType getRuleType() {
|
||||
return AmlRuleType.valueOf(ruleType);
|
||||
}
|
||||
|
||||
+8
@@ -19,9 +19,17 @@
|
||||
package org.apache.fineract.portfolio.aml.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
||||
|
||||
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
|
||||
|
||||
/**
|
||||
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
|
||||
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
|
||||
* by hand from the ledger.
|
||||
*/
|
||||
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
|
||||
}
|
||||
|
||||
+1
@@ -191,6 +191,7 @@ public class ClientApiConstants {
|
||||
public static final String constitutionIdParamName = "constitutionId";
|
||||
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
||||
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
||||
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
|
||||
public static final String additionalInfoParamName = "additionalInfo";
|
||||
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
||||
|
||||
|
||||
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
private Boolean alternateDipVerified;
|
||||
private Long alternateDipLookupLogId;
|
||||
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 Long screeningLogId;
|
||||
private Boolean isStaff;
|
||||
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
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.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
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.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
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.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public final class ClientFamilyMemberChildData implements Serializable {
|
||||
private final Long id;
|
||||
private final String fullName;
|
||||
private final Long genderId;
|
||||
private final String gender;
|
||||
private final LocalDate dateOfBirth;
|
||||
}
|
||||
+3
@@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable {
|
||||
private final LocalDate marriageDate;
|
||||
private final String marriageAuthority;
|
||||
|
||||
@lombok.Setter
|
||||
private java.util.List<ClientFamilyMemberChildData> children;
|
||||
|
||||
// template holder
|
||||
private final Collection<CodeValueData> relationshipIdOptions;
|
||||
private final Collection<CodeValueData> genderIdOptions;
|
||||
|
||||
+14
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
|
||||
private final CodeValueData mainBusinessLine;
|
||||
private final String remarks;
|
||||
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 LocalDate dateOfRegistration;
|
||||
|
||||
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
|
||||
this.constitution = null;
|
||||
this.mainBusinessLine = null;
|
||||
this.authorizedSignatory = null;
|
||||
this.authorizedSignatoryClientIds = null;
|
||||
this.additionalInfo = 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,
|
||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||
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.incorpNumber = incorpNo;
|
||||
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
|
||||
this.mainBusinessLine = mainBusinessLine;
|
||||
this.remarks = remarks;
|
||||
this.authorizedSignatory = authorizedSignatory;
|
||||
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
|
||||
this.additionalInfo = additionalInfo;
|
||||
this.dateOfRegistration = dateOfRegistration;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "is_pwd")
|
||||
private Boolean isPwd;
|
||||
|
||||
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
|
||||
private ClientKyc clientKyc;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "pwd_category_cv_id")
|
||||
private CodeValue pwdCategory;
|
||||
@@ -290,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "alternate_dip_timestamp")
|
||||
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")
|
||||
private Boolean nameVerified;
|
||||
|
||||
|
||||
+307
@@ -25,10 +25,15 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
@@ -60,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
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 */
|
||||
@Column(name = "kyc_status", nullable = false)
|
||||
private Integer kycStatus;
|
||||
@@ -103,6 +116,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "pan_status", length = 20)
|
||||
private String panStatus;
|
||||
|
||||
// ── PAN declaration ──────────────────────────────────────────────────────
|
||||
// Accepted in place of a passing PAN check. The Income Tax check matches on name AND date of
|
||||
// birth, so a customer whose PAN card carries a different spelling — or a different DOB — can
|
||||
// never clear it however many times the officer retries. Rather than leave such a client
|
||||
// permanently in KYC exception, the branch takes a signed declaration recording what differs.
|
||||
// Holding that declaration is what suppresses the exception, so it is stored, not just shown.
|
||||
|
||||
/** True once a signed declaration has been accepted for a PAN that could not be verified. */
|
||||
@Column(name = "pan_declaration_taken")
|
||||
private Boolean panDeclarationTaken;
|
||||
|
||||
/** What the declaration covers: "NAME", "DOB", "NAME,DOB", or "NONE" when PAN simply failed. */
|
||||
@Column(name = "pan_declaration_mismatch_fields", length = 100)
|
||||
private String panDeclarationMismatchFields;
|
||||
|
||||
/** Name exactly as printed on the physical PAN card, keyed in by the officer. */
|
||||
@Column(name = "pan_declaration_name_on_pan", length = 200)
|
||||
private String panDeclarationNameOnPan;
|
||||
|
||||
/** Date of birth exactly as printed on the physical PAN card. */
|
||||
@Column(name = "pan_declaration_dob_on_pan")
|
||||
private LocalDate panDeclarationDobOnPan;
|
||||
|
||||
/** Officer's free-text note — why the mismatch exists and what proof was sighted. */
|
||||
@Column(name = "pan_declaration_remarks", length = 500)
|
||||
private String panDeclarationRemarks;
|
||||
|
||||
@Column(name = "pan_declaration_taken_on")
|
||||
private LocalDate panDeclarationTakenOn;
|
||||
|
||||
// ── Aadhaar ──────────────────────────────────────────────────────────────
|
||||
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
|
||||
@Column(name = "aadhaar_number_masked", length = 12)
|
||||
@@ -114,6 +157,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "aadhaar_verified_on")
|
||||
private LocalDate aadhaarVerifiedOn;
|
||||
|
||||
// ── Aadhaar enrolment receipt ────────────────────────────────────────────
|
||||
// The counterpart of the PAN declaration, for a customer who has applied for Aadhaar but does
|
||||
// not hold one yet. UIDAI issues an enrolment slip carrying a 28-digit EID, and until Aadhaar
|
||||
// itself is issued there is no number to put through the OTP flow — the check cannot pass
|
||||
// however long the branch waits. Recording the receipt is what waives the exception.
|
||||
|
||||
/** True once the customer's Aadhaar enrolment slip has been sighted and recorded. */
|
||||
@Column(name = "aadhaar_receipt_taken")
|
||||
private Boolean aadhaarReceiptTaken;
|
||||
|
||||
/** 28-digit Enrolment ID from the slip, stored with its printed separators. */
|
||||
@Column(name = "aadhaar_enrolment_id", length = 40)
|
||||
private String aadhaarEnrolmentId;
|
||||
|
||||
/** Enrolment date printed on the slip — a receipt is temporary, so its age matters. */
|
||||
@Column(name = "aadhaar_enrolment_date")
|
||||
private LocalDate aadhaarEnrolmentDate;
|
||||
|
||||
/** Officer's note on what was sighted. */
|
||||
@Column(name = "aadhaar_receipt_remarks", length = 500)
|
||||
private String aadhaarReceiptRemarks;
|
||||
|
||||
@Column(name = "aadhaar_receipt_taken_on")
|
||||
private LocalDate aadhaarReceiptTakenOn;
|
||||
|
||||
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
|
||||
@Column(name = "gstin_number", length = 15)
|
||||
private String gstinNumber;
|
||||
@@ -195,6 +263,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "freeze_authority", length = 100)
|
||||
private String freezeAuthority;
|
||||
|
||||
@Column(name = "freeze_date")
|
||||
private LocalDate freezeDate;
|
||||
|
||||
@Column(name = "freeze_amount", precision = 19, scale = 6)
|
||||
private BigDecimal freezeAmount;
|
||||
|
||||
@Column(name = "unfreeze_date")
|
||||
private LocalDate unfreezeDate;
|
||||
|
||||
@Column(name = "no_debit_date")
|
||||
private LocalDate noDebitDate;
|
||||
|
||||
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||
@Column(name = "form60_type", length = 20)
|
||||
private String form60Type;
|
||||
@@ -241,6 +321,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "remarks", length = 500)
|
||||
private String remarks;
|
||||
|
||||
@Column(name = "is_exception", nullable = false)
|
||||
private Boolean isException;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Column(name = "exception_resolved", nullable = false)
|
||||
private Boolean exceptionResolved;
|
||||
|
||||
@Column(name = "exception_resolved_on")
|
||||
private LocalDate exceptionResolvedOn;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
protected ClientKyc() {}
|
||||
@@ -261,6 +353,8 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
this.gstinVerified = false;
|
||||
this.cinVerified = false;
|
||||
this.dinVerified = false;
|
||||
this.isException = false;
|
||||
this.exceptionResolved = false;
|
||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
||||
}
|
||||
|
||||
@@ -288,6 +382,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
private void applyExtendedFields(final JsonCommand command) {
|
||||
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
|
||||
final Boolean panDeclarationTakenVal = command.booleanObjectValueOfParameterNamed("panDeclarationTaken");
|
||||
this.panDeclarationTaken = panDeclarationTakenVal != null ? panDeclarationTakenVal : false;
|
||||
this.panDeclarationMismatchFields = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationMismatchFields"), null);
|
||||
this.panDeclarationNameOnPan = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationNameOnPan"), null);
|
||||
this.panDeclarationDobOnPan = command.localDateValueOfParameterNamed("panDeclarationDobOnPan");
|
||||
this.panDeclarationRemarks = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationRemarks"), null);
|
||||
this.panDeclarationTakenOn = command.localDateValueOfParameterNamed("panDeclarationTakenOn");
|
||||
final Boolean aadhaarReceiptTakenVal = command.booleanObjectValueOfParameterNamed("aadhaarReceiptTaken");
|
||||
this.aadhaarReceiptTaken = aadhaarReceiptTakenVal != null ? aadhaarReceiptTakenVal : false;
|
||||
this.aadhaarEnrolmentId = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("aadhaarEnrolmentId"), null);
|
||||
this.aadhaarEnrolmentDate = command.localDateValueOfParameterNamed("aadhaarEnrolmentDate");
|
||||
this.aadhaarReceiptRemarks = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("aadhaarReceiptRemarks"), null);
|
||||
this.aadhaarReceiptTakenOn = command.localDateValueOfParameterNamed("aadhaarReceiptTakenOn");
|
||||
this.enterprisePan = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("enterprisePan"), null);
|
||||
this.cinNumber = StringUtils.defaultIfEmpty(
|
||||
@@ -317,6 +429,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("freezeReason"), null);
|
||||
this.freezeAuthority = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("freezeAuthority"), null);
|
||||
this.freezeDate = command.localDateValueOfParameterNamed("freezeDate");
|
||||
this.freezeAmount = command.bigDecimalValueOfParameterNamed("freezeAmount");
|
||||
this.unfreezeDate = command.localDateValueOfParameterNamed("unfreezeDate");
|
||||
this.noDebitDate = command.localDateValueOfParameterNamed("noDebitDate");
|
||||
this.form60Type = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form60Type"), null);
|
||||
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
|
||||
@@ -325,15 +441,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("form15Type"), null);
|
||||
this.form15Fy = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form15FY"), null);
|
||||
Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException");
|
||||
this.isException = isExceptionVal != null ? isExceptionVal : false;
|
||||
this.exceptionReason = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("exceptionReason"), null);
|
||||
Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved");
|
||||
this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false;
|
||||
this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn");
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
||||
this.fieldsOverwrittenByLastUpdate.clear();
|
||||
|
||||
final String panNumberParam = "panNumber";
|
||||
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
||||
actualChanges.put(panNumberParam, newValue);
|
||||
recordOverwrite(panNumberParam, this.panNumber);
|
||||
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -341,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
||||
actualChanges.put(aadhaarParam, newValue);
|
||||
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -348,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
||||
actualChanges.put(gstinParam, newValue);
|
||||
recordOverwrite(gstinParam, this.gstinNumber);
|
||||
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -355,9 +482,12 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
||||
actualChanges.put(riskParam, newValue);
|
||||
recordOverwrite(riskParam, this.riskCategory);
|
||||
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";
|
||||
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
||||
@@ -367,6 +497,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
|
||||
v -> this.panAadhaarSeedingStatus = v);
|
||||
updateBooleanField(command, "panDeclarationTaken", this.panDeclarationTaken, actualChanges,
|
||||
v -> this.panDeclarationTaken = v != null ? v : false);
|
||||
updateStringField(command, "panDeclarationMismatchFields", this.panDeclarationMismatchFields, actualChanges,
|
||||
v -> this.panDeclarationMismatchFields = v);
|
||||
updateStringField(command, "panDeclarationNameOnPan", this.panDeclarationNameOnPan, actualChanges,
|
||||
v -> this.panDeclarationNameOnPan = v);
|
||||
updateDateField(command, "panDeclarationDobOnPan", this.panDeclarationDobOnPan, actualChanges,
|
||||
v -> this.panDeclarationDobOnPan = v);
|
||||
updateStringField(command, "panDeclarationRemarks", this.panDeclarationRemarks, actualChanges,
|
||||
v -> this.panDeclarationRemarks = v);
|
||||
updateDateField(command, "panDeclarationTakenOn", this.panDeclarationTakenOn, actualChanges,
|
||||
v -> this.panDeclarationTakenOn = v);
|
||||
updateBooleanField(command, "aadhaarReceiptTaken", this.aadhaarReceiptTaken, actualChanges,
|
||||
v -> this.aadhaarReceiptTaken = v != null ? v : false);
|
||||
updateStringField(command, "aadhaarEnrolmentId", this.aadhaarEnrolmentId, actualChanges,
|
||||
v -> this.aadhaarEnrolmentId = v);
|
||||
updateDateField(command, "aadhaarEnrolmentDate", this.aadhaarEnrolmentDate, actualChanges,
|
||||
v -> this.aadhaarEnrolmentDate = v);
|
||||
updateStringField(command, "aadhaarReceiptRemarks", this.aadhaarReceiptRemarks, actualChanges,
|
||||
v -> this.aadhaarReceiptRemarks = v);
|
||||
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
|
||||
v -> this.aadhaarReceiptTakenOn = v);
|
||||
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
||||
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
||||
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
||||
@@ -392,12 +544,23 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
|
||||
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
|
||||
v -> this.freezeAuthority = v);
|
||||
updateDateField(command, "freezeDate", this.freezeDate, actualChanges, v -> this.freezeDate = v);
|
||||
updateBigDecimalField(command, "freezeAmount", this.freezeAmount, actualChanges, v -> this.freezeAmount = v);
|
||||
updateDateField(command, "unfreezeDate", this.unfreezeDate, actualChanges, v -> this.unfreezeDate = v);
|
||||
updateDateField(command, "noDebitDate", this.noDebitDate, actualChanges, v -> this.noDebitDate = v);
|
||||
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
|
||||
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
|
||||
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
|
||||
v -> this.form15Applicable = v);
|
||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
|
||||
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
|
||||
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
|
||||
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
|
||||
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
@@ -407,6 +570,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
}
|
||||
@@ -416,6 +580,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
@@ -426,11 +591,51 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBigDecimalField(final JsonCommand command, final String param, final BigDecimal current,
|
||||
final Map<String, Object> changes, final java.util.function.Consumer<BigDecimal> setter) {
|
||||
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
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. */
|
||||
public void markInProgress() {
|
||||
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
||||
@@ -631,6 +836,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return panStatus;
|
||||
}
|
||||
|
||||
public Boolean getPanDeclarationTaken() {
|
||||
return panDeclarationTaken;
|
||||
}
|
||||
|
||||
/** Null-safe form for callers deciding whether the PAN exception is waived. */
|
||||
public boolean isPanDeclarationTaken() {
|
||||
return Boolean.TRUE.equals(this.panDeclarationTaken);
|
||||
}
|
||||
|
||||
public String getPanDeclarationMismatchFields() {
|
||||
return panDeclarationMismatchFields;
|
||||
}
|
||||
|
||||
public String getPanDeclarationNameOnPan() {
|
||||
return panDeclarationNameOnPan;
|
||||
}
|
||||
|
||||
public LocalDate getPanDeclarationDobOnPan() {
|
||||
return panDeclarationDobOnPan;
|
||||
}
|
||||
|
||||
public String getPanDeclarationRemarks() {
|
||||
return panDeclarationRemarks;
|
||||
}
|
||||
|
||||
public LocalDate getPanDeclarationTakenOn() {
|
||||
return panDeclarationTakenOn;
|
||||
}
|
||||
|
||||
public Boolean getAadhaarReceiptTaken() {
|
||||
return aadhaarReceiptTaken;
|
||||
}
|
||||
|
||||
/** Null-safe form for callers deciding whether the Aadhaar exception is waived. */
|
||||
public boolean isAadhaarReceiptTaken() {
|
||||
return Boolean.TRUE.equals(this.aadhaarReceiptTaken);
|
||||
}
|
||||
|
||||
public String getAadhaarEnrolmentId() {
|
||||
return aadhaarEnrolmentId;
|
||||
}
|
||||
|
||||
public LocalDate getAadhaarEnrolmentDate() {
|
||||
return aadhaarEnrolmentDate;
|
||||
}
|
||||
|
||||
public String getAadhaarReceiptRemarks() {
|
||||
return aadhaarReceiptRemarks;
|
||||
}
|
||||
|
||||
public LocalDate getAadhaarReceiptTakenOn() {
|
||||
return aadhaarReceiptTakenOn;
|
||||
}
|
||||
|
||||
public String getAadhaarNumberMasked() {
|
||||
return aadhaarNumberMasked;
|
||||
}
|
||||
@@ -795,6 +1054,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return form15Fy;
|
||||
}
|
||||
|
||||
public Boolean getIsException() {
|
||||
return isException;
|
||||
}
|
||||
|
||||
public void setIsException(Boolean isException) {
|
||||
this.isException = isException;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return exceptionReason;
|
||||
}
|
||||
|
||||
public void setExceptionReason(String exceptionReason) {
|
||||
this.exceptionReason = exceptionReason;
|
||||
}
|
||||
|
||||
public Boolean getExceptionResolved() {
|
||||
return exceptionResolved;
|
||||
}
|
||||
|
||||
public void setExceptionResolved(Boolean exceptionResolved) {
|
||||
this.exceptionResolved = exceptionResolved;
|
||||
}
|
||||
|
||||
public LocalDate getExceptionResolvedOn() {
|
||||
return exceptionResolvedOn;
|
||||
}
|
||||
|
||||
public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) {
|
||||
this.exceptionResolvedOn = exceptionResolvedOn;
|
||||
}
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
@@ -846,4 +1137,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
public Long getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public LocalDate getFreezeDate() {
|
||||
return this.freezeDate;
|
||||
}
|
||||
|
||||
public BigDecimal getFreezeAmount() {
|
||||
return this.freezeAmount;
|
||||
}
|
||||
|
||||
public LocalDate getUnfreezeDate() {
|
||||
return this.unfreezeDate;
|
||||
}
|
||||
|
||||
public LocalDate getNoDebitDate() {
|
||||
return this.noDebitDate;
|
||||
}
|
||||
}
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One customer-outreach attempt against an open KYC exception.
|
||||
*
|
||||
* <p>Append-only by design — there is no setter-driven update path and the API exposes no PUT or
|
||||
* DELETE. A contact log that can be edited after the fact is worthless as evidence, and RBI's KYC
|
||||
* Master Direction expects the bank to be able to show what it told the customer and when.
|
||||
*
|
||||
* <p>Destinations are stored masked ({@code destinationMasked}); the full mobile number / email
|
||||
* lives on {@code m_client} and is resolved server-side at send time, never accepted from the
|
||||
* browser.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_client_kyc_contact_log")
|
||||
public class ClientKycContactLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
/** The KYC record the exception belongs to. Nullable — outreach can precede a KYC row. */
|
||||
@Column(name = "kyc_id")
|
||||
private Long kycId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", length = 20, nullable = false)
|
||||
private KycContactChannel channel;
|
||||
|
||||
@Column(name = "notes", length = 1000, nullable = false)
|
||||
private String notes;
|
||||
|
||||
@Column(name = "officer_name", length = 200)
|
||||
private String officerName;
|
||||
|
||||
@Column(name = "logged_by_user_id")
|
||||
private Long loggedByUserId;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "delivery_status", length = 30, nullable = false)
|
||||
private KycContactDeliveryStatus deliveryStatus;
|
||||
|
||||
@Column(name = "destination_masked", length = 120)
|
||||
private String destinationMasked;
|
||||
|
||||
@Column(name = "provider", length = 40)
|
||||
private String provider;
|
||||
|
||||
@Column(name = "provider_reference", length = 120)
|
||||
private String providerReference;
|
||||
|
||||
@Column(name = "failure_reason", length = 500)
|
||||
private String failureReason;
|
||||
|
||||
@Column(name = "message_body", length = 2000)
|
||||
private String messageBody;
|
||||
|
||||
@Column(name = "contacted_at", nullable = false)
|
||||
private LocalDateTime contactedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
protected ClientKycContactLog() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public ClientKycContactLog(final Client client, final Long kycId, final KycContactChannel channel, final String notes,
|
||||
final String officerName, final Long loggedByUserId, final String exceptionReason, final LocalDateTime contactedAt) {
|
||||
this.client = client;
|
||||
this.kycId = kycId;
|
||||
this.channel = channel;
|
||||
this.notes = notes;
|
||||
this.officerName = officerName;
|
||||
this.loggedByUserId = loggedByUserId;
|
||||
this.exceptionReason = exceptionReason;
|
||||
this.contactedAt = contactedAt;
|
||||
this.createdAt = contactedAt;
|
||||
this.deliveryStatus = KycContactDeliveryStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the outcome of a platform send onto this log row. Called once, before the row is
|
||||
* persisted — the entity is append-only afterwards.
|
||||
*/
|
||||
public void recordDeliveryOutcome(final KycContactDeliveryStatus status, final String destinationMasked, final String provider,
|
||||
final String providerReference, final String failureReason, final String messageBody) {
|
||||
this.deliveryStatus = status;
|
||||
this.destinationMasked = destinationMasked;
|
||||
this.provider = provider;
|
||||
this.providerReference = providerReference;
|
||||
this.failureReason = truncate(failureReason, 500);
|
||||
this.messageBody = truncate(messageBody, 2000);
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
if (value == null || value.length() <= max) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, max - 3) + "...";
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public Long getKycId() {
|
||||
return this.kycId;
|
||||
}
|
||||
|
||||
public KycContactChannel getChannel() {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return this.notes;
|
||||
}
|
||||
|
||||
public String getOfficerName() {
|
||||
return this.officerName;
|
||||
}
|
||||
|
||||
public Long getLoggedByUserId() {
|
||||
return this.loggedByUserId;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return this.exceptionReason;
|
||||
}
|
||||
|
||||
public KycContactDeliveryStatus getDeliveryStatus() {
|
||||
return this.deliveryStatus;
|
||||
}
|
||||
|
||||
public String getDestinationMasked() {
|
||||
return this.destinationMasked;
|
||||
}
|
||||
|
||||
public String getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
public String getProviderReference() {
|
||||
return this.providerReference;
|
||||
}
|
||||
|
||||
public String getFailureReason() {
|
||||
return this.failureReason;
|
||||
}
|
||||
|
||||
public String getMessageBody() {
|
||||
return this.messageBody;
|
||||
}
|
||||
|
||||
public LocalDateTime getContactedAt() {
|
||||
return this.contactedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return this.createdAt;
|
||||
}
|
||||
}
|
||||
+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.portfolio.client.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ClientKycContactLogRepository extends JpaRepository<ClientKycContactLog, Long> {
|
||||
|
||||
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
||||
|
||||
/**
|
||||
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
||||
* "last contacted" for the whole page without an N+1 query per row.
|
||||
*/
|
||||
@Query("SELECT l FROM ClientKycContactLog l WHERE l.client.id IN :clientIds ORDER BY l.contactedAt ASC")
|
||||
List<ClientKycContactLog> findByClientIds(@Param("clientIds") List<Long> clientIds);
|
||||
}
|
||||
+12
@@ -33,6 +33,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
||||
|
||||
boolean existsByClientId(Long clientId);
|
||||
|
||||
List<ClientKyc> findByIsExceptionTrueAndExceptionResolvedFalse();
|
||||
|
||||
/**
|
||||
* Resolved exceptions, keyed on {@code exceptionResolved} alone.
|
||||
*
|
||||
* <p>Deliberately does NOT also require {@code isException = true}. Resolving an exception
|
||||
* clears {@code isException} — that flag means "has an OPEN exception", so leaving it set on a
|
||||
* closed one would be wrong. An earlier version of this query required both flags, which made
|
||||
* every resolved record invisible in both tabs the moment it was resolved.
|
||||
*/
|
||||
List<ClientKyc> findByExceptionResolvedTrue();
|
||||
|
||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
||||
|
||||
|
||||
-7
@@ -63,13 +63,6 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
|
||||
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
|
||||
void removeSignatureId(@Param("clientId") Long clientId);
|
||||
|
||||
/**
|
||||
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
|
||||
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
|
||||
*/
|
||||
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
|
||||
long countByCifNumberPrefix(@Param("prefix") String prefix);
|
||||
|
||||
// ── Pre-save uniqueness probes ───────────────────────────────────────────
|
||||
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
|
||||
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
|
||||
|
||||
+38
@@ -95,6 +95,44 @@ public class ClientRiskRule {
|
||||
|
||||
protected ClientRiskRule() {}
|
||||
|
||||
/**
|
||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||
*/
|
||||
public static ClientRiskRule create(final String ruleName, final String reason, final Integer riskCategory,
|
||||
final Integer orderPosition, final Integer legalFormId, final BigDecimal minMonthlyIncome,
|
||||
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
||||
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
||||
final String dormantFlag) {
|
||||
final ClientRiskRule rule = new ClientRiskRule();
|
||||
rule.ruleName = ruleName;
|
||||
rule.reason = reason;
|
||||
rule.riskCategory = riskCategory;
|
||||
rule.orderPosition = orderPosition;
|
||||
rule.legalFormId = legalFormId;
|
||||
rule.minMonthlyIncome = minMonthlyIncome;
|
||||
rule.maxMonthlyIncome = maxMonthlyIncome;
|
||||
rule.minAnnualIncome = minAnnualIncome;
|
||||
rule.maxAnnualIncome = maxAnnualIncome;
|
||||
rule.minAnnualTurnover = minAnnualTurnover;
|
||||
rule.maxAnnualTurnover = maxAnnualTurnover;
|
||||
rule.amlCheckStatus = amlCheckStatus;
|
||||
rule.bannedEntityMatch = bannedEntityMatch;
|
||||
rule.willfulDefaulter = willfulDefaulter;
|
||||
rule.ecgcSalFlag = ecgcSalFlag;
|
||||
rule.dormantFlag = dormantFlag;
|
||||
return rule;
|
||||
}
|
||||
|
||||
public void setOccupationCategory(final CodeValue occupationCategory) {
|
||||
this.occupationCategory = occupationCategory;
|
||||
}
|
||||
|
||||
public void setOccupation(final CodeValue occupation) {
|
||||
this.occupation = occupation;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
+6
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
|
||||
|
||||
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
|
||||
List<ClientRiskRule> findAllOrderedByOrderPosition();
|
||||
|
||||
/** Highest position in use, so a new rule can be appended to the end of the catalog. */
|
||||
@Query("SELECT MAX(r.orderPosition) FROM ClientRiskRule r")
|
||||
Integer findMaxOrderPosition();
|
||||
|
||||
boolean existsByRuleNameIgnoreCase(String ruleName);
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
/**
|
||||
* The channel a KYC officer used (or asked the platform to use) to reach a customer about an open
|
||||
* KYC exception.
|
||||
*
|
||||
* <p>The critical distinction is {@link #isPlatformSent()}:
|
||||
* <ul>
|
||||
* <li><b>Offline channels</b> ({@code PHONE_CALL}, {@code BRANCH_VISIT}, {@code FIELD_VISIT}) are
|
||||
* recorded after the fact — the officer already did the thing, the platform only writes it
|
||||
* down.</li>
|
||||
* <li><b>Sending channels</b> ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to
|
||||
* actually transmit a notice to the customer at save time.</li>
|
||||
* </ul>
|
||||
* Mixing these up would let a "logged" row imply the customer was told something they never
|
||||
* received, so every call site branches on this flag rather than on the channel name.
|
||||
*/
|
||||
public enum KycContactChannel {
|
||||
|
||||
PHONE_CALL("Phone Call", false),
|
||||
SMS("SMS", true),
|
||||
EMAIL("Email", true),
|
||||
WHATSAPP("WhatsApp Message", true),
|
||||
BRANCH_VISIT("In-Branch Visit", false),
|
||||
FIELD_VISIT("Agent Field Visit", false);
|
||||
|
||||
private final String displayLabel;
|
||||
private final boolean platformSent;
|
||||
|
||||
KycContactChannel(final String displayLabel, final boolean platformSent) {
|
||||
this.displayLabel = displayLabel;
|
||||
this.platformSent = platformSent;
|
||||
}
|
||||
|
||||
public String getDisplayLabel() {
|
||||
return this.displayLabel;
|
||||
}
|
||||
|
||||
/** True when saving a log of this channel must actually transmit a message to the customer. */
|
||||
public boolean isPlatformSent() {
|
||||
return this.platformSent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of whatever the UI sent. Accepts the enum name ({@code SMS}) as well as the
|
||||
* display label, and tolerates the descriptive suffixes the dialog uses — e.g.
|
||||
* {@code "SMS (Aadhaar OTP request)"} and {@code "Email (PAN Link request)"} — because those
|
||||
* labels describe the *purpose* of the message, not a different channel.
|
||||
*
|
||||
* @return the matching channel, or {@code null} when nothing matches.
|
||||
*/
|
||||
public static KycContactChannel fromString(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String normalised = raw.trim().toUpperCase();
|
||||
for (final KycContactChannel channel : values()) {
|
||||
if (normalised.equals(channel.name()) || normalised.equals(channel.displayLabel.toUpperCase())) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
// Prefix match handles the "(…request)" suffixes. Longest label first so WHATSAPP's
|
||||
// "WHATSAPP MESSAGE" is preferred over a shorter accidental prefix.
|
||||
KycContactChannel best = null;
|
||||
for (final KycContactChannel channel : values()) {
|
||||
final String label = channel.displayLabel.toUpperCase();
|
||||
if (normalised.startsWith(label) && (best == null || label.length() > best.displayLabel.length())) {
|
||||
best = channel;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
/**
|
||||
* Outcome of a platform-sent KYC contact notice.
|
||||
*
|
||||
* <p>Every state other than {@link #SENT} means the customer was <em>not</em> reached. The officer
|
||||
* is shown the distinction explicitly, because a contact log that silently swallows a gateway
|
||||
* failure would understate the bank's outreach obligation during a KYC audit.
|
||||
*/
|
||||
public enum KycContactDeliveryStatus {
|
||||
|
||||
/** Offline channel — nothing was transmitted and nothing was supposed to be. */
|
||||
NOT_APPLICABLE,
|
||||
|
||||
/** The gateway accepted the message for delivery. */
|
||||
SENT,
|
||||
|
||||
/** The gateway was reached but rejected the message, or the call errored. */
|
||||
FAILED,
|
||||
|
||||
/** The customer has no mobile number / email address on file for this channel. */
|
||||
SKIPPED_NO_DESTINATION,
|
||||
|
||||
/** No gateway is configured for this channel in this deployment. */
|
||||
PROVIDER_NOT_CONFIGURED;
|
||||
|
||||
public boolean isDelivered() {
|
||||
return this == SENT;
|
||||
}
|
||||
}
|
||||
+46
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
||||
@Column(name = "looked_up_on", nullable = false)
|
||||
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() {}
|
||||
|
||||
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
||||
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
||||
this.clientId = clientId;
|
||||
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;
|
||||
|
||||
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.Query;
|
||||
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")
|
||||
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)
|
||||
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() {}
|
||||
|
||||
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
||||
@@ -75,8 +134,75 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
||||
this.clientId = clientId;
|
||||
this.ipAddress = ipAddress;
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
public String getScreenedName() { return screenedName; }
|
||||
@@ -89,4 +215,17 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
||||
public void setClientId(Long clientId) { this.clientId = clientId; }
|
||||
public String getIpAddress() { return ipAddress; }
|
||||
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")
|
||||
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);
|
||||
}
|
||||
|
||||
+7
-1
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
|
||||
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
|
||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
|
||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
|
||||
case 200 -> TRANSFER_TO_SAVINGS;
|
||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||
case 500 -> PARTIAL_REINVEST;
|
||||
default -> INVALID;
|
||||
};
|
||||
}
|
||||
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
|
||||
return this.equals(INVALID);
|
||||
}
|
||||
|
||||
public boolean isPartialReinvest() {
|
||||
return this.equals(PARTIAL_REINVEST);
|
||||
}
|
||||
|
||||
// TODO: do we really need this?!?
|
||||
public static Object[] integerValues() {
|
||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
||||
|
||||
+1
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
|
||||
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
||||
public static final String transferDescriptionParamName = "transferDescription";
|
||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||
public static final String reinvestAmountParamName = "reinvestAmount";
|
||||
public static final String savingsAccounts = "savingsAccounts";
|
||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||
|
||||
+4
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
||||
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
||||
break;
|
||||
case PARTIAL_REINVEST:
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
||||
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
||||
break;
|
||||
}
|
||||
return optionData;
|
||||
}
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+10
-1
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.contentstore.policy;
|
||||
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
@@ -31,6 +32,10 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
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;
|
||||
|
||||
@Override
|
||||
@@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy {
|
||||
var result = contentDetectorManager
|
||||
.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!",
|
||||
result.getMimeType(), ctx.getMimeType()));
|
||||
}
|
||||
|
||||
+29
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.documentmanagement.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -38,6 +40,9 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
||||
|
||||
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
|
||||
private static final int MAX_CAUSE_DEPTH = 10;
|
||||
|
||||
@Deprecated
|
||||
private final List<EntityImageIdAdapter> imageIdAdapters;
|
||||
private final ContentStoreService storeService;
|
||||
@@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
||||
} catch (final AbstractPlatformDomainRuleException e) {
|
||||
throw e;
|
||||
} catch (final Exception e) {
|
||||
if (isMissingFromContentStore(e)) {
|
||||
// The m_image row survives but the file it points at does not — the usual cause is a
|
||||
// database copied between environments without its file store. For the caller that is
|
||||
// simply "this entity has no image", not a server fault, so report it the same way as a
|
||||
// missing image record instead of letting it surface as a 500.
|
||||
log.warn("Image for {} {} is recorded but missing from the content store; treating it as no image", entityType,
|
||||
entityId);
|
||||
throw new DocumentNotFoundException(entityType, entityId, -1L);
|
||||
}
|
||||
throw new DocumentInvalidRequestException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
|
||||
* way up (the file store rethrows as ContentStoreException).
|
||||
*/
|
||||
private boolean isMissingFromContentStore(final Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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 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.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Every declared dividend, financial year by financial year: how much was declared, how much members have collected,
|
||||
* how much is still unclaimed, and how much has come back to profit.
|
||||
*
|
||||
* The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is
|
||||
* still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the
|
||||
* per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the
|
||||
* money moved; the locks are what it moved for.
|
||||
*
|
||||
* The columns mirror the money's actual path, which begins and ends in the same place:
|
||||
*
|
||||
* <pre>
|
||||
* declare Dr Bank Profit Cr Dividend Payable
|
||||
* member claims Dr Dividend Payable Cr Dividend Paid → Cr Sponsor DD Payable
|
||||
* 3 yrs unclaimed Dr Dividend Payable Cr Bank Profit
|
||||
* </pre>
|
||||
*
|
||||
* So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left
|
||||
* is what is still sitting unclaimed and still within its three-year window.
|
||||
*/
|
||||
@Path("/v1/accounting/dividend-yearwise-report")
|
||||
@Component
|
||||
@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.")
|
||||
@RequiredArgsConstructor
|
||||
public class DividendYearwiseReportApiResource {
|
||||
|
||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String report() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
|
||||
// A declaration is labelled by the financial year its period CLOSES in, which is how a member
|
||||
// and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined
|
||||
// through the per-member detail rather than counted off the declaration, because a lock is only
|
||||
// created for a member who actually had something to collect.
|
||||
// Declared amounts and lock outcomes are aggregated separately before being joined by year.
|
||||
// Summing the declaration amount across a join to its locks would multiply it by the number
|
||||
// of members it was split between.
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
WITH fy AS (
|
||||
SELECT d.id,
|
||||
CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4
|
||||
THEN EXTRACT(YEAR FROM d.dividend_period_end_date)
|
||||
ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start,
|
||||
d.amount
|
||||
FROM m_share_product_dividend_pay_out d
|
||||
),
|
||||
declared AS (
|
||||
SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount
|
||||
FROM fy GROUP BY fy_start
|
||||
),
|
||||
outcomes AS (
|
||||
SELECT fy.fy_start,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount,
|
||||
COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount,
|
||||
COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount,
|
||||
COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count,
|
||||
MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due
|
||||
FROM fy
|
||||
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id
|
||||
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
|
||||
GROUP BY fy.fy_start
|
||||
)
|
||||
SELECT d.fy_start, d.declaration_count, d.declared_amount,
|
||||
COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count,
|
||||
COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count,
|
||||
COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count,
|
||||
o.earliest_release_due
|
||||
FROM declared d
|
||||
LEFT JOIN outcomes o ON o.fy_start = d.fy_start
|
||||
ORDER BY d.fy_start DESC
|
||||
""");
|
||||
|
||||
// Rounding residue is tracked per declaration rather than per lock — it never belonged to a
|
||||
// member, so no lock was ever created for it. Pulled separately and matched by year.
|
||||
final Map<String, BigDecimal> residueByYear = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList("""
|
||||
SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4
|
||||
THEN EXTRACT(YEAR FROM period_end_date)
|
||||
ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start,
|
||||
COALESCE(SUM(residue_amount), 0) AS residue
|
||||
FROM m_share_dividend_rounding_residue
|
||||
GROUP BY fy_start
|
||||
""")) {
|
||||
residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue"));
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> years = new ArrayList<>();
|
||||
BigDecimal totalDeclared = BigDecimal.ZERO;
|
||||
BigDecimal totalClaimed = BigDecimal.ZERO;
|
||||
BigDecimal totalUnclaimed = BigDecimal.ZERO;
|
||||
BigDecimal totalForfeited = BigDecimal.ZERO;
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue());
|
||||
final BigDecimal declared = (BigDecimal) row.get("declared_amount");
|
||||
final BigDecimal claimed = (BigDecimal) row.get("claimed_amount");
|
||||
final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount");
|
||||
final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount");
|
||||
final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO);
|
||||
|
||||
final Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("financialYear", financialYear);
|
||||
entry.put("declarationCount", row.get("declaration_count"));
|
||||
entry.put("declaredAmount", declared);
|
||||
entry.put("claimedAmount", claimed);
|
||||
entry.put("claimedCount", row.get("claimed_count"));
|
||||
entry.put("unclaimedAmount", unclaimed);
|
||||
entry.put("unclaimedCount", row.get("unclaimed_count"));
|
||||
entry.put("forfeitedAmount", forfeited);
|
||||
entry.put("forfeitedCount", row.get("forfeited_count"));
|
||||
entry.put("roundingResidueAmount", residue);
|
||||
// What has already gone back to profit for this year: forfeited entitlements plus the
|
||||
// rounding residue swept at declaration time.
|
||||
entry.put("returnedToProfitAmount", forfeited.add(residue));
|
||||
// When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed
|
||||
// amount starts returning to profit.
|
||||
entry.put("earliestReleaseDueDate",
|
||||
row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due")));
|
||||
years.add(entry);
|
||||
|
||||
totalDeclared = totalDeclared.add(declared);
|
||||
totalClaimed = totalClaimed.add(claimed);
|
||||
totalUnclaimed = totalUnclaimed.add(unclaimed);
|
||||
totalForfeited = totalForfeited.add(forfeited);
|
||||
}
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("years", years);
|
||||
response.put("totalDeclared", totalDeclared);
|
||||
response.put("totalClaimed", totalClaimed);
|
||||
response.put("totalUnclaimed", totalUnclaimed);
|
||||
response.put("totalForfeited", totalForfeited);
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
private String financialYearLabel(final int startYear) {
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
}
|
||||
+5
@@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper {
|
||||
accountMapping = paymentChannelSpecificAccountMapping;
|
||||
}
|
||||
}
|
||||
|
||||
if (accountMapping == null) {
|
||||
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
|
||||
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
|
||||
}
|
||||
glAccount = accountMapping.getGlAccount();
|
||||
}
|
||||
return glAccount;
|
||||
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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.math.RoundingMode;
|
||||
import java.sql.Date;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* How much profit a share dividend may actually be declared out of, for a given period.
|
||||
*
|
||||
* A co-operative society may not distribute raw net profit: the Co-operative Societies Act requires a Statutory Reserve
|
||||
* Fund transfer and a state-federation education-fund contribution to come out first, and dividends already declared
|
||||
* for other share products in the same period consume what is left. This endpoint applies that whole waterfall in one
|
||||
* place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
|
||||
*
|
||||
* Rates come from c_configuration (see changelog 0478) because they vary by state and by year. A disabled configuration
|
||||
* row means the society has an exemption from that appropriation, which is treated as 0% rather than as an error.
|
||||
*/
|
||||
@Path("/v1/accounting/distributable-profit")
|
||||
@Component
|
||||
@Tag(name = "Distributable Profit", description = "Net profit reduced by statutory appropriations and dividends already declared, giving the ceiling a new share dividend may be declared within.")
|
||||
@RequiredArgsConstructor
|
||||
public class DistributableProfitApiResource {
|
||||
|
||||
/**
|
||||
* The year's closed-out profit, which is what a dividend may be declared against; kept in step with
|
||||
* IncomeProfitTransferService.PROFIT_GL_CODE.
|
||||
*
|
||||
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would
|
||||
* offer profit already distributed once for distribution again.
|
||||
*/
|
||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||
|
||||
private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage";
|
||||
private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage";
|
||||
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("excludeProductId") final Long excludeProductId) {
|
||||
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");
|
||||
}
|
||||
if (to.before(from)) {
|
||||
throw apiError(400, "toDate must not be before fromDate");
|
||||
}
|
||||
|
||||
// A dividend is normally declared for a whole, closed financial year, and then the audited
|
||||
// profit in Bank Profit is the figure to use. A period that is not a full financial year can
|
||||
// only be an interim declaration, which necessarily runs against the still-open P&L.
|
||||
final String financialYear = financialYearOf(from.toLocalDate());
|
||||
final LocalDate[] fyRange = financialYearRange(financialYear);
|
||||
final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]);
|
||||
|
||||
final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null;
|
||||
final boolean yearClosed = closedProfit != null;
|
||||
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
|
||||
|
||||
final BigDecimal statutoryReservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE);
|
||||
final BigDecimal educationFundPercentage = configuredPercentage(CONFIG_EDUCATION_FUND);
|
||||
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
|
||||
|
||||
// Appropriations only ever reduce a profit. A loss-making period has nothing to appropriate
|
||||
// and nothing to distribute, so the whole waterfall collapses to zero rather than producing
|
||||
// a negative "reserve" that would read as money owed back to the society.
|
||||
final boolean profitable = netProfit.signum() > 0;
|
||||
final BigDecimal statutoryReserveAmount = profitable ? percentageOf(netProfit, statutoryReservePercentage) : BigDecimal.ZERO;
|
||||
final BigDecimal educationFundAmount = profitable ? percentageOf(netProfit, educationFundPercentage) : BigDecimal.ZERO;
|
||||
|
||||
final BigDecimal distributableProfit = profitable ? netProfit.subtract(statutoryReserveAmount).subtract(educationFundAmount)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
|
||||
BigDecimal alreadyDeclared = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> declaration : existingDeclarations) {
|
||||
alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount"));
|
||||
}
|
||||
|
||||
// An over-declaration that already happened (or appropriation rates raised after the fact)
|
||||
// must not present as negative headroom, which a caller could read as a credit.
|
||||
final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO);
|
||||
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("fromDate", fromDate);
|
||||
response.put("toDate", toDate);
|
||||
response.put("financialYear", financialYear);
|
||||
response.put("coversWholeFinancialYear", coversWholeFinancialYear);
|
||||
response.put("yearClosed", yearClosed);
|
||||
// Names the figure so the screen can say where it came from — the closed books, or a P&L
|
||||
// that is still moving — rather than showing a bare number the officer has to interpret.
|
||||
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
|
||||
response.put("netProfit", netProfit);
|
||||
response.put("statutoryReservePercentage", statutoryReservePercentage);
|
||||
response.put("statutoryReserveAmount", statutoryReserveAmount);
|
||||
response.put("educationFundPercentage", educationFundPercentage);
|
||||
response.put("educationFundAmount", educationFundAmount);
|
||||
response.put("distributableProfit", distributableProfit);
|
||||
response.put("alreadyDeclared", alreadyDeclared);
|
||||
response.put("availableForDeclaration", availableForDeclaration);
|
||||
response.put("maximumRatePercentage", maximumRatePercentage);
|
||||
response.put("existingDeclarations", existingDeclarations);
|
||||
return gson.toJson(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The year's profit as the books actually record it, once the year has been closed.
|
||||
*
|
||||
* A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the
|
||||
* Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather
|
||||
* than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a
|
||||
* closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it.
|
||||
*
|
||||
* Returns null when the year has not been closed, which the caller reports as a blocking condition rather than
|
||||
* silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit
|
||||
* figure that is still moving.
|
||||
*/
|
||||
private BigDecimal closedProfitForFinancialYear(final String financialYear) {
|
||||
final Integer closedCount = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
|
||||
if (closedCount == null || closedCount == 0) {
|
||||
return null;
|
||||
}
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
/** "2026-27" -> 1 April 2026 to 31 March 2027. */
|
||||
private LocalDate[] financialYearRange(final String financialYear) {
|
||||
try {
|
||||
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
|
||||
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
|
||||
} catch (final RuntimeException e) {
|
||||
throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear);
|
||||
}
|
||||
}
|
||||
|
||||
/** The Indian financial year a date falls in, as a "YYYY-YY" label. */
|
||||
private String financialYearOf(final LocalDate date) {
|
||||
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal
|
||||
* entries only. Used only for the interim case — a period inside a year that has not been closed yet.
|
||||
*/
|
||||
private BigDecimal netProfitForPeriod(final Date from, final Date to) {
|
||||
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");
|
||||
return totalIncome.subtract(totalExpense);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every dividend already declared against a period overlapping this one, across ALL share products — the
|
||||
* appropriation waterfall is society-wide, so a second product's declaration competes for the same distributable
|
||||
* profit as the first.
|
||||
*
|
||||
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
|
||||
* declaration is separately blocked outright.
|
||||
*/
|
||||
private List<Map<String, Object>> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount,
|
||||
d.dividend_period_start_date, d.dividend_period_end_date, d.status
|
||||
FROM m_share_product_dividend_pay_out d
|
||||
JOIN m_share_product p ON p.id = d.product_id
|
||||
WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ?
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(to, from));
|
||||
if (excludeProductId != null) {
|
||||
sql.append(" AND d.product_id <> ? ");
|
||||
params.add(excludeProductId);
|
||||
}
|
||||
sql.append(" ORDER BY d.dividend_period_start_date, d.id");
|
||||
|
||||
final List<Map<String, Object>> declarations = new ArrayList<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||
final Map<String, Object> declaration = new LinkedHashMap<>();
|
||||
declaration.put("declarationId", row.get("declaration_id"));
|
||||
declaration.put("productId", row.get("product_id"));
|
||||
declaration.put("productName", row.get("product_name"));
|
||||
declaration.put("amount", row.get("amount"));
|
||||
declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
|
||||
declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
|
||||
declaration.put("status", row.get("status"));
|
||||
declarations.add(declaration);
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling),
|
||||
* which is 0 — not a failure. Only an enabled row with a value applies.
|
||||
*/
|
||||
private BigDecimal configuredPercentage(final String configurationName) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
|
||||
if (rows.isEmpty() || rows.get(0).get("value") == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
|
||||
}
|
||||
|
||||
private BigDecimal percentageOf(final BigDecimal amount, final BigDecimal percentage) {
|
||||
return amount.multiply(percentage).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
+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());
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package org.apache.fineract.accounting.yearend.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Financial year-end closing of the share entry fee into the Bank Profit equity GL.
|
||||
*
|
||||
* <p>
|
||||
* {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
|
||||
* performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
|
||||
* this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
|
||||
*/
|
||||
@Path("/v1/accounting/incomeprofittransfer")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class IncomeProfitTransferApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final IncomeProfitTransferService service;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — how much is waiting to be posted, and what has been posted before
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@GET
|
||||
public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
|
||||
@QueryParam("financialYear") final String financialYearParam) {
|
||||
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
|
||||
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||
: incomeGlCodeParam.trim();
|
||||
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
|
||||
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
|
||||
|
||||
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
|
||||
: financialYearParam.trim();
|
||||
final LocalDate[] range = service.financialYearRange(financialYear);
|
||||
|
||||
final Long incomeGlId = toLong(incomeGl.get("id"));
|
||||
final BigDecimal pending = service.balanceOf(incomeGlId, range);
|
||||
final int entryCount = service.entryCountOf(incomeGlId, range);
|
||||
|
||||
final List<Map<String, Object>> history = jdbcTemplate
|
||||
.queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
|
||||
+ " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
|
||||
+ " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
|
||||
+ " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
|
||||
|
||||
final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("financialYear", financialYear);
|
||||
out.put("fromDate", range[0].toString());
|
||||
out.put("toDate", range[1].toString());
|
||||
out.put("incomeGlCode", incomeGl.get("gl_code"));
|
||||
out.put("incomeGlName", incomeGl.get("name"));
|
||||
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||
out.put("profitGlName", profitGl.get("name"));
|
||||
out.put("pendingAmount", pending);
|
||||
out.put("entryCount", entryCount);
|
||||
out.put("alreadyPosted", alreadyPosted);
|
||||
out.put("postedHistory", history);
|
||||
out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST — close the income GL into Bank Profit
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@POST
|
||||
@Transactional
|
||||
public Response post(final String body) {
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
|
||||
final JsonObject json = parseBody(body);
|
||||
final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||
: getString(json, "incomeGlCode").trim();
|
||||
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
|
||||
: getString(json, "financialYear").trim();
|
||||
|
||||
// strict = true: a manual posting reports "already closed" or "nothing to post" as an
|
||||
// error, so the user is told why nothing happened.
|
||||
final Map<String, Object> result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
|
||||
sanitiseNote(getString(json, "note")), true);
|
||||
|
||||
return Response.ok(gson.toJson(result)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST /close-year — close the WHOLE P&L into Bank Profit
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
|
||||
*
|
||||
* This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
|
||||
* share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
|
||||
* has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
|
||||
*/
|
||||
@POST
|
||||
@Path("/close-year")
|
||||
@Transactional
|
||||
public Response closeYear(final String body) {
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
|
||||
final JsonObject json = parseBody(body);
|
||||
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
|
||||
: getString(json, "financialYear").trim();
|
||||
|
||||
// strict = true: a manual close reports "nothing left to close" as an error, so the user is
|
||||
// told why nothing happened rather than getting a silent success.
|
||||
final Map<String, Object> result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
|
||||
sanitiseNote(getString(json, "note")), true);
|
||||
|
||||
return Response.ok(gson.toJson(result)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private JsonObject parseBody(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitiseNote(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = raw.trim();
|
||||
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
|
||||
}
|
||||
|
||||
private String getString(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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.yearend.jobs.postshareentryfeetoprofit;
|
||||
|
||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class PostShareEntryFeeToProfitConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private IncomeProfitTransferService incomeProfitTransferService;
|
||||
|
||||
@Bean
|
||||
protected Step postShareEntryFeeToProfitStep() {
|
||||
return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
|
||||
.tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job postShareEntryFeeToProfitJob() {
|
||||
return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
|
||||
return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
|
||||
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
|
||||
* Profit equity GL, leaving the income account at nil for the new year.
|
||||
*
|
||||
* <p>
|
||||
* Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
|
||||
* rather than failing the job. Only a genuine posting failure is raised.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PostShareEntryFeeToProfitTasklet implements Tasklet {
|
||||
|
||||
/** Fineract's system user id, used because a scheduled run has no logged-in user. */
|
||||
private static final long SYSTEM_USER_ID = 1L;
|
||||
|
||||
private final IncomeProfitTransferService incomeProfitTransferService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
final String financialYear = incomeProfitTransferService.currentFinancialYear();
|
||||
|
||||
// Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
|
||||
// is now what the share dividend declaration reads as "the year's profit", and a partial close
|
||||
// would leave most of the profit still sitting in the income GLs — understating what may be
|
||||
// distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
|
||||
final Map<String, Object> result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
|
||||
"Automatic year-end closing", false);
|
||||
|
||||
log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
package org.apache.fineract.accounting.yearend.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Closes an income GL into the Bank Profit equity GL at financial year end.
|
||||
*
|
||||
* <pre>
|
||||
* Dr Share Entry Fee Income (10.4) = balance for the year
|
||||
* Cr Bank Profit (BANKPROFIT01)
|
||||
* </pre>
|
||||
*
|
||||
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
|
||||
* retained profit.
|
||||
*
|
||||
* <p>
|
||||
* Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are
|
||||
* protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a
|
||||
* logged-in user.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class IncomeProfitTransferService {
|
||||
|
||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||
private static final int DEBIT = 2;
|
||||
private static final int CREDIT = 1;
|
||||
|
||||
/** Equity GL that accumulates profit. */
|
||||
/**
|
||||
* The year's profit is closed into the Non-Distributable account, not plain Bank Profit.
|
||||
*
|
||||
* A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once
|
||||
* the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years'
|
||||
* leftovers, and the next declaration would be sized on profit that had already been through a distribution
|
||||
* decision — offering the same money for distribution twice.
|
||||
*/
|
||||
public static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||
|
||||
/** Income GL closed to profit by the year-end job — the share entry fee. */
|
||||
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
|
||||
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
|
||||
* errors.
|
||||
*
|
||||
* @param strict
|
||||
* when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it
|
||||
* returns quietly.
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName,
|
||||
final String note, final boolean strict) {
|
||||
|
||||
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
|
||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
||||
final Long incomeGlId = toLong(incomeGl.get("id"));
|
||||
final Long profitGlId = toLong(profitGl.get("id"));
|
||||
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final Long officeId = headOfficeId();
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("financialYear", financialYear);
|
||||
out.put("incomeGlCode", incomeGl.get("gl_code"));
|
||||
out.put("incomeGlName", incomeGl.get("name"));
|
||||
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||
out.put("profitGlName", profitGl.get("name"));
|
||||
|
||||
final Integer existing = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
|
||||
Integer.class, financialYear, incomeGlId, officeId);
|
||||
if (existing != null && existing > 0) {
|
||||
if (strict) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted",
|
||||
incomeGl.get("name") + " has already been closed to profit for " + financialYear + ".");
|
||||
}
|
||||
out.put("posted", false);
|
||||
out.put("reason", "Already closed for " + financialYear);
|
||||
return out;
|
||||
}
|
||||
|
||||
final BigDecimal balance = balanceOf(incomeGlId, range);
|
||||
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
if (strict) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post",
|
||||
"There is no income in " + incomeGl.get("name") + " to close for " + financialYear + ".");
|
||||
}
|
||||
out.put("posted", false);
|
||||
out.put("reason", "No income to close for " + financialYear);
|
||||
return out;
|
||||
}
|
||||
|
||||
final int entryCount = entryCountOf(incomeGlId, range);
|
||||
final String currencyCode = resolveCurrency(incomeGlId);
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate postingDate = range[1];
|
||||
final String txnId = "YEPROFIT-" + System.nanoTime();
|
||||
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
|
||||
+ (note != null ? " | " + note : "");
|
||||
|
||||
insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc);
|
||||
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||
"Profit for " + financialYear + " from " + incomeGl.get("name"));
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_income_profit_transfer "
|
||||
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
|
||||
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance,
|
||||
entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note);
|
||||
|
||||
log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"),
|
||||
financialYear);
|
||||
|
||||
out.put("posted", true);
|
||||
out.put("amountPostedToProfit", balance);
|
||||
out.put("entryCount", entryCount);
|
||||
out.put("postedOnDate", postingDate.toString());
|
||||
out.put("glTransactionId", txnId);
|
||||
out.put("currency", currencyCode);
|
||||
out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + ".");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real
|
||||
* net profit.
|
||||
*
|
||||
* <pre>
|
||||
* each income GL: Dr Income GL Cr Bank Profit (income raises profit)
|
||||
* each expense GL: Dr Bank Profit Cr Expense GL (expense reduces profit)
|
||||
* </pre>
|
||||
*
|
||||
* {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes
|
||||
* the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" —
|
||||
* the share dividend declaration in particular — depends on this having run, because a partial close leaves the
|
||||
* profit split across two places and understates it.
|
||||
*
|
||||
* Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a
|
||||
* re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was.
|
||||
* Accounts with a nil balance are skipped rather than posted as zero entries.
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
|
||||
final boolean strict) {
|
||||
|
||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
||||
final Long profitGlId = toLong(profitGl.get("id"));
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final Long officeId = headOfficeId();
|
||||
|
||||
// 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a
|
||||
// historical balance but must not attract new postings.
|
||||
final List<Map<String, Object>> plAccounts = jdbcTemplate.queryForList(
|
||||
"SELECT id, gl_code, name, classification_enum FROM acc_gl_account "
|
||||
+ " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code");
|
||||
|
||||
final List<Map<String, Object>> closed = new ArrayList<>();
|
||||
final List<Map<String, Object>> skipped = new ArrayList<>();
|
||||
BigDecimal totalIncome = BigDecimal.ZERO;
|
||||
BigDecimal totalExpense = BigDecimal.ZERO;
|
||||
|
||||
for (final Map<String, Object> gl : plAccounts) {
|
||||
final Long glId = toLong(gl.get("id"));
|
||||
final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4;
|
||||
|
||||
final Integer existing = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
|
||||
Integer.class, financialYear, glId, officeId);
|
||||
if (existing != null && existing > 0) {
|
||||
skipped.add(skipRow(gl, "Already closed for " + financialYear));
|
||||
continue;
|
||||
}
|
||||
|
||||
// balanceOf returns credit-less-debit. For an income GL that is the income earned; for an
|
||||
// expense GL the natural balance is a debit, so the same figure comes back negative and
|
||||
// is flipped here to read as a positive expense.
|
||||
final BigDecimal rawBalance = balanceOf(glId, range);
|
||||
final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate();
|
||||
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
skipped.add(skipRow(gl, "Nothing to close for " + financialYear));
|
||||
continue;
|
||||
}
|
||||
|
||||
final int entryCount = entryCountOf(glId, range);
|
||||
final String currencyCode = resolveCurrency(glId);
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate postingDate = range[1];
|
||||
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
|
||||
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
|
||||
+ (note != null ? " | " + note : "");
|
||||
|
||||
if (isIncome) {
|
||||
insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
|
||||
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||
"Profit for " + financialYear + " from " + gl.get("name"));
|
||||
totalIncome = totalIncome.add(balance);
|
||||
} else {
|
||||
insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
|
||||
insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||
"Expense for " + financialYear + " from " + gl.get("name"));
|
||||
totalExpense = totalExpense.add(balance);
|
||||
}
|
||||
|
||||
jdbcTemplate.update("INSERT INTO m_income_profit_transfer "
|
||||
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
|
||||
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId,
|
||||
java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId,
|
||||
java.sql.Date.valueOf(postingDate), userId, userName, note);
|
||||
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("glCode", gl.get("gl_code"));
|
||||
row.put("glName", gl.get("name"));
|
||||
row.put("type", isIncome ? "INCOME" : "EXPENSE");
|
||||
row.put("amount", balance);
|
||||
row.put("entryCount", entryCount);
|
||||
row.put("glTransactionId", txnId);
|
||||
closed.add(row);
|
||||
}
|
||||
|
||||
if (closed.isEmpty() && strict) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close",
|
||||
"There is nothing left to close to profit for " + financialYear + ".");
|
||||
}
|
||||
|
||||
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
|
||||
log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense,
|
||||
netProfit, profitGl.get("name"));
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("financialYear", financialYear);
|
||||
out.put("fromDate", range[0].toString());
|
||||
out.put("toDate", range[1].toString());
|
||||
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||
out.put("profitGlName", profitGl.get("name"));
|
||||
out.put("totalIncomeClosed", totalIncome);
|
||||
out.put("totalExpenseClosed", totalExpense);
|
||||
out.put("netProfitPosted", netProfit);
|
||||
out.put("accountsClosed", closed);
|
||||
out.put("accountsSkipped", skipped);
|
||||
out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit "
|
||||
+ netProfit + ".");
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> skipRow(final Map<String, Object> gl, final String reason) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("glCode", gl.get("gl_code"));
|
||||
row.put("glName", gl.get("name"));
|
||||
row.put("reason", reason);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the
|
||||
* year has been closed.
|
||||
*
|
||||
* Only entries dated inside the financial year count, so each year's profit is separable rather than being one
|
||||
* ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide
|
||||
* whether that is a blocking condition.
|
||||
*/
|
||||
public BigDecimal profitForFinancialYear(final String financialYear) {
|
||||
final LocalDate[] range = financialYearRange(financialYear);
|
||||
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
||||
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
|
||||
BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
/** Whether the year's P&L has been closed into Bank Profit at all. */
|
||||
public boolean isYearClosed(final String financialYear) {
|
||||
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?",
|
||||
Integer.class, financialYear);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
/** Net credit balance sitting in the income GL for the year. */
|
||||
public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
||||
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?",
|
||||
BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
/** How many fee postings made up that balance. */
|
||||
public int entryCountOf(final Long incomeGlId, final LocalDate[] range) {
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false "
|
||||
+ " AND type_enum = ? AND entry_date BETWEEN ? AND ?",
|
||||
Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||
return count == null ? 0 : count;
|
||||
}
|
||||
|
||||
/** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */
|
||||
public String currentFinancialYear() {
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
|
||||
/** 1 April to 31 March for a "YYYY-YY" label. */
|
||||
public LocalDate[] financialYearRange(final String financialYear) {
|
||||
try {
|
||||
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
|
||||
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
|
||||
} catch (RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid",
|
||||
"financialYear must look like 2026-27. Received: " + financialYear);
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> loadGlByCode(final String glCode) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode);
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
public Long headOfficeId() {
|
||||
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||
}
|
||||
|
||||
private String resolveCurrency(final Long glAccountId) {
|
||||
try {
|
||||
final String code = jdbcTemplate.queryForObject(
|
||||
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
|
||||
glAccountId);
|
||||
return code == null ? "INR" : code;
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
return "INR";
|
||||
}
|
||||
}
|
||||
|
||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||
+ " entry_date, type_enum, amount, description, "
|
||||
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||
+ " is_running_balance_calculated, office_running_balance, "
|
||||
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
|
||||
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC),
|
||||
now.atOffset(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
}
|
||||
+5
@@ -154,6 +154,11 @@ public class SecurityConfig {
|
||||
// a Fineract Bearer token; security is enforced at the JWT envelope
|
||||
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
||||
// Client edit approval email open-tracking pixel — hit by the client's email client
|
||||
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
|
||||
// path is the only capability credential; the endpoint always returns a GIF so
|
||||
// token validity is not leaked via different HTTP responses.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
|
||||
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
|
||||
+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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
||||
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
|
||||
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
|
||||
accountsSummary.append("sa.deposit_type_enum as depositType ");
|
||||
accountsSummary.append("sa.deposit_type_enum as depositType, ");
|
||||
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
|
||||
// this row's cycle within the chain, so a client's account list can collapse a renewed
|
||||
// FD into one entry rather than listing every term as a separate account.
|
||||
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
|
||||
accountsSummary.append("from m_savings_account sa ");
|
||||
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
||||
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
|
||||
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
||||
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
||||
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
|
||||
@@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
|
||||
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
|
||||
|
||||
// Null for a savings/current account and for an FD opened before this column existed;
|
||||
// callers fall back to accountNo, so an FD that has never been renewed still reads as
|
||||
// its own FD number.
|
||||
final String fdAccountNumber = rs.getString("fdAccountNumber");
|
||||
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
|
||||
|
||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
||||
lastActiveTransactionDate);
|
||||
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
||||
|
||||
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
||||
|
||||
// Current accounts have no deposit term, so no FD renewal chain to report.
|
||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
|
||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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.accountregister.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Account register for one date: which accounts were opened and which were closed that day, with
|
||||
* each account's balance as it stood on that date alongside what it holds now.
|
||||
*
|
||||
* The dated balance is read from the running balance stamped on the last transaction up to that
|
||||
* date, rather than recomputed from the account's current figure — a current balance tells you
|
||||
* nothing about what the account held three weeks ago.
|
||||
*/
|
||||
@Path("/v1/accountregister")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class AccountRegisterApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final LocalDate asOn;
|
||||
if (dateParam != null && !dateParam.isBlank()) {
|
||||
try {
|
||||
asOn = LocalDate.parse(dateParam);
|
||||
} catch (final java.time.format.DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
|
||||
"Date must be in yyyy-MM-dd format.");
|
||||
}
|
||||
} else {
|
||||
asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
}
|
||||
|
||||
final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
|
||||
rows.addAll(depositAccounts(asOn, want));
|
||||
}
|
||||
if (matches(want, "SHARE")) {
|
||||
rows.addAll(shareAccounts(asOn));
|
||||
}
|
||||
|
||||
// Opened and closed counts come from the rows themselves rather than separate COUNT queries,
|
||||
// so the totals can never disagree with the list they summarise.
|
||||
int opened = 0;
|
||||
int closed = 0;
|
||||
BigDecimal balanceOnDate = BigDecimal.ZERO;
|
||||
BigDecimal currentBalance = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> r : rows) {
|
||||
if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
|
||||
opened++;
|
||||
}
|
||||
if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
|
||||
closed++;
|
||||
}
|
||||
balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
|
||||
currentBalance = currentBalance.add(dec(r.get("currentBalance")));
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("date", asOn.toString());
|
||||
out.put("section", want);
|
||||
out.put("accounts", rows);
|
||||
out.put("count", rows.size());
|
||||
out.put("openedCount", opened);
|
||||
out.put("closedCount", closed);
|
||||
out.put("totalBalanceOnDate", balanceOnDate);
|
||||
out.put("totalCurrentBalance", currentBalance);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
private static boolean matches(final String want, final String section) {
|
||||
return "ALL".equals(want) || want.equals(section);
|
||||
}
|
||||
|
||||
private static BigDecimal dec(final Object o) {
|
||||
return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
|
||||
* apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
|
||||
*
|
||||
* Accounts submitted after the date are excluded — on that date they did not exist, and listing
|
||||
* them would make a register of a past day describe a present one.
|
||||
*/
|
||||
private List<Map<String, Object>> depositAccounts(final LocalDate asOn, final String want) {
|
||||
final Date date = Date.valueOf(asOn);
|
||||
|
||||
final StringBuilder typeFilter = new StringBuilder();
|
||||
if (!"ALL".equals(want)) {
|
||||
final int type = switch (want) {
|
||||
case "SAVINGS" -> 100;
|
||||
case "FD" -> 200;
|
||||
case "RD" -> 300;
|
||||
default -> 0;
|
||||
};
|
||||
if (type > 0) {
|
||||
typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
|
||||
}
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
|
||||
sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
|
||||
sa.account_balance_derived,
|
||||
c.display_name AS client_name,
|
||||
p.name AS product_name,
|
||||
(SELECT t.running_balance_derived
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = sa.id
|
||||
AND COALESCE(t.is_reversed, false) = false
|
||||
AND t.transaction_date <= ?
|
||||
ORDER BY t.transaction_date DESC, t.id DESC
|
||||
LIMIT 1) AS balance_on_date
|
||||
FROM m_savings_account sa
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_savings_product p ON p.id = sa.product_id
|
||||
WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
|
||||
""" + typeFilter + """
|
||||
ORDER BY sa.deposit_type_enum, sa.account_no
|
||||
""", date, date);
|
||||
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
final int depositType = num(r.get("deposit_type_enum"));
|
||||
m.put("accountId", r.get("id"));
|
||||
m.put("accountNo", r.get("account_no"));
|
||||
m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
|
||||
m.put("clientName", r.get("client_name"));
|
||||
m.put("productName", r.get("product_name"));
|
||||
m.put("status", savingsStatus(num(r.get("status_enum"))));
|
||||
m.put("openedOn", str(r.get("activatedon_date")));
|
||||
m.put("closedOn", str(r.get("closedon_date")));
|
||||
m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
|
||||
m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
|
||||
// No transaction up to that date means the account had nothing in it yet, not that the
|
||||
// balance is unknown.
|
||||
m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
|
||||
m.put("currentBalance", r.get("account_balance_derived") == null
|
||||
? BigDecimal.ZERO : r.get("account_balance_derived"));
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share accounts. Their transactions carry no running balance the way savings ones do, but they
|
||||
* are individually dated, so the holding on a given date is the shares purchased up to that
|
||||
* date less those redeemed — which is a real dated figure, not the current one relabelled.
|
||||
*/
|
||||
private List<Map<String, Object>> shareAccounts(final LocalDate asOn) {
|
||||
final Date date = Date.valueOf(asOn);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
|
||||
sa.total_approved_shares,
|
||||
c.display_name AS client_name,
|
||||
p.name AS product_name,
|
||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
|
||||
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
|
||||
ELSE 0 END), 0)
|
||||
FROM m_share_account_transactions t
|
||||
WHERE t.account_id = sa.id AND t.is_active = true
|
||||
AND t.status_enum = 300
|
||||
AND t.type_enum IN (500, 600)) AS share_value,
|
||||
-- Purchases less redemptions dated on or before the register's date. Charge
|
||||
-- rows (type 700) are excluded: an entry fee is not share capital. Only
|
||||
-- approved purchases count (status 300) — a rejected application leaves its
|
||||
-- row behind still flagged active, and counting it credited a member with
|
||||
-- shares they were never issued.
|
||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
|
||||
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
|
||||
ELSE 0 END), 0)
|
||||
FROM m_share_account_transactions t
|
||||
WHERE t.account_id = sa.id AND t.is_active = true
|
||||
AND t.status_enum = 300
|
||||
AND t.type_enum IN (500, 600)
|
||||
AND t.transaction_date <= ?) AS value_on_date,
|
||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
|
||||
WHEN t.type_enum = 600 THEN -t.total_shares
|
||||
ELSE 0 END), 0)
|
||||
FROM m_share_account_transactions t
|
||||
WHERE t.account_id = sa.id AND t.is_active = true
|
||||
AND t.status_enum = 300
|
||||
AND t.type_enum IN (500, 600)
|
||||
AND t.transaction_date <= ?) AS shares_on_date
|
||||
FROM m_share_account sa
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_share_product p ON p.id = sa.product_id
|
||||
WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
|
||||
ORDER BY sa.account_no
|
||||
""", date, date, date);
|
||||
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("accountId", r.get("id"));
|
||||
m.put("accountNo", r.get("account_no"));
|
||||
m.put("section", "SHARE");
|
||||
m.put("clientName", r.get("client_name"));
|
||||
m.put("productName", r.get("product_name"));
|
||||
m.put("status", shareStatus(num(r.get("status_enum"))));
|
||||
m.put("openedOn", str(r.get("activated_date")));
|
||||
m.put("closedOn", str(r.get("closed_date")));
|
||||
m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
|
||||
m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
|
||||
m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
|
||||
m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
|
||||
// Shares as at the date, not the approved figure — an account can hold fewer than were
|
||||
// approved once some have been redeemed.
|
||||
m.put("sharesHeld", r.get("shares_on_date"));
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
|
||||
return dbDate != null && asOn.toString().equals(dbDate.toString());
|
||||
}
|
||||
|
||||
private static String str(final Object o) {
|
||||
return o == null ? null : o.toString();
|
||||
}
|
||||
|
||||
private static int num(final Object o) {
|
||||
return o instanceof Number n ? n.intValue() : 0;
|
||||
}
|
||||
|
||||
private static String savingsStatus(final int status) {
|
||||
return switch (status) {
|
||||
case 100 -> "SUBMITTED";
|
||||
case 200 -> "APPROVED";
|
||||
case 300 -> "ACTIVE";
|
||||
case 400 -> "WITHDRAWN";
|
||||
case 500 -> "REJECTED";
|
||||
case 600 -> "CLOSED";
|
||||
case 700 -> "TRANSFER IN PROGRESS";
|
||||
case 800 -> "TRANSFER ON HOLD";
|
||||
case 900 -> "PREMATURELY CLOSED";
|
||||
default -> "UNKNOWN";
|
||||
};
|
||||
}
|
||||
|
||||
private static String shareStatus(final int status) {
|
||||
return switch (status) {
|
||||
case 100 -> "SUBMITTED";
|
||||
case 200 -> "APPROVED";
|
||||
case 300 -> "ACTIVE";
|
||||
case 400 -> "WITHDRAWN";
|
||||
case 500 -> "REJECTED";
|
||||
case 600 -> "CLOSED";
|
||||
default -> "UNKNOWN";
|
||||
};
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -102,12 +102,23 @@ public interface ShareAccountApiConstants {
|
||||
|
||||
String shareEntityType = "share";
|
||||
|
||||
/** Required on a redeem-shares (withdrawal) request — Board Resolution Number and Date of
|
||||
* Meeting authorising the withdrawal (cooperative-sector share capital governance). */
|
||||
String redeemresolutionnumber_paramname = "resolutionNumber";
|
||||
|
||||
String redeemmeetingdate_paramname = "meetingDate";
|
||||
|
||||
/** Loan this share account is held as margin/security against — optional, set via the
|
||||
* account update command. When present, a redeem request is checked against the loan's
|
||||
* outstanding balance. */
|
||||
String linkedloanid_paramname = "linkedLoanId";
|
||||
|
||||
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
||||
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
|
||||
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
|
||||
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
|
||||
numberofshares_paramname, purchasedprice_paramname)));
|
||||
numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname)));
|
||||
|
||||
}
|
||||
|
||||
+28
@@ -33,8 +33,10 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/aml/alerts")
|
||||
@@ -46,7 +48,9 @@ public class AmlAlertApiResource {
|
||||
private final PlatformSecurityContext context;
|
||||
private final AmlAlertReadPlatformService readPlatformService;
|
||||
private final AmlAlertWritePlatformService writePlatformService;
|
||||
private final JournalEntryReferralService referralService;
|
||||
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
|
||||
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -76,4 +80,28 @@ public class AmlAlertApiResource {
|
||||
.orElse(null);
|
||||
return this.toApiJsonSerializer.serialize(updated);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("manual-referrals")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List ledger entries marked by a user",
|
||||
description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.")
|
||||
public String retrieveManualReferrals() {
|
||||
this.context.authenticatedUser();
|
||||
return this.referralJsonSerializer.serialize(this.referralService.retrieveAll());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("manual-referral")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Mark a ledger entry cleared or suspicious",
|
||||
description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.")
|
||||
public String referJournalEntry(final ManualReferralRequest request) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
||||
final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(),
|
||||
request.getNotes());
|
||||
return this.referralJsonSerializer.serialize(referral);
|
||||
}
|
||||
}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertReadPlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/aml/recurring-alerts")
|
||||
@Component
|
||||
@Tag(name = "AML Recurring Alerts",
|
||||
description = "Customers whose transactions repeatedly trip the AML engine, queued for an employee to review why.")
|
||||
@RequiredArgsConstructor
|
||||
public class AmlRecurringAlertApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
||||
private final AmlRecurringAlertWritePlatformService writePlatformService;
|
||||
private final DefaultToApiJsonSerializer<AmlRecurringAlertData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve the recurring-alert review queue",
|
||||
description = "Customers with at least minAlerts engine alerts inside the last windowDays, worst first. "
|
||||
+ "Each row carries the triggering transactions so the reviewer can see why the customer keeps surfacing.")
|
||||
public String retrieveRecurringAlerts(
|
||||
@QueryParam("minAlerts") @DefaultValue("3") final int minAlerts,
|
||||
@QueryParam("windowDays") @DefaultValue("90") final int windowDays,
|
||||
@QueryParam("onlyNeedingReview") @DefaultValue("false") final boolean onlyNeedingReview) {
|
||||
this.context.authenticatedUser();
|
||||
final List<AmlRecurringAlertData> rows = this.readPlatformService.retrieve(Math.max(minAlerts, 2), Math.max(windowDays, 1),
|
||||
onlyNeedingReview);
|
||||
return this.toApiJsonSerializer.serialize(rows);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{clientId}/review")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Record a review of a customer's repeated AML activity",
|
||||
description = "outcome is CLEARED, MONITOR or ESCALATED. The customer leaves the queue until their alert "
|
||||
+ "count grows beyond the count reviewed here.")
|
||||
public String review(@PathParam("clientId") final Long clientId, final AmlRecurringReviewRequest request) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
||||
final Long reviewId = this.writePlatformService.recordReview(clientId, request.getOutcome(), request.getNotes(),
|
||||
request.getWindowDays() == null ? 0 : request.getWindowDays());
|
||||
return "{\"clientId\":" + clientId + ",\"reviewId\":" + reviewId + "}";
|
||||
}
|
||||
}
|
||||
+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.aml.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Request body for {@code POST /v1/aml/recurring-alerts/{clientId}/review}. */
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class AmlRecurringReviewRequest {
|
||||
|
||||
@Schema(description = "CLEARED, MONITOR or ESCALATED", requiredMode = Schema.RequiredMode.REQUIRED, example = "MONITOR")
|
||||
private String outcome;
|
||||
|
||||
@Schema(description = "Why the customer keeps surfacing and what was decided",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String notes;
|
||||
|
||||
@Schema(description = "Observation window the reviewer had on screen, so the recorded alert count matches it",
|
||||
example = "90")
|
||||
private Integer windowDays;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.api;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /v1/aml/alerts/manual-referral}.
|
||||
*
|
||||
* {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared,
|
||||
* no report) or ESCALATE (raises an STR case for the Principal Officer).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ManualReferralRequest {
|
||||
|
||||
private Long journalEntryId;
|
||||
private String disposition;
|
||||
private String notes;
|
||||
}
|
||||
+8
-1
@@ -22,7 +22,14 @@ import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @param riskCategory
|
||||
* severity of the rule that fired (1 Low, 2 Medium, 3 High), taken from the originating
|
||||
* {@code m_client_risk_rule} entry via {@code source_client_risk_rule_id}. Null for rules with no catalog
|
||||
* ancestor — MANUAL_REFERRAL is raised by a user, not derived from the rule set, so it carries no severity.
|
||||
*/
|
||||
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
|
||||
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
|
||||
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition) implements Serializable {
|
||||
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
|
||||
implements Serializable {
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One customer who has tripped the AML engine repeatedly inside the observation window.
|
||||
*
|
||||
* @param alertCount
|
||||
* alerts in the window — what the threshold is compared against
|
||||
* @param highRiskCount
|
||||
* how many of those came from a High severity catalog rule
|
||||
* @param distinctRules
|
||||
* number of different rules involved; one rule firing five times is a weaker signal than
|
||||
* five different rules firing once, and the reviewer needs to see which it is
|
||||
* @param openCount
|
||||
* alerts still awaiting a per-transaction disposition
|
||||
* @param escalatedCount
|
||||
* alerts already escalated to an STR case
|
||||
* @param needsReview
|
||||
* true when there is no review yet, or new alerts have arrived since the last one
|
||||
* @param alerts
|
||||
* the triggering transactions themselves, so the reviewer can see why this customer keeps
|
||||
* surfacing without leaving the row
|
||||
*/
|
||||
public record AmlRecurringAlertData(Long clientId, String clientDisplayName, String accountNo, Integer clientRiskCategory,
|
||||
int alertCount, int highRiskCount, int distinctRules, int openCount, int escalatedCount, BigDecimal totalAmount,
|
||||
LocalDateTime firstAlertOn, LocalDateTime lastAlertOn, List<String> ruleCodes, boolean needsReview,
|
||||
Integer alertCountAtLastReview, String lastReviewOutcome, String lastReviewedByUsername, LocalDateTime lastReviewedOn,
|
||||
String lastReviewNotes, LocalDate windowFrom, List<AmlAlertData> alerts) implements Serializable {
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One ledger entry a user has marked, so the ledger screen can show what has already been dealt with.
|
||||
*
|
||||
* {@code journalEntryId} is the acc_gl_journal_entry row the mark was made against - it is what the ledger table keys
|
||||
* on. {@code strCaseId} is populated only where the mark escalated into an STR case.
|
||||
*/
|
||||
public record ManualReferralData(Long alertId, Long journalEntryId, Long clientId, String clientDisplayName,
|
||||
BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, String disposition,
|
||||
String reviewedByUsername, String reviewNotes, Long strCaseId) implements Serializable {}
|
||||
+25
-4
@@ -19,12 +19,15 @@
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -34,20 +37,38 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
|
||||
|
||||
private final AmlAlertRepository repository;
|
||||
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
||||
|
||||
@Override
|
||||
public List<AmlAlertData> retrieve(String statusFilter) {
|
||||
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
|
||||
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
|
||||
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData)
|
||||
.collect(Collectors.toList());
|
||||
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
|
||||
// lookup inside the map step would re-read the same catalog rows repeatedly.
|
||||
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts);
|
||||
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
|
||||
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private AmlAlertData mapToData(AmlAlert alert) {
|
||||
/** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */
|
||||
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts) {
|
||||
final List<Long> sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct()
|
||||
.toList();
|
||||
final Map<Long, Integer> byId = new HashMap<>();
|
||||
if (sourceIds.isEmpty()) {
|
||||
return byId;
|
||||
}
|
||||
this.clientRiskRuleRepository.findAllById(sourceIds).forEach(rule -> byId.put(rule.getId(), rule.getRiskCategory()));
|
||||
return byId;
|
||||
}
|
||||
|
||||
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
|
||||
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
|
||||
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
|
||||
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
|
||||
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
|
||||
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
|
||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
|
||||
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name());
|
||||
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
@@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not
|
||||
// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer.
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public class AmlFundTransferEvaluationService {
|
||||
|
||||
private static final int STRUCTURING_WINDOW_DAYS = 30;
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
|
||||
public interface AmlRecurringAlertNotificationService {
|
||||
|
||||
/**
|
||||
* Called once per transaction after the engine has saved its alerts. Raises a notification for
|
||||
* AML reviewers when this customer's alert count crosses the recurring-behaviour threshold.
|
||||
*
|
||||
* @param alertsJustRaised
|
||||
* how many alerts this transaction produced, so the count before it can be derived
|
||||
*/
|
||||
void onAlertsRaised(Client client, int alertsJustRaised);
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.notification.service.UserNotificationService;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Pushes a notification to AML reviewers when one customer keeps tripping the engine.
|
||||
*
|
||||
* The review queue on the AML Rules Master screen is the durable record; this is the nudge that
|
||||
* stops it having to be watched. Recipients are every user holding REVIEW_AML_ALERT in the
|
||||
* customer's office, resolved by {@link UserNotificationService}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AmlRecurringAlertNotificationServiceImpl implements AmlRecurringAlertNotificationService {
|
||||
|
||||
/**
|
||||
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD — the point the platform already
|
||||
* treats as "this customer has done this enough times to warrant another look".
|
||||
*/
|
||||
private static final int RECURRING_THRESHOLD = 3;
|
||||
private static final int WINDOW_DAYS = 90;
|
||||
|
||||
private static final String REVIEW_PERMISSION = "REVIEW_AML_ALERT";
|
||||
private static final String OBJECT_TYPE = "AML_RECURRING";
|
||||
private static final String EVENT_TYPE = "recurringAmlActivity";
|
||||
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final UserNotificationService userNotificationService;
|
||||
|
||||
@Override
|
||||
public void onAlertsRaised(final Client client, final int alertsJustRaised) {
|
||||
if (client == null || alertsJustRaised <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final LocalDateTime since = LocalDate.now().minusDays(WINDOW_DAYS).atStartOfDay();
|
||||
final long after = this.alertRepository.countByClient_IdAndRaisedOnGreaterThanEqual(client.getId(), since);
|
||||
final long before = after - alertsJustRaised;
|
||||
|
||||
// Fires on each multiple of the threshold - 3rd, 6th, 9th alert - rather than on every
|
||||
// alert once the threshold is passed. Notifying every time would bury the reviewer in
|
||||
// duplicates for a customer they have already been told about; notifying only on the
|
||||
// first crossing would go permanently silent on a customer who keeps offending.
|
||||
if (before / RECURRING_THRESHOLD >= after / RECURRING_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String content = String.format(
|
||||
"%s has triggered %d AML alerts in the last %d days. Review why this customer keeps being flagged.",
|
||||
client.getDisplayName(), after, WINDOW_DAYS);
|
||||
|
||||
this.userNotificationService.notifyUsers(REVIEW_PERMISSION, OBJECT_TYPE, client.getId(), content, EVENT_TYPE, null,
|
||||
client.getOffice() == null ? null : client.getOffice().getId());
|
||||
log.info("Recurring AML activity notification raised for client {} at {} alerts", client.getId(), after);
|
||||
} catch (final RuntimeException e) {
|
||||
// Consistent with AmlDomainServiceImpl: AML observation must never break the transaction
|
||||
// it is watching, and a notification is the least important part of that observation.
|
||||
log.error("Failed to raise recurring AML activity notification for client {}", client.getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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.portfolio.aml.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
|
||||
|
||||
public interface AmlRecurringAlertReadPlatformService {
|
||||
|
||||
/**
|
||||
* Customers with {@code minAlerts} or more engine alerts inside the last {@code windowDays}.
|
||||
*
|
||||
* @param onlyNeedingReview
|
||||
* when true, hides customers already reviewed at their current alert count
|
||||
*/
|
||||
List<AmlRecurringAlertData> retrieve(int minAlerts, int windowDays, boolean onlyNeedingReview);
|
||||
|
||||
/** Alerts in the window for one customer, used to record a review against the count seen. */
|
||||
int alertCountInWindow(Long clientId, int windowDays);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Builds the recurring-alert review queue.
|
||||
*
|
||||
* The alert set is read through {@link AmlAlertReadPlatformService} rather than queried directly, so
|
||||
* the rule-severity resolution lives in exactly one place and a customer's High-risk count here can
|
||||
* never disagree with the Risk Category shown on the alert itself.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAlertReadPlatformService {
|
||||
|
||||
private static final int HIGH_RISK = 3;
|
||||
|
||||
private final AmlAlertReadPlatformService alertReadPlatformService;
|
||||
private final AmlRecurringReviewRepository reviewRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public List<AmlRecurringAlertData> retrieve(final int minAlerts, final int windowDays, final boolean onlyNeedingReview) {
|
||||
final LocalDate windowFrom = LocalDate.now().minusDays(windowDays);
|
||||
final LocalDateTime cutoff = windowFrom.atStartOfDay();
|
||||
|
||||
// Grouped in insertion order of the (already newest-first) alert list, so each customer's
|
||||
// nested alerts stay newest-first without a second sort.
|
||||
final Map<Long, List<AmlAlertData>> byClient = this.alertReadPlatformService.retrieve(null).stream()
|
||||
.filter(a -> a.clientId() != null && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff))
|
||||
.collect(Collectors.groupingBy(AmlAlertData::clientId, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
final List<Long> qualifying = byClient.entrySet().stream().filter(e -> e.getValue().size() >= minAlerts)
|
||||
.map(Map.Entry::getKey).toList();
|
||||
if (qualifying.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final Map<Long, ClientSummary> summaries = clientSummaries(qualifying);
|
||||
|
||||
final List<AmlRecurringAlertData> rows = new ArrayList<>();
|
||||
for (final Long clientId : qualifying) {
|
||||
final List<AmlAlertData> alerts = byClient.get(clientId);
|
||||
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
|
||||
.orElse(null);
|
||||
// Reviewed at a lower count means new alerts have landed since — the customer has to
|
||||
// come back, otherwise one early review would silence them permanently.
|
||||
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
|
||||
if (onlyNeedingReview && !needsReview) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final ClientSummary summary = summaries.getOrDefault(clientId, new ClientSummary(null, null));
|
||||
rows.add(new AmlRecurringAlertData(clientId, alerts.get(0).clientDisplayName(), summary.accountNo(),
|
||||
summary.riskCategory(), alerts.size(),
|
||||
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
|
||||
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
|
||||
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
|
||||
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
|
||||
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
|
||||
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
|
||||
alerts.stream().map(AmlAlertData::raisedOn).max(Comparator.naturalOrder()).orElse(null),
|
||||
alerts.stream().map(AmlAlertData::ruleCode).distinct().sorted().toList(), needsReview,
|
||||
lastReview == null ? null : lastReview.getAlertCountAtReview(),
|
||||
lastReview == null ? null : lastReview.getOutcome().name(),
|
||||
lastReview == null ? null : lastReview.getReviewedBy().getUsername(),
|
||||
lastReview == null ? null : lastReview.getReviewedOn(), lastReview == null ? null : lastReview.getNotes(),
|
||||
windowFrom, alerts));
|
||||
}
|
||||
|
||||
// Worst offenders first; most recent activity breaks ties.
|
||||
rows.sort(Comparator.comparingInt(AmlRecurringAlertData::alertCount).reversed()
|
||||
.thenComparing(AmlRecurringAlertData::lastAlertOn, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int alertCountInWindow(final Long clientId, final int windowDays) {
|
||||
final LocalDateTime cutoff = LocalDate.now().minusDays(windowDays).atStartOfDay();
|
||||
return (int) this.alertReadPlatformService.retrieve(null).stream()
|
||||
.filter(a -> clientId.equals(a.clientId()) && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff)).count();
|
||||
}
|
||||
|
||||
/** Account number and current KYC risk rating, fetched once for the whole queue. */
|
||||
private Map<Long, ClientSummary> clientSummaries(final List<Long> clientIds) {
|
||||
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||
final String sql = "SELECT c.id AS clientId, c.account_no AS accountNo, k.risk_category AS riskCategory "
|
||||
+ "FROM m_client c LEFT JOIN m_client_kyc k ON k.client_id = c.id WHERE c.id IN (" + placeholders + ")";
|
||||
final Map<Long, ClientSummary> byId = new HashMap<>();
|
||||
this.jdbcTemplate.query(sql, rs -> {
|
||||
byId.put(rs.getLong("clientId"), new ClientSummary(rs.getString("accountNo"),
|
||||
rs.getObject("riskCategory") == null ? null : rs.getInt("riskCategory")));
|
||||
}, clientIds.toArray());
|
||||
return byId;
|
||||
}
|
||||
|
||||
private record ClientSummary(String accountNo, Integer riskCategory) {}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
public interface AmlRecurringAlertWritePlatformService {
|
||||
|
||||
/**
|
||||
* Records a reviewer's judgement on a customer's repeated AML activity, stamping the alert count
|
||||
* the reviewer was looking at so the customer only resurfaces when that count grows.
|
||||
*
|
||||
* @param windowDays
|
||||
* observation window the reviewer had on screen, so the recorded count matches it
|
||||
* @return the id of the stored review
|
||||
*/
|
||||
Long recordReview(Long clientId, String outcome, String notes, int windowDays);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAlertWritePlatformService {
|
||||
|
||||
private static final String RESOURCE = "amlrecurringreview";
|
||||
private static final int DEFAULT_WINDOW_DAYS = 90;
|
||||
|
||||
private final AmlRecurringReviewRepository reviewRepository;
|
||||
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long recordReview(final Long clientId, final String outcome, final String notes, final int windowDays) {
|
||||
final AmlRecurringReviewOutcome parsedOutcome = validate(clientId, outcome, notes);
|
||||
final int window = windowDays > 0 ? windowDays : DEFAULT_WINDOW_DAYS;
|
||||
|
||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
// Counted here rather than taken from the request: a caller-supplied count could be used to
|
||||
// silence a customer at an inflated number they were never actually reviewed at.
|
||||
final int alertCount = this.readPlatformService.alertCountInWindow(clientId, window);
|
||||
|
||||
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
|
||||
this.context.authenticatedUser());
|
||||
return this.reviewRepository.saveAndFlush(review).getId();
|
||||
}
|
||||
|
||||
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
|
||||
validator.reset().parameter("clientId").value(clientId).notNull();
|
||||
validator.reset().parameter("notes").value(notes).notBlank().notExceedingLengthOf(1000);
|
||||
validator.reset().parameter("outcome").value(outcome).notBlank();
|
||||
|
||||
AmlRecurringReviewOutcome parsed = null;
|
||||
if (StringUtils.isNotBlank(outcome)) {
|
||||
for (final AmlRecurringReviewOutcome candidate : AmlRecurringReviewOutcome.values()) {
|
||||
if (candidate.name().equalsIgnoreCase(outcome.trim())) {
|
||||
parsed = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parsed == null) {
|
||||
validator.reset().parameter("outcome").value(outcome).failWithCode("is.not.supported");
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(errors);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
+51
-4
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -27,16 +28,32 @@ import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a
|
||||
* transaction that has already succeeded. Joining the caller's transaction would let a failed
|
||||
* alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at
|
||||
* commit time - and the callers' catch blocks cannot rescue that, because the damage is done to
|
||||
* the shared transaction rather than thrown to them. A private transaction keeps AML failure
|
||||
* contained to AML.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
|
||||
private static final int MEDIUM_RISK = 2;
|
||||
private static final int HIGH_RISK = 3;
|
||||
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||
|
||||
@Override
|
||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||
@@ -52,24 +69,54 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
raised.add(alertRepository.save(alert));
|
||||
}
|
||||
}
|
||||
|
||||
// Every transaction type funnels through here, so the recurring-behaviour check is done once
|
||||
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
|
||||
// the batch size lets the service tell a first-time offender from a repeat one without
|
||||
// re-querying what it just wrote.
|
||||
if (!raised.isEmpty()) {
|
||||
recurringAlertNotificationService.onAlertsRaised(context.getClient(), raised.size());
|
||||
}
|
||||
return raised;
|
||||
}
|
||||
|
||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (rule.getThresholdAmount() == null) {
|
||||
final BigDecimal threshold = resolveThreshold(rule, context);
|
||||
if (threshold == null) {
|
||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
||||
// per rule and are not yet evaluated here - they exist in the catalog for future work.
|
||||
return false;
|
||||
}
|
||||
switch (rule.getRuleType()) {
|
||||
case THRESHOLD:
|
||||
return context.getAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
||||
return context.getAmount().compareTo(threshold) >= 0;
|
||||
case STRUCTURING:
|
||||
case VELOCITY:
|
||||
return context.getWindowAggregateAmount() != null
|
||||
&& context.getWindowAggregateAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
||||
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
||||
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
||||
* and pick the matching tier, falling back to {@code thresholdAmount} for Low-risk/unclassified
|
||||
* clients.
|
||||
*/
|
||||
private BigDecimal resolveThreshold(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||
if (rule.getThresholdAmountMediumRisk() == null && rule.getThresholdAmountHighRisk() == null) {
|
||||
return rule.getThresholdAmount();
|
||||
}
|
||||
final Integer riskCategory = clientKycRepository.findByClientId(context.getClient().getId()).map(ClientKyc::getRiskCategoryValue)
|
||||
.orElse(null);
|
||||
if (riskCategory != null && riskCategory == HIGH_RISK && rule.getThresholdAmountHighRisk() != null) {
|
||||
return rule.getThresholdAmountHighRisk();
|
||||
}
|
||||
if (riskCategory != null && riskCategory == MEDIUM_RISK && rule.getThresholdAmountMediumRisk() != null) {
|
||||
return rule.getThresholdAmountMediumRisk();
|
||||
}
|
||||
return rule.getThresholdAmount();
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
|
||||
/**
|
||||
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
|
||||
*
|
||||
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
|
||||
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
|
||||
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
|
||||
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
|
||||
*/
|
||||
public interface JournalEntryReferralService {
|
||||
|
||||
/** disposition: WHITELIST | ESCALATE. */
|
||||
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
|
||||
|
||||
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
|
||||
List<ManualReferralData> retrieveAll();
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
|
||||
|
||||
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
|
||||
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final AmlStrCaseRepository strCaseRepository;
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientRiskEscalationService clientRiskEscalationService;
|
||||
|
||||
/**
|
||||
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
|
||||
* posted against.
|
||||
*
|
||||
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
|
||||
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
|
||||
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
|
||||
* a savings-type entity may be either a savings or a current account, which live in separate tables.
|
||||
*
|
||||
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
||||
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
||||
*/
|
||||
private static final String RESOLVE_SQL = """
|
||||
SELECT je.amount AS amount,
|
||||
je.currency_code AS currencyCode,
|
||||
je.savings_transaction_id AS savingsTxnId,
|
||||
je.loan_transaction_id AS loanTxnId,
|
||||
je.entity_type_enum AS entityType,
|
||||
COALESCE(
|
||||
esa.client_id,
|
||||
eca.client_id,
|
||||
eloan.client_id,
|
||||
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
||||
sa.client_id,
|
||||
l.client_id,
|
||||
ct.client_id
|
||||
) AS clientId
|
||||
FROM acc_gl_journal_entry je
|
||||
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
||||
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
|
||||
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
||||
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
||||
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
||||
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
|
||||
LEFT JOIN m_loan l ON l.id = lt.loan_id
|
||||
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
|
||||
WHERE je.id = ?
|
||||
""";
|
||||
|
||||
private static final String LIST_SQL = """
|
||||
SELECT a.id AS alertId,
|
||||
a.transaction_id AS journalEntryId,
|
||||
a.client_id AS clientId,
|
||||
c.display_name AS clientDisplayName,
|
||||
a.amount AS amount,
|
||||
a.currency_code AS currencyCode,
|
||||
a.raised_on AS raisedOn,
|
||||
a.status AS status,
|
||||
a.disposition AS disposition,
|
||||
u.username AS reviewedByUsername,
|
||||
a.review_notes AS reviewNotes,
|
||||
s.id AS strCaseId
|
||||
FROM m_aml_alert a
|
||||
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
|
||||
LEFT JOIN m_client c ON c.id = a.client_id
|
||||
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
|
||||
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
|
||||
ORDER BY a.raised_on DESC
|
||||
""";
|
||||
|
||||
@Override
|
||||
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
|
||||
if (journalEntryId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
|
||||
"A journal entry must be given to refer.");
|
||||
}
|
||||
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
|
||||
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
|
||||
"Disposition must be WHITELIST or ESCALATE.", disposition);
|
||||
}
|
||||
|
||||
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
|
||||
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
|
||||
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
|
||||
|
||||
// Marking the same entry twice would put two cases in front of the Principal Officer for
|
||||
// one transaction, so the first mark stands until it is explicitly reworked.
|
||||
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
|
||||
"This ledger entry has already been marked.", journalEntryId);
|
||||
}
|
||||
|
||||
final Map<String, Object> row = loadEntry(journalEntryId);
|
||||
final Number clientIdValue = (Number) row.get("clientId");
|
||||
if (clientIdValue == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
|
||||
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
|
||||
}
|
||||
|
||||
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
final String currencyCode = (String) row.get("currencyCode");
|
||||
|
||||
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
|
||||
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
|
||||
// savings or loan transaction id.
|
||||
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
|
||||
LocalDateTime.now());
|
||||
alertRepository.save(alert);
|
||||
|
||||
Long strCaseId = null;
|
||||
if (escalate) {
|
||||
alert.escalate(context.authenticatedUser(), notes);
|
||||
alertRepository.save(alert);
|
||||
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||
strCaseId = strCase.getId();
|
||||
} else {
|
||||
alert.whitelist(context.authenticatedUser(), notes);
|
||||
alertRepository.save(alert);
|
||||
}
|
||||
|
||||
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
|
||||
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
|
||||
alert.getDisposition() == null ? null : alert.getDisposition().name(),
|
||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
|
||||
strCaseId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<ManualReferralData> retrieveAll() {
|
||||
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
|
||||
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
|
||||
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
|
||||
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
|
||||
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
|
||||
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
|
||||
MANUAL_RULE_CODE);
|
||||
}
|
||||
|
||||
private Map<String, Object> loadEntry(final Long journalEntryId) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
|
||||
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TRANSFER covers everything that is neither a savings nor a loan posting.
|
||||
*
|
||||
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
|
||||
* id; falling straight through to the transaction columns would mislabel those as transfers.
|
||||
*/
|
||||
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
|
||||
final Number entityType = (Number) row.get("entityType");
|
||||
if (entityType != null) {
|
||||
if (entityType.intValue() == 2) {
|
||||
return AmlTransactionType.SAVINGS;
|
||||
}
|
||||
if (entityType.intValue() == 1) {
|
||||
return AmlTransactionType.LOAN;
|
||||
}
|
||||
}
|
||||
if (row.get("savingsTxnId") != null) {
|
||||
return AmlTransactionType.SAVINGS;
|
||||
}
|
||||
if (row.get("loanTxnId") != null) {
|
||||
return AmlTransactionType.LOAN;
|
||||
}
|
||||
return AmlTransactionType.TRANSFER;
|
||||
}
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* 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.branchday.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Branch day register — the formal opening and closing of a branch's business day.
|
||||
*
|
||||
* A day that was never closed is the thing this exists to surface: while it stays open its balance
|
||||
* cannot be carried forward reliably, a back-dated entry can still be posted into it, and any cash
|
||||
* difference on that date goes unnoticed. The register records who opened and closed each day, and
|
||||
* lists the ones still hanging.
|
||||
*
|
||||
* Closing a day late is allowed but never silent: it is stamped as a force-close with the reason it
|
||||
* was left open, so "closed that evening" and "closed nine days later" stay distinguishable.
|
||||
*/
|
||||
@Path("/v1/branchday")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class BranchDayRegisterApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||
|
||||
/**
|
||||
* The register for one office, newest first. Defaults to the caller's own office so a branch
|
||||
* user sees their own branch without having to know its id.
|
||||
*/
|
||||
@GET
|
||||
public String list(@QueryParam("officeId") final Long officeIdParam, @QueryParam("limit") final Integer limitParam) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final int limit = limitParam == null || limitParam < 1 ? 60 : Math.min(limitParam, 400);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
|
||||
r.opened_by_name, r.opened_at, r.opening_cash,
|
||||
r.closed_by_name, r.closed_at, r.closing_cash,
|
||||
r.book_cash, r.cash_difference,
|
||||
r.force_closed, r.force_close_reason, r.remarks
|
||||
FROM m_branch_day_register r
|
||||
LEFT JOIN m_office o ON o.id = r.office_id
|
||||
WHERE r.office_id = ?
|
||||
ORDER BY r.business_date DESC
|
||||
LIMIT ?
|
||||
""", officeId, limit);
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("officeId", officeId);
|
||||
result.put("canOperate", canOperateDay(currentUser));
|
||||
result.put("days", mapDays(rows));
|
||||
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
|
||||
result.put("openDay", findOpenDay(officeId));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/missed ── days left open ────────────────────────────
|
||||
|
||||
/**
|
||||
* Business days that were opened and never closed, oldest first — the ones that need attention
|
||||
* most. Today's own open day is excluded: it is open because the day is still running, which is
|
||||
* not a missed close.
|
||||
*/
|
||||
@GET
|
||||
@Path("missed")
|
||||
public String missed(@QueryParam("officeId") final Long officeIdParam) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
|
||||
r.opened_by_name, r.opened_at, r.opening_cash,
|
||||
r.closed_by_name, r.closed_at, r.closing_cash,
|
||||
r.book_cash, r.cash_difference,
|
||||
r.force_closed, r.force_close_reason, r.remarks
|
||||
FROM m_branch_day_register r
|
||||
LEFT JOIN m_office o ON o.id = r.office_id
|
||||
WHERE r.office_id = ? AND r.closed_at IS NULL AND r.business_date < ?
|
||||
ORDER BY r.business_date ASC
|
||||
""", officeId, Date.valueOf(today));
|
||||
|
||||
final List<Map<String, Object>> days = mapDays(rows);
|
||||
// Age in days is what makes the list actionable — a day open since last week is a different
|
||||
// problem from one open since yesterday.
|
||||
for (final Map<String, Object> d : days) {
|
||||
final Object bd = d.get("businessDate");
|
||||
d.put("daysOpen", bd == null ? null : java.time.temporal.ChronoUnit.DAYS.between(LocalDate.parse(bd.toString()), today));
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("officeId", officeId, "canOperate", canOperateDay(currentUser), "missed", days,
|
||||
"count", days.size()));
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/close-preview ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What the day should close at, with the arithmetic that produces it, plus the denomination
|
||||
* total the cashier actually keyed in during the day.
|
||||
*
|
||||
* Exists so nobody has to work the figure out by hand or type it from memory: the screen shows
|
||||
* where every line came from, and offers the counted total as the closing figure. A day is
|
||||
* still closed on what someone physically counted — this only removes the guesswork about what
|
||||
* that count is supposed to match.
|
||||
*/
|
||||
@GET
|
||||
@Path("close-preview")
|
||||
public String closePreview(@QueryParam("officeId") final Long officeIdParam,
|
||||
@QueryParam("businessDate") final String businessDateParam) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
|
||||
final LocalDate businessDate;
|
||||
if (businessDateParam != null && !businessDateParam.isBlank()) {
|
||||
try {
|
||||
businessDate = LocalDate.parse(businessDateParam);
|
||||
} catch (final java.time.format.DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid",
|
||||
"Business date must be in yyyy-MM-dd format.");
|
||||
}
|
||||
} else {
|
||||
final Map<String, Object> open = findOpenDay(officeId);
|
||||
if (open == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.not.open",
|
||||
"No business day is open for this branch.");
|
||||
}
|
||||
businessDate = LocalDate.parse(String.valueOf(open.get("businessDate")));
|
||||
}
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>(bookBreakdown(officeId, businessDate));
|
||||
result.put("officeId", officeId);
|
||||
result.put("businessDate", businessDate.toString());
|
||||
|
||||
// What the cashier's own denomination entries add up to for the day. Where this and the
|
||||
// expected figure agree, the day tallies and the closing figure is not in question.
|
||||
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
|
||||
WHERE txn_date = ? AND txn_type = 'DEPOSIT'
|
||||
""", BigDecimal.class, Date.valueOf(businessDate)));
|
||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
|
||||
WHERE txn_date = ? AND txn_type = 'WITHDRAWAL'
|
||||
""", BigDecimal.class, Date.valueOf(businessDate)));
|
||||
result.put("denominationIn", countedIn);
|
||||
result.put("denominationOut", countedOut);
|
||||
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── POST /v1/branchday/open ───────────────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("open")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public String openDay(final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
requireDayAuthority(currentUser);
|
||||
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
|
||||
|
||||
// A branch runs one day at a time. Opening a second while the first is still open is how a
|
||||
// day gets stranded, so the previous one has to be closed first.
|
||||
final Map<String, Object> open = findOpenDay(officeId);
|
||||
if (open != null) {
|
||||
final Object existing = open.get("businessDate");
|
||||
if (businessDate.toString().equals(String.valueOf(existing))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.already.open",
|
||||
"Business day " + businessDate + " is already open for this branch.");
|
||||
}
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.previous.open",
|
||||
"Business day " + existing + " is still open. Close it before opening " + businessDate + ".");
|
||||
}
|
||||
|
||||
final Integer exists = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||
Integer.class, officeId, Date.valueOf(businessDate));
|
||||
if (exists != null && exists > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
|
||||
"Business day " + businessDate + " has already been opened and closed for this branch.");
|
||||
}
|
||||
|
||||
// Yesterday's closing is today's opening unless the caller states otherwise, so the carried
|
||||
// figure does not have to be retyped and cannot drift.
|
||||
final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate);
|
||||
|
||||
final Long id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_branch_day_register
|
||||
(office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed)
|
||||
VALUES (?,?,?,?,?,?,false)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(),
|
||||
Timestamp.from(Instant.now()), carried);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(),
|
||||
"openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN"));
|
||||
}
|
||||
|
||||
// ── POST /v1/branchday/close ──────────────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("close")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public String closeDay(final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
requireDayAuthority(currentUser);
|
||||
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate", null);
|
||||
if (businessDate == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.date.required",
|
||||
"Which business day is being closed?");
|
||||
}
|
||||
final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
|
||||
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
|
||||
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
|
||||
|
||||
final List<Map<String, Object>> found = jdbcTemplate.queryForList(
|
||||
"SELECT id, closed_at, opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||
officeId, Date.valueOf(businessDate));
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.not.open",
|
||||
"Business day " + businessDate + " was never opened for this branch.");
|
||||
}
|
||||
if (found.get(0).get("closed_at") != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
|
||||
"Business day " + businessDate + " is already closed.");
|
||||
}
|
||||
|
||||
// Closing a past date is allowed — otherwise a day left open could never be settled at all —
|
||||
// but it must say why it was left open, and it is stamped as a force close.
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final boolean isLate = businessDate.isBefore(today);
|
||||
if (isLate && (forceReason == null || forceReason.isBlank())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
|
||||
"Business day " + businessDate + " is being closed late. Record why it was left open.");
|
||||
}
|
||||
|
||||
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
||||
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_branch_day_register
|
||||
SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
|
||||
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
|
||||
WHERE office_id = ? AND business_date = ?
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash,
|
||||
bookCash, difference, isLate, isLate ? forceReason : null, remarks,
|
||||
officeId, Date.valueOf(businessDate));
|
||||
|
||||
final Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("officeId", officeId);
|
||||
res.put("businessDate", businessDate.toString());
|
||||
res.put("status", "CLOSED");
|
||||
res.put("closingCash", closingCash);
|
||||
res.put("bookCash", bookCash);
|
||||
res.put("cashDifference", difference);
|
||||
res.put("forceClosed", isLate);
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private List<Map<String, Object>> mapDays(final List<Map<String, Object>> rows) {
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", r.get("id"));
|
||||
m.put("officeId", r.get("office_id"));
|
||||
m.put("officeName", r.get("office_name"));
|
||||
m.put("businessDate", str(r.get("business_date")));
|
||||
m.put("openedByName", r.get("opened_by_name"));
|
||||
m.put("openedAt", str(r.get("opened_at")));
|
||||
m.put("openingCash", r.get("opening_cash"));
|
||||
m.put("closedByName", r.get("closed_by_name"));
|
||||
m.put("closedAt", str(r.get("closed_at")));
|
||||
m.put("closingCash", r.get("closing_cash"));
|
||||
m.put("bookCash", r.get("book_cash"));
|
||||
m.put("cashDifference", r.get("cash_difference"));
|
||||
m.put("forceClosed", r.get("force_closed"));
|
||||
m.put("forceCloseReason", r.get("force_close_reason"));
|
||||
m.put("remarks", r.get("remarks"));
|
||||
m.put("status", r.get("closed_at") == null ? "OPEN" : "CLOSED");
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String str(final Object o) {
|
||||
return o == null ? null : o.toString();
|
||||
}
|
||||
|
||||
/** The branch's currently open day, or null when none is open. */
|
||||
private Map<String, Object> findOpenDay(final Long officeId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash
|
||||
FROM m_branch_day_register
|
||||
WHERE office_id = ? AND closed_at IS NULL
|
||||
ORDER BY business_date ASC LIMIT 1
|
||||
""", officeId);
|
||||
if (rows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", r.get("id"));
|
||||
m.put("businessDate", str(r.get("business_date")));
|
||||
m.put("openedByName", r.get("opened_by_name"));
|
||||
m.put("openedAt", str(r.get("opened_at")));
|
||||
m.put("openingCash", r.get("opening_cash"));
|
||||
return m;
|
||||
}
|
||||
|
||||
/** The most recent closing figure before this date, so it carries forward as the opening. */
|
||||
private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) {
|
||||
final List<BigDecimal> rows = jdbcTemplate.query("""
|
||||
SELECT closing_cash FROM m_branch_day_register
|
||||
WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL
|
||||
ORDER BY business_date DESC LIMIT 1
|
||||
""", (rs, n) -> rs.getBigDecimal(1), officeId, Date.valueOf(before));
|
||||
return rows.isEmpty() ? BigDecimal.ZERO : rows.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash the books say the branch should be holding at the end of that date: what came in as
|
||||
* approved cash deposits less what went out as approved cash withdrawals. Deliberately derived
|
||||
* here and then stored on the row, so the figure the day was closed against survives any later
|
||||
* reversal.
|
||||
*/
|
||||
private Map<String, Object> bookBreakdown(final Long officeId, final LocalDate businessDate) {
|
||||
final Date date = Date.valueOf(businessDate);
|
||||
|
||||
// The figure recorded when this very day was opened — not the previous day's closing.
|
||||
// Deriving it from history alone ignored an opening balance keyed in by hand, which is
|
||||
// exactly what happens on the first day a branch is put on the system: the day was opened
|
||||
// with 20,000 in the drawer and the closing arithmetic started from zero.
|
||||
final BigDecimal ownOpening = jdbcTemplate.query(
|
||||
"SELECT opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
||||
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
||||
|
||||
final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
||||
WHERE status = 'APPROVED'
|
||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
||||
AND (office_id = ? OR office_id IS NULL)
|
||||
""", BigDecimal.class, date, officeId));
|
||||
|
||||
final BigDecimal withdrawals = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request
|
||||
WHERE status = 'APPROVED'
|
||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
||||
""", BigDecimal.class, date));
|
||||
|
||||
// Cash drawn from the vault to a cashier — more physical cash sitting in the branch.
|
||||
final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
|
||||
WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false
|
||||
""", BigDecimal.class, date));
|
||||
|
||||
// Cash a cashier settled back to the vault at the end of the day.
|
||||
final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
|
||||
WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false
|
||||
""", BigDecimal.class, date));
|
||||
|
||||
final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("openingCash", opening);
|
||||
out.put("deposits", deposits);
|
||||
out.put("vaultIn", vaultIn);
|
||||
out.put("withdrawals", withdrawals);
|
||||
out.put("settledOut", settledOut);
|
||||
out.put("expectedCash", expected);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static BigDecimal zero(final BigDecimal v) {
|
||||
return v == null ? BigDecimal.ZERO : v;
|
||||
}
|
||||
|
||||
private Long resolveOffice(final Long requested, final AppUser user) {
|
||||
if (requested != null) {
|
||||
return requested;
|
||||
}
|
||||
return user.getOffice() != null ? user.getOffice().getId() : 1L;
|
||||
}
|
||||
|
||||
private LocalDate extractDate(final com.google.gson.JsonElement root, final String field, final LocalDate fallback) {
|
||||
final String raw = this.fromJsonHelper.extractStringNamed(field, root);
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw);
|
||||
} catch (final java.time.format.DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid",
|
||||
"Business date must be in yyyy-MM-dd format.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may open or close a branch day: a clerk, head cashier or manager. The clerk carries the
|
||||
* same authority as the manager here by design — a day left open because the only manager was
|
||||
* away is exactly the situation this register exists to prevent.
|
||||
*
|
||||
* A cashier is excluded: the cashier works inside the day and must not be the one who declares
|
||||
* it closed.
|
||||
*/
|
||||
private boolean canOperateDay(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return true;
|
||||
}
|
||||
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk"));
|
||||
}
|
||||
|
||||
private void requireDayAuthority(final AppUser user) {
|
||||
if (!canOperateDay(user)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.forbidden",
|
||||
"Only a Clerk, Head Cashier or Bank Manager can open or close a business day.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
@@ -32,6 +32,179 @@ public class CashReportApiResource {
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* Day book — every voucher raised on one date, in one list, newest first.
|
||||
*
|
||||
* Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR)
|
||||
* and the clerk disbursement register (DR, or CR for a collection). Each row carries its
|
||||
* voucher number, side, amount, account, party and status, so the cashier can reconcile a day
|
||||
* without opening three separate screens.
|
||||
*
|
||||
* Dated on the business date the voucher belongs to rather than the row's creation timestamp:
|
||||
* deposits and withdrawals carry an explicit txn_date, and the disbursement register — which
|
||||
* has no such column — falls back to the date it was submitted on.
|
||||
*/
|
||||
@GET
|
||||
@Path("day-book")
|
||||
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
|
||||
@QueryParam("status") final String status) {
|
||||
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
// Each register is left-joined to the verification table and to an attachment count, so a
|
||||
// row already carries its physical-check verdict and whether a slip was scanned in — the
|
||||
// screen never has to fire a follow-up call per voucher.
|
||||
final String verifyJoin = """
|
||||
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no
|
||||
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
|
||||
va ON va.voucher_no = r.voucher_no
|
||||
""";
|
||||
final String verifyCols = """
|
||||
vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks,
|
||||
vv.verified_by_name, vv.verified_at,
|
||||
vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at,
|
||||
COALESCE(va.n, 0) AS attachment_count
|
||||
""";
|
||||
|
||||
// Cashier deposits — money in, so the credit side.
|
||||
rows.addAll(jdbcTemplate.query("""
|
||||
SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
|
||||
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
|
||||
r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
|
||||
r.submitted_at, r.actioned_at,
|
||||
""" + verifyCols + """
|
||||
FROM m_cashier_deposit_request r
|
||||
""" + verifyJoin + """
|
||||
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
|
||||
""", this::mapDayBookRow, txnDate));
|
||||
|
||||
// Clerk withdrawals — money out, so the debit side.
|
||||
rows.addAll(jdbcTemplate.query("""
|
||||
SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
|
||||
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
|
||||
r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id,
|
||||
r.submitted_at, r.actioned_at,
|
||||
""" + verifyCols + """
|
||||
FROM m_clerk_withdrawal_request r
|
||||
""" + verifyJoin + """
|
||||
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
|
||||
""", this::mapDayBookRow, txnDate));
|
||||
|
||||
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
|
||||
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
|
||||
rows.addAll(jdbcTemplate.query("""
|
||||
SELECT r.voucher_no,
|
||||
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
|
||||
r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
|
||||
r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
|
||||
r.description, r.status, r.clerk_name AS handled_by,
|
||||
r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at,
|
||||
""" + verifyCols + """
|
||||
FROM m_clerk_disbursement_request r
|
||||
""" + verifyJoin + """
|
||||
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
|
||||
""", this::mapDayBookRow, txnDate));
|
||||
|
||||
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
|
||||
// ordered as one result set, and the row count for a single day is small.
|
||||
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
|
||||
final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase();
|
||||
|
||||
final List<Map<String, Object>> filtered = new ArrayList<>();
|
||||
BigDecimal creditTotal = BigDecimal.ZERO;
|
||||
BigDecimal debitTotal = BigDecimal.ZERO;
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
||||
continue;
|
||||
}
|
||||
if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) {
|
||||
continue;
|
||||
}
|
||||
filtered.add(row);
|
||||
|
||||
// Only approved money is real money: a pending or rejected voucher must not inflate the
|
||||
// day's totals, even though it still belongs in the listing.
|
||||
if ("APPROVED".equalsIgnoreCase((String) row.get("status")) || "COMPLETED".equalsIgnoreCase((String) row.get("status"))) {
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
if (amount != null) {
|
||||
if ("CR".equals(row.get("side"))) {
|
||||
creditTotal = creditTotal.add(amount);
|
||||
} else {
|
||||
debitTotal = debitTotal.add(amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filtered.sort((a, b) -> {
|
||||
final Object left = b.get("submittedAt");
|
||||
final Object right = a.get("submittedAt");
|
||||
if (left == null && right == null) {
|
||||
return 0;
|
||||
}
|
||||
if (left == null) {
|
||||
return -1;
|
||||
}
|
||||
if (right == null) {
|
||||
return 1;
|
||||
}
|
||||
return left.toString().compareTo(right.toString());
|
||||
});
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("date", txnDate);
|
||||
result.put("rows", filtered);
|
||||
result.put("count", filtered.size());
|
||||
result.put("creditTotal", creditTotal);
|
||||
result.put("debitTotal", debitTotal);
|
||||
result.put("netTotal", creditTotal.subtract(debitTotal));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("voucherNo", rs.getString("voucher_no"));
|
||||
row.put("side", rs.getString("side"));
|
||||
row.put("kind", rs.getString("kind"));
|
||||
row.put("accountNo", rs.getString("account_no"));
|
||||
row.put("accountType", rs.getString("account_type"));
|
||||
row.put("party", rs.getString("party"));
|
||||
row.put("amount", rs.getBigDecimal("amount"));
|
||||
row.put("paymentTypeName", rs.getString("payment_type_name"));
|
||||
row.put("description", rs.getString("description"));
|
||||
row.put("status", rs.getString("status"));
|
||||
row.put("handledBy", rs.getString("handled_by"));
|
||||
row.put("approvedBy", rs.getString("approved_by"));
|
||||
row.put("txnId", rs.getString("txn_id"));
|
||||
|
||||
// Physical-slip check. verifyStatus is null until someone actually looks at the paper.
|
||||
row.put("verifyStatus", rs.getString("verify_status"));
|
||||
row.put("slipAmount", rs.getBigDecimal("slip_amount"));
|
||||
row.put("verifyRemarks", rs.getString("verify_remarks"));
|
||||
row.put("verifiedByName", rs.getString("verified_by_name"));
|
||||
final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at");
|
||||
row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString());
|
||||
|
||||
// How the exception ended, if it has. Without this the day book keeps showing a closed
|
||||
// exception as though it were still open — the flag never clears on screen.
|
||||
row.put("resolutionStatus", rs.getString("resolution_status"));
|
||||
row.put("resolutionNotes", rs.getString("resolution_notes"));
|
||||
row.put("resolvedByName", rs.getString("resolved_by_name"));
|
||||
final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at");
|
||||
row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString());
|
||||
row.put("attachmentCount", rs.getInt("attachment_count"));
|
||||
// Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped
|
||||
// form the browser cannot parse reliably.
|
||||
final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at");
|
||||
final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at");
|
||||
row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString());
|
||||
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
|
||||
return row;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("closing")
|
||||
public String closing(@QueryParam("date") String date) {
|
||||
|
||||
+754
@@ -0,0 +1,754 @@
|
||||
package org.apache.fineract.portfolio.cashtransfer.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.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.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.LinkedHashMap;
|
||||
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.exception.PlatformDataIntegrityException;
|
||||
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.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Share Advance Collection API — cash taken from a member BEFORE any share account exists.
|
||||
*
|
||||
* The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that
|
||||
* flow.
|
||||
*
|
||||
* <p>
|
||||
* <b>1. Collect</b> — {@code POST /v1/shareadvancecollection}
|
||||
*
|
||||
* <pre>
|
||||
* Dr Cash at Vault (SHARES_REFERENCE) = amount
|
||||
* Cr Share Sundry (SHARES_SUSPENSE) = amount
|
||||
* </pre>
|
||||
*
|
||||
* The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in
|
||||
* {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet.
|
||||
*
|
||||
* <p>
|
||||
* <b>2. Apply</b> — {@code POST /v1/shareadvancecollection/{id}/apply}
|
||||
*
|
||||
* <pre>
|
||||
* Dr Share Sundry (SHARES_SUSPENSE) = everything collected
|
||||
* Cr Share Capital (SHARES_EQUITY) = share amount
|
||||
* Cr Income from Fees (INCOME_FROM_FEES) = charge amount (only when a charge applies)
|
||||
* Cr Savings (via deposit cmd) = over-payment (only when the member paid more)
|
||||
* </pre>
|
||||
*
|
||||
* Applying clears the liability, issues the share capital, marks the account's charges paid and sets
|
||||
* {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views.
|
||||
* Account creation, approval and activation post no GL of their own — all share accounting happens here.
|
||||
*
|
||||
* <p>
|
||||
* <b>When the cash does not match</b>
|
||||
*
|
||||
* <p>
|
||||
* A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be
|
||||
* argued with at the window:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Short</b> — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt
|
||||
* and applies the receipts together: one settlement, several receipts, all marked applied.</li>
|
||||
* <li><b>Over</b> — allowed, and the difference is credited to the member's linked savings account through Fineract's
|
||||
* own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute
|
||||
* back to them.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Path("/v1/shareadvancecollection")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ShareAdvanceCollectionApiResource {
|
||||
|
||||
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
|
||||
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr).
|
||||
private static final int DEBIT = 2;
|
||||
private static final int CREDIT = 1;
|
||||
|
||||
// ── CashAccountsForShares financial_account_type ───────────────────────
|
||||
private static final int FA_SHARES_REFERENCE = 1;
|
||||
private static final int FA_SHARES_SUSPENSE = 2;
|
||||
private static final int FA_INCOME_FROM_FEES = 3;
|
||||
private static final int FA_SHARES_EQUITY = 4;
|
||||
|
||||
// ── acc_product_mapping.product_type for shares ────────────────────────
|
||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||
|
||||
// ── m_share_account.status_enum ───────────────────────────────────────
|
||||
private static final int STATUS_ACTIVE = 300;
|
||||
|
||||
// ── GL account the cashier deposits share money into ──────────────────
|
||||
private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST — Take cash from a member with no share account yet
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@POST
|
||||
@Transactional
|
||||
public Response collect(final String body) {
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
|
||||
final JsonObject json = parseBody(body);
|
||||
final Long clientId = getLong(json, "clientId");
|
||||
final BigDecimal amount = getBigDecimal(json, "amount");
|
||||
final String note = sanitiseNote(getString(json, "note"));
|
||||
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
|
||||
|
||||
if (clientId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required.");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero.");
|
||||
}
|
||||
|
||||
final Map<String, Object> client = loadClient(clientId);
|
||||
final Long officeId = toLong(client.get("office_id"));
|
||||
final String clientName = (String) client.get("display_name");
|
||||
|
||||
// The share product drives the GL mapping. Collection happens before an account exists,
|
||||
// so the product is supplied by the caller or defaults to the only mapped share product.
|
||||
final Long productId = resolveProductId(getLong(json, "productId"));
|
||||
final String currencyCode = resolveCurrency(productId);
|
||||
|
||||
final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)");
|
||||
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String txnId = "SHADV-" + System.nanoTime();
|
||||
final String receiptNo = nextReceiptNo();
|
||||
|
||||
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
|
||||
|
||||
insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc);
|
||||
insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode,
|
||||
"Share advance held in Sundry | Receipt " + receiptNo);
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_share_advance_collection "
|
||||
+ "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
||||
receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId,
|
||||
currentUser.getUsername(), note);
|
||||
|
||||
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class,
|
||||
receiptNo);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("collectionId", id);
|
||||
out.put("receiptNo", receiptNo);
|
||||
out.put("clientId", clientId);
|
||||
out.put("clientName", clientName);
|
||||
out.put("amount", amount);
|
||||
out.put("currency", currencyCode);
|
||||
out.put("status", "UNAPPLIED");
|
||||
out.put("glTransactionId", txnId);
|
||||
out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created.");
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — Unapplied receipts, optionally for one client
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@GET
|
||||
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
|
||||
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
|
||||
if (clientId != null) {
|
||||
sql.append(" AND c.client_id = ").append(clientId);
|
||||
}
|
||||
sql.append(" ORDER BY c.collection_date, c.id");
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("unappliedCollections", rows);
|
||||
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — One unapplied receipt by the voucher number quoted by the member
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// The voucher arrives as a query parameter because numbers are formatted CR/001/2108/0001:
|
||||
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
|
||||
// firewall with a 400 before the request reaches this resource. The older
|
||||
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
|
||||
@GET
|
||||
@Path("/by-voucher")
|
||||
public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
|
||||
@QueryParam("clientId") final Long clientIdHint) {
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
|
||||
"A voucher or receipt number is required.");
|
||||
}
|
||||
return findByVoucher(voucherNo, clientIdHint);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/by-voucher/{voucherNo}")
|
||||
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
|
||||
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
// Cash is normally taken through the cashier's Transaction Entry deposit into the Share
|
||||
// Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a
|
||||
// collection so the member's voucher resolves here.
|
||||
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint);
|
||||
if (adopted != null) {
|
||||
return Response.ok(gson.toJson(adopted)).build();
|
||||
}
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
|
||||
+ voucherNo + ". Check the number, or confirm the deposit has been approved by a manager.");
|
||||
}
|
||||
|
||||
final Map<String, Object> row = rows.get(0);
|
||||
if (!"UNAPPLIED".equals(row.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
|
||||
"Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again.");
|
||||
}
|
||||
return Response.ok(gson.toJson(row)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST — Apply a receipt to an activated share account
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@POST
|
||||
@Path("/{collectionId}/apply")
|
||||
@Transactional
|
||||
public Response apply(@PathParam("collectionId") final Long collectionId, final String body) {
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
|
||||
final JsonObject json = parseBody(body);
|
||||
final Long shareAccountId = getLong(json, "shareAccountId");
|
||||
if (shareAccountId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
|
||||
}
|
||||
|
||||
// A member who pays in instalments has more than one receipt against the same application:
|
||||
// ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
|
||||
// are applied together as one settlement. The path receipt leads; any others come in the
|
||||
// body.
|
||||
final List<Long> collectionIds = new java.util.ArrayList<>();
|
||||
collectionIds.add(collectionId);
|
||||
for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
|
||||
if (!collectionIds.contains(additionalId)) {
|
||||
collectionIds.add(additionalId);
|
||||
}
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
|
||||
for (final Long id : collectionIds) {
|
||||
final Map<String, Object> row = loadCollection(id);
|
||||
if (!"UNAPPLIED".equals(row.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
|
||||
"Receipt " + row.get("receipt_no") + " is already " + row.get("status") + ".");
|
||||
}
|
||||
collections.add(row);
|
||||
}
|
||||
|
||||
final Map<String, Object> coll = collections.get(0);
|
||||
|
||||
final Map<String, Object> acct = loadAccount(shareAccountId);
|
||||
|
||||
// Every receipt belongs to the member: applying one to someone else's account would move
|
||||
// one member's money into another's share capital. Checked per receipt, since a settlement
|
||||
// can now carry several.
|
||||
final Long acctClientId = toLong(acct.get("client_id"));
|
||||
for (final Map<String, Object> row : collections) {
|
||||
if (!toLong(row.get("client_id")).equals(acctClientId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
|
||||
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
|
||||
}
|
||||
}
|
||||
|
||||
final int statusEnum = toInt(acct.get("status_enum"));
|
||||
if (statusEnum != STATUS_ACTIVE) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active",
|
||||
"Share account " + acct.get("account_no") + " must be Active before a collection can be applied.");
|
||||
}
|
||||
if (Boolean.TRUE.equals(acct.get("collection_applied"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled",
|
||||
"Share account " + acct.get("account_no") + " has already been settled.");
|
||||
}
|
||||
|
||||
// Share value the account was opened for, and any entry fee attached to it.
|
||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
||||
final BigDecimal required = shareAmount.add(chargeAmount);
|
||||
|
||||
BigDecimal receiptAmount = BigDecimal.ZERO;
|
||||
final StringBuilder receiptNos = new StringBuilder();
|
||||
for (final Map<String, Object> row : collections) {
|
||||
receiptAmount = receiptAmount.add((BigDecimal) row.get("amount"));
|
||||
if (receiptNos.length() > 0) {
|
||||
receiptNos.append(" + ");
|
||||
}
|
||||
receiptNos.append(row.get("receipt_no"));
|
||||
}
|
||||
|
||||
// Short is blocked: share capital cannot be part-issued — the member either bought those
|
||||
// shares or did not — so settling for less would leave the account short by an amount owed
|
||||
// by nobody. The clerk collects the balance on another receipt and applies both together.
|
||||
if (receiptAmount.compareTo(required) < 0) {
|
||||
final BigDecimal shortfall = required.subtract(receiptAmount);
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
|
||||
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
|
||||
+ required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall
|
||||
+ " from the member, then apply both receipts together.");
|
||||
}
|
||||
|
||||
// Over-payment is allowed through rather than turning a member away over a rounded-up note.
|
||||
// The difference is credited to their linked savings account below.
|
||||
final BigDecimal excessAmount = receiptAmount.subtract(required);
|
||||
final Long savingsAccountId = toLong(acct.get("savings_account_id"));
|
||||
if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
|
||||
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
|
||||
+ " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings "
|
||||
+ "account, but none is linked to this share account. Link one, or collect the exact amount.");
|
||||
}
|
||||
|
||||
final Long officeId = toLong(acct.get("office_id"));
|
||||
final Long productId = toLong(acct.get("product_id"));
|
||||
final String currencyCode = (String) acct.get("currency_code");
|
||||
|
||||
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
|
||||
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
|
||||
final String txnId = "SHAPP-" + System.nanoTime();
|
||||
final String accountNo = (String) acct.get("account_no");
|
||||
|
||||
// Dr Sundry for everything collected, including any over-payment — the liability those
|
||||
// receipts created is cleared in full, in one leg, so nothing is left behind in suspense.
|
||||
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
|
||||
|
||||
// Cr Share Capital for the share value.
|
||||
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share capital issued for " + accountNo);
|
||||
|
||||
// Cr Income from Fees for the entry fee, when one was charged.
|
||||
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
// Resolved from the share product's "Income from Fees" mapping, which points at the
|
||||
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
|
||||
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
||||
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share entry fee for " + accountNo);
|
||||
|
||||
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
||||
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
||||
}
|
||||
|
||||
// The over-payment goes back to the member as a savings deposit rather than a bare journal
|
||||
// entry, so it lands in their passbook and running balance the way any counter deposit
|
||||
// would. Posted through Fineract's own deposit command, which writes its own Cr Savings
|
||||
// Control leg — the Dr side is the Sundry debit above, which covered the whole receipt.
|
||||
Long excessSavingsTransactionId = null;
|
||||
if (excessAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate,
|
||||
"Excess on share application receipt " + receiptNos + " for share account " + accountNo);
|
||||
}
|
||||
|
||||
// Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
|
||||
for (final Map<String, Object> row : collections) {
|
||||
if (row.get("voucher_no") != null) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
|
||||
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL",
|
||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO);
|
||||
}
|
||||
}
|
||||
|
||||
// Every receipt in this settlement is marked applied, not just the one in the path — they
|
||||
// were all consumed by it, and an unmarked one would still show as money awaiting use.
|
||||
for (final Long id : collectionIds) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
|
||||
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
|
||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
|
||||
}
|
||||
|
||||
// Reveals the purchase transaction in the passbook and transactions views.
|
||||
jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("collectionId", collectionId);
|
||||
out.put("collectionIds", collectionIds);
|
||||
out.put("receiptNo", receiptNos.toString());
|
||||
out.put("shareAccountId", shareAccountId);
|
||||
out.put("accountNo", accountNo);
|
||||
out.put("shareAmount", shareAmount);
|
||||
out.put("chargeAmount", chargeAmount);
|
||||
out.put("totalApplied", required);
|
||||
out.put("totalCollected", receiptAmount);
|
||||
out.put("glTransactionId", txnId);
|
||||
out.put("status", "APPLIED");
|
||||
// Told at the counter, while the member is still there, rather than left to be noticed on
|
||||
// a statement later.
|
||||
out.put("excessAmount", excessAmount);
|
||||
if (excessSavingsTransactionId != null) {
|
||||
out.put("excessSavingsTransactionId", excessSavingsTransactionId);
|
||||
out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to "
|
||||
+ "their savings account.");
|
||||
}
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection.
|
||||
*
|
||||
* The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager,
|
||||
* so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup
|
||||
* so the voucher resolves and can be applied.
|
||||
*
|
||||
* A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the
|
||||
* clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
|
||||
* is what keeps the money tied to the right member.
|
||||
*
|
||||
* @return the newly created collection row, or null when no approved Sundry deposit matches.
|
||||
*/
|
||||
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) {
|
||||
final List<Map<String, Object>> deposits = jdbcTemplate
|
||||
.queryForList(
|
||||
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
|
||||
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
|
||||
+ " FROM m_cashier_deposit_request d"
|
||||
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
|
||||
voucherNo, SHARE_SUNDRY_ACCOUNT_NO);
|
||||
|
||||
if (deposits.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> dep = deposits.get(0);
|
||||
|
||||
if (toLong(dep.get("share_account_id")) != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
|
||||
"Voucher " + voucherNo + " has already been applied to a share account.");
|
||||
}
|
||||
|
||||
// Prefer the member recorded on the deposit; fall back to the account being settled.
|
||||
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
|
||||
if (clientId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
|
||||
"Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member,"
|
||||
+ " so it cannot be matched. Re-enter the deposit against the member.");
|
||||
}
|
||||
|
||||
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
|
||||
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final String receiptNo = nextReceiptNo();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_share_advance_collection "
|
||||
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
||||
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
|
||||
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
|
||||
"Adopted from cashier deposit voucher " + voucherNo);
|
||||
|
||||
return jdbcTemplate.queryForMap(
|
||||
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
|
||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
||||
receiptNo);
|
||||
}
|
||||
|
||||
private Long loadClientOfficeId(final Long clientId) {
|
||||
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
|
||||
}
|
||||
|
||||
/** Share value the account was opened for: the pending/approved purchase transaction. */
|
||||
private BigDecimal shareValueOf(final Long shareAccountId) {
|
||||
final BigDecimal total = jdbcTemplate
|
||||
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
|
||||
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
|
||||
return total == null ? BigDecimal.ZERO : total;
|
||||
}
|
||||
|
||||
/** Entry fee still outstanding on the account, zero when no charge was attached. */
|
||||
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
|
||||
// Driven by the money still owed rather than the is_paid_derived flag alone: accounts
|
||||
// created before the flat-charge fix carry the flag set to true while the full amount is
|
||||
// still outstanding, and those charges must still be settled by the collection.
|
||||
final BigDecimal total = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge "
|
||||
+ " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0",
|
||||
BigDecimal.class, shareAccountId);
|
||||
return total == null ? BigDecimal.ZERO : total;
|
||||
}
|
||||
|
||||
private Map<String, Object> loadCollection(final Long id) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id);
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found",
|
||||
"Share advance collection " + id + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> loadClient(final Long clientId) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId);
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> loadAccount(final Long shareAccountId) {
|
||||
try {
|
||||
return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, "
|
||||
+ " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id "
|
||||
+ " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId);
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found",
|
||||
"Share account " + shareAccountId + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the caller's product when given, else the single mapped share product.
|
||||
*
|
||||
* The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are
|
||||
* not share products (stale rows left by deleted products), and counting those would make auto-detection fail even
|
||||
* when only one real share product is mapped.
|
||||
*/
|
||||
private Long resolveProductId(final Long requested) {
|
||||
if (requested != null) {
|
||||
return requested;
|
||||
}
|
||||
final List<Long> ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m "
|
||||
+ " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES);
|
||||
if (ids.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped",
|
||||
"No share product has GL mappings configured.");
|
||||
}
|
||||
if (ids.size() > 1) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required",
|
||||
"productId is required: " + ids.size() + " share products have GL mappings.");
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
private String resolveCurrency(final Long productId) {
|
||||
try {
|
||||
final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class,
|
||||
productId);
|
||||
return code == null ? "INR" : code;
|
||||
} catch (EmptyResultDataAccessException e) {
|
||||
return "INR";
|
||||
}
|
||||
}
|
||||
|
||||
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
|
||||
final Long glId = resolveGl(productId, financialAccountType);
|
||||
if (glId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped",
|
||||
label + " GL is not mapped for share product " + productId + ".");
|
||||
}
|
||||
return glId;
|
||||
}
|
||||
|
||||
private Long resolveGl(final Long productId, final int financialAccountType) {
|
||||
try {
|
||||
return jdbcTemplate.queryForObject(
|
||||
"SELECT gl_account_id FROM acc_product_mapping "
|
||||
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
|
||||
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
||||
} catch (IncorrectResultSizeDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String nextReceiptNo() {
|
||||
final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class);
|
||||
return String.format("SHADV%06d", seq == null ? 1 : seq);
|
||||
}
|
||||
|
||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
||||
final String description) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||
+ " is_running_balance_calculated, office_running_balance, "
|
||||
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
|
||||
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
||||
}
|
||||
|
||||
/**
|
||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
||||
*
|
||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
||||
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
|
||||
* the tenant's own zone first, which is the zone {@code now} was taken in.
|
||||
*/
|
||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
private JsonObject parseBody(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required.");
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (Exception e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw);
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid",
|
||||
"transactionDate must be in YYYY-MM-DD format. Received: " + raw);
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitiseNote(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = raw.trim();
|
||||
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
|
||||
}
|
||||
|
||||
private String getString(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
||||
}
|
||||
|
||||
private Long getLong(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
|
||||
}
|
||||
|
||||
private BigDecimal getBigDecimal(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null;
|
||||
}
|
||||
|
||||
/** Further receipts making up one settlement. Empty when the caller sent a single receipt. */
|
||||
private List<Long> getLongList(final JsonObject json, final String field) {
|
||||
final List<Long> values = new java.util.ArrayList<>();
|
||||
if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) {
|
||||
return values;
|
||||
}
|
||||
for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) {
|
||||
if (!element.isJsonNull()) {
|
||||
values.add(element.getAsLong());
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits an over-payment to the member's savings account.
|
||||
*
|
||||
* Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in
|
||||
* the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry
|
||||
* would move the balance in the ledger while leaving the member's own account history silent about it.
|
||||
*/
|
||||
private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
payload.addProperty("transactionDate", txnDate.toString());
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId());
|
||||
payload.addProperty("note", note);
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
|
||||
.build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId();
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment type this deposit is recorded under.
|
||||
*
|
||||
* Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in
|
||||
* the opposite direction — an internal transfer between a share application and the member's savings, not cash
|
||||
* over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row.
|
||||
*/
|
||||
private Long resolveExcessPaymentTypeId() {
|
||||
final List<Long> byName = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class);
|
||||
if (!byName.isEmpty()) {
|
||||
return byName.get(0);
|
||||
}
|
||||
final List<Long> any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class);
|
||||
if (any.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type",
|
||||
"No payment type is configured, so the over-payment cannot be credited to the member's savings account.");
|
||||
}
|
||||
return any.get(0);
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
|
||||
private int toInt(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).intValue() : 0;
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -375,8 +375,20 @@ public class ShareCollectionApiResource {
|
||||
userId, userId, now, now,
|
||||
java.sql.Date.valueOf(entryDate),
|
||||
java.sql.Date.valueOf(entryDate),
|
||||
now.atOffset(ZoneOffset.UTC),
|
||||
now.atOffset(ZoneOffset.UTC));
|
||||
toUtc(now),
|
||||
toUtc(now));
|
||||
}
|
||||
|
||||
/**
|
||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
||||
*
|
||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
||||
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
|
||||
* the tenant's own zone first, which is the zone {@code now} was taken in.
|
||||
*/
|
||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
private Map<String, Object> glEntryMap(final String account, final String type,
|
||||
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* 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.cashtransfer.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.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.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
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.exception.PlatformDataIntegrityException;
|
||||
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.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Settles a share application out of the member's own savings account.
|
||||
*
|
||||
* The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks
|
||||
* in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks
|
||||
* with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Savings Control (share value + entry fee)
|
||||
* Cr Share Capital (share value)
|
||||
* Cr Entry Fee Income (charge, when one is attached)
|
||||
* </pre>
|
||||
*
|
||||
* No Sundry leg, because nothing is ever held — the debit and the share issue happen together.
|
||||
*
|
||||
* Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval
|
||||
* touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair
|
||||
* of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves.
|
||||
*
|
||||
* The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and
|
||||
* running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own
|
||||
* Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and
|
||||
* replaced with the share-capital pair above.
|
||||
*/
|
||||
@Path("/v1/sharesavingsdebit")
|
||||
@Component
|
||||
@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.")
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ShareSavingsDebitRequestApiResource {
|
||||
|
||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||
private static final int CREDIT = 1;
|
||||
private static final int DEBIT = 2;
|
||||
|
||||
// acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource.
|
||||
private static final int FA_INCOME_FROM_FEES = 3;
|
||||
private static final int FA_SHARES_EQUITY = 4;
|
||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||
|
||||
/** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */
|
||||
private static final int PRODUCT_TYPE_SAVINGS = 2;
|
||||
private static final int FA_SAVINGS_CONTROL = 2;
|
||||
|
||||
private static final int SHARE_STATUS_ACTIVE = 300;
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
|
||||
/** m_payment_type.value seeded by changelog 0483 for this settlement. */
|
||||
private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
// ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ──────────────
|
||||
|
||||
/**
|
||||
* Whether this share account can be settled from savings, and for how much.
|
||||
*
|
||||
* Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked,
|
||||
* does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the
|
||||
* counter instead of after a request has been raised and has to be rejected.
|
||||
*/
|
||||
@GET
|
||||
@Path("/eligibility/{shareAccountId}")
|
||||
public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) {
|
||||
this.context.authenticatedUser();
|
||||
final Map<String, Object> acct = loadShareAccount(shareAccountId);
|
||||
|
||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
||||
final BigDecimal required = shareAmount.add(chargeAmount);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("shareAccountId", shareAccountId);
|
||||
out.put("shareAccountNo", acct.get("account_no"));
|
||||
out.put("shareAmount", shareAmount);
|
||||
out.put("chargeAmount", chargeAmount);
|
||||
out.put("totalRequired", required);
|
||||
|
||||
final Object savingsAccountIdRaw = acct.get("savings_account_id");
|
||||
if (savingsAccountIdRaw == null) {
|
||||
out.put("eligible", false);
|
||||
out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead.");
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
final Long savingsAccountId = toLong(savingsAccountIdRaw);
|
||||
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
|
||||
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
|
||||
final BigDecimal available = balance == null ? BigDecimal.ZERO : balance;
|
||||
|
||||
out.put("savingsAccountId", savingsAccountId);
|
||||
out.put("savingsAccountNo", savings.get("account_no"));
|
||||
out.put("savingsBalance", available);
|
||||
|
||||
final String blocker = settlementBlocker(acct, savings, required, available);
|
||||
out.put("eligible", blocker == null);
|
||||
out.put("reason", blocker);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this share account cannot be settled from savings right now, or null when it can.
|
||||
*
|
||||
* Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement
|
||||
* is allowed.
|
||||
*/
|
||||
private String settlementBlocker(final Map<String, Object> shareAccount, final Map<String, Object> savings,
|
||||
final BigDecimal required, final BigDecimal available) {
|
||||
|
||||
if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) {
|
||||
return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled.";
|
||||
}
|
||||
if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) {
|
||||
return "Share account " + shareAccount.get("account_no") + " has already been settled.";
|
||||
}
|
||||
if (required.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return "There is nothing outstanding on share account " + shareAccount.get("account_no") + ".";
|
||||
}
|
||||
// The savings account must belong to the same member — debiting anyone else's balance for
|
||||
// these shares would be taking one member's money for another's holding.
|
||||
if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) {
|
||||
return "The linked savings account belongs to a different member.";
|
||||
}
|
||||
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
|
||||
return "Savings account " + savings.get("account_no") + " is not Active.";
|
||||
}
|
||||
if (available.compareTo(required) < 0) {
|
||||
return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required
|
||||
+ " is required. Take a deposit first, or settle by cash collection.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── POST /v1/sharesavingsdebit ── Clerk records the request ─────────────
|
||||
|
||||
@POST
|
||||
@Transactional
|
||||
public Response submit(final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final JsonObject json = parseBody(body);
|
||||
final Long shareAccountId = getLong(json, "shareAccountId");
|
||||
if (shareAccountId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required.");
|
||||
}
|
||||
|
||||
final Map<String, Object> acct = loadShareAccount(shareAccountId);
|
||||
final Object savingsAccountIdRaw = acct.get("savings_account_id");
|
||||
if (savingsAccountIdRaw == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings",
|
||||
"Share account " + acct.get("account_no") + " has no linked savings account.");
|
||||
}
|
||||
final Long savingsAccountId = toLong(savingsAccountIdRaw);
|
||||
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
|
||||
|
||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
||||
final BigDecimal required = shareAmount.add(chargeAmount);
|
||||
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
|
||||
|
||||
// Checked at submit as well as at approval: the clerk should be told now, not after the
|
||||
// member has left and a manager finds the account short.
|
||||
final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
|
||||
if (blocker != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
|
||||
}
|
||||
|
||||
final Long existingPending = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class,
|
||||
shareAccountId);
|
||||
if (existingPending != null && existingPending > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending",
|
||||
"A savings-debit request for this share account is already pending review.");
|
||||
}
|
||||
|
||||
final Long newId = this.jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_share_savings_debit_request
|
||||
(share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount,
|
||||
currency_code, status, clerk_id, clerk_name, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
|
||||
RETURNING id
|
||||
""", Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required,
|
||||
acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note"));
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", newId);
|
||||
out.put("status", "PENDING");
|
||||
out.put("totalAmount", required);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ── GET /v1/sharesavingsdebit ── Requests awaiting review ───────────────
|
||||
|
||||
@GET
|
||||
public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("shareAccountId") final Long shareAccountId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name,
|
||||
r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance,
|
||||
r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status,
|
||||
r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at,
|
||||
r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note
|
||||
FROM m_share_savings_debit_request r
|
||||
JOIN m_share_account sa ON sa.id = r.share_account_id
|
||||
JOIN m_client c ON c.id = r.client_id
|
||||
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
|
||||
WHERE 1 = 1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (!"ALL".equalsIgnoreCase(status)) {
|
||||
sql.append(" AND r.status = ? ");
|
||||
params.add(status.toUpperCase());
|
||||
}
|
||||
if (shareAccountId != null) {
|
||||
sql.append(" AND r.share_account_id = ? ");
|
||||
params.add(shareAccountId);
|
||||
}
|
||||
sql.append(" ORDER BY r.submitted_at DESC, r.id DESC");
|
||||
|
||||
return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build();
|
||||
}
|
||||
|
||||
// ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ─────────
|
||||
|
||||
/**
|
||||
* Debits the member's savings and issues the shares.
|
||||
*
|
||||
* Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the
|
||||
* approval is the point at which it actually matters.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{id}/approve")
|
||||
@Transactional
|
||||
public Response approve(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> req = loadRequest(id);
|
||||
if (!"PENDING".equals(req.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
|
||||
"Only PENDING requests can be approved. This one is " + req.get("status") + ".");
|
||||
}
|
||||
if (toLong(req.get("clerk_id")).equals(currentUser.getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval",
|
||||
"Maker-checker: you cannot approve a request you submitted yourself. Ask another manager.");
|
||||
}
|
||||
|
||||
final Long shareAccountId = toLong(req.get("share_account_id"));
|
||||
final Long savingsAccountId = toLong(req.get("savings_account_id"));
|
||||
final Map<String, Object> acct = loadShareAccount(shareAccountId);
|
||||
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
|
||||
|
||||
// Recomputed rather than taken from the request: shares or charges may have been edited
|
||||
// between submit and approval, and the member must be debited for what is actually owed.
|
||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
||||
final BigDecimal required = shareAmount.add(chargeAmount);
|
||||
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
|
||||
|
||||
final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
|
||||
if (blocker != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
|
||||
}
|
||||
|
||||
final Long productId = toLong(acct.get("product_id"));
|
||||
final Long officeId = toLong(acct.get("office_id"));
|
||||
final String currencyCode = (String) acct.get("currency_code");
|
||||
final String accountNo = (String) acct.get("account_no");
|
||||
|
||||
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
|
||||
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||
if (glSavingsControl == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped",
|
||||
"The Savings Control GL is not mapped for this savings account's product.");
|
||||
}
|
||||
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
final String note = "Share application settled from savings | " + accountNo;
|
||||
|
||||
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
|
||||
// pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money
|
||||
// became share capital, which the pair below records.
|
||||
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note);
|
||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
|
||||
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String txnId = "SHSAV-" + savingsTxnId;
|
||||
|
||||
insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share application debited from savings " + savings.get("account_no") + " | " + accountNo);
|
||||
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share capital issued for " + accountNo);
|
||||
|
||||
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
||||
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share entry fee for " + accountNo);
|
||||
this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
||||
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
||||
}
|
||||
|
||||
// Marks the account settled, which is what stops it being settled again by either route.
|
||||
this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
|
||||
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_share_savings_debit_request
|
||||
SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(),
|
||||
share_amount = ?, charge_amount = ?, total_amount = ?,
|
||||
savings_transaction_id = ?, gl_transaction_id = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", id);
|
||||
out.put("status", "APPROVED");
|
||||
out.put("shareAmount", shareAmount);
|
||||
out.put("chargeAmount", chargeAmount);
|
||||
out.put("totalDebited", required);
|
||||
out.put("savingsTransactionId", savingsTxnId);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ───────────
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reject")
|
||||
@Transactional
|
||||
public Response reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> req = loadRequest(id);
|
||||
if (!"PENDING".equals(req.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
|
||||
"Only PENDING requests can be rejected. This one is " + req.get("status") + ".");
|
||||
}
|
||||
|
||||
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body);
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_share_savings_debit_request
|
||||
SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", id);
|
||||
out.put("status", "REJECTED");
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate,
|
||||
final String note) {
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
payload.addProperty("transactionDate", txnDate.toString());
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
|
||||
payload.addProperty("note", note);
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString())
|
||||
.build();
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
return result.getResourceId();
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the
|
||||
* withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out.
|
||||
* The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it
|
||||
* on the member's statement and in payment-type reporting.
|
||||
*
|
||||
* Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles
|
||||
* rather than failing at approval with money already half-moved.
|
||||
*/
|
||||
private Long resolveSettlementPaymentTypeId() {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE);
|
||||
if (!rows.isEmpty()) {
|
||||
return toLong(rows.get(0).get("id"));
|
||||
}
|
||||
final List<Map<String, Object>> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1");
|
||||
if (fallback.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.payment.type",
|
||||
"No payment types are configured. Add one before settling a share application from savings.");
|
||||
}
|
||||
return toLong(fallback.get(0).get("id"));
|
||||
}
|
||||
|
||||
/** Savings Control GL for this savings account's product. */
|
||||
private Long savingsControlGlOf(final Long savingsAccountId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||
SELECT apm.gl_account_id FROM m_savings_account sa
|
||||
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
|
||||
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
|
||||
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
|
||||
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
|
||||
}
|
||||
|
||||
/** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */
|
||||
private BigDecimal shareValueOf(final Long shareAccountId) {
|
||||
final BigDecimal total = this.jdbcTemplate
|
||||
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
|
||||
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
|
||||
return total == null ? BigDecimal.ZERO : total;
|
||||
}
|
||||
|
||||
/** Entry fee still outstanding, zero when no charge was attached. */
|
||||
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
|
||||
final BigDecimal total = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge "
|
||||
+ " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0",
|
||||
BigDecimal.class, shareAccountId);
|
||||
return total == null ? BigDecimal.ZERO : total;
|
||||
}
|
||||
|
||||
private Map<String, Object> loadShareAccount(final Long shareAccountId) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForMap("""
|
||||
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code,
|
||||
sa.collection_applied, sa.savings_account_id, c.office_id
|
||||
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?
|
||||
""", shareAccountId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found",
|
||||
"Share account " + shareAccountId + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForMap(
|
||||
"SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?",
|
||||
savingsAccountId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found",
|
||||
"Savings account " + savingsAccountId + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> loadRequest(final Long id) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found",
|
||||
"Savings-debit request " + id + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
|
||||
try {
|
||||
final Long glId = this.jdbcTemplate.queryForObject(
|
||||
"SELECT gl_account_id FROM acc_product_mapping "
|
||||
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
|
||||
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
||||
if (glId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
|
||||
label + " GL is not mapped for share product " + productId + ".");
|
||||
}
|
||||
return glId;
|
||||
} catch (final IncorrectResultSizeDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
|
||||
label + " GL is not mapped for share product " + productId + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
||||
final String description) {
|
||||
this.jdbcTemplate.update(
|
||||
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||
+ " is_running_balance_calculated, office_running_balance, "
|
||||
+ " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
|
||||
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
||||
}
|
||||
|
||||
/**
|
||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
||||
*
|
||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
||||
* 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's
|
||||
* own zone first, which is the zone {@code now} was taken in.
|
||||
*/
|
||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
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 new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required",
|
||||
"Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request.");
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject parseBody(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required.");
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (final RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
private Long getLong(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
|
||||
}
|
||||
|
||||
private String getString(final JsonObject json, final String field) {
|
||||
if (!json.has(field) || json.get(field).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String value = json.get(field).getAsString().trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
private int toInt(final Object o) {
|
||||
return o instanceof Number n ? n.intValue() : 0;
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -185,9 +185,13 @@ public class ClerkDisbursementRequestApiResource {
|
||||
activityLog, Timestamp.from(Instant.now()), requestType
|
||||
);
|
||||
|
||||
// A collection pulls money into the GL, so it reads as a credit; a disbursement and a
|
||||
// bank transfer both move money out of the source side, so they read as a debit.
|
||||
final String voucherPrefix = "COLLECTION".equals(requestType)
|
||||
? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
|
||||
final String voucherNo = voucherService.createVoucher(
|
||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
||||
newId, txnAmount, currentUser);
|
||||
newId, txnAmount, currentUser, voucherPrefix);
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||
|
||||
@@ -544,7 +548,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
return ps;
|
||||
}, btLogKh);
|
||||
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
|
||||
final String btVoucherNo = generateBtVoucherNo(btLogId);
|
||||
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
|
||||
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
|
||||
}
|
||||
|
||||
@@ -981,7 +985,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
return ps;
|
||||
}, logKh);
|
||||
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
|
||||
final String voucherNo = generateBtVoucherNo(logId);
|
||||
final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr);
|
||||
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
@@ -997,9 +1001,15 @@ public class ClerkDisbursementRequestApiResource {
|
||||
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
|
||||
* under concurrent inserts on the same date.
|
||||
*/
|
||||
private String generateBtVoucherNo(final long logId) {
|
||||
return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
|
||||
+ String.format("%05d", logId);
|
||||
/**
|
||||
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
||||
* voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
|
||||
* the debited accounts, so it reads as a debit: DR/001/2108/0001.
|
||||
*/
|
||||
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
|
||||
return voucherService.createVoucher(
|
||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG,
|
||||
logId, amount, currentUser, VoucherService.PREFIX_DEBIT);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
+841
@@ -0,0 +1,841 @@
|
||||
/**
|
||||
* 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.clerkwithdrawal.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
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 withdrawal (redeem shares) request workflow for a Share Account, submitted only
|
||||
* from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the
|
||||
* governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which
|
||||
* server-side executes the real Fineract "redeemshares" command against the share account, or
|
||||
* rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same
|
||||
* maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share
|
||||
* redemption is a book-entry, not a cash handover).
|
||||
*/
|
||||
@Path("/v1/clerk/share-withdrawal-requests")
|
||||
@Component
|
||||
@Tag(name = "Clerk Share Withdrawal Requests", description = "Maker-checker share redemption workflow for clerk and bank manager.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClerkShareWithdrawalRequestApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
// Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date,
|
||||
// meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent
|
||||
// format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's
|
||||
// simple split('T')[0]/split(' ')[0] date parsing, which expects an ISO-shaped string.
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final String[] LIST_COLUMNS = { "id", "share_account_id", "account_no", "client_id", "client_name",
|
||||
"requested_shares", "requested_date", "resolution_number", "meeting_date", "description", "status", "clerk_id", "clerk_name",
|
||||
"clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id",
|
||||
"activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id",
|
||||
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
|
||||
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
|
||||
|
||||
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
|
||||
|
||||
@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 shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
|
||||
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
|
||||
final Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
|
||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
|
||||
final String requestedDate = fromJsonHelper.extractStringNamed("requestedDate", root);
|
||||
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
|
||||
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
|
||||
// Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no.,
|
||||
// amount, cheque no./date, token no., description, bank name, routing code, payment type) —
|
||||
// stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads
|
||||
// it, since the CR row's account number is only ever used client-side to double-check against
|
||||
// the share account's own authoritative linked-savings-account link.
|
||||
final JsonElement snapshotElement = root.isJsonObject() ? root.getAsJsonObject().get("settlementRowSnapshot") : null;
|
||||
final String settlementRowSnapshot = snapshotElement != null && !snapshotElement.isJsonNull() ? snapshotElement.toString()
|
||||
: null;
|
||||
|
||||
if (shareAccountId == null) {
|
||||
throw apiError(400, "Share account id is required");
|
||||
}
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw apiError(400, "Account number is required");
|
||||
}
|
||||
if (requestedShares == null || requestedShares <= 0) {
|
||||
throw apiError(400, "Number of shares to withdraw must be greater than zero");
|
||||
}
|
||||
if (requestedDate == null || requestedDate.isBlank()) {
|
||||
throw apiError(400, "Requested date is required");
|
||||
}
|
||||
if (resolutionNumber == null || resolutionNumber.isBlank()) {
|
||||
throw apiError(400, "Board Resolution Number is required for a share withdrawal");
|
||||
}
|
||||
if (meetingDate == null || meetingDate.isBlank()) {
|
||||
throw apiError(400, "Date of Meeting is required for a share withdrawal");
|
||||
}
|
||||
if (settlementPaymentTypeId == null) {
|
||||
throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
|
||||
}
|
||||
validateSettlementPaymentType(settlementPaymentTypeId);
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_clerk_share_withdrawal_request
|
||||
(share_account_id, account_no, client_id, client_name, requested_shares, requested_date,
|
||||
resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username,
|
||||
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate),
|
||||
resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(),
|
||||
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement is always a book-transfer from Share Sundry to the linked savings account — never a
|
||||
* cash payout at the teller counter — so cash-flagged payment types are rejected here regardless
|
||||
* of what the clerk picked in the UI.
|
||||
*/
|
||||
private void validateSettlementPaymentType(final Long paymentTypeId) {
|
||||
final Map<String, Object> paymentType = jdbcTemplate
|
||||
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", paymentTypeId).stream().findFirst()
|
||||
.orElse(null);
|
||||
if (paymentType == null) {
|
||||
throw apiError(400, "Settlement Payment Type " + paymentTypeId + " does not exist");
|
||||
}
|
||||
final boolean isCash = Boolean.TRUE.equals(paymentType.get("is_cash_payment"));
|
||||
if (isCash) {
|
||||
throw apiError(400, "Settlement Payment Type '" + paymentType.get("value")
|
||||
+ "' is a cash payment type — share redemption settlement to a linked savings account must be a non-cash book-transfer");
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests ── Manager sees all requests ────
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
||||
|
||||
@GET
|
||||
@Path("/my")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String myRequests(@QueryParam("status") final String status, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
|
||||
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
|
||||
if (status != null && !status.isBlank()) {
|
||||
where.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
|
||||
}
|
||||
|
||||
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
|
||||
final int offset, final int limit) {
|
||||
final StringBuilder where = new StringBuilder(baseWhere);
|
||||
final List<Object> params = new ArrayList<>(baseParams);
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
params.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
params.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional
|
||||
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
|
||||
params.toArray()))
|
||||
.orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(params);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalFilteredRecords", total);
|
||||
result.add("pageItems", gson.toJsonTree(rows));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests/{id} ── Full detail ─────────────
|
||||
|
||||
/**
|
||||
* Unlike list()/myRequests(), this single-record detail also carries the client's compliance
|
||||
* summary (KYC status, Risk Category, open AML alert count) — so the review screen can show it
|
||||
* permanently on the page, not only inside the on-demand /validate popup.
|
||||
*/
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getById(@PathParam("id") final Long id) {
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
final Map<String, Object> enriched = new java.util.LinkedHashMap<>();
|
||||
for (final Map.Entry<?, ?> entry : record.entrySet()) {
|
||||
enriched.put(String.valueOf(entry.getKey()), entry.getValue());
|
||||
}
|
||||
final Object clientIdObj = record.get("client_id");
|
||||
final Long clientId = clientIdObj instanceof Number ? ((Number) clientIdObj).longValue() : null;
|
||||
enriched.putAll(loadClientComplianceSummary(clientId));
|
||||
return gson.toJson(enriched);
|
||||
}
|
||||
|
||||
private Map<String, Object> loadClientComplianceSummary(final Long clientId) {
|
||||
final Map<String, Object> summary = new java.util.LinkedHashMap<>();
|
||||
if (clientId == null) {
|
||||
return summary;
|
||||
}
|
||||
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
|
||||
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
|
||||
WHERE client_id = ? ORDER BY id DESC LIMIT 1
|
||||
""", clientId).stream().findFirst().orElse(null);
|
||||
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
|
||||
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null ? ((Number) kycRow.get("riskCategory")).intValue()
|
||||
: null;
|
||||
final Long openAlerts = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
|
||||
|
||||
summary.put("client_kyc_status_label", kycStatus != null ? kycStatusLabel(kycStatus) : "No KYC record");
|
||||
summary.put("client_kyc_verified", kycStatus != null && kycStatus == 300);
|
||||
summary.put("client_risk_category_label", riskCategory != null ? riskCategoryLabel(riskCategory) : "Not classified");
|
||||
summary.put("client_risk_high", riskCategory != null && riskCategory == 3);
|
||||
summary.put("client_open_aml_alerts", openAlerts != null ? openAlerts : 0L);
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/validate ── Manager runs live checks ──
|
||||
|
||||
/**
|
||||
* Re-runs every check against the CURRENT live state (not the snapshot taken at submit time), so
|
||||
* the checker can catch things that changed since submission — a share/savings account that got
|
||||
* closed, a payment type that got deactivated, a client whose KYC lapsed or who now has an open
|
||||
* AML alert, an active loan secured by these very shares ({@code m_share_account.linked_loan_id}
|
||||
* — this bank supports loan-against-shares), that loan's tenure not yet being complete, this
|
||||
* client currently guaranteeing someone else's active loan, or (the exact bug class this session
|
||||
* ran into) a savings product's GL Fund Source mapping for this payment type going missing, which
|
||||
* would silently post the settlement leg to the default Cash at Vault account instead of Share
|
||||
* Sundry.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{id}/validate")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String validate(@PathParam("id") final Long id) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
final List<Map<String, Object>> checks = new ArrayList<>();
|
||||
|
||||
final String resolutionNumber = (String) record.get("resolution_number");
|
||||
checks.add(checkResult("Board Resolution Number provided", resolutionNumber != null && !resolutionNumber.isBlank(),
|
||||
resolutionNumber == null || resolutionNumber.isBlank() ? "Missing" : resolutionNumber));
|
||||
|
||||
final Object meetingDate = record.get("meeting_date");
|
||||
checks.add(checkResult("Date of Meeting provided", meetingDate != null,
|
||||
meetingDate == null ? "Missing" : meetingDate.toString().substring(0, Math.min(10, meetingDate.toString().length()))));
|
||||
|
||||
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
|
||||
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
||||
|
||||
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
|
||||
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
|
||||
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
|
||||
FROM m_share_account WHERE id = ?
|
||||
""", shareAccountId).stream().findFirst().orElse(null);
|
||||
|
||||
final long heldShares = shareAccountRow != null && shareAccountRow.get("totalApprovedShares") != null
|
||||
? ((Number) shareAccountRow.get("totalApprovedShares")).longValue()
|
||||
: 0L;
|
||||
checks.add(checkResult("Shares requested within current shares held", shareAccountRow != null && requestedShares <= heldShares,
|
||||
requestedShares + " of " + heldShares + " shares currently held"));
|
||||
|
||||
final int shareAccountStatus = shareAccountRow != null && shareAccountRow.get("statusEnum") != null
|
||||
? ((Number) shareAccountRow.get("statusEnum")).intValue()
|
||||
: -1;
|
||||
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
|
||||
|
||||
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
|
||||
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
|
||||
: null;
|
||||
final Long clientId = shareAccountRow != null && shareAccountRow.get("clientId") != null
|
||||
? ((Number) shareAccountRow.get("clientId")).longValue()
|
||||
: null;
|
||||
|
||||
Integer savingsStatus = null;
|
||||
Long savingsProductId = null;
|
||||
if (savingsAccountId != null) {
|
||||
final Map<String, Object> savingsRow = jdbcTemplate
|
||||
.queryForList("SELECT status_enum AS statusEnum, product_id AS productId FROM m_savings_account WHERE id = ?",
|
||||
savingsAccountId)
|
||||
.stream().findFirst().orElse(null);
|
||||
savingsStatus = savingsRow != null && savingsRow.get("statusEnum") != null ? ((Number) savingsRow.get("statusEnum")).intValue()
|
||||
: null;
|
||||
savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue()
|
||||
: null;
|
||||
}
|
||||
checks.add(checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
||||
savingsAccountId == null ? "No linked savings account configured"
|
||||
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
||||
|
||||
// Loan / Tenure / Guarantor checks — this bank links shares to a loan (m_share_account.linked_loan_id)
|
||||
// when shares secure a loan against shares, so a withdrawal must not weaken that security while the
|
||||
// loan is still outstanding, and separately, releasing shares to a client currently on the hook as a
|
||||
// guarantor for someone else's active loan deserves the same scrutiny.
|
||||
final Long linkedLoanId = shareAccountRow != null && shareAccountRow.get("linkedLoanId") != null
|
||||
? ((Number) shareAccountRow.get("linkedLoanId")).longValue()
|
||||
: null;
|
||||
Map<String, Object> linkedLoanRow = null;
|
||||
if (linkedLoanId != null) {
|
||||
linkedLoanRow = jdbcTemplate.queryForList("""
|
||||
SELECT account_no AS accountNo, loan_status_id AS statusId, expected_maturedon_date AS expectedMaturedOnDate
|
||||
FROM m_loan WHERE id = ?
|
||||
""", linkedLoanId).stream().findFirst().orElse(null);
|
||||
}
|
||||
final Integer linkedLoanStatus = linkedLoanRow != null && linkedLoanRow.get("statusId") != null
|
||||
? ((Number) linkedLoanRow.get("statusId")).intValue()
|
||||
: null;
|
||||
final boolean linkedLoanOutstanding = linkedLoanStatus != null && isLoanStatusOutstanding(linkedLoanStatus);
|
||||
final String loanCheckDetail;
|
||||
if (linkedLoanId == null) {
|
||||
loanCheckDetail = "No loan linked to this share account";
|
||||
} else if (linkedLoanRow == null) {
|
||||
loanCheckDetail = "Loan #" + linkedLoanId + " — record not found";
|
||||
} else {
|
||||
loanCheckDetail = "Loan " + linkedLoanRow.get("accountNo") + " — " + loanStatusLabel(linkedLoanStatus);
|
||||
}
|
||||
checks.add(checkResult("Loan Check — no active loan secured by these shares", linkedLoanId == null || !linkedLoanOutstanding,
|
||||
loanCheckDetail));
|
||||
|
||||
boolean tenureOk = true;
|
||||
String tenureDetail = "No linked loan";
|
||||
if (linkedLoanId != null && linkedLoanOutstanding && linkedLoanRow != null) {
|
||||
final Object maturedOnObj = linkedLoanRow.get("expectedMaturedOnDate");
|
||||
if (maturedOnObj instanceof java.sql.Date maturedOnDate) {
|
||||
final boolean matured = !maturedOnDate.toLocalDate()
|
||||
.isAfter(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
tenureOk = matured;
|
||||
tenureDetail = "Linked loan term ends " + maturedOnDate.toLocalDate() + (matured ? " — completed" : " — not yet completed");
|
||||
} else {
|
||||
tenureOk = false;
|
||||
tenureDetail = "Linked loan is active with no expected maturity date on record";
|
||||
}
|
||||
}
|
||||
checks.add(checkResult("Tenure Check — linked loan term completed", tenureOk, tenureDetail));
|
||||
|
||||
if (clientId != null) {
|
||||
final Long guarantorActiveLoans = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_guarantor g JOIN m_loan l ON l.id = g.loan_id
|
||||
WHERE g.entity_id = ? AND g.type_enum = 1 AND g.is_active = true
|
||||
AND l.loan_status_id IN (300, 303, 304, 700)
|
||||
""", Long.class, clientId);
|
||||
checks.add(checkResult("Guarantor Check — not guaranteeing any active loan",
|
||||
guarantorActiveLoans == null || guarantorActiveLoans == 0,
|
||||
guarantorActiveLoans != null && guarantorActiveLoans > 0 ? "Guarantor on " + guarantorActiveLoans + " active loan(s)"
|
||||
: "None"));
|
||||
}
|
||||
|
||||
final Object settlementPaymentTypeIdObj = record.get("settlement_payment_type_id");
|
||||
final Long settlementPaymentTypeId = settlementPaymentTypeIdObj instanceof Number
|
||||
? ((Number) settlementPaymentTypeIdObj).longValue()
|
||||
: null;
|
||||
boolean paymentTypeNonCash = false;
|
||||
String paymentTypeDetail = "Missing";
|
||||
if (settlementPaymentTypeId != null) {
|
||||
final Map<String, Object> pt = jdbcTemplate
|
||||
.queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", settlementPaymentTypeId).stream()
|
||||
.findFirst().orElse(null);
|
||||
if (pt != null) {
|
||||
paymentTypeNonCash = !Boolean.TRUE.equals(pt.get("is_cash_payment"));
|
||||
paymentTypeDetail = pt.get("value") + (paymentTypeNonCash ? "" : " (cash — not allowed)");
|
||||
}
|
||||
}
|
||||
checks.add(checkResult("Settlement Payment Type is valid (non-cash)", paymentTypeNonCash, paymentTypeDetail));
|
||||
|
||||
boolean glMappingOk = false;
|
||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||
if (savingsProductId != null && settlementPaymentTypeId != null) {
|
||||
final Map<String, Object> mapping = jdbcTemplate.queryForList("""
|
||||
SELECT gl.name AS glName FROM acc_product_mapping apm
|
||||
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
|
||||
WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
|
||||
""", savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
|
||||
glMappingOk = mapping != null;
|
||||
glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
|
||||
: "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
|
||||
}
|
||||
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
|
||||
|
||||
if (clientId != null) {
|
||||
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
|
||||
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
|
||||
WHERE client_id = ? ORDER BY id DESC LIMIT 1
|
||||
""", clientId).stream().findFirst().orElse(null);
|
||||
|
||||
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
|
||||
checks.add(checkResult("Client KYC is Verified", kycStatus != null && kycStatus == 300,
|
||||
kycStatus == null ? "No KYC record found" : kycStatusLabel(kycStatus)));
|
||||
|
||||
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null
|
||||
? ((Number) kycRow.get("riskCategory")).intValue()
|
||||
: null;
|
||||
checks.add(checkResult("Client Risk Category is not High", riskCategory == null || riskCategory != 3,
|
||||
riskCategory == null ? "Not classified" : riskCategoryLabel(riskCategory)));
|
||||
|
||||
final Long openAlerts = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
|
||||
checks.add(checkResult("No open AML alerts for this client", openAlerts == null || openAlerts == 0,
|
||||
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
|
||||
}
|
||||
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
|
||||
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
|
||||
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
|
||||
final Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("label", label);
|
||||
result.put("pass", pass);
|
||||
result.put("detail", detail);
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isLoanStatusOutstanding(final int loanStatusId) {
|
||||
return loanStatusId == 300 || loanStatusId == 303 || loanStatusId == 304 || loanStatusId == 700;
|
||||
}
|
||||
|
||||
private String loanStatusLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 100 -> "Submitted and Pending Approval";
|
||||
case 200 -> "Approved";
|
||||
case 300 -> "Active";
|
||||
case 303 -> "Transfer in Progress";
|
||||
case 304 -> "Transfer on Hold";
|
||||
case 400 -> "Withdrawn by Client";
|
||||
case 500 -> "Rejected";
|
||||
case 600 -> "Closed (Obligations Met)";
|
||||
case 601 -> "Closed (Written Off)";
|
||||
case 602 -> "Closed (Rescheduled)";
|
||||
case 700 -> "Overpaid";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private String shareStatusLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 100 -> "Submitted and Pending Approval";
|
||||
case 200 -> "Approved";
|
||||
case 300 -> "Active";
|
||||
case 500 -> "Rejected";
|
||||
case 600 -> "Closed";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private String savingsStatusLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 100 -> "Submitted and Pending Approval";
|
||||
case 200 -> "Approved";
|
||||
case 300 -> "Active";
|
||||
case 303 -> "Transfer in Progress";
|
||||
case 304 -> "Transfer on Hold";
|
||||
case 400 -> "Withdrawn by Applicant";
|
||||
case 500 -> "Rejected";
|
||||
case 600 -> "Closed";
|
||||
case 700 -> "Pre-mature Closure";
|
||||
case 800 -> "Matured";
|
||||
case 900 -> "Terminated";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private String kycStatusLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 100 -> "Pending";
|
||||
case 200 -> "In Progress";
|
||||
case 300 -> "Verified";
|
||||
case 400 -> "Rejected";
|
||||
case 500 -> "Expired";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
private String riskCategoryLabel(final int code) {
|
||||
return switch (code) {
|
||||
case 1 -> "Low";
|
||||
case 2 -> "Medium";
|
||||
case 3 -> "High";
|
||||
default -> "Unknown (" + code + ")";
|
||||
};
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/approve ── Manager approves ──
|
||||
|
||||
@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<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING 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 share withdrawal request you submitted yourself. Ask another manager to review it.");
|
||||
}
|
||||
|
||||
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
|
||||
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
||||
final Object requestedDateObj = record.get("requested_date");
|
||||
final Object meetingDateObj = record.get("meeting_date");
|
||||
final String resolutionNumber = (String) record.get("resolution_number");
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("requestedDate", formatForFineract(requestedDateObj.toString()));
|
||||
payload.addProperty("requestedShares", requestedShares);
|
||||
payload.addProperty("resolutionNumber", resolutionNumber);
|
||||
payload.addProperty("meetingDate", formatForFineract(meetingDateObj.toString()));
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares")
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
// redeemShares() puts the newly-created share transaction's id under "requestedShares" in
|
||||
// the changes map — NOT in getResourceId(), which for this command is the share ACCOUNT id
|
||||
// instead. Reading getResourceId() here would silently look up the wrong row downstream.
|
||||
final Object shareTxnIdRaw = result.getChanges() != null ? result.getChanges().get("requestedShares") : null;
|
||||
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
|
||||
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
|
||||
|
||||
// Settlement: within this SAME approval transaction, immediately credit the redemption value
|
||||
// to the member's linked savings account. Two Fineract journal entries get posted — native
|
||||
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
|
||||
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
|
||||
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
|
||||
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
|
||||
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
|
||||
? ((Number) storedSettlementPaymentTypeId).longValue()
|
||||
: null;
|
||||
final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(),
|
||||
resolutionNumber, settlementPaymentTypeId);
|
||||
|
||||
final String settlementNote = settlement != null
|
||||
? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
|
||||
: "No linked savings account on this share account — settlement must be completed manually.";
|
||||
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(),
|
||||
settlementNote);
|
||||
|
||||
final int approvedRows = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_share_withdrawal_request SET
|
||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
share_transaction_id = ?, activity_log = ?, actioned_at = ?,
|
||||
settlement_amount = ?, settlement_savings_transaction_id = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog,
|
||||
Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null,
|
||||
settlement != null ? settlement.savingsTransactionId() : null, id);
|
||||
if (approvedRows == 0) {
|
||||
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
// If this redemption brought the account's holding to zero, close the share account right
|
||||
// here in the SAME approval action — no separate "Share Closure Request" maker-checker step.
|
||||
// Safe to do inline (unlike a raw manual "Close" from the account screen) because the
|
||||
// redemption amount itself already went through this same Maker-Checker review; there are no
|
||||
// shares left for the native close command to force-redeem without oversight.
|
||||
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
res.addProperty("status", "APPROVED");
|
||||
if (shareTxnId != null) {
|
||||
res.addProperty("shareTransactionId", shareTxnId);
|
||||
}
|
||||
if (settlement != null) {
|
||||
res.addProperty("settlementAmount", settlement.amount());
|
||||
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
|
||||
} else {
|
||||
res.addProperty("settlementWarning", settlementNote);
|
||||
}
|
||||
if (accountClosed) {
|
||||
res.addProperty("accountClosed", true);
|
||||
}
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the share account when this redemption has just brought its holding to zero — the
|
||||
* account closes immediately on approval, in the same step as the withdrawal itself, rather than
|
||||
* needing a separate closure request to be submitted and approved afterwards.
|
||||
*/
|
||||
private boolean closeShareAccountIfSharesZero(final Long shareAccountId, final String requestedDateStr) {
|
||||
final Long remainingShares = jdbcTemplate.queryForObject("SELECT total_approved_shares FROM m_share_account WHERE id = ?",
|
||||
Long.class, shareAccountId);
|
||||
if (remainingShares == null || remainingShares != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final JsonObject closePayload = new JsonObject();
|
||||
closePayload.addProperty("locale", "en");
|
||||
closePayload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
closePayload.addProperty("closedDate", formatForFineract(requestedDateStr));
|
||||
closePayload.addProperty("note", "Closed automatically — all shares withdrawn on approval of this Share Withdrawal Request.");
|
||||
final CommandWrapper closeCommand = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close")
|
||||
.withJson(closePayload.toString()).build();
|
||||
commandsSourceWritePlatformService.logCommandSource(closeCommand);
|
||||
return true;
|
||||
}
|
||||
|
||||
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
||||
|
||||
/**
|
||||
* Credits the redemption value to the share account's linked savings account, if one exists.
|
||||
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
|
||||
* the unit price ourselves — the transaction was already priced by Fineract's own
|
||||
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
|
||||
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
|
||||
* command so the transaction is fully audited and posts its own GL entry the normal way — the
|
||||
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
|
||||
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
|
||||
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
|
||||
* on that bridge account. Falls back to the legacy hardcoded
|
||||
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
|
||||
* column existed.
|
||||
*/
|
||||
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
|
||||
final String resolutionNumber, final Long settlementPaymentTypeId) {
|
||||
if (shareTransactionId == null) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> row = jdbcTemplate.queryForList("""
|
||||
SELECT sa.savings_account_id AS savingsAccountId, sat.amount AS settlementAmount
|
||||
FROM m_share_account_transactions sat JOIN m_share_account sa ON sa.id = sat.account_id
|
||||
WHERE sat.id = ?
|
||||
""", shareTransactionId).stream().findFirst().orElse(null);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
final Long savingsAccountId = row.get("savingsAccountId") == null ? null : ((Number) row.get("savingsAccountId")).longValue();
|
||||
if (savingsAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount");
|
||||
|
||||
final Long paymentTypeId = settlementPaymentTypeId != null ? settlementPaymentTypeId
|
||||
: jdbcTemplate.queryForList("SELECT id FROM m_payment_type WHERE code_name = ?", Long.class,
|
||||
SHARE_REDEMPTION_PAYMENT_TYPE_CODE).stream().findFirst().orElse(null);
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", formatForFineract(requestedDateStr));
|
||||
payload.addProperty("transactionAmount", settlementAmount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", paymentTypeId);
|
||||
}
|
||||
payload.addProperty("note", "Share redemption settlement — Resolution No. " + resolutionNumber);
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
return new Settlement(settlementAmount, result.getResourceId());
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/share-withdrawal-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<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING 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");
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason);
|
||||
|
||||
final int rejectedRows = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_share_withdrawal_request SET
|
||||
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
rejection_reason = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), reason, updatedLog,
|
||||
Timestamp.from(Instant.now()), id);
|
||||
if (rejectedRows == 0) {
|
||||
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
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 share withdrawal requests.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Share withdrawal request not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private String formatForFineract(final String isoOrTimestamp) {
|
||||
try {
|
||||
final String isoDatePart = isoOrTimestamp.length() >= 10 ? isoOrTimestamp.substring(0, 10) : isoOrTimestamp;
|
||||
final LocalDate ld = LocalDate.parse(isoDatePart, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
|
||||
JsonArray log = new JsonArray();
|
||||
if (existingJson != null && !existingJson.isBlank()) {
|
||||
try {
|
||||
log = fromJsonHelper.parse(existingJson).getAsJsonArray();
|
||||
} catch (Exception e) {
|
||||
log = new JsonArray();
|
||||
}
|
||||
}
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("timestamp", Instant.now().toString());
|
||||
entry.addProperty("action", action);
|
||||
entry.addProperty("user", username);
|
||||
if (note != null && !note.isBlank()) {
|
||||
entry.addProperty("note", note);
|
||||
}
|
||||
log.add(entry);
|
||||
return gson.toJson(log);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("message", message);
|
||||
body.addProperty("defaultUserMessage", message);
|
||||
body.addProperty("httpStatus", httpStatus);
|
||||
final JsonArray errors = new JsonArray();
|
||||
final JsonObject err = new JsonObject();
|
||||
err.addProperty("defaultUserMessage", message);
|
||||
errors.add(err);
|
||||
body.add("errors", errors);
|
||||
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+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.
|
||||
*/
|
||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||
@@ -810,10 +812,16 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
sql.append(" AND id <> ?");
|
||||
params.add(excludeId);
|
||||
}
|
||||
final long count = Optional.ofNullable(
|
||||
final long ownCount = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||
).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,
|
||||
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||
}
|
||||
|
||||
+84
-3
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.client.api;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
@@ -31,7 +32,9 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
@@ -43,8 +46,11 @@ import org.apache.fineract.infrastructure.core.service.Page;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
|
||||
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/clients")
|
||||
@@ -56,9 +62,21 @@ import org.springframework.stereotype.Component;
|
||||
@RequiredArgsConstructor
|
||||
public class ClientEditApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientEditApiResource.class);
|
||||
|
||||
/** Standard 1×1 transparent GIF — returned by the tracking-pixel endpoint regardless of
|
||||
* whether the token matched, to avoid leaking token validity via response differences. */
|
||||
private static final byte[] TRANSPARENT_GIF = {
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, (byte) 0x80, 0x00, 0x00,
|
||||
(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
|
||||
};
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
||||
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
||||
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||
@@ -124,14 +142,26 @@ public class ClientEditApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve a pending Client details edit",
|
||||
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
|
||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
|
||||
description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
|
||||
+ "The maker of the edit cannot also approve it — no exceptions.")
|
||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||
@Context final HttpServletRequest httpRequest) {
|
||||
|
||||
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
|
||||
throw new NoAuthorizationException("User has no authority to approve client edits");
|
||||
}
|
||||
|
||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
|
||||
// Derive base URL from the incoming request so the tracking pixel URL is absolute and
|
||||
// reachable by the client's email client without needing a configured server.url property.
|
||||
final String baseUrl = httpRequest.getScheme() + "://" + httpRequest.getServerName()
|
||||
+ (httpRequest.getServerPort() == 443 || httpRequest.getServerPort() == 80 ? ""
|
||||
: ":" + httpRequest.getServerPort());
|
||||
|
||||
// Tenant ID from the Fineract-specific header (same header the frontend sends).
|
||||
final String tenantId = httpRequest.getHeader("Fineract-Platform-TenantId");
|
||||
|
||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId, baseUrl,
|
||||
tenantId != null ? tenantId : "default");
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
@@ -161,4 +191,55 @@ public class ClientEditApiResource {
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
// ── Email preview (authenticated) ─────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("{clientId}/edit/{pendingEditId}/email-preview")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
|
||||
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
|
||||
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
|
||||
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
|
||||
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
|
||||
if (preview == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
|
||||
}
|
||||
|
||||
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("edit/pixel/{token}")
|
||||
@Produces("image/gif")
|
||||
@Operation(summary = "Email open tracking pixel",
|
||||
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
|
||||
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
|
||||
+ "regardless of whether the token matched, to avoid leaking token validity.")
|
||||
public Response trackEmailOpen(@PathParam("token") final String token) {
|
||||
if (token != null && !token.isBlank()) {
|
||||
try {
|
||||
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
|
||||
if (pendingEditOpt.isPresent()) {
|
||||
final var pendingEdit = pendingEditOpt.get();
|
||||
pendingEdit.markEmailOpened();
|
||||
pendingEditRepository.saveAndFlush(pendingEdit);
|
||||
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
// Never fail a pixel request — email clients don't handle errors well.
|
||||
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
|
||||
}
|
||||
}
|
||||
return Response.ok(TRANSPARENT_GIF, "image/gif")
|
||||
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
.header("Pragma", "no-cache")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.api;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Customer-outreach log for KYC exceptions.
|
||||
*
|
||||
* <pre>
|
||||
* POST /v1/kyc/exceptions/{clientId}/contact-log body: {"channel":"SMS","notes":"...","officerName":"..."}
|
||||
* GET /v1/kyc/exceptions/{clientId}/contact-log
|
||||
* </pre>
|
||||
*
|
||||
* <p>POST both records the attempt and — for SMS / Email / WhatsApp — sends the notice to the
|
||||
* customer. The response carries {@code deliveryStatus} and {@code deliveryMessage} so the caller
|
||||
* can distinguish a delivered notice from a merely-recorded one; a send failure is reported in the
|
||||
* body with HTTP 200, not as an error, because the log row was still written.
|
||||
*
|
||||
* <p>No PUT or DELETE: the log is append-only evidence.
|
||||
*
|
||||
* <p>Authorisation reuses the client UPDATE permission — contacting a customer about their KYC is a
|
||||
* maintenance action on that customer's record, and every KYC officer who can resolve an exception
|
||||
* already holds it.
|
||||
*/
|
||||
@Path("/v1/kyc/exceptions/{clientId}/contact-log")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Exceptions", description = "Record and send customer outreach for KYC exceptions.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientKycContactLogApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycContactLogService kycContactLogService;
|
||||
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Log a customer contact attempt for a KYC exception",
|
||||
description = "Records the attempt. For channel SMS, EMAIL or WHATSAPP the notice is also transmitted to the "
|
||||
+ "customer using the destination held on their client record. Returns the persisted log including "
|
||||
+ "its delivery outcome.")
|
||||
public String logContact(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
|
||||
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Retrieve the customer-contact history for a KYC exception",
|
||||
description = "All logged outreach for this customer, oldest first.")
|
||||
public String retrieveContactLog(@PathParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<KycContactLogData> logs = this.kycContactLogService.retrieveForClient(clientId);
|
||||
return this.toApiJsonSerializer.serialize(logs);
|
||||
}
|
||||
|
||||
private JsonObject parse(final String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(json);
|
||||
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject payload, final String member) {
|
||||
final JsonElement element = payload.get(member);
|
||||
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.ClientKycData;
|
||||
import org.apache.fineract.portfolio.client.service.ClientKycReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
|
||||
@Path("/v1/kyc/exceptions")
|
||||
@Component
|
||||
@Tag(name = "Client KYC Exceptions", description = "Retrieve active client KYC exceptions.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientKycExceptionsApiResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientKycExceptionsApiResource.class);
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ClientKycReadPlatformService readPlatformService;
|
||||
private final DefaultToApiJsonSerializer<ClientKycData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve client KYC exceptions", description = "Returns the list of all clients with active or resolved KYC exceptions.")
|
||||
public String retrieveActiveExceptions(@QueryParam("resolved") final Boolean resolved) {
|
||||
this.context.authenticatedUser();
|
||||
final List<ClientKycData> exceptions;
|
||||
if (Boolean.TRUE.equals(resolved)) {
|
||||
exceptions = this.readPlatformService.retrieveResolvedKycExceptions();
|
||||
LOG.info("retrieveActiveExceptions: fetched {} resolved exception(s) from database", exceptions.size());
|
||||
} else {
|
||||
exceptions = this.readPlatformService.retrieveActiveKycExceptions();
|
||||
LOG.info("retrieveActiveExceptions: fetched {} active exception(s) from database", exceptions.size());
|
||||
}
|
||||
for (ClientKycData k : exceptions) {
|
||||
LOG.info(" -> Exception: id={}, clientId={}, clientName='{}', isException={}, exceptionResolved={}",
|
||||
k.getId(), k.getClientId(), k.getClientDisplayName(), k.getIsException(), k.getExceptionResolved());
|
||||
}
|
||||
return this.toApiJsonSerializer.serialize(exceptions);
|
||||
}
|
||||
}
|
||||
+17
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
@@ -30,7 +31,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
|
||||
import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService;
|
||||
import org.apache.fineract.portfolio.client.service.KycRiskRulesWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/kyc/risk-rules")
|
||||
@@ -41,6 +44,7 @@ public class KycRiskRulesApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycRiskRulesReadPlatformService readPlatformService;
|
||||
private final KycRiskRulesWritePlatformService writePlatformService;
|
||||
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@@ -52,4 +56,17 @@ public class KycRiskRulesApiResource {
|
||||
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
|
||||
return this.toApiJsonSerializer.serialize(rules);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Add a KYC/AML risk rule",
|
||||
description = "Appends a rule to the catalog. Supplying any transaction band or observation window also "
|
||||
+ "creates the matching live-transaction rule, linked back to the new catalog entry. Returns the "
|
||||
+ "persisted rule in the same shape as the catalog GET.")
|
||||
public String createRiskRule(final KycRiskRuleRequest request) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
||||
return this.toApiJsonSerializer.serialize(rule);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-3
@@ -41,6 +41,9 @@ public class ClientKycCommand {
|
||||
private final Boolean verifyPan;
|
||||
private final Boolean verifyAadhaar;
|
||||
private final Boolean verifyGstin;
|
||||
private final Boolean isException;
|
||||
private final String exceptionReason;
|
||||
private final Boolean exceptionResolved;
|
||||
|
||||
// ── Aadhaar OTP flow ──────────────────────────────────────────────────────
|
||||
/** Full Aadhaar – used ONLY for OTP generation; never stored. */
|
||||
@@ -54,7 +57,8 @@ public class ClientKycCommand {
|
||||
final String aadhaarNumberMasked, final String gstinNumber, final String remarks,
|
||||
final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar,
|
||||
final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber,
|
||||
final String aadhaarOtp) {
|
||||
final String aadhaarOtp, final Boolean isException, final String exceptionReason,
|
||||
final Boolean exceptionResolved) {
|
||||
this.kycType = kycType;
|
||||
this.riskCategory = riskCategory;
|
||||
this.panNumber = panNumber;
|
||||
@@ -68,6 +72,9 @@ public class ClientKycCommand {
|
||||
this.aadhaarNumber = aadhaarNumber;
|
||||
this.aadhaarRefNumber = aadhaarRefNumber;
|
||||
this.aadhaarOtp = aadhaarOtp;
|
||||
this.isException = isException;
|
||||
this.exceptionReason = exceptionReason;
|
||||
this.exceptionResolved = exceptionResolved;
|
||||
}
|
||||
|
||||
public void validateForInitiate() {
|
||||
@@ -111,8 +118,9 @@ public class ClientKycCommand {
|
||||
}
|
||||
|
||||
// At least one ID document must be provided — except for minors, who prove identity via
|
||||
// the Birth Certificate / School ID document-type picker instead.
|
||||
if (!skipIdentityDocumentCheck) {
|
||||
// the Birth Certificate / School ID document-type picker instead, or if the client is marked as a KYC exception.
|
||||
final boolean isKycException = isException != null && isException;
|
||||
if (!skipIdentityDocumentCheck && !isKycException) {
|
||||
final boolean hasPan = panNumber != null && !panNumber.isBlank();
|
||||
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
|
||||
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
|
||||
@@ -250,4 +258,16 @@ public class ClientKycCommand {
|
||||
public String getAadhaarRefNumber() { return aadhaarRefNumber; }
|
||||
/** OTP entered by the client. */
|
||||
public String getAadhaarOtp() { return aadhaarOtp; }
|
||||
|
||||
public Boolean getIsException() {
|
||||
return isException;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return exceptionReason;
|
||||
}
|
||||
|
||||
public Boolean getExceptionResolved() {
|
||||
return exceptionResolved;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -111,12 +111,16 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@Autowired
|
||||
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||
|
||||
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.otpLogRepository = otpLogRepository;
|
||||
this.emailSenderService = emailSenderService;
|
||||
this.emailTemplateService = emailTemplateService;
|
||||
this.mobileLookupService = mobileLookupService;
|
||||
}
|
||||
|
||||
// ── Generate ─────────────────────────────────────────────────────────────
|
||||
@@ -128,6 +132,18 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
|
||||
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();
|
||||
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
|
||||
log.setProvider(PROVIDER_LINCPAY);
|
||||
|
||||
+6
-4
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
||||
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
|
||||
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,
|
||||
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
|
||||
@@ -65,12 +66,13 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
||||
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
|
||||
nameVerifiedParamName, screeningLogIdParamName,
|
||||
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
|
||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName));
|
||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
|
||||
"oldMobileVerificationExceptionDocumentId"));
|
||||
|
||||
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
||||
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName, additionalInfoParamName,
|
||||
dateOfRegistrationParamName));
|
||||
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName,
|
||||
authorizedSignatoryClientIdsParamName, additionalInfoParamName, dateOfRegistrationParamName));
|
||||
|
||||
/**
|
||||
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
|
||||
|
||||
+5
@@ -533,6 +533,11 @@ public final class ClientDataValidator {
|
||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
|
||||
.longGreaterThanZero();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("oldMobileVerificationExceptionDocumentId", element)) {
|
||||
final Long exceptionDocId = this.fromApiJsonHelper.extractLongNamed("oldMobileVerificationExceptionDocumentId", element);
|
||||
baseDataValidator.reset().parameter("oldMobileVerificationExceptionDocumentId").value(exceptionDocId).ignoreIfNull()
|
||||
.longGreaterThanZero();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
|
||||
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
|
||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
|
||||
|
||||
+44
-3
@@ -39,6 +39,9 @@ public final class ClientKycApiConstants {
|
||||
public static final String verifyPanParam = "verifyPan";
|
||||
public static final String verifyAadhaarParam = "verifyAadhaar";
|
||||
public static final String verifyGstinParam = "verifyGstin";
|
||||
public static final String panVerifiedParam = "panVerified";
|
||||
public static final String aadhaarVerifiedParam = "aadhaarVerified";
|
||||
public static final String gstinVerifiedParam = "gstinVerified";
|
||||
|
||||
// ── Aadhaar OTP flow parameters ───────────────────────────────────────────
|
||||
/** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */
|
||||
@@ -50,6 +53,22 @@ public final class ClientKycApiConstants {
|
||||
|
||||
// ── Extended KYC fields ──────────────────────────────────────────────────
|
||||
public static final String panAadhaarSeedingStatusParam = "panAadhaarSeedingStatus";
|
||||
|
||||
// ── PAN declaration (accepted when the PAN check cannot pass) ─────────────
|
||||
public static final String panDeclarationTakenParam = "panDeclarationTaken";
|
||||
public static final String panDeclarationMismatchFieldsParam = "panDeclarationMismatchFields";
|
||||
public static final String panDeclarationNameOnPanParam = "panDeclarationNameOnPan";
|
||||
public static final String panDeclarationDobOnPanParam = "panDeclarationDobOnPan";
|
||||
public static final String panDeclarationRemarksParam = "panDeclarationRemarks";
|
||||
public static final String panDeclarationTakenOnParam = "panDeclarationTakenOn";
|
||||
|
||||
// ── Aadhaar enrolment receipt (accepted when Aadhaar is not yet issued) ───
|
||||
public static final String aadhaarReceiptTakenParam = "aadhaarReceiptTaken";
|
||||
public static final String aadhaarEnrolmentIdParam = "aadhaarEnrolmentId";
|
||||
public static final String aadhaarEnrolmentDateParam = "aadhaarEnrolmentDate";
|
||||
public static final String aadhaarReceiptRemarksParam = "aadhaarReceiptRemarks";
|
||||
public static final String aadhaarReceiptTakenOnParam = "aadhaarReceiptTakenOn";
|
||||
|
||||
public static final String enterprisePanParam = "enterprisePan";
|
||||
public static final String cinNumberParam = "cinNumber";
|
||||
public static final String dinNumberParam = "dinNumber";
|
||||
@@ -69,11 +88,19 @@ public final class ClientKycApiConstants {
|
||||
public static final String accountStatusParam = "accountStatus";
|
||||
public static final String freezeReasonParam = "freezeReason";
|
||||
public static final String freezeAuthorityParam = "freezeAuthority";
|
||||
public static final String freezeDateParam = "freezeDate";
|
||||
public static final String freezeAmountParam = "freezeAmount";
|
||||
public static final String unfreezeDateParam = "unfreezeDate";
|
||||
public static final String noDebitDateParam = "noDebitDate";
|
||||
public static final String form60TypeParam = "form60Type";
|
||||
public static final String form60DateParam = "form60Date";
|
||||
public static final String form15ApplicableParam = "form15Applicable";
|
||||
public static final String form15TypeParam = "form15Type";
|
||||
public static final String form15FYParam = "form15FY";
|
||||
public static final String isExceptionParam = "isException";
|
||||
public static final String exceptionReasonParam = "exceptionReason";
|
||||
public static final String exceptionResolvedParam = "exceptionResolved";
|
||||
public static final String exceptionResolvedOnParam = "exceptionResolvedOn";
|
||||
|
||||
// ── Supported parameters for initiate/update ──────────────────────────────
|
||||
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
@@ -83,16 +110,30 @@ public final class ClientKycApiConstants {
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
|
||||
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
||||
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
|
||||
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
|
||||
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
|
||||
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
|
||||
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
|
||||
|
||||
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
riskCategoryParam, remarksParam, "locale", "dateFormat",
|
||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam));
|
||||
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
||||
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
|
||||
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
|
||||
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
|
||||
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
|
||||
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
|
||||
|
||||
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
|
||||
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
|
||||
+49
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -52,11 +53,29 @@ public final class ClientKycData {
|
||||
private final String panCategory;
|
||||
private final String panStatus;
|
||||
|
||||
// ── PAN declaration ───────────────────────────────────────────────────────
|
||||
/** Signed customer declaration accepted in place of a passing PAN check. */
|
||||
private final Boolean panDeclarationTaken;
|
||||
/** "NAME", "DOB", "NAME,DOB", or "NONE" when PAN could not be checked at all. */
|
||||
private final String panDeclarationMismatchFields;
|
||||
private final String panDeclarationNameOnPan;
|
||||
private final LocalDate panDeclarationDobOnPan;
|
||||
private final String panDeclarationRemarks;
|
||||
private final LocalDate panDeclarationTakenOn;
|
||||
|
||||
// ── Aadhaar ───────────────────────────────────────────────────────────────
|
||||
private final String aadhaarNumberMasked;
|
||||
private final Boolean aadhaarVerified;
|
||||
private final LocalDate aadhaarVerifiedOn;
|
||||
|
||||
// ── Aadhaar enrolment receipt ─────────────────────────────────────────────
|
||||
/** Enrolment slip accepted for a customer who has applied for Aadhaar but holds none yet. */
|
||||
private final Boolean aadhaarReceiptTaken;
|
||||
private final String aadhaarEnrolmentId;
|
||||
private final LocalDate aadhaarEnrolmentDate;
|
||||
private final String aadhaarReceiptRemarks;
|
||||
private final LocalDate aadhaarReceiptTakenOn;
|
||||
|
||||
// ── GSTIN ─────────────────────────────────────────────────────────────────
|
||||
private final String gstinNumber;
|
||||
private final Boolean gstinVerified;
|
||||
@@ -65,7 +84,14 @@ public final class ClientKycData {
|
||||
// ── Enterprise / Non-Individual ──────────────────────────────────────────
|
||||
private final String enterprisePan;
|
||||
private final String cinNumber;
|
||||
private final Boolean cinVerified;
|
||||
private final LocalDate cinVerifiedOn;
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
private final String kycDocumentName;
|
||||
@@ -84,6 +110,10 @@ public final class ClientKycData {
|
||||
private final String accountStatus;
|
||||
private final String freezeReason;
|
||||
private final String freezeAuthority;
|
||||
private final LocalDate freezeDate;
|
||||
private final BigDecimal freezeAmount;
|
||||
private final LocalDate unfreezeDate;
|
||||
private final LocalDate noDebitDate;
|
||||
|
||||
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||
private final String form60Type;
|
||||
@@ -97,10 +127,29 @@ public final class ClientKycData {
|
||||
private final LocalDate ckycUploadedOn;
|
||||
private final String ckycBatchId;
|
||||
|
||||
// ── Exception management ──────────────────────────────────────────────────
|
||||
private final Boolean isException;
|
||||
private final String exceptionReason;
|
||||
private final Boolean exceptionResolved;
|
||||
private final LocalDate exceptionResolvedOn;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
private final LocalDate initiatedOn;
|
||||
private final LocalDate verifiedOn;
|
||||
private final LocalDate rejectedOn;
|
||||
private final LocalDate expiryDate;
|
||||
private final String remarks;
|
||||
|
||||
private final String clientAccountNo;
|
||||
private final String clientOfficeName;
|
||||
|
||||
// ── Customer contact (for KYC outreach) ──────────────────────────────────
|
||||
// Masked only. The exceptions screen shows these so the officer knows who they are reaching,
|
||||
// but the full values never leave the server — the send path resolves them from m_client
|
||||
// itself, so a tampered browser cannot redirect a KYC notice to another address.
|
||||
private final String clientMobileMasked;
|
||||
private final String clientEmailMasked;
|
||||
/** Whether a destination exists at all, so the UI can grey out a channel that cannot be used. */
|
||||
private final Boolean hasMobile;
|
||||
private final Boolean hasEmail;
|
||||
}
|
||||
|
||||
+52
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -51,6 +52,45 @@ public class ClientKycRequest {
|
||||
@Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED")
|
||||
private String panAadhaarSeedingStatus;
|
||||
|
||||
@Schema(description = "True when a signed customer declaration has been accepted in place of a passing "
|
||||
+ "PAN check. Suppresses the automatic 'PAN Card Verification Failed' KYC exception.", example = "true")
|
||||
private Boolean panDeclarationTaken;
|
||||
|
||||
@Schema(description = "Which client details differ from the PAN card: NAME, DOB, NAME,DOB, or NONE when "
|
||||
+ "PAN could not be checked at all", example = "NAME")
|
||||
private String panDeclarationMismatchFields;
|
||||
|
||||
@Schema(description = "Name exactly as printed on the physical PAN card", example = "RAJESH KUMAR SHARMA")
|
||||
private String panDeclarationNameOnPan;
|
||||
|
||||
@Schema(description = "Date of birth exactly as printed on the physical PAN card", example = "12 May 1988")
|
||||
private String panDeclarationDobOnPan;
|
||||
|
||||
@Schema(description = "Officer's note on why the mismatch exists and what proof was sighted",
|
||||
example = "Client's PAN carries expanded middle name; passport sighted.")
|
||||
private String panDeclarationRemarks;
|
||||
|
||||
@Schema(description = "Date the declaration was accepted", example = "07 August 2026")
|
||||
private String panDeclarationTakenOn;
|
||||
|
||||
@Schema(description = "True when the customer's Aadhaar enrolment slip has been sighted and recorded, for a "
|
||||
+ "customer who has applied for Aadhaar but does not hold one yet. Suppresses the automatic "
|
||||
+ "'Aadhaar Verification Failed' KYC exception.", example = "true")
|
||||
private Boolean aadhaarReceiptTaken;
|
||||
|
||||
@Schema(description = "28-digit Enrolment ID (EID) printed on the UIDAI enrolment slip",
|
||||
example = "1234/12345/12345 12/05/2026 11:30:15")
|
||||
private String aadhaarEnrolmentId;
|
||||
|
||||
@Schema(description = "Enrolment date printed on the slip", example = "12 May 2026")
|
||||
private String aadhaarEnrolmentDate;
|
||||
|
||||
@Schema(description = "Officer's note on what was sighted", example = "Original UIDAI enrolment slip sighted.")
|
||||
private String aadhaarReceiptRemarks;
|
||||
|
||||
@Schema(description = "Date the receipt was accepted", example = "07 August 2026")
|
||||
private String aadhaarReceiptTakenOn;
|
||||
|
||||
@Schema(description = "Enterprise PAN (for non-individual clients)", example = "ABCDE1234F")
|
||||
private String enterprisePan;
|
||||
|
||||
@@ -96,6 +136,18 @@ public class ClientKycRequest {
|
||||
@Schema(description = "Authority that ordered the freeze (null if not frozen)")
|
||||
private String freezeAuthority;
|
||||
|
||||
@Schema(description = "Date of account freeze (null if not frozen)")
|
||||
private String freezeDate;
|
||||
|
||||
@Schema(description = "Amount frozen (null if not frozen)")
|
||||
private BigDecimal freezeAmount;
|
||||
|
||||
@Schema(description = "Date the account was unfrozen (null unless accountStatus is UNFREEZE)")
|
||||
private String unfreezeDate;
|
||||
|
||||
@Schema(description = "Date the no-debit restriction takes effect (null unless accountStatus is NO_DEBIT)")
|
||||
private String noDebitDate;
|
||||
|
||||
@Schema(description = "Form 60 type (FORM_60 for clients without PAN)", example = "FORM_60")
|
||||
private String form60Type;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user