Compare commits
56
Commits
uate
..
a04b65583e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04b65583e | ||
|
|
f6686b0c8f | ||
|
|
45c2826a42 | ||
|
|
c86e0d1cc2 | ||
|
|
c052beeacf | ||
|
|
942bad6c8c | ||
|
|
5a9a103832 | ||
|
|
fd91da3d76 | ||
|
|
c4a99fea5d | ||
|
|
dc0539d53d | ||
|
|
509370eaeb | ||
|
|
1130a482a0 | ||
|
|
8ecab08d57 | ||
|
|
29677e610c | ||
|
|
6ffb0dd9c0 | ||
|
|
8d224a22b4 | ||
|
|
f746a1fb2c | ||
|
|
1bc9d0901d | ||
|
|
08519da8b1 | ||
|
|
58187c23d0 | ||
|
|
179fe3a6ce | ||
|
|
72221f704d | ||
|
|
a42a874811 | ||
|
|
cad7f54c47 | ||
|
|
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;
|
||||
|
||||
+20
-1
@@ -137,7 +137,7 @@ public class CommandSourceService {
|
||||
String permission = commandSource.getPermissionCode();
|
||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||
if (isApprovedByChecker || user.isCheckerSuperUser()) {
|
||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||
commandSource.markAsChecked(user);
|
||||
} else {
|
||||
if (commandSource.isSanitized()) {
|
||||
@@ -151,6 +151,25 @@ public class CommandSourceService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||
if (!user.isCheckerSuperUser()) {
|
||||
return false;
|
||||
}
|
||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||
return;
|
||||
|
||||
+25
@@ -2211,6 +2211,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createCbsBaseInterestRate() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "CBSBASEINTERESTRATE";
|
||||
this.entityId = null;
|
||||
this.href = "/fixeddepositproducts/baseinterestrates";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||
@@ -2897,6 +2905,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";
|
||||
@@ -3103,6 +3120,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateFacilityChargeMapping(final Long mappingId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "FACILITYCHARGEMAPPING";
|
||||
this.entityId = mappingId;
|
||||
this.href = "/facilitychargemappings/" + mappingId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
||||
|
||||
+2
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
|
||||
|
||||
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
||||
|
||||
boolean isMakerCheckerEnabled();
|
||||
|
||||
/**
|
||||
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
||||
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
+10
@@ -162,6 +162,15 @@ public class ClientApiConstants {
|
||||
public static final String ageDaysParamName = "ageDays";
|
||||
public static final String nationalityIdParamName = "nationalityId";
|
||||
public static final String isMinorParamName = "isMinor";
|
||||
/**
|
||||
* Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD
|
||||
* document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the
|
||||
* KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult
|
||||
* list (with mandatory PAN) for a client the UI classifies as a minor.
|
||||
*/
|
||||
public static final int MINOR_AGE_THRESHOLD = 10;
|
||||
/** At or above this age a client qualifies for senior-citizen deposit rates. */
|
||||
public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60;
|
||||
public static final String guardianClientIdParamName = "guardianClientId";
|
||||
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
||||
public static final String guardianRelationParamName = "guardianRelation";
|
||||
@@ -191,6 +200,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();
|
||||
|
||||
+5
@@ -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";
|
||||
@@ -588,6 +589,10 @@ public final class DepositsApiConstants {
|
||||
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
||||
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
||||
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
||||
// FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne)
|
||||
fixedDepositResponseData.add("projectedInterestSchedule");
|
||||
fixedDepositResponseData.add("totalProjectedInterest");
|
||||
fixedDepositResponseData.add("totalValueAtMaturity");
|
||||
return fixedDepositResponseData;
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -92,6 +92,9 @@ public class SavingsApiConstants {
|
||||
public static final String statusParamName = "status";
|
||||
public static final String subStatusParamName = "subStatus";
|
||||
public static final String reasonForBlockParamName = "reasonForBlock";
|
||||
/** "CASH" (default, a manual/staff-placed lien) or "CLEARING" (money still moving through the
|
||||
* clearing process — a not-yet-realized cheque/DD deposit, or a presented cheque not yet settled). */
|
||||
public static final String holdCategoryParamName = "holdCategory";
|
||||
public static final String clientIdParamName = "clientId";
|
||||
public static final String isRetailAccountParamName = "isRetailAccount";
|
||||
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
||||
@@ -226,6 +229,7 @@ public class SavingsApiConstants {
|
||||
public static final String endDateParamName = "endDate";
|
||||
public static final String introducerRequiredParamName = "introducerRequired";
|
||||
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
||||
public static final String shareAccountIdParamName = "shareAccountId";
|
||||
public static final String nominationFacilityParamName = "nominationFacility";
|
||||
public static final String dormancyAlertParamName = "dormancyAlert";
|
||||
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
||||
|
||||
+17
@@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable {
|
||||
private LocalDate endDate;
|
||||
private Boolean introducerRequired;
|
||||
private Boolean shareLinkingEnabled;
|
||||
private Long shareAccountId;
|
||||
private String shareAccountNo;
|
||||
private Boolean nominationFacility;
|
||||
private Boolean dormancyAlert;
|
||||
private Boolean isDormancyTrackingActiveFromProduct;
|
||||
@@ -217,6 +219,21 @@ public final class SavingsAccountData implements Serializable {
|
||||
private BigDecimal interestPosting;
|
||||
private BigDecimal overdraftPosting;
|
||||
|
||||
/**
|
||||
* True when this deposit pays its interest out to a linked savings account every period (FD Special).
|
||||
* Such interest never stays in the account, so it must never compound -- every period earns on the
|
||||
* principal alone. Transient because it is only read by the batch interest-posting engine.
|
||||
*/
|
||||
private transient boolean transferInterestToLinkedAccount;
|
||||
|
||||
public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) {
|
||||
this.transferInterestToLinkedAccount = transferInterestToLinkedAccount;
|
||||
}
|
||||
|
||||
public boolean isTransferInterestToLinkedAccount() {
|
||||
return this.transferInterestToLinkedAccount;
|
||||
}
|
||||
|
||||
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
||||
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
||||
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
||||
|
||||
+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;
|
||||
|
||||
+64
-1
@@ -24,6 +24,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -104,6 +105,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor
|
||||
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
|
||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.NonTransientDataAccessException;
|
||||
@@ -136,6 +139,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
||||
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
||||
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
||||
private final LoanTransactionRepository loanTransactionRepository;
|
||||
private final VoucherService voucherService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -232,14 +236,73 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
||||
|
||||
}
|
||||
|
||||
// Raise the voucher from the entries that were actually written, rather than from the
|
||||
// command: an accounting rule can supply a side the request never named, so reading
|
||||
// back by transaction id is the only view that matches what the ledger now holds.
|
||||
final String voucherNo = raiseJournalVoucher(transactionId);
|
||||
if (voucherNo != null) {
|
||||
changes.put("voucherNo", voucherNo);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
||||
.withTransactionId(transactionId).build();
|
||||
.withTransactionId(transactionId).with(changes).build();
|
||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||
final Throwable throwable = dve.getMostSpecificCause();
|
||||
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a manual journal in the voucher register and returns its number.
|
||||
*
|
||||
* The sides are read back from the entries just written rather than taken from the request,
|
||||
* because an accounting rule can contribute a debit or credit the caller never named; only the
|
||||
* persisted rows show what the ledger actually holds.
|
||||
*
|
||||
* A failure here does not fail the journal. The entries are already posted and correct, and
|
||||
* refusing them because the register could not be written would reject a sound accounting
|
||||
* transaction over its paperwork. The problem is logged and the journal stands without a
|
||||
* voucher, which reads as a gap in the register rather than as a missing ledger entry.
|
||||
*/
|
||||
private String raiseJournalVoucher(final String transactionId) {
|
||||
// A register that posts its ledger movement through this command raises its own voucher,
|
||||
// naming the member and the instrument. Raising a journal one beside it would put the same
|
||||
// money in the register twice.
|
||||
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final List<JournalEntry> entries = this.glJournalEntryRepository
|
||||
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
||||
if (CollectionUtils.isEmpty(entries)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
for (final JournalEntry entry : entries) {
|
||||
final GLAccount account = entry.getGlAccount();
|
||||
final Long accountId = account == null ? null : account.getId();
|
||||
final String name = account == null ? null : account.getName();
|
||||
final String code = account == null ? null : account.getGlCode();
|
||||
|
||||
final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount())
|
||||
: VoucherLine.creditGl(accountId, name, code, entry.getAmount());
|
||||
lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription()));
|
||||
}
|
||||
|
||||
// Debits first, so the voucher prints the way a slip is read.
|
||||
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
|
||||
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null,
|
||||
transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines);
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise a voucher for journal transaction {}", transactionId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
||||
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
||||
/**
|
||||
|
||||
+4
-2
@@ -52,6 +52,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration {
|
||||
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
||||
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
||||
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
||||
LoanTransactionRepository loanTransactionRepository) {
|
||||
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
|
||||
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
||||
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
||||
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
||||
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
||||
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
||||
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
|
||||
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
|
||||
voucherService);
|
||||
}
|
||||
}
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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 is available to fund a share dividend, for a given period.
|
||||
*
|
||||
* This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has
|
||||
* to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared
|
||||
* for another share product over the same period, since they all draw on the same balance.
|
||||
*
|
||||
* The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution
|
||||
* screen before any dividend is declared, so the balance this reads has already had them taken out; deducting them a
|
||||
* second time would hold back money that has already left the account.
|
||||
*
|
||||
* Held in one place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
|
||||
*/
|
||||
@Path("/v1/accounting/distributable-profit")
|
||||
@Component
|
||||
@Tag(name = "Distributable Profit", description = "Profit available to fund a share dividend, less dividends already declared against the same period.")
|
||||
@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_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 maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
|
||||
|
||||
// The statutory reserve and education fund are NOT deducted here. They are appropriated on
|
||||
// the profit-distribution screen before any dividend is declared, so the profit this reads
|
||||
// has already had them taken out — subtracting them again would hold back money that is no
|
||||
// longer in the account. A loss-making period funds nothing, so it collapses to zero rather
|
||||
// than a negative figure a caller could read as a credit.
|
||||
final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : 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("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 profit is
|
||||
* society-wide, so a second product's declaration competes for the same balance 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 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
@@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMakerCheckerEnabled() {
|
||||
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||
|
||||
+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";
|
||||
};
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -102,12 +102,54 @@ 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";
|
||||
|
||||
/**
|
||||
* Where the money for an additional-share tranche comes from — {@code CASH} or {@code SAVINGS}.
|
||||
*
|
||||
* Both routes leave the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
|
||||
* approving the tranche posts the same Dr Sundry / Cr Share Capital pair either way. They
|
||||
* differ only in how it got there: CASH means the cashier already took it at the counter
|
||||
* against a voucher, SAVINGS means the member's own savings account is debited on submit.
|
||||
*/
|
||||
String fundingsource_paramname = "fundingSource";
|
||||
|
||||
String FUNDING_SOURCE_CASH = "CASH";
|
||||
|
||||
String FUNDING_SOURCE_SAVINGS = "SAVINGS";
|
||||
|
||||
/**
|
||||
* The Share Sundry receipt funding a CASH tranche, from {@code m_share_advance_collection}.
|
||||
*
|
||||
* Recorded against the tranche so one voucher cannot be spent twice, and so the approver can
|
||||
* see which receipt the money they are releasing came in on.
|
||||
*/
|
||||
String advancecollectionid_paramname = "advanceCollectionId";
|
||||
|
||||
/**
|
||||
* The remaining vouchers funding a CASH tranche, when one receipt does not cover it.
|
||||
*
|
||||
* A member paying in instalments — ₹600 today, the ₹400 balance tomorrow — has several receipts
|
||||
* against one request. The first leads in {@link #advancecollectionid_paramname}; these are the
|
||||
* rest, and their combined total is what has to cover the shares.
|
||||
*/
|
||||
String additionalcollectionids_paramname = "additionalCollectionIds";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1040
File diff suppressed because it is too large
Load Diff
+139
-6
@@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -67,8 +70,13 @@ import com.google.common.base.Splitter;
|
||||
@Component
|
||||
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
|
||||
@RequiredArgsConstructor
|
||||
@lombok.extern.slf4j.Slf4j
|
||||
public class CashierDepositRequestApiResource {
|
||||
|
||||
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
|
||||
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
||||
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
@@ -161,7 +169,7 @@ public class CashierDepositRequestApiResource {
|
||||
// reject() will delete these records so the vault position is reversed.
|
||||
if (denominationData != null && !denominationData.isBlank()) {
|
||||
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
||||
currentUser.getId(), currentUser.getUsername());
|
||||
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
||||
@@ -394,6 +402,20 @@ public class CashierDepositRequestApiResource {
|
||||
txnId = isCurrentAccount
|
||||
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
if (!isCurrentAccount && txnId != null) {
|
||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
||||
// hit an edge case in the new hold-placement logic would fail entirely, where before
|
||||
// this feature existed it always succeeded. Catching it keeps the Clearing Hold strictly
|
||||
// best-effort: the deposit approval this whole endpoint exists for must never depend on
|
||||
// it, only benefit from it when it works.
|
||||
try {
|
||||
placeClearingHoldIfInstrumentDeposit(savingsAccountId, ptId, amount, txnDateStr);
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("Could not place a Clearing Hold for cashier deposit request {} (savings account {}): {}", id,
|
||||
savingsAccountId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Link voucher to the created savings txn and stamp voucher_no on it.
|
||||
@@ -406,6 +428,10 @@ public class CashierDepositRequestApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
// The sides of the slip, recorded now rather than at submission: which accounts the money
|
||||
// actually moved between is only settled once the request is approved and posted.
|
||||
addDepositVoucherLines(record, savingsAccountId, amount);
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||
);
|
||||
@@ -540,6 +566,62 @@ public class CashierDepositRequestApiResource {
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Records the two sides of an approved deposit on its voucher.
|
||||
*
|
||||
* A deposit is money arriving at the counter, so cash is debited and whatever receives it is
|
||||
* credited — a member's savings account, or a GL account when the deposit was raised against
|
||||
* one. The credited side is read from the request, because that is what the cashier actually
|
||||
* filled in and what the member signed for.
|
||||
*
|
||||
* A failure here does not fail the approval. The deposit is already posted and the member's
|
||||
* balance already raised; refusing that over its slip would take a correct transaction back for
|
||||
* a paperwork reason. The problem is logged, and the voucher keeps its number without sides.
|
||||
*/
|
||||
private void addDepositVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
|
||||
final String voucherNo = (String) record.get("voucher_no");
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final boolean isGl = "GL".equalsIgnoreCase((String) record.get("account_type"));
|
||||
final String accountNo = (String) record.get("account_no");
|
||||
|
||||
// Cash reaching the counter. For a GL deposit the contra is the cash/vault account the
|
||||
// request named; for a member deposit it is the cashier's own till, which the deposit
|
||||
// register does not carry a GL id for — the line still names it, without an id.
|
||||
final Object contraObj = record.get("contra_gl_id");
|
||||
final Long contraGlId = contraObj != null ? ((Number) contraObj).longValue() : null;
|
||||
final Map<String, Object> contra = glAccountForVoucher(contraGlId);
|
||||
final VoucherLine debit = VoucherLine.debitGl(contraGlId, (String) contra.getOrDefault("name", "Cash in Hand"),
|
||||
(String) contra.get("gl_code"), amount);
|
||||
|
||||
final VoucherLine credit;
|
||||
if (isGl) {
|
||||
final Object glObj = record.get("gl_account_id");
|
||||
final Long glAccountId = glObj != null ? ((Number) glObj).longValue() : null;
|
||||
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
|
||||
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
|
||||
} else {
|
||||
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
|
||||
}
|
||||
|
||||
voucherService.addLines(voucherNo, List.of(debit, credit));
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.error("Could not record voucher lines for deposit request voucher {}", voucherNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
||||
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return Map.of();
|
||||
}
|
||||
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
||||
@@ -586,6 +668,54 @@ public class CashierDepositRequestApiResource {
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
|
||||
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
|
||||
* until the instrument clears rather than let it sit withdrawable, so this places an automatic
|
||||
* Clearing Hold for the full deposited amount right after a Cheque/DD deposit posts. Cash and
|
||||
* electronic-transfer payment types (NEFT/RTGS/IMPS/UPI) are untouched -- cash is available on the
|
||||
* spot, and an electronic transfer has already settled by the time it's entered here.
|
||||
*/
|
||||
private void placeClearingHoldIfInstrumentDeposit(final Long savingsAccountId, final Object paymentTypeId, final BigDecimal amount,
|
||||
final String txnDateStr) {
|
||||
if (paymentTypeId == null) {
|
||||
return;
|
||||
}
|
||||
final Long ptId = ((Number) paymentTypeId).longValue();
|
||||
// m_payment_type's display name column is "value", not "name" (the mistake that broke this
|
||||
// in testing) -- name() collisions with other tables made this easy to get wrong once and not
|
||||
// notice until it actually ran against the real schema.
|
||||
final List<String> names = jdbcTemplate.query("SELECT value FROM m_payment_type WHERE id = ?",
|
||||
(rs, rowNum) -> rs.getString("value"), ptId);
|
||||
if (names.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final String name = names.get(0).toLowerCase();
|
||||
final boolean isInstrumentDeposit = name.contains("cheque") || name.contains("check") || name.equals("dd")
|
||||
|| name.contains("demand draft");
|
||||
if (!isInstrumentDeposit) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
payload.addProperty("reasonForBlock", "Pending clearing — " + names.get(0) + " deposit");
|
||||
payload.addProperty("holdCategory", "CLEARING");
|
||||
payload.addProperty("lienAllowed", false);
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.holdAmount(savingsAccountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
}
|
||||
|
||||
private String executeCurrentAccountDeposit(
|
||||
final Long currentAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
@@ -685,7 +815,10 @@ public class CashierDepositRequestApiResource {
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
// The deposit register raises its own voucher, naming the member and their account; the
|
||||
// journal command must not raise a second one over the same money.
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getTransactionId() != null ? result.getTransactionId() : null;
|
||||
}
|
||||
|
||||
@@ -699,7 +832,7 @@ public class CashierDepositRequestApiResource {
|
||||
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
||||
private void savePendingDenomination(final String denominationData,
|
||||
final Long requestId, final Long savingsAccountId, final String accountNo,
|
||||
final Long clientId, final Long cashierId, final String cashierUsername) {
|
||||
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
|
||||
final JsonObject denomMap;
|
||||
try {
|
||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||
@@ -729,12 +862,12 @@ public class CashierDepositRequestApiResource {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_savings_txn_denomination
|
||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
|
||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
||||
""",
|
||||
requestId, savingsAccountId, accountNo, clientId, denomId,
|
||||
value, count, BigDecimal.valueOf((long) value * count),
|
||||
cashierId, cashierUsername
|
||||
cashierId, cashierUsername, paymentTypeName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+274
-4
@@ -15,6 +15,7 @@ import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -30,8 +31,217 @@ public class CashReportApiResource {
|
||||
private final PlatformSecurityContext context;
|
||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final VaultPositionService vaultPositionService;
|
||||
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));
|
||||
|
||||
// Account closures — a full-balance payout, so the debit side. Its own register rather than
|
||||
// the withdrawal one: a closure carries no clerk-entered amount (the figure is whatever
|
||||
// close() computes at approval) and the cash handed over is the ROUNDED
|
||||
// cashier_disbursed_amount, which is what a cash book has to agree with, not the
|
||||
// paisa-exact closing_amount posted to the member's ledger.
|
||||
//
|
||||
// Only CLOSED rows, unlike the registers above: a closure becomes a cash-book event when the
|
||||
// cashier actually hands the notes over, and not before. A PENDING or APPROVED closure has
|
||||
// taken nothing out of the drawer yet, and a REJECTED one never will — listing those would
|
||||
// put rows with no cash behind them (a rejected closure has no amount at all) into the
|
||||
// teller's day book. And the day they belong to is the day the cash left, so this filters on
|
||||
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
|
||||
// step to reach.
|
||||
rows.addAll(jdbcTemplate.query("""
|
||||
SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no,
|
||||
'SAVINGS' AS account_type, r.client_name AS party,
|
||||
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
|
||||
r.payment_type_name, r.description, r.status,
|
||||
COALESCE(r.disbursed_by, 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_closure_request r
|
||||
""" + verifyJoin + """
|
||||
WHERE r.status = 'CLOSED'
|
||||
AND COALESCE(CAST(r.disbursed_at AS DATE), r.closure_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. The source registers don't
|
||||
// share one terminal status string: deposits and disbursements land on APPROVED, a clerk
|
||||
// withdrawal's finished state is WITHDRAWN, and an account closure's is CLOSED -- missing
|
||||
// any of them meant a completed transaction moved real money out yet never showed up in
|
||||
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
|
||||
// both states cannot double-count it; only 'CLOSED' is unique to that register.
|
||||
final String rowStatus = (String) row.get("status");
|
||||
if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) {
|
||||
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) {
|
||||
@@ -360,6 +570,30 @@ public class CashReportApiResource {
|
||||
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd
|
||||
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the
|
||||
* bank have", as opposed to cashier-position which answers the same question for a cashier.
|
||||
* See {@link VaultPositionService} for how the anchor is chosen.
|
||||
*/
|
||||
@GET
|
||||
@Path("vault-position")
|
||||
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
|
||||
@QueryParam("asOfDate") final String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
|
||||
if (tellerId == null) {
|
||||
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
|
||||
final List<Map<String, Object>> all = new ArrayList<>();
|
||||
for (final Long id : tellerIds) {
|
||||
all.add(vaultPositionService.buildPositionForVault(id, dateFilter));
|
||||
}
|
||||
return gson.toJson(all);
|
||||
}
|
||||
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
|
||||
* Returns denomination-wise cash received by the cashier from vault on the given date
|
||||
@@ -597,9 +831,44 @@ public class CashReportApiResource {
|
||||
baseParams = new Object[] { txnDate };
|
||||
}
|
||||
|
||||
// Base grouped query (reused for count, totals, and paged data)
|
||||
// Base grouped query (reused for count, totals, and paged data). The two LEFT JOINs resolve
|
||||
// the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/
|
||||
// withdrawal request at approval time -- std.txn_id on its own is only the internal savings
|
||||
// transaction id (e.g. "4"), never what was actually printed on the voucher.
|
||||
//
|
||||
// A GL-type deposit's own txn_id is a hex string (e.g. "a29ff54dc88f", from a journal-entry
|
||||
// reference) rather than a plain decimal savings-transaction id, but std.txn_id is always
|
||||
// stored as the DECIMAL parse of whatever id it was given -- so for a GL row it holds the
|
||||
// hex string's numeric value (178807899015311), not the hex string itself, and a plain
|
||||
// string-equality join against cdr.txn_id can never match it. The second half of each OR
|
||||
// below re-derives that same decimal value from the hex string to match it -- guarded to
|
||||
// only run on strings that are actually hex (so an ordinary decimal txn_id, which never
|
||||
// contains a-f, is never sent through the hex cast at all).
|
||||
final String baseGroupSql = "FROM m_savings_txn_denomination std "
|
||||
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
|
||||
// A GL-type row (no client_id) carries the GL account's own code in account_no
|
||||
// rather than a savings/current account number, so its "name" has to come from the
|
||||
// chart of accounts, not from a client that was never party to the transaction.
|
||||
+ "LEFT JOIN acc_gl_account gla ON std.client_id IS NULL AND gla.gl_code = std.account_no "
|
||||
// Scoped to std.txn_type: without it, a deposit's hex txn_id and a withdrawal's
|
||||
// decimal txn_id can coincidentally hex-decode to the exact same number (confirmed
|
||||
// live -- a2a0888f2bcf and a genuine withdrawal reference both resolved to
|
||||
// 178810369551311), and an unscoped join matched the withdrawal row to that unrelated
|
||||
// deposit's voucher, which COALESCE then preferred over the withdrawal's own real one.
|
||||
+ "LEFT JOIN m_cashier_deposit_request cdr ON std.txn_type = 'DEPOSIT' AND "
|
||||
+ " (cdr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
||||
+ " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' "
|
||||
+ " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
|
||||
+ "LEFT JOIN m_clerk_withdrawal_request cwr ON std.txn_type = 'WITHDRAWAL' AND "
|
||||
+ " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
||||
+ " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' "
|
||||
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
|
||||
// An account closure also writes its hand-over as a 'WITHDRAWAL' denomination row
|
||||
// (the column records which way cash moved), but its request lives in its own
|
||||
// register -- without this join the closure's real voucher number is never found and
|
||||
// the report falls back to showing the bare savings-transaction id instead.
|
||||
+ "LEFT JOIN m_clerk_closure_request ccr ON std.txn_type = 'WITHDRAWAL' AND "
|
||||
+ " ccr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
||||
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
||||
+ officerFilter
|
||||
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
|
||||
@@ -620,9 +889,10 @@ public class CashReportApiResource {
|
||||
|
||||
// 2. Paged data query
|
||||
final String dataSql = "SELECT "
|
||||
+ " std.txn_id AS voucher_no, "
|
||||
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
|
||||
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
|
||||
+ " std.account_no AS ac_no, "
|
||||
+ " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, "
|
||||
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
|
||||
+ " MAX(std.txn_date) AS txn_date, "
|
||||
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
||||
+ " MAX(std.officer_username) AS teller_name, "
|
||||
@@ -642,7 +912,7 @@ public class CashReportApiResource {
|
||||
|
||||
final List<Map<String, Object>> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("voucherNo", rs.getLong("voucher_no"));
|
||||
row.put("voucherNo", rs.getString("voucher_no"));
|
||||
row.put("acNo", rs.getString("ac_no"));
|
||||
row.put("acName", rs.getString("ac_name"));
|
||||
row.put("txnDate", rs.getString("txn_date"));
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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.cashreport.service;
|
||||
|
||||
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.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Denomination-wise cash a vault (teller) is currently holding.
|
||||
*
|
||||
* Anchored on the most recent business day (on or before the target date) whose branch day
|
||||
* register recorded a vault-only opening breakdown ({@code OPENING_VAULT}, tagged with this exact
|
||||
* teller), then walked forward by everything settled into this vault and handed out of it since.
|
||||
* Vault-only rows exist separately from the branch-total {@code OPENING}/{@code CLOSING} rows
|
||||
* (which mix the vault with whatever every cashier under it is holding) precisely so this anchor
|
||||
* never includes cash that still physically sits with a cashier — mixing the two double-counts
|
||||
* whatever a cashier settles back later, once already folded into the branch total, again as the
|
||||
* settlement itself. A day recorded before vault-only tracking existed has no {@code OPENING_VAULT}
|
||||
* row at all; the branch-total {@code OPENING} is used for it as a last resort so an old anchor
|
||||
* still returns something rather than zero.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class VaultPositionService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
|
||||
private static final RowMapper<Object[]> DENOM_ROW = (rs, r) -> new Object[] { rs.getLong("denomination_id"),
|
||||
rs.getInt("denomination_value"), rs.getString("kind"), rs.getLong("total_count") };
|
||||
|
||||
public Map<String, Object> buildPositionForVault(final Long tellerId, final String dateFilter) {
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("tellerId", tellerId);
|
||||
result.put("asOfDate", dateFilter);
|
||||
|
||||
String tellerName = null;
|
||||
Long officeId = null;
|
||||
try {
|
||||
final Map<String, Object> teller = jdbcTemplate.queryForMap("SELECT name, office_id FROM m_tellers WHERE id = ?",
|
||||
tellerId);
|
||||
tellerName = (String) teller.get("name");
|
||||
officeId = ((Number) teller.get("office_id")).longValue();
|
||||
} catch (final Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
result.put("tellerName", tellerName);
|
||||
|
||||
if (officeId == null) {
|
||||
result.put("hasBreakdown", false);
|
||||
result.put("totalAmount", BigDecimal.ZERO);
|
||||
result.put("denominations", new ArrayList<>());
|
||||
return result;
|
||||
}
|
||||
|
||||
// Prefer a vault-scoped anchor: it already reflects only this vault's own cash, carried
|
||||
// forward day to day, with no cashier's held cash folded in. Fall back to the old
|
||||
// branch-combined OPENING leg only when this office has no vault-scoped row at all on or
|
||||
// before the target date (a day closed before vault-only tracking existed).
|
||||
List<Map<String, Object>> anchorRows = jdbcTemplate.queryForList("""
|
||||
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
||||
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
|
||||
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
|
||||
WHERE d.register_id = r.id AND d.leg = 'OPENING_VAULT' AND d.teller_id = ?)
|
||||
ORDER BY r.business_date DESC LIMIT 1
|
||||
""", officeId, dateFilter, tellerId);
|
||||
String anchorLeg = "OPENING_VAULT";
|
||||
if (anchorRows.isEmpty()) {
|
||||
anchorLeg = "OPENING";
|
||||
anchorRows = jdbcTemplate.queryForList("""
|
||||
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
||||
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
|
||||
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
|
||||
WHERE d.register_id = r.id AND d.leg = 'OPENING')
|
||||
ORDER BY r.business_date DESC LIMIT 1
|
||||
""", officeId, dateFilter);
|
||||
}
|
||||
|
||||
if (anchorRows.isEmpty()) {
|
||||
result.put("hasBreakdown", false);
|
||||
result.put("totalAmount", BigDecimal.ZERO);
|
||||
result.put("denominations", new ArrayList<>());
|
||||
return result;
|
||||
}
|
||||
|
||||
final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue();
|
||||
final String anchorDate = anchorRows.get(0).get("business_date").toString();
|
||||
|
||||
final Map<Long, long[]> balance = new LinkedHashMap<>();
|
||||
|
||||
addToBalance(balance, jdbcTemplate.query("""
|
||||
SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count
|
||||
FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id
|
||||
WHERE d.register_id = ? AND d.leg = ? AND (d.teller_id IS NULL OR d.teller_id = ?)
|
||||
""", DENOM_ROW, registerId, anchorLeg, tellerId), 1);
|
||||
|
||||
addToBalance(balance, jdbcTemplate.query("""
|
||||
SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count
|
||||
FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
|
||||
LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id
|
||||
WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false)
|
||||
AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE)
|
||||
GROUP BY csd.denomination_id, csd.denomination_value, dm.kind
|
||||
""", DENOM_ROW, tellerId, anchorDate, dateFilter), 1);
|
||||
|
||||
addToBalance(balance, jdbcTemplate.query("""
|
||||
SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count
|
||||
FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
|
||||
LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id
|
||||
WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
|
||||
AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE)
|
||||
GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind
|
||||
""", DENOM_ROW, tellerId, anchorDate, dateFilter), -1);
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
balance.entrySet().stream().sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])).forEach(e -> {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("denominationId", e.getKey());
|
||||
row.put("denominationValue", (int) e.getValue()[0]);
|
||||
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
|
||||
row.put("count", e.getValue()[2]);
|
||||
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
|
||||
rows.add(row);
|
||||
});
|
||||
|
||||
final BigDecimal totalAmount = rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO,
|
||||
BigDecimal::add);
|
||||
|
||||
result.put("hasBreakdown", true);
|
||||
result.put("anchorDate", anchorDate);
|
||||
result.put("totalAmount", totalAmount);
|
||||
result.put("denominations", rows);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void addToBalance(final Map<Long, long[]> balance, final List<Object[]> rows, final int sign) {
|
||||
for (final Object[] row : rows) {
|
||||
final Long denomId = (Long) row[0];
|
||||
final int denomValue = (Integer) row[1];
|
||||
final long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
|
||||
final long count = (Long) row[3];
|
||||
final long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
|
||||
entry[2] += count * sign;
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -98,6 +98,8 @@ public class CashTransferApiResource {
|
||||
"At least one denomination with a positive count is required.");
|
||||
}
|
||||
|
||||
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
|
||||
|
||||
CashTransfer header = new CashTransfer();
|
||||
header.setTransferDate(transferDate);
|
||||
header.setFromParty(fromParty);
|
||||
@@ -264,6 +266,127 @@ public class CashTransferApiResource {
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* A vault can only hand out cash it is actually holding — in total, and note-by-note. The
|
||||
* branch's business day carries an opening_cash figure forward each day (see
|
||||
* BranchDayRegisterApiResource#lastClosingCash), so the vault's available balance for "today" is
|
||||
* that opening figure, plus anything a cashier has already settled back into this same vault
|
||||
* today, minus whatever this vault has already handed out to any cashier today. Reversed
|
||||
* transfers/settlements don't count.
|
||||
*
|
||||
* No open business day for the vault's office means there is no opening_cash to check against, so
|
||||
* the transfer is blocked outright rather than silently allowed against an unknown balance.
|
||||
*/
|
||||
private void validateVaultHasSufficientCash(final Long fromTellerId, final BigDecimal requestedAmount,
|
||||
final JsonArray denominations, final LocalDate transferDate) {
|
||||
final Long officeId;
|
||||
try {
|
||||
officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_tellers WHERE id = ?", Long.class, fromTellerId);
|
||||
} catch (Exception e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.teller.not.found",
|
||||
"Vault/teller " + fromTellerId + " was not found.");
|
||||
}
|
||||
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.query("""
|
||||
SELECT id FROM m_branch_day_register
|
||||
WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
|
||||
""", (rs, n) -> rs.getLong(1), officeId, sqlBusinessDate);
|
||||
if (registerIds.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.branchday.not.open",
|
||||
"The business day for this branch is not open for " + businessDate
|
||||
+ ". Open the business day before allocating cash to a cashier.");
|
||||
}
|
||||
final Long registerId = registerIds.get(0);
|
||||
|
||||
final BigDecimal openingCash = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(opening_cash, 0) FROM m_branch_day_register WHERE id = ?", BigDecimal.class, registerId);
|
||||
|
||||
final BigDecimal handedOutToday = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
|
||||
WHERE from_teller_id = ? AND transfer_date = ? AND COALESCE(is_reversed, false) = false
|
||||
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
|
||||
|
||||
final BigDecimal settledBackToday = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
|
||||
WHERE to_teller_id = ? AND settlement_date = ? AND COALESCE(is_reversed, false) = false
|
||||
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
|
||||
|
||||
final BigDecimal available = openingCash.add(settledBackToday).subtract(handedOutToday);
|
||||
if (requestedAmount.compareTo(available) > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.cash",
|
||||
"Insufficient vault cash: available " + available + ", requested " + requestedAmount + ".");
|
||||
}
|
||||
|
||||
validateVaultHasSufficientDenominations(registerId, fromTellerId, denominations, sqlBusinessDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* The total-amount check above can pass while a specific note/coin still runs short — a vault
|
||||
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
|
||||
* total is well within its balance. This checks each requested denomination against what the
|
||||
* vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's
|
||||
* settlements-in minus today's transfers-out actually holds of that specific denomination.
|
||||
*
|
||||
* A day opened with a plain lump sum and no breakdown recorded has nothing to check a
|
||||
* denomination against, so this is skipped rather than blocking every transfer on a day that
|
||||
* simply never captured one.
|
||||
*/
|
||||
private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
|
||||
final JsonArray denominations, final java.sql.Date businessDate) {
|
||||
final List<Object[]> openingRows = jdbcTemplate.query("""
|
||||
SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail
|
||||
WHERE register_id = ? AND leg = 'OPENING'
|
||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId);
|
||||
if (openingRows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<Long, Integer> available = new java.util.HashMap<>();
|
||||
for (final Object[] row : openingRows) {
|
||||
available.merge((Long) row[0], (Integer) row[2], Integer::sum);
|
||||
}
|
||||
|
||||
final List<Object[]> settledRows = jdbcTemplate.query("""
|
||||
SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd
|
||||
JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
|
||||
WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
|
||||
GROUP BY csd.denomination_id
|
||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
|
||||
for (final Object[] row : settledRows) {
|
||||
available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum);
|
||||
}
|
||||
|
||||
final List<Object[]> handedOutRows = jdbcTemplate.query("""
|
||||
SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd
|
||||
JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
|
||||
WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false
|
||||
GROUP BY ctd.denomination_id
|
||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
|
||||
for (final Object[] row : handedOutRows) {
|
||||
available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum);
|
||||
}
|
||||
|
||||
for (final JsonElement el : denominations) {
|
||||
final JsonObject item = el.getAsJsonObject();
|
||||
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
||||
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
|
||||
continue;
|
||||
}
|
||||
final long denominationId = item.get("denominationId").getAsLong();
|
||||
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
||||
? item.get("denominationValue").getAsInt() : 0;
|
||||
final int availableCount = available.getOrDefault(denominationId, 0);
|
||||
if (count > availableCount) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.denomination",
|
||||
"Insufficient stock of denomination " + denominationValue + ": vault has " + availableCount
|
||||
+ ", requested " + count + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasPositiveCount(JsonArray items) {
|
||||
for (JsonElement el : items) {
|
||||
JsonObject item = el.getAsJsonObject();
|
||||
|
||||
+883
@@ -0,0 +1,883 @@
|
||||
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;
|
||||
|
||||
|
||||
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/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, @QueryParam("glAccountNo") final String glAccountNoOverride) {
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
|
||||
"A voucher or receipt number is required.");
|
||||
}
|
||||
return findByVoucher(voucherNo, clientIdHint, glAccountNoOverride);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/by-voucher/{voucherNo}")
|
||||
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint,
|
||||
@QueryParam("glAccountNo") final String glAccountNoOverride) {
|
||||
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 GL
|
||||
// account the counter deposited it to, which lives in m_cashier_deposit_request. Adopt
|
||||
// that approved deposit as a collection so the member's voucher resolves here. Which GL
|
||||
// account to look in is never guessed from product configuration — the counter selects it
|
||||
// by hand every time, since a product mapping can silently go missing or point at the
|
||||
// wrong account and a plain voucher lookup should never depend on that being right.
|
||||
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
|
||||
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 + " in GL account " + glAccountNoOverride + ". Check the voucher number, the GL account, and 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();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — GL accounts selectable for a by-voucher lookup
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
|
||||
*
|
||||
* Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
|
||||
* point at today — that mapping is exactly what this lookup no longer trusts, since it can go
|
||||
* missing or wrong without anyone noticing until a voucher search fails at the counter. The person
|
||||
* applying a receipt picks the account the deposit actually went to, every time.
|
||||
*/
|
||||
@GET
|
||||
@Path("/glaccounts")
|
||||
public Response glAccounts() {
|
||||
this.context.authenticatedUser();
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("glAccounts", rows);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — What a share account needs before a collection can be applied
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Share value plus any outstanding entry fee for one share account -- shown up front on the Apply
|
||||
* Collection screen so the person at the counter knows the full amount to collect (and that it may
|
||||
* come in as more than one receipt, e.g. one for the share value and a separate one for the fee)
|
||||
* before a search ever comes up short, rather than learning it only from an error after the fact.
|
||||
*/
|
||||
@GET
|
||||
@Path("/requirement")
|
||||
public Response requirement(@QueryParam("shareAccountId") final Long shareAccountId) {
|
||||
this.context.authenticatedUser();
|
||||
if (shareAccountId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
|
||||
}
|
||||
final Map<String, Object> acct = loadAccount(shareAccountId);
|
||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("accountNo", acct.get("account_no"));
|
||||
out.put("shareAmount", shareAmount);
|
||||
out.put("chargeAmount", chargeAmount);
|
||||
out.put("required", shareAmount.add(chargeAmount));
|
||||
return Response.ok(gson.toJson(out)).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. These are the SHARE-side receipts only -- money that actually sat in Sundry and
|
||||
// genuinely needs to move out of it into Share Capital.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
|
||||
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
|
||||
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
|
||||
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
|
||||
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
|
||||
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
|
||||
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Map<String, Object> chargeColl = null;
|
||||
if (chargeCollectionId != null) {
|
||||
chargeColl = loadCollection(chargeCollectionId);
|
||||
if (!"UNAPPLIED".equals(chargeColl.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
|
||||
"Entry fee receipt " + chargeColl.get("receipt_no") + " is already " + chargeColl.get("status") + ".");
|
||||
}
|
||||
}
|
||||
|
||||
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") + ".");
|
||||
}
|
||||
}
|
||||
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
|
||||
+ chargeColl.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);
|
||||
|
||||
// When the fee is being verified as its own receipt, the Sundry-side receipts only need to
|
||||
// cover the share value -- the fee is proved paid separately below, and its money is never
|
||||
// expected to be part of what came out of Sundry. Without a separate fee receipt, the fee is
|
||||
// assumed bundled into the same Sundry receipt(s) as the share value, exactly as before.
|
||||
final BigDecimal requiredForShareReceipts = chargeColl != null ? shareAmount : 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(requiredForShareReceipts) < 0) {
|
||||
final BigDecimal shortfall = requiredForShareReceipts.subtract(receiptAmount);
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
|
||||
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
|
||||
+ requiredForShareReceipts + ". Collect the remaining " + shortfall
|
||||
+ " from the member, then apply both receipts together.");
|
||||
}
|
||||
|
||||
// The fee receipt, when given, must cover the fee on its own -- checked separately from the
|
||||
// share-side shortfall above, since the two are no longer one combined pot.
|
||||
if (chargeColl != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
final BigDecimal chargeReceiptAmount = (BigDecimal) chargeColl.get("amount");
|
||||
if (chargeReceiptAmount.compareTo(chargeAmount) < 0) {
|
||||
final BigDecimal shortfall = chargeAmount.subtract(chargeReceiptAmount);
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.charge.amount.short",
|
||||
"Entry fee receipt " + chargeColl.get("receipt_no") + " totals " + chargeReceiptAmount + " but share account "
|
||||
+ acct.get("account_no") + " requires " + chargeAmount + " for the entry fee. Collect the remaining "
|
||||
+ shortfall + " from the member.");
|
||||
}
|
||||
}
|
||||
|
||||
// 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. Only the share-side
|
||||
// excess is handled here -- an over-payment on a separately-verified fee receipt would need
|
||||
// its own GL leg to fund the savings credit, which this flow does not post (that receipt's
|
||||
// money is left exactly where the cashier already put it), so it is left uncollected rather
|
||||
// than silently unbalancing the ledger.
|
||||
final BigDecimal excessAmount = receiptAmount.subtract(requiredForShareReceipts);
|
||||
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 " + requiredForShareReceipts + ". 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 the share-side receipts, including any over-payment on them — the liability
|
||||
// those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
|
||||
// it was never in Sundry, so nothing here should debit Sundry for it.
|
||||
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, but only when it was NOT verified as its own receipt
|
||||
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
|
||||
// income account itself), so moving it again here would double it. When it was bundled into
|
||||
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
|
||||
// to move, exactly as before.
|
||||
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
if (chargeColl == null) {
|
||||
// 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.
|
||||
// Matched on voucher_no alone -- voucher numbers are unique across the whole cashier deposit
|
||||
// table regardless of which GL account they were posted to, so no GL account lookup is needed
|
||||
// here either. Covers the fee receipt's own deposit too, when one was verified.
|
||||
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 share_account_id IS NULL",
|
||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"));
|
||||
}
|
||||
}
|
||||
if (chargeColl != null && chargeColl.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 share_account_id IS NULL",
|
||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeColl.get("voucher_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. The fee
|
||||
// receipt is marked applied too, even though it posted no GL entry of its own.
|
||||
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);
|
||||
}
|
||||
if (chargeCollectionId != null) {
|
||||
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, chargeCollectionId);
|
||||
}
|
||||
|
||||
// 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("chargeReceiptVerifiedSeparately", chargeColl != null);
|
||||
out.put("totalApplied", requiredForShareReceipts);
|
||||
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.
|
||||
*
|
||||
* @param glAccountNoOverride
|
||||
* required -- the GL account number the deposit was posted to, selected by the counter
|
||||
* rather than resolved from any product's accounting configuration, so a broken or
|
||||
* missing product mapping can never make a genuine voucher unreachable.
|
||||
* @return the newly created collection row, or null when no approved deposit matches.
|
||||
*/
|
||||
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
|
||||
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
|
||||
"Select the GL account this voucher was deposited to, then search again.");
|
||||
}
|
||||
final String shareSundryAccountNo = glAccountNoOverride.trim();
|
||||
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, shareSundryAccountNo);
|
||||
|
||||
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,
|
||||
|
||||
+55
-1
@@ -35,6 +35,9 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.charge.domain.Charge;
|
||||
@@ -89,6 +92,12 @@ public class ChequeBookIssuanceChargeService {
|
||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
||||
private final SavingsHelper savingsHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final VoucherService voucherService;
|
||||
|
||||
/** A cheque book issuance charge, keyed by its m_cheque_book id. */
|
||||
private static final String REF_CHEQUEBOOK_CHARGE = "CHEQUEBOOK_CHARGE";
|
||||
|
||||
|
||||
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
|
||||
private FacilityChargeMapping resolveMapping() {
|
||||
@@ -244,6 +253,7 @@ public class ChequeBookIssuanceChargeService {
|
||||
|
||||
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
||||
accountId);
|
||||
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
||||
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
||||
chequeBookId);
|
||||
}
|
||||
@@ -278,6 +288,47 @@ public class ChequeBookIssuanceChargeService {
|
||||
this.currentAccountRepository.saveAndFlush(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a cheque book issuance charge in the voucher register.
|
||||
*
|
||||
* The account is debited and the charge's income account credited — the society's fee, taken
|
||||
* from the customer. The debit names the customer and their account number even though it posts
|
||||
* against the product's control GL, so the slip reads as the customer's own.
|
||||
*
|
||||
* A failure here does not fail the charge. The money is already taken and the ledger already
|
||||
* posted; reversing that over a slip would undo a correct deduction for a paperwork reason. The
|
||||
* problem is logged and the charge stands.
|
||||
*/
|
||||
private void raiseChargeVoucher(final ChequeAccountType accountType, final Long accountId, final Long ownAccountGlId,
|
||||
final Long incomeGlId, final BigDecimal chargeAmount, final Long chequeBookId) {
|
||||
try {
|
||||
final Map<String, Object> holder = accountHolderForVoucher(accountType, accountId);
|
||||
final Map<String, Object> incomeGl = this.voucherService.glAccount(incomeGlId);
|
||||
final String narration = "Cheque book issuance charge - book #" + chequeBookId;
|
||||
|
||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, REF_CHEQUEBOOK_CHARGE, chequeBookId,
|
||||
this.context.authenticatedUser(), VoucherService.PREFIX_DEBIT,
|
||||
List.of(VoucherLine
|
||||
.debitGl(ownAccountGlId, (String) holder.get("party"), (String) holder.get("account_no"), chargeAmount)
|
||||
.withDescription(narration),
|
||||
VoucherLine
|
||||
.creditGl(incomeGlId, (String) incomeGl.get("name"), (String) incomeGl.get("gl_code"), chargeAmount)
|
||||
.withDescription(narration)));
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise a voucher for cheque book {} issuance charge", chequeBookId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The customer and account number a charge is taken from, for printing on a voucher. */
|
||||
private Map<String, Object> accountHolderForVoucher(final ChequeAccountType accountType, final Long accountId) {
|
||||
final String sql = accountType == ChequeAccountType.SAVINGS
|
||||
? "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
|
||||
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?"
|
||||
: "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
|
||||
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
|
||||
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
||||
}
|
||||
|
||||
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
|
||||
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
|
||||
@@ -320,7 +371,10 @@ public class ChequeBookIssuanceChargeService {
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
// The charge raises its own voucher, naming the account charged and the fee; the journal
|
||||
// command must not raise a second one over the same money.
|
||||
final CommandProcessingResult result = this.voucherService
|
||||
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
if (result == null) {
|
||||
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
||||
return;
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.clearing.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import 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.security.service.PlatformSecurityContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Admin screen backing API for {@code m_clearing_gl_config}: which GL account backs each clearing
|
||||
* role (CTS inward/outward suspense, NACH suspense, RTGS settlement, ...). The rows themselves are
|
||||
* fixed — created by earlier migrations — so, like Facility Charge Mappings, this only ever reads
|
||||
* them and updates which GL account one points at.
|
||||
*/
|
||||
@Path("/v1/clearingglconfig")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ClearingGlConfigApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
public String retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT c.id, c.config_key, c.description, c.gl_account_id, a.name AS gl_account_name, a.gl_code
|
||||
FROM m_clearing_gl_config c LEFT JOIN acc_gl_account a ON a.id = c.gl_account_id
|
||||
ORDER BY c.config_key
|
||||
""");
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
out.add(toResult(row));
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("template")
|
||||
public String template() {
|
||||
this.context.authenticatedUser().validateHasReadPermission("CLEARINGGLCONFIG");
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT id, name, gl_code FROM acc_gl_account
|
||||
WHERE disabled = false AND manual_journal_entries_allowed = true
|
||||
ORDER BY name
|
||||
""");
|
||||
final List<Map<String, Object>> options = new ArrayList<>();
|
||||
for (final Map<String, Object> a : accounts) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", a.get("id"));
|
||||
o.put("name", a.get("name"));
|
||||
o.put("glCode", a.get("gl_code"));
|
||||
options.add(o);
|
||||
}
|
||||
return gson.toJson(Map.of("glAccountOptions", options));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{configKey}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String update(@PathParam("configKey") final String configKey, final String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_CLEARINGGLCONFIG");
|
||||
|
||||
final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clearing_gl_config WHERE config_key = ?",
|
||||
Integer.class, configKey);
|
||||
if (exists == null || exists == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.not.found",
|
||||
"Clearing GL config key '" + configKey + "' was not found.");
|
||||
}
|
||||
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long glAccountId = this.fromJsonHelper.extractLongNamed("glAccountId", root);
|
||||
|
||||
if (glAccountId != null) {
|
||||
final Integer accountExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM acc_gl_account WHERE id = ?",
|
||||
Integer.class, glAccountId);
|
||||
if (accountExists == null || accountExists == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.clearingglconfig.gl.account.not.found",
|
||||
"GL account " + glAccountId + " was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
jdbcTemplate.update("UPDATE m_clearing_gl_config SET gl_account_id = ? WHERE config_key = ?", glAccountId,
|
||||
configKey);
|
||||
|
||||
return retrieveAll();
|
||||
}
|
||||
|
||||
private Map<String, Object> toResult(final Map<String, Object> row) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("id", row.get("id"));
|
||||
out.put("configKey", row.get("config_key"));
|
||||
out.put("description", row.get("description"));
|
||||
out.put("glAccountId", row.get("gl_account_id"));
|
||||
out.put("glAccountName", row.get("gl_account_name"));
|
||||
out.put("glCode", row.get("gl_code"));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+94
-1
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
@@ -32,6 +33,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
||||
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -49,6 +53,11 @@ public class ClearingJournalService {
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final VoucherService voucherService;
|
||||
|
||||
/** A clearing posting — a CTS item, a NACH movement, a settlement — keyed by its GL transaction id. */
|
||||
private static final String REF_CLEARING_POSTING = "CLEARING_POSTING";
|
||||
|
||||
/**
|
||||
* Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null
|
||||
@@ -57,6 +66,19 @@ public class ClearingJournalService {
|
||||
*/
|
||||
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) {
|
||||
return postJournalEntry(officeId, currencyCode, txnDate, debitGlId, creditGlId, amount, comments, linkAccountId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* As above, naming the kind of account {@code linkAccountId} refers to — SAVINGS or CURRENT.
|
||||
*
|
||||
* Savings and current accounts number independently, so the same id exists in both tables and the id
|
||||
* alone cannot say whose account it is. A caller that knows the type passes it and the voucher names
|
||||
* the right customer; one that has no account at all — a settlement summary — passes neither.
|
||||
*/
|
||||
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId,
|
||||
final String linkAccountType) {
|
||||
if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) {
|
||||
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
|
||||
creditGlId, amount);
|
||||
@@ -86,7 +108,10 @@ public class ClearingJournalService {
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
// Clearing raises its own voucher below, naming the customer account the item belongs to;
|
||||
// the journal command must not raise a second one over the same money.
|
||||
final CommandProcessingResult result = this.voucherService
|
||||
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
if (result == null) {
|
||||
log.warn("Clearing journal entry did not return a result ({}).", comments);
|
||||
return null;
|
||||
@@ -95,9 +120,77 @@ public class ClearingJournalService {
|
||||
if (glTxnId != null && linkAccountId != null) {
|
||||
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
||||
}
|
||||
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
|
||||
return glTxnId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one clearing posting in the voucher register.
|
||||
*
|
||||
* Raised here rather than in each caller because CTS items, NACH movements and settlement
|
||||
* summaries all post through this one path: a voucher per caller would be the same slip written
|
||||
* three times, and one of them would drift.
|
||||
*
|
||||
* The caller's comment carries what the posting is — the item, the movement, the batch — so it
|
||||
* becomes the narration on both sides. Where the caller named a customer account, the debit line
|
||||
* carries that customer and account number; a settlement summary names no account and its lines
|
||||
* stand on the GL names alone, which is what a summary is.
|
||||
*
|
||||
* A failure here does not fail the posting. The entry is already in the ledger, and clearing runs
|
||||
* over batches where one unwritable slip must not take a whole settlement down with it. The
|
||||
* problem is logged and the posting stands.
|
||||
*/
|
||||
private void raiseClearingVoucher(final String glTxnId, final Long debitGlId, final Long creditGlId, final BigDecimal amount,
|
||||
final String comments, final Long linkAccountId, final String linkAccountType) {
|
||||
if (glTxnId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final Map<String, Object> debitGl = this.voucherService.glAccount(debitGlId);
|
||||
final Map<String, Object> creditGl = this.voucherService.glAccount(creditGlId);
|
||||
final Map<String, Object> holder = accountHolderForVoucher(linkAccountId, linkAccountType);
|
||||
|
||||
// The debit is the side a customer account is named against, matching the row this
|
||||
// posting links to the account for reporting.
|
||||
final String debitParty = holder.get("party") != null ? (String) holder.get("party") : (String) debitGl.get("name");
|
||||
final String debitAccountNo = holder.get("account_no") != null ? (String) holder.get("account_no")
|
||||
: (String) debitGl.get("gl_code");
|
||||
|
||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_CLEARING_POSTING, null, glTxnId,
|
||||
this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL,
|
||||
List.of(VoucherLine.debitGl(debitGlId, debitParty, debitAccountNo, amount).withDescription(comments),
|
||||
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
|
||||
.withDescription(comments)));
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not raise a voucher for clearing journal transaction {}", glTxnId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer and account number a clearing item belongs to, for printing on a voucher.
|
||||
*
|
||||
* The type decides which table to read: savings and current accounts number independently, so
|
||||
* the same id exists in both and looking in the wrong one would put another customer's name on
|
||||
* the slip. Empty when the caller named no account, as a settlement summary does, or named one
|
||||
* without saying which kind — a wrong name is worse on a voucher than no name.
|
||||
*/
|
||||
private Map<String, Object> accountHolderForVoucher(final Long accountId, final String accountType) {
|
||||
if (accountId == null || accountType == null) {
|
||||
return Map.of();
|
||||
}
|
||||
final String sql;
|
||||
if ("SAVINGS".equalsIgnoreCase(accountType)) {
|
||||
sql = "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
|
||||
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?";
|
||||
} else if ("CURRENT".equalsIgnoreCase(accountType)) {
|
||||
sql = "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
|
||||
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
|
||||
} else {
|
||||
return Map.of();
|
||||
}
|
||||
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
||||
}
|
||||
|
||||
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
||||
final List<Long> rowIds = this.jdbcTemplate.queryForList(
|
||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
|
||||
|
||||
+121
-9
@@ -46,6 +46,7 @@ import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -65,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
|
||||
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -109,7 +111,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
|
||||
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
|
||||
if (requestType == null || requestType.isBlank()
|
||||
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) {
|
||||
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) {
|
||||
requestType = "DISBURSEMENT";
|
||||
}
|
||||
|
||||
@@ -185,9 +187,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 +550,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);
|
||||
}
|
||||
|
||||
@@ -715,6 +721,11 @@ public class ClerkDisbursementRequestApiResource {
|
||||
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
|
||||
}
|
||||
|
||||
// The sides of the slip, built from the entries as approved. Only reached once every
|
||||
// posting above has succeeded, so the voucher describes a completed movement rather than
|
||||
// one that was attempted.
|
||||
addDisbursementVoucherLines(record, creditEntries, sourceGlId, requestType);
|
||||
|
||||
final String txnRefsStr = String.join(",", txnRefs);
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
||||
@@ -981,7 +992,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 +1008,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/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 ───────────────────────────────────────────────────────────────────
|
||||
@@ -1032,6 +1049,97 @@ public class ClerkDisbursementRequestApiResource {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the sides of an approved disbursement, collection or bank transfer on its voucher.
|
||||
*
|
||||
* The register covers three directions under one request type, and each puts the society's GL
|
||||
* on a different side:
|
||||
*
|
||||
* <ul>
|
||||
* <li>DISBURSEMENT — money out of the source GL into the listed accounts: GL debited, each
|
||||
* account credited.</li>
|
||||
* <li>COLLECTION — money into the target GL out of the listed accounts: each account debited,
|
||||
* GL credited.</li>
|
||||
* <li>BANK_TRANSFER — account to account with no GL involved at all; each entry already carries
|
||||
* its own side, so they are used as given.</li>
|
||||
* </ul>
|
||||
*
|
||||
* The society's side is written as a single line for the total rather than one per counterparty,
|
||||
* because that is how it posts and how the slip reads.
|
||||
*
|
||||
* A failure here does not fail the approval: every transaction is posted by this point, and the
|
||||
* money has moved. The problem is logged, and the voucher keeps its number without sides.
|
||||
*/
|
||||
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
|
||||
final String requestType) {
|
||||
|
||||
final String voucherNo = (String) record.get("voucher_no");
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final boolean isBankTransfer = "BANK_TRANSFER".equals(requestType);
|
||||
final boolean isCollection = "COLLECTION".equals(requestType);
|
||||
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
BigDecimal counterpartyTotal = BigDecimal.ZERO;
|
||||
|
||||
for (final JsonElement el : creditEntries) {
|
||||
final JsonObject entry = el.getAsJsonObject();
|
||||
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
|
||||
final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
|
||||
final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull()
|
||||
? entry.get("accountName").getAsString()
|
||||
: accountNo;
|
||||
final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
|
||||
|
||||
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
|
||||
? entry.get("glAccountId").getAsLong()
|
||||
: null;
|
||||
final Long savingsId = !isGlEntry && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
|
||||
? entry.get("savingsAccountId").getAsLong()
|
||||
: null;
|
||||
|
||||
// A bank transfer states each side on the entry itself; the other two directions
|
||||
// put every counterparty on the side opposite the society's GL.
|
||||
final boolean entryIsDebit = isBankTransfer
|
||||
? "Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")
|
||||
: isCollection;
|
||||
|
||||
final VoucherLine line;
|
||||
if (isGlEntry) {
|
||||
line = entryIsDebit ? VoucherLine.debitGl(glId, accountName, accountNo, entryAmount)
|
||||
: VoucherLine.creditGl(glId, accountName, accountNo, entryAmount);
|
||||
} else {
|
||||
line = entryIsDebit ? VoucherLine.debitSavings(savingsId, accountName, accountNo, entryAmount)
|
||||
: VoucherLine.creditSavings(savingsId, accountName, accountNo, entryAmount);
|
||||
}
|
||||
lines.add(line);
|
||||
|
||||
if (!isBankTransfer) {
|
||||
counterpartyTotal = counterpartyTotal.add(entryAmount);
|
||||
}
|
||||
}
|
||||
|
||||
// The society's own side. A bank transfer has none — the money never leaves the members
|
||||
// between them — so its entries already balance on their own.
|
||||
if (!isBankTransfer) {
|
||||
final String glName = (String) record.get("gl_account_name");
|
||||
final String glNo = (String) record.get("gl_account_no");
|
||||
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
|
||||
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
|
||||
}
|
||||
|
||||
// Debits first, so the voucher prints the way a slip is read.
|
||||
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
|
||||
|
||||
voucherService.addLines(voucherNo, lines);
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Could not record voucher lines for disbursement request voucher {}", voucherNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
||||
@@ -1216,7 +1324,8 @@ public class ClerkDisbursementRequestApiResource {
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
// The disbursement register raises its own voucher; suppress the journal command's own.
|
||||
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
}
|
||||
|
||||
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
||||
@@ -1653,7 +1762,10 @@ public class ClerkDisbursementRequestApiResource {
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
// The disbursement register raises its own voucher, naming every account involved; the
|
||||
// journal command must not raise a second one over the same money.
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
return result != null ? result.getTransactionId() : null;
|
||||
}
|
||||
|
||||
|
||||
+1073
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user