Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c052beeacf | ||
|
|
942bad6c8c | ||
|
|
5a9a103832 | ||
|
|
fd91da3d76 | ||
|
|
c4a99fea5d | ||
|
|
dc0539d53d | ||
|
|
509370eaeb | ||
|
|
1130a482a0 | ||
|
|
8ecab08d57 | ||
|
|
29677e610c | ||
|
|
6ffb0dd9c0 | ||
|
|
8d224a22b4 | ||
|
|
f746a1fb2c | ||
|
|
1bc9d0901d | ||
|
|
58187c23d0 | ||
|
|
179fe3a6ce | ||
|
|
72221f704d | ||
|
|
a42a874811 | ||
|
|
1a2a945464 | ||
|
|
73ec85cfd6 | ||
|
|
46f5a1babd | ||
|
|
20fe11e60d | ||
|
|
eb1aa767f7 |
+1
@@ -437,6 +437,7 @@ public final class AccountingConstants {
|
|||||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||||
|
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
||||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
||||||
|
|
||||||
|
|||||||
+17
@@ -2905,6 +2905,15 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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) {
|
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
|
||||||
this.actionName = "CALCULATEINTEREST";
|
this.actionName = "CALCULATEINTEREST";
|
||||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||||
@@ -3111,6 +3120,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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) {
|
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
|
||||||
this.actionName = "DELETE";
|
this.actionName = "DELETE";
|
||||||
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
||||||
|
|||||||
+13
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
|
|||||||
this.dataValidationErrors.add(error);
|
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) {
|
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
|
||||||
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
|
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
|
||||||
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
|
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
|
||||||
|
|||||||
+1
@@ -39,6 +39,7 @@ public enum JobName {
|
|||||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
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"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||||
|
|||||||
+16
-1
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
|
|||||||
// differentiate deposit accounts Savings, FD and RD accounts
|
// differentiate deposit accounts Savings, FD and RD accounts
|
||||||
private final EnumOptionData depositType;
|
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,
|
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
||||||
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
|
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
||||||
|
final String fdAccountNumber, final Integer renewalCycleNo) {
|
||||||
|
this.fdAccountNumber = fdAccountNumber;
|
||||||
|
this.renewalCycleNo = renewalCycleNo;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.accountNo = accountNo;
|
this.accountNo = accountNo;
|
||||||
this.externalId = externalId;
|
this.externalId = externalId;
|
||||||
|
|||||||
+9
@@ -162,6 +162,15 @@ public class ClientApiConstants {
|
|||||||
public static final String ageDaysParamName = "ageDays";
|
public static final String ageDaysParamName = "ageDays";
|
||||||
public static final String nationalityIdParamName = "nationalityId";
|
public static final String nationalityIdParamName = "nationalityId";
|
||||||
public static final String isMinorParamName = "isMinor";
|
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 guardianClientIdParamName = "guardianClientId";
|
||||||
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
||||||
public static final String guardianRelationParamName = "guardianRelation";
|
public static final String guardianRelationParamName = "guardianRelation";
|
||||||
|
|||||||
+7
-1
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
|
|||||||
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
||||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
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 Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
|
|||||||
case 200 -> TRANSFER_TO_SAVINGS;
|
case 200 -> TRANSFER_TO_SAVINGS;
|
||||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||||
|
case 500 -> PARTIAL_REINVEST;
|
||||||
default -> INVALID;
|
default -> INVALID;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
|
|||||||
return this.equals(INVALID);
|
return this.equals(INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isPartialReinvest() {
|
||||||
|
return this.equals(PARTIAL_REINVEST);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: do we really need this?!?
|
// TODO: do we really need this?!?
|
||||||
public static Object[] integerValues() {
|
public static Object[] integerValues() {
|
||||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
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 onAccountClosureIdParamName = "onAccountClosureId";
|
||||||
public static final String transferDescriptionParamName = "transferDescription";
|
public static final String transferDescriptionParamName = "transferDescription";
|
||||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||||
|
public static final String reinvestAmountParamName = "reinvestAmount";
|
||||||
public static final String savingsAccounts = "savingsAccounts";
|
public static final String savingsAccounts = "savingsAccounts";
|
||||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||||
@@ -588,6 +589,10 @@ public final class DepositsApiConstants {
|
|||||||
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
||||||
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
||||||
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
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;
|
return fixedDepositResponseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -92,6 +92,9 @@ public class SavingsApiConstants {
|
|||||||
public static final String statusParamName = "status";
|
public static final String statusParamName = "status";
|
||||||
public static final String subStatusParamName = "subStatus";
|
public static final String subStatusParamName = "subStatus";
|
||||||
public static final String reasonForBlockParamName = "reasonForBlock";
|
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 clientIdParamName = "clientId";
|
||||||
public static final String isRetailAccountParamName = "isRetailAccount";
|
public static final String isRetailAccountParamName = "isRetailAccount";
|
||||||
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
||||||
@@ -226,6 +229,7 @@ public class SavingsApiConstants {
|
|||||||
public static final String endDateParamName = "endDate";
|
public static final String endDateParamName = "endDate";
|
||||||
public static final String introducerRequiredParamName = "introducerRequired";
|
public static final String introducerRequiredParamName = "introducerRequired";
|
||||||
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
||||||
|
public static final String shareAccountIdParamName = "shareAccountId";
|
||||||
public static final String nominationFacilityParamName = "nominationFacility";
|
public static final String nominationFacilityParamName = "nominationFacility";
|
||||||
public static final String dormancyAlertParamName = "dormancyAlert";
|
public static final String dormancyAlertParamName = "dormancyAlert";
|
||||||
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
||||||
|
|||||||
+17
@@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable {
|
|||||||
private LocalDate endDate;
|
private LocalDate endDate;
|
||||||
private Boolean introducerRequired;
|
private Boolean introducerRequired;
|
||||||
private Boolean shareLinkingEnabled;
|
private Boolean shareLinkingEnabled;
|
||||||
|
private Long shareAccountId;
|
||||||
|
private String shareAccountNo;
|
||||||
private Boolean nominationFacility;
|
private Boolean nominationFacility;
|
||||||
private Boolean dormancyAlert;
|
private Boolean dormancyAlert;
|
||||||
private Boolean isDormancyTrackingActiveFromProduct;
|
private Boolean isDormancyTrackingActiveFromProduct;
|
||||||
@@ -217,6 +219,21 @@ public final class SavingsAccountData implements Serializable {
|
|||||||
private BigDecimal interestPosting;
|
private BigDecimal interestPosting;
|
||||||
private BigDecimal overdraftPosting;
|
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,
|
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
||||||
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
||||||
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
||||||
|
|||||||
+4
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
|
|||||||
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
||||||
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
||||||
break;
|
break;
|
||||||
|
case PARTIAL_REINVEST:
|
||||||
|
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
||||||
|
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return optionData;
|
return optionData;
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
accountMapping = paymentChannelSpecificAccountMapping;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountMapping == null) {
|
||||||
|
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
|
||||||
|
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
|
||||||
|
}
|
||||||
glAccount = accountMapping.getGlAccount();
|
glAccount = accountMapping.getGlAccount();
|
||||||
}
|
}
|
||||||
return glAccount;
|
return glAccount;
|
||||||
|
|||||||
+64
-1
@@ -24,6 +24,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
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.loanproduct.service.LoanEnumerations;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.dao.NonTransientDataAccessException;
|
import org.springframework.dao.NonTransientDataAccessException;
|
||||||
@@ -136,6 +139,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
||||||
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
||||||
private final LoanTransactionRepository loanTransactionRepository;
|
private final LoanTransactionRepository loanTransactionRepository;
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@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)
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
||||||
.withTransactionId(transactionId).build();
|
.withTransactionId(transactionId).with(changes).build();
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
final Throwable throwable = dve.getMostSpecificCause();
|
final Throwable throwable = dve.getMostSpecificCause();
|
||||||
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
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,
|
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
||||||
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
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.LoanAmortizationAllocationMappingRepository;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
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.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration {
|
|||||||
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
||||||
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
||||||
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
||||||
LoanTransactionRepository loanTransactionRepository) {
|
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
|
||||||
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
||||||
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
||||||
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
||||||
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
||||||
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
||||||
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
|
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.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("from m_savings_account sa ");
|
||||||
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
||||||
|
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
|
||||||
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
||||||
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
||||||
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_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,
|
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
|
||||||
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
|
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,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
||||||
lastActiveTransactionDate);
|
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
|
|
||||||
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
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,
|
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";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -113,6 +113,37 @@ public interface ShareAccountApiConstants {
|
|||||||
* outstanding balance. */
|
* outstanding balance. */
|
||||||
String linkedloanid_paramname = "linkedLoanId";
|
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,
|
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,
|
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||||
|
|||||||
+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.FundTransfer;
|
||||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@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 {
|
public class AmlFundTransferEvaluationService {
|
||||||
|
|
||||||
private static final int STRUCTURING_WINDOW_DAYS = 30;
|
private static final int STRUCTURING_WINDOW_DAYS = 30;
|
||||||
|
|||||||
+10
-1
@@ -31,11 +31,20 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
|||||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Transactional
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||||
|
|
||||||
private static final int MEDIUM_RISK = 2;
|
private static final int MEDIUM_RISK = 2;
|
||||||
|
|||||||
+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.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -67,8 +70,13 @@ import com.google.common.base.Splitter;
|
|||||||
@Component
|
@Component
|
||||||
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
|
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@lombok.extern.slf4j.Slf4j
|
||||||
public class CashierDepositRequestApiResource {
|
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 JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -161,7 +169,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
// reject() will delete these records so the vault position is reversed.
|
// reject() will delete these records so the vault position is reversed.
|
||||||
if (denominationData != null && !denominationData.isBlank()) {
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
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));
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
||||||
@@ -394,6 +402,20 @@ public class CashierDepositRequestApiResource {
|
|||||||
txnId = isCurrentAccount
|
txnId = isCurrentAccount
|
||||||
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||||
: executeSavingsDeposit(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.
|
// 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(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||||
);
|
);
|
||||||
@@ -540,6 +566,62 @@ public class CashierDepositRequestApiResource {
|
|||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── 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) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
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;
|
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(
|
private String executeCurrentAccountDeposit(
|
||||||
final Long currentAccountId, final BigDecimal amount,
|
final Long currentAccountId, final BigDecimal amount,
|
||||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||||
@@ -685,7 +815,10 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.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;
|
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.
|
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
||||||
private void savePendingDenomination(final String denominationData,
|
private void savePendingDenomination(final String denominationData,
|
||||||
final Long requestId, final Long savingsAccountId, final String accountNo,
|
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;
|
final JsonObject denomMap;
|
||||||
try {
|
try {
|
||||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
@@ -729,12 +862,12 @@ public class CashierDepositRequestApiResource {
|
|||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_savings_txn_denomination
|
INSERT INTO m_savings_txn_denomination
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
requestId, savingsAccountId, accountNo, clientId, denomId,
|
requestId, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
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 lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
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.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -30,8 +31,217 @@ public class CashReportApiResource {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
|
private final VaultPositionService vaultPositionService;
|
||||||
private final Gson gson = new Gson();
|
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
|
@GET
|
||||||
@Path("closing")
|
@Path("closing")
|
||||||
public String closing(@QueryParam("date") String date) {
|
public String closing(@QueryParam("date") String date) {
|
||||||
@@ -360,6 +570,30 @@ public class CashReportApiResource {
|
|||||||
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
|
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
|
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
|
||||||
* Returns denomination-wise cash received by the cashier from vault on the given date
|
* 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 };
|
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 "
|
final String baseGroupSql = "FROM m_savings_txn_denomination std "
|
||||||
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
|
+ "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) "
|
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
||||||
+ officerFilter
|
+ officerFilter
|
||||||
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
|
+ " 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
|
// 2. Paged data query
|
||||||
final String dataSql = "SELECT "
|
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, "
|
+ " 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, "
|
+ " MAX(std.txn_date) AS txn_date, "
|
||||||
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
||||||
+ " MAX(std.officer_username) AS teller_name, "
|
+ " 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 List<Map<String, Object>> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
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("acNo", rs.getString("ac_no"));
|
||||||
row.put("acName", rs.getString("ac_name"));
|
row.put("acName", rs.getString("ac_name"));
|
||||||
row.put("txnDate", rs.getString("txn_date"));
|
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.");
|
"At least one denomination with a positive count is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
|
||||||
|
|
||||||
CashTransfer header = new CashTransfer();
|
CashTransfer header = new CashTransfer();
|
||||||
header.setTransferDate(transferDate);
|
header.setTransferDate(transferDate);
|
||||||
header.setFromParty(fromParty);
|
header.setFromParty(fromParty);
|
||||||
@@ -264,6 +266,127 @@ public class CashTransferApiResource {
|
|||||||
return total;
|
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) {
|
private boolean hasPositiveCount(JsonArray items) {
|
||||||
for (JsonElement el : items) {
|
for (JsonElement el : items) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
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,
|
userId, userId, now, now,
|
||||||
java.sql.Date.valueOf(entryDate),
|
java.sql.Date.valueOf(entryDate),
|
||||||
java.sql.Date.valueOf(entryDate),
|
java.sql.Date.valueOf(entryDate),
|
||||||
now.atOffset(ZoneOffset.UTC),
|
toUtc(now),
|
||||||
now.atOffset(ZoneOffset.UTC));
|
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,
|
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.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.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.monetary.domain.Money;
|
||||||
import org.apache.fineract.organisation.office.domain.Office;
|
import org.apache.fineract.organisation.office.domain.Office;
|
||||||
import org.apache.fineract.portfolio.charge.domain.Charge;
|
import org.apache.fineract.portfolio.charge.domain.Charge;
|
||||||
@@ -89,6 +92,12 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
||||||
private final SavingsHelper savingsHelper;
|
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." */
|
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
|
||||||
private FacilityChargeMapping resolveMapping() {
|
private FacilityChargeMapping resolveMapping() {
|
||||||
@@ -244,6 +253,7 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
||||||
accountId);
|
accountId);
|
||||||
|
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
||||||
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
||||||
chequeBookId);
|
chequeBookId);
|
||||||
}
|
}
|
||||||
@@ -278,6 +288,47 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
this.currentAccountRepository.saveAndFlush(account);
|
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. */
|
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
|
||||||
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
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 "
|
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();
|
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
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) {
|
if (result == null) {
|
||||||
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
||||||
return;
|
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.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
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.commands.service.SynchronousCommandProcessingService;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
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.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -49,6 +53,11 @@ public class ClearingJournalService {
|
|||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
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
|
* 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,
|
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 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) {
|
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,
|
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
|
||||||
creditGlId, amount);
|
creditGlId, amount);
|
||||||
@@ -86,7 +108,10 @@ public class ClearingJournalService {
|
|||||||
|
|
||||||
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
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) {
|
if (result == null) {
|
||||||
log.warn("Clearing journal entry did not return a result ({}).", comments);
|
log.warn("Clearing journal entry did not return a result ({}).", comments);
|
||||||
return null;
|
return null;
|
||||||
@@ -95,9 +120,77 @@ public class ClearingJournalService {
|
|||||||
if (glTxnId != null && linkAccountId != null) {
|
if (glTxnId != null && linkAccountId != null) {
|
||||||
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
||||||
}
|
}
|
||||||
|
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
|
||||||
return glTxnId;
|
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) {
|
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
||||||
final List<Long> rowIds = this.jdbcTemplate.queryForList(
|
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,
|
"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.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
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.exception.IdempotentCommandProcessSucceedException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -109,7 +111,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
|
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
|
||||||
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
|
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
|
||||||
if (requestType == null || requestType.isBlank()
|
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";
|
requestType = "DISBURSEMENT";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +187,13 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
activityLog, Timestamp.from(Instant.now()), requestType
|
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(
|
final String voucherNo = voucherService.createVoucher(
|
||||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
||||||
newId, txnAmount, currentUser);
|
newId, txnAmount, currentUser, voucherPrefix);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||||
|
|
||||||
@@ -544,7 +550,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return ps;
|
return ps;
|
||||||
}, btLogKh);
|
}, btLogKh);
|
||||||
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
|
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);
|
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.");
|
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 txnRefsStr = String.join(",", txnRefs);
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
||||||
@@ -981,7 +992,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return ps;
|
return ps;
|
||||||
}, logKh);
|
}, logKh);
|
||||||
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
|
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);
|
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
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
|
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
|
||||||
* under concurrent inserts on the same date.
|
* under concurrent inserts on the same date.
|
||||||
*/
|
*/
|
||||||
private String generateBtVoucherNo(final long logId) {
|
/**
|
||||||
return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
|
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
||||||
+ String.format("%05d", logId);
|
* 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 ───────────────────────────────────────────────────────────────────
|
// ── 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) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
||||||
@@ -1216,7 +1324,8 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
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 ─────────────────────────────────────────────
|
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
||||||
@@ -1653,7 +1762,10 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
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;
|
return result != null ? result.getTransactionId() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1039
File diff suppressed because it is too large
Load Diff
+229
-19
@@ -41,6 +41,8 @@ import java.sql.Date;
|
|||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -55,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -91,7 +94,25 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
|
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
|
||||||
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
|
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
|
||||||
|
|
||||||
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER";
|
// "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of
|
||||||
|
// the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a
|
||||||
|
// pre-settlement_payment_type_id-column legacy request still resolves to a real payment type
|
||||||
|
// instead of silently posting its settlement with none at all.
|
||||||
|
private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "TRANSFER";
|
||||||
|
|
||||||
|
// ── Sundry pass-through for the redemption settlement ───────────────────
|
||||||
|
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||||
|
private static final int CREDIT = 1;
|
||||||
|
private static final int DEBIT = 2;
|
||||||
|
|
||||||
|
// acc_product_mapping.financial_account_type for share products.
|
||||||
|
private static final int FA_SHARES_REFERENCE = 1;
|
||||||
|
private static final int FA_SHARES_SUSPENSE = 2;
|
||||||
|
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||||
|
|
||||||
|
// acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control.
|
||||||
|
private static final int PRODUCT_TYPE_SAVINGS = 2;
|
||||||
|
private static final int FA_SAVINGS_CONTROL = 2;
|
||||||
|
|
||||||
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
|
// ── POST /v1/clerk/share-withdrawal-requests ── Clerk submits new request ───
|
||||||
|
|
||||||
@@ -187,8 +208,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||||
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
|
@QueryParam("search") final String search) {
|
||||||
|
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
||||||
@@ -206,11 +228,11 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
where.append(" AND status = ?");
|
where.append(" AND status = ?");
|
||||||
params.add(status);
|
params.add(status);
|
||||||
}
|
}
|
||||||
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
|
return queryList(where.toString(), params, fromDate, toDate, null, offset, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
|
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
|
||||||
final int offset, final int limit) {
|
final String search, final int offset, final int limit) {
|
||||||
final StringBuilder where = new StringBuilder(baseWhere);
|
final StringBuilder where = new StringBuilder(baseWhere);
|
||||||
final List<Object> params = new ArrayList<>(baseParams);
|
final List<Object> params = new ArrayList<>(baseParams);
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
@@ -221,6 +243,13 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
where.append(" AND DATE(submitted_at) <= ?");
|
where.append(" AND DATE(submitted_at) <= ?");
|
||||||
params.add(Date.valueOf(toDate));
|
params.add(Date.valueOf(toDate));
|
||||||
}
|
}
|
||||||
|
if (search != null && !search.isBlank()) {
|
||||||
|
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR resolution_number ILIKE ?)");
|
||||||
|
final String like = "%" + search.trim() + "%";
|
||||||
|
params.add(like);
|
||||||
|
params.add(like);
|
||||||
|
params.add(like);
|
||||||
|
}
|
||||||
|
|
||||||
final long total = Optional
|
final long total = Optional
|
||||||
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
|
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
|
||||||
@@ -320,7 +349,7 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
|
||||||
|
|
||||||
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
|
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
|
||||||
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
|
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, collection_applied AS collectionApplied,
|
||||||
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
|
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
|
||||||
FROM m_share_account WHERE id = ?
|
FROM m_share_account WHERE id = ?
|
||||||
""", shareAccountId).stream().findFirst().orElse(null);
|
""", shareAccountId).stream().findFirst().orElse(null);
|
||||||
@@ -336,6 +365,14 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
: -1;
|
: -1;
|
||||||
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
|
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
|
||||||
|
|
||||||
|
// Active only means approved and activated -- not paid for. Real share capital is only posted
|
||||||
|
// once a Share Advance Collection is applied (see ShareAdvanceCollectionApiResource), and
|
||||||
|
// approve() below hard-blocks on this same flag, so a request failing this check can never
|
||||||
|
// actually be approved regardless of what a checker decides here.
|
||||||
|
final boolean collectionApplied = shareAccountRow != null && Boolean.TRUE.equals(shareAccountRow.get("collectionApplied"));
|
||||||
|
checks.add(checkResult("Shares have been paid for (Share Advance Collection applied)", collectionApplied,
|
||||||
|
collectionApplied ? "Settled" : "Not settled — no collection has been applied to this share account yet"));
|
||||||
|
|
||||||
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
|
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
|
||||||
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
|
? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
|
||||||
: null;
|
: null;
|
||||||
@@ -585,6 +622,20 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final Object meetingDateObj = record.get("meeting_date");
|
final Object meetingDateObj = record.get("meeting_date");
|
||||||
final String resolutionNumber = (String) record.get("resolution_number");
|
final String resolutionNumber = (String) record.get("resolution_number");
|
||||||
|
|
||||||
|
// Account creation, approval and activation post no share-capital GL of their own -- a share
|
||||||
|
// account can be Active with "approved" shares that nobody has actually paid for yet, and only
|
||||||
|
// gets a real Dr Share Sundry / Cr Share Capital entry once a collection is applied (see
|
||||||
|
// ShareAdvanceCollectionApiResource). Redeeming before that would reverse capital that was
|
||||||
|
// never issued, so this is a hard block here, not just an advisory line in validate()'s
|
||||||
|
// checklist -- a checker approving off that checklist alone has no other backstop.
|
||||||
|
final Boolean collectionApplied = jdbcTemplate
|
||||||
|
.queryForList("SELECT collection_applied AS collectionApplied FROM m_share_account WHERE id = ?", shareAccountId).stream()
|
||||||
|
.findFirst().map(row -> (Boolean) row.get("collectionApplied")).orElse(null);
|
||||||
|
if (!Boolean.TRUE.equals(collectionApplied)) {
|
||||||
|
throw apiError(409, "Share account " + shareAccountId + " has not been settled yet -- no Share Advance Collection has been "
|
||||||
|
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
|
||||||
|
}
|
||||||
|
|
||||||
final JsonObject payload = new JsonObject();
|
final JsonObject payload = new JsonObject();
|
||||||
payload.addProperty("locale", "en");
|
payload.addProperty("locale", "en");
|
||||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
@@ -603,11 +654,20 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
|
final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null;
|
||||||
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
|
final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null;
|
||||||
|
|
||||||
|
// Redirects the native redeem posting's Cr Shares Reference (Cash at Vault) leg into
|
||||||
|
// Cr Sundry instead — see routeRedeemIntoSundry() for why: every other way money reaches or
|
||||||
|
// leaves share capital in this codebase passes through Sundry, and leaving redeem on its own
|
||||||
|
// "give cash back over the counter" assumption breaks that the moment the settlement below
|
||||||
|
// lands on a different GL than this leg did.
|
||||||
|
if (shareTransactionId != null) {
|
||||||
|
routeRedeemIntoSundry(shareAccountId, shareTransactionId, LocalDate.parse(requestedDateObj.toString().substring(0, 10)),
|
||||||
|
currentUser.getId());
|
||||||
|
}
|
||||||
|
|
||||||
// Settlement: within this SAME approval transaction, immediately credit the redemption value
|
// Settlement: within this SAME approval transaction, immediately credit the redemption value
|
||||||
// to the member's linked savings account. Two Fineract journal entries get posted — native
|
// to the member's linked savings account, routed Dr Sundry / Cr Savings Control -- explicitly,
|
||||||
// redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference /
|
// not by relying on the settlement payment type's own GL Fund Source mapping to coincidentally
|
||||||
// Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined
|
// land on the same account the leg above credited. See creditLinkedSavingsAccount().
|
||||||
// financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry.
|
|
||||||
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
|
final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id");
|
||||||
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
|
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
|
||||||
? ((Number) storedSettlementPaymentTypeId).longValue()
|
? ((Number) storedSettlementPaymentTypeId).longValue()
|
||||||
@@ -689,14 +749,17 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
|
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
|
||||||
* the unit price ourselves — the transaction was already priced by Fineract's own
|
* the unit price ourselves — the transaction was already priced by Fineract's own
|
||||||
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
|
* {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily
|
||||||
* today) and already nets out any redemption charge. Uses Fineract's own native savings deposit
|
* today) and already nets out any redemption charge.
|
||||||
* command so the transaction is fully audited and posts its own GL entry the normal way — the
|
*
|
||||||
* debit side lands on whichever GL account is mapped to the settlement payment type the clerk
|
* <p>
|
||||||
* chose at submit time (validated to be non-cash and persisted on the request row) — matching the
|
* Posted through Fineract's own native savings deposit command first, so the transaction is fully audited and the
|
||||||
* Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero
|
* member's passbook/statement carry it exactly as an over-the-counter deposit would. That command's own GL entry
|
||||||
* on that bridge account. Falls back to the legacy hardcoded
|
* (Dr <i>whichever GL the settlement payment type's Fund Source maps to</i> / Cr Savings Control) is then
|
||||||
* {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this
|
* <b>reversed in full</b> and replaced with an explicit Dr Sundry / Cr Savings Control pair — the debit side used
|
||||||
* column existed.
|
* to be left to the payment type's own mapping in the hope it would land on the same account
|
||||||
|
* {@link #routeRedeemIntoSundry} just credited, netting to zero; when that mapping pointed anywhere else (as it
|
||||||
|
* did the day this was found — Sundry, when it should have matched Cash at Vault) the two legs never actually
|
||||||
|
* met, leaving Sundry debited for money that was never credited to it. Explicit beats coincidental.
|
||||||
*/
|
*/
|
||||||
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
|
private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
|
||||||
final String resolutionNumber, final Long settlementPaymentTypeId) {
|
final String resolutionNumber, final Long settlementPaymentTypeId) {
|
||||||
@@ -735,7 +798,35 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||||
.withJson(payload.toString()).build();
|
.withJson(payload.toString()).build();
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
return new Settlement(settlementAmount, result.getResourceId());
|
final Long savingsTransactionId = result.getResourceId();
|
||||||
|
|
||||||
|
// The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather
|
||||||
|
// than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands
|
||||||
|
// on its own regardless of which GL the payment type's Fund Source mapping happened to hit.
|
||||||
|
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId);
|
||||||
|
|
||||||
|
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
|
||||||
|
final Long productId = toLong(acct.get("product_id"));
|
||||||
|
final Long officeId = toLong(acct.get("office_id"));
|
||||||
|
final String currencyCode = (String) acct.get("currency_code");
|
||||||
|
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||||
|
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||||
|
if (glSavingsControl == null) {
|
||||||
|
throw apiError(409, "The Savings Control GL is not mapped for this savings account's product.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10));
|
||||||
|
final long userId = context.authenticatedUser().getId();
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final String txnId = "SHRDMSTL-" + System.nanoTime();
|
||||||
|
final String accountNo = (String) acct.get("account_no");
|
||||||
|
|
||||||
|
insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||||
|
"Share redemption settled to savings | " + accountNo);
|
||||||
|
insertEntry(officeId, glSavingsControl, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||||
|
"Share redemption settlement credited to savings | " + accountNo);
|
||||||
|
|
||||||
|
return new Settlement(settlementAmount, savingsTransactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
|
// ── POST /v1/clerk/share-withdrawal-requests/{id}/reject ── Manager rejects ─
|
||||||
@@ -806,6 +897,125 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sundry pass-through helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects the native {@code redeemshares} posting's Cr Shares Reference (Cash at Vault) leg into Cr Sundry.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Fineract's own accounting bridge assumes a redemption hands cash back over the counter (Dr Share Equity / Cr
|
||||||
|
* Shares Reference) — right for a walk-in cash redemption, wrong here, where the value is heading to the
|
||||||
|
* member's linked savings instead. Left alone, that Cr Shares Reference leg has no partner: the settlement
|
||||||
|
* posted in {@link #creditLinkedSavingsAccount} debits Sundry, not Shares Reference, so Sundry would be debited
|
||||||
|
* for money it was never credited with. Reversing just this one leg (matched by the specific "Shares Reference"
|
||||||
|
* GL account, not merely "the credit leg" — a chargeable redemption also credits Income from Fees, which must be
|
||||||
|
* left standing) and replacing it with Cr Sundry closes that gap: the account genuinely passes through Sundry
|
||||||
|
* the same way every other share-capital movement in this codebase does.
|
||||||
|
*/
|
||||||
|
private void routeRedeemIntoSundry(final Long shareAccountId, final Long shareTransactionId, final LocalDate entryDate,
|
||||||
|
final long userId) {
|
||||||
|
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
|
||||||
|
final Long productId = toLong(acct.get("product_id"));
|
||||||
|
final Long officeId = toLong(acct.get("office_id"));
|
||||||
|
final String currencyCode = (String) acct.get("currency_code");
|
||||||
|
final String accountNo = (String) acct.get("account_no");
|
||||||
|
|
||||||
|
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
|
||||||
|
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||||
|
|
||||||
|
final Map<String, Object> reversedLeg = jdbcTemplate
|
||||||
|
.queryForList(
|
||||||
|
"SELECT amount, transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ? AND account_id = ? "
|
||||||
|
+ " AND type_enum = ? AND reversed = false",
|
||||||
|
shareTransactionId, glReference, CREDIT)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
if (reversedLeg == null) {
|
||||||
|
// Nothing to redirect -- the native posting didn't credit Shares Reference at all (an
|
||||||
|
// unexpected accounting configuration), so there is no leg here to reroute.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final BigDecimal amount = (BigDecimal) reversedLeg.get("amount");
|
||||||
|
// Reuses the native posting's OWN transaction_id rather than minting a new one -- entries are
|
||||||
|
// grouped and checked for balance by transaction_id, so a fresh id here would leave the Dr
|
||||||
|
// Share Equity leg standing alone as "unbalanced" and this new Cr Sundry leg equally alone,
|
||||||
|
// instead of the two reading as the one balanced entry they actually are.
|
||||||
|
final String nativeTxnId = (String) reversedLeg.get("transaction_id");
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE acc_gl_journal_entry SET reversed = true WHERE share_transaction_id = ? AND account_id = ? "
|
||||||
|
+ " AND type_enum = ? AND reversed = false",
|
||||||
|
shareTransactionId, glReference, CREDIT);
|
||||||
|
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
insertEntry(officeId, glSundry, CREDIT, amount, nativeTxnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||||
|
"Share redemption held in Sundry pending settlement | " + accountNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loadShareAccountGlContext(final Long shareAccountId) {
|
||||||
|
return jdbcTemplate.queryForMap("""
|
||||||
|
SELECT sa.account_no, sa.product_id, sa.currency_code, c.office_id
|
||||||
|
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id
|
||||||
|
WHERE sa.id = ?
|
||||||
|
""", shareAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long requireShareGl(final Long productId, final int financialAccountType, final String label) {
|
||||||
|
final Long glId = resolveGl(productId, financialAccountType);
|
||||||
|
if (glId == null) {
|
||||||
|
throw apiError(409, 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 (final IncorrectResultSizeDataAccessException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long savingsControlGlOf(final Long savingsAccountId) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT apm.gl_account_id FROM m_savings_account sa
|
||||||
|
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
|
||||||
|
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
|
||||||
|
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
|
||||||
|
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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. 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 Long toLong(final Object o) {
|
||||||
|
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
|
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
|
||||||
JsonArray log = new JsonArray();
|
JsonArray log = new JsonArray();
|
||||||
if (existingJson != null && !existingJson.isBlank()) {
|
if (existingJson != null && !existingJson.isBlank()) {
|
||||||
|
|||||||
+67
-9
@@ -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.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -69,6 +72,10 @@ import com.google.common.base.Splitter;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ClerkWithdrawalRequestApiResource {
|
public class ClerkWithdrawalRequestApiResource {
|
||||||
|
|
||||||
|
// 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(ClerkWithdrawalRequestApiResource.class);
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -151,7 +158,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||||
@QueryParam("fromDate") final String fromDate,
|
@QueryParam("fromDate") final String fromDate,
|
||||||
@QueryParam("toDate") final String toDate) {
|
@QueryParam("toDate") final String toDate,
|
||||||
|
@QueryParam("search") final String search) {
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||||
@@ -163,6 +171,14 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
where.append(" AND DATE(submitted_at) <= ?");
|
where.append(" AND DATE(submitted_at) <= ?");
|
||||||
baseParams.add(Date.valueOf(toDate));
|
baseParams.add(Date.valueOf(toDate));
|
||||||
}
|
}
|
||||||
|
if (search != null && !search.isBlank()) {
|
||||||
|
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR voucher_no ILIKE ? OR tok_no ILIKE ?)");
|
||||||
|
final String like = "%" + search.trim() + "%";
|
||||||
|
baseParams.add(like);
|
||||||
|
baseParams.add(like);
|
||||||
|
baseParams.add(like);
|
||||||
|
baseParams.add(like);
|
||||||
|
}
|
||||||
|
|
||||||
final long total = Optional.ofNullable(
|
final long total = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray())
|
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray())
|
||||||
@@ -457,6 +473,10 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
||||||
|
// debited and the till that pays it out is credited.
|
||||||
|
addWithdrawalVoucherLines(record, savingsAccountId, amount);
|
||||||
|
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||||
);
|
);
|
||||||
@@ -594,6 +614,11 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented.");
|
throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The voucher was raised in the clerk's name at submission, before any cashier was involved --
|
||||||
|
// reattribute it to whoever is actually handing over the cash now, so it shows up in their own
|
||||||
|
// voucher register rather than only in the clerk's.
|
||||||
|
voucherService.updateCashier(VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, currentUser);
|
||||||
|
|
||||||
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
|
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
|
||||||
if (denominationData != null && !denominationData.isBlank()) {
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
saveDisbursementDenomination(denominationData, record, currentUser);
|
saveDisbursementDenomination(denominationData, record, currentUser);
|
||||||
@@ -663,6 +688,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final String accountNo = (String) record.get("account_no");
|
final String accountNo = (String) record.get("account_no");
|
||||||
final Object clientIdObj = record.get("client_id");
|
final Object clientIdObj = record.get("client_id");
|
||||||
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
|
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
|
||||||
|
final String paymentTypeName = (String) record.get("payment_type_name");
|
||||||
|
|
||||||
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
|
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
|
||||||
final int count;
|
final int count;
|
||||||
@@ -686,16 +712,42 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_savings_txn_denomination
|
INSERT INTO m_savings_txn_denomination
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
txnId, savingsAccountId, accountNo, clientId, denomId,
|
txnId, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
value, count, BigDecimal.valueOf((long) value * count),
|
||||||
cashier.getId(), cashier.getUsername()
|
cashier.getId(), cashier.getUsername(), paymentTypeName
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the two sides of an approved withdrawal on its voucher.
|
||||||
|
*
|
||||||
|
* Money leaves the member and is paid out over the counter, so the member's account is debited
|
||||||
|
* and the till is credited. The withdrawal register does not carry a GL id for the till — it
|
||||||
|
* pays out of whichever cashier's drawer serves the request — so that side is named without one.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the approval: the withdrawal is already posted and the member
|
||||||
|
* already paid. The problem is logged, and the voucher keeps its number without sides.
|
||||||
|
*/
|
||||||
|
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
|
||||||
|
final String voucherNo = (String) record.get("voucher_no");
|
||||||
|
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
voucherService.addLines(voucherNo,
|
||||||
|
List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"),
|
||||||
|
(String) record.get("account_no"), amount),
|
||||||
|
VoucherLine.creditGl(null, "Cash in Hand", null, amount)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||||
@@ -798,9 +850,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the token number is unique for today's date among non-rejected requests, checking
|
* Ensures the token number is unique for today's date among non-rejected requests, checking
|
||||||
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
|
* this table plus {@code m_share_dividend_claim_request} and {@code m_clerk_closure_request} --
|
||||||
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
|
* all three are redeemed at the very same Transaction Entry token field, so none of the pools
|
||||||
* {@code excludeId} is set during resubmit to skip the record being edited.
|
* may ever collide. {@code excludeId} is set during resubmit to skip the record being edited.
|
||||||
*/
|
*/
|
||||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||||
final StringBuilder sql = new StringBuilder(
|
final StringBuilder sql = new StringBuilder(
|
||||||
@@ -815,13 +867,19 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final long ownCount = Optional.ofNullable(
|
final long ownCount = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
final long otherCount = Optional.ofNullable(
|
final long dividendCount = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject(
|
jdbcTemplate.queryForObject(
|
||||||
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
||||||
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
||||||
Long.class, tokNo)
|
Long.class, tokNo)
|
||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
if (ownCount + otherCount > 0) {
|
final long closureCount = Optional.ofNullable(
|
||||||
|
jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_clerk_closure_request "
|
||||||
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
||||||
|
Long.class, tokNo)
|
||||||
|
).orElse(0L);
|
||||||
|
if (ownCount + dividendCount + closureCount > 0) {
|
||||||
throw apiError(400,
|
throw apiError(400,
|
||||||
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -160,10 +160,13 @@ public class ClientKycApiResource {
|
|||||||
template.put("clientTypeName", clientTypeName);
|
template.put("clientTypeName", clientTypeName);
|
||||||
template.put("legalFormId", legalFormId);
|
template.put("legalFormId", legalFormId);
|
||||||
|
|
||||||
// Minor detection mirrors the frontend's own rule (age < 18) so the document list
|
// Minor detection mirrors the frontend's own rule so the document list served here stays
|
||||||
// served here stays consistent with what the UI computes from the same date of birth.
|
// consistent with what the UI computes from the same date of birth. Both sides read the
|
||||||
|
// threshold from a single named constant -- when they disagree, this endpoint wins and would
|
||||||
|
// silently serve an adult document list (mandatory PAN) to a minor.
|
||||||
final LocalDate dateOfBirth = client.dateOfBirth();
|
final LocalDate dateOfBirth = client.dateOfBirth();
|
||||||
final boolean isMinor = dateOfBirth != null && Period.between(dateOfBirth, LocalDate.now()).getYears() < 18;
|
final boolean isMinor = dateOfBirth != null
|
||||||
|
&& Period.between(dateOfBirth, LocalDate.now()).getYears() < ClientApiConstants.MINOR_AGE_THRESHOLD;
|
||||||
template.put("isMinor", isMinor);
|
template.put("isMinor", isMinor);
|
||||||
|
|
||||||
template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor));
|
template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor));
|
||||||
|
|||||||
+3
-3
@@ -225,7 +225,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
|
|||||||
controlGlId, suspenseGlId);
|
controlGlId, suspenseGlId);
|
||||||
} else {
|
} else {
|
||||||
this.clearingJournalService.postJournalEntry(debit.officeId, debit.currencyCode, today, controlGlId, suspenseGlId, amount,
|
this.clearingJournalService.postJournalEntry(debit.officeId, debit.currencyCode, today, controlGlId, suspenseGlId, amount,
|
||||||
"CTS inward clearing pass - item #" + itemId, item.getAccountId());
|
"CTS inward clearing pass - item #" + itemId, item.getAccountId(), item.getAccountType());
|
||||||
}
|
}
|
||||||
|
|
||||||
markLeaf(item, true, "CTS cleared", null, userId);
|
markLeaf(item, true, "CTS cleared", null, userId);
|
||||||
@@ -738,7 +738,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
|
|||||||
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
|
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
|
||||||
account.setHelpers(this.savingsAccountTransactionSummaryWrapper, this.savingsHelper);
|
account.setHelpers(this.savingsAccountTransactionSummaryWrapper, this.savingsHelper);
|
||||||
if (item.isHoldPlaced() && item.getHoldAmount() != null) {
|
if (item.isHoldPlaced() && item.getHoldAmount() != null) {
|
||||||
account.releaseOnHoldAmount(item.getHoldAmount());
|
account.releaseOnHoldAmount(item.getHoldAmount(), SavingsAccount.HOLD_CATEGORY_CLEARING);
|
||||||
}
|
}
|
||||||
final Office office = account.office();
|
final Office office = account.office();
|
||||||
final Money amountMoney = Money.of(account.getCurrency(), amount);
|
final Money amountMoney = Money.of(account.getCurrency(), amount);
|
||||||
@@ -770,7 +770,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
|
|||||||
}
|
}
|
||||||
if (isSavings(item)) {
|
if (isSavings(item)) {
|
||||||
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
|
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
|
||||||
account.releaseOnHoldAmount(item.getHoldAmount());
|
account.releaseOnHoldAmount(item.getHoldAmount(), SavingsAccount.HOLD_CATEGORY_CLEARING);
|
||||||
this.savingsAccountRepositoryWrapper.saveAndFlush(account);
|
this.savingsAccountRepositoryWrapper.saveAndFlush(account);
|
||||||
} else {
|
} else {
|
||||||
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
|
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(item.getAccountId());
|
||||||
|
|||||||
+4
-1
@@ -284,7 +284,10 @@ public class InwardClearingValidationService {
|
|||||||
if (account == null) {
|
if (account == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
account.holdAmount(amount);
|
// A presented-but-unsettled cheque is exactly what "Clearing Hold" means -- money in the
|
||||||
|
// clearing pipeline, not a staff-placed lien -- so it shares the bucket with a pending
|
||||||
|
// cheque/DD deposit rather than the generic manual Cash Hold.
|
||||||
|
account.holdAmount(amount, SavingsAccount.HOLD_CATEGORY_CLEARING);
|
||||||
this.savingsAccountRepository.saveAndFlush(account);
|
this.savingsAccountRepository.saveAndFlush(account);
|
||||||
} else {
|
} else {
|
||||||
final CurrentAccount account = this.currentAccountRepository.findById(item.getAccountId()).orElse(null);
|
final CurrentAccount account = this.currentAccountRepository.findById(item.getAccountId()).orElse(null);
|
||||||
|
|||||||
+386
-461
@@ -37,7 +37,6 @@ import jakarta.ws.rs.WebApplicationException;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -45,6 +44,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -52,90 +52,73 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
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.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maker-checker workflow for a member to claim an unclaimed dividend (see
|
* Maker-checker workflow for a member to claim an unclaimed dividend (see {@code m_share_dividend_lock}, status
|
||||||
* {@code m_share_dividend_lock}, status {@code UNCLAIMED} -- a "Dividend Payable" GL liability
|
* {@code UNCLAIMED} -- a "Dividend Payable" GL liability that was never credited into any savings account). A clerk
|
||||||
* that was never credited into any savings account). A clerk (maker) records exactly how the
|
* (maker) records the claim when the member visits the branch; a Bank Manager (checker) approves it, which draws the
|
||||||
* member wants it split when they visit the branch: straight into an overdue loan, credited to
|
* draft, or rejects it, leaving the unclaimed liability untouched. Any amount not claimed stays {@code UNCLAIMED} for a
|
||||||
* their linked savings account, or handed over as cash -- any combination, any leftover amount
|
* later visit, and is forfeited to profit if three years pass.
|
||||||
* stays {@code UNCLAIMED} for a later visit. A Bank Manager (checker) approves it, which executes
|
|
||||||
* the real disbursement, or rejects it, leaving the unclaimed liability untouched.
|
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Disbursement mechanics: a loan-apply or savings-credit leg goes through Fineract's own native
|
* A dividend is collected as a Demand Draft, and only as a Demand Draft. The savings-credit, cash-at-counter and
|
||||||
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
|
* loan-set-off routes this workflow previously offered are retired: a draft leaves a numbered instrument the member
|
||||||
* which debits whatever GL account is actually configured as that product's Fund Source -- not
|
* holds and the branch can trace, where a savings credit disappeared into a running balance and cash left no
|
||||||
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
|
* counterfoil at all. Claims settled through the old routes remain readable — their columns are still populated on
|
||||||
* "Warehouse Receipt" account instead). Since the money never actually came from that account,
|
* those historical rows — but new claims only ever write {@code dd_apply_amount}.
|
||||||
* that same DEBIT leg is rewritten in place to Dividend Payable (see
|
*
|
||||||
* {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual
|
* <p>
|
||||||
* entry pair, so the transaction ends up with exactly the entries a real accountant would expect
|
* The draft is raised through the ordinary Sponsor DD flow, consuming a leaflet from inventory and carrying a real DD
|
||||||
* -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched --
|
* number, so it reconciles, stops and reprints like any other. What differs is the funding: a DD bought at the counter
|
||||||
* instead of that fund-source account confusingly appearing twice. The cash leg has no account to
|
* debits the purchaser, whereas this one is funded from the society's own Dividend Payable liability, because the
|
||||||
* post through, so approval
|
* member is being paid rather than buying anything. That is what {@code payment_method = 'DIVIDEND'} marks on the issue
|
||||||
* posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the
|
* request, and why no charge or GST attaches to it.
|
||||||
* physical hand-over is deferred: the claim's {@code cash_status} becomes
|
|
||||||
* {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be
|
|
||||||
* redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before
|
|
||||||
* the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s
|
|
||||||
* submit/approve/disburse token flow for ordinary savings withdrawals.
|
|
||||||
*/
|
*/
|
||||||
@Path("/v1/clerk/dividend-claim-requests")
|
@Path("/v1/clerk/dividend-claim-requests")
|
||||||
@Component
|
@Component
|
||||||
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend -- split between an overdue loan, savings credit, or cash.")
|
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class DividendClaimRequestApiResource {
|
public class DividendClaimRequestApiResource {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final VoucherService voucherService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||||
|
|
||||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
private static final int FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT = 101;
|
|
||||||
|
/** A dividend payout, keyed by its m_share_dividend_claim_request id. */
|
||||||
|
private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST";
|
||||||
|
/** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */
|
||||||
|
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
|
||||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
|
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named as the purchaser on a dividend draft. The society bought the draft out of its own Dividend Payable, not the
|
||||||
|
* member — so the register shows who actually raised it rather than repeating the beneficiary's name in both
|
||||||
|
* columns, which would read as a counter sale to the member.
|
||||||
|
*/
|
||||||
|
private static final String PURCHASER_SOCIETY_DIVIDEND = "THE SOCIETY (Dividend Payout)";
|
||||||
|
|
||||||
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
|
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
|
||||||
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
|
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
|
||||||
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
|
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount", "r.savings_apply_amount",
|
||||||
"r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount",
|
"r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id",
|
||||||
"l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason",
|
"r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at",
|
||||||
"r.submitted_at", "r.actioned_at" };
|
// The Demand Draft this claim is collected as — the only settlement route for new claims.
|
||||||
|
// loan/savings/cash columns above remain for claims settled before those routes were retired.
|
||||||
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
|
"r.dd_apply_amount", "r.dd_beneficiary_name", "r.dd_payable_at_branch", "r.dd_payable_at_city", "r.dd_issue_request_id",
|
||||||
|
"r.dd_number" };
|
||||||
/** Lets the Claim panel show whether this client has an overdue loan to offer paying off. */
|
|
||||||
@GET
|
|
||||||
@Path("/overdue-loans/{clientId}")
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
public String overdueLoans(@PathParam("clientId") final Long clientId) {
|
|
||||||
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
|
|
||||||
SELECT ln.id AS loan_id, ln.account_no,
|
|
||||||
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
|
|
||||||
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
|
|
||||||
+ GREATEST(ri.interest_amount - COALESCE(ri.interest_completed_derived, 0)
|
|
||||||
- COALESCE(ri.interest_writtenoff_derived, 0) - COALESCE(ri.interest_waived_derived, 0), 0)) AS overdue_amount
|
|
||||||
FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
|
||||||
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
|
||||||
GROUP BY ln.id, ln.account_no
|
|
||||||
ORDER BY overdue_amount DESC
|
|
||||||
""", clientId);
|
|
||||||
final JsonArray result = new JsonArray();
|
|
||||||
for (final Map<String, Object> loan : loans) {
|
|
||||||
final JsonObject row = new JsonObject();
|
|
||||||
row.addProperty("loanId", ((Number) loan.get("loan_id")).longValue());
|
|
||||||
row.addProperty("accountNo", (String) loan.get("account_no"));
|
|
||||||
row.addProperty("overdueAmount", (BigDecimal) loan.get("overdue_amount"));
|
|
||||||
result.add(row);
|
|
||||||
}
|
|
||||||
return gson.toJson(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
|
// ── POST /v1/clerk/dividend-claim-requests ── Clerk records the member's claim ──
|
||||||
|
|
||||||
@@ -147,18 +130,13 @@ public class DividendClaimRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
final JsonElement root = fromJsonHelper.parse(body);
|
final JsonElement root = fromJsonHelper.parse(body);
|
||||||
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
|
final Long lockId = fromJsonHelper.extractLongNamed("lockId", root);
|
||||||
final Long loanId = fromJsonHelper.extractLongNamed("loanId", root);
|
final BigDecimal ddApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("ddApplyAmount", root));
|
||||||
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
|
final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root));
|
||||||
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
|
final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root));
|
||||||
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
|
final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root));
|
||||||
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1),
|
// Which physical leaflet the clerk took off the shelf. Optional: a caller that has not
|
||||||
// while a dividend share often works out to paise -- floor to the nearest rupee (never
|
// chosen one gets the oldest available instead.
|
||||||
// trusting the frontend to have already done so) so the denomination breakdown the
|
final Long requestedLeafletId = fromJsonHelper.extractLongNamed("leafletId", root);
|
||||||
// cashier counts at disburse time can always match exactly; any leftover paise is simply
|
|
||||||
// not claimed here and stays part of the lock's balance for a future claim.
|
|
||||||
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root))
|
|
||||||
.setScale(0, RoundingMode.FLOOR);
|
|
||||||
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
|
||||||
if (lockId == null) {
|
if (lockId == null) {
|
||||||
throw apiError(400, "lockId is required");
|
throw apiError(400, "lockId is required");
|
||||||
}
|
}
|
||||||
@@ -171,53 +149,19 @@ public class DividendClaimRequestApiResource {
|
|||||||
}
|
}
|
||||||
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
||||||
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||||
final Object lockSavingsAccountId = lock.get("savings_account_id");
|
|
||||||
|
|
||||||
// The share account isn't required to have a linked savings account up front any more --
|
if (ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
// if the member wants a savings credit and none is linked, the clerk supplies which
|
throw apiError(400, "ddApplyAmount is required and must be greater than zero");
|
||||||
// account to credit right here.
|
}
|
||||||
Long resolvedSavingsAccountId = lockSavingsAccountId != null ? ((Number) lockSavingsAccountId).longValue()
|
if (ddApplyAmount.compareTo(unclaimedAmount) > 0) {
|
||||||
: savingsAccountIdOverride;
|
|
||||||
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
if (resolvedSavingsAccountId == null) {
|
|
||||||
throw apiError(400,
|
throw apiError(400,
|
||||||
"This share account has no linked savings account -- savingsAccountId is required when savingsApplyAmount is greater than zero");
|
"Amount claimed (" + ddApplyAmount + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
|
||||||
}
|
}
|
||||||
final Long savingsClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
|
if (ddBeneficiaryName == null) {
|
||||||
resolvedSavingsAccountId);
|
throw apiError(400, "ddBeneficiaryName is required — a Demand Draft cannot be drawn without a payee");
|
||||||
if (savingsClientId == null || !clientId.equals(savingsClientId)) {
|
|
||||||
throw apiError(400, "Savings account " + resolvedSavingsAccountId + " does not belong to this dividend's client");
|
|
||||||
}
|
}
|
||||||
}
|
if (ddPayableAtBranch == null || ddPayableAtCity == null) {
|
||||||
|
throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft");
|
||||||
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
|
|
||||||
if (total.compareTo(BigDecimal.ZERO) <= 0) {
|
|
||||||
throw apiError(400, "Enter at least one of loanApplyAmount, savingsApplyAmount or cashApplyAmount, greater than zero");
|
|
||||||
}
|
|
||||||
if (total.compareTo(unclaimedAmount) > 0) {
|
|
||||||
throw apiError(400, "Total claimed (" + total + ") cannot exceed the unclaimed dividend amount of " + unclaimedAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
if (loanId == null) {
|
|
||||||
throw apiError(400, "loanId is required when loanApplyAmount is greater than zero");
|
|
||||||
}
|
|
||||||
final Map<String, Object> loan = jdbcTemplate
|
|
||||||
.queryForList("SELECT client_id, loan_status_id FROM m_loan WHERE id = ?", loanId).stream().findFirst()
|
|
||||||
.orElseThrow(() -> apiError(404, "Loan not found: " + loanId));
|
|
||||||
if (!clientId.equals(((Number) loan.get("client_id")).longValue())) {
|
|
||||||
throw apiError(400, "Loan " + loanId + " does not belong to this dividend's client");
|
|
||||||
}
|
|
||||||
if (((Number) loan.get("loan_status_id")).intValue() != 300) {
|
|
||||||
throw apiError(400, "Loan " + loanId + " is not Active");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
if (tokNo == null || tokNo.isBlank()) {
|
|
||||||
throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero");
|
|
||||||
}
|
|
||||||
validateTokenUniqueToday(tokNo, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final Long existingPending = jdbcTemplate.queryForObject(
|
final Long existingPending = jdbcTemplate.queryForObject(
|
||||||
@@ -226,40 +170,129 @@ public class DividendClaimRequestApiResource {
|
|||||||
throw apiError(409, "A claim request for this dividend is already pending review.");
|
throw apiError(409, "A claim request for this dividend is already pending review.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateNotGuarantorOfOverdueLoan(clientId);
|
||||||
|
|
||||||
|
// The leaflet is taken now rather than at approval, so the DD number exists from the moment
|
||||||
|
// the claim is raised and the draft has a place in the queue that nothing else can take.
|
||||||
|
//
|
||||||
|
// The clerk chooses which one on the leaflets screen, and that choice is honoured: the draft
|
||||||
|
// has to be drawn on the physical form in their hand, or the number written on the
|
||||||
|
// counterfoil is not the number in the books. Locked so two concurrent claims cannot take the
|
||||||
|
// same leaflet; if it was taken in the meantime the clerk is told rather than being given a
|
||||||
|
// different draft than the one they are holding.
|
||||||
|
final Map<String, Object> leaflet = requestedLeafletId != null
|
||||||
|
? jdbcTemplate
|
||||||
|
.queryForList("SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ? AND status = 'AVAILABLE' "
|
||||||
|
+ "FOR UPDATE SKIP LOCKED", requestedLeafletId)
|
||||||
|
.stream().findFirst()
|
||||||
|
.orElseThrow(() -> apiError(409, "That Demand Draft leaflet is no longer available — someone else has "
|
||||||
|
+ "just taken it. Go back and choose another leaflet."))
|
||||||
|
// No leaflet named (a caller that is not the leaflets screen): fall back to the
|
||||||
|
// oldest available, which is the order a series is meant to be consumed in.
|
||||||
|
: jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
|
||||||
|
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||||
|
""").stream().findFirst().orElseThrow(() -> apiError(409,
|
||||||
|
"No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD."));
|
||||||
|
final Long leafletId = ((Number) leaflet.get("id")).longValue();
|
||||||
|
final String ddNumber = (String) leaflet.get("dd_number");
|
||||||
|
final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||||
|
|
||||||
|
// loan/savings/cash apply amounts are written as zero: those routes are retired, but the
|
||||||
|
// columns remain for claims settled through them historically.
|
||||||
final Long newId = jdbcTemplate.queryForObject("""
|
final Long newId = jdbcTemplate.queryForObject("""
|
||||||
INSERT INTO m_share_dividend_claim_request
|
INSERT INTO m_share_dividend_claim_request
|
||||||
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
|
(lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount,
|
||||||
cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no)
|
dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
|
status, clerk_id, clerk_name, submitted_at, dd_number)
|
||||||
|
VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
|
""", Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
|
||||||
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
|
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), ddNumber);
|
||||||
cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null);
|
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
// Raised as an ordinary PENDING issue request so the draft joins the DD Issuance Approvals
|
||||||
|
// queue with every other draft, rather than sitting in a second approvals screen of its own.
|
||||||
|
// payment_method 'DIVIDEND' is what tells that approval there is no purchaser to debit —
|
||||||
|
// the society already owes this money, so the draft is funded from Dividend Payable and the
|
||||||
|
// member pays nothing: no charge, no GST, which is why those columns are zero.
|
||||||
|
final Long issueRequestId = jdbcTemplate.queryForObject("""
|
||||||
|
INSERT INTO m_sponsor_dd_issue_request
|
||||||
|
(leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city,
|
||||||
|
face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date,
|
||||||
|
payment_method, status, maker_id, created_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'PENDING', ?, ?)
|
||||||
|
RETURNING id
|
||||||
|
""", Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
|
||||||
|
ddApplyAmount, branchId, java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), currentUser.getId(),
|
||||||
|
Timestamp.from(Instant.now()));
|
||||||
|
|
||||||
|
jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET dd_issue_request_id = ? WHERE id = ?", issueRequestId, newId);
|
||||||
|
|
||||||
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "ddNumber", ddNumber, "ddIssueRequestId", issueRequestId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the token number is unique for today's date across both this table and
|
* Blocks a dividend claim while the member stands as guarantor on someone else's overdue loan.
|
||||||
* {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same
|
*
|
||||||
* Transaction Entry field, so the two pools must never collide.
|
* A guarantee is a promise to pay if the borrower does not, and the guarantor's share capital is the security
|
||||||
|
* behind it — the same capital this dividend was earned on. Paying the dividend out by draft while the guaranteed
|
||||||
|
* loan is in arrears hands over money the society may be about to demand back from the very same member, and once
|
||||||
|
* a draft is drawn there is nothing left to recover it from.
|
||||||
|
*
|
||||||
|
* Only OVERDUE guarantees block. A guarantee on a loan being repaid on time is a contingent obligation that has
|
||||||
|
* not crystallised, and withholding a dividend against it would penalise a member for a loan nobody has defaulted
|
||||||
|
* on. The block lifts on its own the moment the borrower clears the arrears.
|
||||||
|
*
|
||||||
|
* Deliberately not a silent hold: the clerk is told which loan, whose, and how much is overdue, so they can tell
|
||||||
|
* the member standing at the counter why — and so the member can go to the borrower about it.
|
||||||
*/
|
*/
|
||||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
private void validateNotGuarantorOfOverdueLoan(final Long clientId) {
|
||||||
final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
final List<Map<String, Object>> overdueGuarantees = jdbcTemplate.queryForList("""
|
||||||
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
|
SELECT l.account_no,
|
||||||
final List<Object> params = new ArrayList<>();
|
COALESCE(b.display_name, '(unnamed borrower)') AS borrower_name,
|
||||||
params.add(tokNo);
|
COALESCE(SUM(ri.principal_amount + ri.interest_amount
|
||||||
if (excludeId != null) {
|
- COALESCE(ri.principal_completed_derived, 0)
|
||||||
sql.append(" AND id <> ?");
|
- COALESCE(ri.interest_completed_derived, 0)), 0) AS overdue_amount
|
||||||
params.add(excludeId);
|
FROM m_guarantor g
|
||||||
|
JOIN m_loan l ON l.id = g.loan_id
|
||||||
|
LEFT JOIN m_client b ON b.id = l.client_id
|
||||||
|
JOIN m_loan_repayment_schedule ri ON ri.loan_id = l.id
|
||||||
|
WHERE g.entity_id = ?
|
||||||
|
AND g.type_enum = 1
|
||||||
|
AND g.is_active = true
|
||||||
|
AND l.loan_status_id IN (300, 303, 304, 700)
|
||||||
|
AND ri.completed_derived = false
|
||||||
|
AND ri.duedate < CURRENT_DATE
|
||||||
|
GROUP BY l.account_no, b.display_name
|
||||||
|
HAVING COALESCE(SUM(ri.principal_amount + ri.interest_amount
|
||||||
|
- COALESCE(ri.principal_completed_derived, 0)
|
||||||
|
- COALESCE(ri.interest_completed_derived, 0)), 0) > 0
|
||||||
|
ORDER BY overdue_amount DESC
|
||||||
|
""", clientId);
|
||||||
|
|
||||||
|
if (overdueGuarantees.isEmpty()) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray());
|
|
||||||
final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request "
|
final StringBuilder detail = new StringBuilder();
|
||||||
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo);
|
for (final Map<String, Object> guarantee : overdueGuarantees) {
|
||||||
final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount);
|
if (detail.length() > 0) {
|
||||||
if (total > 0) {
|
detail.append("; ");
|
||||||
throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
|
||||||
}
|
}
|
||||||
|
detail.append("loan ").append(guarantee.get("account_no")).append(" (").append(guarantee.get("borrower_name"))
|
||||||
|
.append(") overdue ").append(guarantee.get("overdue_amount"));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw apiError(409, "This member is an active guarantor on an overdue loan, so their dividend cannot be paid out yet: "
|
||||||
|
+ detail + ". The dividend stays unclaimed until the arrears are cleared or the guarantee ends.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(final String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal parseAmount(final String value) {
|
private BigDecimal parseAmount(final String value) {
|
||||||
@@ -317,373 +350,243 @@ public class DividendClaimRequestApiResource {
|
|||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
|
throw apiError(409, "Only PENDING claim requests can be approved. Current status: " + record.get("status"));
|
||||||
}
|
}
|
||||||
|
// 409 rather than 403: the user has every right to approve dividend claims — just not this
|
||||||
|
// one, because they raised it. A 403 reads as "you lack permission", which sends the checker
|
||||||
|
// to an administrator to fix access that was never the problem.
|
||||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||||
if (makerId.equals(currentUser.getId())) {
|
if (makerId.equals(currentUser.getId())) {
|
||||||
throw apiError(403, "Maker-checker: you cannot approve a dividend claim you submitted yourself. Ask another manager.");
|
throw apiError(409, "You raised this dividend claim, so you cannot also approve it. Two different people must handle a "
|
||||||
|
+ "payout: one to raise it, another to check it. Ask a different manager to approve this claim.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final Long lockId = ((Number) record.get("lock_id")).longValue();
|
final Long lockId = ((Number) record.get("lock_id")).longValue();
|
||||||
final Map<String, Object> lock = jdbcTemplate
|
final Map<String, Object> lock = jdbcTemplate
|
||||||
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream()
|
.queryForList("SELECT currency_code, amount, status FROM m_share_dividend_lock WHERE id = ?", lockId).stream().findFirst()
|
||||||
.findFirst().orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
|
.orElseThrow(() -> apiError(404, "Dividend lock not found: " + lockId));
|
||||||
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
if (!"UNCLAIMED".equals(lock.get("status"))) {
|
||||||
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
|
throw apiError(409, "This dividend is no longer UNCLAIMED (status: " + lock.get("status") + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-checked at approval, not just at submission: a claim can sit pending for days, and a
|
||||||
|
// guaranteed loan can fall into arrears in that window. Approval is the point the draft is
|
||||||
|
// actually drawn, so it is the last moment the society can still hold the money back.
|
||||||
|
validateNotGuarantorOfOverdueLoan(((Number) record.get("client_id")).longValue());
|
||||||
|
|
||||||
final String currencyCode = (String) lock.get("currency_code");
|
final String currencyCode = (String) lock.get("currency_code");
|
||||||
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||||
final BigDecimal loanApplyAmount = (BigDecimal) record.get("loan_apply_amount");
|
final BigDecimal ddApplyAmount = (BigDecimal) record.get("dd_apply_amount");
|
||||||
final BigDecimal savingsApplyAmount = (BigDecimal) record.get("savings_apply_amount");
|
if (ddApplyAmount == null || ddApplyAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
|
throw apiError(409, "This claim has no Demand Draft amount recorded.");
|
||||||
final BigDecimal total = loanApplyAmount.add(savingsApplyAmount).add(cashApplyAmount);
|
}
|
||||||
if (total.compareTo(unclaimedAmount) > 0) {
|
if (ddApplyAmount.compareTo(unclaimedAmount) > 0) {
|
||||||
throw apiError(409,
|
throw apiError(409,
|
||||||
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
|
"The unclaimed amount has since reduced below what this claim requested -- reject this claim and resubmit.");
|
||||||
}
|
}
|
||||||
final Object claimSavingsAccountId = record.get("savings_account_id");
|
|
||||||
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0 && claimSavingsAccountId == null) {
|
|
||||||
throw apiError(409, "No savings account was recorded on this claim for the savings-credit portion.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
final String note = "Dividend claim #" + id + " (lock #" + lockId + ")";
|
final Map<String, Object> issuedDraft = issueDemandDraftFromDividendPayable(id, lockId, record, ddApplyAmount, currencyCode, today,
|
||||||
|
currentUser);
|
||||||
|
|
||||||
if (loanApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
final BigDecimal remainder = unclaimedAmount.subtract(ddApplyAmount);
|
||||||
final Long loanId = ((Number) record.get("loan_id")).longValue();
|
|
||||||
final JsonObject payload = new JsonObject();
|
|
||||||
payload.addProperty("locale", "en");
|
|
||||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
|
||||||
payload.addProperty("transactionDate", today.toString());
|
|
||||||
payload.addProperty("transactionAmount", loanApplyAmount);
|
|
||||||
payload.addProperty("note", note);
|
|
||||||
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
|
||||||
.withJson(payload.toString()).build();
|
|
||||||
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
|
||||||
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
final JsonObject payload = new JsonObject();
|
|
||||||
payload.addProperty("locale", "en");
|
|
||||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
|
||||||
payload.addProperty("transactionDate", today.toString());
|
|
||||||
payload.addProperty("transactionAmount", savingsApplyAmount);
|
|
||||||
payload.addProperty("note", note);
|
|
||||||
payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId());
|
|
||||||
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
|
|
||||||
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
|
||||||
.withJson(payload.toString()).build();
|
|
||||||
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
|
|
||||||
redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
payCashFromDividendPayable(id, currencyCode, cashApplyAmount, today);
|
|
||||||
}
|
|
||||||
|
|
||||||
final BigDecimal remainder = unclaimedAmount.subtract(total);
|
|
||||||
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
|
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId);
|
||||||
lockId);
|
|
||||||
} else {
|
} else {
|
||||||
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
|
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null;
|
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?,
|
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?,
|
||||||
cash_status = ?
|
dd_issue_request_id = ?, dd_number = ?
|
||||||
WHERE id = ? AND status = 'PENDING'
|
WHERE id = ? AND status = 'PENDING'
|
||||||
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id);
|
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
|
||||||
|
issuedDraft.get("issueRequestId"), issuedDraft.get("ddNumber"), id);
|
||||||
|
|
||||||
final JsonObject response = new JsonObject();
|
final JsonObject response = new JsonObject();
|
||||||
response.addProperty("resourceId", id);
|
response.addProperty("resourceId", id);
|
||||||
response.addProperty("status", "APPROVED");
|
response.addProperty("status", "APPROVED");
|
||||||
response.addProperty("totalDisbursed", total);
|
response.addProperty("totalDisbursed", ddApplyAmount);
|
||||||
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
|
response.addProperty("remainingUnclaimed", remainder.max(BigDecimal.ZERO));
|
||||||
|
response.addProperty("ddNumber", (String) issuedDraft.get("ddNumber"));
|
||||||
|
response.addProperty("ddIssueRequestId", (Long) issuedDraft.get("issueRequestId"));
|
||||||
return gson.toJson(response);
|
return gson.toJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ──
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction
|
* Draws the Demand Draft the member is collecting their dividend as, funded by the society's Dividend Payable
|
||||||
* Entry types the clerk-supplied token number here to resolve the approved cash amount before
|
* liability rather than by anything the member pays in.
|
||||||
* disbursing it, instead of the manager handing over cash directly at approval time.
|
*
|
||||||
|
* A DD raised at the counter debits the purchaser — their savings account, or cash they hand over — because they
|
||||||
|
* are buying the instrument. Here the society already owes the money, so the draft is funded from Dividend Payable
|
||||||
|
* and the member pays nothing: no charge, no GST. That is what payment_method 'DIVIDEND' marks, and why those
|
||||||
|
* columns are written as zero.
|
||||||
|
*
|
||||||
|
* The leaflet is taken from inventory and marked ISSUED exactly as the normal flow does, so the draft carries a
|
||||||
|
* real DD number, appears in DD reconciliation, and can be stopped or reprinted like any other.
|
||||||
*/
|
*/
|
||||||
@GET
|
private Map<String, Object> issueDemandDraftFromDividendPayable(final Long claimId, final Long lockId,
|
||||||
@Path("/by-token/{token}")
|
final Map<String, Object> record, final BigDecimal amount, final String currencyCode, final LocalDate today,
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
final AppUser currentUser) {
|
||||||
public String getByToken(@PathParam("token") final String token) {
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount,
|
|
||||||
r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at
|
|
||||||
FROM m_share_dividend_claim_request r
|
|
||||||
JOIN m_client c ON c.id = r.client_id
|
|
||||||
WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE
|
|
||||||
ORDER BY r.submitted_at DESC LIMIT 1
|
|
||||||
""", token);
|
|
||||||
|
|
||||||
if (rows.isEmpty()) {
|
// Oldest series first, and locked so two concurrent approvals cannot take the same leaflet.
|
||||||
throw apiError(404, "Token '" + token + "' not found for today. Please check the token number.");
|
final Map<String, Object> leaflet = jdbcTemplate.queryForList("""
|
||||||
}
|
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
|
||||||
|
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||||
|
""").stream().findFirst()
|
||||||
|
.orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory."));
|
||||||
|
final Long leafletId = ((Number) leaflet.get("id")).longValue();
|
||||||
|
final String ddNumber = (String) leaflet.get("dd_number");
|
||||||
|
|
||||||
final Map<String, Object> row = rows.get(0);
|
|
||||||
final String status = String.valueOf(row.get("status"));
|
|
||||||
final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status"));
|
|
||||||
|
|
||||||
if ("PENDING".equals(status)) {
|
|
||||||
throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet.");
|
|
||||||
}
|
|
||||||
if ("REJECTED".equals(status)) {
|
|
||||||
throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed.");
|
|
||||||
}
|
|
||||||
if (!"APPROVED".equals(status)) {
|
|
||||||
throw apiError(422, "Token '" + token + "' has unexpected status: " + status);
|
|
||||||
}
|
|
||||||
if ("DISBURSED".equals(cashStatus)) {
|
|
||||||
throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
|
|
||||||
}
|
|
||||||
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
|
|
||||||
throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement.");
|
|
||||||
}
|
|
||||||
return gson.toJson(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at
|
|
||||||
* approve time, so this step is purely a physical-cash-handover record with double-disbursement
|
|
||||||
* protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction.
|
|
||||||
*/
|
|
||||||
@POST
|
|
||||||
@Path("/{id}/disburse")
|
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
@Transactional
|
|
||||||
public String disburse(@PathParam("id") final Long id, final String body) {
|
|
||||||
final AppUser currentUser = context.authenticatedUser();
|
|
||||||
final Map<String, Object> record = jdbcTemplate
|
|
||||||
.queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?",
|
|
||||||
id)
|
|
||||||
.stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id));
|
|
||||||
|
|
||||||
final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status"));
|
|
||||||
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
|
|
||||||
throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
String denominationData = null;
|
|
||||||
BigDecimal transactionAmount = null;
|
|
||||||
if (body != null && !body.isBlank()) {
|
|
||||||
final JsonElement root = fromJsonHelper.parse(body);
|
|
||||||
denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
|
||||||
transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root));
|
|
||||||
}
|
|
||||||
if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
|
||||||
throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// cash_apply_amount is floored to whole rupees at submit time now (see submit() above),
|
|
||||||
// but a claim created before that fix may still carry paise -- floor here too so an older
|
|
||||||
// claim can still be disbursed in whole-rupee cash rather than being stuck forever.
|
|
||||||
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
|
|
||||||
final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR);
|
|
||||||
if (disbursableAmount.compareTo(transactionAmount) != 0) {
|
|
||||||
throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of "
|
|
||||||
+ disbursableAmount + " (cash can only be handed over in whole rupees).");
|
|
||||||
}
|
|
||||||
|
|
||||||
final long denomTotal = denominationTotal(denominationData);
|
|
||||||
if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) {
|
|
||||||
throw apiError(400,
|
|
||||||
"Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
final int updated = jdbcTemplate.update("""
|
|
||||||
UPDATE m_share_dividend_claim_request SET
|
|
||||||
cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ?
|
|
||||||
WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT'
|
|
||||||
""", currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id);
|
|
||||||
|
|
||||||
if (updated == 0) {
|
|
||||||
throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (denominationData != null && !denominationData.isBlank()) {
|
|
||||||
final Long clientId = ((Number) record.get("client_id")).longValue();
|
final Long clientId = ((Number) record.get("client_id")).longValue();
|
||||||
final Long lockId = ((Number) record.get("lock_id")).longValue();
|
final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||||
saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
|
// APPROVED outright: the manager approving this claim IS the check on it, and a second
|
||||||
}
|
// maker-checker round on the draft would leave a member's paid dividend in limbo.
|
||||||
|
// client_id still records WHOSE dividend this is — the entitlement belongs to them and the
|
||||||
/** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */
|
// draft has to be traceable back to the member. walkin_purchaser_name names who actually
|
||||||
private long denominationTotal(final String denominationData) {
|
// raised it, which is the society, so the two roles stay distinguishable.
|
||||||
if (denominationData == null || denominationData.isBlank()) {
|
final Long issueRequestId = jdbcTemplate.queryForObject("""
|
||||||
return 0;
|
INSERT INTO m_sponsor_dd_issue_request
|
||||||
}
|
(leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city,
|
||||||
final JsonObject denomMap;
|
face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date,
|
||||||
try {
|
payment_method, status, maker_id, created_date, checker_id, checker_date)
|
||||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'APPROVED', ?, ?, ?, ?)
|
||||||
} catch (final Exception e) {
|
RETURNING id
|
||||||
throw apiError(400, "denominationData is not valid JSON");
|
""", Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"),
|
||||||
}
|
record.get("dd_payable_at_branch"), record.get("dd_payable_at_city"), amount, branchId, java.sql.Date.valueOf(today),
|
||||||
if (denomMap == null) {
|
((Number) record.get("clerk_id")).longValue(), Timestamp.from(Instant.now()), currentUser.getId(),
|
||||||
return 0;
|
Timestamp.from(Instant.now()));
|
||||||
}
|
|
||||||
long total = 0;
|
|
||||||
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
|
||||||
final String[] parts = entry.getKey().split(":", 2);
|
|
||||||
if (parts.length != 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final int value;
|
|
||||||
final int count;
|
|
||||||
try {
|
|
||||||
value = Integer.parseInt(parts[1]);
|
|
||||||
count = entry.getValue().getAsInt();
|
|
||||||
} catch (final Exception e) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (count > 0) {
|
|
||||||
total += (long) value * count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records the cashier's note/coin breakdown for a dividend cash disbursement into
|
|
||||||
* {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings
|
|
||||||
* transaction backing this (the cash leg is a pure GL entry posted at approve time), so this
|
|
||||||
* claim request's own id is used as the stable txn identifier for grouping.
|
|
||||||
*/
|
|
||||||
private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId,
|
|
||||||
final String denominationData, final AppUser cashier) {
|
|
||||||
final JsonObject denomMap;
|
|
||||||
try {
|
|
||||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
|
||||||
} catch (final Exception e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (denomMap == null || denomMap.size() == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
|
||||||
final int count;
|
|
||||||
try {
|
|
||||||
count = entry.getValue().getAsInt();
|
|
||||||
} catch (final Exception e) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (count <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String[] parts = entry.getKey().split(":", 2);
|
|
||||||
if (parts.length != 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final String kind = parts[0];
|
|
||||||
final int value;
|
|
||||||
try {
|
|
||||||
value = Integer.parseInt(parts[1]);
|
|
||||||
} catch (final Exception e) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Long> ids = jdbcTemplate.queryForList(
|
|
||||||
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1",
|
|
||||||
Long.class, value, kind);
|
|
||||||
if (ids.isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final Long denomId = ids.get(0);
|
|
||||||
|
|
||||||
|
// The leaflet carries its own copy of the draft's details, and the DD register reads from
|
||||||
|
// there rather than from the issue request — so setting the status alone leaves the draft
|
||||||
|
// listed with every column blank. Mirrors what the ordinary issue flow writes.
|
||||||
|
//
|
||||||
|
// issued_to_client_id is deliberately left NULL and the purchaser named as the society
|
||||||
|
// instead. On an ordinary draft the client IS the purchaser — they walked in and bought it.
|
||||||
|
// Here the society is paying the member their own dividend, so recording the member as
|
||||||
|
// purchaser would read as though they had bought the draft, and the register would show the
|
||||||
|
// same name as both purchaser and beneficiary with nothing to distinguish a
|
||||||
|
// society-initiated draft from a counter sale.
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_savings_txn_denomination
|
UPDATE m_sponsor_dd_leaflet
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
SET status = 'ISSUED', face_value = ?, issued_to_client_id = NULL,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
walkin_purchaser_name = ?, beneficiary_name = ?,
|
||||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
|
payable_at_branch = ?, payable_at_city = ?, issuing_branch_id = ?, issued_date = ?,
|
||||||
""", claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count,
|
advice_status = 'NOT_SENT'
|
||||||
BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername());
|
WHERE id = ?
|
||||||
}
|
""", amount, PURCHASER_SOCIETY_DIVIDEND, record.get("dd_beneficiary_name"), record.get("dd_payable_at_branch"),
|
||||||
|
record.get("dd_payable_at_city"), branchId, java.sql.Date.valueOf(today), leafletId);
|
||||||
|
|
||||||
|
postDividendPayableToDdPayable(claimId, lockId, ddNumber, currencyCode, amount, today, currentUser);
|
||||||
|
|
||||||
|
final Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||||
|
result.put("issueRequestId", issueRequestId);
|
||||||
|
result.put("ddNumber", ddNumber);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fineract's savings deposit command mandates paymentTypeId even for an internal posting with
|
* Moves the member's entitlement from Dividend Payable to the DD liability: the society still owes the money, but
|
||||||
* no real customer payment instrument -- resolves the system-seeded "Dividend Claim
|
* now against a numbered instrument rather than an unclaimed dividend. Cleared when the draft is presented and
|
||||||
* Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which
|
* paid, through the normal DD settlement.
|
||||||
* would break on any tenant seeded differently.
|
*
|
||||||
|
* Skipped if the DD liability GL is not configured, rather than blocking the claim — same posture the declaration
|
||||||
|
* and forfeiture entries take, so a missing GL mapping never traps a member's dividend.
|
||||||
*/
|
*/
|
||||||
private Long resolveDividendClaimPaymentTypeId() {
|
private void postDividendPayableToDdPayable(final Long claimId, final Long lockId, final String ddNumber, final String currencyCode,
|
||||||
final Long paymentTypeId = jdbcTemplate
|
final BigDecimal amount, final LocalDate today, final AppUser currentUser) {
|
||||||
.queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream()
|
|
||||||
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
final Long dividendPaidAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_DIVIDEND_PAID)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
final Long ddPayableAccountId = jdbcTemplate
|
||||||
|
.queryForList("SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping ORDER BY id LIMIT 1", Long.class).stream()
|
||||||
.findFirst().orElse(null);
|
.findFirst().orElse(null);
|
||||||
if (paymentTypeId == null) {
|
if (payableDividendsAccountId == null || ddPayableAccountId == null) {
|
||||||
throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations.");
|
|
||||||
}
|
|
||||||
return paymentTypeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The loan repayment / savings deposit command just posted its own native GL entries assuming
|
|
||||||
* the money came out of whichever account is that product's actual Fund Source (not always
|
|
||||||
* literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its
|
|
||||||
* own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with
|
|
||||||
* a second manual entry pair (which would leave that fund-source account appearing twice, once
|
|
||||||
* debited then immediately credited back -- a confusing "wash" no real accountant would post),
|
|
||||||
* this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction
|
|
||||||
* ends up with exactly the entries a real accountant would expect: Dividend Payable debited,
|
|
||||||
* Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still
|
|
||||||
* updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of
|
|
||||||
* the two ids is non-null per call.
|
|
||||||
*/
|
|
||||||
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) {
|
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
if (payableDividendsAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loanTransactionId != null) {
|
|
||||||
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
|
|
||||||
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
|
||||||
}
|
|
||||||
if (savingsTransactionId != null) {
|
|
||||||
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?",
|
|
||||||
payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A straight cash handover has no account to post through, so Cash at Main Vault genuinely is
|
|
||||||
* correct here (unlike the loan/savings legs above) -- real cash leaves the vault into the
|
|
||||||
* member's hand.
|
|
||||||
*/
|
|
||||||
private void payCashFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
|
|
||||||
final LocalDate entryDate) {
|
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
final Long cashAtVaultAccountId = jdbcTemplate
|
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_CASH_AT_MAINVAULT)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
if (payableDividendsAccountId == null || cashAtVaultAccountId == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final AppUser currentUser = context.authenticatedUser();
|
final java.sql.Date entryDate = java.sql.Date.valueOf(today);
|
||||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
final String transactionId = "DIVDD" + claimId;
|
||||||
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
|
// Stamped with the financial year the dividend was DECLARED for, not the year it is being
|
||||||
final String description = "Dividend claim #" + claimRequestId + " disbursed";
|
// claimed in — a member can collect a 2026-27 dividend in 2028. Without it the ledger cannot
|
||||||
|
// say how much of each year's declaration has actually been claimed.
|
||||||
|
final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #"
|
||||||
|
+ claimId + " (lock #" + lockId + ")";
|
||||||
|
if (dividendPaidAccountId == null) {
|
||||||
|
// Dividend Paid not mapped: post straight across, exactly as before this account existed.
|
||||||
|
// A society that has not configured it still gets a correct payout, just without the
|
||||||
|
// separate "how much has been claimed" measure.
|
||||||
|
postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description,
|
||||||
|
currentUser);
|
||||||
|
raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routed through Dividend Paid rather than posted straight across, so the society can read
|
||||||
|
// off what has been claimed and what is still unclaimed as two separate balances instead of
|
||||||
|
// only the net of Dividend Payable.
|
||||||
|
//
|
||||||
|
// Two pairs, in the order the money moves, each with its own description — reading Dividend
|
||||||
|
// Paid's ledger has to show which line settled the member's entitlement and which raised the
|
||||||
|
// draft, and one shared description makes the two indistinguishable.
|
||||||
|
postJournalEntryPair(payableDividendsAccountId, dividendPaidAccountId, amount, currencyCode, transactionId, entryDate,
|
||||||
|
description + " (member's entitlement settled)", currentUser);
|
||||||
|
// The obligation does not end here: the society still owes the money until the draft is
|
||||||
|
// presented and paid, which is why this moves to Sponsor DD Payable rather than to cash.
|
||||||
|
// Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed.
|
||||||
|
postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate,
|
||||||
|
description + " (draft raised)", currentUser);
|
||||||
|
|
||||||
|
// The voucher names the two accounts the payout actually moves between, not the three the
|
||||||
|
// ledger touches. Dividend Paid nets to nil across the pair above: it is there to measure
|
||||||
|
// how much has been claimed, and putting it on the slip would show the member a middle
|
||||||
|
// account that holds none of their money.
|
||||||
|
raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the payout in the voucher register: Dividend Payable debited, the DD liability credited.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the claim. The dividend is already posted and the draft already
|
||||||
|
* raised, and refusing an approved payout because its slip could not be written would hold back
|
||||||
|
* a member's money over paperwork. The claim stands, and the register shows a gap.
|
||||||
|
*/
|
||||||
|
private void raiseDividendVoucher(final Long claimId, final Long debitGlId, final Long creditGlId, final BigDecimal amount,
|
||||||
|
final String description, final AppUser currentUser) {
|
||||||
|
try {
|
||||||
|
final Map<String, Object> debitGl = glAccountForVoucher(debitGlId);
|
||||||
|
final Map<String, Object> creditGl = glAccountForVoucher(creditGlId);
|
||||||
|
|
||||||
|
voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser,
|
||||||
|
VoucherService.PREFIX_DEBIT,
|
||||||
|
List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount)
|
||||||
|
.withDescription(description),
|
||||||
|
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
|
||||||
|
.withDescription(description)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for dividend claim {}", claimId, 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) {
|
||||||
|
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||||
|
.orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts one balanced journal-entry pair — debit account first, then credit — so the entry ids follow the direction
|
||||||
|
* the money travelled and the ledger reads in order.
|
||||||
|
*/
|
||||||
|
private void postJournalEntryPair(final Long debitAccountId, final Long creditAccountId, final BigDecimal amount,
|
||||||
|
final String currencyCode, final String transactionId, final java.sql.Date entryDate, final String description,
|
||||||
|
final AppUser currentUser) {
|
||||||
|
|
||||||
final String insertSql = """
|
final String insertSql = """
|
||||||
INSERT INTO acc_gl_journal_entry
|
INSERT INTO acc_gl_journal_entry
|
||||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
||||||
@@ -691,13 +594,12 @@ public class DividendClaimRequestApiResource {
|
|||||||
submitted_on_date, transaction_date)
|
submitted_on_date, transaction_date)
|
||||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||||
""";
|
""";
|
||||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
jdbcTemplate.update(insertSql, debitAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_DEBIT, amount,
|
||||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
|
||||||
this.jdbcTemplate.update(insertSql, cashAtVaultAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
jdbcTemplate.update(insertSql, creditAccountId, currencyCode, transactionId, entryDate, JOURNAL_ENTRY_TYPE_CREDIT, amount,
|
||||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
description, currentUser.getId(), currentUser.getId(), entryDate, entryDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /v1/clerk/dividend-claim-requests/{id}/reject ── Manager rejects ───
|
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{id}/reject")
|
@Path("/{id}/reject")
|
||||||
@@ -742,6 +644,7 @@ public class DividendClaimRequestApiResource {
|
|||||||
private Map<String, Object> loadRecord(final Long id) {
|
private Map<String, Object> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
|
SELECT id, lock_id, client_id, savings_account_id, loan_id, loan_apply_amount, savings_apply_amount, cash_apply_amount,
|
||||||
|
dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
|
||||||
status, clerk_id, clerk_name
|
status, clerk_id, clerk_name
|
||||||
FROM m_share_dividend_claim_request WHERE id = ?
|
FROM m_share_dividend_claim_request WHERE id = ?
|
||||||
""", id);
|
""", id);
|
||||||
@@ -751,6 +654,28 @@ public class DividendClaimRequestApiResource {
|
|||||||
return rows.get(0);
|
return rows.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The financial year of the declaration a lock came from — walking lock to per-member detail to declaration.
|
||||||
|
*
|
||||||
|
* Returns "unknown" rather than throwing if that chain is broken: a missing label must never stop a member being
|
||||||
|
* paid their dividend.
|
||||||
|
*/
|
||||||
|
private String financialYearOfLock(final Long lockId) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.dividend_period_end_date
|
||||||
|
FROM m_share_dividend_lock l
|
||||||
|
JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
|
||||||
|
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
|
||||||
|
WHERE l.id = ?
|
||||||
|
""", lockId);
|
||||||
|
if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date")));
|
||||||
|
final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1;
|
||||||
|
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||||
|
}
|
||||||
|
|
||||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
final JsonObject errorBody = new JsonObject();
|
final JsonObject errorBody = new JsonObject();
|
||||||
errorBody.addProperty("message", message);
|
errorBody.addProperty("message", message);
|
||||||
|
|||||||
+217
-41
@@ -26,6 +26,7 @@ import com.google.gson.JsonObject;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
@@ -51,11 +52,10 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout
|
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared and its payout progress, full 3-year
|
||||||
* progress, full 3-year lock history, and members whose unclaimed dividend (see
|
* lock history, and members whose unclaimed dividend (see {@code m_share_dividend_lock}) sits alongside an overdue loan
|
||||||
* {@code m_share_dividend_lock}) sits alongside an overdue loan installment so the manager can
|
* installment so the manager can proactively apply it towards that EMI instead of waiting for the member to claim it or
|
||||||
* proactively apply it towards that EMI instead of waiting for the member to claim it or for it
|
* for it to be forfeited to profit after 3 years.
|
||||||
* to be forfeited to profit after 3 years.
|
|
||||||
*/
|
*/
|
||||||
@Path("/v1/clerk/dividend-dashboard")
|
@Path("/v1/clerk/dividend-dashboard")
|
||||||
@Component
|
@Component
|
||||||
@@ -112,8 +112,7 @@ public class DividendDashboardApiResource {
|
|||||||
|
|
||||||
final JsonObject result = new JsonObject();
|
final JsonObject result = new JsonObject();
|
||||||
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
|
result.addProperty("totalDeclarations", ((Number) declarationStats.get("total_declarations")).longValue());
|
||||||
result.addProperty("pendingApprovalDeclarations",
|
result.addProperty("pendingApprovalDeclarations", ((Number) declarationStats.get("pending_approval_declarations")).longValue());
|
||||||
((Number) declarationStats.get("pending_approval_declarations")).longValue());
|
|
||||||
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
|
result.addProperty("approvedDeclarations", ((Number) declarationStats.get("approved_declarations")).longValue());
|
||||||
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
|
result.addProperty("membersAffected", ((Number) detailStats.get("members_affected")).longValue());
|
||||||
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
|
result.addProperty("totalMemberDividends", ((Number) detailStats.get("total_member_dividends")).longValue());
|
||||||
@@ -129,12 +128,70 @@ public class DividendDashboardApiResource {
|
|||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-dashboard/declaration/{id}/report-header ──────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading of a Share Dividend Distribution Report: what rate the board resolved, what it came to, and when it
|
||||||
|
* was paid.
|
||||||
|
*
|
||||||
|
* The per-member breakdown screen already lists who gets what, but a distribution report is not just the list — it
|
||||||
|
* has to state the rate and the payment date, because that is what an AGM resolution and an auditor both ask for.
|
||||||
|
* Those two facts live on the declaration rather than on any member row, so they are fetched once here rather than
|
||||||
|
* repeated on every row of the member query.
|
||||||
|
*
|
||||||
|
* The rate is read as declared, never re-derived from amount over capital: a share movement back-dated into a
|
||||||
|
* closed period would silently change a rate the board actually resolved.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/declaration/{dividendId}/report-header")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String declarationReportHeader(@PathParam("dividendId") final Long dividendId) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.id, p.name AS product_name, d.amount, d.rate_percentage, d.status,
|
||||||
|
d.dividend_period_start_date, d.dividend_period_end_date,
|
||||||
|
d.created_date, d.lastmodified_date,
|
||||||
|
COUNT(sad.id) FILTER (WHERE sad.amount > 0) AS paid_members,
|
||||||
|
COUNT(sad.id) FILTER (WHERE sad.amount = 0) AS unpaid_members,
|
||||||
|
COALESCE(SUM(sad.amount), 0) AS allocated_amount
|
||||||
|
FROM m_share_product_dividend_pay_out d
|
||||||
|
JOIN m_share_product p ON p.id = d.product_id
|
||||||
|
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = d.id
|
||||||
|
WHERE d.id = ?
|
||||||
|
GROUP BY d.id, p.name
|
||||||
|
""", dividendId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new NotFoundException("Dividend declaration not found: " + dividendId);
|
||||||
|
}
|
||||||
|
final Map<String, Object> row = rows.get(0);
|
||||||
|
final boolean approved = ((Number) row.get("status")).intValue() == DIVIDEND_PAYOUT_STATUS_APPROVED;
|
||||||
|
|
||||||
|
final JsonObject out = new JsonObject();
|
||||||
|
out.addProperty("dividendId", ((Number) row.get("id")).longValue());
|
||||||
|
out.addProperty("productName", (String) row.get("product_name"));
|
||||||
|
final Object periodEnd = row.get("dividend_period_end_date");
|
||||||
|
out.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
|
||||||
|
out.addProperty("periodEndDate", String.valueOf(periodEnd));
|
||||||
|
out.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd)));
|
||||||
|
out.addProperty("ratePercentage", (BigDecimal) row.get("rate_percentage"));
|
||||||
|
out.addProperty("declaredAmount", (BigDecimal) row.get("amount"));
|
||||||
|
out.addProperty("allocatedAmount", (BigDecimal) row.get("allocated_amount"));
|
||||||
|
out.addProperty("paidMembers", ((Number) row.get("paid_members")).longValue());
|
||||||
|
out.addProperty("unpaidMembers", ((Number) row.get("unpaid_members")).longValue());
|
||||||
|
out.addProperty("status", approved ? "APPROVED" : "PENDING_APPROVAL");
|
||||||
|
out.addProperty("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date")));
|
||||||
|
// The payment date IS the approval date: approval is what makes a member's entitlement
|
||||||
|
// claimable, so nothing is payable before it and it is the date the report must quote.
|
||||||
|
out.addProperty("paymentDate",
|
||||||
|
approved && row.get("lastmodified_date") != null ? String.valueOf(row.get("lastmodified_date")) : null);
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
|
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every dividend ever declared (across every share product), so a manager tracking multiple
|
* Every dividend ever declared (across every share product), so a manager tracking multiple dividends at once can
|
||||||
* dividends at once can see -- per declaration -- how many members it covers and how far its
|
* see -- per declaration -- how many members it covers and how far its payout has actually progressed, not just
|
||||||
* payout has actually progressed, not just whether it was approved.
|
* whether it was approved.
|
||||||
*/
|
*/
|
||||||
@GET
|
@GET
|
||||||
@Path("/declarations")
|
@Path("/declarations")
|
||||||
@@ -179,25 +236,116 @@ public class DividendDashboardApiResource {
|
|||||||
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
|
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still
|
* One member's dividend record across every financial year — what they were entitled to, what they collected, what
|
||||||
* counting down), CLAIMED (a member claimed it, or a manager applied it to an overdue loan --
|
* they let lapse, and on which draft.
|
||||||
* see {@code applied_loan_id}) or FORFEITED (3 years passed unclaimed, swept to the bank's
|
*
|
||||||
* profit). Optional {@code status} query param filters to one.
|
* The lock history answers "what is outstanding right now"; this answers "what has this member ever been paid",
|
||||||
|
* which is the question at the counter when someone asks about a dividend from years ago. Forfeited years are
|
||||||
|
* included deliberately: a member asking why they never received a dividend needs to be told it lapsed, not to be
|
||||||
|
* shown nothing.
|
||||||
|
*
|
||||||
|
* Searched by name or share account number, because a member at the counter has a passbook rather than a client id.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/member-history")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String memberHistory(@QueryParam("search") final String search) {
|
||||||
|
final String term = search == null ? "" : search.trim();
|
||||||
|
if (term.length() < 2) {
|
||||||
|
return gson.toJson(new JsonArray());
|
||||||
|
}
|
||||||
|
final String like = "%" + term.toLowerCase() + "%";
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT c.id AS client_id, c.display_name AS client_name, sha.account_no AS share_account_no,
|
||||||
|
p.name AS product_name, d.dividend_period_start_date, d.dividend_period_end_date,
|
||||||
|
sad.amount AS entitled_amount,
|
||||||
|
l.id AS lock_id, l.status AS lock_status, l.amount AS lock_amount,
|
||||||
|
l.locked_date, l.release_due_date, ln.account_no AS applied_loan_account_no,
|
||||||
|
cr.status AS claim_status, cr.dd_number, cr.actioned_at
|
||||||
|
FROM m_share_account_dividend_details sad
|
||||||
|
JOIN m_share_account sha ON sha.id = sad.account_id
|
||||||
|
JOIN m_client c ON c.id = sha.client_id
|
||||||
|
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
|
||||||
|
JOIN m_share_product p ON p.id = d.product_id
|
||||||
|
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
|
||||||
|
LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id
|
||||||
|
LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED')
|
||||||
|
WHERE sad.amount > 0
|
||||||
|
AND (LOWER(c.display_name) LIKE ? OR LOWER(COALESCE(sha.account_no, '')) LIKE ?)
|
||||||
|
ORDER BY c.display_name, d.dividend_period_end_date DESC
|
||||||
|
""", like, like);
|
||||||
|
|
||||||
|
final JsonArray result = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
|
||||||
|
entry.addProperty("clientName", (String) row.get("client_name"));
|
||||||
|
entry.addProperty("shareAccountNo", (String) row.get("share_account_no"));
|
||||||
|
entry.addProperty("productName", (String) row.get("product_name"));
|
||||||
|
final Object periodEnd = row.get("dividend_period_end_date");
|
||||||
|
entry.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
|
||||||
|
entry.addProperty("periodEndDate", String.valueOf(periodEnd));
|
||||||
|
entry.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd)));
|
||||||
|
entry.addProperty("entitledAmount", (BigDecimal) row.get("entitled_amount"));
|
||||||
|
final Object lockId = row.get("lock_id");
|
||||||
|
entry.addProperty("lockId", lockId == null ? null : ((Number) lockId).longValue());
|
||||||
|
// No lock row at all means the entitlement was below the payable minimum and was swept
|
||||||
|
// with the rounding residue — never anybody's money to collect.
|
||||||
|
entry.addProperty("status", lockId == null ? "BELOW_MINIMUM" : (String) row.get("lock_status"));
|
||||||
|
entry.addProperty("amount", (BigDecimal) row.get("lock_amount"));
|
||||||
|
entry.addProperty("lockedDate", row.get("locked_date") == null ? null : String.valueOf(row.get("locked_date")));
|
||||||
|
entry.addProperty("releaseDueDate", row.get("release_due_date") == null ? null : String.valueOf(row.get("release_due_date")));
|
||||||
|
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
|
||||||
|
entry.addProperty("claimStatus", (String) row.get("claim_status"));
|
||||||
|
entry.addProperty("ddNumber", (String) row.get("dd_number"));
|
||||||
|
entry.addProperty("claimedOn", row.get("actioned_at") == null ? null : String.valueOf(row.get("actioned_at")));
|
||||||
|
result.add(entry);
|
||||||
|
}
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a
|
||||||
|
* member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3
|
||||||
|
* years passed unclaimed, swept to the bank's profit). Optional {@code status} query param filters to one.
|
||||||
*/
|
*/
|
||||||
@GET
|
@GET
|
||||||
@Path("/locks")
|
@Path("/locks")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String locks(@QueryParam("status") final String status) {
|
public String locks(@QueryParam("status") final String status) {
|
||||||
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
|
final boolean filterByStatus = status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status);
|
||||||
|
// Which declaration each lock came from. Once a member has more than one unclaimed dividend —
|
||||||
|
// this year's plus a prior year they never collected — amount and locked date alone cannot
|
||||||
|
// tell them apart, so the clerk has no way to say which year is being paid out. The join
|
||||||
|
// walks lock -> per-member detail -> declaration -> product to name the period and product.
|
||||||
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
|
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
|
||||||
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
|
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
|
||||||
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no " //
|
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no, "
|
||||||
|
+ "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name, " //
|
||||||
|
// The share account the dividend was earned on. A clerk searching for a member at the
|
||||||
|
// counter has the passbook in front of them, which carries the account number and not
|
||||||
|
// the display name as the system spells it — so the number has to be searchable too.
|
||||||
|
+ "sha.account_no AS share_account_no, " //
|
||||||
|
// The draft this dividend is being paid out as, and how far along it is. Once a claim
|
||||||
|
// exists the clerk needs the DD number rather than another Claim button — it is what
|
||||||
|
// goes on the counterfoil and what the member asks about when they come back.
|
||||||
|
+ "cr.status AS claim_status, cr.dd_number AS claim_dd_number " //
|
||||||
+ "FROM m_share_dividend_lock l " //
|
+ "FROM m_share_dividend_lock l " //
|
||||||
+ "JOIN m_client c ON c.id = l.client_id " //
|
+ "JOIN m_client c ON c.id = l.client_id " //
|
||||||
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
|
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
|
||||||
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
|
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
|
||||||
|
+ "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " //
|
||||||
|
+ "LEFT JOIN m_share_account sha ON sha.id = sad.account_id " //
|
||||||
|
+ "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " //
|
||||||
|
+ "LEFT JOIN m_share_product p ON p.id = d.product_id " //
|
||||||
|
// The live claim only: a rejected one leaves the dividend claimable again, so it must
|
||||||
|
// not keep showing a DD number the member will never receive.
|
||||||
|
+ "LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED') " //
|
||||||
+ (filterByStatus ? "WHERE l.status = ? " : "") //
|
+ (filterByStatus ? "WHERE l.status = ? " : "") //
|
||||||
+ "ORDER BY l.locked_date DESC";
|
// Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that
|
||||||
|
// needs attention, and it is the one a member should be paid out of first.
|
||||||
|
+ "ORDER BY d.dividend_period_end_date DESC NULLS LAST, l.locked_date DESC";
|
||||||
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
|
final List<Map<String, Object>> rows = filterByStatus ? jdbcTemplate.queryForList(sql, status.toUpperCase())
|
||||||
: jdbcTemplate.queryForList(sql);
|
: jdbcTemplate.queryForList(sql);
|
||||||
|
|
||||||
@@ -207,6 +355,9 @@ public class DividendDashboardApiResource {
|
|||||||
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
|
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
|
||||||
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
|
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
|
||||||
entry.addProperty("clientName", (String) row.get("client_name"));
|
entry.addProperty("clientName", (String) row.get("client_name"));
|
||||||
|
entry.addProperty("shareAccountNo", (String) row.get("share_account_no"));
|
||||||
|
entry.addProperty("claimStatus", (String) row.get("claim_status"));
|
||||||
|
entry.addProperty("claimDdNumber", (String) row.get("claim_dd_number"));
|
||||||
final Object savingsAccountId = row.get("savings_account_id");
|
final Object savingsAccountId = row.get("savings_account_id");
|
||||||
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
||||||
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
|
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
|
||||||
@@ -217,6 +368,17 @@ public class DividendDashboardApiResource {
|
|||||||
final Object appliedLoanId = row.get("applied_loan_id");
|
final Object appliedLoanId = row.get("applied_loan_id");
|
||||||
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
|
entry.addProperty("appliedLoanId", appliedLoanId == null ? null : ((Number) appliedLoanId).longValue());
|
||||||
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
|
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
|
||||||
|
final Object declarationId = row.get("declaration_id");
|
||||||
|
entry.addProperty("declarationId", declarationId == null ? null : ((Number) declarationId).longValue());
|
||||||
|
entry.addProperty("productName", (String) row.get("product_name"));
|
||||||
|
final Object periodStart = row.get("dividend_period_start_date");
|
||||||
|
final Object periodEnd = row.get("dividend_period_end_date");
|
||||||
|
entry.addProperty("periodStartDate", periodStart == null ? null : periodStart.toString());
|
||||||
|
entry.addProperty("periodEndDate", periodEnd == null ? null : periodEnd.toString());
|
||||||
|
// The financial year the dividend was earned in, taken from the period end so a
|
||||||
|
// declaration is labelled by the year it closes — "FY 2026-27", the way a member and an
|
||||||
|
// auditor both refer to it.
|
||||||
|
entry.addProperty("financialYear", periodEnd == null ? null : financialYearOf(periodEnd.toString()));
|
||||||
result.add(entry);
|
result.add(entry);
|
||||||
}
|
}
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
@@ -229,9 +391,14 @@ public class DividendDashboardApiResource {
|
|||||||
public String list() {
|
public String list() {
|
||||||
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
|
||||||
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
|
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
|
||||||
l.locked_date, l.release_due_date
|
l.locked_date, l.release_due_date,
|
||||||
|
-- The share account the dividend was earned on, so a clerk holding the
|
||||||
|
-- member's passbook can find the row by its number rather than by name.
|
||||||
|
sha.account_no AS share_account_no
|
||||||
FROM m_share_dividend_lock l
|
FROM m_share_dividend_lock l
|
||||||
JOIN m_client c ON c.id = l.client_id
|
JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
|
||||||
|
LEFT JOIN m_share_account sha ON sha.id = sad.account_id
|
||||||
WHERE l.status = 'UNCLAIMED'
|
WHERE l.status = 'UNCLAIMED'
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||||
@@ -248,6 +415,7 @@ public class DividendDashboardApiResource {
|
|||||||
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
|
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
|
||||||
row.addProperty("clientId", clientId);
|
row.addProperty("clientId", clientId);
|
||||||
row.addProperty("clientName", (String) lock.get("client_name"));
|
row.addProperty("clientName", (String) lock.get("client_name"));
|
||||||
|
row.addProperty("shareAccountNo", (String) lock.get("share_account_no"));
|
||||||
final Object savingsAccountId = lock.get("savings_account_id");
|
final Object savingsAccountId = lock.get("savings_account_id");
|
||||||
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
||||||
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
|
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
|
||||||
@@ -260,7 +428,8 @@ public class DividendDashboardApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private JsonArray overdueLoansFor(final Long clientId) {
|
private JsonArray overdueLoansFor(final Long clientId) {
|
||||||
final List<Map<String, Object>> loans = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> loans = jdbcTemplate.queryForList(
|
||||||
|
"""
|
||||||
SELECT ln.id AS loan_id, ln.account_no,
|
SELECT ln.id AS loan_id, ln.account_no,
|
||||||
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
|
SUM(GREATEST(ri.principal_amount - COALESCE(ri.principal_completed_derived, 0)
|
||||||
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
|
- COALESCE(ri.principal_writtenoff_derived, 0), 0)
|
||||||
@@ -270,7 +439,8 @@ public class DividendDashboardApiResource {
|
|||||||
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
WHERE ln.client_id = ? AND ln.loan_status_id = 300 AND ri.completed_derived = false AND ri.duedate < CURRENT_DATE
|
||||||
GROUP BY ln.id, ln.account_no
|
GROUP BY ln.id, ln.account_no
|
||||||
ORDER BY overdue_amount DESC
|
ORDER BY overdue_amount DESC
|
||||||
""", clientId);
|
""",
|
||||||
|
clientId);
|
||||||
final JsonArray result = new JsonArray();
|
final JsonArray result = new JsonArray();
|
||||||
for (final Map<String, Object> loan : loans) {
|
for (final Map<String, Object> loan : loans) {
|
||||||
final JsonObject row = new JsonObject();
|
final JsonObject row = new JsonObject();
|
||||||
@@ -285,14 +455,13 @@ public class DividendDashboardApiResource {
|
|||||||
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
|
// ── POST /v1/clerk/dividend-dashboard/{lockId}/apply-to-loan ────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue
|
* Proactive recovery: a manager notices a member with an unclaimed dividend AND an overdue loan EMI (see
|
||||||
* loan EMI (see {@code list()} above) and applies it without waiting for the member to visit
|
* {@code list()} above) and applies it without waiting for the member to visit and file a claim. Since the dividend
|
||||||
* and file a claim. Since the dividend was never credited into any savings account (it's a
|
* was never credited into any savings account (it's a pure "Dividend Payable" GL liability until claimed -- see
|
||||||
* pure "Dividend Payable" GL liability until claimed -- see
|
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to make: this posts a
|
||||||
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
|
* native loan repayment (so the loan's own schedule/state stays fully native and correct) and then redirects the
|
||||||
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
|
* debit leg it assumed the cash came from to the Dividend Payable liability instead -- identical mechanic to a
|
||||||
* native and correct) and then redirects the debit leg it assumed the cash came from to the
|
* claim's loan leg.
|
||||||
* Dividend Payable liability instead -- identical mechanic to a claim's loan leg.
|
|
||||||
*/
|
*/
|
||||||
@POST
|
@POST
|
||||||
@Path("/{lockId}/apply-to-loan")
|
@Path("/{lockId}/apply-to-loan")
|
||||||
@@ -341,15 +510,14 @@ public class DividendDashboardApiResource {
|
|||||||
payload.addProperty("transactionDate", today.toString());
|
payload.addProperty("transactionDate", today.toString());
|
||||||
payload.addProperty("transactionAmount", applyAmount);
|
payload.addProperty("transactionAmount", applyAmount);
|
||||||
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
|
payload.addProperty("note", "Unclaimed share dividend applied to overdue loan EMI (lock #" + lockId + ")");
|
||||||
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId).withJson(payload.toString())
|
||||||
.withJson(payload.toString()).build();
|
.build();
|
||||||
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||||
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
|
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
|
||||||
|
|
||||||
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
|
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
|
||||||
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
|
jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder, lockId);
|
||||||
lockId);
|
|
||||||
} else {
|
} else {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_share_dividend_lock
|
UPDATE m_share_dividend_lock
|
||||||
@@ -366,16 +534,14 @@ public class DividendDashboardApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The loan repayment just posted above assumed the money came out of whichever account is
|
* The loan repayment just posted above assumed the money came out of whichever account is this loan product's
|
||||||
* this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a
|
* actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product
|
||||||
* Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead)
|
* debits its own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with a second
|
||||||
* -- it didn't really. Rather than reverse that with a second manual entry pair (which would
|
* manual entry pair (which would leave that fund-source account appearing twice, once debited then immediately
|
||||||
* leave that fund-source account appearing twice, once debited then immediately credited back
|
* credited back -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's account
|
||||||
* -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's
|
* in place to Dividend Payable, so the transaction ends up with exactly the entries a real accountant would expect:
|
||||||
* account in place to Dividend Payable, so the transaction ends up with exactly the entries a
|
* Dividend Payable debited, Loan Portfolio/Income credited, nothing else touched. Skipped (loan still updated) if
|
||||||
* real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited,
|
* the bank hasn't mapped the Dividend Payable financial activity.
|
||||||
* nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend
|
|
||||||
* Payable financial activity.
|
|
||||||
*/
|
*/
|
||||||
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
|
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
@@ -389,6 +555,16 @@ public class DividendDashboardApiResource {
|
|||||||
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Indian financial year an ISO date falls in, as "2026-27". April starts the year, so a declaration ending
|
||||||
|
* 31 March 2027 is FY 2026-27 — the label a member and an auditor would both use for it.
|
||||||
|
*/
|
||||||
|
private String financialYearOf(final String isoDate) {
|
||||||
|
final LocalDate date = LocalDate.parse(isoDate);
|
||||||
|
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||||
|
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||||
|
}
|
||||||
|
|
||||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
final JsonObject errorBody = new JsonObject();
|
final JsonObject errorBody = new JsonObject();
|
||||||
errorBody.addProperty("message", message);
|
errorBody.addProperty("message", message);
|
||||||
|
|||||||
+71
@@ -217,6 +217,8 @@ import org.apache.fineract.portfolio.note.domain.Note;
|
|||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks;
|
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks;
|
||||||
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
|
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
|
||||||
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler;
|
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler;
|
||||||
@@ -232,6 +234,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatformService {
|
public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatformService {
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
|
private final VoucherService voucherService;
|
||||||
private final LoanTransactionValidator loanTransactionValidator;
|
private final LoanTransactionValidator loanTransactionValidator;
|
||||||
private final LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer;
|
private final LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer;
|
||||||
private final LoanRepositoryWrapper loanRepositoryWrapper;
|
private final LoanRepositoryWrapper loanRepositoryWrapper;
|
||||||
@@ -521,6 +524,17 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
|
|||||||
|
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan));
|
businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan));
|
||||||
|
|
||||||
|
// Money leaving the society to the borrower: the Loan Portfolio account is debited, because
|
||||||
|
// the loan is now an asset owed back, and the fund the money came out of is credited.
|
||||||
|
// Skipped for an account transfer, where the disbursement lands in another account rather
|
||||||
|
// than being paid out and that account's own posting carries the voucher.
|
||||||
|
if (!isAccountTransfer && disbursalTransactionId != null) {
|
||||||
|
final String voucherNo = raiseLoanVoucher(loan, disbursalTransactionId, loan.getDisbursedAmount(), true);
|
||||||
|
if (voucherNo != null) {
|
||||||
|
changes.put("voucherNo", voucherNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(command.commandId()) //
|
.withCommandId(command.commandId()) //
|
||||||
.withEntityId(loan.getId()) //
|
.withEntityId(loan.getId()) //
|
||||||
@@ -1073,6 +1087,56 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a loan disbursement or repayment in the voucher register, and returns its number.
|
||||||
|
*
|
||||||
|
* The borrower's side is the product's Loan Portfolio account, because that is what the balance
|
||||||
|
* posts against; the line still names the borrower and their loan account number, so the slip
|
||||||
|
* reads as theirs. The other side is the till.
|
||||||
|
*
|
||||||
|
* A disbursement debits the portfolio — the society now holds a receivable — and credits the
|
||||||
|
* cash paid out. A repayment reverses that.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the transaction. The disbursement or repayment is already posted
|
||||||
|
* and the borrower's balance already moved; taking that back over a slip would undo a correct
|
||||||
|
* transaction for a paperwork reason. The problem is logged and the transaction stands.
|
||||||
|
*/
|
||||||
|
private String raiseLoanVoucher(final Loan loan, final Long transactionId, final BigDecimal amount, final boolean isDisbursement) {
|
||||||
|
// A register further out may have already raised a voucher naming the instrument this
|
||||||
|
// movement belongs to; its voucher describes it better than a bare loan posting would.
|
||||||
|
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final Map<String, Object> portfolio = this.voucherService.loanPortfolioAccount(loan.getId());
|
||||||
|
final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue();
|
||||||
|
|
||||||
|
final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null;
|
||||||
|
final String accountNo = loan.getAccountNumber();
|
||||||
|
final String partyName = party != null ? party : (String) portfolio.get("name");
|
||||||
|
|
||||||
|
final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount)
|
||||||
|
: VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount);
|
||||||
|
|
||||||
|
// The till. The loan module does not resolve a GL for the cashier's drawer, so the line
|
||||||
|
// names it without an id rather than guessing at one.
|
||||||
|
final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
|
||||||
|
: VoucherLine.debitGl(null, "Cash in Hand", null, amount);
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide);
|
||||||
|
|
||||||
|
return this.voucherService.createVoucherWithLines(
|
||||||
|
isDisbursement ? VoucherService.TYPE_DISBURSEMENT : VoucherService.TYPE_REPAYMENT,
|
||||||
|
VoucherService.REF_LOAN_TRANSACTION, transactionId, currentUser,
|
||||||
|
isDisbursement ? VoucherService.PREFIX_DEBIT : VoucherService.PREFIX_CREDIT, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for loan transaction {}", transactionId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void saveNote(String noteText, Loan loan, LoanTransaction loanTransaction) {
|
private void saveNote(String noteText, Loan loan, LoanTransaction loanTransaction) {
|
||||||
if (StringUtils.isNotBlank(noteText)) {
|
if (StringUtils.isNotBlank(noteText)) {
|
||||||
final Note note = Note.loanTransactionNote(loan, loanTransaction, noteText);
|
final Note note = Note.loanTransactionNote(loan, loanTransaction, noteText);
|
||||||
@@ -1118,6 +1182,13 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
|
|||||||
loan = loanTransaction.getLoan();
|
loan = loanTransaction.getLoan();
|
||||||
this.loanAccountDomainService.updateAndSaveLoanCollateralTransactionsForIndividualAccounts(loan, loanTransaction);
|
this.loanAccountDomainService.updateAndSaveLoanCollateralTransactionsForIndividualAccounts(loan, loanTransaction);
|
||||||
|
|
||||||
|
// Money coming back from the borrower: cash is debited and the Loan Portfolio account is
|
||||||
|
// credited, reducing what the loan still carries as owed.
|
||||||
|
final String repaymentVoucherNo = raiseLoanVoucher(loan, loanTransaction.getId(), transactionAmount, false);
|
||||||
|
if (repaymentVoucherNo != null) {
|
||||||
|
changes.put("voucherNo", repaymentVoucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()) //
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()) //
|
||||||
.withLoanId(loan.getId()) //
|
.withLoanId(loan.getId()) //
|
||||||
.withEntityId(loanTransaction.getId()) //
|
.withEntityId(loanTransaction.getId()) //
|
||||||
|
|||||||
+4
-2
@@ -176,6 +176,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor
|
|||||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
|
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||||
import org.apache.fineract.portfolio.rate.service.RateAssembler;
|
import org.apache.fineract.portfolio.rate.service.RateAssembler;
|
||||||
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
|
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
|
||||||
@@ -413,7 +414,7 @@ public class LoanAccountConfiguration {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean(LoanWritePlatformService.class)
|
@ConditionalOnMissingBean(LoanWritePlatformService.class)
|
||||||
public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context,
|
public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, VoucherService voucherService,
|
||||||
LoanTransactionValidator loanTransactionValidator,
|
LoanTransactionValidator loanTransactionValidator,
|
||||||
LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer, LoanRepositoryWrapper loanRepositoryWrapper,
|
LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer, LoanRepositoryWrapper loanRepositoryWrapper,
|
||||||
LoanAccountDomainService loanAccountDomainService, NoteRepository noteRepository,
|
LoanAccountDomainService loanAccountDomainService, NoteRepository noteRepository,
|
||||||
@@ -446,7 +447,8 @@ public class LoanAccountConfiguration {
|
|||||||
LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanMapper loanMapper,
|
LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanMapper loanMapper,
|
||||||
LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService,
|
LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService,
|
||||||
LoanTransactionService loanTransactionService) {
|
LoanTransactionService loanTransactionService) {
|
||||||
return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer,
|
return new LoanWritePlatformServiceJpaRepositoryImpl(context, voucherService, loanTransactionValidator,
|
||||||
|
loanUpdateCommandFromApiJsonDeserializer,
|
||||||
loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository,
|
loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository,
|
||||||
loanTransactionRelationRepository, loanAssembler, calendarInstanceRepository, paymentDetailWritePlatformService,
|
loanTransactionRelationRepository, loanAssembler, calendarInstanceRepository, paymentDetailWritePlatformService,
|
||||||
holidayRepository, configurationDomainService, workingDaysRepository, accountTransfersWritePlatformService,
|
holidayRepository, configurationDomainService, workingDaysRepository, accountTransfersWritePlatformService,
|
||||||
|
|||||||
+2
-2
@@ -338,10 +338,10 @@ public class NachWritePlatformServiceImpl implements NachWritePlatformService {
|
|||||||
}
|
}
|
||||||
if (txn.isCredit()) {
|
if (txn.isCredit()) {
|
||||||
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, suspenseGlId, controlGlId,
|
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, suspenseGlId, controlGlId,
|
||||||
amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId());
|
amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType());
|
||||||
} else {
|
} else {
|
||||||
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, controlGlId, suspenseGlId,
|
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, controlGlId, suspenseGlId,
|
||||||
amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId());
|
amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.orgsetup.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.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
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.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operational profile behind a branch — code, address, contact, type and working hours — kept as
|
||||||
|
* a side table on m_office (see migration 0511) rather than added to the core office entity, so this
|
||||||
|
* stays independent of the stock office create/edit flow. Every office already exists via the
|
||||||
|
* standard Offices screen; this only adds the real-world fields on top for the offices that have them
|
||||||
|
* set. An office with no profile row yet is a valid, ordinary state — not an error.
|
||||||
|
*/
|
||||||
|
@Path("/v1/branchprofile")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class BranchProfileApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public String list(@QueryParam("officeId") final Long officeId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission("BRANCHPROFILE");
|
||||||
|
final String where = officeId != null ? "WHERE o.id = ?" : "";
|
||||||
|
final List<Map<String, Object>> rows = officeId != null
|
||||||
|
? jdbcTemplate.queryForList(baseSql() + where + " ORDER BY o.hierarchy", officeId)
|
||||||
|
: jdbcTemplate.queryForList(baseSql() + " ORDER BY o.hierarchy");
|
||||||
|
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
out.add(toResult(row));
|
||||||
|
}
|
||||||
|
if (officeId != null) {
|
||||||
|
if (out.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
|
||||||
|
"Office " + officeId + " was not found.");
|
||||||
|
}
|
||||||
|
return gson.toJson(out.get(0));
|
||||||
|
}
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("{officeId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
public String update(@PathParam("officeId") final Long officeId, final String body) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE");
|
||||||
|
|
||||||
|
final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?",
|
||||||
|
Integer.class, officeId);
|
||||||
|
if (officeExists == null || officeExists == 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found",
|
||||||
|
"Office " + officeId + " was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final var root = this.fromJsonHelper.parse(body);
|
||||||
|
final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root);
|
||||||
|
final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root);
|
||||||
|
final String address = this.fromJsonHelper.extractStringNamed("address", root);
|
||||||
|
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
|
||||||
|
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
|
||||||
|
final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root);
|
||||||
|
final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root);
|
||||||
|
final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root);
|
||||||
|
final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root);
|
||||||
|
final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root);
|
||||||
|
final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root);
|
||||||
|
final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root);
|
||||||
|
|
||||||
|
if (branchCode != null && !branchCode.isBlank()) {
|
||||||
|
final Integer dupe = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class,
|
||||||
|
branchCode, officeId);
|
||||||
|
if (dupe != null && dupe > 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate",
|
||||||
|
"Branch code '" + branchCode + "' is already used by another branch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?",
|
||||||
|
Integer.class, officeId);
|
||||||
|
if (existing != null && existing > 0) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_office_profile
|
||||||
|
SET branch_code = ?, branch_type = ?, address = ?, contact_phone = ?, contact_email = ?,
|
||||||
|
working_hours_start = ?, working_hours_end = ?, working_days = ?,
|
||||||
|
ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?,
|
||||||
|
updated_by_username = ?, updated_at = ?
|
||||||
|
WHERE office_id = ?
|
||||||
|
""", branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart,
|
||||||
|
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
|
||||||
|
Timestamp.from(Instant.now()), officeId);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_office_profile
|
||||||
|
(office_id, branch_code, branch_type, address, contact_phone, contact_email,
|
||||||
|
working_hours_start, working_hours_end, working_days, ifsc_code, micr_code,
|
||||||
|
latitude, longitude, updated_by_username, updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", officeId, branchCode, branchType, address, contactPhone, contactEmail, workingHoursStart,
|
||||||
|
workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(),
|
||||||
|
Timestamp.from(Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list(officeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String baseSql() {
|
||||||
|
return """
|
||||||
|
SELECT o.id AS office_id, o.name AS office_name, o.hierarchy, o.parent_id, o.opening_date,
|
||||||
|
p.branch_code, p.branch_type, p.address, p.contact_phone, p.contact_email,
|
||||||
|
p.working_hours_start, p.working_hours_end, p.working_days,
|
||||||
|
p.ifsc_code, p.micr_code, p.latitude, p.longitude,
|
||||||
|
p.updated_by_username, p.updated_at
|
||||||
|
FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toResult(final Map<String, Object> row) {
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("officeId", row.get("office_id"));
|
||||||
|
out.put("officeName", row.get("office_name"));
|
||||||
|
out.put("hierarchy", row.get("hierarchy"));
|
||||||
|
out.put("parentId", row.get("parent_id"));
|
||||||
|
final Object opening = row.get("opening_date");
|
||||||
|
out.put("openingDate", opening == null ? null : opening.toString());
|
||||||
|
out.put("branchCode", row.get("branch_code"));
|
||||||
|
out.put("branchType", row.get("branch_type"));
|
||||||
|
out.put("address", row.get("address"));
|
||||||
|
out.put("contactPhone", row.get("contact_phone"));
|
||||||
|
out.put("contactEmail", row.get("contact_email"));
|
||||||
|
out.put("workingHoursStart", row.get("working_hours_start"));
|
||||||
|
out.put("workingHoursEnd", row.get("working_hours_end"));
|
||||||
|
out.put("workingDays", row.get("working_days"));
|
||||||
|
out.put("ifscCode", row.get("ifsc_code"));
|
||||||
|
out.put("micrCode", row.get("micr_code"));
|
||||||
|
out.put("latitude", row.get("latitude"));
|
||||||
|
out.put("longitude", row.get("longitude"));
|
||||||
|
out.put("updatedByUsername", row.get("updated_by_username"));
|
||||||
|
final Object updatedAt = row.get("updated_at");
|
||||||
|
out.put("updatedAt", updatedAt == null ? null : updatedAt.toString());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* 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.orgsetup.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.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The institution's own "Bank Master" — identity and classification, regulatory details, address
|
||||||
|
* (registered, with optional head-office/communication overrides), contact, the institution-level
|
||||||
|
* banking identifier (SWIFT/BIC — IFSC/MICR are per-branch and live on {@link BranchProfileApiResource}
|
||||||
|
* instead), document header names and terms text, the authorised representative, and audit fields. A
|
||||||
|
* singleton (always id=1): unlike offices there is only ever one bank running this tenant.
|
||||||
|
*
|
||||||
|
* Deliberately does NOT store Business Date, Financial Year, Time Zone, Working Days or Holiday
|
||||||
|
* Calendar — those already exist as their own configuration elsewhere in the system, and copying
|
||||||
|
* them here would just create a second, driftable value for the same fact. The currency shown is
|
||||||
|
* likewise derived live from {@code m_organisation_currency} rather than stored.
|
||||||
|
*/
|
||||||
|
@Path("/v1/institutionprofile")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class InstitutionProfileApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public String get() {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission("INSTITUTIONPROFILE");
|
||||||
|
ensureRowExists();
|
||||||
|
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT bank_name, legal_name, short_name, bank_code, institution_code, bank_type,
|
||||||
|
ownership_type, parent_institution_name,
|
||||||
|
registration_number, license_number, established_date, status, country,
|
||||||
|
regulatory_authority, registration_date, license_status, license_expiry_date,
|
||||||
|
swift_bic,
|
||||||
|
address_line1, address_line2, city, district, state, pincode,
|
||||||
|
head_office_same_as_registered, head_office_address,
|
||||||
|
communication_same_as_registered, communication_address,
|
||||||
|
contact_phone, contact_email, customer_care_number, grievance_email, website,
|
||||||
|
pan, tan, gstin,
|
||||||
|
statement_header_name, passbook_header_name, receipt_header_name, terms_and_conditions,
|
||||||
|
authorized_person_name, authorized_person_designation, authorized_person_contact,
|
||||||
|
authorized_person_email,
|
||||||
|
created_by_username, created_at, activation_date, deactivation_date,
|
||||||
|
updated_by_username, updated_at
|
||||||
|
FROM m_institution_profile WHERE id = 1
|
||||||
|
""");
|
||||||
|
return gson.toJson(toResult(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
public String update(final String body) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE");
|
||||||
|
ensureRowExists();
|
||||||
|
|
||||||
|
final var root = this.fromJsonHelper.parse(body);
|
||||||
|
final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root);
|
||||||
|
final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root);
|
||||||
|
final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root);
|
||||||
|
final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root);
|
||||||
|
final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root);
|
||||||
|
final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root);
|
||||||
|
final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root);
|
||||||
|
final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root);
|
||||||
|
final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root);
|
||||||
|
final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root);
|
||||||
|
final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root));
|
||||||
|
final String status = this.fromJsonHelper.extractStringNamed("status", root);
|
||||||
|
final String country = this.fromJsonHelper.extractStringNamed("country", root);
|
||||||
|
final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root);
|
||||||
|
final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root));
|
||||||
|
final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root);
|
||||||
|
final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root));
|
||||||
|
final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root);
|
||||||
|
final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root);
|
||||||
|
final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root);
|
||||||
|
final String city = this.fromJsonHelper.extractStringNamed("city", root);
|
||||||
|
final String district = this.fromJsonHelper.extractStringNamed("district", root);
|
||||||
|
final String state = this.fromJsonHelper.extractStringNamed("state", root);
|
||||||
|
final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root);
|
||||||
|
final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root)
|
||||||
|
? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE;
|
||||||
|
final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root);
|
||||||
|
final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root)
|
||||||
|
? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE;
|
||||||
|
final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root);
|
||||||
|
final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root);
|
||||||
|
final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root);
|
||||||
|
final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root);
|
||||||
|
final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root);
|
||||||
|
final String website = this.fromJsonHelper.extractStringNamed("website", root);
|
||||||
|
final String pan = this.fromJsonHelper.extractStringNamed("pan", root);
|
||||||
|
final String tan = this.fromJsonHelper.extractStringNamed("tan", root);
|
||||||
|
final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root);
|
||||||
|
final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root);
|
||||||
|
final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root);
|
||||||
|
final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root);
|
||||||
|
final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root);
|
||||||
|
final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root);
|
||||||
|
final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation",
|
||||||
|
root);
|
||||||
|
final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root);
|
||||||
|
final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root);
|
||||||
|
|
||||||
|
// A status flip is stamped automatically the first time it happens, rather than asking the
|
||||||
|
// user to also remember to set a date — the previous row's own status is what "the first
|
||||||
|
// time" is judged against.
|
||||||
|
final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1",
|
||||||
|
String.class);
|
||||||
|
final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus);
|
||||||
|
final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus);
|
||||||
|
final LocalDate today = LocalDate.now();
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_institution_profile
|
||||||
|
SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?,
|
||||||
|
bank_type = ?, ownership_type = ?, parent_institution_name = ?,
|
||||||
|
registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?,
|
||||||
|
regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?,
|
||||||
|
swift_bic = ?,
|
||||||
|
address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?,
|
||||||
|
head_office_same_as_registered = ?, head_office_address = ?,
|
||||||
|
communication_same_as_registered = ?, communication_address = ?,
|
||||||
|
contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?,
|
||||||
|
website = ?, pan = ?, tan = ?, gstin = ?,
|
||||||
|
statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?,
|
||||||
|
terms_and_conditions = ?,
|
||||||
|
authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?,
|
||||||
|
authorized_person_email = ?,
|
||||||
|
activation_date = CASE WHEN ? THEN ? ELSE activation_date END,
|
||||||
|
deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END,
|
||||||
|
updated_by_username = ?, updated_at = ?
|
||||||
|
WHERE id = 1
|
||||||
|
""", bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType,
|
||||||
|
parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country,
|
||||||
|
regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic,
|
||||||
|
addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress,
|
||||||
|
communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail,
|
||||||
|
website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions,
|
||||||
|
authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail,
|
||||||
|
turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(),
|
||||||
|
Timestamp.from(Instant.now()));
|
||||||
|
|
||||||
|
return get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureRowExists() {
|
||||||
|
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1",
|
||||||
|
Integer.class);
|
||||||
|
if (count == null || count == 0) {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_institution_profile (id, status, created_by_username, created_at)
|
||||||
|
VALUES (1, 'ACTIVE', ?, ?)
|
||||||
|
""", currentUser.getUsername(), Timestamp.from(Instant.now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tenant's one enabled currency (see the INR-only enforcement migration) — read-only here. */
|
||||||
|
private String resolveCurrencyCode() {
|
||||||
|
final List<String> codes = jdbcTemplate.queryForList(
|
||||||
|
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
|
||||||
|
return codes.isEmpty() ? null : codes.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate parseDate(final String raw) {
|
||||||
|
return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date toSqlDate(final LocalDate d) {
|
||||||
|
return d == null ? null : Date.valueOf(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toResult(final Map<String, Object> row) {
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("bankName", row.get("bank_name"));
|
||||||
|
out.put("legalName", row.get("legal_name"));
|
||||||
|
out.put("shortName", row.get("short_name"));
|
||||||
|
out.put("bankCode", row.get("bank_code"));
|
||||||
|
out.put("institutionCode", row.get("institution_code"));
|
||||||
|
out.put("bankType", row.get("bank_type"));
|
||||||
|
out.put("ownershipType", row.get("ownership_type"));
|
||||||
|
out.put("parentInstitutionName", row.get("parent_institution_name"));
|
||||||
|
out.put("registrationNumber", row.get("registration_number"));
|
||||||
|
out.put("licenseNumber", row.get("license_number"));
|
||||||
|
out.put("establishedDate", str(row.get("established_date")));
|
||||||
|
out.put("status", row.get("status"));
|
||||||
|
out.put("country", row.get("country"));
|
||||||
|
out.put("currencyCode", resolveCurrencyCode());
|
||||||
|
out.put("regulatoryAuthority", row.get("regulatory_authority"));
|
||||||
|
out.put("registrationDate", str(row.get("registration_date")));
|
||||||
|
out.put("licenseStatus", row.get("license_status"));
|
||||||
|
out.put("licenseExpiryDate", str(row.get("license_expiry_date")));
|
||||||
|
out.put("swiftBic", row.get("swift_bic"));
|
||||||
|
out.put("addressLine1", row.get("address_line1"));
|
||||||
|
out.put("addressLine2", row.get("address_line2"));
|
||||||
|
out.put("city", row.get("city"));
|
||||||
|
out.put("district", row.get("district"));
|
||||||
|
out.put("state", row.get("state"));
|
||||||
|
out.put("pincode", row.get("pincode"));
|
||||||
|
out.put("headOfficeSameAsRegistered", row.get("head_office_same_as_registered"));
|
||||||
|
out.put("headOfficeAddress", row.get("head_office_address"));
|
||||||
|
out.put("communicationSameAsRegistered", row.get("communication_same_as_registered"));
|
||||||
|
out.put("communicationAddress", row.get("communication_address"));
|
||||||
|
out.put("contactPhone", row.get("contact_phone"));
|
||||||
|
out.put("contactEmail", row.get("contact_email"));
|
||||||
|
out.put("customerCareNumber", row.get("customer_care_number"));
|
||||||
|
out.put("grievanceEmail", row.get("grievance_email"));
|
||||||
|
out.put("website", row.get("website"));
|
||||||
|
out.put("pan", row.get("pan"));
|
||||||
|
out.put("tan", row.get("tan"));
|
||||||
|
out.put("gstin", row.get("gstin"));
|
||||||
|
out.put("statementHeaderName", row.get("statement_header_name"));
|
||||||
|
out.put("passbookHeaderName", row.get("passbook_header_name"));
|
||||||
|
out.put("receiptHeaderName", row.get("receipt_header_name"));
|
||||||
|
out.put("termsAndConditions", row.get("terms_and_conditions"));
|
||||||
|
out.put("authorizedPersonName", row.get("authorized_person_name"));
|
||||||
|
out.put("authorizedPersonDesignation", row.get("authorized_person_designation"));
|
||||||
|
out.put("authorizedPersonContact", row.get("authorized_person_contact"));
|
||||||
|
out.put("authorizedPersonEmail", row.get("authorized_person_email"));
|
||||||
|
out.put("createdByUsername", row.get("created_by_username"));
|
||||||
|
out.put("createdAt", str(row.get("created_at")));
|
||||||
|
out.put("activationDate", str(row.get("activation_date")));
|
||||||
|
out.put("deactivationDate", str(row.get("deactivation_date")));
|
||||||
|
out.put("updatedByUsername", row.get("updated_by_username"));
|
||||||
|
out.put("updatedAt", str(row.get("updated_at")));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(final Object o) {
|
||||||
|
return o == null ? null : o.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.api;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.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.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.savings.data.FacilityChargeMappingData;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FacilityChargeMappingReadPlatformService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin screen backing API for {@code m_facility_charge_mapping}: which {@link org.apache.fineract.portfolio.charge.domain.Charge}
|
||||||
|
* is applied when a named account facility (ATM_FACILITY, UPI_FACILITY, ...) is enabled on a savings/current
|
||||||
|
* account. The 16 facility rows themselves are fixed and auto-seeded by FacilityChargeSeederService at startup,
|
||||||
|
* so this resource only ever reads them and updates which charge one maps to -- there is no create/delete.
|
||||||
|
*/
|
||||||
|
@Path("/v1/facilitychargemappings")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Facility Charge Mappings", description = """
|
||||||
|
Which charge is applied when a named account facility (ATM Facility, UPI Facility, Cheque Book, ...) \
|
||||||
|
is enabled on a savings or current account. Enabling a facility whose mapping has no charge configured \
|
||||||
|
is rejected -- configure the mapping here first.""")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FacilityChargeMappingsApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSION = "FACILITYCHARGEMAPPING";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final FacilityChargeMappingReadPlatformService readPlatformService;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List Facility Charge Mappings", description = "Example Requests: facilitychargemappings")
|
||||||
|
public List<FacilityChargeMappingData> retrieveAll() {
|
||||||
|
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
return readPlatformService.retrieveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("template")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve Facility Charge Mapping Template", description = "Charge options available for mapping to a facility")
|
||||||
|
public FacilityChargeMappingData retrieveTemplate() {
|
||||||
|
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
return readPlatformService.retrieveTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("{mappingId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Update a Facility Charge Mapping", description = "Changes which charge is applied when the facility is enabled")
|
||||||
|
public CommandProcessingResult update(@PathParam("mappingId") @Parameter(description = "mappingId") final Long mappingId,
|
||||||
|
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateFacilityChargeMapping(mappingId)
|
||||||
|
.withJson(apiRequestBodyAsJson).build();
|
||||||
|
|
||||||
|
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-1
@@ -81,6 +81,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData;
|
|||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
|
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
|
||||||
import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalculationPlatformService;
|
import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalculationPlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService;
|
import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.service.FixedDepositAccountInterestCalculationService;
|
import org.apache.fineract.portfolio.savings.service.FixedDepositAccountInterestCalculationService;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService;
|
import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService;
|
||||||
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
|
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
|
||||||
@@ -107,6 +108,7 @@ public class FixedDepositAccountsApiResource {
|
|||||||
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
|
private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService;
|
||||||
private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService;
|
private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService;
|
||||||
private final SqlValidator sqlValidator;
|
private final SqlValidator sqlValidator;
|
||||||
|
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("template")
|
@Path("template")
|
||||||
@@ -245,6 +247,28 @@ public class FixedDepositAccountsApiResource {
|
|||||||
final FixedDepositAccountData accountTemplate = populateTemplateAndAssociations(accountId, account, staffInSelectedOfficeOnly,
|
final FixedDepositAccountData accountTemplate = populateTemplateAndAssociations(accountId, account, staffInSelectedOfficeOnly,
|
||||||
chargeStatus, uriInfo, mandatoryResponseParameters);
|
chargeStatus, uriInfo, mandatoryResponseParameters);
|
||||||
accountTemplate.setActivationCharge(getActivationCharge(accountId));
|
accountTemplate.setActivationCharge(getActivationCharge(accountId));
|
||||||
|
// FD Special customers/staff need to see exactly how much interest is projected each month across
|
||||||
|
// the whole term, not just a flat rate/12 approximation -- computed on demand from the real
|
||||||
|
// interest engine (FixedDepositAccount#projectInterestSchedule), side-effect-free, so this GET can
|
||||||
|
// safely recompute it on every request. SINGLE/DOUBLE FDs never get this field.
|
||||||
|
if ("SPECIAL".equalsIgnoreCase(account.getFdType())) {
|
||||||
|
final java.util.List<java.util.Map<String, Object>> schedule = this.depositAccountWritePlatformService
|
||||||
|
.retrieveProjectedInterestSchedule(accountId, DepositAccountType.FIXED_DEPOSIT);
|
||||||
|
accountTemplate.setProjectedInterestSchedule(schedule);
|
||||||
|
// Computed once here -- the single source of truth for "what does the customer end up with
|
||||||
|
// overall" -- rather than the frontend re-summing the schedule array itself, the same way
|
||||||
|
// maturityAmount itself is always a backend-computed field, never a client-side derivation.
|
||||||
|
BigDecimal totalInterest = BigDecimal.ZERO;
|
||||||
|
for (final java.util.Map<String, Object> period : schedule) {
|
||||||
|
final Object amount = period.get("interestAmount");
|
||||||
|
if (amount instanceof BigDecimal) {
|
||||||
|
totalInterest = totalInterest.add((BigDecimal) amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final BigDecimal principal = accountTemplate.getDepositAmount();
|
||||||
|
final BigDecimal totalValue = principal != null ? principal.add(totalInterest) : totalInterest;
|
||||||
|
accountTemplate.setProjectedInterestTotals(totalInterest, totalValue);
|
||||||
|
}
|
||||||
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters(),
|
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters(),
|
||||||
mandatoryResponseParameters);
|
mandatoryResponseParameters);
|
||||||
return this.toApiJsonSerializer.serialize(settings, accountTemplate,
|
return this.toApiJsonSerializer.serialize(settings, accountTemplate,
|
||||||
@@ -440,6 +464,9 @@ public class FixedDepositAccountsApiResource {
|
|||||||
} else if (is(commandParam, "prematureClose")) {
|
} else if (is(commandParam, "prematureClose")) {
|
||||||
final CommandWrapper commandRequest = builder.prematureCloseFixedDepositAccount(accountId).build();
|
final CommandWrapper commandRequest = builder.prematureCloseFixedDepositAccount(accountId).build();
|
||||||
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
} else if (is(commandParam, "payment")) {
|
||||||
|
final CommandWrapper commandRequest = builder.paymentFixedDepositAccount(accountId).build();
|
||||||
|
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
} else if (is(commandParam, "calculatePrematureAmount")) {
|
} else if (is(commandParam, "calculatePrematureAmount")) {
|
||||||
final JsonElement parsedQuery = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
final JsonElement parsedQuery = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||||
final JsonQuery query = JsonQuery.from(apiRequestBodyAsJson, parsedQuery, this.fromJsonHelper);
|
final JsonQuery query = JsonQuery.from(apiRequestBodyAsJson, parsedQuery, this.fromJsonHelper);
|
||||||
@@ -453,7 +480,7 @@ public class FixedDepositAccountsApiResource {
|
|||||||
if (result == null) {
|
if (result == null) {
|
||||||
throw new UnrecognizedQueryParamException("command", commandParam,
|
throw new UnrecognizedQueryParamException("command", commandParam,
|
||||||
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
|
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
|
||||||
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
|
"postInterest", "close", "prematureClose", "payment", "calculatePrematureAmount" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toApiJsonSerializer.serialize(result);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
@@ -489,6 +516,8 @@ public class FixedDepositAccountsApiResource {
|
|||||||
DepositAccountData account = null;
|
DepositAccountData account = null;
|
||||||
if (is(commandParam, "close")) {
|
if (is(commandParam, "close")) {
|
||||||
account = this.depositAccountReadPlatformService.retrieveOneWithClosureTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
|
account = this.depositAccountReadPlatformService.retrieveOneWithClosureTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
|
||||||
|
} else if (is(commandParam, "payment")) {
|
||||||
|
account = this.depositAccountReadPlatformService.retrieveOneWithPaymentTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
|
||||||
|
*
|
||||||
|
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
|
||||||
|
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
|
||||||
|
* be retried without recomputing the accrual, and an accrual can be rebuilt after a backdated
|
||||||
|
* transaction without disturbing what has already been paid.
|
||||||
|
*
|
||||||
|
* Each product posts in its own transaction, so one product failing does not roll back another
|
||||||
|
* that already settled. The failure is recorded against that product's run and the day continues.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SavingsDailyInterestOrchestrator {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final SavingsDailyInterestService accrualService;
|
||||||
|
private final SavingsPooledInterestPostingService postingService;
|
||||||
|
|
||||||
|
/** Accrue for the day, then post for any product whose cycle ends today. */
|
||||||
|
public void runForDate(final LocalDate businessDate) {
|
||||||
|
accrualService.accrueForDate(businessDate);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
|
||||||
|
SELECT s.savings_product_id, s.posting_period_enum
|
||||||
|
FROM m_savings_interest_posting_schedule s
|
||||||
|
JOIN m_savings_product p ON p.id = s.savings_product_id
|
||||||
|
WHERE s.is_active = true AND p.deposit_type_enum = 100
|
||||||
|
ORDER BY s.savings_product_id
|
||||||
|
""");
|
||||||
|
|
||||||
|
for (final Map<String, Object> schedule : schedules) {
|
||||||
|
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
||||||
|
final Long dueCount = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true
|
||||||
|
AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0
|
||||||
|
""", Long.class, productId, businessDate);
|
||||||
|
if (dueCount == null || dueCount == 0) {
|
||||||
|
updateSchedule(productId, businessDate, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
|
||||||
|
updateSchedule(productId, businessDate, "COMPLETED");
|
||||||
|
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// The product rolled back on its own; record why and let the others run.
|
||||||
|
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_run
|
||||||
|
SET status = 'FAILED', failure_reason = ?
|
||||||
|
WHERE savings_product_id = ? AND posting_date = ?
|
||||||
|
""", truncate(e.getMessage()), productId, businessDate);
|
||||||
|
updateSchedule(productId, businessDate, "FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger
|
||||||
|
* itself recorded, so the schedule and the rows can never drift apart.
|
||||||
|
*/
|
||||||
|
private void updateSchedule(final long productId, final LocalDate businessDate, final String status) {
|
||||||
|
final List<Map<String, Object>> cycle = jdbcTemplate.queryForList("""
|
||||||
|
SELECT MIN(cycle_end_date) AS next_end FROM m_savings_daily_interest
|
||||||
|
WHERE savings_product_id = ? AND balance_date = ? AND cycle_end_date >= ?
|
||||||
|
""", productId, businessDate, businessDate);
|
||||||
|
|
||||||
|
LocalDate nextPosting = null;
|
||||||
|
if (!cycle.isEmpty() && cycle.get(0).get("next_end") != null) {
|
||||||
|
nextPosting = ((java.sql.Date) cycle.get(0).get("next_end")).toLocalDate();
|
||||||
|
if (nextPosting.isEqual(businessDate) && "COMPLETED".equals(status)) {
|
||||||
|
final int period = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT posting_period_enum FROM m_savings_interest_posting_schedule WHERE savings_product_id = ?",
|
||||||
|
Integer.class, productId);
|
||||||
|
nextPosting = SavingsInterestCycleCalculator.nextCycleEndDate(businessDate, period,
|
||||||
|
financialYearBeginningMonth());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_schedule
|
||||||
|
SET next_posting_date = ?,
|
||||||
|
last_posting_date = CASE WHEN ? = 'COMPLETED' THEN ? ELSE last_posting_date END,
|
||||||
|
last_run_status = COALESCE(?, last_run_status),
|
||||||
|
last_run_at = CURRENT_TIMESTAMP, updated_on = CURRENT_TIMESTAMP
|
||||||
|
WHERE savings_product_id = ?
|
||||||
|
""", nextPosting, status, businessDate, status, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int financialYearBeginningMonth() {
|
||||||
|
final Integer month = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class);
|
||||||
|
return month == null || month < 1 || month > 12 ? 1 : month;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(final String message) {
|
||||||
|
if (message == null) {
|
||||||
|
return "unknown failure";
|
||||||
|
}
|
||||||
|
return message.length() > 1000 ? message.substring(0, 1000) : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
+445
@@ -0,0 +1,445 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the day-by-day working behind each savings account's interest.
|
||||||
|
*
|
||||||
|
* The engine already computes interest correctly; what it does not do is leave a record. It keeps
|
||||||
|
* one derived total per account and recomputes it from the whole transaction history whenever
|
||||||
|
* anybody looks, so there is no way to ask what an account earned on a particular day, or why a
|
||||||
|
* day earned nothing. This service produces that record: one row per account per day, carrying the
|
||||||
|
* balance it was worked out on and the running total for the cycle.
|
||||||
|
*
|
||||||
|
* The interest terms - rate, posting period, compounding, calculation type, days in year - are read
|
||||||
|
* from the PRODUCT, deliberately. Fineract copies them onto each account at creation and the engine
|
||||||
|
* afterwards reads the account's copy, so the two can drift: in this tenant seventeen accounts on a
|
||||||
|
* quarterly 10% product still carry a daily 7% from whenever they were opened. Interest here is a
|
||||||
|
* property of the product a member bought, not of a stale copy, so this ledger states the product's
|
||||||
|
* terms and the whole product posts on one rhythm. The consequence is that these figures can differ
|
||||||
|
* from total_interest_earned_derived on any account whose copy has drifted; that difference is the
|
||||||
|
* point, and the reconciliation reports it rather than hiding it.
|
||||||
|
*
|
||||||
|
* The minimum balance is the exception: it is held only on the account, never on the product.
|
||||||
|
*
|
||||||
|
* Two rules the engine enforces and this has to match, or the ledger and the account will disagree:
|
||||||
|
*
|
||||||
|
* - Interest is taken on the day's CLOSING balance. EndOfDayBalance holds an opening balance too
|
||||||
|
* but never uses it in the arithmetic; opening is kept here only because it chains.
|
||||||
|
* - A day whose closing balance is under the account's minimum earns nothing at all, not a
|
||||||
|
* reduced amount. That is a cliff, and the row records it so a zero does not read as a fault.
|
||||||
|
*
|
||||||
|
* Scope is savings only - deposit_type_enum 100. Fixed and recurring deposits share
|
||||||
|
* m_savings_account and the same interest engine, but they carry maturity and lock-in rules this
|
||||||
|
* ledger does not model, so they are left alone.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SavingsDailyInterestService {
|
||||||
|
|
||||||
|
private static final MathContext MC = new MathContext(19, RoundingMode.HALF_EVEN);
|
||||||
|
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||||
|
private static final int ACTIVE_STATUS = 300;
|
||||||
|
|
||||||
|
/** m_savings_account.interest_calculation_type_enum: 1 daily balance, 2 average daily balance. */
|
||||||
|
private static final class SavingsInterestCalculationType {
|
||||||
|
|
||||||
|
private SavingsInterestCalculationType() {}
|
||||||
|
|
||||||
|
static final int AVERAGE_DAILY_BALANCE = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one day's rows for every active savings account, for both savings products.
|
||||||
|
*
|
||||||
|
* Safe to run more than once for the same day: rows are keyed on (account, date) and a repeat
|
||||||
|
* updates in place rather than accruing twice. That matters because a day rewritten after a
|
||||||
|
* backdated transaction has to be able to replace what is already there.
|
||||||
|
*
|
||||||
|
* @return how many rows were written
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int accrueForDate(final LocalDate businessDate) {
|
||||||
|
final int financialYearBeginningMonth = financialYearBeginningMonth();
|
||||||
|
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id, sa.product_id, sa.activatedon_date,
|
||||||
|
sp.nominal_annual_interest_rate AS rate,
|
||||||
|
sp.interest_posting_period_enum AS posting_period,
|
||||||
|
sp.interest_compounding_period_enum AS compounding_period,
|
||||||
|
sp.interest_calculation_type_enum AS calculation_type,
|
||||||
|
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
|
||||||
|
sa.min_balance_for_interest_calculation AS min_balance
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sp.deposit_type_enum = ?
|
||||||
|
AND sa.status_enum = ?
|
||||||
|
AND sa.activatedon_date IS NOT NULL
|
||||||
|
AND sa.activatedon_date <= ?
|
||||||
|
ORDER BY sa.product_id, sa.id
|
||||||
|
""", SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS, businessDate);
|
||||||
|
|
||||||
|
int written = 0;
|
||||||
|
for (final Map<String, Object> account : accounts) {
|
||||||
|
written += accrueOneAccount(account, businessDate, financialYearBeginningMonth) ? 1 : 0;
|
||||||
|
}
|
||||||
|
log.info("Savings daily interest: wrote {} rows for {}", written, businessDate);
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean accrueOneAccount(final Map<String, Object> account, final LocalDate businessDate,
|
||||||
|
final int financialYearBeginningMonth) {
|
||||||
|
final Long accountId = ((Number) account.get("id")).longValue();
|
||||||
|
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||||
|
final LocalDate activatedOn = ((java.sql.Date) account.get("activatedon_date")).toLocalDate();
|
||||||
|
final BigDecimal rate = (BigDecimal) account.get("rate");
|
||||||
|
final int postingPeriod = ((Number) account.get("posting_period")).intValue();
|
||||||
|
final Number compounding = (Number) account.get("compounding_period");
|
||||||
|
final Number calcType = (Number) account.get("calculation_type");
|
||||||
|
final int daysInYear = daysInYear(account.get("days_in_year"), businessDate);
|
||||||
|
final BigDecimal minBalance = (BigDecimal) account.get("min_balance");
|
||||||
|
|
||||||
|
// The previous row is the only thing that carries the chain forward. Its absence means
|
||||||
|
// this is the account's first day; a gap would mean a day the job never ran, and rather
|
||||||
|
// than silently re-anchoring we take the last row we do have and let the reconciliation
|
||||||
|
// report the break.
|
||||||
|
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
||||||
|
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
|
||||||
|
cycle_start_date, cycle_end_date, is_posting_day
|
||||||
|
FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = ? AND balance_date < ?
|
||||||
|
ORDER BY balance_date DESC LIMIT 1
|
||||||
|
""", accountId, businessDate);
|
||||||
|
|
||||||
|
final BigDecimal openingBalance;
|
||||||
|
final BigDecimal carriedInterest;
|
||||||
|
final int cycleNumber;
|
||||||
|
final LocalDate cycleStart;
|
||||||
|
final LocalDate cycleEnd;
|
||||||
|
|
||||||
|
if (previous.isEmpty()) {
|
||||||
|
openingBalance = BigDecimal.ZERO;
|
||||||
|
carriedInterest = BigDecimal.ZERO;
|
||||||
|
cycleNumber = 1;
|
||||||
|
// Cycle 1 is a stub: it starts when the account was activated, not on the grid.
|
||||||
|
cycleStart = activatedOn;
|
||||||
|
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth);
|
||||||
|
} else {
|
||||||
|
final Map<String, Object> prev = previous.get(0);
|
||||||
|
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||||
|
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
|
||||||
|
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
|
||||||
|
if (prevWasPosting) {
|
||||||
|
// The cycle closed yesterday: the sum went to the member and starts again at zero.
|
||||||
|
carriedInterest = BigDecimal.ZERO;
|
||||||
|
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
|
||||||
|
cycleStart = prevCycleEnd.plusDays(1);
|
||||||
|
cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
} else {
|
||||||
|
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
||||||
|
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
|
||||||
|
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
|
||||||
|
cycleEnd = prevCycleEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal movement = dayMovement(accountId, businessDate);
|
||||||
|
final BigDecimal closingBalance = openingBalance.add(movement);
|
||||||
|
|
||||||
|
// The minimum is a cliff, not a taper: below it nothing is earned. It is tested against
|
||||||
|
// whichever balance the account actually earns on, which is not always today's closing.
|
||||||
|
final boolean averageDailyBalance = calcType != null
|
||||||
|
&& calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
|
||||||
|
|
||||||
|
final BigDecimal dailyInterest;
|
||||||
|
final BigDecimal cumulative;
|
||||||
|
final boolean belowMinimumForInterest;
|
||||||
|
|
||||||
|
if (averageDailyBalance) {
|
||||||
|
// Average daily balance is a statement about the whole period, not about one day:
|
||||||
|
// the engine averages every closing balance in the cycle and applies the rate once
|
||||||
|
// across all of the days at once. So what this works out is the cycle's running
|
||||||
|
// total, and the day's own interest is whatever that total moved by since yesterday.
|
||||||
|
// Early in a cycle the figure is provisional - a later deposit shifts the average and
|
||||||
|
// restates it - which is inherent to the method, not an artefact of writing it down.
|
||||||
|
final int daysSoFar = (int) (businessDate.toEpochDay() - cycleStart.toEpochDay()) + 1;
|
||||||
|
final BigDecimal averageBalance = averageBalanceForCycle(accountId, cycleStart, businessDate,
|
||||||
|
closingBalance);
|
||||||
|
belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
&& averageBalance.compareTo(minBalance) < 0;
|
||||||
|
if (belowMinimumForInterest || averageBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null
|
||||||
|
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
cumulative = carriedInterest;
|
||||||
|
dailyInterest = BigDecimal.ZERO;
|
||||||
|
} else {
|
||||||
|
cumulative = averageBalance.multiply(rate, MC)
|
||||||
|
.divide(BigDecimal.valueOf(100L), MC)
|
||||||
|
.divide(BigDecimal.valueOf(daysInYear), MC)
|
||||||
|
.multiply(BigDecimal.valueOf(daysSoFar), MC);
|
||||||
|
dailyInterest = cumulative.subtract(carriedInterest);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
&& closingBalance.compareTo(minBalance) < 0;
|
||||||
|
if (belowMinimumForInterest || closingBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null
|
||||||
|
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
dailyInterest = BigDecimal.ZERO;
|
||||||
|
} else {
|
||||||
|
dailyInterest = closingBalance.multiply(rate, MC)
|
||||||
|
.divide(BigDecimal.valueOf(100L), MC)
|
||||||
|
.divide(BigDecimal.valueOf(daysInYear), MC);
|
||||||
|
}
|
||||||
|
cumulative = carriedInterest.add(dailyInterest);
|
||||||
|
}
|
||||||
|
final boolean isPostingDay = !businessDate.isBefore(cycleEnd);
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_daily_interest
|
||||||
|
(savings_product_id, savings_account_id, balance_date, opening_balance, closing_balance,
|
||||||
|
daily_interest, cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date,
|
||||||
|
posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum,
|
||||||
|
posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET
|
||||||
|
opening_balance = EXCLUDED.opening_balance,
|
||||||
|
-- A day whose interest has already been paid keeps the balance it was left
|
||||||
|
-- with. Re-accruing that day recomputes the closing balance from
|
||||||
|
-- transactions alone, which does not include this ledger's own posting, and
|
||||||
|
-- writing it back would erase the credit from the chain: the next day would
|
||||||
|
-- open lower than the member's real balance and they would stop earning on
|
||||||
|
-- interest they have been given.
|
||||||
|
closing_balance = CASE
|
||||||
|
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
|
||||||
|
THEN EXCLUDED.closing_balance + m_savings_daily_interest.posted_amount
|
||||||
|
ELSE EXCLUDED.closing_balance END,
|
||||||
|
daily_interest = EXCLUDED.daily_interest,
|
||||||
|
-- Likewise the total that was actually paid: once money has moved on the
|
||||||
|
-- strength of this figure, recomputing it would leave the ledger disagreeing
|
||||||
|
-- with the member's passbook. A posted day is settled.
|
||||||
|
cycle_cumulative_interest = CASE
|
||||||
|
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
|
||||||
|
THEN m_savings_daily_interest.cycle_cumulative_interest
|
||||||
|
ELSE EXCLUDED.cycle_cumulative_interest END,
|
||||||
|
cycle_number = EXCLUDED.cycle_number,
|
||||||
|
cycle_start_date = EXCLUDED.cycle_start_date,
|
||||||
|
cycle_end_date = EXCLUDED.cycle_end_date,
|
||||||
|
posting_date = EXCLUDED.posting_date,
|
||||||
|
is_posting_day = EXCLUDED.is_posting_day,
|
||||||
|
below_min_balance = EXCLUDED.below_min_balance,
|
||||||
|
-- The terms are re-stamped too. A row rewritten under different product terms
|
||||||
|
-- must not keep the old rate beside a figure the new one produced, or the row
|
||||||
|
-- stops explaining itself. Days already posted keep their money above; this
|
||||||
|
-- only restates what the money was worked out from.
|
||||||
|
interest_rate = EXCLUDED.interest_rate,
|
||||||
|
days_in_year = EXCLUDED.days_in_year,
|
||||||
|
compounding_period_enum = EXCLUDED.compounding_period_enum,
|
||||||
|
posting_period_enum = EXCLUDED.posting_period_enum,
|
||||||
|
calculation_type_enum = EXCLUDED.calculation_type_enum,
|
||||||
|
min_balance_for_interest = EXCLUDED.min_balance_for_interest,
|
||||||
|
last_recomputed_on = CURRENT_TIMESTAMP
|
||||||
|
""", productId, accountId, businessDate, openingBalance, closingBalance, dailyInterest, cumulative,
|
||||||
|
cycleNumber, cycleStart, cycleEnd, cycleEnd, isPostingDay, rate, daysInYear,
|
||||||
|
compounding == null ? null : compounding.intValue(), postingPeriod,
|
||||||
|
calcType == null ? null : calcType.intValue(), minBalance, belowMinimumForInterest);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mean of the cycle's daily closing balances, today included.
|
||||||
|
*
|
||||||
|
* Rows already written for this cycle carry the earlier days; today's closing is not among
|
||||||
|
* them yet, so it is added by the caller. A day the ledger never wrote would pull the mean
|
||||||
|
* off, which is the other reason the chain check matters.
|
||||||
|
*/
|
||||||
|
private BigDecimal averageBalanceForCycle(final Long accountId, final LocalDate cycleStart,
|
||||||
|
final LocalDate businessDate, final BigDecimal todaysClosing) {
|
||||||
|
final BigDecimal earlierSum = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(closing_balance), 0) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ?
|
||||||
|
""", BigDecimal.class, accountId, cycleStart, businessDate);
|
||||||
|
final Long earlierDays = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ?
|
||||||
|
""", Long.class, accountId, cycleStart, businessDate);
|
||||||
|
final long days = (earlierDays == null ? 0L : earlierDays) + 1L;
|
||||||
|
final BigDecimal total = (earlierSum == null ? BigDecimal.ZERO : earlierSum).add(todaysClosing);
|
||||||
|
return total.divide(BigDecimal.valueOf(days), MC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day's net movement, excluding interest postings.
|
||||||
|
*
|
||||||
|
* Interest postings are left out on purpose. The posting is this ledger's own output, and
|
||||||
|
* folding it back in here would let it accrue interest on itself a second time. It is added to
|
||||||
|
* the closing balance by the posting run, on the posting day, once.
|
||||||
|
*/
|
||||||
|
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
|
||||||
|
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(CASE
|
||||||
|
WHEN t.transaction_type_enum IN (1, 8) THEN t.amount
|
||||||
|
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||||
|
ELSE 0 END), 0)
|
||||||
|
FROM m_savings_account_transaction t
|
||||||
|
WHERE t.savings_account_id = ?
|
||||||
|
AND t.transaction_date = ?
|
||||||
|
AND t.is_reversed = false
|
||||||
|
""", BigDecimal.class, accountId, businessDate);
|
||||||
|
return movement == null ? BigDecimal.ZERO : movement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 365, 360, or the actual length of the year. The column holds the enum value, which for the
|
||||||
|
* two fixed options is the number itself; ACTUAL is 1 and means the calendar year.
|
||||||
|
*/
|
||||||
|
private int daysInYear(final Object raw, final LocalDate businessDate) {
|
||||||
|
if (raw == null) {
|
||||||
|
return 365;
|
||||||
|
}
|
||||||
|
final int value = ((Number) raw).intValue();
|
||||||
|
if (value == 360 || value == 365) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return businessDate.lengthOfYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int financialYearBeginningMonth() {
|
||||||
|
final Integer month = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class);
|
||||||
|
return month == null || month < 1 || month > 12 ? 1 : month;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the ledger for one account from a date forward, for when a backdated transaction
|
||||||
|
* changes what earlier days should have earned.
|
||||||
|
*
|
||||||
|
* Recomputing in place rather than writing correction rows: the ledger's job is to say what an
|
||||||
|
* account earned, and after a backdated deposit the honest answer is the new figure. The
|
||||||
|
* last_recomputed_on stamp is what records that the row was revisited.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int recomputeAccountFrom(final long accountId, final LocalDate fromDate, final LocalDate toDate) {
|
||||||
|
final int financialYearBeginningMonth = financialYearBeginningMonth();
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id, sa.product_id, sa.activatedon_date,
|
||||||
|
sp.nominal_annual_interest_rate AS rate,
|
||||||
|
sp.interest_posting_period_enum AS posting_period,
|
||||||
|
sp.interest_compounding_period_enum AS compounding_period,
|
||||||
|
sp.interest_calculation_type_enum AS calculation_type,
|
||||||
|
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
|
||||||
|
sa.min_balance_for_interest_calculation AS min_balance
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sa.id = ?
|
||||||
|
""", accountId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final Map<String, Object> account = rows.get(0);
|
||||||
|
int rewritten = 0;
|
||||||
|
for (LocalDate day = fromDate; !day.isAfter(toDate); day = day.plusDays(1)) {
|
||||||
|
accrueOneAccount(account, day, financialYearBeginningMonth);
|
||||||
|
rewritten++;
|
||||||
|
}
|
||||||
|
log.info("Savings daily interest: recomputed {} days for account {} from {}", rewritten, accountId, fromDate);
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills every active savings account from activation up to the given date.
|
||||||
|
*
|
||||||
|
* Existing accounts have months of history the ledger knows nothing about; without this the
|
||||||
|
* table starts blank and the first cycle it sees is a partial one that will not reconcile
|
||||||
|
* against the account's own totals.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int backfillAll(final LocalDate upToDate) {
|
||||||
|
final List<Long> accountIds = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sp.deposit_type_enum = ? AND sa.status_enum = ?
|
||||||
|
AND sa.activatedon_date IS NOT NULL
|
||||||
|
ORDER BY sa.id
|
||||||
|
""", Long.class, SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS);
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
for (final Long accountId : accountIds) {
|
||||||
|
final java.sql.Date activated = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT activatedon_date FROM m_savings_account WHERE id = ?", java.sql.Date.class, accountId);
|
||||||
|
if (activated == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += recomputeAccountFrom(accountId, activated.toLocalDate(), upToDate);
|
||||||
|
}
|
||||||
|
log.info("Savings daily interest: backfilled {} rows across {} accounts", total, accountIds.size());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two checks worth running: that no day's opening lost touch with the day before it, and
|
||||||
|
* that the open cycle still agrees with what the account itself reports as unposted.
|
||||||
|
*/
|
||||||
|
public List<String> reconcile() {
|
||||||
|
final List<String> problems = new ArrayList<>();
|
||||||
|
|
||||||
|
jdbcTemplate.queryForList("""
|
||||||
|
SELECT curr.savings_account_id, curr.balance_date, curr.opening_balance, prev.closing_balance
|
||||||
|
FROM m_savings_daily_interest curr
|
||||||
|
JOIN m_savings_daily_interest prev
|
||||||
|
ON prev.savings_account_id = curr.savings_account_id
|
||||||
|
AND prev.balance_date = curr.balance_date - INTERVAL '1 day'
|
||||||
|
WHERE curr.opening_balance <> prev.closing_balance
|
||||||
|
ORDER BY curr.savings_account_id, curr.balance_date
|
||||||
|
""").forEach(row -> problems.add(String.format(
|
||||||
|
"chain break: account %s on %s opens at %s but previous day closed at %s",
|
||||||
|
row.get("savings_account_id"), row.get("balance_date"), row.get("opening_balance"),
|
||||||
|
row.get("closing_balance"))));
|
||||||
|
|
||||||
|
jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.savings_account_id, d.cycle_cumulative_interest AS ledger,
|
||||||
|
COALESCE(sa.total_interest_earned_derived, 0) - COALESCE(sa.total_interest_posted_derived, 0) AS account
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
WHERE d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = d.savings_account_id)
|
||||||
|
AND ABS(d.cycle_cumulative_interest
|
||||||
|
- (COALESCE(sa.total_interest_earned_derived, 0)
|
||||||
|
- COALESCE(sa.total_interest_posted_derived, 0))) > 0.01
|
||||||
|
""").forEach(row -> problems.add(String.format(
|
||||||
|
"unposted mismatch: account %s ledger says %s, account says %s", row.get("savings_account_id"),
|
||||||
|
row.get("ledger"), row.get("account"))));
|
||||||
|
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a posting cycle ends, for a given posting period.
|
||||||
|
*
|
||||||
|
* This mirrors SavingsHelper.determineInterestPostingPeriodEndDateFrom, which is private to the
|
||||||
|
* engine and works on the engine's own period objects. The daily ledger needs the same boundaries
|
||||||
|
* to label its rows, so the rule is restated here rather than reached into.
|
||||||
|
*
|
||||||
|
* The boundaries are calendar dates anchored on the financial year start, not anniversaries of the
|
||||||
|
* account: an account opened on 24 August under a quarterly product does not post on 24 November,
|
||||||
|
* it posts at the next quarter end on the grid. The consequence is that the first cycle of any
|
||||||
|
* account is a stub - activation to the first boundary - and every cycle after it is a full period.
|
||||||
|
*
|
||||||
|
* One deliberate difference from the engine. Fineract adds a day to the period end and posts on the
|
||||||
|
* morning after ("interest posting always occurs on next day after the period end date"), unless
|
||||||
|
* the savings-interest-posting-current-period-end configuration is on, in which case it posts on
|
||||||
|
* the period end itself. This ledger always uses the period end date, matching that configuration
|
||||||
|
* being enabled, which is the behaviour this feature was asked for. If the configuration is left
|
||||||
|
* off, the engine's own posting transaction will be dated one day later than the ledger's
|
||||||
|
* posting_date, and the reconciliation will show it - the amounts still agree, only the date moves.
|
||||||
|
*/
|
||||||
|
public final class SavingsInterestCycleCalculator {
|
||||||
|
|
||||||
|
private SavingsInterestCycleCalculator() {}
|
||||||
|
|
||||||
|
// m_savings_product.interest_posting_period_enum / SavingsPostingInterestPeriodType
|
||||||
|
public static final int DAILY = 1;
|
||||||
|
public static final int MONTHLY = 4;
|
||||||
|
public static final int QUARTERLY = 5;
|
||||||
|
public static final int BIANNUAL = 6;
|
||||||
|
public static final int ANNUAL = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last day of the cycle that {@code within} falls in - the day interest is posted.
|
||||||
|
*
|
||||||
|
* @param within
|
||||||
|
* any day inside the cycle
|
||||||
|
* @param postingPeriodEnum
|
||||||
|
* the account's posting period
|
||||||
|
* @param financialYearBeginningMonth
|
||||||
|
* 1-12, from the financial-year-beginning-month configuration
|
||||||
|
*/
|
||||||
|
public static LocalDate cycleEndDate(final LocalDate within, final int postingPeriodEnum,
|
||||||
|
final int financialYearBeginningMonth) {
|
||||||
|
switch (postingPeriodEnum) {
|
||||||
|
case DAILY:
|
||||||
|
return within;
|
||||||
|
case MONTHLY:
|
||||||
|
return within.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
case QUARTERLY:
|
||||||
|
return periodEndOnGrid(within, financialYearBeginningMonth, 3);
|
||||||
|
case BIANNUAL:
|
||||||
|
return periodEndOnGrid(within, financialYearBeginningMonth, 6);
|
||||||
|
case ANNUAL:
|
||||||
|
return periodEndOnGrid(within, financialYearBeginningMonth, 12);
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("Unsupported savings interest posting period: " + postingPeriodEnum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first day of the cycle that {@code within} falls in, ignoring activation. Callers hold
|
||||||
|
* the account's start date and take the later of the two, which is what makes cycle 1 a stub.
|
||||||
|
*/
|
||||||
|
public static LocalDate cycleStartDate(final LocalDate within, final int postingPeriodEnum,
|
||||||
|
final int financialYearBeginningMonth) {
|
||||||
|
if (postingPeriodEnum == DAILY) {
|
||||||
|
return within;
|
||||||
|
}
|
||||||
|
final LocalDate previousEnd = cycleEndDate(within, postingPeriodEnum, financialYearBeginningMonth);
|
||||||
|
// The cycle containing `within` starts the day after the previous cycle ended.
|
||||||
|
return cycleEndDate(previousEnd.minusMonths(monthsIn(postingPeriodEnum)), postingPeriodEnum,
|
||||||
|
financialYearBeginningMonth).plusDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cycle after the one ending on {@code cycleEnd}. */
|
||||||
|
public static LocalDate nextCycleEndDate(final LocalDate cycleEnd, final int postingPeriodEnum,
|
||||||
|
final int financialYearBeginningMonth) {
|
||||||
|
return cycleEndDate(cycleEnd.plusDays(1), postingPeriodEnum, financialYearBeginningMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int monthsIn(final int postingPeriodEnum) {
|
||||||
|
switch (postingPeriodEnum) {
|
||||||
|
case MONTHLY:
|
||||||
|
return 1;
|
||||||
|
case QUARTERLY:
|
||||||
|
return 3;
|
||||||
|
case BIANNUAL:
|
||||||
|
return 6;
|
||||||
|
case ANNUAL:
|
||||||
|
return 12;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the fixed grid of period ends starting at the financial year month and returns the
|
||||||
|
* first one on or after {@code within}.
|
||||||
|
*
|
||||||
|
* The grid is built from the financial year start rather than from January, so a year
|
||||||
|
* beginning in April gives quarter ends of 30 Jun, 30 Sep, 31 Dec, 31 Mar - the same dates
|
||||||
|
* the engine produces for the same configuration.
|
||||||
|
*/
|
||||||
|
private static LocalDate periodEndOnGrid(final LocalDate within, final int financialYearBeginningMonth,
|
||||||
|
final int monthsPerPeriod) {
|
||||||
|
// Last day of the month before the financial year starts, in the year `within` falls in.
|
||||||
|
LocalDate gridAnchor = within.withDayOfMonth(1).withMonth(financialYearBeginningMonth).minusDays(1);
|
||||||
|
if (gridAnchor.isAfter(within)) {
|
||||||
|
gridAnchor = gridAnchor.minusYears(1);
|
||||||
|
}
|
||||||
|
LocalDate candidate = gridAnchor;
|
||||||
|
// At most 12/monthsPerPeriod steps land on or past `within`; the extra step guards the
|
||||||
|
// case where the anchor itself is the answer.
|
||||||
|
for (int i = 0; i <= 12 / monthsPerPeriod; i++) {
|
||||||
|
if (!candidate.isBefore(within)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
candidate = candidate.plusDays(1).plusMonths(monthsPerPeriod).minusDays(1)
|
||||||
|
.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays a savings product's accrued interest to its members, pooled through two control accounts.
|
||||||
|
*
|
||||||
|
* Stock Fineract posts interest one member at a time - debit Interest on Savings, credit Savings
|
||||||
|
* Control, per account - and its posting job spreads that work across a thread pool, each batch in
|
||||||
|
* its own transaction. That is sound double entry, but it has no seam a failure can be caught on:
|
||||||
|
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
||||||
|
* holds the figure that would say so.
|
||||||
|
*
|
||||||
|
* This posts the product's total first, into Savings Interest Posting Control, moves it to Savings
|
||||||
|
* Interest Posted Control, and only then pays it out to members. The whole product settles inside
|
||||||
|
* one transaction, and both control accounts have to read zero before it is allowed to commit. A
|
||||||
|
* failure rolls the product back entirely - nobody is half paid - and a run that never reached
|
||||||
|
* commit leaves a PENDING row naming the product and date for the next run to pick up.
|
||||||
|
*
|
||||||
|
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
|
||||||
|
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
|
||||||
|
* ends a few paise adrift and that money is simply gone. Here the residual is worked out and given
|
||||||
|
* to the largest holding - the account least distorted by it - so the pool closes exactly and the
|
||||||
|
* amount that moved is written down in the run record.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SavingsPooledInterestPostingService {
|
||||||
|
|
||||||
|
/** Two decimals: what the member is actually credited. */
|
||||||
|
private static final int MONEY_SCALE = 2;
|
||||||
|
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||||
|
|
||||||
|
private static final String GL_POSTING_CONTROL = "200304";
|
||||||
|
private static final String GL_POSTED_CONTROL = "200305";
|
||||||
|
|
||||||
|
/** m_savings_account_transaction.transaction_type_enum */
|
||||||
|
private static final int INTEREST_POSTING = 3;
|
||||||
|
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
|
||||||
|
private static final int ENTITY_TYPE_SAVINGS = 2;
|
||||||
|
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts one product's interest for one posting date.
|
||||||
|
*
|
||||||
|
* REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other
|
||||||
|
* product's completed run down with it.
|
||||||
|
*
|
||||||
|
* @return the amount distributed, or zero if there was nothing to do
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
|
||||||
|
final Long completed = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_savings_interest_posting_run
|
||||||
|
WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED'
|
||||||
|
""", Long.class, productId, postingDate);
|
||||||
|
if (completed != null && completed > 0) {
|
||||||
|
log.info("Savings interest posting: product {} already completed for {}", productId, postingDate);
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The accounts due today, with what they accrued. Anything already carrying a posting
|
||||||
|
// transaction is skipped, so a resumed run cannot pay somebody a second time.
|
||||||
|
// A savings account has no office of its own: it belongs to a client, or to a group when
|
||||||
|
// there is no client, and the office is theirs. The journal entries have to be raised
|
||||||
|
// against that office or they land in the wrong branch's books.
|
||||||
|
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance,
|
||||||
|
d.cycle_cumulative_interest AS gross_interest,
|
||||||
|
COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code,
|
||||||
|
sa.currency_digits
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true
|
||||||
|
AND d.savings_transaction_id IS NULL
|
||||||
|
AND d.cycle_cumulative_interest > 0
|
||||||
|
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
||||||
|
ORDER BY d.savings_account_id
|
||||||
|
""", productId, postingDate);
|
||||||
|
|
||||||
|
if (due.isEmpty()) {
|
||||||
|
log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate);
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
final long runId = openRun(productId, postingDate, due.size());
|
||||||
|
|
||||||
|
// Round each member's share, then make the rounded shares add up. The residual is the
|
||||||
|
// difference between what the pool holds and what the members would otherwise receive.
|
||||||
|
BigDecimal grossTotal = BigDecimal.ZERO;
|
||||||
|
BigDecimal roundedTotal = BigDecimal.ZERO;
|
||||||
|
for (final Map<String, Object> row : due) {
|
||||||
|
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
|
||||||
|
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
row.put("rounded_interest", rounded);
|
||||||
|
grossTotal = grossTotal.add(gross);
|
||||||
|
roundedTotal = roundedTotal.add(rounded);
|
||||||
|
}
|
||||||
|
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
final BigDecimal residual = pooled.subtract(roundedTotal);
|
||||||
|
|
||||||
|
if (residual.compareTo(BigDecimal.ZERO) != 0) {
|
||||||
|
// To the largest balance: the account where a paisa is proportionally smallest.
|
||||||
|
final Map<String, Object> largest = due.stream()
|
||||||
|
.max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow();
|
||||||
|
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
|
||||||
|
log.info("Savings interest posting: residual {} assigned to account {}", residual,
|
||||||
|
largest.get("savings_account_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long postingControlGl = glAccountId(GL_POSTING_CONTROL);
|
||||||
|
final Long postedControlGl = glAccountId(GL_POSTED_CONTROL);
|
||||||
|
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS);
|
||||||
|
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
||||||
|
final String currency = (String) due.get(0).get("currency_code");
|
||||||
|
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
||||||
|
|
||||||
|
// Stage 1: the product's whole interest bill, recognised and pooled.
|
||||||
|
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
|
||||||
|
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null);
|
||||||
|
|
||||||
|
// Stage 2: the pool moves to the account it is paid out of.
|
||||||
|
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null);
|
||||||
|
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
|
||||||
|
|
||||||
|
// Stage 3: out to the members, one at a time.
|
||||||
|
BigDecimal distributed = BigDecimal.ZERO;
|
||||||
|
for (final Map<String, Object> row : due) {
|
||||||
|
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final long accountId = ((Number) row.get("savings_account_id")).longValue();
|
||||||
|
final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount);
|
||||||
|
// The member's own branch, which need not be the branch the pool was raised in.
|
||||||
|
final long accountOfficeId = ((Number) row.get("office_id")).longValue();
|
||||||
|
|
||||||
|
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
|
||||||
|
newBalance);
|
||||||
|
|
||||||
|
journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference,
|
||||||
|
accountId);
|
||||||
|
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
||||||
|
accountId, transactionId);
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_daily_interest
|
||||||
|
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
||||||
|
closing_balance = closing_balance + ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_account
|
||||||
|
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
||||||
|
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", amount, amount, accountId);
|
||||||
|
|
||||||
|
distributed = distributed.add(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the two control accounts: if this does not hold, nothing commits.
|
||||||
|
if (pooled.compareTo(distributed) != 0) {
|
||||||
|
throw new IllegalStateException(String.format(
|
||||||
|
"Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s",
|
||||||
|
productId, postingDate, pooled, distributed));
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_run
|
||||||
|
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
|
||||||
|
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""", pooled, distributed, residual, runId);
|
||||||
|
|
||||||
|
log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
|
||||||
|
postingDate, distributed, due.size(), residual);
|
||||||
|
return distributed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_interest_posting_run
|
||||||
|
(savings_product_id, posting_date, account_count, status)
|
||||||
|
VALUES (?, ?, ?, 'PENDING')
|
||||||
|
ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET
|
||||||
|
account_count = EXCLUDED.account_count, status = 'PENDING',
|
||||||
|
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
|
||||||
|
""", productId, postingDate, accountCount);
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
SELECT id FROM m_savings_interest_posting_run
|
||||||
|
WHERE savings_product_id = ? AND posting_date = ?
|
||||||
|
""", Long.class, productId, postingDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
|
||||||
|
final BigDecimal amount, final BigDecimal runningBalance) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_account_transaction
|
||||||
|
(savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction,
|
||||||
|
transaction_type_enum, transaction_date, amount, running_balance_derived,
|
||||||
|
submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc)
|
||||||
|
VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
""", accountId, officeId, INTEREST_POSTING, date, amount, runningBalance, date, systemUserId(),
|
||||||
|
systemUserId());
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
SELECT id FROM m_savings_account_transaction
|
||||||
|
WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ?
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""", Long.class, accountId, date, INTEREST_POSTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The member's own leg: the product's Savings Control account, credited per account. */
|
||||||
|
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
|
||||||
|
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
|
||||||
|
final long transactionId) {
|
||||||
|
final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL
|
||||||
|
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||||
|
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) {
|
||||||
|
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||||
|
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
||||||
|
final Long transactionId) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO acc_gl_journal_entry
|
||||||
|
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
|
||||||
|
amount, description, entity_type_enum, entity_id, is_running_balance_calculated,
|
||||||
|
savings_transaction_id, reversed, manual_entry, office_running_balance,
|
||||||
|
organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date,
|
||||||
|
created_on_utc, last_modified_on_utc, submitted_on_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
||||||
|
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||||
|
""", officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount,
|
||||||
|
debit ? "Savings interest posting" : "Savings interest posted",
|
||||||
|
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
||||||
|
systemUserId(), systemUserId(), date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user these rows are attributed to. The job runs without anybody logged in, and the audit
|
||||||
|
* columns are NOT NULL, so entries are stamped to the system account the scheduler uses.
|
||||||
|
*/
|
||||||
|
private Long systemUserId() {
|
||||||
|
final List<Long> ids = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class);
|
||||||
|
return ids.isEmpty() ? 1L : ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long glAccountId(final String glCode) {
|
||||||
|
return jdbcTemplate.queryForObject("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long mappedGlAccount(final long productId, final int financialAccountType) {
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
SELECT gl_account_id FROM acc_product_mapping
|
||||||
|
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", Long.class, productId, financialAccountType);
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the savings daily interest ledger, per product and per account.
|
||||||
|
*
|
||||||
|
* The per-product listing is the view this was built for: pick a product, see its accounts and
|
||||||
|
* where each one is in its cycle. The per-account listing is the working itself - a day per row,
|
||||||
|
* the balance it was taken on, and the running total resetting to zero the day interest is paid.
|
||||||
|
*/
|
||||||
|
@Path("/v1/savingsdailyinterest")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class SavingsDailyInterestApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final SavingsDailyInterestService accrualService;
|
||||||
|
private final SavingsDailyInterestOrchestrator orchestrator;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/** The savings products carrying a ledger, with where each one is in its timetable. */
|
||||||
|
@GET
|
||||||
|
@Path("/products")
|
||||||
|
public String products() {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT p.id, p.name, p.short_name, p.nominal_annual_interest_rate AS rate,
|
||||||
|
s.posting_period_enum, s.run_at_time, s.last_posting_date, s.next_posting_date,
|
||||||
|
s.last_run_status, s.is_active,
|
||||||
|
(SELECT COUNT(*) FROM m_savings_account sa
|
||||||
|
WHERE sa.product_id = p.id AND sa.status_enum = 300) AS active_accounts,
|
||||||
|
(SELECT COALESCE(SUM(d.cycle_cumulative_interest), 0)
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
WHERE d.savings_product_id = p.id
|
||||||
|
AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_product_id = p.id)) AS accrued_this_cycle
|
||||||
|
FROM m_savings_product p
|
||||||
|
LEFT JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id
|
||||||
|
WHERE p.deposit_type_enum = 100
|
||||||
|
ORDER BY p.id
|
||||||
|
""");
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One product's accounts as at the latest day the ledger holds. */
|
||||||
|
@GET
|
||||||
|
@Path("/products/{productId}/accounts")
|
||||||
|
public String accountsForProduct(@PathParam("productId") final Long productId) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.savings_account_id, sa.account_no, c.display_name AS client_name,
|
||||||
|
d.balance_date, d.opening_balance, d.closing_balance, d.daily_interest,
|
||||||
|
d.cycle_cumulative_interest, d.cycle_number, d.cycle_start_date, d.cycle_end_date,
|
||||||
|
d.below_min_balance
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
WHERE d.savings_product_id = ?
|
||||||
|
AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_product_id = ?)
|
||||||
|
ORDER BY sa.account_no
|
||||||
|
""", productId, productId);
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One account's day-by-day working, newest first. */
|
||||||
|
@GET
|
||||||
|
@Path("/accounts/{accountId}")
|
||||||
|
public String accountLedger(@PathParam("accountId") final Long accountId,
|
||||||
|
@QueryParam("limit") final Integer limit) {
|
||||||
|
final int rowLimit = limit == null || limit <= 0 ? 200 : Math.min(limit, 2000);
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT balance_date, opening_balance, closing_balance, daily_interest,
|
||||||
|
cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date,
|
||||||
|
is_posting_day, posted_amount, savings_transaction_id, below_min_balance,
|
||||||
|
interest_rate, days_in_year, min_balance_for_interest, last_recomputed_on
|
||||||
|
FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = ?
|
||||||
|
ORDER BY balance_date DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", accountId, rowLimit);
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each posting run pooled and paid, and whether it finished. */
|
||||||
|
@GET
|
||||||
|
@Path("/runs")
|
||||||
|
public String runs(@QueryParam("productId") final Long productId) {
|
||||||
|
final String sql = """
|
||||||
|
SELECT r.id, r.savings_product_id, p.name AS product_name, r.posting_date, r.account_count,
|
||||||
|
r.pooled_amount, r.distributed_amount, r.rounding_residual, r.status,
|
||||||
|
r.failure_reason, r.started_at, r.completed_at
|
||||||
|
FROM m_savings_interest_posting_run r
|
||||||
|
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||||
|
""" + (productId == null ? "" : " WHERE r.savings_product_id = ? ")
|
||||||
|
+ " ORDER BY r.posting_date DESC, r.savings_product_id";
|
||||||
|
final List<Map<String, Object>> rows = productId == null ? jdbcTemplate.queryForList(sql)
|
||||||
|
: jdbcTemplate.queryForList(sql, productId);
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chain check and the unposted-total check. An empty list is the answer you want: every
|
||||||
|
* day picks up where the last one left off, and the ledger still agrees with the accounts.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/reconcile")
|
||||||
|
public String reconcile() {
|
||||||
|
final List<String> problems = accrualService.reconcile();
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("clean", problems.isEmpty());
|
||||||
|
result.put("problemCount", problems.size());
|
||||||
|
result.put("problems", problems);
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a day by hand, for a first backfill or after fixing a failed night. */
|
||||||
|
@POST
|
||||||
|
@Path("/run")
|
||||||
|
public String run(@QueryParam("date") final String dateParam) {
|
||||||
|
final LocalDate businessDate = dateParam == null || dateParam.isBlank()
|
||||||
|
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
|
||||||
|
: LocalDate.parse(dateParam);
|
||||||
|
orchestrator.runForDate(businessDate);
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ranFor", businessDate.toString());
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the ledger for every account from activation to the given date. */
|
||||||
|
@POST
|
||||||
|
@Path("/backfill")
|
||||||
|
public String backfill(@QueryParam("upTo") final String upToParam) {
|
||||||
|
final LocalDate upTo = upToParam == null || upToParam.isBlank()
|
||||||
|
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
|
||||||
|
: LocalDate.parse(upToParam);
|
||||||
|
final int rows = accrualService.backfillAll(upTo);
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("rowsWritten", rows);
|
||||||
|
result.put("upTo", upTo.toString());
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-1
@@ -97,6 +97,7 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
|
|||||||
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
|
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -105,11 +106,14 @@ public class DepositAccountDataValidator {
|
|||||||
|
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final DepositProductDataValidator productDataValidator;
|
private final DepositProductDataValidator productDataValidator;
|
||||||
|
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator) {
|
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator,
|
||||||
|
final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) {
|
||||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||||
this.productDataValidator = productDataValidator;
|
this.productDataValidator = productDataValidator;
|
||||||
|
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void validateFixedDepositForSubmit(final String json) {
|
public void validateFixedDepositForSubmit(final String json) {
|
||||||
@@ -351,10 +355,40 @@ public class DepositAccountDataValidator {
|
|||||||
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
|
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
|
||||||
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
|
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
|
||||||
|
|
||||||
|
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
|
||||||
|
// moment the account matures -- before the customer has actually come in to collect it. For
|
||||||
|
// maturities processed automatically by the scheduled job (not a live counter transaction),
|
||||||
|
// that books cash as disbursed while it is still physically in the vault, so this option is
|
||||||
|
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
|
||||||
|
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
|
||||||
|
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
|
||||||
|
"maturity.instruction.withdraw.deposit.not.allowed",
|
||||||
|
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
|
||||||
|
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
|
||||||
|
// ever chosen manually via the Payment action, never set here.
|
||||||
|
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
|
||||||
|
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
|
||||||
|
"maturity.instruction.partial.reinvest.not.allowed",
|
||||||
|
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
|
||||||
|
}
|
||||||
|
|
||||||
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
|
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
|
||||||
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
|
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
|
||||||
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
|
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
|
||||||
.longGreaterThanZero();
|
.longGreaterThanZero();
|
||||||
|
|
||||||
|
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
|
||||||
|
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
|
||||||
|
if (transferToSavingsId != null && transferToSavingsId > 0
|
||||||
|
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
|
||||||
|
DepositAccountType.SAVINGS_DEPOSIT)) {
|
||||||
|
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
|
||||||
|
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
|
||||||
|
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,10 +527,40 @@ public class DepositAccountDataValidator {
|
|||||||
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
|
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
|
||||||
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
|
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
|
||||||
|
|
||||||
|
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
|
||||||
|
// moment the account matures -- before the customer has actually come in to collect it. For
|
||||||
|
// maturities processed automatically by the scheduled job (not a live counter transaction),
|
||||||
|
// that books cash as disbursed while it is still physically in the vault, so this option is
|
||||||
|
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
|
||||||
|
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
|
||||||
|
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
|
||||||
|
"maturity.instruction.withdraw.deposit.not.allowed",
|
||||||
|
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
|
||||||
|
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
|
||||||
|
// ever chosen manually via the Payment action, never set here.
|
||||||
|
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
|
||||||
|
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
|
||||||
|
"maturity.instruction.partial.reinvest.not.allowed",
|
||||||
|
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
|
||||||
|
}
|
||||||
|
|
||||||
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
|
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
|
||||||
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
|
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
|
||||||
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
|
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
|
||||||
.longGreaterThanZero();
|
.longGreaterThanZero();
|
||||||
|
|
||||||
|
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
|
||||||
|
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
|
||||||
|
if (transferToSavingsId != null && transferToSavingsId > 0
|
||||||
|
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
|
||||||
|
DepositAccountType.SAVINGS_DEPOSIT)) {
|
||||||
|
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
|
||||||
|
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
|
||||||
|
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-1
@@ -82,6 +82,13 @@ public class DepositAccountTransactionDataValidator {
|
|||||||
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
|
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
|
||||||
|
|
||||||
|
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
|
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||||
|
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
|
||||||
|
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
|
||||||
|
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
|
||||||
|
DepositsApiConstants.reinvestAmountParamName));
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public DepositAccountTransactionDataValidator(final FromJsonHelper fromApiJsonHelper) {
|
public DepositAccountTransactionDataValidator(final FromJsonHelper fromApiJsonHelper) {
|
||||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
this.fromApiJsonHelper = fromApiJsonHelper;
|
||||||
@@ -222,7 +229,15 @@ public class DepositAccountTransactionDataValidator {
|
|||||||
|
|
||||||
if (onAccountClosureId != null) {
|
if (onAccountClosureId != null) {
|
||||||
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
if (accountOnClosureType.isTransferToSavings()) {
|
if (accountOnClosureType.isPartialReinvest()) {
|
||||||
|
// Partial Re-Invest needs a kept-amount and a destination account that only the Payment
|
||||||
|
// command collects, and only its handler implements. Neither the FD nor the RD closure
|
||||||
|
// handler has a branch for it, so without this it would fall through to their terminal
|
||||||
|
// "withdraw the whole balance" case and silently pay the deposit out in cash.
|
||||||
|
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
|
||||||
|
"partial.reinvest.not.allowed",
|
||||||
|
"Partial Re-Invest is only supported through the Payment action, not account closure");
|
||||||
|
} else if (accountOnClosureType.isTransferToSavings()) {
|
||||||
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
|
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
|
||||||
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
|
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
|
||||||
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName,
|
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName,
|
||||||
@@ -247,6 +262,73 @@ public class DepositAccountTransactionDataValidator {
|
|||||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the FD "Payment" command -- kept independent of {@link #validateClosing}, which stays exactly as-is
|
||||||
|
* for Close/Premature Close, so this never changes either of their existing behavior. Unlike
|
||||||
|
* {@code validateClosing}, reinvest is always allowed here (there is no {@code isPreMatureClose} restriction --
|
||||||
|
* Payment is meant to work on an Active account too), and a new Partial Reinvest closure type needs its own
|
||||||
|
* amount and destination-account rules.
|
||||||
|
*/
|
||||||
|
public void validatePayment(final JsonCommand command) {
|
||||||
|
final String json = command.json();
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(json)) {
|
||||||
|
throw new InvalidJsonException();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
|
||||||
|
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS);
|
||||||
|
|
||||||
|
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||||
|
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
||||||
|
.resource(DepositAccountType.FIXED_DEPOSIT.resourceName());
|
||||||
|
|
||||||
|
final JsonElement element = command.parsedJson();
|
||||||
|
|
||||||
|
final LocalDate closedOnDate = this.fromApiJsonHelper.extractLocalDateNamed(closedOnDateParamName, element);
|
||||||
|
baseDataValidator.reset().parameter(closedOnDateParamName).value(closedOnDate).notNull();
|
||||||
|
|
||||||
|
final Integer onAccountClosureId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(onAccountClosureIdParamName, element);
|
||||||
|
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).notBlank()
|
||||||
|
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
|
||||||
|
|
||||||
|
if (onAccountClosureId != null) {
|
||||||
|
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
|
if (accountOnClosureType.isWithdarwDeposit()) {
|
||||||
|
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCodeAndMessage(
|
||||||
|
"withdraw.deposit.not.allowed.for.payment",
|
||||||
|
"Withdraw Deposit is not supported via Payment -- choose Transfer to Savings or a Reinvest option.");
|
||||||
|
}
|
||||||
|
// Interest earned under Reinvest Principal Only has to leave the deposit somewhere -- the same
|
||||||
|
// destination-account rule Transfer to Savings and Partial Reinvest already need.
|
||||||
|
if (accountOnClosureType.isTransferToSavings() || accountOnClosureType.isPartialReinvest()
|
||||||
|
|| accountOnClosureType.isReinvestPrincipal()) {
|
||||||
|
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
|
||||||
|
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
|
||||||
|
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
|
||||||
|
}
|
||||||
|
if (accountOnClosureType.isPartialReinvest()) {
|
||||||
|
final BigDecimal reinvestAmount = this.fromApiJsonHelper
|
||||||
|
.extractBigDecimalWithLocaleNamed(DepositsApiConstants.reinvestAmountParamName, element);
|
||||||
|
baseDataValidator.reset().parameter(DepositsApiConstants.reinvestAmountParamName).value(reinvestAmount).notNull()
|
||||||
|
.positiveAmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all string payment detail fields for max length
|
||||||
|
final Integer paymentTypeId = this.fromApiJsonHelper.extractIntegerWithLocaleNamed(paymentTypeIdParamName, element);
|
||||||
|
baseDataValidator.reset().parameter(paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull().integerGreaterThanZero();
|
||||||
|
final Set<String> paymentDetailParameters = new HashSet<>(Arrays.asList(transactionAccountNumberParamName, checkNumberParamName,
|
||||||
|
routingCodeParamName, receiptNumberParamName, bankNumberParamName));
|
||||||
|
for (final String paymentDetailParameterName : paymentDetailParameters) {
|
||||||
|
final String paymentDetailParameterValue = this.fromApiJsonHelper.extractStringNamed(paymentDetailParameterName, element);
|
||||||
|
baseDataValidator.reset().parameter(paymentDetailParameterName).value(paymentDetailParameterValue).ignoreIfNull()
|
||||||
|
.notExceedingLengthOf(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
||||||
if (!dataValidationErrors.isEmpty()) {
|
if (!dataValidationErrors.isEmpty()) {
|
||||||
//
|
//
|
||||||
|
|||||||
+2
-1
@@ -69,7 +69,8 @@ public class SavingsAccountTransactionDataValidator {
|
|||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private static final Set<String> SAVINGS_ACCOUNT_HOLD_AMOUNT_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
private static final Set<String> SAVINGS_ACCOUNT_HOLD_AMOUNT_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(transactionDateParamName, SavingsApiConstants.dateFormatParamName, SavingsApiConstants.localeParamName,
|
Arrays.asList(transactionDateParamName, SavingsApiConstants.dateFormatParamName, SavingsApiConstants.localeParamName,
|
||||||
transactionAmountParamName, lienAllowedParamName, SavingsApiConstants.reasonForBlockParamName));
|
transactionAmountParamName, lienAllowedParamName, SavingsApiConstants.reasonForBlockParamName,
|
||||||
|
SavingsApiConstants.holdCategoryParamName));
|
||||||
private final ConfigurationDomainService configurationDomainService;
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
|
|
||||||
public void validateTransactionWithPivotDate(final LocalDate transactionDate, final SavingsAccount savingsAccount) {
|
public void validateTransactionWithPivotDate(final LocalDate transactionDate, final SavingsAccount savingsAccount) {
|
||||||
|
|||||||
+72
-7
@@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.expecte
|
|||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isCalendarInheritedParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isCalendarInheritedParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isMandatoryDepositParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isMandatoryDepositParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.linkedAccountParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.mandatoryRecommendedDepositAmountParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.mandatoryRecommendedDepositAmountParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maturityInstructionIdParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maturityInstructionIdParamName;
|
||||||
@@ -68,6 +69,7 @@ import java.util.Locale;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException;
|
import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
@@ -87,6 +89,7 @@ import org.apache.fineract.portfolio.group.exception.CenterNotActiveException;
|
|||||||
import org.apache.fineract.portfolio.group.exception.ClientNotInGroupException;
|
import org.apache.fineract.portfolio.group.exception.ClientNotInGroupException;
|
||||||
import org.apache.fineract.portfolio.group.exception.GroupNotActiveException;
|
import org.apache.fineract.portfolio.group.exception.GroupNotActiveException;
|
||||||
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
|
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
|
||||||
|
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailAssembler;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailAssembler;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||||
@@ -97,9 +100,11 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
|
|||||||
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
|
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
|
||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
|
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
|
||||||
|
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
||||||
import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException;
|
import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException;
|
||||||
import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException;
|
import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException;
|
||||||
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
|
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -123,6 +128,7 @@ public class DepositAccountAssembler {
|
|||||||
|
|
||||||
private final ExternalIdFactory externalIdFactory;
|
private final ExternalIdFactory externalIdFactory;
|
||||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||||
|
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public DepositAccountAssembler(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper,
|
public DepositAccountAssembler(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper,
|
||||||
@@ -134,7 +140,8 @@ public class DepositAccountAssembler {
|
|||||||
final RecurringDepositProductRepository recurringDepositProductRepository,
|
final RecurringDepositProductRepository recurringDepositProductRepository,
|
||||||
final AccountTransfersReadPlatformService accountTransfersReadPlatformService, final PlatformSecurityContext context,
|
final AccountTransfersReadPlatformService accountTransfersReadPlatformService, final PlatformSecurityContext context,
|
||||||
final PaymentDetailAssembler paymentDetailAssembler, ExternalIdFactory externalIdFactory,
|
final PaymentDetailAssembler paymentDetailAssembler, ExternalIdFactory externalIdFactory,
|
||||||
final SavingsAccountAssembler savingsAccountAssembler) {
|
final SavingsAccountAssembler savingsAccountAssembler,
|
||||||
|
final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService) {
|
||||||
|
|
||||||
this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper;
|
this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper;
|
||||||
this.clientRepository = clientRepository;
|
this.clientRepository = clientRepository;
|
||||||
@@ -151,6 +158,7 @@ public class DepositAccountAssembler {
|
|||||||
this.paymentDetailAssembler = paymentDetailAssembler;
|
this.paymentDetailAssembler = paymentDetailAssembler;
|
||||||
this.externalIdFactory = externalIdFactory;
|
this.externalIdFactory = externalIdFactory;
|
||||||
this.savingsAccountAssembler = savingsAccountAssembler;
|
this.savingsAccountAssembler = savingsAccountAssembler;
|
||||||
|
this.fdBaseInterestRateReadPlatformService = fdBaseInterestRateReadPlatformService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,6 +250,14 @@ public class DepositAccountAssembler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FD Special (fdType=SPECIAL) is a non-cumulative, monthly-interest-payout product: interest must
|
||||||
|
// post every month and leave the account for its linked savings account (see
|
||||||
|
// assembleAccountTermAndPreClosure below and the maturity-instruction override there), regardless
|
||||||
|
// of whatever compounding/posting period the product or the request otherwise specifies. SINGLE and
|
||||||
|
// DOUBLE FD types are untouched by this flag.
|
||||||
|
final boolean isSpecialFixedDeposit = depositAccountType.isFixedDeposit() && product instanceof FixedDepositProduct
|
||||||
|
&& "SPECIAL".equalsIgnoreCase(((FixedDepositProduct) product).getFdType());
|
||||||
|
|
||||||
SavingsCompoundingInterestPeriodType interestCompoundingPeriodType = null;
|
SavingsCompoundingInterestPeriodType interestCompoundingPeriodType = null;
|
||||||
final Integer interestPeriodTypeValue = command.integerValueOfParameterNamed(interestCompoundingPeriodTypeParamName);
|
final Integer interestPeriodTypeValue = command.integerValueOfParameterNamed(interestCompoundingPeriodTypeParamName);
|
||||||
if (interestPeriodTypeValue != null) {
|
if (interestPeriodTypeValue != null) {
|
||||||
@@ -249,6 +265,13 @@ public class DepositAccountAssembler {
|
|||||||
} else {
|
} else {
|
||||||
interestCompoundingPeriodType = product.interestCompoundingPeriodType();
|
interestCompoundingPeriodType = product.interestCompoundingPeriodType();
|
||||||
}
|
}
|
||||||
|
if (isSpecialFixedDeposit) {
|
||||||
|
// Keep compounding no coarser than the forced Monthly posting below -- compounded interest gets
|
||||||
|
// reset to zero every period anyway once transferInterestToLinkedAccount is true (see
|
||||||
|
// CompoundInterestHelper), so this is only about keeping the interest engine's own
|
||||||
|
// posting/compounding-period expectations internally consistent, not a behaviour choice.
|
||||||
|
interestCompoundingPeriodType = SavingsCompoundingInterestPeriodType.MONTHLY;
|
||||||
|
}
|
||||||
|
|
||||||
SavingsPostingInterestPeriodType interestPostingPeriodType = null;
|
SavingsPostingInterestPeriodType interestPostingPeriodType = null;
|
||||||
final Integer interestPostingPeriodTypeValue = command.integerValueOfParameterNamed(interestPostingPeriodTypeParamName);
|
final Integer interestPostingPeriodTypeValue = command.integerValueOfParameterNamed(interestPostingPeriodTypeParamName);
|
||||||
@@ -257,6 +280,9 @@ public class DepositAccountAssembler {
|
|||||||
} else {
|
} else {
|
||||||
interestPostingPeriodType = product.interestPostingPeriodType();
|
interestPostingPeriodType = product.interestPostingPeriodType();
|
||||||
}
|
}
|
||||||
|
if (isSpecialFixedDeposit) {
|
||||||
|
interestPostingPeriodType = SavingsPostingInterestPeriodType.MONTHLY;
|
||||||
|
}
|
||||||
|
|
||||||
SavingsInterestCalculationType interestCalculationType = null;
|
SavingsInterestCalculationType interestCalculationType = null;
|
||||||
final Integer interestCalculationTypeValue = command.integerValueOfParameterNamed(interestCalculationTypeParamName);
|
final Integer interestCalculationTypeValue = command.integerValueOfParameterNamed(interestCalculationTypeParamName);
|
||||||
@@ -322,6 +348,28 @@ public class DepositAccountAssembler {
|
|||||||
accountChart = DepositAccountInterestRateChart.from(productChart);
|
accountChart = DepositAccountInterestRateChart.from(productChart);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new FD priced from its product's own chart never carries a senior-citizen premium -- that
|
||||||
|
// concept exists only on the CBS Base Interest Rate master. So whenever staff hasn't picked a
|
||||||
|
// specific chart, price the account from that master's currently active version instead (the same
|
||||||
|
// one the Base Interest Rates screen shows and renewals already use), so a senior-citizen FD shows
|
||||||
|
// its premium from day one rather than only after its first renewal. RD is unaffected: this only
|
||||||
|
// runs when depositAccountType.isFixedDeposit(), and it's a no-op wherever no active base-rate
|
||||||
|
// version exists for the product's FD type, in which case the product's own chart above stands.
|
||||||
|
if (depositAccountType.isFixedDeposit() && accountChart != null && !command.parameterExists(chartIdParamName)
|
||||||
|
&& product instanceof FixedDepositProduct) {
|
||||||
|
final String fdType = ((FixedDepositProduct) product).getFdType();
|
||||||
|
if (fdType != null) {
|
||||||
|
final FdInterestRateEffectiveData activeBaseRates = this.fdBaseInterestRateReadPlatformService
|
||||||
|
.retrieveActiveInterestRatesByType(fdType).stream().findFirst().orElse(null);
|
||||||
|
if (activeBaseRates != null && activeBaseRates.getSlabs() != null && !activeBaseRates.getSlabs().isEmpty()) {
|
||||||
|
final InterestRateChartFields baseChartFields = DepositAccountDomainServiceJpa.baseChartFieldsOf(activeBaseRates);
|
||||||
|
final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs = DepositAccountDomainServiceJpa
|
||||||
|
.baseChartSlabsOf(activeBaseRates, product.currency().getCode());
|
||||||
|
accountChart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
boolean withHoldTax = product.withHoldTax();
|
boolean withHoldTax = product.withHoldTax();
|
||||||
if (command.parameterExists(withHoldTaxParamName)) {
|
if (command.parameterExists(withHoldTaxParamName)) {
|
||||||
withHoldTax = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName);
|
withHoldTax = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName);
|
||||||
@@ -338,7 +386,7 @@ public class DepositAccountAssembler {
|
|||||||
if (depositAccountType.isFixedDeposit()) {
|
if (depositAccountType.isFixedDeposit()) {
|
||||||
final DepositProductTermAndPreClosure prodTermAndPreClosure = ((FixedDepositProduct) product).depositProductTermAndPreClosure();
|
final DepositProductTermAndPreClosure prodTermAndPreClosure = ((FixedDepositProduct) product).depositProductTermAndPreClosure();
|
||||||
final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command,
|
final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command,
|
||||||
prodTermAndPreClosure);
|
prodTermAndPreClosure, isSpecialFixedDeposit);
|
||||||
|
|
||||||
FixedDepositAccount fdAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product, fieldOfficer,
|
FixedDepositAccount fdAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product, fieldOfficer,
|
||||||
accountNo, externalIdFactory.create(externalId), accountType, submittedOnDate, submittedBy, interestRate,
|
accountNo, externalIdFactory.create(externalId), accountType, submittedOnDate, submittedBy, interestRate,
|
||||||
@@ -352,7 +400,7 @@ public class DepositAccountAssembler {
|
|||||||
final DepositProductTermAndPreClosure prodTermAndPreClosure = ((RecurringDepositProduct) product)
|
final DepositProductTermAndPreClosure prodTermAndPreClosure = ((RecurringDepositProduct) product)
|
||||||
.depositProductTermAndPreClosure();
|
.depositProductTermAndPreClosure();
|
||||||
final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command,
|
final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command,
|
||||||
prodTermAndPreClosure);
|
prodTermAndPreClosure, false);
|
||||||
|
|
||||||
final DepositProductRecurringDetail prodRecurringDetail = ((RecurringDepositProduct) product).depositRecurringDetail();
|
final DepositProductRecurringDetail prodRecurringDetail = ((RecurringDepositProduct) product).depositRecurringDetail();
|
||||||
final DepositAccountRecurringDetail accountRecurringDetail = this.assembleAccountRecurringDetail(command,
|
final DepositAccountRecurringDetail accountRecurringDetail = this.assembleAccountRecurringDetail(command,
|
||||||
@@ -392,7 +440,7 @@ public class DepositAccountAssembler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DepositAccountTermAndPreClosure assembleAccountTermAndPreClosure(final JsonCommand command,
|
public DepositAccountTermAndPreClosure assembleAccountTermAndPreClosure(final JsonCommand command,
|
||||||
final DepositProductTermAndPreClosure productTermAndPreclosure) {
|
final DepositProductTermAndPreClosure productTermAndPreclosure, final boolean forceMonthlyPayoutToLinkedSavings) {
|
||||||
final DepositPreClosureDetail productPreClosure = (productTermAndPreclosure == null) ? null
|
final DepositPreClosureDetail productPreClosure = (productTermAndPreclosure == null) ? null
|
||||||
: productTermAndPreclosure.depositPreClosureDetail();
|
: productTermAndPreclosure.depositPreClosureDetail();
|
||||||
final DepositTermDetail productTerm = (productTermAndPreclosure == null) ? null : productTermAndPreclosure.depositTermDetail();
|
final DepositTermDetail productTerm = (productTermAndPreclosure == null) ? null : productTermAndPreclosure.depositTermDetail();
|
||||||
@@ -407,17 +455,34 @@ public class DepositAccountAssembler {
|
|||||||
final SavingsPeriodFrequencyType depositPeriodFrequency = SavingsPeriodFrequencyType.fromInt(depositPeriodFrequencyId);
|
final SavingsPeriodFrequencyType depositPeriodFrequency = SavingsPeriodFrequencyType.fromInt(depositPeriodFrequencyId);
|
||||||
final SavingsAccount account = null;
|
final SavingsAccount account = null;
|
||||||
final LocalDate expectedFirstDepositOnDate = command.localDateValueOfParameterNamed(expectedFirstDepositOnDateParamName);
|
final LocalDate expectedFirstDepositOnDate = command.localDateValueOfParameterNamed(expectedFirstDepositOnDateParamName);
|
||||||
final Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName);
|
Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName);
|
||||||
|
|
||||||
// calculate maturity amount
|
// calculate maturity amount
|
||||||
final BigDecimal maturityAmount = null;// calculated and updated in
|
final BigDecimal maturityAmount = null;// calculated and updated in
|
||||||
// account
|
// account
|
||||||
final LocalDate maturityDate = null;// calculated and updated in account
|
final LocalDate maturityDate = null;// calculated and updated in account
|
||||||
final Integer accountOnClosureTypeId = command.integerValueOfParameterNamed(maturityInstructionIdParamName);
|
final Integer accountOnClosureTypeId = command.integerValueOfParameterNamed(maturityInstructionIdParamName);
|
||||||
final DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null
|
DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null
|
||||||
? DepositAccountOnClosureType.fromInt(accountOnClosureTypeId)
|
? DepositAccountOnClosureType.fromInt(accountOnClosureTypeId)
|
||||||
: null;
|
: null;
|
||||||
final Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName);
|
Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName);
|
||||||
|
|
||||||
|
if (forceMonthlyPayoutToLinkedSavings) {
|
||||||
|
final Long linkedSavingsAccountId = command.longValueOfParameterNamed(linkedAccountParamName);
|
||||||
|
if (linkedSavingsAccountId == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.special.requires.linked.savings.account",
|
||||||
|
"FD Special (Monthly Interest Payout) requires a linked savings account to be selected.");
|
||||||
|
}
|
||||||
|
// Non-cumulative payout FD: every month's interest leaves for the linked savings account
|
||||||
|
// (TransferInterestToSavingsTasklet) instead of compounding, and maturity re-uses the existing
|
||||||
|
// "Transfer to Savings" maturity-instruction path (see
|
||||||
|
// DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails) to move the
|
||||||
|
// principal out and close the account automatically, with no staff action needed.
|
||||||
|
trasferInterest = true;
|
||||||
|
transferToSavingsId = linkedSavingsAccountId;
|
||||||
|
accountOnClosureType = DepositAccountOnClosureType.TRANSFER_TO_SAVINGS;
|
||||||
|
}
|
||||||
|
|
||||||
return DepositAccountTermAndPreClosure.createNew(updatedProductPreClosure, updatedProductTerm, account, depositAmount,
|
return DepositAccountTermAndPreClosure.createNew(updatedProductPreClosure, updatedProductTerm, account, depositAmount,
|
||||||
maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, expectedFirstDepositOnDate, accountOnClosureType,
|
maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, expectedFirstDepositOnDate, accountOnClosureType,
|
||||||
trasferInterest, transferToSavingsId);
|
trasferInterest, transferToSavingsId);
|
||||||
|
|||||||
+11
@@ -35,6 +35,14 @@ public interface DepositAccountDomainService {
|
|||||||
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
||||||
BigDecimal transactionAmount, PaymentDetail paymentDetail);
|
BigDecimal transactionAmount, PaymentDetail paymentDetail);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but lets the caller declare the deposit as an internal account-to-account movement rather
|
||||||
|
* than cash taken over the counter. Used by reinvestment on maturity, where the funds come straight
|
||||||
|
* from the matured deposit and must post against the transfer-suspense GL instead of cash/vault.
|
||||||
|
*/
|
||||||
|
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
||||||
|
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer);
|
||||||
|
|
||||||
SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
|
||||||
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction);
|
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction);
|
||||||
|
|
||||||
@@ -56,4 +64,7 @@ public interface DepositAccountDomainService {
|
|||||||
|
|
||||||
Long handleRDAccountPreMatureClosure(RecurringDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
|
Long handleRDAccountPreMatureClosure(RecurringDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
|
||||||
Map<String, Object> changes);
|
Map<String, Object> changes);
|
||||||
|
|
||||||
|
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
|
||||||
|
Map<String, Object> changes);
|
||||||
}
|
}
|
||||||
|
|||||||
+629
-24
@@ -19,6 +19,7 @@
|
|||||||
package org.apache.fineract.portfolio.savings.domain;
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ import java.math.BigDecimal;
|
|||||||
import java.math.MathContext;
|
import java.math.MathContext;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -55,12 +59,18 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository;
|
|||||||
import org.apache.fineract.portfolio.calendar.domain.CalendarType;
|
import org.apache.fineract.portfolio.calendar.domain.CalendarType;
|
||||||
import org.apache.fineract.portfolio.calendar.service.CalendarUtils;
|
import org.apache.fineract.portfolio.calendar.service.CalendarUtils;
|
||||||
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
|
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
|
||||||
|
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
|
||||||
|
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||||
|
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||||
|
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
|
||||||
|
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -80,13 +90,28 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository;
|
private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository;
|
||||||
private final CalendarInstanceRepository calendarInstanceRepository;
|
private final CalendarInstanceRepository calendarInstanceRepository;
|
||||||
private final ExternalIdFactory externalIdFactory;
|
private final ExternalIdFactory externalIdFactory;
|
||||||
|
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
|
||||||
|
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
|
||||||
|
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
|
public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
|
||||||
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
|
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
|
||||||
final boolean applyWithdrawFee, final boolean isRegularTransaction) {
|
final boolean applyWithdrawFee, final boolean isRegularTransaction) {
|
||||||
boolean isAccountTransfer = false;
|
final boolean isAccountTransfer = false;
|
||||||
|
return handleWithdrawal(account, fmt, transactionDate, transactionAmount, paymentDetail, applyWithdrawFee, isRegularTransaction,
|
||||||
|
isAccountTransfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but lets the caller declare the withdrawal as an internal account-to-account movement
|
||||||
|
* rather than cash paid out over the counter. Used by reinvestment on maturity, where the balance
|
||||||
|
* moves straight into the new deposit and must post against the transfer-suspense GL, not cash/vault.
|
||||||
|
*/
|
||||||
|
private SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
|
||||||
|
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
|
||||||
|
final boolean applyWithdrawFee, final boolean isRegularTransaction, final boolean isAccountTransfer) {
|
||||||
boolean isInterestTransfer = false;
|
boolean isInterestTransfer = false;
|
||||||
boolean isWithdrawBalance = false;
|
boolean isWithdrawBalance = false;
|
||||||
final boolean backdatedTxnsAllowedTill = false;
|
final boolean backdatedTxnsAllowedTill = false;
|
||||||
@@ -100,7 +125,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
@Override
|
@Override
|
||||||
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
|
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
|
||||||
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) {
|
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) {
|
||||||
boolean isAccountTransfer = false;
|
final boolean isAccountTransfer = false;
|
||||||
|
return handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
|
||||||
|
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
|
||||||
|
final boolean isAccountTransfer) {
|
||||||
boolean isRegularTransaction = false;
|
boolean isRegularTransaction = false;
|
||||||
final boolean backdatedTxnsAllowedTill = false;
|
final boolean backdatedTxnsAllowedTill = false;
|
||||||
return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail,
|
return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail,
|
||||||
@@ -200,16 +233,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
||||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
if (onClosureType.isReinvest()) {
|
if (onClosureType.isReinvest()) {
|
||||||
|
// Captured before the withdrawal below empties the account, so the renewal history can
|
||||||
|
// record what this term actually matured at.
|
||||||
|
final BigDecimal maturedBalance = account.getAccountBalance();
|
||||||
ExternalId externalId = this.externalIdFactory.create();
|
ExternalId externalId = this.externalIdFactory.create();
|
||||||
FixedDepositAccount reinvestedDeposit = account.reInvest(account.getAccountBalance(), externalId);
|
FixedDepositAccount reinvestedDeposit = account.reInvest(maturedBalance, externalId);
|
||||||
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
|
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
|
||||||
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
||||||
autoGenerateAccountNumber(reinvestedDeposit);
|
autoGenerateAccountNumber(reinvestedDeposit);
|
||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
// Renewal is an internal movement into the new deposit, not cash paid out -- post it
|
||||||
paymentDetail, false, isRegularTransaction);
|
// against the transfer-suspense GL rather than crediting cash/vault.
|
||||||
|
final boolean isInternalTransferLeg = true;
|
||||||
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, maturedBalance, paymentDetail,
|
||||||
|
false, isRegularTransaction, isInternalTransferLeg);
|
||||||
savingsTransactionId = withdrawal.getId();
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
// Reached when a user closes the account with a re-invest instruction, rather than the
|
||||||
|
// maturity job acting on a standing instruction.
|
||||||
|
recordRenewal(account, reinvestedDeposit, maturedBalance, maturedBalance, closedDate,
|
||||||
|
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
|
||||||
} else if (onClosureType.isTransferToSavings()) {
|
} else if (onClosureType.isTransferToSavings()) {
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||||
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||||
@@ -260,13 +303,58 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
account.setClosedOnDate(closedDate);
|
account.setClosedOnDate(closedDate);
|
||||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
if (onClosureType.isReinvest()) {
|
if (onClosureType.isReinvestPrincipalAndInterest()) {
|
||||||
BigDecimal reInvestAmount;
|
// Renews on the SAME account, so the customer keeps one account number however many times
|
||||||
if (onClosureType.isReinvestPrincipal()) {
|
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
|
||||||
reInvestAmount = account.getDepositAmount();
|
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
|
||||||
} else {
|
// rollover itself -- which also removes the transfer-suspense round trip the old
|
||||||
reInvestAmount = account.getAccountBalance();
|
// close-and-reopen flow needed.
|
||||||
}
|
final BigDecimal closingPrincipal = account.getDepositAmount();
|
||||||
|
final BigDecimal maturedBalance = account.getAccountBalance();
|
||||||
|
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
|
||||||
|
: maturedBalance.subtract(closingPrincipal);
|
||||||
|
|
||||||
|
// The active CBS base interest-rate version for this FD type: the same rate card the Base
|
||||||
|
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
|
||||||
|
// new term's slabs and its senior-citizen premium, so screen and account agree.
|
||||||
|
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
|
||||||
|
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
|
||||||
|
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Captured before renewInPlace overwrites the account's chart, so the closing term's full
|
||||||
|
// rate-by-tenure chart (including its senior-citizen premiums) can be archived rather than lost
|
||||||
|
// -- renewInPlace mutates the same chart row every time, it does not create a new one.
|
||||||
|
final String closingChartName = account.currentChartName();
|
||||||
|
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
|
||||||
|
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
|
||||||
|
// term's -- needed only if this is the closing term's first-ever history row (see
|
||||||
|
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
|
||||||
|
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
|
||||||
|
final Integer closingTenure = account.depositPeriod();
|
||||||
|
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
|
||||||
|
|
||||||
|
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
|
||||||
|
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
|
||||||
|
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
this.savingsAccountRepository.save(account);
|
||||||
|
|
||||||
|
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
|
||||||
|
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
|
||||||
|
closingTenure, closingTenureFrequencyEnum);
|
||||||
|
changes.put("renewedInPlace", Boolean.TRUE);
|
||||||
|
changes.put("renewalCycleNo", account.renewalCycleNo());
|
||||||
|
|
||||||
|
} else if (onClosureType.isReinvest()) {
|
||||||
|
// "Re-invest principal only" still closes and reopens, because only the principal carries
|
||||||
|
// forward and the interest has to leave the deposit -- which needs a payout destination that
|
||||||
|
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
|
||||||
|
final BigDecimal maturedBalance = account.getAccountBalance();
|
||||||
|
final BigDecimal reInvestAmount = account.getDepositAmount();
|
||||||
ExternalId externalId = this.externalIdFactory.create();
|
ExternalId externalId = this.externalIdFactory.create();
|
||||||
|
|
||||||
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
|
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
|
||||||
@@ -276,18 +364,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
|
|
||||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
||||||
autoGenerateAccountNumber(reinvestedDeposit);
|
autoGenerateAccountNumber(reinvestedDeposit);
|
||||||
|
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
|
||||||
|
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
|
||||||
|
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
|
||||||
|
final boolean isInternalTransferLeg = true;
|
||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
|
||||||
false, isRegularTransaction);
|
false, isRegularTransaction, isInternalTransferLeg);
|
||||||
savingsTransactionId = withdrawal.getId();
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
|
||||||
|
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
|
||||||
|
// postJournalEntries() call at the end of this method posts it a second time.
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
|
||||||
if (onClosureType.isReinvestPrincipalAndInterest()) {
|
|
||||||
account.updateClosedStatus();
|
|
||||||
account.updateOnAccountClosureStatus(onClosureType);
|
|
||||||
}
|
|
||||||
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
||||||
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
|
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
|
||||||
this.savingsAccountRepository.save(reinvestedDeposit);
|
this.savingsAccountRepository.save(reinvestedDeposit);
|
||||||
|
|
||||||
|
// Reached from the maturity job processing the account's standing maturity instruction.
|
||||||
|
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
|
||||||
|
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
|
||||||
|
|
||||||
} else if (onClosureType.isTransferToSavings()) {
|
} else if (onClosureType.isTransferToSavings()) {
|
||||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
@@ -304,6 +400,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||||
paymentDetail, false, isRegularTransaction);
|
paymentDetail, false, isRegularTransaction);
|
||||||
savingsTransactionId = withdrawal.getId();
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if(!processMaturityInstructionOnly)
|
// if(!processMaturityInstructionOnly)
|
||||||
@@ -429,6 +526,274 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the closing term's full rate-by-tenure chart -- each slab together with its senior-citizen premium --
|
||||||
|
* against its {@link FixedDepositRenewalHistory} row.
|
||||||
|
* <p>
|
||||||
|
* Slabs missing a rate or a from-period are skipped -- they cannot describe a tenure band. Writes nothing when the
|
||||||
|
* closing term had no chart (e.g. a plain flat-rate deposit), rather than persisting an empty, meaningless archive.
|
||||||
|
*/
|
||||||
|
private void archiveChartSlabs(final Long fdRenewalHistoryId, final String chartName,
|
||||||
|
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots, final String currencyCode) {
|
||||||
|
if (snapshots == null || snapshots.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final DepositAccountInterestRateChart.SlabSnapshot snapshot : snapshots) {
|
||||||
|
final InterestRateChartSlabFields fields = snapshot.getFields();
|
||||||
|
if (fields.annualInterestRate() == null || fields.fromPeriod() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.fixedDepositRenewalChartSlabHistoryRepository
|
||||||
|
.save(FixedDepositRenewalChartSlabHistory.create(fdRenewalHistoryId, chartName, fields.description(),
|
||||||
|
fields.periodType(), fields.fromPeriod(), fields.toPeriod(), fields.getAmountRangeFrom(),
|
||||||
|
fields.getAmountRangeTo(), fields.annualInterestRate(), currencyCode, snapshot.getSeniorCitizenRate()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart identity for the account snapshot, so it names the base rate version it was priced from. Package-private
|
||||||
|
* and static (no instance state used) so {@link DepositAccountAssembler} can reuse the exact same mapping when a
|
||||||
|
* new FD account is first priced from this master, instead of duplicating the logic.
|
||||||
|
*/
|
||||||
|
static InterestRateChartFields baseChartFieldsOf(final FdInterestRateEffectiveData baseRates) {
|
||||||
|
// name and from_date are NOT NULL on the embeddable. A base version missing either would abort the
|
||||||
|
// whole renewal (or account creation) with a constraint violation, so treat it as "no identity to
|
||||||
|
// apply" instead: refreshFromSlabSnapshots keeps the chart's existing fields when handed null, and
|
||||||
|
// the slabs -- the part that actually prices the deposit -- still get applied.
|
||||||
|
if (baseRates == null || baseRates.getVersionLabel() == null || baseRates.getEffectiveFromDate() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final boolean isPrimaryGroupingByAmount = false; // the base rate card bands by tenure, never by amount
|
||||||
|
return InterestRateChartFields.createNew(baseRates.getVersionLabel(), baseRates.getRemarks(), baseRates.getEffectiveFromDate(),
|
||||||
|
baseRates.getEffectiveToDate(), isPrimaryGroupingByAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates the base rate card's slabs into the shape an account chart stores, each paired with its senior-citizen
|
||||||
|
* premium so that figure is persisted on the account (and later archived) rather than only ever being looked up live.
|
||||||
|
* <p>
|
||||||
|
* The base master bands purely by tenure, so amount ranges are left unset -- which
|
||||||
|
* {@code InterestRateChartSlabFields.isAmountBetween} then treats as "any amount", exactly the intent. Slabs with no
|
||||||
|
* from-period are dropped, since a slab that does not say where it starts cannot be matched against a tenure.
|
||||||
|
*/
|
||||||
|
static Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabsOf(final FdInterestRateEffectiveData baseRates,
|
||||||
|
final String currencyCode) {
|
||||||
|
if (baseRates == null || baseRates.getSlabs() == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots = new ArrayList<>();
|
||||||
|
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
|
||||||
|
if (slab.getFromPeriod() == null || slab.getAnnualInterestRate() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final InterestRateChartSlabFields fields = InterestRateChartSlabFields.createNew(slab.getDescription(),
|
||||||
|
SavingsPeriodFrequencyType.fromInt(slab.getPeriodTypeEnum()), slab.getFromPeriod(), slab.getToPeriod(), null, null,
|
||||||
|
slab.getAnnualInterestRate(), currencyCode);
|
||||||
|
snapshots.add(new DepositAccountInterestRateChart.SlabSnapshot(fields, slab.getSeniorCitizenRate()));
|
||||||
|
}
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The senior-citizen premium from the active base rate card for a given tenure.
|
||||||
|
* <p>
|
||||||
|
* The tenure and the slab bounds are converted to a common unit before comparing, so a rate card published in years
|
||||||
|
* still matches a deposit taken for a number of months. Conversion stays inside a unit family -- days/weeks, or
|
||||||
|
* months/years -- because only those are exact; a days-based card is therefore still never matched against a
|
||||||
|
* months-based tenure, since "1 month" is not a fixed number of days and guessing would land the deposit in the
|
||||||
|
* wrong band. Null when no slab covers the tenure or the matching slab carries no premium, which leaves the
|
||||||
|
* account's existing rate untouched rather than silently dropping the depositor's premium.
|
||||||
|
*/
|
||||||
|
private BigDecimal seniorCitizenRateFor(final FdInterestRateEffectiveData baseRates, final Integer tenure,
|
||||||
|
final Integer tenurePeriodTypeEnum) {
|
||||||
|
if (baseRates == null || baseRates.getSlabs() == null || tenure == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Integer tenureInBaseUnit = inFamilyBaseUnit(tenure, tenurePeriodTypeEnum);
|
||||||
|
if (tenureInBaseUnit == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
|
||||||
|
if (!sameUnitFamily(tenurePeriodTypeEnum, slab.getPeriodTypeEnum())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Integer slabFrom = inFamilyBaseUnit(slab.getFromPeriod(), slab.getPeriodTypeEnum());
|
||||||
|
if (slabFrom == null || tenureInBaseUnit < slabFrom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (slab.getToPeriod() != null) {
|
||||||
|
final Integer slabTo = inFamilyBaseUnit(slab.getToPeriod(), slab.getPeriodTypeEnum());
|
||||||
|
if (slabTo == null || tenureInBaseUnit > slabTo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final BigDecimal rate = slab.getSeniorCitizenRate();
|
||||||
|
if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
return rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code value} restated in the finest unit of its own family -- days for DAYS/WEEKS, months for MONTHS/YEARS -- so
|
||||||
|
* two periods written in different but related units can be compared without losing precision. Null for an unknown
|
||||||
|
* unit. Enum codes are {@link SavingsPeriodFrequencyType}: DAYS(0), WEEKS(1), MONTHS(2), YEARS(3).
|
||||||
|
*/
|
||||||
|
private Integer inFamilyBaseUnit(final Integer value, final Integer periodTypeEnum) {
|
||||||
|
if (value == null || periodTypeEnum == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (SavingsPeriodFrequencyType.fromInt(periodTypeEnum)) {
|
||||||
|
case DAYS, MONTHS -> value;
|
||||||
|
case WEEKS -> value * 7;
|
||||||
|
case YEARS -> value * 12;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two period types convert exactly into one another. Days and weeks do; months and years do. Across those
|
||||||
|
* two families they do not, because a month is not a fixed number of days -- so a days-based rate card is never
|
||||||
|
* matched against a months-based tenure rather than guessing and landing the deposit in the wrong band.
|
||||||
|
*/
|
||||||
|
private boolean sameUnitFamily(final Integer firstPeriodTypeEnum, final Integer secondPeriodTypeEnum) {
|
||||||
|
if (firstPeriodTypeEnum == null || secondPeriodTypeEnum == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isDayFamily(firstPeriodTypeEnum) == isDayFamily(secondPeriodTypeEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDayFamily(final Integer periodTypeEnum) {
|
||||||
|
final SavingsPeriodFrequencyType type = SavingsPeriodFrequencyType.fromInt(periodTypeEnum);
|
||||||
|
return type == SavingsPeriodFrequencyType.DAYS || type == SavingsPeriodFrequencyType.WEEKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a renewal that happened on the SAME account, where there is no second account row to describe the new term.
|
||||||
|
* <p>
|
||||||
|
* Called after {@link FixedDepositAccount#renewInPlace}, which has already advanced the account's cycle number,
|
||||||
|
* principal and maturity date -- so the closing term's figures have to be passed in rather than read back off the
|
||||||
|
* account, where they no longer exist. Every row for one FD shares that FD's single account id and account number;
|
||||||
|
* they are told apart by renewal_no, which is what makes this a one-to-many history of one account.
|
||||||
|
*
|
||||||
|
* @param closingChartName
|
||||||
|
* the name of the chart the closing term was priced on, captured before renewInPlace overwrote it; null if
|
||||||
|
* unavailable
|
||||||
|
* @param closingChartSlabs
|
||||||
|
* the closing term's full rate-by-tenure chart, likewise captured before it was overwritten. Archived
|
||||||
|
* against the closing term's row so its whole rate card remains queryable, not only the single effective
|
||||||
|
* rate {@link FixedDepositRenewalHistory} records.
|
||||||
|
* @param closingInterestRate
|
||||||
|
* the closing term's own {@code nominalAnnualInterestRate}, captured before renewInPlace overwrote it --
|
||||||
|
* needed only when this term has no existing row yet (see below), since the account itself no longer
|
||||||
|
* carries it once renewInPlace has run.
|
||||||
|
* @param closingTenure
|
||||||
|
* the closing term's own deposit period, captured the same way and needed for the same reason.
|
||||||
|
* @param closingTenureFrequencyEnum
|
||||||
|
* the closing term's own deposit period frequency, captured the same way and needed for the same reason.
|
||||||
|
*/
|
||||||
|
private void recordInPlaceRenewal(final FixedDepositAccount account, final BigDecimal closingPrincipal,
|
||||||
|
final BigDecimal closingInterestEarned, final BigDecimal maturedBalance, final BigDecimal renewedAmount,
|
||||||
|
final LocalDate renewedOn, final String renewalType, final AppUser user, final String closingChartName,
|
||||||
|
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs, final BigDecimal closingInterestRate,
|
||||||
|
final Integer closingTenure, final Integer closingTenureFrequencyEnum) {
|
||||||
|
|
||||||
|
// The CHAIN's number, not this account's own. They differ for an FD that was renewed under the old
|
||||||
|
// close-and-reopen model before renewing in place: its rows are keyed on the original account's
|
||||||
|
// number, so keying new rows on this account's number would split the history in two and re-create
|
||||||
|
// a duplicate row for the term that already has one. fdAccountNumber() falls back to this account's
|
||||||
|
// own number when there is no chain, which is what the read side resolves to as well.
|
||||||
|
final String fdAccountNumber = account.fdAccountNumber();
|
||||||
|
final int newCycleNo = account.renewalCycleNo();
|
||||||
|
final Long createdById = user == null ? null : user.getId();
|
||||||
|
|
||||||
|
final List<FixedDepositRenewalHistory> existingRows = this.fixedDepositRenewalHistoryRepository
|
||||||
|
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber);
|
||||||
|
FixedDepositRenewalHistory closingRow = existingRows.stream()
|
||||||
|
.filter(row -> Integer.valueOf(newCycleNo - 1).equals(row.getRenewalNo())).findFirst().orElse(null);
|
||||||
|
|
||||||
|
// The term that just closed has no row when this FD renews for the first time since the history
|
||||||
|
// table existed. Written now, from the figures captured before the rollover, so the history
|
||||||
|
// always begins with the term the customer originally opened -- rate and tenure included, rather
|
||||||
|
// than left blank, since the caller captured them before renewInPlace overwrote the account with
|
||||||
|
// the new term's own values.
|
||||||
|
if (closingRow == null) {
|
||||||
|
closingRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
|
||||||
|
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
|
||||||
|
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
|
||||||
|
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
|
||||||
|
}
|
||||||
|
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
|
||||||
|
closingRow.markMatured();
|
||||||
|
this.fixedDepositRenewalHistoryRepository.save(closingRow);
|
||||||
|
|
||||||
|
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
|
||||||
|
|
||||||
|
// The new term's own interest/maturity stay unset until it closes in turn.
|
||||||
|
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
|
||||||
|
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
|
||||||
|
account.getNominalAnnualInterestRate(), renewedOn, account.maturityDate(), account.depositPeriod(),
|
||||||
|
account.depositPeriodFrequency(), renewalType, renewedOn, createdById));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the renewal that started {@code newTerm}, and closes off the previous term's history row.
|
||||||
|
* <p>
|
||||||
|
* Each term of an FD runs as its own deposit instance so it keeps an immutable ledger, which means the rollover event
|
||||||
|
* itself -- what the previous term earned, what carried forward, at what rate, automatically or by whom -- is not
|
||||||
|
* recoverable from the account rows alone. This writes it down explicitly.
|
||||||
|
* <p>
|
||||||
|
* The chain's first term is backfilled here too when it has no row yet: an FD opened before this table existed only
|
||||||
|
* gets discovered at its first renewal, and without its opening row the history would start at "Renewal 1" with no
|
||||||
|
* original.
|
||||||
|
*/
|
||||||
|
private void recordRenewal(final FixedDepositAccount previousTerm, final FixedDepositAccount newTerm,
|
||||||
|
final BigDecimal maturityAmount, final BigDecimal renewedAmount, final LocalDate renewedOn, final String renewalType,
|
||||||
|
final AppUser user) {
|
||||||
|
|
||||||
|
final String fdAccountNumber = previousTerm.fdAccountNumber();
|
||||||
|
final Long originalAccountId = this.fixedDepositRenewalHistoryRepository
|
||||||
|
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber).stream().findFirst()
|
||||||
|
.map(FixedDepositRenewalHistory::getOriginalSavingsAccountId)
|
||||||
|
.orElseGet(() -> previousTerm.reinvestedFromSavingsId() == null ? previousTerm.getId() : null);
|
||||||
|
|
||||||
|
FixedDepositRenewalHistory previousRow = this.fixedDepositRenewalHistoryRepository
|
||||||
|
.findBySavingsAccountIdOrderByRenewalNoDesc(previousTerm.getId()).stream().findFirst().orElse(null);
|
||||||
|
|
||||||
|
// Backfill the opening term when the chain started before this table existed, so the history
|
||||||
|
// always begins with the account the customer actually opened.
|
||||||
|
if (previousRow == null && originalAccountId != null && originalAccountId.equals(previousTerm.getId())) {
|
||||||
|
previousRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
|
||||||
|
originalAccountId, previousTerm.getId(), null, null, previousTerm.renewalCycleNo(),
|
||||||
|
previousTerm.getDepositAmount(), null, previousTerm.maturityAmount(), previousTerm.getDepositAmount(),
|
||||||
|
previousTerm.getNominalAnnualInterestRate(), previousTerm.getActivationDate(), previousTerm.maturityDate(),
|
||||||
|
previousTerm.depositPeriod(), previousTerm.depositPeriodFrequency(), renewalType, previousTerm.getActivationDate(),
|
||||||
|
user == null ? null : user.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the closing term actually earned. maturityAmount is passed in rather than read off the
|
||||||
|
// account because the caller has already withdrawn the balance out of it by this point, which
|
||||||
|
// would make it read as zero. Recorded on the CLOSING term's own row -- every row states its own
|
||||||
|
// term's outcome, so a reader never has to look at the next row to find out how one term did.
|
||||||
|
final BigDecimal previousPrincipal = previousTerm.getDepositAmount();
|
||||||
|
final BigDecimal interestEarned = maturityAmount == null || previousPrincipal == null ? null
|
||||||
|
: maturityAmount.subtract(previousPrincipal);
|
||||||
|
|
||||||
|
if (previousRow != null) {
|
||||||
|
previousRow.recordOutcome(interestEarned, maturityAmount);
|
||||||
|
previousRow.markMatured();
|
||||||
|
this.fixedDepositRenewalHistoryRepository.save(previousRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new term's own interest/maturity stay unset until it closes in turn.
|
||||||
|
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber,
|
||||||
|
originalAccountId == null ? previousTerm.getId() : originalAccountId, newTerm.getId(), previousTerm.getId(),
|
||||||
|
previousRow == null ? null : previousRow.getId(), newTerm.renewalCycleNo(), renewedAmount, null, null, renewedAmount,
|
||||||
|
newTerm.getNominalAnnualInterestRate(), renewedOn, newTerm.maturityDate(), newTerm.depositPeriod(),
|
||||||
|
newTerm.depositPeriodFrequency(), renewalType, renewedOn, user == null ? null : user.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public Long handleFDAccountPreMatureClosure(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
|
public Long handleFDAccountPreMatureClosure(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
|
||||||
@@ -474,6 +839,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||||
paymentDetail, false, isRegularTransaction);
|
paymentDetail, false, isRegularTransaction);
|
||||||
savingsTransactionId = withdrawal.getId();
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
|
||||||
|
// accounted for or the postJournalEntries() call below posts it a second time.
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
account.prematureClosure(user, command, changes);
|
account.prematureClosure(user, command, changes);
|
||||||
@@ -484,6 +852,238 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
return savingsTransactionId;
|
return savingsTransactionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
|
||||||
|
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
|
||||||
|
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
|
||||||
|
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
|
||||||
|
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
|
||||||
|
* the existing manual Close command still uses today.
|
||||||
|
* <p>
|
||||||
|
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
|
||||||
|
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
|
||||||
|
* before.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public Long handleFDAccountPayment(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
|
||||||
|
final JsonCommand command, final Map<String, Object> changes) {
|
||||||
|
|
||||||
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||||
|
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||||
|
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||||
|
|
||||||
|
boolean isRegularTransaction = false;
|
||||||
|
boolean isAccountTransfer = false;
|
||||||
|
final boolean isPreMatureClosure = false;
|
||||||
|
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||||
|
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
||||||
|
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||||
|
|
||||||
|
final MathContext mc = MathContext.DECIMAL64;
|
||||||
|
final Locale locale = command.extractLocale();
|
||||||
|
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||||
|
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
|
||||||
|
Long savingsTransactionId = null;
|
||||||
|
|
||||||
|
// account.isMatured() only reflects whether the overnight scheduler has already flipped this
|
||||||
|
// account's status -- not whether the customer actually showed up before or after the maturity
|
||||||
|
// date. A customer walking in ON the maturity date (or any day after) is not premature just
|
||||||
|
// because that job hasn't run yet today, so the real test is the date, not the status flag.
|
||||||
|
if (!account.isMatured()) {
|
||||||
|
if (!DateUtils.isBefore(closedDate, account.maturityDate())) {
|
||||||
|
// Genuinely due, the scheduler just hasn't caught up yet -- do exactly what it would:
|
||||||
|
// flip to Matured and post final interest at the full contracted rate, no penalty.
|
||||||
|
account.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
|
} else {
|
||||||
|
// The three closing closure types re-check this themselves inside prematureClosure()
|
||||||
|
// below, but the two reinvest branches never call it (they keep the account open on the
|
||||||
|
// same term), so it is enforced once, up front, for every genuinely-premature Payment --
|
||||||
|
// a locked-in FD (e.g. a Tax Saver with its statutory lock-in) cannot be touched early via
|
||||||
|
// this action either.
|
||||||
|
if (account.isLockedOnDate(closedDate)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.lockin.period.not.elapsed",
|
||||||
|
"This account is still within its lock-in period and cannot be processed before it elapses.", closedDate);
|
||||||
|
}
|
||||||
|
// Same penalty-aware interest calculation prematureClosure() already relies on, so a
|
||||||
|
// genuinely early Payment settles interest exactly as a Premature Close would.
|
||||||
|
account.postPreMaturityInterest(closedDate, true, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same as the closure and maturity paths do: the legacy reinvest branch's reInvest() reads
|
||||||
|
// getClosedOnDate() to pick the new account's chart and submitted date, so it has to be set
|
||||||
|
// before branching. renewInPlace() clears it again for the branches that keep the account open.
|
||||||
|
account.setClosedOnDate(closedDate);
|
||||||
|
|
||||||
|
// Re-read after the block above: updateMaturityStatus() may just have flipped it, so this now
|
||||||
|
// correctly decides close() (needs Matured) vs prematureClosure() (needs Active) either way.
|
||||||
|
final boolean alreadyMatured = account.isMatured();
|
||||||
|
|
||||||
|
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
||||||
|
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||||
|
|
||||||
|
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
|
||||||
|
// These two branches keep the account open, so they never reach close()/prematureClosure() and
|
||||||
|
// would otherwise accept any date at all -- including one behind already-posted interest, which
|
||||||
|
// the renewal would then turn into a pivot that reverses and re-posts it.
|
||||||
|
account.validateRenewalDate(closedDate);
|
||||||
|
|
||||||
|
final BigDecimal closingPrincipal = account.getDepositAmount();
|
||||||
|
final BigDecimal maturedBalance = account.getAccountBalance();
|
||||||
|
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
|
||||||
|
: maturedBalance.subtract(closingPrincipal);
|
||||||
|
|
||||||
|
final BigDecimal renewedAmount;
|
||||||
|
if (onClosureType.isPartialReinvest()) {
|
||||||
|
renewedAmount = command.bigDecimalValueOfParameterNamed(reinvestAmountParamName);
|
||||||
|
if (renewedAmount == null || renewedAmount.compareTo(BigDecimal.ZERO) <= 0
|
||||||
|
|| renewedAmount.compareTo(maturedBalance) >= 0) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
|
||||||
|
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
renewedAmount = maturedBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
|
||||||
|
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
|
||||||
|
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final String closingChartName = account.currentChartName();
|
||||||
|
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
|
||||||
|
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
|
||||||
|
// term's -- needed only if this is the closing term's first-ever history row (see
|
||||||
|
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
|
||||||
|
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
|
||||||
|
final Integer closingTenure = account.depositPeriod();
|
||||||
|
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
|
||||||
|
|
||||||
|
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
|
||||||
|
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
|
||||||
|
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
this.savingsAccountRepository.save(account);
|
||||||
|
|
||||||
|
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
|
||||||
|
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user, closingChartName, closingChartSlabs, closingInterestRate,
|
||||||
|
closingTenure, closingTenureFrequencyEnum);
|
||||||
|
changes.put("renewedInPlace", Boolean.TRUE);
|
||||||
|
changes.put("renewalCycleNo", account.renewalCycleNo());
|
||||||
|
|
||||||
|
if (onClosureType.isPartialReinvest()) {
|
||||||
|
// The kept portion never left the account -- renewInPlace() just relabels it as the new
|
||||||
|
// term's principal -- so only the remainder is an actual money movement, posted the same
|
||||||
|
// way the Transfer to Savings branch below posts its full-balance transfer.
|
||||||
|
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
|
||||||
|
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||||
|
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||||
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
|
final boolean isExceptionForBalanceCheck = false;
|
||||||
|
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
|
||||||
|
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
|
||||||
|
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
|
||||||
|
isAccountTransfer, isExceptionForBalanceCheck);
|
||||||
|
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (onClosureType.isReinvestPrincipal()) {
|
||||||
|
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
|
||||||
|
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
|
||||||
|
// has nowhere to send.
|
||||||
|
final BigDecimal maturedBalance = account.getAccountBalance();
|
||||||
|
final BigDecimal reInvestAmount = account.getDepositAmount();
|
||||||
|
final ExternalId externalId = this.externalIdFactory.create();
|
||||||
|
|
||||||
|
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
|
||||||
|
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
|
||||||
|
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
||||||
|
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
|
|
||||||
|
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
||||||
|
autoGenerateAccountNumber(reinvestedDeposit);
|
||||||
|
final boolean isInternalTransferLeg = true;
|
||||||
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
|
||||||
|
false, isRegularTransaction, isInternalTransferLeg);
|
||||||
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
|
||||||
|
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
||||||
|
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
|
||||||
|
this.savingsAccountRepository.save(reinvestedDeposit);
|
||||||
|
|
||||||
|
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
|
||||||
|
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
|
||||||
|
|
||||||
|
// Only the principal moved into the new deposit -- the interest earned on this term is still
|
||||||
|
// sitting in this account's balance. Pay it out to the chosen savings account now, before
|
||||||
|
// closing, rather than stranding it here with nowhere left to go.
|
||||||
|
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
|
||||||
|
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||||
|
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||||
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
|
final boolean isExceptionForBalanceCheck = false;
|
||||||
|
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
|
||||||
|
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
|
||||||
|
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
|
||||||
|
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
|
||||||
|
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alreadyMatured) {
|
||||||
|
account.close(user, command, changes);
|
||||||
|
} else {
|
||||||
|
account.prematureClosure(user, command, changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (onClosureType.isTransferToSavings()) {
|
||||||
|
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||||
|
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||||
|
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||||
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
|
final boolean isExceptionForBalanceCheck = false;
|
||||||
|
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, account.getAccountBalance(),
|
||||||
|
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
|
||||||
|
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
|
||||||
|
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
|
||||||
|
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
|
||||||
|
if (alreadyMatured) {
|
||||||
|
account.close(user, command, changes);
|
||||||
|
} else {
|
||||||
|
account.prematureClosure(user, command, changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||||
|
paymentDetail, false, isRegularTransaction);
|
||||||
|
savingsTransactionId = withdrawal.getId();
|
||||||
|
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||||
|
|
||||||
|
if (alreadyMatured) {
|
||||||
|
account.close(user, command, changes);
|
||||||
|
} else {
|
||||||
|
account.prematureClosure(user, command, changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.savingsAccountRepository.save(account);
|
||||||
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||||
|
|
||||||
|
return savingsTransactionId;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public Long handleRDAccountPreMatureClosure(final RecurringDepositAccount account, final PaymentDetail paymentDetail,
|
public Long handleRDAccountPreMatureClosure(final RecurringDepositAccount account, final PaymentDetail paymentDetail,
|
||||||
@@ -555,14 +1155,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
|
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
|
||||||
List<SavingsAccountTransaction> transactions = savingsAccount.getTransactions();
|
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
|
||||||
int size = transactions.size();
|
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
|
||||||
for (int i = size - 1;; i--) {
|
// so that withdrawal must be added to existingTransactionIds here or the caller's own
|
||||||
SavingsAccountTransaction transaction = transactions.get(i);
|
// postJournalEntries() re-derives and re-posts it a second time. The previous implementation
|
||||||
if (transaction.isWithdrawal() || transaction.isWithdrawalFee()) {
|
// found it by walking the transaction list backwards from the end while the type stayed
|
||||||
|
// withdrawal/withdrawal-fee -- but @OneToMany collections only guarantee @OrderBy ordering on
|
||||||
|
// a fresh fetch, not after an in-memory append, so a same-day interest posting or any other
|
||||||
|
// ordering quirk could put the new withdrawal anywhere in the list and break the walk
|
||||||
|
// silently. Scanning every transaction and checking against the original snapshot is correct
|
||||||
|
// regardless of collection order.
|
||||||
|
for (final SavingsAccountTransaction transaction : savingsAccount.getTransactions()) {
|
||||||
|
if ((transaction.isWithdrawal() || transaction.isWithdrawalFee()) && !existingTransactionIds.contains(transaction.getId())) {
|
||||||
existingTransactionIds.add(transaction.getId());
|
existingTransactionIds.add(transaction.getId());
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+463
-21
@@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.accountdetails.domain.AccountType;
|
|||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
import org.apache.fineract.portfolio.group.domain.Group;
|
import org.apache.fineract.portfolio.group.domain.Group;
|
||||||
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
|
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
|
||||||
|
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
|
||||||
import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler;
|
import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||||
@@ -149,6 +150,18 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-forces the standing instructions that make FD Special work -- see
|
||||||
|
* {@link DepositAccountTermAndPreClosure#forceMonthlyPayoutToLinkedSavings}. Called from the modify-application
|
||||||
|
* write path after this same request's own linked-account resolution has settled, since
|
||||||
|
* {@link #modifyApplication} (via {@code accountTermAndPreClosure.update}) has no fdType awareness and would
|
||||||
|
* otherwise let a SPECIAL account's transferInterestToLinkedAccount/onAccountClosureType/transferToSavingsId
|
||||||
|
* be silently unset by that same request.
|
||||||
|
*/
|
||||||
|
public void forceSpecialFdMonthlyPayoutSettings(final Long transferToSavingsId) {
|
||||||
|
this.accountTermAndPreClosure.forceMonthlyPayoutToLinkedSavings(transferToSavingsId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate) {
|
protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate) {
|
||||||
boolean isPreMatureClosure = false;
|
boolean isPreMatureClosure = false;
|
||||||
@@ -249,7 +262,13 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
totalInterestPayable = totalInterestPayable.plus(postingPeriod.getInterestEarned());
|
totalInterestPayable = totalInterestPayable.plus(postingPeriod.getInterestEarned());
|
||||||
}
|
}
|
||||||
final Money depositAmount = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount());
|
final Money depositAmount = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount());
|
||||||
final Money maturityAmount = depositAmount.plus(totalInterestPayable);
|
// FD Special pays each period's interest out to the linked savings account every month instead of
|
||||||
|
// compounding it into this account's own balance (see DepositAccountAssembler and
|
||||||
|
// TransferInterestToSavingsTasklet) -- so unlike a cumulative FD, this account's own balance never
|
||||||
|
// actually grows to depositAmount + totalInterestPayable by maturity; it stays at depositAmount
|
||||||
|
// throughout the term. Showing the compounded projection here would be a customer-facing figure
|
||||||
|
// that never happens. SINGLE/DOUBLE FDs are unaffected -- they still get the real compounded value.
|
||||||
|
final Money maturityAmount = "SPECIAL".equalsIgnoreCase(fdType()) ? depositAmount : depositAmount.plus(totalInterestPayable);
|
||||||
|
|
||||||
this.accountTermAndPreClosure.updateMaturityDetails(maturityAmount.getAmount(), maturityDate);
|
this.accountTermAndPreClosure.updateMaturityDetails(maturityAmount.getAmount(), maturityDate);
|
||||||
}
|
}
|
||||||
@@ -276,7 +295,9 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
|
|
||||||
public LocalDate calculateMaturityDate() {
|
public LocalDate calculateMaturityDate() {
|
||||||
|
|
||||||
final LocalDate startDate = accountSubmittedOrActivationDate();
|
// The CURRENT term's start, not the account's activation date -- on a renewed FD these differ,
|
||||||
|
// and measuring the term from activation would put maturity in the past.
|
||||||
|
final LocalDate startDate = depositStartDate();
|
||||||
LocalDate maturityDate = null;
|
LocalDate maturityDate = null;
|
||||||
final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod();
|
final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod();
|
||||||
switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) {
|
switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) {
|
||||||
@@ -312,13 +333,19 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
.fromInt(this.interestCalculationDaysInYearType);
|
.fromInt(this.interestCalculationDaysInYearType);
|
||||||
List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
|
List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
|
||||||
|
|
||||||
|
// Scoped to the current term, so a renewed FD's projection covers only the term now running --
|
||||||
|
// matching what postInterest() will actually post -- rather than replaying the whole account.
|
||||||
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
|
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
|
||||||
accountSubmittedOrActivationDate(), maturityDate, postingPeriodType, financialYearBeginningMonth,
|
depositStartDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, postedAsOnTransactionDates);
|
||||||
postedAsOnTransactionDates);
|
|
||||||
|
|
||||||
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
|
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
|
||||||
|
|
||||||
Money periodStartingBalance = Money.zero(currency);
|
// On a first term the opening deposit falls inside the first posting period, so the period can
|
||||||
|
// start from zero and pick the balance up from that transaction. On a renewed term that deposit
|
||||||
|
// is BEFORE the period range and would be missed, leaving the term to earn interest on nothing,
|
||||||
|
// so the term's own principal seeds the opening balance instead.
|
||||||
|
Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount())
|
||||||
|
: Money.zero(currency);
|
||||||
|
|
||||||
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
|
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
|
||||||
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure);
|
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure);
|
||||||
@@ -349,6 +376,91 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
return allPostingPeriods;
|
return allPostingPeriods;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only period-by-period (e.g. month-wise, when posting is Monthly) projected interest schedule across
|
||||||
|
* the account's current term -- built with the exact same {@link PostingPeriod} engine as
|
||||||
|
* {@link #calculateInterestPayable}, but deliberately NOT sharing that method's body: that one also calls
|
||||||
|
* {@code this.summary.updateFromInterestPeriodSummaries(...)}, which overwrites the account's real
|
||||||
|
* totalInterestEarned/lastInterestCalculationDate -- fine when it runs once as part of an actual save, but
|
||||||
|
* corrupting if repeated on every GET request. This variant skips that call entirely, so it is safe to
|
||||||
|
* invoke as many times as a read-service likes without ever mutating the account. Used to show FD Special
|
||||||
|
* customers/staff exactly how much interest is projected to post each month across the whole term, since
|
||||||
|
* that figure otherwise only exists internally as a discarded intermediate value.
|
||||||
|
*/
|
||||||
|
public List<PostingPeriod> projectInterestSchedule(final MathContext mc, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
final Integer financialYearBeginningMonth) {
|
||||||
|
List<SavingsAccountTransaction> transactions = retreiveOrderedNonInterestPostingTransactions();
|
||||||
|
if (transactions.isEmpty()) {
|
||||||
|
// Account activated but not yet funded (funding is a separate deposit step) -- project the
|
||||||
|
// schedule as if the planned deposit amount lands on day one, the same synthetic-transaction
|
||||||
|
// approach updateMaturityDateAndAmountBeforeAccountActivation uses for the pre-activation
|
||||||
|
// maturity preview. Without this every period here would show zero interest against a real,
|
||||||
|
// still-empty transaction history, while the maturity amount (forced to depositAmount for
|
||||||
|
// SPECIAL FDs) already assumes the account gets funded -- this keeps the two consistent.
|
||||||
|
final Money transactionAmountMoney = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount());
|
||||||
|
final SavingsAccountTransaction syntheticDeposit = SavingsAccountTransaction.deposit(null, office(), null,
|
||||||
|
this.accountSubmittedOrActivationDate(), transactionAmountMoney, null);
|
||||||
|
syntheticDeposit.setRunningBalance(transactionAmountMoney);
|
||||||
|
syntheticDeposit.updateCumulativeBalanceAndDates(this.getCurrency(), interestCalculatedUpto());
|
||||||
|
transactions = new ArrayList<>();
|
||||||
|
transactions.add(syntheticDeposit);
|
||||||
|
}
|
||||||
|
final LocalDate maturityDate = maturityDate() != null ? maturityDate() : calculateMaturityDate();
|
||||||
|
// calculateInterestPayable's own "maturityDate" parameter is always fed maturityDate.minusDays(1)
|
||||||
|
// by its one real caller (see updateMaturityDateAndAmount above) -- the raw maturity date is only
|
||||||
|
// used for the end-of-day-balance reset either side of the calculation. Missing this -1 here made
|
||||||
|
// determineInterestPostingPeriods run one day too far, pushing the final period's end date (and
|
||||||
|
// therefore its amount) past the account's actual maturity date.
|
||||||
|
final LocalDate interestCalculationUpto = maturityDate.minusDays(1);
|
||||||
|
final boolean isPreMatureClosure = false;
|
||||||
|
|
||||||
|
this.resetAccountTransactionsEndOfDayBalances(transactions, maturityDate);
|
||||||
|
try {
|
||||||
|
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType
|
||||||
|
.fromInt(this.interestPostingPeriodType);
|
||||||
|
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
|
||||||
|
.fromInt(this.interestCompoundingPeriodType);
|
||||||
|
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
|
||||||
|
.fromInt(this.interestCalculationDaysInYearType);
|
||||||
|
final List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
|
||||||
|
|
||||||
|
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
|
||||||
|
depositStartDate(), interestCalculationUpto, postingPeriodType, financialYearBeginningMonth,
|
||||||
|
postedAsOnTransactionDates);
|
||||||
|
|
||||||
|
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
|
||||||
|
Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount())
|
||||||
|
: Money.zero(currency);
|
||||||
|
|
||||||
|
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType
|
||||||
|
.fromInt(this.interestCalculationType);
|
||||||
|
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, interestCalculationUpto, isPreMatureClosure);
|
||||||
|
final Collection<Long> interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId());
|
||||||
|
final boolean isInterestTransfer = false;
|
||||||
|
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||||
|
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||||
|
transactions);
|
||||||
|
|
||||||
|
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||||
|
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
|
||||||
|
final PostingPeriod postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance,
|
||||||
|
savingsAccountTransactionDetailsForPostingPeriodList, this.currency, compoundingPeriodType,
|
||||||
|
interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), interestCalculationUpto,
|
||||||
|
interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation,
|
||||||
|
isSavingsInterestPostingAtCurrentPeriodEnd, isUserPosting, financialYearBeginningMonth);
|
||||||
|
|
||||||
|
periodStartingBalance = postingPeriod.closingBalance();
|
||||||
|
allPostingPeriods.add(postingPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(),
|
||||||
|
isTransferInterestToOtherAccount());
|
||||||
|
return allPostingPeriods;
|
||||||
|
} finally {
|
||||||
|
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void prematureClosure(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
|
public void prematureClosure(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
|
||||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
||||||
@@ -564,7 +676,13 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
interestPostingTransactionDate, interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting());
|
interestPostingTransactionDate, interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting());
|
||||||
this.transactions.add(newPostingTransaction);
|
this.transactions.add(newPostingTransaction);
|
||||||
recalucateDailyBalanceDetails = true;
|
recalucateDailyBalanceDetails = true;
|
||||||
} else {
|
} else if (!interestAlreadyPaidOut(postingTransaction)) {
|
||||||
|
// Once a period's interest has actually been swept out to the linked savings account it is
|
||||||
|
// settled history: reversing and re-posting it here would rewrite interest the customer was
|
||||||
|
// already paid and leave this account's books disagreeing with the cash that left it. But a
|
||||||
|
// posting still sitting IN the account is fair game -- that is how a stale amount (e.g. one
|
||||||
|
// the batch engine compounded) gets corrected at maturity. Testing the individual period,
|
||||||
|
// not the account-wide transfer flag, is the distinction that matters here.
|
||||||
final boolean correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod);
|
final boolean correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod);
|
||||||
if (correctionRequired) {
|
if (correctionRequired) {
|
||||||
postingTransaction.reverse();
|
postingTransaction.reverse();
|
||||||
@@ -586,6 +704,57 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions);
|
this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this period's posted interest has already left the account for the linked savings account.
|
||||||
|
* <p>
|
||||||
|
* The sweep withdrawal carries the same transaction date as the interest posting it pays out (see
|
||||||
|
* DepositAccountReadPlatformService#retrieveDataForInterestTransfer, which pairs them by date), and
|
||||||
|
* {@link #interestTransferWithdrawalTransactionIds()} returns those WITHDRAWAL ids -- not the posting
|
||||||
|
* ids -- so the match has to be on date, not on id.
|
||||||
|
*/
|
||||||
|
private boolean interestAlreadyPaidOut(final SavingsAccountTransaction postingTransaction) {
|
||||||
|
final Collection<Long> sweepWithdrawalIds = interestTransferWithdrawalTransactionIds();
|
||||||
|
if (sweepWithdrawalIds.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (final SavingsAccountTransaction transaction : this.transactions) {
|
||||||
|
if (transaction.isNotReversed() && sweepWithdrawalIds.contains(transaction.getId())
|
||||||
|
&& DateUtils.isEqual(transaction.getTransactionDate(), postingTransaction.getTransactionDate())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the base {@link SavingsAccount#postInterest} correction loop, which base
|
||||||
|
* {@code SavingsAccountDomainServiceJpa#handleWithdrawal} re-triggers after every withdrawal -- including the
|
||||||
|
* sweep withdrawal that transfers a period's interest out to the linked savings account. Without this guard,
|
||||||
|
* that recalculation reverses and reposts the just-paid-out period at a freshly-recomputed (and slightly
|
||||||
|
* different, e.g. rounding) amount, leaving the already-executed sweep withdrawal mismatched against the new
|
||||||
|
* posting.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) {
|
||||||
|
return !interestAlreadyPaidOut(postingTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interest-transfer sweep withdrawal only ever fires for a period whose interest is already posted (see
|
||||||
|
* DepositAccountReadPlatformService#retrieveDataForInterestTransfer), so there is nothing new for this
|
||||||
|
* recalculation to find. Worse, at the moment this withdrawal's own post-withdrawal recalculation would run,
|
||||||
|
* the m_account_transfer_transaction linking row that marks it as a transfer withdrawal does not exist yet
|
||||||
|
* (it is created by AccountTransfersWritePlatformServiceImpl only after both transfer legs are booked) -- so
|
||||||
|
* the exclusion in {@link #resetAccountTransactionsEndOfDayBalances} cannot yet recognise it, and the
|
||||||
|
* recalculation would wrongly treat it as a real balance-reducing transaction for its own and every later
|
||||||
|
* period, reversing and reposting already-settled interest at a corrupted amount. Skipping the recalculation
|
||||||
|
* for this specific withdrawal avoids the corruption at its source.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) {
|
||||||
|
return !isInterestTransfer;
|
||||||
|
}
|
||||||
|
|
||||||
public void postPreMaturityInterest(final LocalDate accountCloseDate, final boolean isPreMatureClosure,
|
public void postPreMaturityInterest(final LocalDate accountCloseDate, final boolean isPreMatureClosure,
|
||||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
|
||||||
|
|
||||||
@@ -686,6 +855,60 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
return this.accountTermAndPreClosure.maturityAmount();
|
return this.accountTermAndPreClosure.maturityAmount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stable customer-facing FD number: the account number of the first account in this renewal chain. Falls back to
|
||||||
|
* this account's own number when it has never been renewed, so callers always get a usable FD number.
|
||||||
|
*/
|
||||||
|
public String fdAccountNumber() {
|
||||||
|
final String carried = this.accountTermAndPreClosure.getFdAccountNumber();
|
||||||
|
return carried != null ? carried : getAccountNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
|
||||||
|
public Integer renewalCycleNo() {
|
||||||
|
return this.accountTermAndPreClosure.getRenewalCycleNo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer depositPeriod() {
|
||||||
|
return this.accountTermAndPreClosure.depositPeriod();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer depositPeriodFrequency() {
|
||||||
|
return this.accountTermAndPreClosure.depositPeriodFrequency();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long reinvestedFromSavingsId() {
|
||||||
|
return this.accountTermAndPreClosure.getReinvestedFromSavingsId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the chart currently priced on this account. Read before a renewal overwrites it, so the closing term's
|
||||||
|
* chart identity can be archived rather than lost.
|
||||||
|
*/
|
||||||
|
public String currentChartName() {
|
||||||
|
return this.chart == null ? null : this.chart.chartFields().getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slabs of the chart currently priced on this account -- each paired with its senior-citizen premium -- detached
|
||||||
|
* from the chart's own persistence lifecycle.
|
||||||
|
* <p>
|
||||||
|
* Read before a renewal overwrites the account's chart in place ({@link #renewInPlace}), so the closing term's full
|
||||||
|
* rate-by-tenure breakdown (including the premium) can be archived -- not just the single effective rate the renewal
|
||||||
|
* history records -- rather than being silently deleted when the new term's slabs replace it.
|
||||||
|
*/
|
||||||
|
public List<DepositAccountInterestRateChart.SlabSnapshot> currentChartSlabFields() {
|
||||||
|
return this.chart == null ? java.util.Collections.emptyList() : this.chart.currentSlabSnapshots();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This deposit's FD type (SINGLE / DOUBLE / SPECIAL), which lives on the product rather than the account. Null when
|
||||||
|
* the product is not a Fixed Deposit product or has no type set.
|
||||||
|
*/
|
||||||
|
public String fdType() {
|
||||||
|
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private Money totalInterestPosted() {
|
private Money totalInterestPosted() {
|
||||||
Money interestPostedToDate = Money.zero(this.currency);
|
Money interestPostedToDate = Money.zero(this.currency);
|
||||||
for (final SavingsAccountTransaction transaction : this.transactions) {
|
for (final SavingsAccountTransaction transaction : this.transactions) {
|
||||||
@@ -715,11 +938,26 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
return actualChanges;
|
return actualChanges;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The start of the term currently running, which is what the deposit's tenure and rate must be measured from.
|
||||||
|
* <p>
|
||||||
|
* For an FD that has never been renewed this is the activation date, exactly as before. A renewal advances
|
||||||
|
* startInterestCalculationDate to the renewal date, so from then on the interest-rate chart slab is chosen by THIS
|
||||||
|
* term's tenure rather than the cumulative tenure since the account was opened, and maturity is measured from the
|
||||||
|
* renewal rather than from the original activation.
|
||||||
|
*/
|
||||||
private LocalDate depositStartDate() {
|
private LocalDate depositStartDate() {
|
||||||
// TODO: Support to add deposit start date which can be a date after
|
final LocalDate currentTermStartDate = getStartInterestCalculationDate();
|
||||||
// account activation date.
|
return currentTermStartDate != null ? currentTermStartDate : accountSubmittedOrActivationDate();
|
||||||
final LocalDate depositStartDate = accountSubmittedOrActivationDate();
|
}
|
||||||
return depositStartDate;
|
|
||||||
|
/**
|
||||||
|
* True once this FD has been renewed at least once, i.e. the term now running started later than the account did.
|
||||||
|
* Everything keyed off this keeps its original, pre-renewal behaviour for an FD still on its first term.
|
||||||
|
*/
|
||||||
|
private boolean isRenewedTerm() {
|
||||||
|
final LocalDate termStart = getStartInterestCalculationDate();
|
||||||
|
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
private LocalDate interestCalculatedUpto() {
|
private LocalDate interestCalculatedUpto() {
|
||||||
@@ -731,6 +969,24 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
return uptoMaturityDate;
|
return uptoMaturityDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An FD that pays its interest out monthly (FD Special) carries a transfer-out WITHDRAWAL for every posted
|
||||||
|
* period, and {@link #retreiveOrderedNonInterestPostingTransactions()} strips those out before the
|
||||||
|
* interest engine sees them. The balance-segment walk has to strip the same ones: that withdrawal is dated
|
||||||
|
* the same day as the posting it pays out, so leaving it in ends the funding deposit's balance segment one
|
||||||
|
* day early while the engine's own list has nothing to cover that day -- costing the first period one
|
||||||
|
* earning day (e.g. 6 days instead of 25-Jul..31-Jul's real 7).
|
||||||
|
* <p>
|
||||||
|
* A no-op for every other account: the excluded-id lookup short-circuits to empty unless
|
||||||
|
* {@link #isTransferInterestToOtherAccount()} is true.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransaction> accountTransactionsSorted,
|
||||||
|
final LocalDate interestPostingUpToDate) {
|
||||||
|
super.resetAccountTransactionsEndOfDayBalances(excludeInterestTransferWithdrawals(accountTransactionsSorted),
|
||||||
|
interestPostingUpToDate);
|
||||||
|
}
|
||||||
|
|
||||||
public void validateDomainRules() {
|
public void validateDomainRules() {
|
||||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
||||||
@@ -814,28 +1070,142 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
return this.accountTermAndPreClosure.getTransferToSavingsAccountId();
|
return this.accountTermAndPreClosure.getTransferToSavingsAccountId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls this FD into a new term on the SAME account, so the customer keeps one account number however many times it
|
||||||
|
* renews. No new account is opened and no money moves: the balance simply carries into the next term.
|
||||||
|
* <p>
|
||||||
|
* What changes is the term itself. startInterestCalculationDate advances to the renewal date, which is what confines
|
||||||
|
* interest posting to the new term and thereby leaves the closing term's postings untouched -- they fall outside the
|
||||||
|
* posting periods the engine now generates, so nothing recalculates or reverses them. The term's principal becomes
|
||||||
|
* {@code renewedAmount}, and every interest-mechanics setting plus the rate chart is re-taken from the product as it
|
||||||
|
* stands today, so a renewal is priced on current terms rather than the ones frozen at account opening.
|
||||||
|
* <p>
|
||||||
|
* The caller must have posted the closing term's interest first, and must record the renewal in the FD's renewal
|
||||||
|
* history so each term's own figures remain reportable.
|
||||||
|
*
|
||||||
|
* @param renewedAmount
|
||||||
|
* what carries into the new term: the full maturity amount, or the principal only
|
||||||
|
* @param renewedOn
|
||||||
|
* the renewal date, which becomes the new term's start
|
||||||
|
* @param currentSeniorCitizenRate
|
||||||
|
* the senior-citizen premium the bank publishes on the renewal date for this FD type and tenure, or null to
|
||||||
|
* keep the rate the account already holds. Only applied to an account already flagged senior-citizen; the
|
||||||
|
* renewal never turns that flag on or off.
|
||||||
|
* @param baseChartFields
|
||||||
|
* identity of the CBS base rate version being applied, or null to keep the existing chart's
|
||||||
|
* @param baseChartSlabs
|
||||||
|
* the slabs of the active CBS base interest-rate version, each paired with its senior-citizen premium -- the
|
||||||
|
* rate card staff maintain and see on the Base Interest Rates screen. When empty or null the account falls
|
||||||
|
* back to the product's own chart, so a product with no base rate version still renews on something.
|
||||||
|
*/
|
||||||
|
public void renewInPlace(final BigDecimal renewedAmount, final LocalDate renewedOn, final BigDecimal currentSeniorCitizenRate,
|
||||||
|
final InterestRateChartFields baseChartFields, final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs) {
|
||||||
|
|
||||||
|
final SavingsProduct product = this.product;
|
||||||
|
|
||||||
|
// Confines interest posting to the new term. Set before recomputing maturity, since maturity is
|
||||||
|
// measured from the current term's start.
|
||||||
|
setStartInterestCalculationDate(renewedOn);
|
||||||
|
|
||||||
|
this.accountTermAndPreClosure.updateDepositAmount(renewedAmount);
|
||||||
|
this.accountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
|
||||||
|
// Pin the chain number now that this account has renewed at least once, so a non-zero cycle number
|
||||||
|
// is never paired with a blank FD number. fdAccountNumber() resolves to the chain's original number
|
||||||
|
// if there is one and to this account's own otherwise, which is the same value every reader derives.
|
||||||
|
this.accountTermAndPreClosure.setFdAccountNumber(fdAccountNumber());
|
||||||
|
|
||||||
|
// Re-price off the CURRENT product, exactly as opening a fresh deposit today would.
|
||||||
|
this.nominalAnnualInterestRate = product.nominalAnnualInterestRate();
|
||||||
|
this.interestCompoundingPeriodType = product.interestCompoundingPeriodType().getValue();
|
||||||
|
this.interestPostingPeriodType = product.interestPostingPeriodType().getValue();
|
||||||
|
this.interestCalculationType = product.interestCalculationType().getValue();
|
||||||
|
this.interestCalculationDaysInYearType = product.interestCalculationDaysInYearType().getValue();
|
||||||
|
this.withHoldTax = product.withHoldTax();
|
||||||
|
|
||||||
|
// Lock-in is re-taken from the product and re-measured from this renewal, exactly as the legacy
|
||||||
|
// close-and-reopen reInvest() does for the account it creates. Left alone, a renewed term would
|
||||||
|
// carry the lock-in window computed at the original activation -- long expired -- so a product with
|
||||||
|
// a statutory lock-in (a Tax Saver, say) would hold the customer for the first term only.
|
||||||
|
final SavingsPeriodFrequencyType renewedLockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
|
||||||
|
this.lockinPeriodFrequency = product.lockinPeriodFrequency();
|
||||||
|
this.lockinPeriodFrequencyType = renewedLockinPeriodFrequencyType == null ? null
|
||||||
|
: renewedLockinPeriodFrequencyType.getValue();
|
||||||
|
this.lockedInUntilDate = this.lockinPeriodFrequencyType == null || this.lockinPeriodFrequency == null ? null
|
||||||
|
: calculateDateAccountIsLockedUntil(renewedOn);
|
||||||
|
|
||||||
|
// The new term is priced off the CBS base interest-rate master -- the rate card staff maintain on
|
||||||
|
// the Base Interest Rates screen, per FD type, with its own active version. That master is what
|
||||||
|
// the bank publishes and what the account-opening wizard already shows, so pricing a renewal off
|
||||||
|
// anything else would put the deposit on rates nobody chose.
|
||||||
|
if (this.chart != null && baseChartSlabs != null && !baseChartSlabs.isEmpty()) {
|
||||||
|
this.chart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
|
||||||
|
} else if (this.chart != null) {
|
||||||
|
// No active base version for this FD type: fall back to the product's own chart so the
|
||||||
|
// renewal still gets current product rates rather than the ones frozen at opening.
|
||||||
|
// activeChart, not applicableChart -- the latter treats a chart with no end date as valid only
|
||||||
|
// up to today's business date, so a renewal dated in the future would find nothing.
|
||||||
|
final InterestRateChart productChart = product instanceof FixedDepositProduct
|
||||||
|
? ((FixedDepositProduct) product).activeChart(renewedOn)
|
||||||
|
: null;
|
||||||
|
if (productChart != null) {
|
||||||
|
this.chart.refreshFrom(productChart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The senior-citizen premium is stored per account and takes priority over the chart in
|
||||||
|
// getEffectiveInterestRateAsFraction(), so leaving it alone would price every future term of a
|
||||||
|
// senior-citizen deposit at the rate captured when the account was opened. Only overwritten when
|
||||||
|
// the caller found a currently published rate -- otherwise the existing one stands, so a missing
|
||||||
|
// rate version can never silently drop the depositor's premium.
|
||||||
|
if (isSeniorCitizen() && currentSeniorCitizenRate != null && currentSeniorCitizenRate.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
setSeniorCitizenInterestRate(currentSeniorCitizenRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account never left ACTIVE, but updateMaturityStatus() flipped it to MATURED just before
|
||||||
|
// this ran, so the new term has to put it back.
|
||||||
|
this.status = SavingsAccountStatusType.ACTIVE.getValue();
|
||||||
|
setClosedOnDate(null);
|
||||||
|
}
|
||||||
|
|
||||||
public FixedDepositAccount reInvest(BigDecimal depositAmount, ExternalId externalId) {
|
public FixedDepositAccount reInvest(BigDecimal depositAmount, ExternalId externalId) {
|
||||||
|
|
||||||
final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount);
|
final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount);
|
||||||
final SavingsProduct product = this.product;
|
final SavingsProduct product = this.product;
|
||||||
final InterestRateChart productChart = product.applicableChart(getClosedOnDate());
|
// Effective-dated pick, same as renewInPlace: an open-ended chart stays valid past today's
|
||||||
|
// business date, and the most recently effective chart wins when more than one covers the date.
|
||||||
|
// Falls back to applicableChart so a product whose charts have no usable from-date still resolves
|
||||||
|
// as it did before.
|
||||||
|
InterestRateChart productChart = product instanceof FixedDepositProduct
|
||||||
|
? ((FixedDepositProduct) product).activeChart(getClosedOnDate())
|
||||||
|
: null;
|
||||||
|
if (productChart == null) {
|
||||||
|
productChart = product.applicableChart(getClosedOnDate());
|
||||||
|
}
|
||||||
final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart);
|
final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart);
|
||||||
|
|
||||||
final AccountType accountType = AccountType.fromInt(this.accountType);
|
final AccountType accountType = AccountType.fromInt(this.accountType);
|
||||||
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType);
|
// Re-price every interest-mechanics setting off the CURRENT product, not this (old) account's
|
||||||
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
|
// frozen-at-opening snapshot -- same reasoning as interestRate/newChart below. Without this, a
|
||||||
.fromInt(this.interestCompoundingPeriodType);
|
// renewed FD kept running on whatever compounding/posting/calculation-type/lock-in/tax setting
|
||||||
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
|
// was in force when the ORIGINAL account was opened, silently ignoring every product edit made
|
||||||
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
|
// since (e.g. Daily->Quarterly compounding, or a lock-in added later).
|
||||||
.fromInt(this.interestCalculationDaysInYearType);
|
final SavingsPostingInterestPeriodType postingPeriodType = product.interestPostingPeriodType();
|
||||||
|
final SavingsCompoundingInterestPeriodType compoundingPeriodType = product.interestCompoundingPeriodType();
|
||||||
|
final SavingsInterestCalculationType interestCalculationType = product.interestCalculationType();
|
||||||
|
final SavingsInterestCalculationDaysInYearType daysInYearType = product.interestCalculationDaysInYearType();
|
||||||
final BigDecimal minRequiredOpeningBalance = null;
|
final BigDecimal minRequiredOpeningBalance = null;
|
||||||
final BigDecimal interestRate = BigDecimal.ZERO;
|
// Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses
|
||||||
|
// for a brand-new account -- so a product with no chart slab covering this tenure/amount
|
||||||
|
// doesn't silently reinvest the customer at 0%. getEffectiveInterestRateAsFraction() still
|
||||||
|
// re-prices off newChart (the product's CURRENT chart, reflecting any edits made since the
|
||||||
|
// original account was opened) whenever a matching slab exists.
|
||||||
|
final BigDecimal interestRate = product.nominalAnnualInterestRate();
|
||||||
final Set<SavingsAccountCharge> savingsAccountCharges = null;
|
final Set<SavingsAccountCharge> savingsAccountCharges = null;
|
||||||
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
|
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
|
||||||
final Integer lockinPeriodFrequency = this.lockinPeriodFrequency;
|
final Integer lockinPeriodFrequency = product.lockinPeriodFrequency();
|
||||||
final boolean withdrawalFeeApplicableForTransfer = false;
|
final boolean withdrawalFeeApplicableForTransfer = false;
|
||||||
final String accountNumber = null;
|
final String accountNumber = null;
|
||||||
final boolean withHoldTax = this.withHoldTax;
|
final boolean withHoldTax = product.withHoldTax();
|
||||||
final FixedDepositAccount reInvestedAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product,
|
final FixedDepositAccount reInvestedAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product,
|
||||||
savingsOfficer, accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType,
|
savingsOfficer, accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType,
|
||||||
postingPeriodType, interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency,
|
postingPeriodType, interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency,
|
||||||
@@ -845,6 +1215,30 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount);
|
newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount);
|
||||||
newChart.updateDepositAccountReference(reInvestedAccount);
|
newChart.updateDepositAccountReference(reInvestedAccount);
|
||||||
|
|
||||||
|
// Carry the senior-citizen premium forward -- without this, a senior-citizen depositor's
|
||||||
|
// reinvested FD silently drops back to the standard rate on the new term.
|
||||||
|
reInvestedAccount.setSeniorCitizen(this.isSeniorCitizen());
|
||||||
|
reInvestedAccount.setSeniorCitizenInterestRate(this.getSeniorCitizenInterestRate());
|
||||||
|
|
||||||
|
// Track which account this was reinvested from, so staff/UI can trace the two accounts to
|
||||||
|
// each other instead of the link disappearing the moment the old one closes.
|
||||||
|
newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId());
|
||||||
|
|
||||||
|
// Carry the ORIGINAL account's number forward across every renewal, so the customer keeps
|
||||||
|
// seeing one recognisable FD number even though each term is its own account/ledger row.
|
||||||
|
final String rootFdAccountNumber = this.accountTermAndPreClosure.getFdAccountNumber() != null
|
||||||
|
? this.accountTermAndPreClosure.getFdAccountNumber()
|
||||||
|
: this.getAccountNumber();
|
||||||
|
newAccountTermAndPreClosure.setFdAccountNumber(rootFdAccountNumber);
|
||||||
|
|
||||||
|
// Number this term within the chain, so the UI can label it "Renewal 2 of FD ...048".
|
||||||
|
newAccountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
|
||||||
|
|
||||||
|
// Without this the renewed deposit has no maturity instruction, so it would mature once more and
|
||||||
|
// then sit idle rather than rolling over again as the customer asked. Done here rather than in
|
||||||
|
// the shared copy(), which Recurring Deposit renewal also uses.
|
||||||
|
newAccountTermAndPreClosure.carryForwardStandingInstructionsFrom(this.accountTermAndPreClosure);
|
||||||
|
|
||||||
return reInvestedAccount;
|
return reInvestedAccount;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -886,8 +1280,10 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
@Override
|
@Override
|
||||||
public void loadLazyCollections() {
|
public void loadLazyCollections() {
|
||||||
super.loadLazyCollections();
|
super.loadLazyCollections();
|
||||||
|
if (this.chart != null) {
|
||||||
this.chart.getId();
|
this.chart.getId();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public BigDecimal getDepositAmount() {
|
public BigDecimal getDepositAmount() {
|
||||||
return this.accountTermAndPreClosure.depositAmount();
|
return this.accountTermAndPreClosure.depositAmount();
|
||||||
@@ -902,6 +1298,52 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
return SavingsAccountStatusType.MATURED.getValue().equals(this.status);
|
return SavingsAccountStatusType.MATURED.getValue().equals(this.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposes the parent class's protected lock-in check to callers outside this class hierarchy. Needed by the
|
||||||
|
* Payment action's domain-service method, which enforces this itself for its reinvest branches rather than going
|
||||||
|
* through {@link #prematureClosure}, since those branches keep the account open on the same term instead of
|
||||||
|
* closing it.
|
||||||
|
*/
|
||||||
|
public boolean isLockedOnDate(final LocalDate transactionDate) {
|
||||||
|
return isAccountLocked(transactionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date guards {@link #close} and {@link #prematureClosure} apply, minus the maturity-date bound, for a Payment
|
||||||
|
* that renews the deposit instead of closing it and so never reaches either of those methods.
|
||||||
|
* <p>
|
||||||
|
* Dropping the maturity bound is the point: Payment is allowed on an already-matured deposit. The other three
|
||||||
|
* matter more here than they do on a closure, because a renewal turns this date into the new term's interest
|
||||||
|
* pivot -- backdate it behind interest that has already been posted and the engine regenerates those posting
|
||||||
|
* periods, reverses the settled postings and re-posts them, which is exactly what the pivot exists to prevent.
|
||||||
|
*/
|
||||||
|
public void validateRenewalDate(final LocalDate renewedOn) {
|
||||||
|
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||||
|
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
||||||
|
.resource(FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction);
|
||||||
|
|
||||||
|
if (DateUtils.isAfterBusinessDate(renewedOn)) {
|
||||||
|
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
|
||||||
|
.failWithCode("cannot.be.a.future.date");
|
||||||
|
}
|
||||||
|
if (DateUtils.isBefore(renewedOn, getActivationDate())) {
|
||||||
|
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
|
||||||
|
.failWithCode("must.be.after.activation.date");
|
||||||
|
}
|
||||||
|
final List<SavingsAccountTransaction> savingsAccountTransactions = retrieveListOfTransactions();
|
||||||
|
if (!savingsAccountTransactions.isEmpty()) {
|
||||||
|
final SavingsAccountTransaction lastTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1);
|
||||||
|
if (lastTransaction.isAfter(renewedOn)) {
|
||||||
|
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
|
||||||
|
.failWithCode("must.be.after.last.transaction.date");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataValidationErrors.isEmpty()) {
|
||||||
|
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void setClosedOnDate(final LocalDate closedOnDate) {
|
public void setClosedOnDate(final LocalDate closedOnDate) {
|
||||||
this.closedOnDate = closedOnDate;
|
this.closedOnDate = closedOnDate;
|
||||||
}
|
}
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One slab of the full tenure-by-rate chart a term of an FD was priced on, archived against that term's
|
||||||
|
* {@link FixedDepositRenewalHistory} row.
|
||||||
|
* <p>
|
||||||
|
* {@code renewInPlace} reuses the same account row across renewals, overwriting the account's own interest-rate chart in
|
||||||
|
* place at every renewal. {@code FixedDepositRenewalHistory} preserves the single effective rate that resulted, but not
|
||||||
|
* the chart it came from -- this table is what lets a later query answer "what did the whole rate card look like during
|
||||||
|
* term 2", not just "what rate applied".
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_fd_renewal_chart_slab_history")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class FixedDepositRenewalChartSlabHistory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "fd_renewal_history_id", nullable = false)
|
||||||
|
private Long fdRenewalHistoryId;
|
||||||
|
|
||||||
|
@Column(name = "chart_name", length = 100)
|
||||||
|
private String chartName;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 200)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "period_type_enum")
|
||||||
|
private Integer periodTypeEnum;
|
||||||
|
|
||||||
|
@Column(name = "from_period")
|
||||||
|
private Integer fromPeriod;
|
||||||
|
|
||||||
|
@Column(name = "to_period")
|
||||||
|
private Integer toPeriod;
|
||||||
|
|
||||||
|
@Column(name = "amount_range_from", scale = 6, precision = 19)
|
||||||
|
private BigDecimal amountRangeFrom;
|
||||||
|
|
||||||
|
@Column(name = "amount_range_to", scale = 6, precision = 19)
|
||||||
|
private BigDecimal amountRangeTo;
|
||||||
|
|
||||||
|
@Column(name = "annual_interest_rate", scale = 6, precision = 19)
|
||||||
|
private BigDecimal annualInterestRate;
|
||||||
|
|
||||||
|
@Column(name = "currency_code", length = 3)
|
||||||
|
private String currencyCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The senior-citizen premium published for this tenure band at the time this term was priced, carried through from the
|
||||||
|
* CBS Base Interest Rates screen rather than looked up live against whatever is published now. Null when the term was
|
||||||
|
* priced off the product's own chart (which has no senior-citizen concept) or predates this column.
|
||||||
|
*/
|
||||||
|
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
|
||||||
|
private BigDecimal seniorCitizenRate;
|
||||||
|
|
||||||
|
public static FixedDepositRenewalChartSlabHistory create(final Long fdRenewalHistoryId, final String chartName,
|
||||||
|
final String description, final Integer periodTypeEnum, final Integer fromPeriod, final Integer toPeriod,
|
||||||
|
final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, final BigDecimal annualInterestRate,
|
||||||
|
final String currencyCode, final BigDecimal seniorCitizenRate) {
|
||||||
|
final FixedDepositRenewalChartSlabHistory slab = new FixedDepositRenewalChartSlabHistory();
|
||||||
|
slab.fdRenewalHistoryId = fdRenewalHistoryId;
|
||||||
|
slab.chartName = chartName;
|
||||||
|
slab.description = description;
|
||||||
|
slab.periodTypeEnum = periodTypeEnum;
|
||||||
|
slab.fromPeriod = fromPeriod;
|
||||||
|
slab.toPeriod = toPeriod;
|
||||||
|
slab.amountRangeFrom = amountRangeFrom;
|
||||||
|
slab.amountRangeTo = amountRangeTo;
|
||||||
|
slab.annualInterestRate = annualInterestRate;
|
||||||
|
slab.currencyCode = currencyCode;
|
||||||
|
slab.seniorCitizenRate = seniorCitizenRate;
|
||||||
|
return slab;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface FixedDepositRenewalChartSlabHistoryRepository extends JpaRepository<FixedDepositRenewalChartSlabHistory, Long> {
|
||||||
|
|
||||||
|
/** The full chart a term was priced on, unordered -- callers typically sort by fromPeriod for display. */
|
||||||
|
List<FixedDepositRenewalChartSlabHistory> findByFdRenewalHistoryId(Long fdRenewalHistoryId);
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per term of one logical Fixed Deposit, recording the renewal event that started it.
|
||||||
|
* <p>
|
||||||
|
* Each term runs as its own deposit instance (its own {@code m_savings_account} row) so that term keeps an immutable
|
||||||
|
* ledger, interest postings and GL entries, while {@link #fdAccountNumber} stays the same across the whole chain and is
|
||||||
|
* what the customer sees. What that structure alone cannot tell you is the intent and outcome of each rollover, which is
|
||||||
|
* what this table captures: the previous term's principal and interest earned, what it matured at, how much was carried
|
||||||
|
* forward, the rate applied to the new term, whether the renewal ran automatically at maturity or was performed by a
|
||||||
|
* user, and by whom.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_fd_renewal_history")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class FixedDepositRenewalHistory {
|
||||||
|
|
||||||
|
/** Renewal ran automatically when the maturity job processed the account's maturity instruction. */
|
||||||
|
public static final String RENEWAL_TYPE_AUTO = "AUTO";
|
||||||
|
/** A user closed the account with a re-invest instruction. */
|
||||||
|
public static final String RENEWAL_TYPE_MANUAL = "MANUAL";
|
||||||
|
|
||||||
|
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||||
|
public static final String STATUS_MATURED = "MATURED";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** The stable customer-facing FD number, shared by every term in the chain. */
|
||||||
|
@Column(name = "fd_account_number", length = 20, nullable = false)
|
||||||
|
private String fdAccountNumber;
|
||||||
|
|
||||||
|
/** The account the customer originally opened, i.e. the anchor of the chain. */
|
||||||
|
@Column(name = "original_savings_account_id", nullable = false)
|
||||||
|
private Long originalSavingsAccountId;
|
||||||
|
|
||||||
|
/** The deposit instance this term runs on. */
|
||||||
|
@Column(name = "savings_account_id", nullable = false)
|
||||||
|
private Long savingsAccountId;
|
||||||
|
|
||||||
|
/** The term this one was renewed from; null on the chain's first row. */
|
||||||
|
@Column(name = "previous_savings_account_id")
|
||||||
|
private Long previousSavingsAccountId;
|
||||||
|
|
||||||
|
@Column(name = "previous_renewal_id")
|
||||||
|
private Long previousRenewalId;
|
||||||
|
|
||||||
|
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
|
||||||
|
@Column(name = "renewal_no", nullable = false)
|
||||||
|
private Integer renewalNo;
|
||||||
|
|
||||||
|
@Column(name = "previous_principal", scale = 6, precision = 19)
|
||||||
|
private BigDecimal previousPrincipal;
|
||||||
|
|
||||||
|
@Column(name = "interest_earned", scale = 6, precision = 19)
|
||||||
|
private BigDecimal interestEarned;
|
||||||
|
|
||||||
|
@Column(name = "maturity_amount", scale = 6, precision = 19)
|
||||||
|
private BigDecimal maturityAmount;
|
||||||
|
|
||||||
|
/** What actually carried into this term: the full maturity amount, or principal only. */
|
||||||
|
@Column(name = "renewed_amount", scale = 6, precision = 19)
|
||||||
|
private BigDecimal renewedAmount;
|
||||||
|
|
||||||
|
@Column(name = "interest_rate", scale = 6, precision = 19)
|
||||||
|
private BigDecimal interestRate;
|
||||||
|
|
||||||
|
@Column(name = "start_date")
|
||||||
|
private LocalDate startDate;
|
||||||
|
|
||||||
|
@Column(name = "maturity_date")
|
||||||
|
private LocalDate maturityDate;
|
||||||
|
|
||||||
|
@Column(name = "tenure")
|
||||||
|
private Integer tenure;
|
||||||
|
|
||||||
|
@Column(name = "tenure_frequency_enum")
|
||||||
|
private Integer tenureFrequencyEnum;
|
||||||
|
|
||||||
|
@Column(name = "renewal_type", length = 10)
|
||||||
|
private String renewalType;
|
||||||
|
|
||||||
|
@Column(name = "renewal_status", length = 20)
|
||||||
|
private String renewalStatus;
|
||||||
|
|
||||||
|
@Column(name = "renewed_on")
|
||||||
|
private LocalDate renewedOn;
|
||||||
|
|
||||||
|
@Column(name = "created_by_id")
|
||||||
|
private Long createdById;
|
||||||
|
|
||||||
|
public static FixedDepositRenewalHistory create(final String fdAccountNumber, final Long originalSavingsAccountId,
|
||||||
|
final Long savingsAccountId, final Long previousSavingsAccountId, final Long previousRenewalId, final Integer renewalNo,
|
||||||
|
final BigDecimal previousPrincipal, final BigDecimal interestEarned, final BigDecimal maturityAmount,
|
||||||
|
final BigDecimal renewedAmount, final BigDecimal interestRate, final LocalDate startDate, final LocalDate maturityDate,
|
||||||
|
final Integer tenure, final Integer tenureFrequencyEnum, final String renewalType, final LocalDate renewedOn,
|
||||||
|
final Long createdById) {
|
||||||
|
final FixedDepositRenewalHistory history = new FixedDepositRenewalHistory();
|
||||||
|
history.fdAccountNumber = fdAccountNumber;
|
||||||
|
history.originalSavingsAccountId = originalSavingsAccountId;
|
||||||
|
history.savingsAccountId = savingsAccountId;
|
||||||
|
history.previousSavingsAccountId = previousSavingsAccountId;
|
||||||
|
history.previousRenewalId = previousRenewalId;
|
||||||
|
history.renewalNo = renewalNo;
|
||||||
|
history.previousPrincipal = previousPrincipal;
|
||||||
|
history.interestEarned = interestEarned;
|
||||||
|
history.maturityAmount = maturityAmount;
|
||||||
|
history.renewedAmount = renewedAmount;
|
||||||
|
history.interestRate = interestRate;
|
||||||
|
history.startDate = startDate;
|
||||||
|
history.maturityDate = maturityDate;
|
||||||
|
history.tenure = tenure;
|
||||||
|
history.tenureFrequencyEnum = tenureFrequencyEnum;
|
||||||
|
history.renewalType = renewalType;
|
||||||
|
history.renewalStatus = STATUS_ACTIVE;
|
||||||
|
history.renewedOn = renewedOn;
|
||||||
|
history.createdById = createdById;
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called on the previous term's row once its successor takes over. */
|
||||||
|
public void markMatured() {
|
||||||
|
this.renewalStatus = STATUS_MATURED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills in what this term actually earned and matured at, which is only known once it closes. Written on the term's
|
||||||
|
* own row so each term reports its own outcome rather than that of its predecessor.
|
||||||
|
*/
|
||||||
|
public void recordOutcome(final BigDecimal interestEarned, final BigDecimal maturityAmount) {
|
||||||
|
this.interestEarned = interestEarned;
|
||||||
|
this.maturityAmount = maturityAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface FixedDepositRenewalHistoryRepository extends JpaRepository<FixedDepositRenewalHistory, Long> {
|
||||||
|
|
||||||
|
/** One FD's whole history, oldest term first. */
|
||||||
|
List<FixedDepositRenewalHistory> findByFdAccountNumberOrderByRenewalNoAsc(String fdAccountNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account's terms, newest first.
|
||||||
|
* <p>
|
||||||
|
* Deliberately a list, not a single row: an FD that renews on the same account has one row per term all pointing at
|
||||||
|
* that one account, so callers wanting the term currently running take the first element. Empty for an account that
|
||||||
|
* pre-dates this table or was never renewed.
|
||||||
|
*/
|
||||||
|
List<FixedDepositRenewalHistory> findBySavingsAccountIdOrderByRenewalNoDesc(Long savingsAccountId);
|
||||||
|
}
|
||||||
+24
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominati
|
|||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebitFacilityParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebitFacilityParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName;
|
||||||
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareAccountIdParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
|
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName;
|
||||||
@@ -81,6 +82,7 @@ import java.util.List;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException;
|
import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||||
@@ -406,6 +408,28 @@ public class SavingsAccountAssembler {
|
|||||||
account.setShareLinkingEnabled(account.savingsProduct().isShareLinkingEnabled());
|
account.setShareLinkingEnabled(account.savingsProduct().isShareLinkingEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which share account this savings account is linked to - required once Share Linking is
|
||||||
|
// enabled, since a "Yes" with nothing chosen would leave the toggle meaning nothing. Checked
|
||||||
|
// against m_share_account directly (raw SQL, not the JPA entity - fineract-savings has no
|
||||||
|
// dependency on the shareaccounts domain class) rather than trusted blindly, the same way an
|
||||||
|
// account number typed for a linked savings/loan account elsewhere in this form is verified
|
||||||
|
// before being accepted.
|
||||||
|
if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) {
|
||||||
|
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
|
||||||
|
if (shareAccountId == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
|
||||||
|
"A share account must be chosen when Share Linking is enabled.");
|
||||||
|
}
|
||||||
|
final Integer matches = this.jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
|
||||||
|
shareAccountId, account.clientId());
|
||||||
|
if (matches == null || matches == 0) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
|
||||||
|
"Share account " + shareAccountId + " is not an active share account for this client.");
|
||||||
|
}
|
||||||
|
account.setShareAccountId(shareAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value
|
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value
|
||||||
if (command.parameterExists(isSeniorCitizenParamName)) {
|
if (command.parameterExists(isSeniorCitizenParamName)) {
|
||||||
final Boolean scFlag = this.fromApiJsonHelper.extractBooleanNamed(isSeniorCitizenParamName, element);
|
final Boolean scFlag = this.fromApiJsonHelper.extractBooleanNamed(isSeniorCitizenParamName, element);
|
||||||
|
|||||||
+6
-2
@@ -114,13 +114,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
|
|||||||
|
|
||||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
|
|
||||||
|
if (account.isPostWithdrawalInterestRecalculationRequired(transactionBooleanValues.isInterestTransfer())) {
|
||||||
if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) {
|
if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) {
|
||||||
account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd,
|
account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals);
|
financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals);
|
||||||
} else {
|
} else {
|
||||||
account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(),
|
account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(),
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill,
|
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate,
|
||||||
postReversals);
|
backdatedTxnsAllowedTill, postReversals);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
account.refreshBalancesAndSummaryAfterTransfer(today, backdatedTxnsAllowedTill, postReversals);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
|
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.handler;
|
||||||
|
|
||||||
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "FIXEDDEPOSITACCOUNT", action = "PAYMENT")
|
||||||
|
public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) {
|
||||||
|
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command);
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.handler;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.portfolio.savings.service.FacilityChargeMappingWritePlatformService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@CommandType(entity = "FACILITYCHARGEMAPPING", action = "UPDATE")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UpdateFacilityChargeMappingCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
|
private final FacilityChargeMappingWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
|
return this.writePlatformService.updateMapping(command.entityId(), command);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-5
@@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
@@ -636,14 +637,20 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
|
|
||||||
for (final String facilityType : newlyEnabledFacilityTypes) {
|
for (final String facilityType : newlyEnabledFacilityTypes) {
|
||||||
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
|
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
|
||||||
if (mappingOpt.isEmpty()) {
|
// An inactive mapping is treated the same as an unmapped one -- see FacilityChargeAutoApplyService's
|
||||||
log.warn("[FacilityCharge] {} newly-enabled via edit-approval but NO mapping found in m_facility_charge_mapping",
|
// requireMappedCharge for why.
|
||||||
|
if (mappingOpt.isEmpty() || mappingOpt.get().getCharge() == null || !Boolean.TRUE.equals(mappingOpt.get().getIsActive())) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped",
|
||||||
|
"A charge has not been mapped for the '" + facilityType
|
||||||
|
+ "' facility. Configure it under Products > Facility Charge Mappings before enabling it.",
|
||||||
facilityType);
|
facilityType);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
|
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
|
||||||
if (charge == null) {
|
if (charge.isDeleted() || !charge.isActive()) {
|
||||||
continue;
|
throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted",
|
||||||
|
"The charge mapped to the '" + facilityType
|
||||||
|
+ "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.",
|
||||||
|
facilityType);
|
||||||
}
|
}
|
||||||
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge =
|
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge =
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge);
|
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge);
|
||||||
|
|||||||
+3
-1
@@ -189,6 +189,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
|
|||||||
.append("ircd.id as ircdId, ircd.description as ircdDescription, ircd.period_type_enum ircdPeriodTypeId, ")
|
.append("ircd.id as ircdId, ircd.description as ircdDescription, ircd.period_type_enum ircdPeriodTypeId, ")
|
||||||
.append("ircd.from_period as ircdFromPeriod, ircd.to_period as ircdToPeriod, ircd.amount_range_from as ircdAmountRangeFrom, ")
|
.append("ircd.from_period as ircdFromPeriod, ircd.to_period as ircdToPeriod, ircd.amount_range_from as ircdAmountRangeFrom, ")
|
||||||
.append("ircd.amount_range_to as ircdAmountRangeTo, ircd.annual_interest_rate as ircdAnnualInterestRate, ")
|
.append("ircd.amount_range_to as ircdAmountRangeTo, ircd.annual_interest_rate as ircdAnnualInterestRate, ")
|
||||||
|
.append("ircd.senior_citizen_rate as ircdSeniorCitizenRate, ")
|
||||||
.append("curr.code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
.append("curr.code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
||||||
.append("curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
.append("curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
||||||
.append("sa.id as accountId, sa.account_no as accountNumber, ").append("iri.id as iriId, ")
|
.append("sa.id as accountId, sa.account_no as accountNumber, ").append("iri.id as iriId, ")
|
||||||
@@ -346,6 +347,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
|
|||||||
final BigDecimal amountRangeFrom = rs.getBigDecimal("ircdAmountRangeFrom");
|
final BigDecimal amountRangeFrom = rs.getBigDecimal("ircdAmountRangeFrom");
|
||||||
final BigDecimal amountRangeTo = rs.getBigDecimal("ircdAmountRangeTo");
|
final BigDecimal amountRangeTo = rs.getBigDecimal("ircdAmountRangeTo");
|
||||||
final BigDecimal annualInterestRate = rs.getBigDecimal("ircdAnnualInterestRate");
|
final BigDecimal annualInterestRate = rs.getBigDecimal("ircdAnnualInterestRate");
|
||||||
|
final BigDecimal seniorCitizenRate = rs.getBigDecimal("ircdSeniorCitizenRate");
|
||||||
|
|
||||||
// currency Slabs
|
// currency Slabs
|
||||||
final String currencyCode = rs.getString("currencyCode");
|
final String currencyCode = rs.getString("currencyCode");
|
||||||
@@ -359,7 +361,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
|
|||||||
currencyNameCode);
|
currencyNameCode);
|
||||||
|
|
||||||
return DepositAccountInterestRateChartSlabData.instance(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
|
return DepositAccountInterestRateChartSlabData.instance(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
|
||||||
amountRangeTo, annualInterestRate, currency);
|
amountRangeTo, annualInterestRate, currency, seniorCitizenRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -46,6 +46,12 @@ public interface DepositAccountReadPlatformService {
|
|||||||
|
|
||||||
DepositAccountData retrieveOneWithClosureTemplate(DepositAccountType depositAccountType, Long accountId);
|
DepositAccountData retrieveOneWithClosureTemplate(DepositAccountType depositAccountType, Long accountId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #retrieveOneWithClosureTemplate}, but for the FD Payment action -- so its option list also carries
|
||||||
|
* PARTIAL_REINVEST, which the closure commands deliberately exclude because only Payment implements it.
|
||||||
|
*/
|
||||||
|
DepositAccountData retrieveOneWithPaymentTemplate(DepositAccountType depositAccountType, Long accountId);
|
||||||
|
|
||||||
DepositAccountData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId);
|
DepositAccountData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId);
|
||||||
|
|
||||||
Collection<SavingsAccountTransactionData> retrieveAllTransactions(DepositAccountType depositAccountType, Long accountId);
|
Collection<SavingsAccountTransactionData> retrieveAllTransactions(DepositAccountType depositAccountType, Long accountId);
|
||||||
|
|||||||
+182
-3
@@ -321,6 +321,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
|||||||
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
|
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) {
|
||||||
|
applyReinvestmentLinks((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account, accountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return account;
|
return account;
|
||||||
|
|
||||||
@@ -329,6 +332,158 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates the "reinvested from" / "reinvested into" links on a Fixed Deposit account, so the
|
||||||
|
* account-view page can show either direction of a "Re-Invest Maturity Amount"/"Re-Invest
|
||||||
|
* Principal Only" closure. There is only one column (reinvested_from_savings_id, set on the NEW
|
||||||
|
* account) -- "reinvested into" is found on the OLD account by reverse-looking up whichever row
|
||||||
|
* points at it.
|
||||||
|
*/
|
||||||
|
private void applyReinvestmentLinks(final org.apache.fineract.portfolio.savings.data.FixedDepositAccountData result,
|
||||||
|
final Long accountId) {
|
||||||
|
final String fromSql = "select src.id as id, src.account_no as accountNo "
|
||||||
|
+ "from m_deposit_account_term_and_preclosure t "
|
||||||
|
+ "join m_savings_account src on src.id = t.reinvested_from_savings_id " + "where t.savings_account_id = ?";
|
||||||
|
final String intoSql = "select dest.id as id, dest.account_no as accountNo "
|
||||||
|
+ "from m_deposit_account_term_and_preclosure t " + "join m_savings_account dest on dest.id = t.savings_account_id "
|
||||||
|
+ "where t.reinvested_from_savings_id = ?";
|
||||||
|
|
||||||
|
Long fromId = null;
|
||||||
|
String fromNo = null;
|
||||||
|
final java.util.List<java.util.Map<String, Object>> fromRows = this.jdbcTemplate.queryForList(fromSql, accountId);
|
||||||
|
if (!fromRows.isEmpty()) {
|
||||||
|
fromId = ((Number) fromRows.get(0).get("id")).longValue();
|
||||||
|
fromNo = (String) fromRows.get(0).get("accountNo");
|
||||||
|
}
|
||||||
|
|
||||||
|
Long intoId = null;
|
||||||
|
String intoNo = null;
|
||||||
|
final java.util.List<java.util.Map<String, Object>> intoRows = this.jdbcTemplate.queryForList(intoSql, accountId);
|
||||||
|
if (!intoRows.isEmpty()) {
|
||||||
|
intoId = ((Number) intoRows.get(0).get("id")).longValue();
|
||||||
|
intoNo = (String) intoRows.get(0).get("accountNo");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromId != null || intoId != null) {
|
||||||
|
result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The number to actually show as "the FD account number" -- the original account's account_no
|
||||||
|
// if this account has been through a renewal, otherwise this account's own account_no.
|
||||||
|
final String termSql = "select fd_account_number, renewal_cycle_no from m_deposit_account_term_and_preclosure "
|
||||||
|
+ "where savings_account_id = ?";
|
||||||
|
final java.util.List<java.util.Map<String, Object>> termRows = this.jdbcTemplate.queryForList(termSql, accountId);
|
||||||
|
final String fdAccountNumber = termRows.isEmpty() ? null : (String) termRows.get(0).get("fd_account_number");
|
||||||
|
final Object cycleNo = termRows.isEmpty() ? null : termRows.get(0).get("renewal_cycle_no");
|
||||||
|
final String rootFdAccountNumber = fdAccountNumber != null ? fdAccountNumber : result.getAccountNo();
|
||||||
|
result.setFdAccountNumber(rootFdAccountNumber);
|
||||||
|
result.setRenewalCycleNo(cycleNo == null ? 0 : ((Number) cycleNo).intValue());
|
||||||
|
result.setRenewalHistory(retrieveRenewalHistory(rootFdAccountNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every term of one logical FD, oldest first.
|
||||||
|
* <p>
|
||||||
|
* Driven by m_fd_renewal_history, which records each rollover event -- interest the closing term earned, what it
|
||||||
|
* matured at, how much carried forward, the rate applied, automatic or by a user -- rather than deriving the chain by
|
||||||
|
* joining account rows, which cannot express any of that. The account row is joined in for its number and live
|
||||||
|
* status. Returns an empty list for an FD that has never been renewed, so callers can treat "has renewal history" as
|
||||||
|
* simply a non-empty list.
|
||||||
|
*/
|
||||||
|
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistory(final String fdAccountNumber) {
|
||||||
|
// Every row states its own term's figures, so this is a straight read: interest_earned and
|
||||||
|
// maturity_amount are filled in when that term closes, and stay null on the term still running,
|
||||||
|
// which the caller renders as its projected maturity instead.
|
||||||
|
final String sql = "select h.id as historyId, h.renewal_no as renewalCycleNo, h.savings_account_id as accountId, "
|
||||||
|
+ "sa.account_no as accountNo, h.renewed_amount as depositAmount, h.interest_rate as interestRate, "
|
||||||
|
+ "h.start_date as activatedOnDate, h.maturity_date as maturityDate, h.tenure as tenure, "
|
||||||
|
+ "h.tenure_frequency_enum as tenureFrequencyEnum, "
|
||||||
|
+ "h.renewal_type as renewalType, h.renewal_status as renewalStatus, sa.status_enum as statusEnum, "
|
||||||
|
+ "h.interest_earned as interestEarned, h.maturity_amount as maturedAmount, "
|
||||||
|
+ "t.maturity_amount as projectedMaturityAmount " + "from m_fd_renewal_history h "
|
||||||
|
+ "join m_savings_account sa on sa.id = h.savings_account_id "
|
||||||
|
+ "left join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
|
||||||
|
+ "where h.fd_account_number = ? " + "order by h.renewal_no asc";
|
||||||
|
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber);
|
||||||
|
// Any row here is a renewal event that was actually recorded, so even one is worth returning --
|
||||||
|
// discarding a lone row used to hide a genuine renewal (and its interest figure) behind the
|
||||||
|
// account-derived fallback, which then returned nothing at all for it.
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
attachArchivedChartSlabs(rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
// Chains that renewed before m_fd_renewal_history existed have no rows to read, so fall back to
|
||||||
|
// reconstructing what the account rows alone can tell us. The per-term interest/renewal-type
|
||||||
|
// columns are simply absent for those, which the UI renders as blanks rather than wrong values.
|
||||||
|
return retrieveRenewalHistoryFromAccounts(fdAccountNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches each term's archived rate-by-tenure chart (as a "chartSlabs" list) onto its own row.
|
||||||
|
* <p>
|
||||||
|
* A term's chart is only archived when it closes via an in-place renewal (see
|
||||||
|
* {@code DepositAccountDomainServiceJpa#archiveChartSlabs}), so the currently-running term and any term from before
|
||||||
|
* that archiving existed simply get an empty list here -- callers should fall back to the account's live
|
||||||
|
* accountChart.chartSlabs for those. Fetched in one batched query keyed by history id, not one query per row, since
|
||||||
|
* this runs once per FD account view.
|
||||||
|
*/
|
||||||
|
private void attachArchivedChartSlabs(final java.util.List<java.util.Map<String, Object>> rows) {
|
||||||
|
final java.util.List<Long> historyIds = new java.util.ArrayList<>();
|
||||||
|
for (final java.util.Map<String, Object> row : rows) {
|
||||||
|
final Object id = row.get("historyId");
|
||||||
|
if (id != null) {
|
||||||
|
historyIds.add(((Number) id).longValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (historyIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String placeholders = String.join(",", java.util.Collections.nCopies(historyIds.size(), "?"));
|
||||||
|
final String sql = "select fd_renewal_history_id as historyId, chart_name as chartName, description, "
|
||||||
|
+ "period_type_enum as periodTypeEnum, from_period as fromPeriod, to_period as toPeriod, "
|
||||||
|
+ "annual_interest_rate as annualInterestRate, senior_citizen_rate as seniorCitizenRate "
|
||||||
|
+ "from m_fd_renewal_chart_slab_history " + "where fd_renewal_history_id in (" + placeholders + ") "
|
||||||
|
+ "order by from_period asc";
|
||||||
|
final java.util.List<java.util.Map<String, Object>> slabRows = this.jdbcTemplate.queryForList(sql, historyIds.toArray());
|
||||||
|
|
||||||
|
final java.util.Map<Long, java.util.List<java.util.Map<String, Object>>> slabsByHistoryId = new java.util.HashMap<>();
|
||||||
|
for (final java.util.Map<String, Object> slabRow : slabRows) {
|
||||||
|
final Long historyId = ((Number) slabRow.remove("historyId")).longValue();
|
||||||
|
slabsByHistoryId.computeIfAbsent(historyId, key -> new java.util.ArrayList<>()).add(slabRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final java.util.Map<String, Object> row : rows) {
|
||||||
|
final Object id = row.get("historyId");
|
||||||
|
final Long historyId = id == null ? null : ((Number) id).longValue();
|
||||||
|
row.put("chartSlabs", slabsByHistoryId.getOrDefault(historyId, java.util.Collections.emptyList()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistoryFromAccounts(final String fdAccountNumber) {
|
||||||
|
// Deliberately emits the SAME column labels as retrieveRenewalHistory above, so a caller never has
|
||||||
|
// to know which path produced the rows. What the account rows genuinely cannot tell us -- each
|
||||||
|
// term's interest earned, and whether the rollover was automatic or done by staff -- is returned as
|
||||||
|
// an explicit null rather than omitted, so those render as blanks instead of the previous behaviour
|
||||||
|
// where a missing renewalStatus made every term, including the running one, look matured.
|
||||||
|
final String sql = "select null as historyId, coalesce(t.renewal_cycle_no, 0) as renewalCycleNo, "
|
||||||
|
+ "sa.id as accountId, sa.account_no as accountNo, t.deposit_amount as depositAmount, "
|
||||||
|
+ "sa.nominal_annual_interest_rate as interestRate, sa.activatedon_date as activatedOnDate, "
|
||||||
|
+ "t.maturity_date as maturityDate, t.deposit_period as tenure, "
|
||||||
|
+ "t.deposit_period_frequency_enum as tenureFrequencyEnum, null as renewalType, "
|
||||||
|
+ "case when sa.status_enum = 300 then 'ACTIVE' else 'MATURED' end as renewalStatus, "
|
||||||
|
+ "sa.status_enum as statusEnum, null as interestEarned, "
|
||||||
|
+ "case when sa.status_enum = 300 then null else t.maturity_amount end as maturedAmount, "
|
||||||
|
+ "case when sa.status_enum = 300 then t.maturity_amount else null end as projectedMaturityAmount "
|
||||||
|
+ "from m_savings_account sa "
|
||||||
|
+ "join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
|
||||||
|
+ "where sa.account_no = ? or t.fd_account_number = ? " + "order by coalesce(t.renewal_cycle_no, 0) asc";
|
||||||
|
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber,
|
||||||
|
fdAccountNumber);
|
||||||
|
// A single row here means this FD simply never renewed -- one account, one term -- so there is no
|
||||||
|
// history to show. Unlike the primary path, where even one recorded row is a real renewal event.
|
||||||
|
return rows.size() > 1 ? rows : java.util.Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
private java.util.List<java.util.Map<String, Object>> retrieveJointHolders(final Long accountId) {
|
private java.util.List<java.util.Map<String, Object>> retrieveJointHolders(final Long accountId) {
|
||||||
final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
|
final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
|
||||||
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
|
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
|
||||||
@@ -349,8 +504,30 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
|||||||
}, accountId);
|
}, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closure options for the {@code close} and {@code prematureClose} commands. PARTIAL_REINVEST is excluded: only the
|
||||||
|
* Payment command's handler implements it, and only its dialog collects the kept-amount and destination account it
|
||||||
|
* needs. Offered here it would appear in the FD and RD close dialogs, where the closure handlers have no branch for
|
||||||
|
* it and would fall through to paying the whole balance out in cash.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) {
|
public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) {
|
||||||
|
final boolean includePartialReinvest = false;
|
||||||
|
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same template, but including PARTIAL_REINVEST -- for the Payment command, which is the one action that
|
||||||
|
* implements it.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public DepositAccountData retrieveOneWithPaymentTemplate(final DepositAccountType depositAccountType, final Long accountId) {
|
||||||
|
final boolean includePartialReinvest = true;
|
||||||
|
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId,
|
||||||
|
final boolean includePartialReinvest) {
|
||||||
try {
|
try {
|
||||||
this.context.authenticatedUser();
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
@@ -367,7 +544,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
|||||||
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
|
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
|
||||||
depositAccountType.getValue());
|
depositAccountType.getValue());
|
||||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
|
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
|
||||||
.depositAccountOnClosureType(DepositAccountOnClosureType.values());
|
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
|
||||||
|
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
|
||||||
|
.toArray(DepositAccountOnClosureType[]::new));
|
||||||
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
|
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
|
||||||
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
|
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
|
||||||
.retrieveActiveForLookup(account.getClientId(), DepositAccountType.SAVINGS_DEPOSIT);
|
.retrieveActiveForLookup(account.getClientId(), DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
@@ -554,7 +733,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
|||||||
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
|
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
|
||||||
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
|
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
|
||||||
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
|
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
|
||||||
periodFrequencyTypeOptions);
|
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
|
||||||
template = RecurringDepositAccountData.withInterestChartAndRecurringDetails((RecurringDepositAccountData) template,
|
template = RecurringDepositAccountData.withInterestChartAndRecurringDetails((RecurringDepositAccountData) template,
|
||||||
accountChart, null, null);
|
accountChart, null, null);
|
||||||
// Re-apply CBS product fields lost during factory method object creation
|
// Re-apply CBS product fields lost during factory method object creation
|
||||||
@@ -626,7 +805,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
|||||||
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
|
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
|
||||||
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
|
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
|
||||||
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
|
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
|
||||||
periodFrequencyTypeOptions);
|
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+163
-2
@@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
@@ -106,6 +107,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrap
|
|||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
|
||||||
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
|
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
|
||||||
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
|
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
|
||||||
import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException;
|
import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException;
|
||||||
@@ -560,6 +562,72 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FD Special (monthly interest payout) shows customers/staff exactly how much interest is projected to
|
||||||
|
* post each month across the account's whole term, rather than a flat rate/12 approximation -- computed
|
||||||
|
* with the real PostingPeriod engine (same one the actual maturity calculation uses) via
|
||||||
|
* FixedDepositAccount#projectInterestSchedule, which is deliberately side-effect-free. readOnly=true
|
||||||
|
* overrides this class's default @Transactional so nothing from that computation is ever flushed.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> retrieveProjectedInterestSchedule(final Long savingsId, final DepositAccountType depositAccountType) {
|
||||||
|
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType);
|
||||||
|
if (!(account instanceof FixedDepositAccount)) {
|
||||||
|
return java.util.Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||||
|
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||||
|
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||||
|
final MathContext mc = new MathContext(15, MoneyHelper.getRoundingMode());
|
||||||
|
|
||||||
|
final FixedDepositAccount fdAccount = (FixedDepositAccount) account;
|
||||||
|
final List<PostingPeriod> postingPeriods = fdAccount.projectInterestSchedule(mc, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
|
||||||
|
// determineInterestPostingPeriods always rounds the LAST period out to the full natural boundary
|
||||||
|
// (e.g. calendar month-end for Monthly posting) even when the account's own maturity date falls
|
||||||
|
// partway through it -- the INTEREST AMOUNT for that period is still correctly computed only up to
|
||||||
|
// the real cutoff (interestCalculationUpto = maturityDate - 1, the same day postMaturityInterest
|
||||||
|
// clamps its own posting date to), but the period's displayed end-date is not, and would otherwise
|
||||||
|
// show a "To Date" past the account's actual maturity date. Clip it here so the schedule shown
|
||||||
|
// never claims interest for days beyond maturity, matching the money that is actually calculated.
|
||||||
|
final LocalDate lastCalculableDate = fdAccount.maturityDate() != null ? fdAccount.maturityDate().minusDays(1) : null;
|
||||||
|
|
||||||
|
// A period is "paid" once a real (non-reversed) interest-posting transaction actually landed for it.
|
||||||
|
// Matched EXACTLY against that period's own end date (Fineract can date the posting transaction
|
||||||
|
// either on the period's own last day, or on the first day of the following period, depending on
|
||||||
|
// the isSavingsInterestPostingAtCurrentPeriodEnd configuration) -- not a date RANGE, which would
|
||||||
|
// wrongly also match the very next period (whose own start date is that same "day after").
|
||||||
|
final List<LocalDate> postedInterestDates = new ArrayList<>();
|
||||||
|
for (final SavingsAccountTransaction transaction : fdAccount.getTransactions()) {
|
||||||
|
if (transaction.isInterestPostingAndNotReversed()) {
|
||||||
|
postedInterestDates.add(transaction.getTransactionDate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> schedule = new ArrayList<>();
|
||||||
|
for (final PostingPeriod period : postingPeriods) {
|
||||||
|
final LocalDate fromDate = period.getPeriodInterval().startDate();
|
||||||
|
LocalDate toDate = period.getPeriodInterval().endDate();
|
||||||
|
if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) {
|
||||||
|
toDate = lastCalculableDate;
|
||||||
|
}
|
||||||
|
final LocalDate toDateFinal = toDate;
|
||||||
|
final boolean paid = postedInterestDates.stream()
|
||||||
|
.anyMatch(postedDate -> postedDate.isEqual(toDateFinal) || postedDate.isEqual(toDateFinal.plusDays(1)));
|
||||||
|
|
||||||
|
final Map<String, Object> row = new HashMap<>();
|
||||||
|
row.put("fromDate", fromDate);
|
||||||
|
row.put("toDate", toDateFinal);
|
||||||
|
row.put("interestAmount", period.getInterestEarned().getAmount());
|
||||||
|
row.put("paid", paid);
|
||||||
|
schedule.add(row);
|
||||||
|
}
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult postInterest(final Long savingsId, final DepositAccountType depositAccountType) {
|
public CommandProcessingResult postInterest(final Long savingsId, final DepositAccountType depositAccountType) {
|
||||||
@@ -912,6 +980,67 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommandProcessingResult processFdPayment(final Long savingsId, final JsonCommand command) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
|
||||||
|
this.depositAccountTransactionDataValidator.validatePayment(command);
|
||||||
|
|
||||||
|
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||||
|
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
|
||||||
|
|
||||||
|
// Payment intentionally spans both states -- unlike Close (Matured only) and Premature Close
|
||||||
|
// (Active only) -- but it must still be one of those two. Without this, a closed or
|
||||||
|
// pre-maturely-closed deposit could be reopened: the reinvest branches put the account back to
|
||||||
|
// ACTIVE and never reach the close()/prematureClosure() calls that would otherwise reject it.
|
||||||
|
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
|
||||||
|
DepositAccountType.FIXED_DEPOSIT);
|
||||||
|
if (!account.isActive() && !account.isMatured()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.account.not.active.or.matured",
|
||||||
|
"Payment can only be processed on an active or matured fixed deposit account.", savingsId);
|
||||||
|
}
|
||||||
|
checkClientOrGroupActive(account);
|
||||||
|
|
||||||
|
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
|
||||||
|
|
||||||
|
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
|
||||||
|
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
|
||||||
|
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
|
||||||
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
|
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
||||||
|
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
||||||
|
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
|
||||||
|
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
|
||||||
|
final Money activationChargeAmount = getActivationCharge(reinvestAccount);
|
||||||
|
if (activationChargeAmount.isGreaterThanZero()) {
|
||||||
|
payActivationCharge(reinvestAccount);
|
||||||
|
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
|
||||||
|
}
|
||||||
|
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale());
|
||||||
|
final LocalDate closedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
|
||||||
|
final boolean isInternalTransferLeg = true;
|
||||||
|
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, closedOnDate, amountForDeposit.getAmount(), null,
|
||||||
|
isInternalTransferLeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String noteText = command.stringValueOfParameterNamed("note");
|
||||||
|
if (StringUtils.isNotBlank(noteText)) {
|
||||||
|
final Note note = Note.savingNote(account, noteText);
|
||||||
|
changes.put("note", noteText);
|
||||||
|
this.noteRepository.save(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withEntityId(savingsId) //
|
||||||
|
.withOfficeId(account.officeId()) //
|
||||||
|
.withClientId(account.clientId()) //
|
||||||
|
.withGroupId(account.groupId()) //
|
||||||
|
.withSavingsId(savingsId) //
|
||||||
|
.with(changes)//
|
||||||
|
.build();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
|
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
|
||||||
final AppUser user = this.context.authenticatedUser();
|
final AppUser user = this.context.authenticatedUser();
|
||||||
@@ -1393,18 +1522,37 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
|
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
|
||||||
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
|
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
|
||||||
|
|
||||||
|
// Every maturity-instruction branch takes the balance out of this account through
|
||||||
|
// SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal
|
||||||
|
// entries itself. existingTransactionIds was snapshotted at the top of this method --
|
||||||
|
// before that withdrawal existed -- so the postJournalEntries() call at the end would
|
||||||
|
// treat it as new and post it a second time. Interest postings created by
|
||||||
|
// updateMaturityStatus() above are still genuinely unposted at this point and must stay
|
||||||
|
// out of the snapshot so that final call can post them exactly once.
|
||||||
|
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
|
||||||
|
|
||||||
if (changes.get("reinvestedDepositId") != null) {
|
if (changes.get("reinvestedDepositId") != null) {
|
||||||
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
|
||||||
Money amountForDeposit = account.activateWithBalance();
|
|
||||||
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
|
||||||
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
|
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
|
||||||
|
// activateWithBalance() always returns zero here -- it exists to stop a manually
|
||||||
|
// opened FD from auto-crediting itself at activation (funding is supposed to be a
|
||||||
|
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
|
||||||
|
// withdrawn from the matured account and needs to land in the new one as part of
|
||||||
|
// this same automated closure, so it has to come from what reInvest() actually
|
||||||
|
// carried over onto the new account, not from that unrelated activation guard.
|
||||||
|
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
|
||||||
Money activationChargeAmount = getActivationCharge(reinvestAccount);
|
Money activationChargeAmount = getActivationCharge(reinvestAccount);
|
||||||
if (activationChargeAmount.isGreaterThanZero()) {
|
if (activationChargeAmount.isGreaterThanZero()) {
|
||||||
payActivationCharge(reinvestAccount);
|
payActivationCharge(reinvestAccount);
|
||||||
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
|
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
|
||||||
}
|
}
|
||||||
|
// Matching internal-transfer leg for the withdrawal the closure just made on the
|
||||||
|
// matured account: the funds never touch cash, so this posts against the
|
||||||
|
// transfer-suspense GL instead of debiting cash/vault.
|
||||||
|
final boolean isInternalTransferLeg = true;
|
||||||
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(),
|
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(),
|
||||||
amountForDeposit.getAmount(), null);
|
amountForDeposit.getAmount(), null, isInternalTransferLeg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (depositAccountType.isRecurringDeposit()) {
|
} else if (depositAccountType.isRecurringDeposit()) {
|
||||||
@@ -1421,6 +1569,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
existingReversedTransactionIds.addAll(account.findExistingReversedTransactionIds());
|
existingReversedTransactionIds.addAll(account.findExistingReversedTransactionIds());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the account's withdrawal (and withdrawal-fee) transactions as already accounted for, so a
|
||||||
|
* subsequent postJournalEntries() built on an older snapshot does not post them again. Used after a
|
||||||
|
* maturity closure, whose withdrawal leg posts its own journal entries.
|
||||||
|
*/
|
||||||
|
private void markWithdrawalsAsAlreadyPosted(final SavingsAccount account, final Set<Long> existingTransactionIds) {
|
||||||
|
for (final SavingsAccountTransaction transaction : account.getTransactions()) {
|
||||||
|
if (transaction.getId() != null && (transaction.isWithdrawal() || transaction.isWithdrawalFee())) {
|
||||||
|
existingTransactionIds.add(transaction.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void postJournalEntries(final SavingsAccount savingsAccount, final Set<Long> existingTransactionIds,
|
private void postJournalEntries(final SavingsAccount savingsAccount, final Set<Long> existingTransactionIds,
|
||||||
final Set<Long> existingReversedTransactionIds) {
|
final Set<Long> existingReversedTransactionIds) {
|
||||||
|
|
||||||
|
|||||||
+25
@@ -80,6 +80,7 @@ import org.apache.fineract.portfolio.group.exception.GroupNotFoundException;
|
|||||||
import org.apache.fineract.portfolio.note.domain.Note;
|
import org.apache.fineract.portfolio.note.domain.Note;
|
||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||||
|
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
|
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
|
||||||
@@ -429,11 +430,18 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
|||||||
final Long savingsAccountId = command.longValueOfParameterNamed(DepositsApiConstants.linkedAccountParamName);
|
final Long savingsAccountId = command.longValueOfParameterNamed(DepositsApiConstants.linkedAccountParamName);
|
||||||
AccountAssociations accountAssociations = this.accountAssociationsRepository.findBySavingsIdAndType(accountId,
|
AccountAssociations accountAssociations = this.accountAssociationsRepository.findBySavingsIdAndType(accountId,
|
||||||
AccountAssociationType.LINKED_ACCOUNT_ASSOCIATION.getValue());
|
AccountAssociationType.LINKED_ACCOUNT_ASSOCIATION.getValue());
|
||||||
|
// Tracks whichever savings account ends up linked once this whole block finishes, across every
|
||||||
|
// branch below (kept as-is, removed, or replaced) -- used afterwards to re-enforce FD Special's
|
||||||
|
// "must always have a linked account" requirement regardless of what this request touched.
|
||||||
|
Long resolvedLinkedAccountId = accountAssociations != null && accountAssociations.linkedSavingsAccount() != null
|
||||||
|
? accountAssociations.linkedSavingsAccount().getId()
|
||||||
|
: null;
|
||||||
if (savingsAccountId == null) {
|
if (savingsAccountId == null) {
|
||||||
if (accountAssociations != null) {
|
if (accountAssociations != null) {
|
||||||
if (this.fromJsonHelper.parameterExists(DepositsApiConstants.linkedAccountParamName, command.parsedJson())) {
|
if (this.fromJsonHelper.parameterExists(DepositsApiConstants.linkedAccountParamName, command.parsedJson())) {
|
||||||
this.accountAssociationsRepository.delete(accountAssociations);
|
this.accountAssociationsRepository.delete(accountAssociations);
|
||||||
changes.put(DepositsApiConstants.linkedAccountParamName, null);
|
changes.put(DepositsApiConstants.linkedAccountParamName, null);
|
||||||
|
resolvedLinkedAccountId = null;
|
||||||
if (isLinkedAccRequired) {
|
if (isLinkedAccRequired) {
|
||||||
this.depositAccountDataValidator.throwLinkedAccountRequiredError();
|
this.depositAccountDataValidator.throwLinkedAccountRequiredError();
|
||||||
}
|
}
|
||||||
@@ -465,6 +473,23 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
|||||||
changes.put(DepositsApiConstants.linkedAccountParamName, savingsAccountId);
|
changes.put(DepositsApiConstants.linkedAccountParamName, savingsAccountId);
|
||||||
this.accountAssociationsRepository.save(accountAssociations);
|
this.accountAssociationsRepository.save(accountAssociations);
|
||||||
}
|
}
|
||||||
|
resolvedLinkedAccountId = savingsAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FD Special is a non-cumulative, monthly-interest-payout product: DepositAccountTermAndPreClosure#update
|
||||||
|
// above has no fdType awareness and will happily unset transferInterestToLinkedAccount,
|
||||||
|
// onAccountClosureType and transferToSavingsAccountId on this same request -- including by mere
|
||||||
|
// omission of maturityInstructionId/transferToSavingsId from the payload -- silently turning a SPECIAL
|
||||||
|
// FD into a plain cumulative one while it still displays as "FD Special". Re-force the three fields
|
||||||
|
// here, now that the linked-account resolution above has settled what account (if any) is linked.
|
||||||
|
if ("SPECIAL".equalsIgnoreCase(account.fdType())) {
|
||||||
|
if (resolvedLinkedAccountId == null) {
|
||||||
|
this.depositAccountDataValidator.throwLinkedAccountRequiredError();
|
||||||
|
}
|
||||||
|
account.forceSpecialFdMonthlyPayoutSettings(resolvedLinkedAccountId);
|
||||||
|
changes.put(transferInterestToSavingsParamName, true);
|
||||||
|
changes.put(DepositsApiConstants.maturityInstructionIdParamName, DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue());
|
||||||
|
this.savingAccountRepository.save(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace nominees if provided
|
// Replace nominees if provided
|
||||||
|
|||||||
+35
-2
@@ -119,7 +119,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
|||||||
} else {
|
} else {
|
||||||
correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated());
|
correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated());
|
||||||
}
|
}
|
||||||
if (correctionRequired) {
|
if (correctionRequired && !isInterestAlreadySweptOut(interestPostingTransactionDate, savingsAccountData)) {
|
||||||
boolean applyWithHoldTaxForOldTransaction = false;
|
boolean applyWithHoldTaxForOldTransaction = false;
|
||||||
postingTransaction.reverse();
|
postingTransaction.reverse();
|
||||||
|
|
||||||
@@ -185,6 +185,28 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
|||||||
return savingsAccountData;
|
return savingsAccountData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors {@code FixedDepositAccount#interestAlreadyPaidOut}: for an FD Special account whose interest sweeps
|
||||||
|
* out to a linked savings account, a withdrawal dated the same day as an interest posting means that period's
|
||||||
|
* interest has already left the account (see DepositAccountReadPlatformService#retrieveDataForInterestTransfer,
|
||||||
|
* which pairs them by date). Recalculating and reposting it here -- as this batch job otherwise would for
|
||||||
|
* every account, FD or not -- would silently disagree with money that has already moved. Scoped to FD Special
|
||||||
|
* only, so plain savings and recurring deposit batch posting is unaffected.
|
||||||
|
*/
|
||||||
|
private boolean isInterestAlreadySweptOut(final LocalDate interestPostingTransactionDate, final SavingsAccountData savingsAccountData) {
|
||||||
|
if (!savingsAccountData.isTransferInterestToLinkedAccount()
|
||||||
|
|| !DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (final SavingsAccountTransactionData transaction : savingsAccountData.getSavingsAccountTransactionData()) {
|
||||||
|
if (transaction.isWithdrawal() && transaction.isNotReversed()
|
||||||
|
&& DateUtils.isEqual(transaction.getTransactionDate(), interestPostingTransactionDate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
protected SavingsAccountTransactionData findTransactionFor(final LocalDate postingDate,
|
protected SavingsAccountTransactionData findTransactionFor(final LocalDate postingDate,
|
||||||
final List<SavingsAccountTransactionData> transactions) {
|
final List<SavingsAccountTransactionData> transactions) {
|
||||||
SavingsAccountTransactionData transaction = null;
|
SavingsAccountTransactionData transaction = null;
|
||||||
@@ -341,8 +363,19 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deposit that sweeps its interest out to a linked savings account every period (FD Special)
|
||||||
|
// must never compound: the interest does not stay in the account, so each period earns on the
|
||||||
|
// principal alone. This used to be hard-coded false here, which made this batch engine disagree
|
||||||
|
// with the domain engine (SavingsAccount#calculateInterestUsing, which passes the real flag) --
|
||||||
|
// the same account could be posted a compounded amount by the nightly job and a simple amount by
|
||||||
|
// its own maturity run, minutes apart. Deliberately scoped to fixed deposits so recurring
|
||||||
|
// deposits keep their existing behaviour.
|
||||||
|
// getDepositTypeId() rather than depositAccountType(), which dereferences the enum unguarded --
|
||||||
|
// this runs per account inside a batch job, so a null deposit type must not take the whole run down.
|
||||||
|
final boolean interestTransferEnabled = savingsAccountData.isTransferInterestToLinkedAccount()
|
||||||
|
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
|
||||||
this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods,
|
this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods,
|
||||||
getLockedInUntilLocalDate(savingsAccountData), false);
|
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled);
|
||||||
|
|
||||||
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
|
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
|
||||||
|
|
||||||
|
|||||||
+14
@@ -349,6 +349,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
final StringBuilder sqlBuilder = new StringBuilder(400);
|
final StringBuilder sqlBuilder = new StringBuilder(400);
|
||||||
sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, ");
|
sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, ");
|
||||||
sqlBuilder.append("sa.deposit_type_enum as depositType, ");
|
sqlBuilder.append("sa.deposit_type_enum as depositType, ");
|
||||||
|
sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, ");
|
||||||
sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, ");
|
sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, ");
|
||||||
sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, ");
|
sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, ");
|
||||||
sqlBuilder.append("sa.status_enum as statusEnum, ");
|
sqlBuilder.append("sa.status_enum as statusEnum, ");
|
||||||
@@ -446,6 +447,12 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 ");
|
sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 ");
|
||||||
sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 ");
|
sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 ");
|
||||||
sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 ");
|
sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 ");
|
||||||
|
// Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a
|
||||||
|
// cumulative one: interest that is swept out to a linked account each month must never
|
||||||
|
// compound, and without this flag the engine compounded it. Left join because ordinary
|
||||||
|
// savings accounts have no term-and-preclosure row at all.
|
||||||
|
sqlBuilder.append(
|
||||||
|
"left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id ");
|
||||||
|
|
||||||
this.schemaSql = sqlBuilder.toString();
|
this.schemaSql = sqlBuilder.toString();
|
||||||
}
|
}
|
||||||
@@ -668,6 +675,9 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
|
|
||||||
savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings);
|
savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings);
|
||||||
savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl);
|
savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl);
|
||||||
|
// Monthly-payout deposits must not compound: their interest leaves the account every
|
||||||
|
// period, so each period earns on the principal alone.
|
||||||
|
savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!transMap.containsValue(transactionId)) {
|
if (!transMap.containsValue(transactionId)) {
|
||||||
@@ -873,6 +883,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, ");
|
sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, ");
|
||||||
sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, ");
|
sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, ");
|
||||||
sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, ");
|
sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, ");
|
||||||
|
sqlBuilder.append("sa.share_account_id as shareAccountId, msha.account_no as shareAccountNo, ");
|
||||||
// Facility config fields
|
// Facility config fields
|
||||||
sqlBuilder.append("safc.atm_facility as atmFacility, ");
|
sqlBuilder.append("safc.atm_facility as atmFacility, ");
|
||||||
sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, ");
|
sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, ");
|
||||||
@@ -905,6 +916,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid ");
|
sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid ");
|
||||||
sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id ");
|
sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id ");
|
||||||
sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id ");
|
sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id ");
|
||||||
|
sqlBuilder.append("left join m_share_account msha on msha.id = sa.share_account_id ");
|
||||||
|
|
||||||
this.schemaSql = sqlBuilder.toString();
|
this.schemaSql = sqlBuilder.toString();
|
||||||
}
|
}
|
||||||
@@ -1131,6 +1143,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null);
|
accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null);
|
||||||
accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null);
|
accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null);
|
||||||
accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null);
|
accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null);
|
||||||
|
accountData.setShareAccountId(JdbcSupport.getLong(rs, "shareAccountId"));
|
||||||
|
accountData.setShareAccountNo(rs.getString("shareAccountNo"));
|
||||||
accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null);
|
accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null);
|
||||||
|
|
||||||
// Nominee as parsed map
|
// Nominee as parsed map
|
||||||
|
|||||||
+151
-14
@@ -97,6 +97,8 @@ import org.apache.fineract.portfolio.note.domain.Note;
|
|||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||||
@@ -120,7 +122,6 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
|||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
||||||
import org.apache.fineract.portfolio.savings.exception.PostInterestAsOnDateException;
|
import org.apache.fineract.portfolio.savings.exception.PostInterestAsOnDateException;
|
||||||
import org.apache.fineract.portfolio.savings.exception.PostInterestAsOnDateException.PostInterestAsOnExceptionType;
|
import org.apache.fineract.portfolio.savings.exception.PostInterestAsOnDateException.PostInterestAsOnExceptionType;
|
||||||
import org.apache.fineract.portfolio.savings.exception.PostInterestClosingDateException;
|
|
||||||
import org.apache.fineract.portfolio.savings.exception.SavingsAccountClosingNotAllowedException;
|
import org.apache.fineract.portfolio.savings.exception.SavingsAccountClosingNotAllowedException;
|
||||||
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
|
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
|
||||||
import org.apache.fineract.portfolio.savings.exception.SavingsOfficerAssignmentException;
|
import org.apache.fineract.portfolio.savings.exception.SavingsOfficerAssignmentException;
|
||||||
@@ -140,6 +141,8 @@ import org.springframework.util.CollectionUtils;
|
|||||||
public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements SavingsAccountWritePlatformService {
|
public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements SavingsAccountWritePlatformService {
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
private static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION";
|
||||||
private final SavingsAccountDataValidator fromApiJsonDeserializer;
|
private final SavingsAccountDataValidator fromApiJsonDeserializer;
|
||||||
private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper;
|
private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper;
|
||||||
private final StaffRepositoryWrapper staffRepository;
|
private final StaffRepositoryWrapper staffRepository;
|
||||||
@@ -345,6 +348,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.noteRepository.save(note);
|
this.noteRepository.save(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Money arriving at the counter: cash is debited and the member's savings control account
|
||||||
|
// is credited.
|
||||||
|
final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true);
|
||||||
|
if (depositVoucherNo != null) {
|
||||||
|
changes.put("voucherNo", depositVoucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(deposit.getId()) //
|
.withEntityId(deposit.getId()) //
|
||||||
.withOfficeId(account.officeId()) //
|
.withOfficeId(account.officeId()) //
|
||||||
@@ -421,6 +431,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.noteRepository.save(note);
|
this.noteRepository.save(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Money leaving the member over the counter: their savings control account is debited and
|
||||||
|
// the till that pays it out is credited.
|
||||||
|
final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false);
|
||||||
|
if (withdrawalVoucherNo != null) {
|
||||||
|
changes.put("voucherNo", withdrawalVoucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(withdrawal.getId()) //
|
.withEntityId(withdrawal.getId()) //
|
||||||
.withOfficeId(account.officeId()) //
|
.withOfficeId(account.officeId()) //
|
||||||
@@ -585,6 +602,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||||
|
|
||||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
|
// Interest credited to the member, recorded against the transactions this run created.
|
||||||
|
// existingTransactionIds is what the account held before posting, so anything not in it
|
||||||
|
// is new — which is how a re-run over an account that already had interest posted last
|
||||||
|
// quarter raises a voucher for this quarter's credit only.
|
||||||
|
raiseInterestVouchers(account, existingTransactionIds, backdatedTxnsAllowedTill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +666,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForInterestReceivable());
|
accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForInterestReceivable());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestPayable());
|
accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestOnSavings());
|
||||||
accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForSavingsControl());
|
accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForSavingsControl());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -955,21 +978,25 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
final Locale locale = command.extractLocale();
|
final Locale locale = command.extractLocale();
|
||||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||||
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
|
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
|
||||||
final boolean isPostInterest = command.booleanPrimitiveValueOfParameterNamed(SavingsApiConstants.postInterestValidationOnClosure);
|
|
||||||
// postInterest(account,closedDate,flag);
|
// Pro-rata interest for whatever partial period has accrued since the account's last posting is
|
||||||
if (isPostInterest) {
|
// calculated and posted here, unconditionally -- closing used to only ever *validate* that
|
||||||
boolean postInterestOnClosingDate = false;
|
// interest had already been posted on the closing date (gated behind
|
||||||
List<SavingsAccountTransaction> savingTransactions = account.getTransactions();
|
// postInterestValidationOnClosure, off by default), so with nothing manually posted beforehand
|
||||||
for (SavingsAccountTransaction savingTransaction : savingTransactions) {
|
// the partial period's interest was simply never paid. postInterest(...) already no-ops for a
|
||||||
|
// zero-interest product (its own rate>0 guard) and for a period with nothing newly due, so
|
||||||
|
// calling it here on every closure is safe -- it only ever adds the posting that closing an
|
||||||
|
// account on a real business day should always have included.
|
||||||
|
boolean interestAlreadyPostedOnClosingDate = false;
|
||||||
|
for (SavingsAccountTransaction savingTransaction : account.getTransactions()) {
|
||||||
if (savingTransaction.isInterestPosting() && savingTransaction.isNotReversed()
|
if (savingTransaction.isInterestPosting() && savingTransaction.isNotReversed()
|
||||||
&& DateUtils.isEqual(closedDate, savingTransaction.getTransactionDate())) {
|
&& DateUtils.isEqual(closedDate, savingTransaction.getTransactionDate())) {
|
||||||
postInterestOnClosingDate = true;
|
interestAlreadyPostedOnClosingDate = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!postInterestOnClosingDate) {
|
if (!interestAlreadyPostedOnClosingDate) {
|
||||||
throw new PostInterestClosingDateException();
|
this.postInterest(account, true, closedDate, false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||||
@@ -1676,6 +1703,15 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
@Override
|
@Override
|
||||||
public void setSubStatusInactive(Long savingsId) {
|
public void setSubStatusInactive(Long savingsId) {
|
||||||
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
||||||
|
// Same snapshot-vs-execution gap as escheat() below: the dormant-accounts batch selects
|
||||||
|
// pending-inactive ids from a query that requires status = ACTIVE, but the account can close
|
||||||
|
// through another path (e.g. deposit maturity) before this call runs. Re-deriving accounting
|
||||||
|
// off the full transaction list at that point re-posts entries that closure already posted.
|
||||||
|
if (!account.isActive()) {
|
||||||
|
log.warn("Skipping setSubStatusInactive for savings account {} -- no longer active (already closed by another process)",
|
||||||
|
savingsId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final Set<Long> existingTransactionIds = new HashSet<>();
|
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||||
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
||||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||||
@@ -1687,6 +1723,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
@Override
|
@Override
|
||||||
public void setSubStatusDormant(Long savingsId) {
|
public void setSubStatusDormant(Long savingsId) {
|
||||||
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
||||||
|
if (!account.isActive()) {
|
||||||
|
log.warn("Skipping setSubStatusDormant for savings account {} -- no longer active (already closed by another process)",
|
||||||
|
savingsId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
account.setSubStatusDormant();
|
account.setSubStatusDormant();
|
||||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||||
}
|
}
|
||||||
@@ -1694,6 +1735,16 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
@Override
|
@Override
|
||||||
public void escheat(Long savingsId) {
|
public void escheat(Long savingsId) {
|
||||||
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
||||||
|
// The dormant-accounts batch selects escheat-eligible ids from a snapshot query, so an
|
||||||
|
// account can legitimately close through another path (e.g. deposit maturity) in the gap
|
||||||
|
// between that selection and this call. escheat() unconditionally forces status/sub-status
|
||||||
|
// to CLOSED/ESCHEAT and re-derives accounting off the account's full transaction list, so
|
||||||
|
// running it against an already-closed account both mislabels the closure reason and
|
||||||
|
// re-posts journal entries for transactions that were already posted by the real closure.
|
||||||
|
if (!account.isActive()) {
|
||||||
|
log.warn("Skipping escheat for savings account {} -- no longer active (already closed by another process)", savingsId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final Set<Long> existingTransactionIds = new HashSet<>();
|
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||||
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
||||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||||
@@ -1811,7 +1862,14 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.savingsAccountTransactionDataValidator.validateHoldAndAssembleForm(command.json(), account, submittedBy,
|
this.savingsAccountTransactionDataValidator.validateHoldAndAssembleForm(command.json(), account, submittedBy,
|
||||||
backdatedTxnsAllowedTill);
|
backdatedTxnsAllowedTill);
|
||||||
SavingsAccountTransaction transaction = this.savingsAccountDomainService.handleHold(account, amount, transactionDate, lienAllowed);
|
SavingsAccountTransaction transaction = this.savingsAccountDomainService.handleHold(account, amount, transactionDate, lienAllowed);
|
||||||
account.holdAmount(amount);
|
// Defaults to Cash Hold (a manual/staff-placed lien) -- the automatic clearing hold placed on a
|
||||||
|
// Cheque/DD deposit at approval time is the only caller expected to pass CLEARING explicitly.
|
||||||
|
final String requestedHoldCategory = command.stringValueOfParameterNamed(SavingsApiConstants.holdCategoryParamName);
|
||||||
|
final String holdCategory = SavingsAccount.HOLD_CATEGORY_CLEARING.equals(requestedHoldCategory)
|
||||||
|
? SavingsAccount.HOLD_CATEGORY_CLEARING
|
||||||
|
: SavingsAccount.HOLD_CATEGORY_CASH;
|
||||||
|
account.holdAmount(amount, holdCategory);
|
||||||
|
transaction.updateHoldCategory(holdCategory);
|
||||||
transaction.setRunningBalance(runningBalance);
|
transaction.setRunningBalance(runningBalance);
|
||||||
|
|
||||||
final String reasonForBlock = command.stringValueOfParameterNamed(SavingsApiConstants.reasonForBlockParamName);
|
final String reasonForBlock = command.stringValueOfParameterNamed(SavingsApiConstants.reasonForBlockParamName);
|
||||||
@@ -1859,7 +1917,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
transaction.setRunningBalance(runningBalance);
|
transaction.setRunningBalance(runningBalance);
|
||||||
|
|
||||||
this.savingsAccountTransactionDataValidator.validateTransactionWithPivotDate(transaction.getTransactionDate(), account);
|
this.savingsAccountTransactionDataValidator.validateTransactionWithPivotDate(transaction.getTransactionDate(), account);
|
||||||
account.releaseOnHoldAmount(transaction.getAmount());
|
// Credit back whichever bucket the original hold debited -- a Clearing Hold released here (the
|
||||||
|
// manual "release early" action) must not accidentally credit the Cash Hold total instead.
|
||||||
|
final String holdCategory = holdTransaction.getHoldCategory() != null ? holdTransaction.getHoldCategory()
|
||||||
|
: SavingsAccount.HOLD_CATEGORY_CASH;
|
||||||
|
account.releaseOnHoldAmount(transaction.getAmount(), holdCategory);
|
||||||
|
|
||||||
this.savingsAccountTransactionRepository.saveAndFlush(transaction);
|
this.savingsAccountTransactionRepository.saveAndFlush(transaction);
|
||||||
holdTransaction.updateReleaseId(transaction.getId());
|
holdTransaction.updateReleaseId(transaction.getId());
|
||||||
@@ -1970,4 +2032,79 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
throw new PlatformDataIntegrityException("Reason For Block is Mandatory", "error.msg.reason.for.block.mandatory");
|
throw new PlatformDataIntegrityException("Reason For Block is Mandatory", "error.msg.reason.for.block.mandatory");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void raiseInterestVouchers(final SavingsAccount account, final Set<Long> existingTransactionIds,
|
||||||
|
final boolean backdatedTxnsAllowedTill) {
|
||||||
|
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<SavingsAccountTransaction> transactions = backdatedTxnsAllowedTill
|
||||||
|
? account.getSavingsAccountTransactionsWithPivotConfig()
|
||||||
|
: account.getTransactions();
|
||||||
|
|
||||||
|
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
|
||||||
|
final Map<String, Object> expense = this.voucherService.savingsInterestExpenseAccount(account.getId());
|
||||||
|
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
|
||||||
|
final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue();
|
||||||
|
|
||||||
|
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
|
||||||
|
final String partyName = party != null ? party : (String) control.get("name");
|
||||||
|
final String accountNo = account.getAccountNumber();
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
|
||||||
|
for (final SavingsAccountTransaction transaction : transactions) {
|
||||||
|
if (transaction.getId() == null || existingTransactionIds.contains(transaction.getId())
|
||||||
|
|| !transaction.isInterestPostingAndNotReversed()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final BigDecimal amount = transaction.getAmount();
|
||||||
|
if (amount == null || amount.signum() <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
|
||||||
|
transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT,
|
||||||
|
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
|
||||||
|
.withDescription("Interest posted to " + accountNo),
|
||||||
|
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
|
||||||
|
.withDescription("Interest credited")));
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise interest vouchers for savings account {}", account.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount,
|
||||||
|
final boolean isDeposit) {
|
||||||
|
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
|
||||||
|
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
|
||||||
|
|
||||||
|
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
|
||||||
|
final String accountNo = account.getAccountNumber();
|
||||||
|
|
||||||
|
final VoucherLine memberSide = isDeposit
|
||||||
|
? VoucherLine.creditGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount)
|
||||||
|
: VoucherLine.debitGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount);
|
||||||
|
|
||||||
|
final VoucherLine cashSide = isDeposit ? VoucherLine.debitGl(null, "Cash in Hand", null, amount)
|
||||||
|
: VoucherLine.creditGl(null, "Cash in Hand", null, amount);
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = isDeposit ? List.of(cashSide, memberSide) : List.of(memberSide, cashSide);
|
||||||
|
|
||||||
|
return this.voucherService.createVoucherWithLines(
|
||||||
|
isDeposit ? VoucherService.TYPE_DEPOSIT : VoucherService.TYPE_WITHDRAWAL, REF_SAVINGS_TRANSACTION, transactionId,
|
||||||
|
currentUser, isDeposit ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for savings transaction {}", transactionId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+11
@@ -116,6 +116,7 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
|||||||
private final GroupRepositoryWrapper groupRepositoryWrapper;
|
private final GroupRepositoryWrapper groupRepositoryWrapper;
|
||||||
private final GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService;
|
private final GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService;
|
||||||
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
|
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
|
||||||
|
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -257,6 +258,16 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
|||||||
}
|
}
|
||||||
|
|
||||||
final Long savingsId = account.getId();
|
final Long savingsId = account.getId();
|
||||||
|
|
||||||
|
// Mirrors the link back onto the share account this savings account was linked to at
|
||||||
|
// creation (share account -> savings account already existed; this is the reverse
|
||||||
|
// pointer), so either side can be navigated to from the other, the same way the "Default
|
||||||
|
// Savings Account" chosen on a share account points back at it.
|
||||||
|
if (account.getShareAccountId() != null) {
|
||||||
|
this.jdbcTemplate.update("UPDATE m_share_account SET savings_account_id = ? WHERE id = ?", savingsId,
|
||||||
|
account.getShareAccountId());
|
||||||
|
}
|
||||||
|
|
||||||
if (command.parameterExists(SavingsApiConstants.datatables)) {
|
if (command.parameterExists(SavingsApiConstants.datatables)) {
|
||||||
this.entityDatatableChecksWritePlatformService.saveDatatables(StatusEnum.CREATE.getValue(), EntityTables.SAVINGS.getName(),
|
this.entityDatatableChecksWritePlatformService.saveDatatables(StatusEnum.CREATE.getValue(), EntityTables.SAVINGS.getName(),
|
||||||
savingsId, account.productId(), command.arrayOfParameterNamed(SavingsApiConstants.datatables));
|
savingsId, account.productId(), command.arrayOfParameterNamed(SavingsApiConstants.datatables));
|
||||||
|
|||||||
+7
-5
@@ -69,6 +69,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
|
|||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
|
||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||||
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
|
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
|
||||||
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
||||||
@@ -473,9 +474,10 @@ public class SavingsConfiguration {
|
|||||||
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
||||||
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
||||||
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
||||||
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
|
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) {
|
||||||
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
|
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer,
|
||||||
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
|
savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler,
|
||||||
|
savingsAccountTransactionDataValidator,
|
||||||
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
|
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
|
||||||
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
|
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
|
||||||
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
||||||
@@ -526,13 +528,13 @@ public class SavingsConfiguration {
|
|||||||
AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, BusinessEventNotifierService businessEventNotifierService,
|
AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, BusinessEventNotifierService businessEventNotifierService,
|
||||||
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, GSIMRepositoy gsimRepository,
|
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, GSIMRepositoy gsimRepository,
|
||||||
GroupRepositoryWrapper groupRepositoryWrapper, GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService,
|
GroupRepositoryWrapper groupRepositoryWrapper, GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService,
|
||||||
ClientNomineeWritePlatformService clientNomineeWritePlatformService) {
|
ClientNomineeWritePlatformService clientNomineeWritePlatformService, org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) {
|
||||||
return new SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository, savingAccountAssembler,
|
return new SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository, savingAccountAssembler,
|
||||||
savingsAccountDataValidator, accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository,
|
savingsAccountDataValidator, accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository,
|
||||||
noteRepository, staffRepository, savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler,
|
noteRepository, staffRepository, savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler,
|
||||||
commandProcessingService, savingsAccountDomainService, savingsAccountWritePlatformService, accountNumberFormatRepository,
|
commandProcessingService, savingsAccountDomainService, savingsAccountWritePlatformService, accountNumberFormatRepository,
|
||||||
businessEventNotifierService, entityDatatableChecksWritePlatformService, gsimRepository, groupRepositoryWrapper,
|
businessEventNotifierService, entityDatatableChecksWritePlatformService, gsimRepository, groupRepositoryWrapper,
|
||||||
gsimWritePlatformService, clientNomineeWritePlatformService);
|
gsimWritePlatformService, clientNomineeWritePlatformService, jdbcTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package org.apache.fineract.portfolio.savingshold.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-releases a Clearing Hold once its instrument's clearing period has passed — real-world CBS
|
||||||
|
* practice for a cheque/DD deposit that was held pending clearance (see
|
||||||
|
* {@code CashierDepositRequestApiResource.placeClearingHoldIfInstrumentDeposit}). This is a plain,
|
||||||
|
* self-contained REST trigger rather than a job registered in Fineract's Spring Batch scheduler: the
|
||||||
|
* batch job framework is a deep, unfamiliar subsystem to extend correctly, and a wrong registration
|
||||||
|
* there risks the whole scheduler, not just this feature. Wire this endpoint into an external
|
||||||
|
* scheduler (cron / the existing OS-level task runner) to run it daily; a manager can also call it
|
||||||
|
* directly, or release one hold early and by hand via the existing releaseAmount action, for an
|
||||||
|
* instrument confirmed cleared sooner than the default window.
|
||||||
|
*/
|
||||||
|
@Path("/v1/savingsaccounts/clearingholds/release-due")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class SavingsClearingHoldReleaseApiResource {
|
||||||
|
|
||||||
|
/** Kept as a simple constant rather than a new admin-editable setting -- the clearing window this
|
||||||
|
* branch actually needs can be tuned here once real usage shows what it should be. */
|
||||||
|
private static final int CLEARING_HOLD_DEFAULT_DAYS = 3;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@POST
|
||||||
|
public String releaseDue() {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final LocalDate cutoff = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).minusDays(CLEARING_HOLD_DEFAULT_DAYS);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> due = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, savings_account_id FROM m_savings_account_transaction
|
||||||
|
WHERE hold_category = 'CLEARING' AND release_id_of_hold_amount IS NULL
|
||||||
|
AND is_reversed = false AND transaction_date <= ?
|
||||||
|
""", java.sql.Date.valueOf(cutoff));
|
||||||
|
|
||||||
|
final List<Map<String, Object>> released = new ArrayList<>();
|
||||||
|
final List<Map<String, Object>> failed = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : due) {
|
||||||
|
final Long savingsId = ((Number) row.get("savings_account_id")).longValue();
|
||||||
|
final Long transactionId = ((Number) row.get("id")).longValue();
|
||||||
|
try {
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().releaseAmount(savingsId, transactionId)
|
||||||
|
.withJson("{}").build();
|
||||||
|
this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
released.add(Map.of("savingsId", savingsId, "transactionId", transactionId));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
failed.add(Map.of("savingsId", savingsId, "transactionId", transactionId, "error", String.valueOf(e.getMessage())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.gson.toJson(
|
||||||
|
Map.of("cutoffDate", cutoff.toString(), "releasedCount", released.size(), "released", released, "failed", failed));
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package org.apache.fineract.portfolio.savingshold.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two-way hold breakdown behind a savings account's single combined "amount on hold" figure —
|
||||||
|
* a manual, staff-placed lien (Cash Hold) versus money still moving through the clearing process, a
|
||||||
|
* cheque/DD deposit not yet realized or a presented cheque not yet settled (Clearing Hold).
|
||||||
|
*
|
||||||
|
* Kept as a narrow, additive endpoint alongside the standard Fineract savings-account read API rather
|
||||||
|
* than adding a field to {@code SavingsAccountData} — that class has roughly a dozen constructor
|
||||||
|
* overloads threading its existing fields through every savings API response, and touching all of them
|
||||||
|
* for one screen's two extra numbers would be a wide, easy-to-regress change for a small addition.
|
||||||
|
*/
|
||||||
|
@Path("/v1/savingsaccounts/{savingsId}/holdbreakdown")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class SavingsHoldBreakdownApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public String retrieve(@PathParam("savingsId") final Long savingsId) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final Map<String, Object> row = this.jdbcTemplate.queryForMap("""
|
||||||
|
SELECT COALESCE(total_savings_amount_on_hold, 0) AS cash_hold,
|
||||||
|
COALESCE(total_savings_amount_on_hold_clearing, 0) AS clearing_hold
|
||||||
|
FROM m_savings_account WHERE id = ?
|
||||||
|
""", savingsId);
|
||||||
|
|
||||||
|
return this.gson.toJson(Map.of("savingsId", savingsId, "cashHold", (BigDecimal) row.get("cash_hold"),
|
||||||
|
"clearingHold", (BigDecimal) row.get("clearing_hold")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.shareaccounts.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.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.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.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can a member fund a <i>new</i> additional-share tranche from their savings, and for how much.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Deliberately scoped to a single tranche rather than the account's whole holding history: the amount here is the
|
||||||
|
* caller's — the tranche is not saved yet, so there is no row to read it from. The share count is passed in and
|
||||||
|
* priced at the product's current market price, which is the same figure the apply form shows the member.
|
||||||
|
*/
|
||||||
|
@Path("/v1/shareaccounts/{accountId}/additionalshares")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Additional Shares Funding", description = "Whether a new additional-share tranche can be funded from the member's linked savings.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class AdditionalSharesFundingApiResource {
|
||||||
|
|
||||||
|
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param requestedShares
|
||||||
|
* how many shares the member wants to buy; the amount is derived from this rather than trusted from the
|
||||||
|
* caller, so the eligibility answer and the tranche's own value cannot disagree.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/savings-eligibility")
|
||||||
|
@Operation(summary = "Savings eligibility for a new additional-share tranche", description = "Reports the tranche's value and whether the member's linked savings account can cover it.")
|
||||||
|
public Response savingsEligibility(@PathParam("accountId") final Long accountId,
|
||||||
|
@QueryParam("requestedShares") final Long requestedShares) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final Map<String, Object> acct = loadShareAccount(accountId);
|
||||||
|
final BigDecimal unitPrice = currentMarketPriceOf(acct.get("product_id"));
|
||||||
|
final long shares = requestedShares == null ? 0L : requestedShares;
|
||||||
|
final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares));
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("shareAccountId", accountId);
|
||||||
|
out.put("requestedShares", shares);
|
||||||
|
out.put("unitPrice", unitPrice);
|
||||||
|
out.put("amount", amount);
|
||||||
|
|
||||||
|
final Object savingsAccountIdRaw = acct.get("savings_account_id");
|
||||||
|
if (savingsAccountIdRaw == null) {
|
||||||
|
return ineligible(out, null, null, "This share account has no linked savings account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> savings = loadSavingsAccount(toLong(savingsAccountIdRaw));
|
||||||
|
final String savingsAccountNo = (String) savings.get("account_no");
|
||||||
|
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
|
||||||
|
out.put("savingsAccountNo", savingsAccountNo);
|
||||||
|
out.put("savingsBalance", balance);
|
||||||
|
|
||||||
|
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
|
||||||
|
return ineligible(out, savingsAccountNo, balance, "Savings account " + savingsAccountNo + " is not active.");
|
||||||
|
}
|
||||||
|
// Zero shares is not a shortfall — the clerk simply has not typed the count yet, and saying
|
||||||
|
// the balance is short would be wrong as well as unhelpful.
|
||||||
|
if (shares <= 0) {
|
||||||
|
return ineligible(out, savingsAccountNo, balance, "Enter the number of shares to check the balance against.");
|
||||||
|
}
|
||||||
|
if (balance == null || balance.compareTo(amount) < 0) {
|
||||||
|
return ineligible(out, savingsAccountNo, balance,
|
||||||
|
"Savings account " + savingsAccountNo + " does not hold enough to cover this share request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
out.put("eligible", Boolean.TRUE);
|
||||||
|
out.put("reason", null);
|
||||||
|
return Response.ok(gson.toJson(out)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Response ineligible(final Map<String, Object> out, final String savingsAccountNo, final BigDecimal balance,
|
||||||
|
final String reason) {
|
||||||
|
out.putIfAbsent("savingsAccountNo", savingsAccountNo);
|
||||||
|
out.putIfAbsent("savingsBalance", balance);
|
||||||
|
out.put("eligible", Boolean.FALSE);
|
||||||
|
out.put("reason", reason);
|
||||||
|
return Response.ok(gson.toJson(out)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The product's price on the latest market-price period that has already started, matching how
|
||||||
|
* {@code ShareProduct.deriveMarketPrice} chooses one; the product's own unit price when no period applies.
|
||||||
|
*/
|
||||||
|
private BigDecimal currentMarketPriceOf(final Object productIdRaw) {
|
||||||
|
final Long productId = toLong(productIdRaw);
|
||||||
|
final List<Map<String, Object>> periods = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT share_value FROM m_share_product_market_price
|
||||||
|
WHERE product_id = ? AND from_date <= CURRENT_DATE
|
||||||
|
ORDER BY from_date DESC LIMIT 1
|
||||||
|
""", productId);
|
||||||
|
if (!periods.isEmpty()) {
|
||||||
|
final BigDecimal periodPrice = (BigDecimal) periods.get(0).get("share_value");
|
||||||
|
if (periodPrice != null) {
|
||||||
|
return periodPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final BigDecimal unitPrice = this.jdbcTemplate.queryForObject("SELECT unit_price FROM m_share_product WHERE id = ?",
|
||||||
|
BigDecimal.class, productId);
|
||||||
|
return unitPrice == null ? BigDecimal.ZERO : unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loadShareAccount(final Long accountId) {
|
||||||
|
try {
|
||||||
|
return this.jdbcTemplate.queryForMap(
|
||||||
|
"SELECT id, account_no, client_id, product_id, status_enum, currency_code, savings_account_id "
|
||||||
|
+ " FROM m_share_account WHERE id = ?",
|
||||||
|
accountId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.account.not.found",
|
||||||
|
"Share account " + accountId + " does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
|
||||||
|
try {
|
||||||
|
return this.jdbcTemplate.queryForMap(
|
||||||
|
"SELECT id, account_no, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.savings.account.not.found",
|
||||||
|
"Savings account " + savingsAccountId + " does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long toLong(final Object value) {
|
||||||
|
return value == null ? null : ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int toInt(final Object value) {
|
||||||
|
return value == null ? 0 : ((Number) value).intValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.shareaccounts.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /v1/clients/{clientId}/activeshareaccounts
|
||||||
|
*
|
||||||
|
* A client's active share accounts, for the "Default Share Account" picker the Savings Account
|
||||||
|
* creation screen shows once Share Linking is enabled - the reverse of the "Default Savings
|
||||||
|
* Account" picker the Share Account creation screen already shows (which comes from
|
||||||
|
* {@code ShareAccountReadPlatformServiceImpl#retrieveActiveForLookup}). Deliberately its own small
|
||||||
|
* endpoint rather than folded into the savings account template response, which is shared by
|
||||||
|
* Savings, Fixed Deposit, Recurring Deposit and Current accounts alike - bolting a share-specific
|
||||||
|
* list onto that one shared, deeply-nested DTO would touch every one of those account types for a
|
||||||
|
* field only Savings actually uses.
|
||||||
|
*/
|
||||||
|
@Path("/v1/clients/{clientId}/activeshareaccounts")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class ClientActiveShareAccountsApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public String retrieve(@PathParam("clientId") final Long clientId) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id AS id, sa.account_no AS "accountNo", sp.name AS "productName"
|
||||||
|
FROM m_share_account sa JOIN m_share_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sa.client_id = ? AND sa.status_enum = 300
|
||||||
|
ORDER BY sa.account_no
|
||||||
|
""", clientId);
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.shareaccounts.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share accounts awaiting approval, for the checker's Pending Share Accounts queue.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Separate from {@code /accounts/share}, whose {@code retrieveAll} is hard-wired to ACTIVE accounts and returns the full
|
||||||
|
* account graph — charges, every purchase transaction — per row. A queue needs the opposite: only accounts in
|
||||||
|
* "submitted and pending approval", and only the handful of fields a checker decides on, so the list stays one cheap
|
||||||
|
* query however many accounts are waiting.
|
||||||
|
*/
|
||||||
|
@Path("/v1/shareaccounts/pending")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Pending Share Accounts", description = "Share accounts submitted and awaiting a checker's approval.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class PendingShareAccountsApiResource {
|
||||||
|
|
||||||
|
/** m_share_account.status_enum for "submitted and pending approval". */
|
||||||
|
private static final int STATUS_SUBMITTED_AND_PENDING_APPROVAL = 100;
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Operation(summary = "List share accounts awaiting approval", description = "Accounts in submitted-and-pending-approval, with the member, product, requested shares and value a checker needs to decide.")
|
||||||
|
public Response retrievePending() {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
// The requested shares come from the account's own pending purchase rows rather than
|
||||||
|
// total_pending_shares, and are summed: an account can carry more than one pending purchase,
|
||||||
|
// and the checker is approving the account, so they need to see the whole subscription.
|
||||||
|
//
|
||||||
|
// Every alias is double-quoted. PostgreSQL folds an unquoted alias to lower case, so
|
||||||
|
// queryForList would key the maps "accountno" and "shareaccountid" — which serialise straight
|
||||||
|
// into the JSON and leave the client reading accountNo/shareAccountId as undefined.
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id AS "shareAccountId",
|
||||||
|
sa.account_no AS "accountNo",
|
||||||
|
sa.client_id AS "clientId",
|
||||||
|
c.display_name AS "clientName",
|
||||||
|
o.name AS "officeName",
|
||||||
|
sp.name AS "productName",
|
||||||
|
sa.currency_code AS "currencyCode",
|
||||||
|
sa.submitted_date AS "submittedDate",
|
||||||
|
sa.resolution_number AS "resolutionNumber",
|
||||||
|
sa.resolution_date AS "resolutionDate",
|
||||||
|
au.username AS "submittedByUsername",
|
||||||
|
COALESCE(txn.requested_shares, 0) AS "requestedShares",
|
||||||
|
COALESCE(txn.requested_amount, 0) AS "requestedAmount"
|
||||||
|
FROM m_share_account sa
|
||||||
|
JOIN m_client c ON c.id = sa.client_id
|
||||||
|
JOIN m_office o ON o.id = c.office_id
|
||||||
|
JOIN m_share_product sp ON sp.id = sa.product_id
|
||||||
|
LEFT JOIN m_appuser au ON au.id = sa.submitted_userid
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT t.account_id,
|
||||||
|
SUM(t.total_shares) AS requested_shares,
|
||||||
|
SUM(t.total_shares * t.unit_price) AS requested_amount
|
||||||
|
FROM m_share_account_transactions t
|
||||||
|
WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = 500
|
||||||
|
GROUP BY t.account_id
|
||||||
|
) txn ON txn.account_id = sa.id
|
||||||
|
WHERE sa.status_enum = ?
|
||||||
|
ORDER BY sa.submitted_date DESC, sa.id DESC
|
||||||
|
""", STATUS_SUBMITTED_AND_PENDING_APPROVAL, STATUS_SUBMITTED_AND_PENDING_APPROVAL);
|
||||||
|
|
||||||
|
return Response.ok(gson.toJson(rows)).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.shareaccounts.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
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.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional-share tranches awaiting approval, across every share account, for a centralized Approval Center queue.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A tranche raised through "Apply Additional Shares" today is only visible for approval on its own account's page
|
||||||
|
* (the native {@code approveadditionalshares}/{@code rejectadditionalshares} commands, invoked directly from a button
|
||||||
|
* there) — there was no single place a checker could see every tranche waiting across the whole membership. This
|
||||||
|
* resource is the read side of that queue: one list of every pending tranche, and one detail view per tranche with
|
||||||
|
* everything a checker needs to decide, including the route-specific proof of funding a list row cannot show — which
|
||||||
|
* voucher(s) a cash tranche is resting on, or which savings account a savings tranche already debited.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Approval and rejection themselves are NOT duplicated here — they still go through Fineract's own
|
||||||
|
* {@code approveadditionalshares}/{@code rejectadditionalshares} commands (same as the account-page buttons), so the
|
||||||
|
* journal postings and {@link org.apache.fineract.portfolio.shareaccounts.service.AdditionalShareTrancheFundingService}
|
||||||
|
* settle/unwind hooks that already run on those commands run exactly once, from exactly one code path.
|
||||||
|
*/
|
||||||
|
@Path("/v1/shareadditionrequests")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Share Addition Requests", description = "Additional-share tranches awaiting approval, across every share account.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class ShareAdditionRequestsApiResource {
|
||||||
|
|
||||||
|
/** m_share_account_transactions.status_enum for "submitted, awaiting approval". */
|
||||||
|
private static final int STATUS_APPLIED = 100;
|
||||||
|
/** m_share_account_transactions.type_enum for a share purchase (an original application or a tranche alike). */
|
||||||
|
private static final int TYPE_PURCHASED = 500;
|
||||||
|
|
||||||
|
private static final String FUNDING_CASH = "CASH";
|
||||||
|
private static final String FUNDING_SAVINGS = "SAVINGS";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
// Every alias is double-quoted: PostgreSQL folds an unquoted alias to lower case, so
|
||||||
|
// queryForList would key the maps "shareaccountid" etc., which serialise straight into JSON and
|
||||||
|
// leave the client reading shareAccountId as undefined.
|
||||||
|
private static final String BASE_SELECT = """
|
||||||
|
SELECT t.id AS "transactionId",
|
||||||
|
sa.id AS "shareAccountId",
|
||||||
|
sa.account_no AS "accountNo",
|
||||||
|
sa.client_id AS "clientId",
|
||||||
|
c.display_name AS "clientName",
|
||||||
|
o.name AS "officeName",
|
||||||
|
sp.name AS "productName",
|
||||||
|
sa.currency_code AS "currencyCode",
|
||||||
|
t.transaction_date AS "transactionDate",
|
||||||
|
t.total_shares AS "totalShares",
|
||||||
|
t.unit_price AS "unitPrice",
|
||||||
|
t.amount AS "amount",
|
||||||
|
t.charge_amount AS "chargeAmount",
|
||||||
|
t.resolution_number AS "resolutionNumber",
|
||||||
|
t.meeting_date AS "meetingDate",
|
||||||
|
t.funding_source AS "fundingSource",
|
||||||
|
t.advance_collection_id AS "advanceCollectionId",
|
||||||
|
t.additional_collection_ids AS "additionalCollectionIds"
|
||||||
|
FROM m_share_account_transactions t
|
||||||
|
JOIN m_share_account sa ON sa.id = t.account_id
|
||||||
|
JOIN m_client c ON c.id = sa.client_id
|
||||||
|
JOIN m_office o ON o.id = c.office_id
|
||||||
|
JOIN m_share_product sp ON sp.id = sa.product_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Operation(summary = "List additional-share tranches awaiting approval",
|
||||||
|
description = "Every pending tranche across every share account, with the member, account, shares and value a checker needs to see before deciding.")
|
||||||
|
public Response retrievePending() {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||||
|
BASE_SELECT + " WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = ? "
|
||||||
|
+ " ORDER BY t.transaction_date, t.id",
|
||||||
|
STATUS_APPLIED, TYPE_PURCHASED);
|
||||||
|
return Response.ok(gson.toJson(rows)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{transactionId}")
|
||||||
|
@Operation(summary = "Full detail for one pending tranche",
|
||||||
|
description = "The list row plus route-specific proof of funding: the voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche.")
|
||||||
|
public Response retrieveOne(@PathParam("transactionId") final Long transactionId) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final Map<String, Object> row;
|
||||||
|
try {
|
||||||
|
row = this.jdbcTemplate.queryForMap(BASE_SELECT + " WHERE t.id = ?", transactionId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.shareaddition.not.found",
|
||||||
|
"Additional-share request " + transactionId + " does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>(row);
|
||||||
|
final String fundingSource = (String) row.get("fundingSource");
|
||||||
|
|
||||||
|
if (FUNDING_CASH.equals(fundingSource)) {
|
||||||
|
out.put("vouchers", vouchersFor(row));
|
||||||
|
} else if (FUNDING_SAVINGS.equals(fundingSource)) {
|
||||||
|
out.put("savingsAccount", savingsAccountFor(toLong(row.get("shareAccountId"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.ok(gson.toJson(out)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every receipt funding a cash tranche — the lead voucher plus any further instalments — so a checker sees the
|
||||||
|
* whole pot behind the amount rather than just the first receipt.
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> vouchersFor(final Map<String, Object> row) {
|
||||||
|
final List<Long> ids = new ArrayList<>();
|
||||||
|
final Long lead = toLong(row.get("advanceCollectionId"));
|
||||||
|
if (lead != null) {
|
||||||
|
ids.add(lead);
|
||||||
|
}
|
||||||
|
final String additional = (String) row.get("additionalCollectionIds");
|
||||||
|
if (additional != null && !additional.isBlank()) {
|
||||||
|
for (final String part : additional.split(",")) {
|
||||||
|
final String trimmed = part.trim();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
ids.add(Long.valueOf(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
final String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
|
||||||
|
return this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, receipt_no AS "receiptNo", voucher_no AS "voucherNo", amount, collection_date AS "collectionDate",
|
||||||
|
collected_by_name AS "collectedByName", status
|
||||||
|
FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id
|
||||||
|
""".formatted(placeholders), ids.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The savings account a savings-route tranche already debited, so a checker can see it was real money. */
|
||||||
|
private Map<String, Object> savingsAccountFor(final Long shareAccountId) {
|
||||||
|
try {
|
||||||
|
return this.jdbcTemplate.queryForMap("""
|
||||||
|
SELECT sav.id, sav.account_no AS "accountNo", sav.account_balance_derived AS "balance"
|
||||||
|
FROM m_savings_account sav
|
||||||
|
JOIN m_share_account sa ON sa.savings_account_id = sav.id
|
||||||
|
WHERE sa.id = ?
|
||||||
|
""", shareAccountId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long toLong(final Object o) {
|
||||||
|
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -301,10 +301,24 @@ public class ShareAccountData implements Serializable, AccountData {
|
|||||||
minimumActivePeriodFrequencyTypeOption, currenMarketPrice);
|
minimumActivePeriodFrequencyTypeOption, currenMarketPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the member's money has actually been taken and the shares issued — either by applying a cash collection
|
||||||
|
* or by debiting their savings.
|
||||||
|
*
|
||||||
|
* Distinct from the account's own status: an account is Approved and then Activated before anything is settled, and
|
||||||
|
* the purchase transaction reads Approved from the moment the account is approved. Only this says money has moved,
|
||||||
|
* which is what the Transactions, Passbook and Charges views actually describe.
|
||||||
|
*/
|
||||||
|
private Boolean collectionApplied;
|
||||||
|
|
||||||
public void setCurrentMarketPrice(final BigDecimal currentMarketPrice) {
|
public void setCurrentMarketPrice(final BigDecimal currentMarketPrice) {
|
||||||
this.currentMarketPrice = currentMarketPrice;
|
this.currentMarketPrice = currentMarketPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setCollectionApplied(final Boolean collectionApplied) {
|
||||||
|
this.collectionApplied = collectionApplied;
|
||||||
|
}
|
||||||
|
|
||||||
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
|
public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) {
|
||||||
this.linkedLoanId = linkedLoanId;
|
this.linkedLoanId = linkedLoanId;
|
||||||
this.linkedLoanAccountNo = linkedLoanAccountNo;
|
this.linkedLoanAccountNo = linkedLoanAccountNo;
|
||||||
|
|||||||
+15
-2
@@ -51,10 +51,23 @@ public class ShareAccountTransactionData implements Serializable {
|
|||||||
|
|
||||||
private final BigDecimal amountPaid;
|
private final BigDecimal amountPaid;
|
||||||
|
|
||||||
/** Board Resolution Number / Date of Meeting that authorised this transaction — populated
|
/** Board Resolution Number / Date of Meeting that authorised this transaction — recorded on a
|
||||||
* only for a redeem (withdrawal) transaction; null otherwise. */
|
* redeem (withdrawal) and on an additional-share purchase; null otherwise. */
|
||||||
private final String resolutionNumber;
|
private final String resolutionNumber;
|
||||||
|
|
||||||
private final LocalDate meetingDate;
|
private final LocalDate meetingDate;
|
||||||
|
|
||||||
|
/** How an additional-share tranche was paid for — {@code CASH} or {@code SAVINGS}; null on a
|
||||||
|
* first application, and on tranches raised before the route was captured. Shown to whoever
|
||||||
|
* approves the tranche, since approving is what releases the money out of Share Sundry. */
|
||||||
|
private final String fundingSource;
|
||||||
|
|
||||||
|
/** The lead voucher funding a CASH tranche; the rest are in {@link #additionalCollectionIds}. */
|
||||||
|
private final Long advanceCollectionId;
|
||||||
|
|
||||||
|
/** Further vouchers funding the same tranche, comma-separated; null when one receipt covered it. */
|
||||||
|
private final String additionalCollectionIds;
|
||||||
|
|
||||||
|
private final Long savingsDebitRequestId;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -368,7 +368,7 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String getSavingsAccountNo() {
|
public String getSavingsAccountNo() {
|
||||||
return this.savingsAccount.getAccountNumber();
|
return this.savingsAccount == null ? null : this.savingsAccount.getAccountNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addAddtionalShares(Set<ShareAccountTransaction> additionalShares) {
|
public void addAddtionalShares(Set<ShareAccountTransaction> additionalShares) {
|
||||||
|
|||||||
+7
-2
@@ -121,10 +121,15 @@ public class ShareAccountCharge extends AbstractPersistableCustom<Long> {
|
|||||||
this.amountOrPercentage = chargeAmount;
|
this.amountOrPercentage = chargeAmount;
|
||||||
if (this.chargeCalculation.equals(ChargeCalculationType.FLAT.getValue())) {
|
if (this.chargeCalculation.equals(ChargeCalculationType.FLAT.getValue())) {
|
||||||
this.percentage = null;
|
this.percentage = null;
|
||||||
this.amount = BigDecimal.ZERO;
|
// A flat charge is owed in full the moment it is attached: its amount IS the
|
||||||
|
// configured fee, and all of it is outstanding until something pays it. Leaving
|
||||||
|
// both at zero (as this once did) made the entry fee invisible to every consumer
|
||||||
|
// that reads amount/amount_outstanding_derived — the share-collection settlement
|
||||||
|
// then asked the member for the share value alone and the fee went uncollected.
|
||||||
|
this.amount = chargeAmount;
|
||||||
this.amountPercentageAppliedTo = null;
|
this.amountPercentageAppliedTo = null;
|
||||||
this.amountPaid = null;
|
this.amountPaid = null;
|
||||||
this.amountOutstanding = BigDecimal.ZERO;
|
this.amountOutstanding = calculateOutstanding();
|
||||||
this.amountWaived = null;
|
this.amountWaived = null;
|
||||||
this.amountWrittenOff = null;
|
this.amountWrittenOff = null;
|
||||||
} else if (this.chargeCalculation.equals(ChargeCalculationType.PERCENT_OF_AMOUNT.getValue())) {
|
} else if (this.chargeCalculation.equals(ChargeCalculationType.PERCENT_OF_AMOUNT.getValue())) {
|
||||||
|
|||||||
+106
@@ -28,8 +28,11 @@ import jakarta.persistence.OneToMany;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -75,6 +78,36 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "meeting_date")
|
@Column(name = "meeting_date")
|
||||||
private LocalDate meetingDate;
|
private LocalDate meetingDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this tranche was paid for — {@code CASH} or {@code SAVINGS}; null on tranches raised
|
||||||
|
* before the route was captured.
|
||||||
|
*
|
||||||
|
* Both routes park the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
|
||||||
|
* approving posts the same Dr Sundry / Cr Share Capital pair either way. The route matters on
|
||||||
|
* the way back out: rejecting a cash tranche releases the voucher to be spent again, while
|
||||||
|
* rejecting a savings tranche has to refund the member.
|
||||||
|
*/
|
||||||
|
@Column(name = "funding_source")
|
||||||
|
private String fundingSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead {@code m_share_advance_collection} receipt funding a CASH tranche.
|
||||||
|
*
|
||||||
|
* "Lead" because a member may pay one tranche in instalments; the rest are in
|
||||||
|
* {@link #additionalCollectionIds}. Read them together via {@link #getAllAdvanceCollectionIds()}
|
||||||
|
* rather than this field alone, or the later instalments are missed.
|
||||||
|
*/
|
||||||
|
@Column(name = "advance_collection_id")
|
||||||
|
private Long advanceCollectionId;
|
||||||
|
|
||||||
|
/** The remaining receipts funding this tranche, comma-separated; null when a single one covers it. */
|
||||||
|
@Column(name = "additional_collection_ids")
|
||||||
|
private String additionalCollectionIds;
|
||||||
|
|
||||||
|
/** The {@code m_share_savings_debit_request} behind a SAVINGS tranche. */
|
||||||
|
@Column(name = "savings_debit_request_id")
|
||||||
|
private Long savingsDebitRequestId;
|
||||||
|
|
||||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER)
|
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER)
|
||||||
private Set<ShareAccountChargePaidBy> shareAccountChargesPaid = new HashSet<>();
|
private Set<ShareAccountChargePaidBy> shareAccountChargesPaid = new HashSet<>();
|
||||||
|
|
||||||
@@ -125,6 +158,79 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
|
|||||||
return transaction;
|
return transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the board resolution authorising a purchase, and how the tranche is funded.
|
||||||
|
*
|
||||||
|
* A purchase tranche carries its own resolution, not the account's: each further subscription
|
||||||
|
* is authorised by its own board meeting. Kept separate from the constructor because a first
|
||||||
|
* application has no funding route to record — only additional shares are raised this way.
|
||||||
|
*/
|
||||||
|
public void updatePurchaseAuthorisationAndFunding(final String resolutionNumber, final LocalDate meetingDate,
|
||||||
|
final String fundingSource, final List<Long> advanceCollectionIds, final Long savingsDebitRequestId) {
|
||||||
|
this.resolutionNumber = resolutionNumber;
|
||||||
|
this.meetingDate = meetingDate;
|
||||||
|
this.fundingSource = fundingSource;
|
||||||
|
this.savingsDebitRequestId = savingsDebitRequestId;
|
||||||
|
// First receipt leads; the rest ride along in a single column. Existing single-voucher rows
|
||||||
|
// keep exactly the shape they had, so nothing reading advance_collection_id has to change.
|
||||||
|
if (advanceCollectionIds == null || advanceCollectionIds.isEmpty()) {
|
||||||
|
this.advanceCollectionId = null;
|
||||||
|
this.additionalCollectionIds = null;
|
||||||
|
} else {
|
||||||
|
this.advanceCollectionId = advanceCollectionIds.get(0);
|
||||||
|
this.additionalCollectionIds = advanceCollectionIds.size() > 1
|
||||||
|
? advanceCollectionIds.subList(1, advanceCollectionIds.size()).stream().map(String::valueOf)
|
||||||
|
.collect(Collectors.joining(","))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFundingSource() {
|
||||||
|
return this.fundingSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getAdvanceCollectionId() {
|
||||||
|
return this.advanceCollectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAdditionalCollectionIds() {
|
||||||
|
return this.additionalCollectionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every receipt funding this tranche, lead first.
|
||||||
|
*
|
||||||
|
* The one method that should be used to act on a tranche's vouchers — reserving them, releasing
|
||||||
|
* them, marking them applied. Using {@code advanceCollectionId} alone would silently skip a
|
||||||
|
* member's later instalments, leaving their money reserved against nothing or spendable twice.
|
||||||
|
*/
|
||||||
|
public List<Long> getAllAdvanceCollectionIds() {
|
||||||
|
final List<Long> ids = new ArrayList<>();
|
||||||
|
if (this.advanceCollectionId != null) {
|
||||||
|
ids.add(this.advanceCollectionId);
|
||||||
|
}
|
||||||
|
if (this.additionalCollectionIds != null && !this.additionalCollectionIds.isBlank()) {
|
||||||
|
for (final String part : this.additionalCollectionIds.split(",")) {
|
||||||
|
final String trimmed = part.trim();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
final Long id = Long.valueOf(trimmed);
|
||||||
|
if (!ids.contains(id)) {
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSavingsDebitRequestId() {
|
||||||
|
return this.savingsDebitRequestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateSavingsDebitRequestId(final Long savingsDebitRequestId) {
|
||||||
|
this.savingsDebitRequestId = savingsDebitRequestId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getResolutionNumber() {
|
public String getResolutionNumber() {
|
||||||
return this.resolutionNumber;
|
return this.resolutionNumber;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -31,11 +31,10 @@ import org.springframework.batch.repeat.RepeatStatus;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year
|
* Daily job: any Share Dividend lock (see {@code m_share_dividend_lock}) whose 3-year release_due_date has passed with
|
||||||
* release_due_date has passed with no Bank Manager action (via the Dividend Dashboard) is
|
* no Bank Manager action (via the Dividend Dashboard) is forfeited — the hold on the member's savings account is
|
||||||
* forfeited — the hold on the member's savings account is released and the amount is swept back
|
* released and the amount is swept back into the bank's profit. See
|
||||||
* into the bank's profit. See {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for
|
* {@link ShareAccountSchedularService#forfeitDividendLock(Long)} for the actual GL/balance mechanics.
|
||||||
* the actual GL/balance mechanics.
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -47,8 +46,13 @@ public class ForfeitUnclaimedDividendsTasklet implements Tasklet {
|
|||||||
@Override
|
@Override
|
||||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
final List<Throwable> exceptions = new ArrayList<>();
|
final List<Throwable> exceptions = new ArrayList<>();
|
||||||
|
// 'UNCLAIMED', not 'LOCKED': changelog 0461 reworked the lock from a savings hold into a pure
|
||||||
|
// "Dividend Payable" GL liability and renamed the initial status accordingly (see
|
||||||
|
// makeDividendsClaimable, which inserts 'UNCLAIMED'). forfeitDividendLock already guards on
|
||||||
|
// 'UNCLAIMED'; this query was the last place still looking for the old name, so the job silently
|
||||||
|
// selected zero rows and nothing was ever forfeited.
|
||||||
final List<Long> lockIds = this.jdbcTemplate.queryForList(
|
final List<Long> lockIds = this.jdbcTemplate.queryForList(
|
||||||
"SELECT id FROM m_share_dividend_lock WHERE status = 'LOCKED' AND release_due_date <= CURRENT_DATE", Long.class);
|
"SELECT id FROM m_share_dividend_lock WHERE status = 'UNCLAIMED' AND release_due_date <= CURRENT_DATE", Long.class);
|
||||||
|
|
||||||
for (final Long lockId : lockIds) {
|
for (final Long lockId : lockIds) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+175
-13
@@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
|||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||||
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
|
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
|
||||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
|
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
|
||||||
|
import org.apache.fineract.portfolio.shareaccounts.domain.PurchasedSharesStatusType;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountCharge;
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountCharge;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy;
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy;
|
||||||
@@ -110,7 +111,9 @@ public class ShareAccountDataSerializer {
|
|||||||
private static final Set<String> addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname,
|
private static final Set<String> addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname,
|
||||||
ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname,
|
ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname,
|
||||||
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname,
|
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname,
|
||||||
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname));
|
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname,
|
||||||
|
ShareAccountApiConstants.fundingsource_paramname, ShareAccountApiConstants.advancecollectionid_paramname,
|
||||||
|
ShareAccountApiConstants.additionalcollectionids_paramname));
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper,
|
public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper,
|
||||||
@@ -155,8 +158,10 @@ public class ShareAccountDataSerializer {
|
|||||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.resolutionnumber_paramname).value(resolutionNumber).notBlank();
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.resolutionnumber_paramname).value(resolutionNumber).notBlank();
|
||||||
|
|
||||||
Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element);
|
Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element);
|
||||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull()
|
if (savingsAccountId != null) {
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId)
|
||||||
.longGreaterThanZero();
|
.longGreaterThanZero();
|
||||||
|
}
|
||||||
final Long requestedShares = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
|
final Long requestedShares = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
|
||||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).notNull()
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).notNull()
|
||||||
.longGreaterThanZero();
|
.longGreaterThanZero();
|
||||||
@@ -197,12 +202,15 @@ public class ShareAccountDataSerializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
||||||
if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(clientId, savingsAccountId, DepositAccountType.SAVINGS_DEPOSIT,
|
SavingsAccount savingsAccount = null;
|
||||||
shareProduct.getCurrency().getCode())) {
|
if (savingsAccountId != null) {
|
||||||
|
if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(clientId, savingsAccountId,
|
||||||
|
DepositAccountType.SAVINGS_DEPOSIT, shareProduct.getCurrency().getCode())) {
|
||||||
throw new SavingsAccountNotFoundException(savingsAccountId);
|
throw new SavingsAccountNotFoundException(savingsAccountId);
|
||||||
}
|
}
|
||||||
SavingsAccount savingsAccount = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId,
|
savingsAccount = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId,
|
||||||
DepositAccountType.SAVINGS_DEPOSIT);
|
DepositAccountType.SAVINGS_DEPOSIT);
|
||||||
|
}
|
||||||
final MonetaryCurrency currency = shareProduct.getCurrency();
|
final MonetaryCurrency currency = shareProduct.getCurrency();
|
||||||
Set<ShareAccountCharge> charges = assembleListOfAccountCharges(element, currency.getCode());
|
Set<ShareAccountCharge> charges = assembleListOfAccountCharges(element, currency.getCode());
|
||||||
|
|
||||||
@@ -654,18 +662,24 @@ public class ShareAccountDataSerializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handlechargesOnActivation(final ShareAccount account) {
|
private void handlechargesOnActivation(final ShareAccount account) {
|
||||||
Set<ShareAccountCharge> charges = account.getCharges();
|
// The entry fee is deliberately NOT marked paid here. Activating the account decides the
|
||||||
for (ShareAccountCharge charge : charges) {
|
// member may hold shares; it does not collect anything, and nobody has yet said the cash
|
||||||
if (charge.isActive() && charge.isShareAccountActivation()) {
|
// receipt at the counter has actually been taken.
|
||||||
charge.markAsFullyPaid();
|
//
|
||||||
}
|
// Marking it paid at activation left amount_paid_derived at the full fee and
|
||||||
}
|
// amount_outstanding_derived at zero while nothing had actually been received, so the
|
||||||
|
// settlement step read the fee as already settled and asked the member for the share value
|
||||||
|
// alone ("shares 400.00 + charges 0"). The fee was then never collected at all.
|
||||||
|
//
|
||||||
|
// It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource,
|
||||||
|
// which sets amount_paid_derived and amount_outstanding_derived alongside the flag.
|
||||||
|
//
|
||||||
|
// The charge transaction is still dated to the activation date — that records when the fee
|
||||||
|
// became due, which is a separate thing from when it was paid.
|
||||||
Set<ShareAccountTransaction> transactions = account.getChargeTransactions();
|
Set<ShareAccountTransaction> transactions = account.getChargeTransactions();
|
||||||
for (ShareAccountTransaction transaction : transactions) {
|
for (ShareAccountTransaction transaction : transactions) {
|
||||||
if (transaction.isChargeTransaction()) {
|
if (transaction.isChargeTransaction()) {
|
||||||
transaction.updateTransactionDate(account.getActivatedDate());
|
transaction.updateTransactionDate(account.getActivatedDate());
|
||||||
transaction.updateAmountPaid(transaction.amount());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -739,17 +753,165 @@ public class ShareAccountDataSerializer {
|
|||||||
.failWithCodeNoParameterAddedToErrorCode("purchase.transaction.date.cannot.be.before.existing.transactions");
|
.failWithCodeNoParameterAddedToErrorCode("purchase.transaction.date.cannot.be.before.existing.transactions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The board resolution authorising this tranche. Its own meeting and number: a further
|
||||||
|
// subscription is a separate decision from the one the account was opened under, so it
|
||||||
|
// cannot inherit the account's resolution.
|
||||||
|
final String resolutionNumber = this.fromApiJsonHelper
|
||||||
|
.extractStringNamed(ShareAccountApiConstants.redeemresolutionnumber_paramname, element);
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemresolutionnumber_paramname).value(resolutionNumber).notBlank();
|
||||||
|
final LocalDate meetingDate = this.fromApiJsonHelper
|
||||||
|
.extractLocalDateNamed(ShareAccountApiConstants.redeemmeetingdate_paramname, element);
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemmeetingdate_paramname).value(meetingDate).notNull();
|
||||||
|
|
||||||
|
final String fundingSource = this.fromApiJsonHelper.extractStringNamed(ShareAccountApiConstants.fundingsource_paramname, element);
|
||||||
|
// The List overload, not the varargs one: the varargs version lowercases the incoming value
|
||||||
|
// but compares it against the un-lowercased allowed list, so an upper-case value can never
|
||||||
|
// match it. The List overload lowercases both sides.
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.fundingsource_paramname).value(fundingSource).notBlank()
|
||||||
|
.isOneOfTheseStringValues(List.of(ShareAccountApiConstants.FUNDING_SOURCE_CASH,
|
||||||
|
ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS));
|
||||||
|
|
||||||
|
// On the cash route the money is already in Share Sundry against receipts the cashier
|
||||||
|
// issued, so the tranche has to name them. Validated here rather than at approval: a voucher
|
||||||
|
// problem found later is found once the member has gone.
|
||||||
|
//
|
||||||
|
// A list, not one id: a member often pays a tranche in instalments, and no single receipt
|
||||||
|
// covers it. They are validated as one settlement, so their combined total is what has to
|
||||||
|
// cover the shares.
|
||||||
|
List<Long> advanceCollectionIds = List.of();
|
||||||
|
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
|
||||||
|
advanceCollectionIds = extractAdvanceCollectionIds(element);
|
||||||
|
if (advanceCollectionIds.isEmpty()) {
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull();
|
||||||
|
} else {
|
||||||
|
validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!dataValidationErrors.isEmpty()) {
|
if (!dataValidationErrors.isEmpty()) {
|
||||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||||
}
|
}
|
||||||
final BigDecimal unitPrice = shareProduct.deriveMarketPrice(requestedDate);
|
final BigDecimal unitPrice = shareProduct.deriveMarketPrice(requestedDate);
|
||||||
ShareAccountTransaction purchaseTransaction = new ShareAccountTransaction(requestedDate, sharesRequested, unitPrice);
|
ShareAccountTransaction purchaseTransaction = new ShareAccountTransaction(requestedDate, sharesRequested, unitPrice);
|
||||||
|
purchaseTransaction.updatePurchaseAuthorisationAndFunding(resolutionNumber, meetingDate, fundingSource, advanceCollectionIds, null);
|
||||||
account.addAdditionalPurchasedShares(purchaseTransaction);
|
account.addAdditionalPurchasedShares(purchaseTransaction);
|
||||||
handleAdditionalSharesChargeTransactions(account, purchaseTransaction);
|
handleAdditionalSharesChargeTransactions(account, purchaseTransaction);
|
||||||
actualChanges.put(ShareAccountApiConstants.additionalshares_paramname, purchaseTransaction);
|
actualChanges.put(ShareAccountApiConstants.additionalshares_paramname, purchaseTransaction);
|
||||||
return actualChanges;
|
return actualChanges;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The receipts a cash-funded tranche names — the lead one plus any further instalments.
|
||||||
|
*
|
||||||
|
* Accepts either shape, so a single-voucher caller need not send a list: {@code advanceCollectionId}
|
||||||
|
* alone, or that plus {@code additionalCollectionIds}.
|
||||||
|
*/
|
||||||
|
private List<Long> extractAdvanceCollectionIds(final JsonElement element) {
|
||||||
|
final List<Long> ids = new ArrayList<>();
|
||||||
|
final Long leadId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.advancecollectionid_paramname, element);
|
||||||
|
if (leadId != null) {
|
||||||
|
ids.add(leadId);
|
||||||
|
}
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.additionalcollectionids_paramname, element)) {
|
||||||
|
final JsonArray array = this.fromApiJsonHelper
|
||||||
|
.extractJsonArrayNamed(ShareAccountApiConstants.additionalcollectionids_paramname, element);
|
||||||
|
if (array != null) {
|
||||||
|
for (final JsonElement arrayElement : array) {
|
||||||
|
if (arrayElement != null && !arrayElement.isJsonNull()) {
|
||||||
|
final Long id = arrayElement.getAsLong();
|
||||||
|
if (!ids.contains(id)) {
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the Share Sundry receipts a cash-funded tranche names.
|
||||||
|
*
|
||||||
|
* Each receipt is checked on its own for the things that make it unusable — it does not exist,
|
||||||
|
* it belongs to somebody else, it has already been spent, or it is reserved by another pending
|
||||||
|
* tranche. That last check matters most: without it the same voucher could fund two tranches, so
|
||||||
|
* Share Sundry would be credited once and released twice.
|
||||||
|
*
|
||||||
|
* The value check is the one thing judged across all of them together, because that is how a
|
||||||
|
* member pays in instalments: ₹600 today and ₹400 tomorrow fund a ₹1,000 tranche, though neither
|
||||||
|
* receipt covers it alone.
|
||||||
|
*/
|
||||||
|
private void validateAdvanceCollectionsForTranche(final List<Long> advanceCollectionIds, final ShareAccount account,
|
||||||
|
final Long sharesRequested, final LocalDate requestedDate, final DataValidatorBuilder baseDataValidator) {
|
||||||
|
BigDecimal receiptTotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (final Long advanceCollectionId : advanceCollectionIds) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT receipt_no, voucher_no, client_id, amount, status FROM m_share_advance_collection WHERE id = ?",
|
||||||
|
advanceCollectionId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
|
||||||
|
.failWithCode("share.advance.collection.not.found", "A voucher for this payment could not be found.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Map<String, Object> collection = rows.get(0);
|
||||||
|
// Named in every message below, because with several receipts in play "the voucher is
|
||||||
|
// already used" does not say which one to go and look at.
|
||||||
|
final Object label = collection.get("voucher_no") != null ? collection.get("voucher_no") : collection.get("receipt_no");
|
||||||
|
|
||||||
|
final Number collectionClientId = (Number) collection.get("client_id");
|
||||||
|
if (collectionClientId == null || collectionClientId.longValue() != account.getClientId().longValue()) {
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
|
||||||
|
.failWithCode("share.advance.collection.client.mismatch",
|
||||||
|
"Voucher " + label + " belongs to a different member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only money still sitting in Sundry can fund a tranche. APPLIED has already become
|
||||||
|
// share capital somewhere, and REFUNDED went back to the member.
|
||||||
|
final String status = (String) collection.get("status");
|
||||||
|
if (!"UNAPPLIED".equals(status)) {
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
|
||||||
|
.failWithCode("share.advance.collection.not.unapplied",
|
||||||
|
"Voucher " + label + " is no longer available — it reads " + status + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReservedByAnotherTranche(advanceCollectionId)) {
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
|
||||||
|
.failWithCode("share.advance.collection.already.reserved",
|
||||||
|
"Voucher " + label + " is already reserved against another pending share request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal receiptAmount = (BigDecimal) collection.get("amount");
|
||||||
|
if (receiptAmount != null) {
|
||||||
|
receiptTotal = receiptTotal.add(receiptAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal trancheValue = account.getShareProduct().deriveMarketPrice(requestedDate)
|
||||||
|
.multiply(BigDecimal.valueOf(sharesRequested));
|
||||||
|
if (receiptTotal.compareTo(trancheValue) < 0) {
|
||||||
|
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal)
|
||||||
|
.failWithCode("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal
|
||||||
|
+ ", which does not cover the " + trancheValue + " value of the shares requested.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this receipt is already earmarked by a pending tranche.
|
||||||
|
*
|
||||||
|
* Checks the lead column and the instalment list, because a receipt reserved as somebody's
|
||||||
|
* second instalment is just as spent as one reserved as a lead.
|
||||||
|
*/
|
||||||
|
private boolean isReservedByAnotherTranche(final Long advanceCollectionId) {
|
||||||
|
final Long count = this.jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_share_account_transactions
|
||||||
|
WHERE is_active = true AND status_enum = ?
|
||||||
|
AND (advance_collection_id = ?
|
||||||
|
OR string_to_array(COALESCE(additional_collection_ids, ''), ',') @> ARRAY[CAST(? AS text)])
|
||||||
|
""", Long.class, PurchasedSharesStatusType.APPLIED.getValue(), advanceCollectionId, String.valueOf(advanceCollectionId));
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isTransactionBeforeExistingTransactions(LocalDate requestedDate, boolean isTransactionBeforeExistingTransactions,
|
private boolean isTransactionBeforeExistingTransactions(LocalDate requestedDate, boolean isTransactionBeforeExistingTransactions,
|
||||||
ShareAccount shareAccount) {
|
ShareAccount shareAccount) {
|
||||||
Collection<ShareAccountTransaction> activeTransactions = shareAccount.getShareAccountTransactions().stream()
|
Collection<ShareAccountTransaction> activeTransactions = shareAccount.getShareAccountTransactions().stream()
|
||||||
|
|||||||
+474
@@ -0,0 +1,474 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.shareaccounts.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.accounts.constants.ShareAccountApiConstants;
|
||||||
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
|
||||||
|
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Funds one additional-share tranche — moves the member's money into the Share Sundry holding
|
||||||
|
* account, where it waits for the tranche to be approved.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* <b>Why this is separate from the account-level settlement resource.</b> {@code ShareAdvanceCollectionApiResource}
|
||||||
|
* settles an account's <i>first</i> application. It values the settlement by summing every purchase row on the
|
||||||
|
* account, and guards with account-wide state — {@code collection_applied} — that assumption does not survive a
|
||||||
|
* second tranche: the valuation would charge the member for their entire holding history. So tranche funding is
|
||||||
|
* valued and recorded per transaction, and leaves that account-level state alone.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* <b>Where the money ends up.</b> Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts
|
||||||
|
* the same Dr Sundry / Cr Share Capital pair either way — that pair is Fineract's own, posted by
|
||||||
|
* {@code CashBasedAccountingProcessorForShares} when the transaction turns APPROVED. Nothing here posts it; this only
|
||||||
|
* gets the money into Sundry in the first place.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Cash</b> — already there. The cashier credited Sundry when the receipt was issued, so funding a cash tranche
|
||||||
|
* leaves no net journal entries: it reverses the stock APPLIED posting (which would credit Sundry a second time for
|
||||||
|
* money that came in once) and reserves the receipt against this tranche so it cannot be spent twice.</li>
|
||||||
|
* <li><b>Savings</b> — not there yet, so this debits the member:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Dr Savings Control (tranche value)
|
||||||
|
* Cr Share Sundry (tranche value)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* The member's balance drops on submit, not on approval. The withdrawal goes through Fineract's own savings withdrawal
|
||||||
|
* command so it reaches the member's passbook and statement exactly as a counter withdrawal would; that command posts
|
||||||
|
* its own Dr Savings Control / Cr Cash pair, which is wrong here because no cash left the branch, so those entries are
|
||||||
|
* reversed and replaced with the pair above.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Note that Fineract's stock accounting <i>also</i> posts an APPLIED leg of its own for a new purchase
|
||||||
|
* (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now. Neither route
|
||||||
|
* here works that way, so both reverse that pair in full: on the cash route the cashier already posted exactly those
|
||||||
|
* entries when the voucher was issued, and on the savings route the money came from the member's account rather than the
|
||||||
|
* till. Reversing only the reference debit and keeping the stock Sundry credit would leave Sundry credited twice — once
|
||||||
|
* by the stock leg and once by the pair posted here — so the whole pair goes, and Sundry ends up credited exactly once.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdditionalShareTrancheFundingService {
|
||||||
|
|
||||||
|
/** Fineract convention: 1 = CREDIT, 2 = DEBIT. */
|
||||||
|
private static final int CREDIT = 1;
|
||||||
|
private static final int DEBIT = 2;
|
||||||
|
|
||||||
|
/** acc_product_mapping.financial_account_type for share products. */
|
||||||
|
private static final int FA_SHARES_REFERENCE = 1;
|
||||||
|
private static final int FA_SHARES_SUSPENSE = 2;
|
||||||
|
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||||
|
|
||||||
|
/** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */
|
||||||
|
private static final int PRODUCT_TYPE_SAVINGS = 2;
|
||||||
|
private static final int FA_SAVINGS_CONTROL = 2;
|
||||||
|
|
||||||
|
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||||
|
|
||||||
|
/** m_payment_type.value seeded by changelog 0483, shared with the account-level settlement. */
|
||||||
|
private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Funds a freshly raised tranche according to the route recorded on it.
|
||||||
|
*
|
||||||
|
* Called after the tranche has been saved and flushed, so it has an id to record against, and
|
||||||
|
* within the apply command's transaction — a tranche that cannot be funded must not exist.
|
||||||
|
*/
|
||||||
|
public void fund(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||||
|
final String fundingSource = transaction.getFundingSource();
|
||||||
|
if (fundingSource == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
|
||||||
|
reserveAdvanceCollection(account, transaction);
|
||||||
|
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
|
||||||
|
debitSavingsIntoSundry(account, transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles a tranche's funding once the tranche has been approved and its value released from
|
||||||
|
* Share Sundry into share capital.
|
||||||
|
*
|
||||||
|
* No journal entries of its own — Fineract's APPROVED posting already moved the money. This only
|
||||||
|
* closes the funding record, so a voucher whose money is now share capital cannot be presented
|
||||||
|
* again: until this point the receipt was merely reserved, because approval was still refusable.
|
||||||
|
*/
|
||||||
|
public void settle(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||||
|
if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Every receipt behind the tranche, not just the lead one: a member's later instalments have
|
||||||
|
// become share capital too, and one left UNAPPLIED could be spent a second time.
|
||||||
|
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
|
||||||
|
this.jdbcTemplate.update("""
|
||||||
|
UPDATE m_share_advance_collection
|
||||||
|
SET status = 'APPLIED', applied_share_account_id = ?, applied_on_date = ?
|
||||||
|
WHERE id = ? AND status = 'UNAPPLIED'
|
||||||
|
""", account.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), collectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwinds the funding of a tranche that was rejected, so the member's money does not sit in
|
||||||
|
* Share Sundry against shares that will never be issued.
|
||||||
|
*
|
||||||
|
* Fineract's own REJECTED posting already takes the value back out of Sundry — but it credits
|
||||||
|
* Shares Reference, the till, which is only correct for the cash route. The routes part company
|
||||||
|
* here:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Cash</b> — nothing to do. The money genuinely came in as cash, so returning it to the
|
||||||
|
* reference account is right, and releasing the receipt's reservation lets the clerk spend the
|
||||||
|
* voucher on another tranche or refund it at the counter.</li>
|
||||||
|
* <li><b>Savings</b> — the reference credit is wrong: no cash is going back over the counter.
|
||||||
|
* It is reversed and the member's savings are credited instead, which is where the money came
|
||||||
|
* from on submit.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public void unwind(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||||
|
final String fundingSource = transaction.getFundingSource();
|
||||||
|
if (fundingSource == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
|
||||||
|
releaseAdvanceCollection(transaction);
|
||||||
|
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
|
||||||
|
refundSavings(account, transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the voucher be used again. The receipt itself was never marked APPLIED — approval is
|
||||||
|
* what would have done that — so only the account it was earmarked against is cleared.
|
||||||
|
*/
|
||||||
|
private void releaseAdvanceCollection(final ShareAccountTransaction transaction) {
|
||||||
|
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
|
||||||
|
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = NULL WHERE id = ?", collectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a rejected tranche's money to the savings account it was taken from.
|
||||||
|
*/
|
||||||
|
private void refundSavings(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||||
|
if (account.getSavingsAccount() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final BigDecimal amount = transaction.amount();
|
||||||
|
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Long savingsAccountId = account.getSavingsAccount().getId();
|
||||||
|
final Long productId = account.getShareProduct().getId();
|
||||||
|
final Long officeId = account.getOfficeId();
|
||||||
|
final String currencyCode = account.getCurrency().getCode();
|
||||||
|
|
||||||
|
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||||
|
if (glSavingsControl == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
|
||||||
|
"The Savings Control GL is not mapped for this savings account's product.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||||
|
final String note = "Additional shares rejected, refunded to savings | share account " + account.getAccountNumber();
|
||||||
|
|
||||||
|
// Fineract's own deposit command, so the refund reaches the member's passbook and statement
|
||||||
|
// as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in
|
||||||
|
// over the counter — so the cash debit is replaced by a Sundry debit below.
|
||||||
|
final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note);
|
||||||
|
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
|
||||||
|
|
||||||
|
// Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the
|
||||||
|
// till, so that credit is reversed; the Sundry debit it raised alongside is correct and
|
||||||
|
// stands.
|
||||||
|
reverseStockReferenceCredit(transaction.getId(), productId, officeId);
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final long userId = currentUser.getId();
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final String txnId = "SHTRREF-" + transaction.getId();
|
||||||
|
|
||||||
|
insertEntry(officeId, glSavingsControl, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
|
||||||
|
"Rejected additional shares refunded to savings " + account.getSavingsAccountNo() + " | " + account.getAccountNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverses the Shares Reference credit that Fineract's stock REJECTED posting raised for this
|
||||||
|
* tranche, leaving its Sundry debit standing.
|
||||||
|
*/
|
||||||
|
private void reverseStockReferenceCredit(final Long shareTransactionId, final Long productId, final Long officeId) {
|
||||||
|
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
|
||||||
|
reverseTrancheEntries(shareTransactionId, officeId, glReference, CREDIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverses this tranche's own unreversed entries on one GL account and one side.
|
||||||
|
*
|
||||||
|
* Matched on {@code share_transaction_id} rather than the {@code SH<id>} transaction_id string,
|
||||||
|
* because the APPLIED, APPROVED and REJECTED postings all reuse that same string; narrowing to a
|
||||||
|
* single GL and side is what keeps each reversal to the posting it is meant to undo.
|
||||||
|
*/
|
||||||
|
private void reverseTrancheEntries(final Long shareTransactionId, final Long officeId, final Long glAccountId, final int typeEnum) {
|
||||||
|
this.jdbcTemplate.update("""
|
||||||
|
UPDATE acc_gl_journal_entry SET reversed = true
|
||||||
|
WHERE share_transaction_id = ? AND account_id = ? AND office_id = ? AND type_enum = ? AND reversed = false
|
||||||
|
""", shareTransactionId, glAccountId, officeId, typeEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
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", resolveSettlementPaymentTypeId());
|
||||||
|
payload.addProperty("note", note);
|
||||||
|
|
||||||
|
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()).build();
|
||||||
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
|
return result.getResourceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash route: the money is already in Sundry, so this only ties the receipt to this tranche.
|
||||||
|
*
|
||||||
|
* The receipt is left UNAPPLIED. It is not share capital yet — approval is what releases it —
|
||||||
|
* and marking it APPLIED now would say the money had already become capital. The tranche's own
|
||||||
|
* {@code advance_collection_id} is what stops it funding a second tranche, checked when the
|
||||||
|
* next request is validated.
|
||||||
|
*/
|
||||||
|
private void reserveAdvanceCollection(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||||
|
final List<Long> collectionIds = transaction.getAllAdvanceCollectionIds();
|
||||||
|
if (collectionIds.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.cash.without.voucher",
|
||||||
|
"A cash-funded share request must name the voucher the money was received on.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fineract's stock APPLIED posting raises Dr Cash at Vault / Cr Share Sundry for a new
|
||||||
|
// purchase. On this route the cashier already posted exactly that pair when the voucher was
|
||||||
|
// issued, so leaving it would count the member's money twice — Sundry credited 200 against a
|
||||||
|
// 100 receipt, and the till debited twice for cash that came in once. Both legs are reversed:
|
||||||
|
// submitting a cash-funded tranche moves no money, it only earmarks the receipt.
|
||||||
|
reverseStockAppliedPair(transaction.getId(), account.getShareProduct().getId(), account.getOfficeId());
|
||||||
|
|
||||||
|
for (final Long collectionId : collectionIds) {
|
||||||
|
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = ? WHERE id = ?", account.getId(),
|
||||||
|
collectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Savings route: debits the member and credits Share Sundry.
|
||||||
|
*/
|
||||||
|
private void debitSavingsIntoSundry(final ShareAccount account, final ShareAccountTransaction transaction) {
|
||||||
|
if (account.getSavingsAccount() == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.no.savings",
|
||||||
|
"This share account has no linked savings account to debit.");
|
||||||
|
}
|
||||||
|
final Long savingsAccountId = account.getSavingsAccount().getId();
|
||||||
|
|
||||||
|
final BigDecimal amount = transaction.amount();
|
||||||
|
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.amount.invalid",
|
||||||
|
"The value of the shares requested could not be determined.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
|
||||||
|
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.not.active",
|
||||||
|
"Savings account " + savings.get("account_no") + " is not active.");
|
||||||
|
}
|
||||||
|
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
|
||||||
|
if (balance == null || balance.compareTo(amount) < 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient",
|
||||||
|
"Savings account " + savings.get("account_no") + " does not hold enough to cover this share request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long productId = account.getShareProduct().getId();
|
||||||
|
final Long officeId = account.getOfficeId();
|
||||||
|
final String currencyCode = account.getCurrency().getCode();
|
||||||
|
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||||
|
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||||
|
if (glSavingsControl == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.gl.not.mapped",
|
||||||
|
"The Savings Control GL is not mapped for this savings account's product.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||||
|
final String note = "Additional shares funded from savings | share account " + account.getAccountNumber();
|
||||||
|
|
||||||
|
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
|
||||||
|
// pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch.
|
||||||
|
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note);
|
||||||
|
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
|
||||||
|
|
||||||
|
// Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry,
|
||||||
|
// assuming cash over the counter. BOTH legs are reversed, not just the reference debit:
|
||||||
|
// leaving its Sundry credit standing and then posting the savings pair below would credit
|
||||||
|
// Sundry twice — 200 held against a 100 debit — and the surplus would never clear.
|
||||||
|
reverseStockAppliedPair(transaction.getId(), productId, officeId);
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final long userId = currentUser.getId();
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final String txnId = "SHTRSAV-" + transaction.getId();
|
||||||
|
|
||||||
|
insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
|
||||||
|
"Additional shares debited from savings " + savings.get("account_no") + " | " + account.getAccountNumber());
|
||||||
|
insertEntry(officeId, glSundry, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
|
||||||
|
"Additional shares held in Share Sundry pending approval | " + account.getAccountNumber());
|
||||||
|
|
||||||
|
transaction.updateSavingsDebitRequestId(savingsTxnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverses the Shares Reference debit that Fineract's stock APPLIED posting raised for this
|
||||||
|
* tranche, leaving its Sundry credit standing.
|
||||||
|
*
|
||||||
|
* Scoped to this tranche's own entries and to those two GL accounts and sides specifically, so
|
||||||
|
* the Sundry-to-Share-Capital pair raised later at approval is never caught by it.
|
||||||
|
*/
|
||||||
|
private void reverseStockAppliedPair(final Long shareTransactionId, final Long productId, final Long officeId) {
|
||||||
|
reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"), DEBIT);
|
||||||
|
reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"),
|
||||||
|
CREDIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate,
|
||||||
|
final String note) {
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||||
|
payload.addProperty("transactionDate", txnDate.toString());
|
||||||
|
payload.addProperty("transactionAmount", amount);
|
||||||
|
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
|
||||||
|
payload.addProperty("note", note);
|
||||||
|
|
||||||
|
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString())
|
||||||
|
.build();
|
||||||
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
|
return result.getResourceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveSettlementPaymentTypeId() {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE);
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
return toLong(rows.get(0).get("id"));
|
||||||
|
}
|
||||||
|
final List<Map<String, Object>> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1");
|
||||||
|
if (fallback.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.no.payment.type",
|
||||||
|
"No payment types are configured. Add one before funding shares from savings.");
|
||||||
|
}
|
||||||
|
return toLong(fallback.get(0).get("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
|
||||||
|
return this.jdbcTemplate.queryForMap(
|
||||||
|
"SELECT id, account_no, status_enum, account_balance_derived, product_id FROM m_savings_account WHERE id = ?",
|
||||||
|
savingsAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long savingsControlGlOf(final Long savingsAccountId) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT apm.gl_account_id FROM m_savings_account sa
|
||||||
|
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
|
||||||
|
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
|
||||||
|
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
|
||||||
|
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long requireShareGl(final Long productId, final int financialAccountType, final String label) {
|
||||||
|
try {
|
||||||
|
final Long glId = this.jdbcTemplate.queryForObject(
|
||||||
|
"SELECT gl_account_id FROM acc_product_mapping "
|
||||||
|
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
|
||||||
|
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
||||||
|
if (glId == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.gl.not.mapped",
|
||||||
|
label + " GL is not mapped for share product " + productId + ".");
|
||||||
|
}
|
||||||
|
return glId;
|
||||||
|
} catch (final IncorrectResultSizeDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.share.tranche.gl.not.mapped",
|
||||||
|
label + " GL is not mapped for share product " + productId + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||||
|
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
||||||
|
final String description) {
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||||
|
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||||
|
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||||
|
+ " is_running_balance_calculated, office_running_balance, "
|
||||||
|
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
|
||||||
|
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||||
|
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
|
||||||
|
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
||||||
|
*
|
||||||
|
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC",
|
||||||
|
* which reads back as 00:04 the next day. The instant has to be resolved in the tenant's own
|
||||||
|
* zone first, which is the zone {@code now} was taken in.
|
||||||
|
*/
|
||||||
|
private OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
||||||
|
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime().withOffsetSameInstant(ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long toLong(final Object value) {
|
||||||
|
return value == null ? null : ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int toInt(final Object value) {
|
||||||
|
return value == null ? 0 : ((Number) value).intValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -50,7 +50,9 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
|
|||||||
StringBuilder buff = new StringBuilder()
|
StringBuilder buff = new StringBuilder()
|
||||||
.append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ")
|
.append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ")
|
||||||
.append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ")
|
.append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ")
|
||||||
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate")
|
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate, ")
|
||||||
|
.append("saps.funding_source as fundingSource, saps.advance_collection_id as advanceCollectionId, ")
|
||||||
|
.append("saps.additional_collection_ids as additionalCollectionIds, saps.savings_debit_request_id as savingsDebitRequestId")
|
||||||
.append(" from m_share_account_transactions saps ");
|
.append(" from m_share_account_transactions saps ");
|
||||||
schema = buff.toString();
|
schema = buff.toString();
|
||||||
}
|
}
|
||||||
@@ -72,9 +74,14 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
|
|||||||
final String resolutionNumber = rs.getString("resolutionNumber");
|
final String resolutionNumber = rs.getString("resolutionNumber");
|
||||||
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
|
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
|
||||||
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
|
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
|
||||||
|
final String fundingSource = rs.getString("fundingSource");
|
||||||
|
final Long advanceCollectionId = JdbcSupport.getLong(rs, "advanceCollectionId");
|
||||||
|
final String additionalCollectionIds = rs.getString("additionalCollectionIds");
|
||||||
|
final Long savingsDebitRequestId = JdbcSupport.getLong(rs, "savingsDebitRequestId");
|
||||||
|
|
||||||
return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
|
return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
|
||||||
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
|
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate, fundingSource, advanceCollectionId,
|
||||||
|
additionalCollectionIds, savingsDebitRequestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String schema() {
|
public String schema() {
|
||||||
|
|||||||
+13
-2
@@ -140,6 +140,10 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
|||||||
final ShareProductData productData = (ShareProductData) service.retrieveOne(data.getProductId(), false);
|
final ShareProductData productData = (ShareProductData) service.retrieveOne(data.getProductId(), false);
|
||||||
final BigDecimal currentMarketPrice = deriveMarketPrice(productData);
|
final BigDecimal currentMarketPrice = deriveMarketPrice(productData);
|
||||||
data.setCurrentMarketPrice(currentMarketPrice);
|
data.setCurrentMarketPrice(currentMarketPrice);
|
||||||
|
// Read separately rather than added to the row mapper, which is shared with the list and
|
||||||
|
// template queries that have no use for it.
|
||||||
|
data.setCollectionApplied(this.jdbcTemplate.queryForObject("SELECT COALESCE(collection_applied, false) FROM m_share_account "
|
||||||
|
+ " WHERE id = ?", Boolean.class, id));
|
||||||
if (!includeTemplate) {
|
if (!includeTemplate) {
|
||||||
Collection<ShareAccountDividendData> dividends = this.retrieveAssociatedDividends(id);
|
Collection<ShareAccountDividendData> dividends = this.retrieveAssociatedDividends(id);
|
||||||
data.setDividends(dividends);
|
data.setDividends(dividends);
|
||||||
@@ -467,7 +471,9 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
|||||||
StringBuilder buff = new StringBuilder().append(
|
StringBuilder buff = new StringBuilder().append(
|
||||||
"saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ")
|
"saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ")
|
||||||
.append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ")
|
.append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ")
|
||||||
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate ");
|
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate, ")
|
||||||
|
.append("saps.funding_source as fundingSource, saps.advance_collection_id as advanceCollectionId, ")
|
||||||
|
.append("saps.additional_collection_ids as additionalCollectionIds, saps.savings_debit_request_id as savingsDebitRequestId ");
|
||||||
|
|
||||||
schema = buff.toString();
|
schema = buff.toString();
|
||||||
}
|
}
|
||||||
@@ -489,8 +495,13 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
|
|||||||
final String resolutionNumber = rs.getString("resolutionNumber");
|
final String resolutionNumber = rs.getString("resolutionNumber");
|
||||||
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
|
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
|
||||||
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
|
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
|
||||||
|
final String fundingSource = rs.getString("fundingSource");
|
||||||
|
final Long advanceCollectionId = JdbcSupport.getLong(rs, "advanceCollectionId");
|
||||||
|
final String additionalCollectionIds = rs.getString("additionalCollectionIds");
|
||||||
|
final Long savingsDebitRequestId = JdbcSupport.getLong(rs, "savingsDebitRequestId");
|
||||||
return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
|
return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
|
||||||
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
|
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate, fundingSource, advanceCollectionId,
|
||||||
|
additionalCollectionIds, savingsDebitRequestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String schema() {
|
public String schema() {
|
||||||
|
|||||||
+61
-24
@@ -40,7 +40,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
|
public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularService {
|
||||||
|
|
||||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
|
|
||||||
|
/** Retained profit, where a dividend nobody claimed returns to. */
|
||||||
|
private static final String PROFIT_GL_CODE = "BANKPROFIT01";
|
||||||
|
|
||||||
|
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||||
|
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||||
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
|
|
||||||
private final ShareAccountDividendRepository shareAccountDividendRepository;
|
private final ShareAccountDividendRepository shareAccountDividendRepository;
|
||||||
private final SavingsAccountDomainService savingsAccountDomainService;
|
private final SavingsAccountDomainService savingsAccountDomainService;
|
||||||
@@ -62,12 +68,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real-world UCB accounting: an approved dividend is never credited into a member's savings
|
* Real-world UCB accounting: an approved dividend is never credited into a member's savings account up front -- it
|
||||||
* account up front -- it stays purely a "Dividend Payable" GL liability (see
|
* stays purely a "Dividend Payable" GL liability (see
|
||||||
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the
|
* {@code ShareProductWritePlatformServiceJpaRepositoryImpl.makeDividendsClaimable}) until the member actually
|
||||||
* member actually claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed
|
* claims it (see {@code DividendClaimRequestApiResource}). So an unclaimed dividend past its 3-year deadline has no
|
||||||
* dividend past its 3-year deadline has no savings hold to release and no account balance to
|
* savings hold to release and no account balance to deduct -- the liability is simply written off directly against
|
||||||
* deduct -- the liability is simply written off directly against the bank's profit.
|
* the bank's profit.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
@@ -84,27 +90,54 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
|
|
||||||
postForfeitureJournalEntry(lockId, currencyCode, amount);
|
postForfeitureJournalEntry(lockId, currencyCode, amount);
|
||||||
|
|
||||||
this.jdbcTemplate.update(
|
this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
|
||||||
"UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debit writes off the Dividend Payable liability (it was never going to be paid); credit
|
* The financial year of the declaration a lock came from. "unknown" rather than an exception if the chain is
|
||||||
* increases the bank's Dividend Forfeited Income — real profit, not just a silent write-off.
|
* broken — a missing label must not stop the forfeiture from posting.
|
||||||
* Reads currency straight off the lock row (set from the share account at creation time) --
|
*/
|
||||||
* this never depended on a linked savings account existing. Skipped if the bank hasn't mapped
|
private String financialYearOfLock(final Long lockId) {
|
||||||
* both GL accounts yet, exactly like the declaration-time Reserve entry.
|
final java.util.List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.dividend_period_end_date
|
||||||
|
FROM m_share_dividend_lock l
|
||||||
|
JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
|
||||||
|
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
|
||||||
|
WHERE l.id = ?
|
||||||
|
""", lockId);
|
||||||
|
if (rows.isEmpty() || rows.get(0).get("dividend_period_end_date") == null) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
final LocalDate periodEnd = LocalDate.parse(String.valueOf(rows.get(0).get("dividend_period_end_date")));
|
||||||
|
final int startYear = periodEnd.getMonthValue() >= 4 ? periodEnd.getYear() : periodEnd.getYear() - 1;
|
||||||
|
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes off a dividend nobody came to claim.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Dr Dividend Payable — the obligation ends; it was never going to be paid
|
||||||
|
* Cr Bank Profit
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* Credited straight to Bank Profit rather than routed through an income account: the money never left the society,
|
||||||
|
* so this is a liability that turned out not to be owed, and it returns directly to the retained profit the
|
||||||
|
* dividend was declared out of.
|
||||||
|
*
|
||||||
|
* Currency comes straight off the lock row (set from the share account when the lock was created), so this never
|
||||||
|
* depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the
|
||||||
|
* declaration-time entries.
|
||||||
*/
|
*/
|
||||||
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
|
private void postForfeitureJournalEntry(final Long lockId, final String currencyCode, final BigDecimal amount) {
|
||||||
final Long payableDividendsAccountId = this.jdbcTemplate
|
final Long payableDividendsAccountId = this.jdbcTemplate
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
.stream().findFirst().orElse(null);
|
.stream().findFirst().orElse(null);
|
||||||
final Long forfeitedIncomeAccountId = this.jdbcTemplate
|
final Long bankProfitAccountId = this.jdbcTemplate
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst()
|
||||||
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
|
.orElse(null);
|
||||||
.stream().findFirst().orElse(null);
|
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
|
||||||
if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +145,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||||
final String transactionId = "DIVFORFEIT" + lockId;
|
final String transactionId = "DIVFORFEIT" + lockId;
|
||||||
final String description = "Dividend forfeited to profit — share dividend lock #" + lockId;
|
// Tagged with the year the dividend was DECLARED for, not the year it is being forfeited in —
|
||||||
|
// a 2026-27 dividend forfeits in 2029-30. That is what lets the ledger say how much of each
|
||||||
|
// year's declaration went unclaimed and came back to profit.
|
||||||
|
final String description = "FY " + financialYearOfLock(lockId) + " dividend forfeited to profit — share dividend lock #" + lockId;
|
||||||
final String insertSql = """
|
final String insertSql = """
|
||||||
INSERT INTO acc_gl_journal_entry
|
INSERT INTO acc_gl_journal_entry
|
||||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
||||||
@@ -120,10 +156,11 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
submitted_on_date, transaction_date)
|
submitted_on_date, transaction_date)
|
||||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||||
""";
|
""";
|
||||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
// Debit first, then credit, so the entry ids follow the direction the money travelled.
|
||||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
|
||||||
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
this.jdbcTemplate.update(insertSql, bankProfitAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT,
|
||||||
|
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-14
@@ -55,7 +55,6 @@ import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDat
|
|||||||
import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
|
import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
|
||||||
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
|
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -75,7 +74,8 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
|
|
||||||
private final NoteRepository noteRepository;
|
private final NoteRepository noteRepository;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
/** Moves an additional-share tranche's money into Share Sundry when the tranche is raised. */
|
||||||
|
private final AdditionalShareTrancheFundingService trancheFundingService;
|
||||||
|
|
||||||
private final BusinessEventNotifierService businessEventNotifierService;
|
private final BusinessEventNotifierService businessEventNotifierService;
|
||||||
|
|
||||||
@@ -218,6 +218,9 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
Set<ShareAccountTransaction> transactions = new HashSet<>();
|
Set<ShareAccountTransaction> transactions = new HashSet<>();
|
||||||
transactions.add(transaction);
|
transactions.add(transaction);
|
||||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
||||||
|
// After the stock APPLIED posting, not before: the savings route reverses that
|
||||||
|
// posting's Shares Reference debit, which has to exist to be reversed.
|
||||||
|
this.trancheFundingService.fund(account, transaction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +264,12 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
shareProduct.addSubscribedShares(totalSubsribedShares);
|
shareProduct.addSubscribedShares(totalSubsribedShares);
|
||||||
this.shareProductRepository.save(shareProduct);
|
this.shareProductRepository.save(shareProduct);
|
||||||
|
|
||||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, journalTransactions));
|
// No journal entries here. Approving an application decides the member may hold the
|
||||||
|
// shares; it does not collect anything from them. Posting Dr Share Sundry / Cr Share
|
||||||
|
// Capital at this point issues share capital against a Sundry balance that holds no
|
||||||
|
// receipt for it — in effect funding one member's shares out of money other members
|
||||||
|
// paid in. The entries belong to settlement, where the money actually arrives: applying
|
||||||
|
// the member's cash receipt (ShareAdvanceCollectionApiResource).
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account));
|
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account));
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
@@ -333,9 +340,17 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LocalDate transactionDate = DateUtils.getBusinessLocalDate();
|
LocalDate transactionDate = DateUtils.getBusinessLocalDate();
|
||||||
|
// Reverts whatever approveShareAccount() posted -- which, per the comment there, is
|
||||||
|
// nothing. Kept anyway as a no-op safety net for any transaction that predates that fix.
|
||||||
this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate);
|
this.journalEntryWritePlatformService.revertShareAccountJournalEntries(journalEntryTransactions, transactionDate);
|
||||||
journalEntryWritePlatformService.createJournalEntriesForShares(
|
// Deliberately NOT re-running the accounting bridge for the transactions this undo just
|
||||||
populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
|
// put back to APPLIED. That bridge's "isApplied" branch posts Dr Shares Reference (Cash at
|
||||||
|
// Vault) / Cr Share Sundry, assuming cash was already received at the counter for these
|
||||||
|
// shares -- exactly the phantom entry approveShareAccount()/createShareAccount() were
|
||||||
|
// fixed to stop posting. Calling it here reintroduced the same phantom entry by a side
|
||||||
|
// door: undo-approve reverting a transaction to APPLIED status looks, to the bridge, just
|
||||||
|
// like a brand-new application being submitted. Money only ever reaches or leaves share
|
||||||
|
// capital through ShareAdvanceCollectionApiResource's own explicit Sundry-routed postings.
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(jsonCommand.commandId()) //
|
.withCommandId(jsonCommand.commandId()) //
|
||||||
.withEntityId(accountId) //
|
.withEntityId(accountId) //
|
||||||
@@ -357,14 +372,16 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
this.shareAccountRepository.save(account);
|
this.shareAccountRepository.save(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cashier collected cash and entered GL directly via the transaction entry
|
// Charges are deliberately NOT marked paid here. Attaching an entry fee when the
|
||||||
// workstation (DR Cash → CR Share Sundry GL for share amount, and DR Cash → CR
|
// account is created records what the member owes; activating the account decides they
|
||||||
// Charges GL for charges). Mark charges as paid to reflect cashier's collection
|
// may hold shares. Neither collects anything, and nobody has yet said the cash receipt
|
||||||
// without posting duplicate GL entries — the approval step already posted the
|
// at the counter has actually been taken.
|
||||||
// share purchase GL (DR Share Sundry → CR Share Capital) via Fineract accounting.
|
//
|
||||||
jdbcTemplate.update(
|
// Marking it paid at activation left the fee showing as settled while it was still
|
||||||
"UPDATE m_share_account_charge SET is_paid_derived = true WHERE account_id = ?",
|
// outstanding, so the settlement step then asked the member for the share value alone
|
||||||
accountId);
|
// and the fee was never collected at all. It is marked paid where the money actually
|
||||||
|
// arrives: ShareAdvanceCollectionApiResource, which sets amount_paid_derived and
|
||||||
|
// amount_outstanding_derived alongside the flag.
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(jsonCommand.commandId()) //
|
.withCommandId(jsonCommand.commandId()) //
|
||||||
@@ -396,6 +413,11 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
totalSubscribedShares += transaction.getTotalShares();
|
totalSubscribedShares += transaction.getTotalShares();
|
||||||
}
|
}
|
||||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
||||||
|
// The APPROVED posting above has released the money from Share Sundry into
|
||||||
|
// share capital, so the funding behind it can now be closed off.
|
||||||
|
for (ShareAccountTransaction transaction : transactions) {
|
||||||
|
this.trancheFundingService.settle(account, transaction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!totalSubscribedShares.equals(Long.valueOf(0))) {
|
if (!totalSubscribedShares.equals(Long.valueOf(0))) {
|
||||||
ShareProduct shareProduct = account.getShareProduct();
|
ShareProduct shareProduct = account.getShareProduct();
|
||||||
@@ -430,6 +452,11 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
transactions.add(transaction);
|
transactions.add(transaction);
|
||||||
}
|
}
|
||||||
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
|
||||||
|
// After the stock REJECTED posting, whose Shares Reference credit the savings
|
||||||
|
// route reverses in favour of refunding the member.
|
||||||
|
for (ShareAccountTransaction transaction : transactions) {
|
||||||
|
this.trancheFundingService.unwind(account, transaction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user