Compare commits
29
Commits
e02ab51ee1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ef5edfd8 | ||
|
|
9df8e924a1 | ||
|
|
1922455996 | ||
|
|
cca4ae583f | ||
|
|
99a7e04872 | ||
|
|
ac7873a51e | ||
|
|
643427bd1a | ||
|
|
6c7c0f8445 | ||
|
|
88157ee9d6 | ||
|
|
760fff8d3e | ||
|
|
d7ff4e4a7b | ||
|
|
7068d65422 | ||
|
|
fd827dec27 | ||
|
|
aa264097e7 | ||
|
|
16c67f6701 | ||
|
|
e846892e9f | ||
|
|
56d98bac8d | ||
|
|
0d6066e0b6 | ||
|
|
4499b8afad | ||
|
|
11a206f42a | ||
|
|
11a0d502be | ||
|
|
2afd4aaf83 | ||
|
|
e5dd6af366 | ||
|
|
ce8bb7e5ca | ||
|
|
82336cbe98 | ||
|
|
9436eab129 | ||
|
|
1408bddf58 | ||
|
|
3a37a5f90f | ||
|
|
e7417d16a6 |
+1
-1
@@ -154,7 +154,7 @@ gitVersioning.apply {
|
||||
}
|
||||
}
|
||||
rev {
|
||||
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
|
||||
version = '1.15.0-SNAPSHOT'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -477,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData);
|
||||
// Income
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData);
|
||||
|
||||
+2
@@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository<Teller, Long>, JpaSpecif
|
||||
Collection<Teller> findTellerByOfficeId(Long officeId);
|
||||
|
||||
long countByDeletedFalse();
|
||||
|
||||
long countByOfficeIdAndDeletedFalse(Long officeId);
|
||||
}
|
||||
|
||||
+5
@@ -58,4 +58,9 @@ public class TellerRepositoryWrapper {
|
||||
public boolean existsAny() {
|
||||
return this.repository.countByDeletedFalse() > 0;
|
||||
}
|
||||
|
||||
/** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */
|
||||
public boolean existsInOffice(final Long officeId) {
|
||||
return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce
|
||||
public TellerAlreadyExistsException() {
|
||||
super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
public TellerAlreadyExistsException(final String officeName) {
|
||||
super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.",
|
||||
officeName);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -270,6 +270,7 @@ public final class AccountingConstants {
|
||||
INCOME_FROM_INTEREST(12), //
|
||||
LOSSES_WRITTEN_OFF(13), //
|
||||
ESCHEAT_LIABILITY(14), //
|
||||
INTEREST_PAYABLE(17), //
|
||||
/**
|
||||
* Where the paise dropped from a cash closure are absorbed.
|
||||
*
|
||||
|
||||
+1
@@ -163,6 +163,7 @@ public class CommandSourceService {
|
||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "RECURRINGDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||
return false;
|
||||
|
||||
+9
@@ -3157,6 +3157,15 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder extendTenureRecurringDepositAccount(final Long accountId) {
|
||||
this.actionName = "EXTENDTENURE";
|
||||
this.entityName = "RECURRINGDEPOSITACCOUNT";
|
||||
this.entityId = accountId;
|
||||
this.savingsId = accountId;
|
||||
this.href = "/recurringdepositaccounts/" + accountId + "?command=extendTenure";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder prematureCloseRecurringDepositAccount(final Long accountId) {
|
||||
this.actionName = "PREMATURECLOSE";
|
||||
this.entityName = "RECURRINGDEPOSITACCOUNT";
|
||||
|
||||
+3
@@ -43,6 +43,7 @@ public enum JobName {
|
||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||
@@ -74,6 +75,8 @@ public enum JobName {
|
||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
||||
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
|
||||
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
|
||||
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
|
||||
; //
|
||||
|
||||
private final String name;
|
||||
|
||||
+14
-1
@@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType {
|
||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
|
||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
|
||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), //
|
||||
TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
@@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType {
|
||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||
case 500 -> PARTIAL_REINVEST;
|
||||
case 600 -> TRANSFER_TO_GL;
|
||||
default -> INVALID;
|
||||
};
|
||||
}
|
||||
@@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType {
|
||||
return this.equals(PARTIAL_REINVEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash.
|
||||
* Only the fixed-deposit premature-closure handler implements it -- see
|
||||
* DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it
|
||||
* in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the
|
||||
* terminal "pay the balance out in cash" branch.
|
||||
*/
|
||||
public boolean isTransferToGl() {
|
||||
return this.equals(TRANSFER_TO_GL);
|
||||
}
|
||||
|
||||
// TODO: do we really need this?!?
|
||||
public static Object[] integerValues() {
|
||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
||||
|
||||
+26
-1
@@ -212,7 +212,18 @@ public final class DepositsApiConstants {
|
||||
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
||||
public static final String transferDescriptionParamName = "transferDescription";
|
||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||
// Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are
|
||||
// credited straight to this GL instead of a savings account or cash.
|
||||
public static final String toGlAccountIdParamName = "toGlAccountId";
|
||||
public static final String reinvestAmountParamName = "reinvestAmount";
|
||||
// Lets a maker waive the product's configured pre-closure penalty for one premature closure --
|
||||
// see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient
|
||||
// per-request flag rather than a stored account field.
|
||||
public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty";
|
||||
// Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike
|
||||
// waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their
|
||||
// penalty and why.
|
||||
public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId";
|
||||
public static final String savingsAccounts = "savingsAccounts";
|
||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||
@@ -241,7 +252,7 @@ public final class DepositsApiConstants {
|
||||
SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(),
|
||||
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(),
|
||||
SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName,
|
||||
SavingsApiConstants.taxGroupIdParamName));
|
||||
SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName));
|
||||
|
||||
private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
|
||||
@@ -379,6 +390,8 @@ public final class DepositsApiConstants {
|
||||
fixedDepositRequestData.add("paymentChannelToFundSourceMappings");
|
||||
fixedDepositRequestData.add("feeToIncomeAccountMappings");
|
||||
fixedDepositRequestData.add("penaltyToIncomeAccountMappings");
|
||||
fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
|
||||
fixedDepositRequestData.add("otherGlMappingsMap");
|
||||
return fixedDepositRequestData;
|
||||
}
|
||||
|
||||
@@ -419,6 +432,10 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add("prematureWithdrawalOption");
|
||||
recurringDepositRequestData.add("tenureFlexibility");
|
||||
recurringDepositRequestData.add("lienMarked");
|
||||
recurringDepositRequestData.add("lienPercentage");
|
||||
recurringDepositRequestData.add("missedInstallmentGraceDays");
|
||||
recurringDepositRequestData.add("missedInstallmentPenaltyType");
|
||||
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
|
||||
recurringDepositRequestData.add("withdrawalFeeForTransfers");
|
||||
recurringDepositRequestData.add("enableLockinPeriod");
|
||||
recurringDepositRequestData.add("maturityInstructions");
|
||||
@@ -478,6 +495,10 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add("prematureWithdrawalOption");
|
||||
recurringDepositRequestData.add("tenureFlexibility");
|
||||
recurringDepositRequestData.add("lienMarked");
|
||||
recurringDepositRequestData.add("lienPercentage");
|
||||
recurringDepositRequestData.add("missedInstallmentGraceDays");
|
||||
recurringDepositRequestData.add("missedInstallmentPenaltyType");
|
||||
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
|
||||
recurringDepositRequestData.add("maturityInstructions");
|
||||
recurringDepositRequestData.add("creditMaturityProceeds");
|
||||
recurringDepositRequestData.add("penaltyGracePeriodMissedInstallments");
|
||||
@@ -490,6 +511,8 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add("paymentChannelToFundSourceMappings");
|
||||
recurringDepositRequestData.add("feeToIncomeAccountMappings");
|
||||
recurringDepositRequestData.add("penaltyToIncomeAccountMappings");
|
||||
recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
|
||||
recurringDepositRequestData.add("otherGlMappingsMap");
|
||||
return recurringDepositRequestData;
|
||||
}
|
||||
|
||||
@@ -633,6 +656,8 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add(SavingsApiConstants.balanceSwapOptionParamName);
|
||||
recurringDepositRequestData.add(SavingsApiConstants.msmeParamName);
|
||||
recurringDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
||||
// Lien Marked: how much of this deposit may be given as loan, capped by its product's percentage.
|
||||
recurringDepositRequestData.add("lienPercentage");
|
||||
recurringDepositRequestData.add(isSeniorCitizenParamName);
|
||||
recurringDepositRequestData.add(seniorCitizenInterestRateParamName);
|
||||
return recurringDepositRequestData;
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not
|
||||
* just the ones that waive the penalty, so the audit trail always shows a reason rather than only
|
||||
* recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this
|
||||
* type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a
|
||||
* free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the
|
||||
* server regardless of what a client happened to also send for the plain waive-penalty flag.
|
||||
*/
|
||||
@Getter
|
||||
public enum PrematureClosureReasonType {
|
||||
|
||||
INVALID(0, "prematureClosureReasonType.invalid", false), //
|
||||
NORMAL(100, "prematureClosureReasonType.normal", false), //
|
||||
PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), //
|
||||
CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), //
|
||||
DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), //
|
||||
NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
/** True when this reason, by itself, waives the pre-closure penalty regardless of any manual
|
||||
* waive-penalty flag also sent -- see the class javadoc. */
|
||||
private final boolean autoWaived;
|
||||
|
||||
PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) {
|
||||
this.value = value;
|
||||
this.code = code;
|
||||
this.autoWaived = autoWaived;
|
||||
}
|
||||
|
||||
public static PrematureClosureReasonType fromInt(final Integer v) {
|
||||
if (v == null) {
|
||||
return INVALID;
|
||||
}
|
||||
return switch (v) {
|
||||
case 100 -> NORMAL;
|
||||
case 200 -> PREMATURE_EXTENSION;
|
||||
case 300 -> CONVERSION_TO_ANOTHER_TD;
|
||||
case 400 -> DECEASED_SETTLEMENT;
|
||||
case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE;
|
||||
default -> INVALID;
|
||||
};
|
||||
}
|
||||
|
||||
public boolean isInvalid() {
|
||||
return this.equals(INVALID);
|
||||
}
|
||||
}
|
||||
+14
@@ -234,6 +234,20 @@ public final class SavingsAccountData implements Serializable {
|
||||
return this.transferInterestToLinkedAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a recurring deposit opened under a product whose Interest Calculation Type is "Simple": its interest
|
||||
* never compounds. Transient because it is only read by the batch interest-posting engine.
|
||||
*/
|
||||
private transient boolean simpleInterest;
|
||||
|
||||
public void setSimpleInterest(final boolean simpleInterest) {
|
||||
this.simpleInterest = simpleInterest;
|
||||
}
|
||||
|
||||
public boolean isSimpleInterest() {
|
||||
return this.simpleInterest;
|
||||
}
|
||||
|
||||
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
||||
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
||||
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
||||
|
||||
+6
@@ -180,6 +180,12 @@ public final class SavingsHelper {
|
||||
immediateWithdrawalOfInterest);
|
||||
}
|
||||
|
||||
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
|
||||
LocalDate accountLockedUntil, Boolean immediateWithdrawalOfInterest, final boolean simpleInterest) {
|
||||
return COMPOUND_INTEREST_HELPER.calculateInterestForAllPostingPeriods(currency, allPeriods, accountLockedUntil,
|
||||
immediateWithdrawalOfInterest, simpleInterest);
|
||||
}
|
||||
|
||||
public Collection<Long> fetchPostInterestTransactionIds(Long accountId) {
|
||||
return this.accountTransfersReadPlatformService.fetchPostInterestTransactionIds(accountId);
|
||||
}
|
||||
|
||||
+35
-1
@@ -40,6 +40,18 @@ public class CompoundInterestHelper {
|
||||
*/
|
||||
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
|
||||
LocalDate lockUntil, Boolean interestTransferEnabled) {
|
||||
return calculateInterestForAllPostingPeriods(currency, allPeriods, lockUntil, interestTransferEnabled, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param simpleInterest
|
||||
* - when true, earned interest is never added back to the balance it is earned on, so every period
|
||||
* earns on the principal alone (a recurring deposit whose product's Interest Calculation Type is
|
||||
* "Simple"). Compounding and posting periods still decide when interest is recognised and posted;
|
||||
* they just stop changing how much is earned.
|
||||
*/
|
||||
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
|
||||
LocalDate lockUntil, Boolean interestTransferEnabled, final boolean simpleInterest) {
|
||||
|
||||
// sum up the 'rounded' values that are posted each posting period
|
||||
Money interestEarned = Money.zero(currency);
|
||||
@@ -47,7 +59,8 @@ public class CompoundInterestHelper {
|
||||
// total interest earned in previous periods but not yet recognised
|
||||
BigDecimal compoundedInterest = BigDecimal.ZERO;
|
||||
BigDecimal unCompoundedInterest = BigDecimal.ZERO;
|
||||
final CompoundInterestValues compoundInterestValues = new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
|
||||
final CompoundInterestValues compoundInterestValues = simpleInterest ? new SimpleInterestValues()
|
||||
: new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
|
||||
for (final PostingPeriod postingPeriod : allPeriods) {
|
||||
|
||||
final BigDecimal interestEarnedThisPeriod = postingPeriod.calculateInterest(compoundInterestValues);
|
||||
@@ -67,4 +80,25 @@ public class CompoundInterestHelper {
|
||||
|
||||
return interestEarned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest values that refuse to compound: whatever a compounding period hands over to be compounded is dropped,
|
||||
* so the balance interest is earned on never includes interest already earned.
|
||||
*/
|
||||
private static final class SimpleInterestValues extends CompoundInterestValues {
|
||||
|
||||
SimpleInterestValues() {
|
||||
super(BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCompoundedInterest(final BigDecimal interestToBeCompounded) {
|
||||
super.setCompoundedInterest(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSimpleInterest() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -50,4 +50,9 @@ public class CompoundInterestValues {
|
||||
this.uncompoundedInterest = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
/** True when earned interest must never earn interest itself - not across periods, and not within one either. */
|
||||
public boolean isSimpleInterest() {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+12
@@ -94,6 +94,18 @@ public final class DailyCompoundingPeriod implements CompoundingPeriod {
|
||||
return interestEarned;
|
||||
}
|
||||
|
||||
/** Interest on each day's balance alone, none of it added to the balance - for simple interest. */
|
||||
public BigDecimal calculateSimpleInterest(final BigDecimal interestRateAsFraction, final long daysInYear,
|
||||
final BigDecimal minBalanceForInterestCalculation, final BigDecimal overdraftInterestRateAsFraction,
|
||||
final BigDecimal minOverdraftForInterestCalculation) {
|
||||
BigDecimal interestEarned = BigDecimal.ZERO;
|
||||
for (final EndOfDayBalance balance : this.endOfDayBalances) {
|
||||
interestEarned = interestEarned.add(balance.calculateInterestOnBalance(BigDecimal.ZERO, interestRateAsFraction, daysInYear,
|
||||
minBalanceForInterestCalculation, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation));
|
||||
}
|
||||
return interestEarned;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalDateInterval getPeriodInterval() {
|
||||
return this.periodInterval;
|
||||
|
||||
+11
-4
@@ -301,10 +301,17 @@ public final class PostingPeriod {
|
||||
// to be applied to the balanced for interest calculation
|
||||
for (final CompoundingPeriod compoundingPeriod : this.compoundingPeriods) {
|
||||
|
||||
final BigDecimal interestUnrounded = compoundingPeriod.calculateInterest(this.interestCompoundingType,
|
||||
this.interestCalculationType, compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction,
|
||||
this.daysInYear, this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
|
||||
this.minOverdraftForInterestCalculation.getAmount());
|
||||
// A daily compounding period compounds day by day inside itself, so simple interest has to bypass it - zeroing
|
||||
// what is carried between periods alone still left a month's interest earning interest within the month.
|
||||
final BigDecimal interestUnrounded = compoundInterestValues.isSimpleInterest()
|
||||
&& compoundingPeriod instanceof DailyCompoundingPeriod dailyPeriod
|
||||
? dailyPeriod.calculateSimpleInterest(this.interestRateAsFraction, this.daysInYear,
|
||||
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
|
||||
this.minOverdraftForInterestCalculation.getAmount())
|
||||
: compoundingPeriod.calculateInterest(this.interestCompoundingType, this.interestCalculationType,
|
||||
compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction, this.daysInYear,
|
||||
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
|
||||
this.minOverdraftForInterestCalculation.getAmount());
|
||||
BigDecimal unCompoundedInterest = compoundInterestValues.getuncompoundedInterest().add(interestUnrounded);
|
||||
compoundInterestValues.setuncompoundedInterest(unCompoundedInterest);
|
||||
LocalDate compoundingPeriodEndDate = compoundingPeriod.getPeriodInterval().endDate();
|
||||
|
||||
+4
@@ -801,6 +801,10 @@ public final class SavingsEnumerations {
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
||||
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
||||
break;
|
||||
case TRANSFER_TO_GL:
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(),
|
||||
DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account");
|
||||
break;
|
||||
}
|
||||
return optionData;
|
||||
}
|
||||
|
||||
+41
-2
@@ -18,20 +18,29 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.documentmanagement.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformService {
|
||||
|
||||
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
|
||||
private static final int MAX_CAUSE_DEPTH = 10;
|
||||
|
||||
private final ContentStoreService storeService;
|
||||
private final DocumentRepository documentRepository;
|
||||
private final DocumentMapper documentMapper;
|
||||
@@ -52,9 +61,39 @@ public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformServ
|
||||
public DocumentContent retrieveDocumentContent(final String entityType, final Long entityId, final Long documentId) {
|
||||
final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId)
|
||||
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId));
|
||||
final var is = storeService.download(doc.getLocation());
|
||||
try {
|
||||
final var is = storeService.download(doc.getLocation());
|
||||
return documentMapper.map(doc, is);
|
||||
} catch (final AbstractPlatformDomainRuleException e) {
|
||||
throw e;
|
||||
} catch (final Exception e) {
|
||||
if (isMissingFromContentStore(e)) {
|
||||
// The m_document row survives but the file it points at does not — the usual cause is
|
||||
// a database copied between environments without its file store (see the identical
|
||||
// guard in ImageReadPlatformServiceImpl). For the caller that is simply "this document
|
||||
// has no content", not a server fault, so report it the same way as a missing document
|
||||
// instead of letting a raw ContentStoreException surface as a 500.
|
||||
log.warn("Document {} for {} {} is recorded but missing from the content store; treating it as not found", documentId,
|
||||
entityType, entityId);
|
||||
throw new DocumentNotFoundException(entityType, entityId, documentId);
|
||||
}
|
||||
throw new DocumentInvalidRequestException(e);
|
||||
}
|
||||
}
|
||||
|
||||
return documentMapper.map(doc, is);
|
||||
/**
|
||||
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
|
||||
* way up (the file store rethrows as ContentStoreException).
|
||||
*/
|
||||
private boolean isMissingFromContentStore(final Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+45
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
|
||||
"glAccountId", glAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to draw more out of a holding account than it holds.
|
||||
*
|
||||
* <p>
|
||||
* A suspense or sundry account exists to hold money on its way somewhere else, so what is in it
|
||||
* is the whole of what can come out. Drawing more leaves it with a negative balance, and a
|
||||
* liability with a negative balance is not a small number - it says the bank owes less than
|
||||
* nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw,
|
||||
* and the figure is small enough to sit unnoticed inside a head that totals positive.
|
||||
*
|
||||
* <p>
|
||||
* Checked here rather than at each call site because the accounts this protects are reached by
|
||||
* several features posting directly through JDBC, and a rule enforced in one of them is a rule
|
||||
* the next one added will not have.
|
||||
*
|
||||
* @param glAccountId the account being drawn on.
|
||||
* @param amount how much the caller wants to take out.
|
||||
* @param what the account's role in the caller's own words, for the message a clerk reads.
|
||||
*/
|
||||
public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId,
|
||||
final java.math.BigDecimal amount, final String what) {
|
||||
if (glAccountId == null || amount == null || amount.signum() <= 0) {
|
||||
return;
|
||||
}
|
||||
// Credits less debits: these are liability-natured holding accounts, so the balance is what
|
||||
// is still owed onward. Reversed entries and the entries they reverse both drop out - a
|
||||
// reversal pair nets to nothing, and counting either side alone moves the balance by the
|
||||
// whole amount.
|
||||
final java.math.BigDecimal held = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ?
|
||||
AND (reversed IS NULL OR reversed = false)
|
||||
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""", java.math.BigDecimal.class, glAccountId);
|
||||
final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held;
|
||||
if (balance.compareTo(amount) < 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance",
|
||||
what + " holds " + balance + " but " + amount + " was asked of it. Drawing the "
|
||||
+ "difference would leave the account with a negative balance, which a "
|
||||
+ "holding account cannot have.",
|
||||
"glAccountId", glAccountId, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+156
-52
@@ -27,6 +27,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
||||
@@ -42,6 +43,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA
|
||||
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
||||
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
|
||||
import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO;
|
||||
import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO;
|
||||
@@ -82,6 +84,7 @@ import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionE
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AccountingProcessorHelper {
|
||||
|
||||
@@ -647,7 +650,7 @@ public class AccountingProcessorHelper {
|
||||
*/
|
||||
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
|
||||
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
|
||||
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
|
||||
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal, final boolean payableCarriesAccrual) {
|
||||
|
||||
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
|
||||
@@ -661,37 +664,103 @@ public class AccountingProcessorHelper {
|
||||
final GLAccount payableAccount = Optional
|
||||
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
|
||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
|
||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Recurring Deposit Interest Payable",
|
||||
"Interest Payable", "Interest Payable Account")))
|
||||
.orElse(controlAccount);
|
||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
||||
// The RD product screen saves its paid account as "Recurring Deposit Interest Paid"; without that name here an
|
||||
// RD's interest skipped the paid leg even when the product had one mapped.
|
||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Recurring Deposit Interest Paid", "Interest Paid",
|
||||
"Interest Paid Account"))
|
||||
.orElse(payableAccount);
|
||||
|
||||
// Stage 1: the expense is recognised against whatever the liability sits in right now -- the
|
||||
// named Payable account if the product has one, or straight against Savings Control if it
|
||||
// doesn't. This was missing entirely: every product, configured or not, posted zero journal
|
||||
// entries for interest until this leg existed, since a product with no Payable/Paid names
|
||||
// configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
|
||||
// to post in their place.
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
|
||||
savingsProductId, payableAccount.getId(), payableAccount.getName(), paidAccount.getId(), paidAccount.getName(),
|
||||
controlAccount.getId(), controlAccount.getName(), interestOnSavingsAccount.getId(), interestOnSavingsAccount.getName(),
|
||||
amount, isReversal);
|
||||
|
||||
// The interest expense is not raised again here. It was just raised above, and this posting
|
||||
// only settles that recognised debt onward to where it is actually paid out from -- raising
|
||||
// the expense a second time would put the same interest on the P&L twice.
|
||||
//
|
||||
// Stage 2: the liability moves from the payable to the account it is paid out of. Where the
|
||||
// product names no paid account both resolve to the payable and this says nothing, so it is
|
||||
// skipped and the payout comes straight out of the payable.
|
||||
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
|
||||
if (!payableIsPaid) {
|
||||
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
// A product that names no payable falls back to Savings Control above. The chain below would
|
||||
// then open with Dr Savings Control / Cr Interest On Savings and close with its exact
|
||||
// reverse, the two cancelling: the expense never charged and the member never credited. Such
|
||||
// a product has no liability to draw down, so it keeps the single pair it always posted.
|
||||
if (payableAccount.getId().equals(controlAccount.getId())) {
|
||||
log.warn("createInterestPostingJournalEntriesForSavings: payableAccount == controlAccount [id={}], posting standard 2-leg entry",
|
||||
payableAccount.getId());
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
|
||||
amount, isReversal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 3: into the member's own balance.
|
||||
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
||||
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
// The interest is charged to expense and raised as a liability first, for whatever part of
|
||||
// it no accrual has already covered.
|
||||
//
|
||||
// The chain below draws the payable DOWN, so it assumes an accrual already credited it for
|
||||
// the period. Where none ran - a product posting on cash accounting, or a period the accrual
|
||||
// job never covered - the payable went negative by the interest and the expense netted to
|
||||
// nothing across the next two legs, leaving the charge off the P&L entirely. A liability
|
||||
// account cannot hold a negative balance: the bank does not owe less than nothing.
|
||||
//
|
||||
// So the shortfall is raised here rather than assumed away. Where the accrual DID run, the
|
||||
// payable already carries the interest and this leg writes nothing - the balance below is
|
||||
// enough, and raising it again would double the liability.
|
||||
//
|
||||
// Only a liability payable is topped up. A bank that maps an expense GL as the payable books
|
||||
// the cost there directly: payableBalance reads credit minus debit, so an expense's normal
|
||||
// debit balance would come back negative and every posting would raise a shortfall larger
|
||||
// than the interest itself.
|
||||
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
|
||||
if (!payableCarriesAccrual) {
|
||||
// Cash basis: nothing ever accrues into the payable, so whatever already sits on it - an opening balance
|
||||
// brought over at migration, or another product's interest sharing the GL - is not this interest. Reading
|
||||
// it as "already accrued" skipped the expense and drew that balance down instead, leaving the cost off the
|
||||
// P&L. The whole amount is charged and raised, and a reversal or clawback takes it back the same way.
|
||||
if (payableIsLiability) {
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
|
||||
amount, isReversal);
|
||||
}
|
||||
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId,
|
||||
transactionDate, amount, isReversal);
|
||||
return;
|
||||
}
|
||||
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
|
||||
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
|
||||
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
|
||||
alreadyAccrued, shortfall, payableIsLiability, isReversal);
|
||||
if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 1 [Dr {} / Cr {} = {}]",
|
||||
interestOnSavingsAccount.getName(), payableAccount.getName(), shortfall);
|
||||
// Stage 1: Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
|
||||
shortfall, false);
|
||||
}
|
||||
|
||||
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId, transactionDate,
|
||||
amount, isReversal);
|
||||
}
|
||||
|
||||
/** Stages 2 and 3: out of the payable, through the paid account when the product names one, into the member. */
|
||||
private void postPayableToMember(final Office office, final String currencyCode, final GLAccount payableAccount,
|
||||
final GLAccount paidAccount, final GLAccount controlAccount, final Long savingsId, final String transactionId,
|
||||
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
|
||||
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
|
||||
if (hasSeparatePaidAccount) {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
|
||||
payableAccount.getName(), paidAccount.getName(), amount, paidAccount.getName(), controlAccount.getName(), amount);
|
||||
// Stage 2: Out of the payable, into the interest-paid transit/clearing account
|
||||
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
|
||||
// Stage 3: Out of the interest-paid account, into the member's control account
|
||||
postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
} else {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: no separate paidAccount, posting direct payout [Dr {} / Cr {} = {}]",
|
||||
payableAccount.getName(), controlAccount.getName(), amount);
|
||||
// Payout directly from payable into member's control account
|
||||
postSavingsLeg(office, currencyCode, payableAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -733,34 +802,69 @@ public class AccountingProcessorHelper {
|
||||
*
|
||||
* @return the account, or null where the product does not configure this name
|
||||
*/
|
||||
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
|
||||
if (savingsProductId == null) {
|
||||
/**
|
||||
* What the interest payable account currently holds for this office, on its own liability side.
|
||||
*
|
||||
* <p>
|
||||
* Read so an interest posting can tell an accrual that already ran from one that did not. The
|
||||
* posting chain draws the payable down; where nothing had raised it, the account went negative
|
||||
* and the expense netted to nothing, so the charge never reached the P&L at all. The caller
|
||||
* raises only the shortfall, which is nil when the accrual did run.
|
||||
*
|
||||
* <p>
|
||||
* Credits less debits, because a payable is a liability: the balance is what the bank still
|
||||
* owes. Floored at zero by the caller rather than here - a negative balance is a real state to
|
||||
* report, and the caller's arithmetic needs the true figure, not a clamped one.
|
||||
*/
|
||||
private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) {
|
||||
if (payableAccount == null || payableAccount.getId() == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
// Reversed entries and the entries they reverse are both excluded: a reversal pair nets to
|
||||
// nothing, and counting either side alone would move the balance by the whole amount.
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ?
|
||||
AND office_id = ?
|
||||
AND currency_code = ?
|
||||
AND (reversed IS NULL OR reversed = false)
|
||||
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""", BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode);
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) {
|
||||
if (savingsProductId == null || accountNames == null || accountNames.length == 0) {
|
||||
return null;
|
||||
}
|
||||
// The column holds an empty string on a product saved with no named mappings, and casting
|
||||
// that to jsonb is an error rather than an empty document - so the cast is done inside a
|
||||
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
|
||||
// clause in any order, so guarding at the same query level as the cast would only work by
|
||||
// luck.
|
||||
final List<Long> found = jdbcTemplate.queryForList("""
|
||||
SELECT gl.id
|
||||
FROM (SELECT other_gl_mappings::jsonb AS doc
|
||||
FROM m_savings_product
|
||||
WHERE id = ?
|
||||
AND other_gl_mappings IS NOT NULL
|
||||
AND other_gl_mappings <> ''
|
||||
AND other_gl_mappings LIKE '{%') sp
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
||||
) AS m(entry)
|
||||
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
||||
WHERE m.entry ->> 'accountName' = ?
|
||||
LIMIT 1
|
||||
""", Long.class, savingsProductId, accountName);
|
||||
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
|
||||
for (final String accountName : accountNames) {
|
||||
if (accountName == null) {
|
||||
continue;
|
||||
}
|
||||
final List<Long> found = jdbcTemplate.queryForList("""
|
||||
SELECT gl.id
|
||||
FROM (SELECT other_gl_mappings::jsonb AS doc
|
||||
FROM m_savings_product
|
||||
WHERE id = ?
|
||||
AND other_gl_mappings IS NOT NULL
|
||||
AND other_gl_mappings <> ''
|
||||
AND other_gl_mappings LIKE '{%') sp
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
||||
) AS m(entry)
|
||||
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
||||
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
|
||||
LIMIT 1
|
||||
""", Long.class, savingsProductId, accountName);
|
||||
if (!found.isEmpty()) {
|
||||
return glAccountRepository.findById(found.get(0)).orElse(null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-6
@@ -173,12 +173,28 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
|
||||
}
|
||||
|
||||
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
||||
// Post journal entry if earned interest amount is greater than
|
||||
// zero
|
||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
// A premature FD closure that reprices interest downward (see
|
||||
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
|
||||
// interest-posting transaction -- a clawback of interest already recognised in earlier
|
||||
// periods, not "zero interest, nothing to post". Skipping it here (the original
|
||||
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
|
||||
// in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the
|
||||
// cash-based path.
|
||||
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||
if (amountSign != 0) {
|
||||
final boolean isClawback = amountSign < 0;
|
||||
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||
// Routed through the same helper the cash processor uses, so a posting states
|
||||
// the whole chain the product is configured for -- out of the payable, through
|
||||
// the interest-paid account, into the member's balance -- rather than the single
|
||||
// payable-to-control pair that used to be here, which skipped interest-paid
|
||||
// entirely and left the expense off the P&L.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
||||
}
|
||||
|
||||
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
||||
// Post journal entry if earned interest amount is greater than
|
||||
// zero
|
||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
// A premature FD closure that reprices interest downward (see
|
||||
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
|
||||
// interest-posting transaction -- a clawback of interest already recognised in earlier
|
||||
// periods, not "zero interest, nothing to post". Skipping it here (the original
|
||||
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
|
||||
// in sync instead.
|
||||
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||
if (amountSign != 0) {
|
||||
final boolean isClawback = amountSign < 0;
|
||||
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||
// Routed through the payable and paid accounts the product names, so the society
|
||||
// can read interest declared but not yet credited as its own balance. Collapses
|
||||
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||
// that names neither.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -294,8 +294,14 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
||||
SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), savingProductId,
|
||||
CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue());
|
||||
|
||||
// FD and RD products switch dormancy on with "dormancyAlert" (DepositProductAssembler derives the product's
|
||||
// tracking flag from it) and never send isDormancyTrackingActive, so reading only that dropped the escheat
|
||||
// account the screen had asked for - the product then tracked dormancy with no escheat account mapped.
|
||||
final Boolean isDormancyTrackingActive = this.fromApiJsonHelper.extractBooleanNamed(isDormancyTrackingActiveParamName, element);
|
||||
if (null != isDormancyTrackingActive && isDormancyTrackingActive) {
|
||||
final Boolean dormancyAlert = this.fromApiJsonHelper.parameterExists("dormancyAlert", element)
|
||||
? this.fromApiJsonHelper.extractBooleanNamed("dormancyAlert", element)
|
||||
: null;
|
||||
if (Boolean.TRUE.equals(isDormancyTrackingActive) || Boolean.TRUE.equals(dormancyAlert)) {
|
||||
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
|
||||
SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingProductId,
|
||||
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
||||
|
||||
+5
-3
@@ -165,9 +165,11 @@ public class SecurityConfig {
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
||||
// Client account summary, third-party callers — a partner integration calls this
|
||||
// with an X-Api-Key header instead of a Fineract username/password (issued via
|
||||
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
|
||||
// outer gate stays open for this one narrow GET path, and
|
||||
// with an X-Api-Key header instead of a Fineract username/password. The key itself
|
||||
// is issued by inserting directly into m_api_client in the tenant-store database
|
||||
// (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled,
|
||||
// since any bank's own super user reaching it could mint a platform-wide key). Same
|
||||
// posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and
|
||||
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
|
||||
// the caller (ApiClientCredentialService checks the key's hash, active flag and
|
||||
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
|
||||
|
||||
+20
-14
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
@@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
|
||||
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
|
||||
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
|
||||
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
|
||||
* key in the first place is not something to hand to just anyone.
|
||||
* List/deactivate/reactivate still require a normal Fineract login restricted to a super user
|
||||
* (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose.
|
||||
*
|
||||
* {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole
|
||||
* point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the
|
||||
* "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access
|
||||
* ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could
|
||||
* call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one
|
||||
* that authenticates against every tenant, not just their own -- completely bypassing the ceiling this
|
||||
* key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine
|
||||
* (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly
|
||||
* the escalation path being closed. New keys are issued the same way this fix issued the first one: a
|
||||
* direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by
|
||||
* whoever already has direct database access -- never over HTTP.
|
||||
*/
|
||||
@Path("/v1/apiclients")
|
||||
@Component
|
||||
@@ -55,20 +63,18 @@ public class ApiClientAdminApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ApiClientCredentialService credentialService;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
/** Disabled -- see the class comment. Minting a key is a direct-database operation only. */
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String create(final String body) {
|
||||
final AppUser user = requireSuperUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
|
||||
|
||||
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
|
||||
return gson.toJson(result);
|
||||
requireSuperUser();
|
||||
throw new WebApplicationException(Response.status(403)
|
||||
.entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. "
|
||||
+ "Insert directly into m_api_client in the tenant-store database instead.\"}")
|
||||
.type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
|
||||
@GET
|
||||
|
||||
+6
-1
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
|
||||
|
||||
@Override
|
||||
public Collection<TellerData> getTellers(Long officeId) {
|
||||
return retrieveAllTellers(false);
|
||||
if (officeId == null) {
|
||||
return retrieveAllTellers(false);
|
||||
}
|
||||
// The tellers of that one branch. The officeId used to be ignored, so the head office, whose
|
||||
// hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close.
|
||||
return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+6
-4
@@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
|
||||
try {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
if (this.tellerRepositoryWrapper.existsAny()) {
|
||||
throw new TellerAlreadyExistsException();
|
||||
}
|
||||
|
||||
final Long officeId = command.longValueOfParameterNamed("officeId");
|
||||
|
||||
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
|
||||
@@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
|
||||
// final Office parent =
|
||||
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
|
||||
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
|
||||
// One teller (vault) per branch, not one for the whole bank: each branch holds and settles
|
||||
// its own cash, so a second branch needs a vault of its own.
|
||||
if (this.tellerRepositoryWrapper.existsInOffice(officeId)) {
|
||||
throw new TellerAlreadyExistsException(tellerOffice.getName());
|
||||
}
|
||||
final Teller teller = Teller.fromJson(tellerOffice, command);
|
||||
|
||||
// pre save to generate id for use in office hierarchy
|
||||
|
||||
+44
-10
@@ -27,6 +27,7 @@ import static org.apache.fineract.portfolio.account.api.AccountTransfersApiConst
|
||||
|
||||
import com.google.common.collect.Lists;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@@ -44,6 +45,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
|
||||
@@ -74,11 +76,13 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
|
||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@@ -102,6 +106,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
||||
private final AccountingProcessorHelper accountingProcessorHelper;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
private final SavingsInterestTdsService savingsInterestTdsService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -147,6 +152,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
||||
throw new DifferentCurrenciesException(fromSavingsAccount.getCurrency().getCode(),
|
||||
@@ -202,6 +208,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
|
||||
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
|
||||
@@ -407,11 +414,15 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
|
||||
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
||||
toSavingsAccount, withdrawal, deposit);
|
||||
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
|
||||
transferTransactionId = accountTransferDetails.getId();
|
||||
// FD/RD interest reaching a savings account is where TDS is tracked and, once due, deducted.
|
||||
this.savingsInterestTdsService.onTransfer(fromSavingsAccount, toSavingsAccount, deposit,
|
||||
AccountTransferType.fromInt(accountTransferDTO.getTransferType()));
|
||||
|
||||
} else if (isLoanToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
|
||||
|
||||
@@ -449,6 +460,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
|
||||
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(accountTransferDTO, fromLoanAccount,
|
||||
toSavingsAccount, deposit, loanTransaction);
|
||||
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
|
||||
@@ -483,6 +495,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
|
||||
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
|
||||
isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill, true);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
if (accountTransferDetails == null) {
|
||||
accountTransferDetails = AccountTransferDetails.currentToSavingsTransfer(
|
||||
@@ -644,6 +657,26 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
return accountTransferDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Money transferred into a recurring deposit is an installment payment, exactly like a counter deposit through
|
||||
* DepositAccountDomainServiceJpa#handleRDDeposit. The generic deposit above only credits the balance, so without
|
||||
* this the deposit's schedule kept showing the installment as unpaid and overdue, and its projected maturity
|
||||
* amount never moved - for manual transfers, standing instructions and RD Auto Debit alike. Any other destination
|
||||
* is left untouched.
|
||||
*/
|
||||
private void applyToRecurringDepositSchedule(final SavingsAccount toSavingsAccount, final SavingsAccountTransaction deposit) {
|
||||
if (!(toSavingsAccount instanceof RecurringDepositAccount recurringDeposit)) {
|
||||
return;
|
||||
}
|
||||
final boolean isPreMatureClosure = false;
|
||||
recurringDeposit.updateDepositAmount(deposit.getAmount());
|
||||
recurringDeposit.handleScheduleInstallments(deposit);
|
||||
recurringDeposit.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure,
|
||||
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
|
||||
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
|
||||
recurringDeposit.updateOverduePayments(DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
private boolean isLoanToSavingsAccountTransfer(final PortfolioAccountType fromAccountType, final PortfolioAccountType toAccountType) {
|
||||
return fromAccountType.isLoanAccount() && toAccountType.isSavingsAccount();
|
||||
}
|
||||
@@ -703,8 +736,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
currentAccountRepository.save(account);
|
||||
if (account.getProductId() != null && txn.getId() != null) {
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(),
|
||||
account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
||||
account.getId(), currentTxnId, txnDate, amount);
|
||||
}
|
||||
return txn;
|
||||
@@ -715,7 +748,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final Long counterpartEntityId) {
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
@@ -735,7 +768,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
|
||||
currentAccountRepository.save(account);
|
||||
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
@@ -744,7 +777,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
* Deposit amount into a current account and save the transaction.
|
||||
*/
|
||||
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent,
|
||||
final Long counterpartEntityId) {
|
||||
kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT);
|
||||
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
|
||||
@@ -757,7 +790,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
|
||||
currentAccountRepository.save(account);
|
||||
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent,
|
||||
counterpartEntityId);
|
||||
return txn;
|
||||
}
|
||||
@@ -803,13 +836,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
final String currentTxnId = "CA" + txn.getId();
|
||||
if (isWithdrawal) {
|
||||
// DR: current account GL CR: counterpart (savings/current) GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId,
|
||||
currentTxnId, txnDate, amount);
|
||||
} else {
|
||||
// DR: counterpart (savings/current) GL CR: current account GL
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(),
|
||||
counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(),
|
||||
currentTxnId, txnDate, amount);
|
||||
}
|
||||
}
|
||||
@@ -852,6 +885,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||
transactionAmount, paymentDetail, true, true, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
|
||||
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
|
||||
|
||||
+5
-2
@@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -97,12 +98,14 @@ public class AccountConfiguration {
|
||||
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
|
||||
FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
|
||||
CurrentAccountTransactionRepository currentAccountTransactionRepository,
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard) {
|
||||
AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard,
|
||||
SavingsInterestTdsService savingsInterestTdsService) {
|
||||
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
||||
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
||||
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
||||
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard,
|
||||
savingsInterestTdsService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+132
-20
@@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -69,10 +71,26 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class BranchDayRegisterApiResource {
|
||||
|
||||
/**
|
||||
* The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier
|
||||
* allocation if that allocation already existed when the cash was counted, otherwise the officer's
|
||||
* own office. A person moved to another branch's teller keeps what they took at the old branch
|
||||
* there.
|
||||
*/
|
||||
private static final String COUNTER_OFFICE_OF_STD = """
|
||||
COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = mc.teller_id
|
||||
WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """
|
||||
LIMIT 1),
|
||||
(SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id))
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final SavingsInterestPostingReportService interestPostingReportService;
|
||||
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||
@@ -104,11 +122,26 @@ public class BranchDayRegisterApiResource {
|
||||
result.put("officeId", officeId);
|
||||
result.put("canOperate", canOperateDay(currentUser));
|
||||
result.put("days", mapDays(rows));
|
||||
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
|
||||
result.put("today", DateUtils.getBusinessLocalDate().toString());
|
||||
result.put("openDay", findOpenDay(officeId));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/today ── the bank's "today" ─────────────────────────
|
||||
|
||||
/**
|
||||
* The business date while enable-business-date is on, the tenant's own date while it is off.
|
||||
* Every screen that defaults a date to "today" reads this, rather than the browser clock or the
|
||||
* business-date API (which returns the stored date even when the feature is off). Open to any
|
||||
* signed-in user — cashiers and clerks cannot read the configuration that says which applies.
|
||||
*/
|
||||
@GET
|
||||
@Path("today")
|
||||
public String today() {
|
||||
this.context.authenticatedUser();
|
||||
return gson.toJson(Map.of("today", DateUtils.getBusinessLocalDate().toString()));
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/missed ── days left open ────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -121,7 +154,7 @@ public class BranchDayRegisterApiResource {
|
||||
public String missed(@QueryParam("officeId") final Long officeIdParam) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
|
||||
@@ -191,8 +224,8 @@ public class BranchDayRegisterApiResource {
|
||||
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
|
||||
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
|
||||
// cross-check was being compared against every branch's counted cash at once, which makes it
|
||||
// wrong at every office but the first. A row whose officer maps to no cashier is still
|
||||
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
||||
// wrong at every office but the first. A row whose officer has no cashier allocation counts
|
||||
// at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office.
|
||||
//
|
||||
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
|
||||
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
|
||||
@@ -201,23 +234,15 @@ public class BranchDayRegisterApiResource {
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||
AND (std.officer_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
WHERE au.id = std.officer_id)
|
||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||
) = ?)
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||
AND (std.officer_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
WHERE au.id = std.officer_id)
|
||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||
) = ?)
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
result.put("denominationIn", countedIn);
|
||||
result.put("denominationOut", countedOut);
|
||||
@@ -243,7 +268,7 @@ public class BranchDayRegisterApiResource {
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
DateUtils.getBusinessLocalDate());
|
||||
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
|
||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||
@@ -437,7 +462,7 @@ public class BranchDayRegisterApiResource {
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
|
||||
final LocalDate businessDate = extractDate(root, "businessDate",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
DateUtils.getBusinessLocalDate());
|
||||
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
|
||||
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
|
||||
|
||||
@@ -589,6 +614,32 @@ public class BranchDayRegisterApiResource {
|
||||
"Business day " + businessDate + " is already closed.");
|
||||
}
|
||||
|
||||
// ── This branch's savings interest reports must be approved before it closes ──
|
||||
// Not overridable by a force-close reason: the branch posts its own customers' interest when
|
||||
// it closes, and a report nobody approved has nothing to post, so they would go unpaid.
|
||||
final List<String> interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
|
||||
if (!interestPending.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending",
|
||||
"Cannot close the day — this branch's savings interest posting report is not approved for: "
|
||||
+ String.join(", ", interestPending)
|
||||
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
|
||||
}
|
||||
|
||||
// ── The head office closes last ──
|
||||
// Every branch that opened the day closes first and posts its own interest; a branch that
|
||||
// never opened is not waited for.
|
||||
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
|
||||
if (isHeadOffice) {
|
||||
// Any day still open counts, not only today's: a branch whose earlier day was never closed is
|
||||
// still working and has not posted its interest.
|
||||
final List<String> stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId);
|
||||
if (!stillOpen.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
|
||||
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
|
||||
+ ". The head office closes last.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure every cashier holding cash has been reconciled AND settled ──
|
||||
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
|
||||
// and if so, verify reconciliation and settlement records exist for the business date.
|
||||
@@ -687,6 +738,28 @@ public class BranchDayRegisterApiResource {
|
||||
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
||||
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
||||
|
||||
// ── The branch posts its own approved savings interest as it closes ──
|
||||
// The head office also posts what it approved for branches that did not open today. Entries
|
||||
// are raised in the customers' own branch either way.
|
||||
//
|
||||
// Posted here, before the close is written, and synchronously, so the outcome is never
|
||||
// silent: each report posts in a transaction of its own and commits, and if any fails the
|
||||
// close is refused with the reason — the day stays open, the report stays APPROVED, and
|
||||
// closing again once the cause is fixed posts it.
|
||||
final List<Long> toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate));
|
||||
if (isHeadOffice) {
|
||||
toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
|
||||
}
|
||||
if (!toPost.isEmpty()) {
|
||||
log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size());
|
||||
final List<String> interestFailures = savingsDailyInterestOrchestrator.postReports(toPost);
|
||||
if (!interestFailures.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed",
|
||||
"Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures)
|
||||
+ ". The report is still approved; fix the cause and close the day again.");
|
||||
}
|
||||
}
|
||||
|
||||
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
|
||||
// this write are two separate statements, so two managers pressing Close at once (or one
|
||||
// double-click) both got past the check. The second UPDATE then overwrote the first's
|
||||
@@ -716,11 +789,13 @@ public class BranchDayRegisterApiResource {
|
||||
final Long registerId = ((Number) found.get(0).get("id")).longValue();
|
||||
saveDenominationDetail(registerId, "CLOSING", denominations);
|
||||
snapshotVaultDenominations(officeId, registerId, businessDate);
|
||||
final int depositsClosed = closeMaturedZeroBalanceRecurringDeposits(officeId, businessDate, currentUser);
|
||||
|
||||
final Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("officeId", officeId);
|
||||
res.put("businessDate", businessDate.toString());
|
||||
res.put("status", "CLOSED");
|
||||
res.put("zeroBalanceDepositsClosed", depositsClosed);
|
||||
res.put("closingCash", closingCash);
|
||||
res.put("bookCash", bookCash);
|
||||
res.put("cashDifference", difference);
|
||||
@@ -728,6 +803,38 @@ public class BranchDayRegisterApiResource {
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Account Closed with Zero balance in Day Closing" on a recurring deposit product: at the branch's day close, each
|
||||
* of its recurring deposits that has MATURED with nothing left in it - no balance, and no loan holding a lien on it
|
||||
* - is marked Closed on that business date, so it no longer waits in the matured list.
|
||||
*
|
||||
* Only matured deposits: a running deposit sits at zero between opening and its first installment, and must never
|
||||
* be closed for that. Nothing is paid out (there is nothing to pay), so no transaction or journal entry is made.
|
||||
*
|
||||
* @return how many deposits were closed
|
||||
*/
|
||||
private int closeMaturedZeroBalanceRecurringDeposits(final Long officeId, final LocalDate businessDate, final AppUser currentUser) {
|
||||
final int closed = jdbcTemplate.update("""
|
||||
UPDATE m_savings_account sa
|
||||
SET status_enum = 600, closedon_date = ?, closedon_userid = ?
|
||||
FROM m_savings_product sp, m_client c
|
||||
WHERE sp.id = sa.product_id AND c.id = sa.client_id AND c.office_id = ?
|
||||
AND sa.deposit_type_enum = 300 AND sa.status_enum = 800
|
||||
AND COALESCE(sp.account_closed_zero_balance_in_day_closing, false) = true
|
||||
AND COALESCE(sa.account_balance_derived, 0) = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM m_portfolio_account_associations paa
|
||||
JOIN m_guarantor_funding_details gfd ON gfd.account_associations_id = paa.id
|
||||
JOIN m_loan l ON l.id = paa.loan_account_id
|
||||
WHERE paa.linked_savings_account_id = sa.id AND gfd.status_enum = 100
|
||||
AND l.loan_status_id IN (100, 200, 300)
|
||||
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0)
|
||||
""", Date.valueOf(businessDate), currentUser.getId(), officeId);
|
||||
if (closed > 0) {
|
||||
log.info("Day close {}: office {} closed {} matured recurring deposit(s) with zero balance", businessDate, officeId, closed);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ────
|
||||
|
||||
/**
|
||||
@@ -743,7 +850,7 @@ public class BranchDayRegisterApiResource {
|
||||
final Long officeId = resolveOffice(officeIdParam, currentUser);
|
||||
final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank())
|
||||
? LocalDate.parse(businessDateParam)
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||
@@ -1004,6 +1111,9 @@ public class BranchDayRegisterApiResource {
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", r.get("id"));
|
||||
// The close screen fetches this office's tellers with it; left out, it fetched every teller
|
||||
// the user can see, and the head office counted each branch's vault as its own.
|
||||
m.put("officeId", r.get("office_id"));
|
||||
m.put("businessDate", str(r.get("business_date")));
|
||||
m.put("openedByName", r.get("opened_by_name"));
|
||||
m.put("openedAt", str(r.get("opened_at")));
|
||||
@@ -1057,11 +1167,13 @@ public class BranchDayRegisterApiResource {
|
||||
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
||||
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
||||
|
||||
// By the counter that took the cash. A savings deposit carries no office_id, and the old
|
||||
// "OR office_id IS NULL" counted every such deposit in the bank at every branch.
|
||||
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
||||
WHERE status = 'APPROVED'
|
||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
||||
AND (office_id = ? OR office_id IS NULL)
|
||||
AND COALESCE(counter_office_id, office_id) = ?
|
||||
""", BigDecimal.class, date, officeId));
|
||||
|
||||
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
|
||||
|
||||
+27
-12
@@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource {
|
||||
client_id, client_name, amount,
|
||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
|
||||
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
@@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource {
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
|
||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser)
|
||||
);
|
||||
|
||||
// Globally unique voucher number from the central m_voucher register
|
||||
@@ -691,6 +691,20 @@ public class CashierDepositRequestApiResource {
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch whose counter took the cash: the office of the teller the user is allocated to as a
|
||||
* cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries
|
||||
* none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it.
|
||||
*/
|
||||
private Long counterOfficeId(final AppUser user) {
|
||||
final List<Long> tellerOffice = user.getStaff() == null ? List.of()
|
||||
: jdbcTemplate.queryForList("""
|
||||
SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE c.staff_id = ? LIMIT 1
|
||||
""", Long.class, user.getStaff().getId());
|
||||
return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0);
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
||||
@@ -713,7 +727,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long savingsAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -747,7 +761,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -776,7 +790,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long accountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -830,7 +844,7 @@ public class CashierDepositRequestApiResource {
|
||||
return;
|
||||
}
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -853,7 +867,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Long currentAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
@@ -962,7 +976,7 @@ public class CashierDepositRequestApiResource {
|
||||
final Object paymentTypeId, final String txnDateStr, final String note,
|
||||
final boolean isDeposit) {
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
// Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra)
|
||||
@@ -1059,8 +1073,9 @@ public class CashierDepositRequestApiResource {
|
||||
value, count, BigDecimal.valueOf((long) value * count),
|
||||
// Dated on the request's own date, which is what the branch day's book figure reads.
|
||||
// CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the
|
||||
// drawer on another and leave both days' cash wrong.
|
||||
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
|
||||
// drawer on another and leave both days' cash wrong. No date falls back to the
|
||||
// business date for the same reason.
|
||||
Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()),
|
||||
cashierId, cashierUsername, paymentTypeName
|
||||
);
|
||||
}
|
||||
@@ -1165,7 +1180,7 @@ public class CashierDepositRequestApiResource {
|
||||
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
return DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -93,7 +93,7 @@ public class CashReconciliationApiResource {
|
||||
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Out: settlements to vault
|
||||
@@ -123,7 +123,7 @@ public class CashReconciliationApiResource {
|
||||
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
|
||||
|
||||
+17
-17
@@ -83,7 +83,7 @@ public class CashReportApiResource {
|
||||
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 String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
|
||||
@@ -470,7 +470,7 @@ public class CashReportApiResource {
|
||||
@GET
|
||||
@Path("closing")
|
||||
public String closing(@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
|
||||
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
|
||||
@@ -671,25 +671,25 @@ public class CashReportApiResource {
|
||||
// day itself: taken from the vault, in over the counter, out over the counter, settled
|
||||
// back. What is left is what should physically be in the drawer.
|
||||
result.put("todayVaultIn", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||
"SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
|
||||
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ct.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todaySettledOut", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||
"SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
|
||||
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(cs.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todayInterCashierIn", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todayInterCashierOut", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||
@@ -721,7 +721,7 @@ public class CashReportApiResource {
|
||||
final Long scopedCashierId) {
|
||||
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : "";
|
||||
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
||||
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
||||
|
||||
@@ -938,7 +938,7 @@ public class CashReportApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String today = DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// Find the earliest transfer date before today for which no settlement exists on or after that date
|
||||
final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled "
|
||||
@@ -1001,7 +1001,7 @@ public class CashReportApiResource {
|
||||
@Path("cashier-position")
|
||||
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
// CASHIER role: always scope to their own position only
|
||||
final Long scopedId = cashierRoleHelper.getCashierIdForFiltering();
|
||||
@@ -1032,7 +1032,7 @@ public class CashReportApiResource {
|
||||
@GET
|
||||
@Path("cashier-position/me")
|
||||
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString();
|
||||
Long appUserId;
|
||||
try {
|
||||
appUserId = context.authenticatedUser().getId();
|
||||
@@ -1070,7 +1070,7 @@ public class CashReportApiResource {
|
||||
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();
|
||||
: DateUtils.getBusinessLocalDate().toString();
|
||||
|
||||
if (tellerId == null) {
|
||||
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
|
||||
@@ -1092,7 +1092,7 @@ public class CashReportApiResource {
|
||||
@Path("cashier-morning")
|
||||
public String cashierMorning(@QueryParam("cashierId") Long cashierId,
|
||||
@QueryParam("date") String date) {
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
if (cashierId == null) {
|
||||
Map<String, Object> empty = new LinkedHashMap<>();
|
||||
empty.put("cashierId", null);
|
||||
@@ -1171,7 +1171,7 @@ public class CashReportApiResource {
|
||||
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Settlements back to vault
|
||||
@@ -1197,7 +1197,7 @@ public class CashReportApiResource {
|
||||
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
||||
@@ -1304,7 +1304,7 @@ public class CashReportApiResource {
|
||||
@QueryParam("offset") Integer offset,
|
||||
@QueryParam("limit") Integer limit) {
|
||||
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
final int resolvedLimit = (limit != null && limit > 0) ? limit : 20;
|
||||
final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0;
|
||||
|
||||
@@ -1315,13 +1315,13 @@ public class CashReportApiResource {
|
||||
if (cashierId != null) {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
+ "SELECT au.id FROM m_appuser au "
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)";
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
|
||||
baseParams = new Object[] { txnDate, cashierId };
|
||||
} else if (tellerId != null) {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
+ "SELECT au.id FROM m_appuser au "
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id "
|
||||
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)";
|
||||
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
|
||||
baseParams = new Object[] { txnDate, tellerId };
|
||||
} else {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
|
||||
+3
-3
@@ -89,7 +89,7 @@ public class CashSettlementApiResource {
|
||||
}
|
||||
|
||||
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate();
|
||||
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
|
||||
? json.get("fromParty").getAsString() : null;
|
||||
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
|
||||
@@ -226,7 +226,7 @@ public class CashSettlementApiResource {
|
||||
}
|
||||
glDispatchService.allocateCashToCashier(glTellerId,
|
||||
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
|
||||
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
@@ -247,7 +247,7 @@ public class CashSettlementApiResource {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required",
|
||||
"cashierId query param is required.");
|
||||
}
|
||||
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||
final String settlementDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString();
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
|
||||
Integer.class, cashierId, settlementDate);
|
||||
|
||||
+3
-3
@@ -146,7 +146,7 @@ public class CashTransferApiResource {
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
}
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
|
||||
glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total,
|
||||
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
note != null ? note : "");
|
||||
@@ -206,7 +206,7 @@ public class CashTransferApiResource {
|
||||
}
|
||||
glDispatchService.settleCashFromCashier(glTellerId,
|
||||
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
|
||||
java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
|
||||
} catch (Exception ignored) {
|
||||
// intentionally ignored
|
||||
@@ -291,7 +291,7 @@ public class CashTransferApiResource {
|
||||
"Vault/teller " + fromTellerId + " was not found.");
|
||||
}
|
||||
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final LocalDate businessDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate();
|
||||
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
|
||||
|
||||
final List<Long> registerIds = jdbcTemplate.query("""
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ public class CashierGlDenominationApiResource {
|
||||
final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank())
|
||||
? LocalDate.parse(txnDateStr)
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) {
|
||||
return "{\"result\":\"skipped\"}";
|
||||
|
||||
+5
-5
@@ -889,7 +889,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Long resolvedClientId = toLong(dep.get("client_id"));
|
||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
|
||||
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
final String receiptNo = nextReceiptNo();
|
||||
// What the cashier actually typed at save time, when they typed anything -- that is what
|
||||
// the Apply Share screen's Description column exists to show. Falls back to a plain
|
||||
@@ -927,7 +927,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
final String voucherNo = (String) req.get("voucher_no");
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
@@ -1006,7 +1006,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
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());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
// What the cashier actually typed at save time, when they typed anything -- same preference
|
||||
// as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by
|
||||
@@ -1093,7 +1093,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
final Object submittedAt = req.get("submitted_at");
|
||||
final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp
|
||||
? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate()
|
||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
: DateUtils.getBusinessLocalDate();
|
||||
|
||||
final String receiptNo = nextReceiptNo();
|
||||
final String reqDescription = (String) req.get("description");
|
||||
@@ -1338,7 +1338,7 @@ public class ShareAdvanceCollectionApiResource {
|
||||
|
||||
private LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
return DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw);
|
||||
|
||||
+30
@@ -176,6 +176,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
// A GL leg carries no savingsAccountId and the guard ignores it.
|
||||
// A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against
|
||||
// m_current_account -- checking it as a savings id would test some unrelated savings account.
|
||||
rejectRecurringDepositLeg(entry, entryAccountType);
|
||||
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
|
||||
final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER
|
||||
: KycRestrictionGuard.Operation.CREDIT;
|
||||
@@ -486,6 +487,8 @@ public class ClerkDisbursementRequestApiResource {
|
||||
throw apiError(400, "Account ID missing for entry: " + acNo
|
||||
+ ". Clerk must re-submit with account details loaded.");
|
||||
}
|
||||
// Also refuses requests raised before RD legs were blocked at submit.
|
||||
rejectRecurringDepositLeg(entry, entryType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,6 +935,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (amt.compareTo(BigDecimal.ZERO) <= 0)
|
||||
throw apiError(400, "Amount must be greater than zero. Account: "
|
||||
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
|
||||
rejectRecurringDepositLeg(e, acTyp);
|
||||
drAccountIds.add(e.get("savingsAccountId").getAsLong());
|
||||
}
|
||||
for (final JsonObject e : crEntries) {
|
||||
@@ -944,6 +948,7 @@ public class ClerkDisbursementRequestApiResource {
|
||||
if (amt.compareTo(BigDecimal.ZERO) <= 0)
|
||||
throw apiError(400, "Amount must be greater than zero. Account: "
|
||||
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
|
||||
rejectRecurringDepositLeg(e, acTyp);
|
||||
final Long crId = e.get("savingsAccountId").getAsLong();
|
||||
if (drAccountIds.contains(crId)) throw apiError(400,
|
||||
"Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId)
|
||||
@@ -1193,6 +1198,31 @@ public class ClerkDisbursementRequestApiResource {
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/** m_savings_account.deposit_type_enum of a recurring deposit (Fineract's DepositAccountType.RECURRING_DEPOSIT). */
|
||||
private static final int DEPOSIT_TYPE_RECURRING = 300;
|
||||
|
||||
/**
|
||||
* A recurring deposit cannot be a leg of any clerk request. Every request type here posts straight into the
|
||||
* account with a direct insert, which never reaches the RD's installment schedule: the installments stay unpaid,
|
||||
* Auto Debit collects them a second time and the maturity amount is computed on the wrong schedule. RD
|
||||
* installments come in through the cashier's Receipt (the RD deposit command) or the RD's own Auto Debit.
|
||||
* Looked up by id, not trusted from the entry's label; a CURRENT leg's id belongs to m_current_account.
|
||||
*/
|
||||
private void rejectRecurringDepositLeg(final JsonObject entry, final String accountType) {
|
||||
if ("GL".equals(accountType) || "CURRENT".equals(accountType) || !entry.has("savingsAccountId")
|
||||
|| entry.get("savingsAccountId").isJsonNull()) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT account_no FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
|
||||
entry.get("savingsAccountId").getAsLong(), DEPOSIT_TYPE_RECURRING);
|
||||
if (!rows.isEmpty()) {
|
||||
throw apiError(422, "Recurring deposit " + rows.get(0).get("account_no")
|
||||
+ " cannot be used in a clerk Disbursement, Collection, Fund Transfer or Bank Transfer."
|
||||
+ " Pay RD installments through a cashier Receipt or Auto Debit.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
|
||||
|
||||
+153
-12
@@ -390,6 +390,101 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
public String validate(@PathParam("id") final Long id) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(record);
|
||||
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
|
||||
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
|
||||
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the clerk see the same checklist the approver will see, before the request is even
|
||||
* submitted — so a request that is going to fail a check (a missing GL mapping, an unverified
|
||||
* KYC, an open AML alert) is caught while someone can still fix it, not after a manager has
|
||||
* already spent time reviewing it. Takes the same fields {@link #submit(String)} does and
|
||||
* applies the identical Sundry-downgrade rule, so what this previews is exactly what would be
|
||||
* stored and exactly what {@link #validate(Long)} would later report on the saved request —
|
||||
* never a second, independently-drifting copy of the rules.
|
||||
*
|
||||
* <p>
|
||||
* The one check {@link #validate(Long)} runs that this cannot — "Maker and Checker are
|
||||
* different users" — has no real answer yet: the clerk previewing this *is* the maker, and no
|
||||
* checker has been chosen. It is reported here as a fixed reminder rather than silently
|
||||
* dropped, so the clerk sees why the two lists differ by exactly that one row, and knows the
|
||||
* rule is still enforced later, at {@code approve()}.
|
||||
*/
|
||||
@POST
|
||||
@Path("/preview-validate")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String previewValidate(final String body) {
|
||||
context.authenticatedUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
|
||||
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
|
||||
if (shareAccountId == null) {
|
||||
throw apiError(400, "Share account id is required");
|
||||
}
|
||||
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
|
||||
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
|
||||
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
|
||||
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
|
||||
final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
|
||||
String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
|
||||
: settlementModeRaw.trim().toUpperCase();
|
||||
Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root);
|
||||
|
||||
// Mirrors submit()'s own downgrade: naming the product's own Share Sundry GL as the
|
||||
// destination means "stop here", not a real GL settlement — see submit() for the rationale.
|
||||
if ("GL".equals(settlementMode) && settlementGlAccountId != null) {
|
||||
final Long productId = jdbcTemplate.queryForList("SELECT product_id FROM m_share_account WHERE id = ?", shareAccountId)
|
||||
.stream().findFirst().map(r -> ((Number) r.get("product_id")).longValue()).orElse(null);
|
||||
if (productId != null) {
|
||||
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
|
||||
if (glSundry != null && glSundry.equals(settlementGlAccountId)) {
|
||||
settlementMode = "NONE";
|
||||
settlementGlAccountId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, Object> draftRecord = new java.util.HashMap<>();
|
||||
draftRecord.put("resolution_number", resolutionNumber);
|
||||
draftRecord.put("meeting_date", meetingDate);
|
||||
draftRecord.put("share_account_id", shareAccountId);
|
||||
draftRecord.put("requested_shares", requestedShares != null ? requestedShares : 0L);
|
||||
draftRecord.put("settlement_mode", settlementMode);
|
||||
draftRecord.put("settlement_gl_account_id", settlementGlAccountId);
|
||||
draftRecord.put("settlement_payment_type_id", settlementPaymentTypeId);
|
||||
|
||||
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(draftRecord);
|
||||
checks.add(checkResult("Maker and Checker are different users", true,
|
||||
"Verified automatically when a different manager approves"));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full checklist a checker sees before approving a share withdrawal — share balance and
|
||||
* status, loan/tenure/guarantor exposure, settlement destination and payment type, and client
|
||||
* KYC/risk/AML standing. Shared verbatim between {@link #validate(Long)} (an already-submitted
|
||||
* request) and {@link #previewValidate(String)} (a draft that does not exist yet), so the two
|
||||
* can never silently diverge. Reads {@code record} by the same string keys both callers
|
||||
* populate: resolution_number, meeting_date, share_account_id, requested_shares,
|
||||
* settlement_mode, settlement_gl_account_id, settlement_payment_type_id.
|
||||
*/
|
||||
private List<Map<String, Object>> buildShareWithdrawalChecks(final Map<?, ?> record) {
|
||||
final List<Map<String, Object>> checks = new ArrayList<>();
|
||||
|
||||
final String resolutionNumber = (String) record.get("resolution_number");
|
||||
@@ -451,8 +546,18 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
// not-applicable rather than failed for a GL request — a share account with no linked
|
||||
// savings is a perfectly valid GL-settlement case.
|
||||
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
|
||||
checks.add(glSettlementMode
|
||||
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
|
||||
// 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who
|
||||
// names the product's own Share Sundry GL as the destination is saying "stop here", so the
|
||||
// request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry
|
||||
// and nothing further. Like a GL settlement it never reaches a savings account, so the
|
||||
// savings-side checks below do not apply to it either — running them reports a missing fund
|
||||
// source mapping that no posting in this mode is ever going to read.
|
||||
final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode"));
|
||||
final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement;
|
||||
checks.add(noSavingsLeg
|
||||
? checkResult("Linked savings account is Active", true, sundryOnlySettlement
|
||||
? "Not applicable — the redemption rests in Share Sundry"
|
||||
: "Not applicable — settlement is a GL book-transfer")
|
||||
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
|
||||
savingsAccountId == null ? "No linked savings account configured"
|
||||
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
|
||||
@@ -541,6 +646,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
final boolean settlementGlPresent = glAccountIdObj != null;
|
||||
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
|
||||
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
|
||||
} else if (sundryOnlySettlement) {
|
||||
checks.add(checkResult("Settlement destination recorded", true,
|
||||
"Share Sundry — the redemption stops there, so no fund source mapping is read"));
|
||||
} else {
|
||||
boolean glMappingOk = false;
|
||||
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
|
||||
@@ -579,16 +687,7 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
|
||||
}
|
||||
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
|
||||
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
|
||||
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
|
||||
|
||||
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("allPassed", allPassed);
|
||||
res.add("checks", gson.toJsonTree(checks));
|
||||
return gson.toJson(res);
|
||||
return checks;
|
||||
}
|
||||
|
||||
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
|
||||
@@ -709,6 +808,21 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
|
||||
}
|
||||
|
||||
// The Share Sundry account the redemption draws on must actually hold the money.
|
||||
//
|
||||
// collectionApplied above says A collection was applied to THIS account, not that the
|
||||
// proceeds are still sitting in Sundry - the same holding account serves every share
|
||||
// account, and an earlier redemption may already have drawn it down. Approving past that
|
||||
// takes Sundry negative, which is a liability owing less than nothing, and the overdraw
|
||||
// lands inside a balance-sheet head that totals positive, where it does not look wrong.
|
||||
final BigDecimal redemptionValue = redemptionValueOf(shareAccountId, requestedShares);
|
||||
if (redemptionValue != null) {
|
||||
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
|
||||
shareAccountId);
|
||||
GLAccountTransactionGuard.assertSufficientBalance(jdbcTemplate, resolveGl(productId, FA_SHARES_SUSPENSE), redemptionValue,
|
||||
"The Share Sundry (Suspense) account");
|
||||
}
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
@@ -958,6 +1072,33 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
|
||||
* reset id sequence in a dev environment can violate.
|
||||
*/
|
||||
/**
|
||||
* What a redemption of this many shares will draw out of Share Sundry.
|
||||
*
|
||||
* <p>
|
||||
* Priced at the unit price the member's own shares were issued at, which is the price the
|
||||
* redemption itself uses. The product's nominal price is not consulted: a member who subscribed
|
||||
* at an earlier price redeems at theirs, and pricing the guard differently from the posting
|
||||
* would either block a redemption that fits or let one through that does not.
|
||||
*
|
||||
* @return the value, or null where no price can be established - the guard then stands aside
|
||||
* rather than blocking on a figure it had to invent.
|
||||
*/
|
||||
private BigDecimal redemptionValueOf(final Long shareAccountId, final Long requestedShares) {
|
||||
if (shareAccountId == null || requestedShares == null || requestedShares <= 0) {
|
||||
return null;
|
||||
}
|
||||
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
|
||||
final BigDecimal unitPrice = jdbcTemplate
|
||||
.queryForList("""
|
||||
SELECT unit_price FROM m_share_account_transactions
|
||||
WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", BigDecimal.class, shareAccountId)
|
||||
.stream().findFirst().orElse(null);
|
||||
return unitPrice == null ? null : unitPrice.multiply(BigDecimal.valueOf(requestedShares));
|
||||
}
|
||||
|
||||
private Long redeemDebitGlOf(final Long shareTransactionId) {
|
||||
if (shareTransactionId == null) {
|
||||
return null;
|
||||
|
||||
+11
-1
@@ -130,9 +130,19 @@ public final class CountedCashLedger {
|
||||
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
|
||||
+ " AND" + REAL_TXN_ID + ") ";
|
||||
|
||||
/**
|
||||
* Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what
|
||||
* the login counted once this allocation existed. A breakdown is tied to a cashier through its
|
||||
* officer's login, so without this a person moved from one branch's teller to another's carried
|
||||
* every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation
|
||||
* with no recorded creation time keeps its whole history.
|
||||
*/
|
||||
public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL"
|
||||
+ " OR std.created_at >= mc.created_at)";
|
||||
|
||||
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
|
||||
private static String breakdownDiffersFrom(final String amountColumn) {
|
||||
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
|
||||
return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)"
|
||||
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
|
||||
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
|
||||
}
|
||||
|
||||
+4
-4
@@ -288,7 +288,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
||||
@@ -312,7 +312,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
||||
@@ -409,7 +409,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
||||
@@ -433,7 +433,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
||||
|
||||
+8
-1
@@ -55,6 +55,7 @@ import org.apache.fineract.portfolio.loanaccount.guarantor.exception.InvalidGuar
|
||||
import org.apache.fineract.portfolio.loanaccount.guarantor.serialization.GuarantorCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -79,6 +80,7 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final AccountAssociationsRepository accountAssociationsRepository;
|
||||
private final GuarantorDomainService guarantorDomainService;
|
||||
private final RecurringDepositLienService recurringDepositLienService;
|
||||
|
||||
@Autowired
|
||||
public GuarantorWritePlatformServiceJpaRepositoryIImpl(final LoanRepositoryWrapper loanRepositoryWrapper,
|
||||
@@ -86,7 +88,9 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
|
||||
final StaffRepositoryWrapper staffRepositoryWrapper, final GroupRepositoryWrapper groupRepositoryWrapper,
|
||||
final GuarantorCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||
final CodeValueRepositoryWrapper codeValueRepositoryWrapper, final SavingsAccountAssembler savingsAccountAssembler,
|
||||
final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService) {
|
||||
final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService,
|
||||
final RecurringDepositLienService recurringDepositLienService) {
|
||||
this.recurringDepositLienService = recurringDepositLienService;
|
||||
this.loanRepositoryWrapper = loanRepositoryWrapper;
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.groupRepositoryWrapper = groupRepositoryWrapper;
|
||||
@@ -120,6 +124,9 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
|
||||
final SavingsAccount savingsAccount = this.savingsAccountAssembler.assembleFrom(guarantorCommand.getSavingsId(),
|
||||
backdatedTxnsAllowedTill);
|
||||
validateGuarantorSavingsAccountActivationDateWithLoanSubmittedOnDate(loan, savingsAccount);
|
||||
// A recurring deposit pledged here is a lien on it: its product must allow that, and it can
|
||||
// never secure more than the balance it has not already pledged.
|
||||
this.recurringDepositLienService.validatePledge(savingsAccount, guarantorCommand.getAmount(), loan.getId());
|
||||
accountAssociations = AccountAssociations.associateSavingsAccount(loan, savingsAccount,
|
||||
AccountAssociationType.GUARANTOR_ACCOUNT_ASSOCIATION.getValue(), backdatedTxnsAllowedTill);
|
||||
|
||||
|
||||
+53
-6
@@ -166,12 +166,23 @@ public class RbiFormAFilingApiResource {
|
||||
"The generated return must be supplied with the save.");
|
||||
}
|
||||
|
||||
final LocalDate periodStart = localDate(report, "fortnightStart");
|
||||
final LocalDate periodEnd = localDate(report, "fortnightEnd");
|
||||
final BigDecimal ndtl = decimal(report, "ndtlAmount");
|
||||
final BigDecimal required = decimal(report, "requiredAmount");
|
||||
final BigDecimal maintained = decimal(report, "maintainedAmount");
|
||||
final BigDecimal rate = decimal(report, "ratePercentage");
|
||||
// The statutory form states its figures per column inside Part B rather than once at the
|
||||
// top level, so the columns the saved LIST shows - NDTL, required, maintained - have to be
|
||||
// read out of it. Without this a filed statutory return listed as a row of dashes, and the
|
||||
// list could not be scanned for the one return somebody was looking for.
|
||||
final boolean statutory = report.has("formCode") && !report.get("formCode").isJsonNull()
|
||||
&& "FORM_1_STATUTORY".equals(report.get("formCode").getAsString());
|
||||
|
||||
// The statutory form's period runs between its two fortnight closes, which is what the
|
||||
// saved list prints as the row's period.
|
||||
final LocalDate periodStart = statutory ? columnDate(report, 0) : localDate(report, "fortnightStart");
|
||||
final LocalDate periodEnd = statutory ? columnDate(report, 1) : localDate(report, "fortnightEnd");
|
||||
// The current fortnight's figures are the second column - the close the form is made up to.
|
||||
final BigDecimal ndtl = statutory ? partFigure(report, "partA", "baseDateLiabilities") : decimal(report, "ndtlAmount");
|
||||
final BigDecimal required = statutory ? partFigure(report, "partB", "required") : decimal(report, "requiredAmount");
|
||||
final BigDecimal maintained = statutory ? partFigure(report, "partB", "maintained")
|
||||
: decimal(report, "maintainedAmount");
|
||||
final BigDecimal rate = statutory ? decimal(report, "crrPercentage") : decimal(report, "ratePercentage");
|
||||
BigDecimal average = null;
|
||||
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
||||
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
||||
@@ -302,4 +313,40 @@ public class RbiFormAFilingApiResource {
|
||||
final String value = object.get(field).getAsString();
|
||||
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* One of a statutory part's per-column figures, taken at the column the form is made up to.
|
||||
*
|
||||
* The form carries three columns and the saved list has one cell per figure, so it takes the
|
||||
* second - the close the return reports. Index 1 rather than "the last": the third column is
|
||||
* the base the ratios are charged on, not a later position, and listing it would show a
|
||||
* requirement against liabilities from a fortnight nobody filed.
|
||||
*/
|
||||
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
|
||||
if (!report.has(part) || !report.get(part).isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
final JsonObject partObject = report.getAsJsonObject(part);
|
||||
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
final var values = partObject.getAsJsonArray(field);
|
||||
if (values.size() < 2 || values.get(1).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
return values.get(1).getAsBigDecimal();
|
||||
}
|
||||
|
||||
/** One of the statutory form's column dates, for the period a saved row reports. */
|
||||
private static LocalDate columnDate(final JsonObject report, final int index) {
|
||||
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
final var columns = report.getAsJsonArray("columns");
|
||||
if (columns.size() <= index || columns.get(index).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String value = columns.get(index).getAsString();
|
||||
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||
}
|
||||
}
|
||||
|
||||
+46
-11
@@ -71,9 +71,11 @@ public class RbiReserveConfigApiResource {
|
||||
static {
|
||||
ROLE_DESCRIPTIONS.put("CASH_IN_HAND",
|
||||
"Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained.");
|
||||
ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS",
|
||||
"Current account balances the bank holds with other banks. Counts toward CRR maintained. "
|
||||
+ "Pin every such account - a bank typically banks with several.");
|
||||
// BALANCE_WITH_OTHER_BANKS is deliberately not offered. It belongs to the older working
|
||||
// report, and the statutory Form 1 this bank files has no line for it: balances with other
|
||||
// banks are reported there under III(a), III(b)(i) or VI, depending on which bank holds
|
||||
// them. Left on the screen it invited a mapping that would raise item X without appearing
|
||||
// anywhere on the form to say where the figure came from.
|
||||
ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES",
|
||||
"Deposit liabilities forming the DTL base that CRR and SLR are charged on. "
|
||||
+ "Pin the deposit accounts and any interest accrued on them.");
|
||||
@@ -199,6 +201,7 @@ public class RbiReserveConfigApiResource {
|
||||
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
|
||||
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
|
||||
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
|
||||
ROLE_GROUP_LABELS.put("FORM9", "Form IX — Return on Assets and Liabilities (R408)");
|
||||
|
||||
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
|
||||
if (role.startsWith("OSS1_")) {
|
||||
@@ -209,12 +212,14 @@ public class RbiReserveConfigApiResource {
|
||||
ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter");
|
||||
} else {
|
||||
ROLE_GROUPS.put(role, "RESERVE");
|
||||
// Both ratios are settled on Form 1 - Part B is section 18, Part C section 24 - so
|
||||
// the part is named rather than a separate return that does not exist.
|
||||
if ("SLR_INVESTMENTS".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
|
||||
ROLE_USAGE.put(role, "Form 1 Part C (SLR) — maintained");
|
||||
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
|
||||
ROLE_USAGE.put(role, "Form 1 — base for both ratios");
|
||||
} else {
|
||||
ROLE_USAGE.put(role, "Form 1 (CRR) — maintained");
|
||||
ROLE_USAGE.put(role, "Form 1 Part B (CRR) — maintained");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,9 +232,15 @@ public class RbiReserveConfigApiResource {
|
||||
*/
|
||||
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
|
||||
|
||||
/** The four treatments a head can be given, each naming a branch the returns already have. */
|
||||
/**
|
||||
* The treatments a head can be given, each naming a branch the returns already have.
|
||||
*
|
||||
* ASSET is Form IX's, and is not ASSET_NET: that one is netted OFF the liability base to arrive
|
||||
* at NDTL, this one is added INTO Total Assets. A Form IX asset given ASSET_NET would be
|
||||
* subtracted from the liabilities of a return that has no such base.
|
||||
*/
|
||||
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
|
||||
"MEMO");
|
||||
"ASSET", "MEMO");
|
||||
|
||||
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
|
||||
static {
|
||||
@@ -243,6 +254,27 @@ public class RbiReserveConfigApiResource {
|
||||
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
|
||||
}
|
||||
|
||||
/**
|
||||
* How a head is consumed, in the terms of the return it belongs to.
|
||||
*
|
||||
* LIABILITY means two different things depending on the group. On Form 1 and Form 9 it says the
|
||||
* head is added into the DTL base a ratio is charged on. On Form IX there is no ratio and no
|
||||
* base: it says which SIDE OF THE BALANCE SHEET the head sits on. Describing both with the DTL
|
||||
* wording would tell a Form IX filer their head feeds a calculation that does not exist on
|
||||
* their return.
|
||||
*/
|
||||
private static String usedBy(final String group, final String usage) {
|
||||
if ("FORM9".equals(group)) {
|
||||
return switch (usage == null ? "" : usage) {
|
||||
case "LIABILITY" -> "Form IX — a liability; enters Total Liabilities";
|
||||
case "ASSET" -> "Form IX — an asset; enters Total Assets";
|
||||
case "MEMO" -> "Form IX — stated on the form but entering no total";
|
||||
default -> usage;
|
||||
};
|
||||
}
|
||||
return USAGE_DESCRIPTIONS.getOrDefault(usage, usage);
|
||||
}
|
||||
|
||||
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
|
||||
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
|
||||
static {
|
||||
@@ -390,11 +422,14 @@ public class RbiReserveConfigApiResource {
|
||||
o.put("label", row.get("label"));
|
||||
o.put("group", group);
|
||||
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
|
||||
o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
|
||||
o.put("usedBy", usedBy(group, usage));
|
||||
o.put("builtIn", false);
|
||||
o.put("usage", usage);
|
||||
// A liability head carries the demand/time split; nothing else does.
|
||||
o.put("classified", "LIABILITY".equals(usage));
|
||||
// A liability head carries the demand/time split; nothing else does. Not on Form IX,
|
||||
// where the demand and time heads are separate lines of the form itself - asking for a
|
||||
// class on top of that would let a head declared under 5(a) Fixed Deposits be marked
|
||||
// DEMAND, which the form has no way to report.
|
||||
o.put("classified", "LIABILITY".equals(usage) && !"FORM9".equals(group));
|
||||
o.put("roleId", row.get("id"));
|
||||
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
|
||||
out.add(o);
|
||||
|
||||
+1709
-24
File diff suppressed because it is too large
Load Diff
+61
-13
@@ -53,7 +53,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Slf4j
|
||||
public class RbiFormAAutoSaveService {
|
||||
|
||||
private static final String FORM_CODE = "FORM_1";
|
||||
// The statutory form, because that is the Form 1 the screen shows and the one that gets filed.
|
||||
// Saving the working report's shape left rows in the list that opened as a blank statement:
|
||||
// the viewer built for the statutory form found none of the fields it reads.
|
||||
private static final String FORM_CODE = "FORM_1_STATUTORY";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final RbiReserveReturnApiResource returnApiResource;
|
||||
@@ -89,7 +92,13 @@ public class RbiFormAAutoSaveService {
|
||||
try {
|
||||
// Computed through the same endpoint the screen uses, so an auto-saved return and one
|
||||
// saved by hand cannot come out differently.
|
||||
report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class);
|
||||
// No column dates passed: today IS a fortnight close - the check above returned
|
||||
// otherwise - so the form derives its own pair from it, which is the pair an unattended
|
||||
// run should file. A filer needing a different period enters both dates on the screen.
|
||||
// No base dates passed either: an unattended run files the base the rules fix, which is
|
||||
// what a saved return should carry. A filer needing a different base enters it on screen.
|
||||
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null, null, null),
|
||||
JsonObject.class);
|
||||
} catch (final RuntimeException e) {
|
||||
// A return that cannot be computed is not an error worth failing the batch over - an
|
||||
// unmapped role or a missing rate is a configuration gap, and the next run picks it up
|
||||
@@ -98,18 +107,22 @@ public class RbiFormAAutoSaveService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) {
|
||||
log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.",
|
||||
// The statutory form has no all-or-nothing computable flag: an unmapped head is a real nil
|
||||
// on it - a bank with no balances at the State Co-operative Bank files that line empty - so
|
||||
// the form is filed as it stands. What it cannot do without is a rate, because every
|
||||
// required amount below is a percentage of one, and saving a return whose requirement is
|
||||
// zero for want of configuration would put a compliant-looking statement on the record.
|
||||
if (report == null || report.get("crrPercentage") == null || report.get("crrPercentage").isJsonNull()) {
|
||||
log.info("RBI Form A auto-save: no CRR rate in force on {}; not saving a return with no requirement.",
|
||||
today);
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDate periodStart = date(report, "fortnightStart");
|
||||
final LocalDate periodEnd = date(report, "fortnightEnd");
|
||||
BigDecimal average = null;
|
||||
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
||||
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
||||
}
|
||||
// The form's period runs between its two fortnight closes, and its headline figures sit per
|
||||
// column inside the parts rather than once at the top.
|
||||
final LocalDate periodStart = column(report, 0);
|
||||
final LocalDate periodEnd = column(report, 1);
|
||||
final BigDecimal average = null;
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_rbi_form_a_filing
|
||||
@@ -119,9 +132,9 @@ public class RbiFormAAutoSaveService {
|
||||
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now())
|
||||
""", FORM_CODE, java.sql.Date.valueOf(today),
|
||||
java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
|
||||
gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"),
|
||||
decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"),
|
||||
"Saved automatically at the close of the fortnight.");
|
||||
gson.toJson(report), partFigure(report, "partA", "baseDateLiabilities"),
|
||||
partFigure(report, "partB", "required"), partFigure(report, "partB", "maintained"), average,
|
||||
decimal(report, "crrPercentage"), "Saved automatically at the close of the fortnight.");
|
||||
|
||||
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
|
||||
}
|
||||
@@ -143,6 +156,41 @@ public class RbiFormAAutoSaveService {
|
||||
return object.get(field).getAsBigDecimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the statutory form's per-column figures, at the close the return is made up to.
|
||||
*
|
||||
* Index 1, the second column: the third is the base the ratios are charged on, not a later
|
||||
* position, and listing it would show a requirement against a fortnight nobody filed.
|
||||
*/
|
||||
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
|
||||
if (!report.has(part) || !report.get(part).isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
final JsonObject partObject = report.getAsJsonObject(part);
|
||||
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
final var values = partObject.getAsJsonArray(field);
|
||||
if (values.size() < 2 || values.get(1).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
return values.get(1).getAsBigDecimal();
|
||||
}
|
||||
|
||||
/** One of the form's column dates. */
|
||||
private static LocalDate column(final JsonObject report, final int index) {
|
||||
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
final var columns = report.getAsJsonArray("columns");
|
||||
if (columns.size() <= index || columns.get(index).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String value = columns.get(index).getAsString();
|
||||
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static LocalDate date(final JsonObject object, final String field) {
|
||||
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
|
||||
return null;
|
||||
|
||||
+4
-1
@@ -361,6 +361,9 @@ public class RecurringDepositAccountsApiResource {
|
||||
} else if (is(commandParam, "close")) {
|
||||
final CommandWrapper commandRequest = builder.closeRecurringDepositAccount(accountId).build();
|
||||
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
} else if (is(commandParam, "extendTenure")) {
|
||||
final CommandWrapper commandRequest = builder.extendTenureRecurringDepositAccount(accountId).build();
|
||||
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
} else if (is(commandParam, "prematureClose")) {
|
||||
final CommandWrapper commandRequest = builder.prematureCloseRecurringDepositAccount(accountId).build();
|
||||
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
@@ -377,7 +380,7 @@ public class RecurringDepositAccountsApiResource {
|
||||
if (result == null) {
|
||||
throw new UnrecognizedQueryParamException("command", commandParam,
|
||||
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
|
||||
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
|
||||
"postInterest", "close", "prematureClose", "calculatePrematureAmount", "extendTenure" });
|
||||
}
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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 com.google.gson.Gson;
|
||||
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.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.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* RD Dashboard's data source: every Recurring Deposit still running (ACTIVE, or MATURED and waiting to be closed) in
|
||||
* the user's office hierarchy, each with its installment position worked out as of the business date, plus the
|
||||
* month's closures and penalty income for the summary cards.
|
||||
*
|
||||
* <p>
|
||||
* An installment counts as paid by the deposit's cumulative deposits, not by the schedule's own completed flag - the
|
||||
* same rule {@code RecurringDepositMissedInstallmentPenaltyService} uses, since money paid in advance at the counter is
|
||||
* not always allocated to the installment it covers. The dashboard and the penalty job therefore agree on which
|
||||
* installments are overdue.
|
||||
*/
|
||||
@Path("/v1/recurringdepositaccounts/dashboard")
|
||||
@Component
|
||||
@Tag(name = "Recurring Deposit Accounts", description = "RD Dashboard: running RDs with their installment position.")
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositDashboardApiResource {
|
||||
|
||||
/** DepositAccountType.RECURRING_DEPOSIT. */
|
||||
private static final int RECURRING_DEPOSIT = 300;
|
||||
/** SavingsAccountStatusType values. */
|
||||
private static final int ACTIVE = 300;
|
||||
private static final int CLOSED = 600;
|
||||
private static final int PRE_MATURE_CLOSURE = 700;
|
||||
private static final int MATURED = 800;
|
||||
/** SavingsAccountTransactionType.DEPOSIT. */
|
||||
private static final int DEPOSIT = 1;
|
||||
|
||||
private static final String ROWS_SQL = """
|
||||
WITH rd AS (
|
||||
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.account_balance_derived,
|
||||
sa.auto_debit_facility, sa.linked_account_no, c.display_name AS client_name, o.id AS office_id,
|
||||
o.name AS office_name
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) AND o.hierarchy LIKE ?
|
||||
),
|
||||
paid AS (
|
||||
SELECT t.savings_account_id AS id, SUM(t.amount) AS total_paid,
|
||||
SUM(t.amount) FILTER (WHERE t.transaction_date BETWEEN ? AND ?) AS paid_this_month
|
||||
FROM m_savings_account_transaction t
|
||||
JOIN rd ON rd.id = t.savings_account_id
|
||||
WHERE t.transaction_type_enum = ? AND t.is_reversed = false
|
||||
GROUP BY t.savings_account_id
|
||||
),
|
||||
sched AS (
|
||||
SELECT s.savings_account_id AS id, s.duedate, s.deposit_amount,
|
||||
SUM(s.deposit_amount) OVER (PARTITION BY s.savings_account_id ORDER BY s.duedate, s.installment) AS cum_due
|
||||
FROM m_mandatory_savings_schedule s
|
||||
JOIN rd ON rd.id = s.savings_account_id
|
||||
),
|
||||
pos AS (
|
||||
SELECT sc.id,
|
||||
COUNT(*) AS total_installments,
|
||||
COUNT(*) FILTER (WHERE sc.cum_due <= COALESCE(p.total_paid, 0)) AS paid_installments,
|
||||
MIN(sc.duedate) FILTER (WHERE sc.cum_due > COALESCE(p.total_paid, 0)) AS next_unpaid_due,
|
||||
COUNT(*) FILTER (WHERE sc.cum_due > COALESCE(p.total_paid, 0) AND sc.duedate < ?) AS overdue_installments,
|
||||
GREATEST(COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate < ?), 0) - COALESCE(MAX(p.total_paid), 0), 0)
|
||||
AS overdue_amount,
|
||||
COUNT(*) FILTER (WHERE sc.duedate = ?) AS due_today_count,
|
||||
COUNT(*) FILTER (WHERE sc.duedate = ? AND sc.cum_due <= COALESCE(p.total_paid, 0)) AS due_today_paid,
|
||||
COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate BETWEEN ? AND ?), 0) AS due_this_month,
|
||||
COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate BETWEEN ? AND ?
|
||||
AND sc.cum_due <= COALESCE(p.total_paid, 0)), 0) AS received_this_month
|
||||
FROM sched sc
|
||||
LEFT JOIN paid p ON p.id = sc.id
|
||||
GROUP BY sc.id
|
||||
),
|
||||
pen AS (
|
||||
SELECT pe.savings_account_id AS id, COUNT(*) AS penalty_count, SUM(pe.penalty_amount) AS penalty_amount
|
||||
FROM m_rd_installment_penalty pe
|
||||
JOIN rd ON rd.id = pe.savings_account_id
|
||||
GROUP BY pe.savings_account_id
|
||||
),
|
||||
lien AS (
|
||||
SELECT paa.linked_savings_account_id AS id, SUM(COALESCE(gfd.amount_remaining_derived, gfd.amount)) AS lien_amount
|
||||
FROM m_guarantor_funding_details gfd
|
||||
JOIN m_portfolio_account_associations paa ON paa.id = gfd.account_associations_id
|
||||
JOIN m_loan l ON l.id = paa.loan_account_id
|
||||
JOIN rd ON rd.id = paa.linked_savings_account_id
|
||||
WHERE gfd.status_enum = 100 AND l.loan_status_id IN (100, 200, 300)
|
||||
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0
|
||||
GROUP BY paa.linked_savings_account_id
|
||||
)
|
||||
SELECT rd.id AS "id", rd.account_no AS "accountNo", rd.client_id AS "clientId", rd.client_name AS "clientName",
|
||||
rd.office_id AS "officeId", rd.office_name AS "officeName", sp.id AS "productId", sp.name AS "productName",
|
||||
rd.status_enum AS "status", rd.account_balance_derived AS "balance",
|
||||
rdd.mandatory_recommended_deposit_amount AS "installmentAmount",
|
||||
TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate", datp.maturity_amount AS "maturityAmount",
|
||||
COALESCE(rd.auto_debit_facility, false) AS "autoDebit", rd.linked_account_no AS "linkedAccountNo",
|
||||
COALESCE(sp.missed_installment_grace_days, 0) AS "graceDays",
|
||||
COALESCE(sp.penalty_grace_period_missed_installments, false) AS "penaltyEnabled",
|
||||
COALESCE(pos.total_installments, 0) AS "totalInstallments",
|
||||
COALESCE(pos.paid_installments, 0) AS "paidInstallments",
|
||||
TO_CHAR(pos.next_unpaid_due, 'YYYY-MM-DD') AS "nextDueDate",
|
||||
COALESCE(pos.overdue_installments, 0) AS "overdueInstallments",
|
||||
COALESCE(pos.overdue_amount, 0) AS "overdueAmount",
|
||||
COALESCE(pos.due_today_count, 0) AS "dueTodayCount",
|
||||
COALESCE(pos.due_today_paid, 0) AS "dueTodayPaid",
|
||||
COALESCE(pos.due_this_month, 0) AS "dueThisMonth",
|
||||
COALESCE(pos.received_this_month, 0) AS "receivedThisMonth",
|
||||
COALESCE(p.paid_this_month, 0) AS "paidThisMonth",
|
||||
COALESCE(pen.penalty_count, 0) AS "penaltyCount",
|
||||
COALESCE(pen.penalty_amount, 0) AS "penaltyAmount",
|
||||
COALESCE(lien.lien_amount, 0) AS "lienAmount"
|
||||
FROM rd
|
||||
JOIN m_savings_product sp ON sp.id = rd.product_id
|
||||
LEFT JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = rd.id
|
||||
LEFT JOIN m_deposit_account_recurring_detail rdd ON rdd.savings_account_id = rd.id
|
||||
LEFT JOIN pos ON pos.id = rd.id
|
||||
LEFT JOIN paid p ON p.id = rd.id
|
||||
LEFT JOIN pen ON pen.id = rd.id
|
||||
LEFT JOIN lien ON lien.id = rd.id
|
||||
ORDER BY pos.next_unpaid_due NULLS LAST, rd.account_no
|
||||
""";
|
||||
|
||||
/** RDs closed this month, split by how they ended. */
|
||||
private static final String CLOSED_THIS_MONTH_SQL = """
|
||||
SELECT COUNT(*) FILTER (WHERE sa.status_enum = ?) AS matured_closed,
|
||||
COUNT(*) FILTER (WHERE sa.status_enum = ?) AS premature_closed
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
WHERE sa.deposit_type_enum = ? AND sa.closedon_date BETWEEN ? AND ? AND o.hierarchy LIKE ?
|
||||
""";
|
||||
|
||||
/** Missed-installment penalties charged, this month and in all, on RDs in the hierarchy (closed ones included). */
|
||||
private static final String PENALTY_INCOME_SQL = """
|
||||
SELECT COALESCE(SUM(pe.penalty_amount) FILTER (WHERE pe.applied_on_date BETWEEN ? AND ?), 0) AS this_month,
|
||||
COALESCE(SUM(pe.penalty_amount), 0) AS total
|
||||
FROM m_rd_installment_penalty pe
|
||||
JOIN m_savings_account sa ON sa.id = pe.savings_account_id
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
WHERE o.hierarchy LIKE ?
|
||||
""";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String retrieve() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
||||
|
||||
// "Today" is the business date - the branch's working day, not the server clock.
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate monthStart = today.withDayOfMonth(1);
|
||||
final LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth());
|
||||
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy,
|
||||
monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd);
|
||||
final Map<String, Object> closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE,
|
||||
RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy);
|
||||
final Map<String, Object> penalty = this.jdbcTemplate.queryForMap(PENALTY_INCOME_SQL, monthStart, monthEnd, hierarchy);
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("businessDate", today.toString());
|
||||
result.put("maturedClosedThisMonth", closed.get("matured_closed"));
|
||||
result.put("prematureClosedThisMonth", closed.get("premature_closed"));
|
||||
result.put("penaltyIncomeThisMonth", penalty.get("this_month"));
|
||||
result.put("penaltyIncomeTotal", penalty.get("total"));
|
||||
result.put("accounts", rows);
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 com.google.gson.Gson;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Read-only: the loans a recurring deposit currently secures and how much of it each one holds, plus how much more
|
||||
* could be given as loan against it today, so the RD screens can show the lien, its limit, and why the deposit cannot
|
||||
* be closed.
|
||||
*/
|
||||
@Path("/v1/recurringdepositaccounts/{accountId}/liens")
|
||||
@Component
|
||||
@Tag(name = "Recurring Deposit Account", description = "Liens on a recurring deposit pledged as loan security.")
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositLienApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final RecurringDepositLienService recurringDepositLienService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String retrieve(@PathParam("accountId") final Long accountId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
final List<RecurringDepositLienService.Lien> liens = this.recurringDepositLienService.activeLiens(accountId);
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
final BigDecimal totalLien = liens.stream().map(RecurringDepositLienService.Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
result.put("totalLienAmount", totalLien);
|
||||
final RecurringDepositLienService.LienLimit limit = this.recurringDepositLienService.lienLimit(accountId);
|
||||
if (limit != null) {
|
||||
result.put("lienAllowed", limit.lienAllowed());
|
||||
result.put("lienPercentage", limit.accountPercentage());
|
||||
result.put("productLienPercentage", limit.productPercentage());
|
||||
result.put("effectiveLienPercentage", limit.effectivePercentage());
|
||||
result.put("maxLoanAmount", limit.maxLoanAmount());
|
||||
result.put("availableForLoan", limit.maxLoanAmount().subtract(totalLien).max(BigDecimal.ZERO));
|
||||
}
|
||||
result.put("liens", liens.stream().map(lien -> {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("loanId", lien.loanId());
|
||||
row.put("loanAccountNo", lien.loanAccountNo());
|
||||
row.put("loanStatus", lien.loanStatus());
|
||||
row.put("amount", lien.amount());
|
||||
return row;
|
||||
}).toList());
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+81
-70
@@ -26,10 +26,14 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
|
||||
* Runs the day's savings interest: accrue for every account, and post interest only from approved
|
||||
* interest posting reports ({@link SavingsInterestPostingReportService}).
|
||||
*
|
||||
* 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
|
||||
@@ -47,91 +51,98 @@ public class SavingsDailyInterestOrchestrator {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsDailyInterestService accrualService;
|
||||
private final SavingsPooledInterestPostingService postingService;
|
||||
private final SavingsInterestPostingReportService reportService;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* Accrue for the day, then post for any product whose posting date has arrived.
|
||||
* The nightly run: settles any approved interest report still unposted, then accrues the day.
|
||||
*
|
||||
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
|
||||
* posting then reads and updates those same rows in a transaction of its own
|
||||
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
|
||||
* another's payment). Holding an outer transaction across both would mean this thread still owns
|
||||
* the row locks the accrual took while the posting's separate transaction waits for them — each
|
||||
* waiting on the other, and the job hanging silently with nothing in the log. So each step
|
||||
* commits on its own before the next begins.
|
||||
* <p>
|
||||
* This run no longer decides to pay anybody. Interest is paid only from an approved posting
|
||||
* report, normally when the last branch closes the day. What is left here is the safety net for a
|
||||
* report that was approved but could not be posted then, and it runs BEFORE the accrual on
|
||||
* purpose: by the time this job runs the business date has already moved past the posting date,
|
||||
* and the accrual rolls each account's ledger row into the next cycle, which would leave nothing
|
||||
* for the posting to settle.
|
||||
*
|
||||
* <p>
|
||||
* Deliberately not {@code @Transactional}. Each report posts in a transaction of its own, and the
|
||||
* accrual then commits on its own, so neither holds the other's row locks.
|
||||
*/
|
||||
public void runForDate(final LocalDate businessDate) {
|
||||
clearStaleRunningFlag();
|
||||
final List<String> failures = postApprovedReports(businessDate);
|
||||
accrualService.accrueForDate(businessDate);
|
||||
enrolNewProducts();
|
||||
|
||||
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
|
||||
""");
|
||||
|
||||
// Products that could not be paid, collected so the run can fail loudly at the end.
|
||||
//
|
||||
// Carrying on past one product is right — a mapping missing on one product must not stop
|
||||
// every member of every other from being paid. Reporting the job as successful afterwards is
|
||||
// not: the scheduler recorded 'success', the operator saw a green run, and the only trace
|
||||
// that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
|
||||
// nothing reads. On a night when every product fails, that is a whole day of interest
|
||||
// silently unpaid.
|
||||
final List<String> failures = new java.util.ArrayList<>();
|
||||
|
||||
for (final Map<String, Object> schedule : schedules) {
|
||||
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
||||
|
||||
// Whether anything is due is decided inside postForProduct, not here.
|
||||
//
|
||||
// This used to pre-check with its own COUNT over m_savings_daily_interest, and that
|
||||
// deadlocked the run: accrueForDate above is @Transactional and has just written every
|
||||
// ledger row, so its transaction still holds them; the COUNT then read those rows on
|
||||
// this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
|
||||
// transaction of its own — sat waiting for a lock this thread was holding and would only
|
||||
// release after postForProduct returned. Each waited on the other and the job hung
|
||||
// silently, with nothing in the log and the flag left saying it was still running.
|
||||
//
|
||||
// postForProduct already selects what is due and returns zero when there is nothing, so
|
||||
// the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
|
||||
// than by the run date, which is what makes a missed posting date catch up on a later
|
||||
// run instead of being skipped for good.
|
||||
try {
|
||||
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
|
||||
if (paid.signum() == 0) {
|
||||
updateSchedule(productId, businessDate, null);
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
// An UPDATE, so it writes nothing when the product threw before openRun() inserted
|
||||
// the row — which is exactly what a product with no GL mapping does. The failure is
|
||||
// therefore collected in memory as well, so it cannot be lost with the breadcrumb.
|
||||
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");
|
||||
failures.add("product " + productId + ": " + e.getMessage());
|
||||
}
|
||||
// Keeps each product's next posting date on the timetable in step with the ledger, which
|
||||
// the posting loop used to do as a side effect of looking for work.
|
||||
for (final Long productId : jdbcTemplate.queryForList(
|
||||
"SELECT savings_product_id FROM m_savings_interest_posting_schedule WHERE is_active = true", Long.class)) {
|
||||
updateSchedule(productId, businessDate, null);
|
||||
}
|
||||
|
||||
// Fails the job so the scheduler records it as failed and an operator is told. Every product
|
||||
// that could be paid has already been paid and committed by this point — this reports what
|
||||
// happened, it does not undo it.
|
||||
// Fails the job so the scheduler records it as failed and an operator is told. Every report
|
||||
// that could be paid has already been paid and committed by this point.
|
||||
if (!failures.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
|
||||
+ failures.size() + " product(s): " + String.join("; ", failures));
|
||||
+ failures.size() + " report(s): " + String.join("; ", failures));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts every approved interest posting report dated on or before the date.
|
||||
*
|
||||
* <p>
|
||||
* Each report posts in a new transaction of its own. That is required, not a preference: this is
|
||||
* called from the day close's after-commit callback, where the closing transaction has finished
|
||||
* but is still bound to the thread, and joining it would write nothing that ever commits. It also
|
||||
* keeps one product's failure from rolling back another's payment. A report that fails stays
|
||||
* APPROVED with the reason recorded, and the nightly run tries it again.
|
||||
*
|
||||
* @return one message per report that could not be posted
|
||||
*/
|
||||
public List<String> postApprovedReports(final LocalDate upToDate) {
|
||||
return postReports(reportService.approvedReportIds(upToDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the given approved reports, each in a new transaction of its own (see
|
||||
* {@link #postApprovedReports}). Used by the day close for one branch's reports.
|
||||
*
|
||||
* @return one message per report that could not be posted
|
||||
*/
|
||||
public List<String> postReports(final List<Long> reportIds) {
|
||||
final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager);
|
||||
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
final List<String> failures = new java.util.ArrayList<>();
|
||||
for (final Long reportId : reportIds) {
|
||||
final Map<String, Object> report = jdbcTemplate.queryForMap(
|
||||
"SELECT savings_product_id, posting_date FROM m_savings_interest_posting_report WHERE id = ?", reportId);
|
||||
final long productId = ((Number) report.get("savings_product_id")).longValue();
|
||||
final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||
try {
|
||||
final BigDecimal paid = newTransaction.execute(status -> postingService.postReport(reportId));
|
||||
newTransaction.executeWithoutResult(status -> updateSchedule(productId, postingDate, "COMPLETED"));
|
||||
log.info("Savings interest: report {} for product {} posted {} on {}", reportId, productId, paid, postingDate);
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Savings interest: report {} for product {} failed to post on {}", reportId, productId, postingDate, e);
|
||||
newTransaction.executeWithoutResult(status -> {
|
||||
reportService.recordPostingFailure(reportId, e.getMessage());
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_run
|
||||
SET status = 'FAILED', failure_reason = ?
|
||||
WHERE report_id = ?
|
||||
""", truncate(e.getMessage()), reportId);
|
||||
updateSchedule(productId, postingDate, "FAILED");
|
||||
});
|
||||
failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases this job's own currently_running flag if an earlier run left it set.
|
||||
*
|
||||
|
||||
+27
-7
@@ -243,7 +243,7 @@ public class SavingsDailyInterestService {
|
||||
carriedInterest = backfill.cumulativeInterest();
|
||||
} else {
|
||||
final Map<String, Object> prev = previous.get(0);
|
||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
|
||||
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
|
||||
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
|
||||
// A cycle is finished either because it was paid out or because its end date has gone
|
||||
@@ -251,11 +251,24 @@ public class SavingsDailyInterestService {
|
||||
// run was missed, or the account was accrued again after a break - and treating it as
|
||||
// unfinished would leave the row in a cycle that ended before the day it records.
|
||||
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
|
||||
carriedInterest = BigDecimal.ZERO;
|
||||
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
|
||||
cycleStart = prevCycleEnd.plusDays(1);
|
||||
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
|
||||
financialYearBeginningMonth);
|
||||
// The new cycle may already be days old: the business date moved on by more than a
|
||||
// day, or nights were missed. Those days earned too. Starting the cycle at zero and
|
||||
// counting only today dropped them — a jump from 30 Sep to 31 Oct paid one day of
|
||||
// October. They are replayed from the account's transactions, as a first row is, and
|
||||
// the opening balance comes from the same replay so money moved in the gap counts.
|
||||
if (cycleStart.isBefore(businessDate)) {
|
||||
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||
minBalance, averageDailyBalanceOf(calcType));
|
||||
openingBalance = gap.closingBalance();
|
||||
carriedInterest = gap.cumulativeInterest();
|
||||
} else {
|
||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||
carriedInterest = BigDecimal.ZERO;
|
||||
}
|
||||
} else {
|
||||
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
|
||||
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
|
||||
@@ -274,12 +287,15 @@ public class SavingsDailyInterestService {
|
||||
// So the gap is replayed from the account's own transactions, the same way a first
|
||||
// row is built, and the result stands in for the running total the ledger would be
|
||||
// holding had it run every night.
|
||||
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
|
||||
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
|
||||
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||
minBalance, averageDailyBalanceOf(calcType));
|
||||
// The replay's balance, not the stale row's: deposits made during the gap were
|
||||
// otherwise left out of every balance from here on.
|
||||
openingBalance = gap.closingBalance();
|
||||
carriedInterest = gap.cumulativeInterest();
|
||||
} else {
|
||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
||||
}
|
||||
}
|
||||
@@ -506,7 +522,11 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
|
||||
|
||||
if (!cycleStart.isBefore(upTo)) {
|
||||
return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
// No day of the cycle to replay — it opens today — but the money held going into it is
|
||||
// still there. Returning a zero balance made every re-accrual on a cycle's first day (a
|
||||
// deposit, or the noon report, on the 1st) open the account at nil: an account holding
|
||||
// 11,503.15 then earned only on what was deposited that day.
|
||||
return new Backfill(balanceAsOf(accountId, upTo.minusDays(1)), BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
|
||||
@@ -560,7 +580,7 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END), 0)
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
@@ -577,7 +597,7 @@ public class SavingsDailyInterestService {
|
||||
SELECT t.transaction_date AS d,
|
||||
SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END) AS net
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
@@ -593,7 +613,7 @@ public class SavingsDailyInterestService {
|
||||
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE
|
||||
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||
WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount
|
||||
ELSE 0 END), 0)
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ?
|
||||
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
/**
|
||||
* 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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* The interest posting report: what each savings account of one branch will be paid on its product's
|
||||
* posting date, put in front of that branch before any money moves.
|
||||
*
|
||||
* <p>
|
||||
* One report per branch and product. On a posting date the noon job generates one for every branch
|
||||
* holding accounts with interest due; a user of that branch may generate or regenerate it at any time
|
||||
* — the previous report is superseded, not overwritten — and approves it. The branch's approved
|
||||
* reports are posted when that branch closes its day, and until they are approved it cannot close.
|
||||
* The head office closes last, once every branch open that day has closed.
|
||||
*
|
||||
* <p>
|
||||
* Only the branch acts on its own reports. The single exception is a branch that did not open that
|
||||
* day: the head office may then generate and approve its report on its behalf, and posts it at the
|
||||
* head office's own close. Every entry is still raised in the branch the customers belong to.
|
||||
*
|
||||
* <p>
|
||||
* The figures are the daily ledger's own: each account's running interest for the cycle, rounded to
|
||||
* two decimals, with the paise the rounding loses given to the largest balance so the report's total
|
||||
* is the branch's pooled interest exactly.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SavingsInterestPostingReportService {
|
||||
|
||||
public static final String GENERATED = "GENERATED";
|
||||
public static final String APPROVED = "APPROVED";
|
||||
public static final String POSTED = "POSTED";
|
||||
public static final String SUPERSEDED = "SUPERSEDED";
|
||||
|
||||
private static final int MONEY_SCALE = 2;
|
||||
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||
/** m_savings_account.status_enum for an active account. */
|
||||
private static final int ACTIVE = 300;
|
||||
|
||||
/** A product with interest due in one branch. */
|
||||
private record DueKey(Long productId, Long officeId) {}
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsDailyInterestService accrualService;
|
||||
private final SavingsPooledInterestPostingService postingService;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public SavingsInterestPostingReportService(final JdbcTemplate jdbcTemplate, final SavingsDailyInterestService accrualService,
|
||||
final SavingsPooledInterestPostingService postingService, final PlatformTransactionManager transactionManager) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.accrualService = accrualService;
|
||||
this.postingService = postingService;
|
||||
// Always a transaction of its own. The noon job runs inside the batch step's transaction, and
|
||||
// generating in it would read the accrual through that uncommitted transaction and hold its
|
||||
// row locks for the whole run; a report committed on its own is also visible to the screen at once.
|
||||
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
// ── generation ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The noon job: brings the ledger up to the business date, then generates a report for every
|
||||
* branch and product with interest due that does not already have one.
|
||||
*
|
||||
* <p>
|
||||
* Each report is generated in its own transaction, so one that cannot be — a GL mapping missing,
|
||||
* say — does not stop the others. The failures are raised together at the end so the job is
|
||||
* recorded as failed.
|
||||
*
|
||||
* @return the ids of the reports generated
|
||||
*/
|
||||
public List<Long> generateDue(final LocalDate postingDate) {
|
||||
// So each account's figure includes the posting date itself, on the balance it holds now.
|
||||
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(postingDate));
|
||||
|
||||
final List<Long> generated = new ArrayList<>();
|
||||
final List<String> failures = new ArrayList<>();
|
||||
for (final DueKey due : dueKeys(postingDate, null)) {
|
||||
if (activeReportId(due.productId(), due.officeId(), postingDate) != null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
generated.add(transactionTemplate.execute(status -> generate(due.productId(), due.officeId(), postingDate, "AUTO", null)));
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("Savings interest report: product {} branch {} could not be reported for {}", due.productId(),
|
||||
due.officeId(), postingDate, e);
|
||||
failures.add("product " + due.productId() + " branch " + due.officeId() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
if (!failures.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest posting report could not be generated on " + postingDate + " for "
|
||||
+ failures.size() + " report(s): " + String.join("; ", failures));
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the report afresh for one branch and product, on the user's request.
|
||||
*
|
||||
* <p>
|
||||
* Allowed at any time of day; only the noon job waits for noon. Not for a date after the business
|
||||
* date, whose interest has not been earned yet. A posted report cannot be regenerated: its money
|
||||
* has moved. An approved one can, and has to be approved again.
|
||||
*/
|
||||
public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
if (postingDate.isAfter(businessDate)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.future.date",
|
||||
"The report for " + postingDate + " cannot be generated before that date.");
|
||||
}
|
||||
authorize(user, officeId, businessDate);
|
||||
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
|
||||
return transactionTemplate.execute(status -> generate(productId, officeId, postingDate, "MANUAL", user.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves a generated report. A user of the report's branch approves it; the head office may
|
||||
* approve it only for a branch that did not open that day, and the report records that it did.
|
||||
*/
|
||||
public void approve(final Long reportId, final AppUser user) {
|
||||
final List<Map<String, Object>> found = jdbcTemplate
|
||||
.queryForList("SELECT office_id FROM m_savings_interest_posting_report WHERE id = ?", reportId);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
|
||||
"Interest posting report " + reportId + " does not exist.");
|
||||
}
|
||||
final Long officeId = ((Number) found.get(0).get("office_id")).longValue();
|
||||
final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate());
|
||||
final int approved = jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_report
|
||||
SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ?
|
||||
WHERE id = ? AND status = 'GENERATED'
|
||||
""", user.getId(), onBehalf, reportId);
|
||||
if (approved == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.generated",
|
||||
"Only a report in GENERATED status can be approved. Reload the report — it may have been approved, "
|
||||
+ "regenerated or posted already.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── day close ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What stops one branch from closing the day: a product with interest due in the branch and no
|
||||
* approved (or posted) report for the date, and any older report of the branch still awaiting
|
||||
* approval — one left from a day the branch did not open. A report that was never generated
|
||||
* blocks the close too.
|
||||
*/
|
||||
public List<String> reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) {
|
||||
final Set<String> blocking = new LinkedHashSet<>();
|
||||
for (final DueKey due : dueKeys(businessDate, officeId)) {
|
||||
final Long settled = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_savings_interest_posting_report
|
||||
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED')
|
||||
""", Long.class, due.productId(), officeId, businessDate);
|
||||
if (settled == null || settled == 0) {
|
||||
blocking.add(productName(due.productId()) + " (" + businessDate + ")");
|
||||
}
|
||||
}
|
||||
for (final Map<String, Object> pending : jdbcTemplate.queryForList("""
|
||||
SELECT p.name, r.posting_date FROM m_savings_interest_posting_report r
|
||||
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||
WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ?
|
||||
ORDER BY r.posting_date
|
||||
""", officeId, businessDate)) {
|
||||
blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")");
|
||||
}
|
||||
return new ArrayList<>(blocking);
|
||||
}
|
||||
|
||||
/** The branch's approved reports, posted when it closes the day. */
|
||||
public List<Long> approvedReportIds(final Long officeId, final LocalDate upToDate) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_savings_interest_posting_report
|
||||
WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ?
|
||||
ORDER BY posting_date, savings_product_id
|
||||
""", Long.class, officeId, upToDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approved reports of branches that did not open on the date — what the head office posts at its
|
||||
* own close, the branches having no close of their own that day.
|
||||
*/
|
||||
public List<Long> approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT r.id FROM m_savings_interest_posting_report r
|
||||
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
|
||||
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id
|
||||
AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?)))
|
||||
ORDER BY r.posting_date, r.office_id, r.savings_product_id
|
||||
""", Long.class, businessDate, headOfficeId, businessDate, businessDate);
|
||||
}
|
||||
|
||||
/** Every approved report still waiting, for the nightly safety net. */
|
||||
public List<Long> approvedReportIds(final LocalDate upToDate) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_savings_interest_posting_report
|
||||
WHERE status = 'APPROVED' AND posting_date <= ?
|
||||
ORDER BY posting_date, office_id, savings_product_id
|
||||
""", Long.class, upToDate);
|
||||
}
|
||||
|
||||
/** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */
|
||||
public void recordPostingFailure(final Long reportId, final String reason) {
|
||||
jdbcTemplate.update("UPDATE m_savings_interest_posting_report SET failure_reason = ? WHERE id = ?",
|
||||
reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId);
|
||||
}
|
||||
|
||||
// ── reading ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a
|
||||
* head office user, is every other branch (or one, with officeId). canAct says whether this user
|
||||
* may regenerate or approve the report, and blockedReason why not.
|
||||
*/
|
||||
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
|
||||
final String scope, final AppUser user) {
|
||||
final Long userOffice = officeOf(user);
|
||||
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
|
||||
final Long branch = others ? officeId : userOffice;
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
||||
o.name AS "officeName", r.posting_date AS "postingDate", r.status, r.total_interest AS "totalInterest",
|
||||
r.account_count AS "accountCount", r.generation_mode AS "generationMode", r.generated_at AS "generatedAt",
|
||||
gu.username AS "generatedBy", r.approved_at AS "approvedAt", au.username AS "approvedBy",
|
||||
r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason"
|
||||
FROM m_savings_interest_posting_report r
|
||||
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||
LEFT JOIN m_office o ON o.id = r.office_id
|
||||
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
|
||||
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||
WHERE 1 = 1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (postingDate != null) {
|
||||
sql.append(" AND r.posting_date = ?");
|
||||
params.add(postingDate);
|
||||
}
|
||||
if (status != null && !status.isBlank()) {
|
||||
sql.append(" AND r.status = ?");
|
||||
params.add(status.trim().toUpperCase());
|
||||
} else {
|
||||
sql.append(" AND r.status <> 'SUPERSEDED'");
|
||||
}
|
||||
if (productId != null) {
|
||||
sql.append(" AND r.savings_product_id = ?");
|
||||
params.add(productId);
|
||||
}
|
||||
if (branch != null) {
|
||||
sql.append(" AND r.office_id = ?");
|
||||
params.add(branch);
|
||||
}
|
||||
if (others) {
|
||||
sql.append(" AND r.office_id <> ?");
|
||||
params.add(userOffice);
|
||||
}
|
||||
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
|
||||
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>(row);
|
||||
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
|
||||
final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate);
|
||||
out.put("canAct", blocked == null);
|
||||
out.put("blockedReason", blocked);
|
||||
rows.add(out);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public Map<String, Object> detail(final Long reportId) {
|
||||
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
||||
o.name AS "officeName", r.posting_date AS "postingDate",
|
||||
r.status, r.total_interest AS "totalInterest", r.account_count AS "accountCount",
|
||||
r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy",
|
||||
r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf",
|
||||
r.posted_at AS "postedAt", r.failure_reason AS "failureReason",
|
||||
pg.gl_code AS "payableGlCode", pg.name AS "payableGlName",
|
||||
dg.gl_code AS "paidGlCode", dg.name AS "paidGlName",
|
||||
eg.gl_code AS "expenseGlCode", eg.name AS "expenseGlName"
|
||||
FROM m_savings_interest_posting_report r
|
||||
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||
LEFT JOIN m_office o ON o.id = r.office_id
|
||||
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
|
||||
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||
LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id
|
||||
LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id
|
||||
LEFT JOIN acc_product_mapping em ON em.product_id = r.savings_product_id AND em.product_type = 2
|
||||
AND em.financial_account_type = 3 AND em.payment_type IS NULL
|
||||
LEFT JOIN acc_gl_account eg ON eg.id = em.gl_account_id
|
||||
WHERE r.id = ?
|
||||
""", reportId);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
|
||||
"Interest posting report " + reportId + " does not exist.");
|
||||
}
|
||||
final Map<String, Object> report = new LinkedHashMap<>(found.get(0));
|
||||
report.put("lines", jdbcTemplate.queryForList("""
|
||||
SELECT l.savings_account_id AS "savingsAccountId", l.account_no AS "accountNo", l.client_name AS "clientName",
|
||||
l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore",
|
||||
l.balance_after AS "balanceAfter", l.savings_transaction_id AS "savingsTransactionId"
|
||||
FROM m_savings_interest_posting_report_line l
|
||||
WHERE l.report_id = ?
|
||||
ORDER BY l.account_no
|
||||
""", reportId));
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* What each savings account has earned so far in its current cycle — what it would be paid if
|
||||
* interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first
|
||||
* brought up to the business date (which moves no money) so the figure includes today.
|
||||
*
|
||||
* <p>
|
||||
* A branch user sees their own branch; a head office user every branch, or one with officeId.
|
||||
*/
|
||||
public List<Map<String, Object>> preview(final Long officeId, final AppUser user) {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
|
||||
|
||||
final Long userOffice = officeOf(user);
|
||||
final Long branch = isHeadOffice(userOffice) ? officeId : userOffice;
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName",
|
||||
p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo",
|
||||
COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance",
|
||||
d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate",
|
||||
d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest"
|
||||
FROM m_savings_daily_interest d
|
||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||
JOIN m_savings_product p ON p.id = d.savings_product_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE p.deposit_type_enum = 100
|
||||
AND sa.status_enum = ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(ACTIVE));
|
||||
if (branch != null) {
|
||||
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
|
||||
params.add(branch);
|
||||
}
|
||||
sql.append(" ORDER BY o.name, p.name, sa.account_no");
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch and product interest for the head office: per report, how many customers and how much
|
||||
* interest, and where it stands. Superseded reports are left out — each line is the report that
|
||||
* counts for that branch, product and date.
|
||||
*/
|
||||
public List<Map<String, Object>> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName",
|
||||
r.savings_product_id AS "productId", p.name AS "productName", r.account_count AS "accountCount",
|
||||
r.total_interest AS "totalInterest", r.status, au.username AS "approvedBy",
|
||||
r.approved_on_behalf AS "approvedOnBehalf", r.approved_at AS "approvedAt", r.posted_at AS "postedAt"
|
||||
FROM m_savings_interest_posting_report r
|
||||
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||
LEFT JOIN m_office o ON o.id = r.office_id
|
||||
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||
WHERE r.status <> 'SUPERSEDED'
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (fromDate != null) {
|
||||
sql.append(" AND r.posting_date >= ?");
|
||||
params.add(fromDate);
|
||||
}
|
||||
if (toDate != null) {
|
||||
sql.append(" AND r.posting_date <= ?");
|
||||
params.add(toDate);
|
||||
}
|
||||
if (productId != null) {
|
||||
sql.append(" AND r.savings_product_id = ?");
|
||||
params.add(productId);
|
||||
}
|
||||
sql.append(" ORDER BY r.posting_date DESC, p.name, o.name");
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
|
||||
* before they had anything due, or failed.
|
||||
*
|
||||
* <p>
|
||||
* {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user,
|
||||
* lists the other branches — each with canAct, and where the head office may not act for it, why.
|
||||
*/
|
||||
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) {
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
// A future date has nothing due yet, and offering to generate for it would label this cycle's
|
||||
// part-earned interest with a date it has not reached.
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
if (postingDate.isAfter(businessDate)) {
|
||||
return out;
|
||||
}
|
||||
final Long userOffice = officeOf(user);
|
||||
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
|
||||
for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) {
|
||||
if (others == due.officeId().equals(userOffice)) {
|
||||
continue;
|
||||
}
|
||||
if (activeReportId(due.productId(), due.officeId(), postingDate) == null) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("productId", due.productId());
|
||||
row.put("productName", productName(due.productId()));
|
||||
row.put("officeId", due.officeId());
|
||||
row.put("officeName", officeName(due.officeId()));
|
||||
row.put("postingDate", postingDate.toString());
|
||||
final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate);
|
||||
row.put("canAct", blocked == null);
|
||||
row.put("blockedReason", blocked);
|
||||
out.add(row);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── who may act ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public boolean isHeadOffice(final Long officeId) {
|
||||
final List<Long> parent = jdbcTemplate.queryForList("SELECT parent_id FROM m_office WHERE id = ?", Long.class, officeId);
|
||||
return !parent.isEmpty() && parent.get(0) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the branch is working on the date: it opened that day, or a day it opened earlier is still
|
||||
* not closed. A branch with yesterday's day still open is open — it closes that day and goes on.
|
||||
*/
|
||||
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
|
||||
final Long rows = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_branch_day_register
|
||||
WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?))
|
||||
""", Long.class, officeId, businessDate, businessDate);
|
||||
return rows != null && rows > 0;
|
||||
}
|
||||
|
||||
/** The branches, other than those given, that still have a day open on or before the date. */
|
||||
public List<String> branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
|
||||
WHERE r.closed_at IS NULL AND r.business_date <= ?
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(businessDate));
|
||||
for (final Long exclude : excludeOfficeIds) {
|
||||
sql.append(" AND r.office_id <> ?");
|
||||
params.add(exclude);
|
||||
}
|
||||
sql.append(" ORDER BY o.name");
|
||||
return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Null where the user may generate or approve the branch's report, otherwise why not.
|
||||
*
|
||||
* <p>
|
||||
* A branch always acts for itself. The head office acts for another branch only when that branch
|
||||
* has not opened at all, and only once every branch that did open has closed — the head office's
|
||||
* own close being the last of the day, so nothing is taken out of a branch that may still open.
|
||||
*/
|
||||
private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||
if (userOffice == null) {
|
||||
return "No branch is set for this user.";
|
||||
}
|
||||
if (userOffice.equals(reportOffice)) {
|
||||
return null;
|
||||
}
|
||||
final String branch = officeName(reportOffice);
|
||||
if (!isHeadOffice(userOffice)) {
|
||||
return "Only " + branch + " can generate or approve its own interest posting report.";
|
||||
}
|
||||
if (branchOpenedOn(reportOffice, businessDate)) {
|
||||
return branch + " is open — it generates and approves its own report.";
|
||||
}
|
||||
final List<String> stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice);
|
||||
if (!stillOpen.isEmpty()) {
|
||||
return "The head office can act for " + branch + " only after every open branch has closed. Still open: "
|
||||
+ String.join(", ", stillOpen) + ".";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||
return actBlockedReason(userOffice, reportOffice, businessDate) == null;
|
||||
}
|
||||
|
||||
/** @return true where the head office is acting on a branch's behalf */
|
||||
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
|
||||
final Long userOffice = officeOf(user);
|
||||
final String blocked = actBlockedReason(userOffice, reportOffice, businessDate);
|
||||
if (blocked != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked);
|
||||
}
|
||||
return !reportOffice.equals(userOffice);
|
||||
}
|
||||
|
||||
private String officeName(final Long officeId) {
|
||||
return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
|
||||
}
|
||||
|
||||
private static Long officeOf(final AppUser user) {
|
||||
return user == null || user.getOffice() == null ? null : user.getOffice().getId();
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Branch and product pairs with interest due on or before the date: a savings product on the
|
||||
* daily-interest timetable whose ledger holds unpaid interest for a cycle that ends by then, in
|
||||
* accounts belonging to that branch.
|
||||
*/
|
||||
private List<DueKey> dueKeys(final LocalDate postingDate, final Long officeId) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT DISTINCT d.savings_product_id, COALESCE(c.office_id, g.office_id) AS office_id
|
||||
FROM m_savings_daily_interest d
|
||||
JOIN m_savings_product p ON p.id = d.savings_product_id
|
||||
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
|
||||
JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id AND s.is_active = true
|
||||
WHERE p.deposit_type_enum = 100
|
||||
AND sa.status_enum = ?
|
||||
AND d.posting_date <= ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
AND d.cycle_cumulative_interest > 0
|
||||
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(ACTIVE, postingDate));
|
||||
if (officeId != null) {
|
||||
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
|
||||
params.add(officeId);
|
||||
}
|
||||
sql.append(" ORDER BY 2, 1");
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray()).stream()
|
||||
.map(r -> new DueKey(((Number) r.get("savings_product_id")).longValue(), ((Number) r.get("office_id")).longValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) {
|
||||
final List<Long> ids = jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_savings_interest_posting_report
|
||||
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
|
||||
""", Long.class, productId, officeId, postingDate);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
}
|
||||
|
||||
private String productName(final Long productId) {
|
||||
return jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId);
|
||||
}
|
||||
|
||||
/** Runs inside the caller's transaction. */
|
||||
private Long generate(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, final Long userId) {
|
||||
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
||||
SELECT id, status FROM m_savings_interest_posting_report
|
||||
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
|
||||
FOR UPDATE
|
||||
""", productId, officeId, postingDate);
|
||||
for (final Map<String, Object> report : previous) {
|
||||
if (POSTED.equals(report.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.already.posted",
|
||||
"Interest for this product and branch on " + postingDate + " has already been posted; the report cannot be"
|
||||
+ " regenerated.");
|
||||
}
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_report SET status = 'SUPERSEDED'
|
||||
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED')
|
||||
""", productId, officeId, postingDate);
|
||||
|
||||
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,
|
||||
sa.account_no, COALESCE(c.display_name, g.display_name) AS client_name
|
||||
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.posting_date <= ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
AND d.cycle_cumulative_interest > 0
|
||||
AND sa.status_enum = ?
|
||||
AND COALESCE(c.office_id, g.office_id) = ?
|
||||
ORDER BY d.savings_account_id
|
||||
""", productId, postingDate, ACTIVE, officeId);
|
||||
if (due.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.nothing.due",
|
||||
"No interest is due for this product in this branch on " + postingDate + ".");
|
||||
}
|
||||
|
||||
// The GL accounts as the product maps them now; a product missing one is refused here, before
|
||||
// anybody approves figures that could never be posted.
|
||||
final SavingsPooledInterestPostingService.PostingGlAccounts gls = postingService.resolvePostingGlAccounts(productId);
|
||||
|
||||
// Round each customer's share, then make the rounded shares add up to the rounded pool: the
|
||||
// residual goes to the largest balance, where a paisa is proportionally smallest.
|
||||
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.signum() != 0) {
|
||||
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));
|
||||
}
|
||||
|
||||
final Long reportId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_savings_interest_posting_report
|
||||
(savings_product_id, office_id, posting_date, status, total_interest, account_count, payable_gl_account_id,
|
||||
paid_gl_account_id, generation_mode, generated_by, generated_at)
|
||||
VALUES (?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""", Long.class, productId, officeId, postingDate, pooled, due.size(), gls.payableGl(), gls.paidGl(), mode, userId);
|
||||
|
||||
for (final Map<String, Object> row : due) {
|
||||
final BigDecimal before = ((BigDecimal) row.get("closing_balance")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
final BigDecimal interest = (BigDecimal) row.get("rounded_interest");
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_savings_interest_posting_report_line
|
||||
(report_id, savings_account_id, account_no, client_name, office_id, interest_amount, balance_before,
|
||||
balance_after, ledger_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", reportId, row.get("savings_account_id"), row.get("account_no"), row.get("client_name"), officeId, interest,
|
||||
before, before.add(interest), row.get("ledger_id"));
|
||||
}
|
||||
|
||||
log.info("Savings interest report {}: product {} branch {} on {} ({}) — {} across {} accounts", reportId, productId, officeId,
|
||||
postingDate, mode, pooled, due.size());
|
||||
return reportId;
|
||||
}
|
||||
}
|
||||
+240
-216
@@ -21,7 +21,6 @@ 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;
|
||||
@@ -29,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -42,21 +43,24 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* 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 settles the debt the accrual has already recognised: the product's total moves out of the
|
||||
* liability the product names as its Savings Interest Payable, into the account it is paid out of,
|
||||
* and only then to the members:
|
||||
* The product's total is raised as an expense and credited to the liability the product names as its
|
||||
* Savings Interest Payable, moves from there into the clearing account it is paid out of, and only
|
||||
* then to the members:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Savings Interest Payable (liability, from the product)
|
||||
* Cr Savings Interest Paid on its way out
|
||||
* Dr Interest On Savings (expense, from the product) cash-accounted products only
|
||||
* Cr Savings Interest Payable (liability, from the product)
|
||||
*
|
||||
* Dr Savings Interest Payable
|
||||
* Cr Savings Interest Paid (liability clearing) on its way out
|
||||
*
|
||||
* Dr Savings Interest Paid (per member)
|
||||
* Cr Savings Control (per member, from the product) in the member's hands
|
||||
* </pre>
|
||||
*
|
||||
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
|
||||
* this is the settlement of what that recognition already booked - raising the expense again at
|
||||
* payout would put the same interest on the P&L twice.
|
||||
* The payable and paid accounts each close the posting at nil, so the only lasting effect is the
|
||||
* expense against the members' balances, and a liability report reading the payable is unaffected.
|
||||
* The first pair is skipped for an accrual-accounted product, whose accruals already raised it.
|
||||
*
|
||||
* Every account named above is read from the product's own GL configuration; none is written into
|
||||
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
|
||||
@@ -113,231 +117,259 @@ public class SavingsPooledInterestPostingService {
|
||||
* Accounting tab, which is where a bank picks the account interest is owed from.
|
||||
*/
|
||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
|
||||
/** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense every interest posting is charged to. */
|
||||
private static final int GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS = 3;
|
||||
/** m_savings_product.accounting_type for cash-based accounting. */
|
||||
private static final int ACCOUNTING_TYPE_CASH = 2;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** The payable and paid accounts a product's interest passes through. */
|
||||
public record PostingGlAccounts(Long payableGl, Long paidGl) {}
|
||||
|
||||
/**
|
||||
* Posts one product's interest for one posting date.
|
||||
*
|
||||
* @return the amount distributed, or zero if there was nothing to do
|
||||
* The payable and paid accounts, as the product maps them now.
|
||||
*
|
||||
* <p>
|
||||
* REQUIRED, not REQUIRES_NEW.
|
||||
* The product's Accounting tab is the authority: the standard Interest Payable row first, then
|
||||
* the reserved "Savings Interest Payable" mapping. Where a product sets neither, the payable falls
|
||||
* back to its Savings Control; the paid leg falls back to the payable, which collapses stage 1 and
|
||||
* leaves the two-legged posting a product without these mappings has always had.
|
||||
*/
|
||||
public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
|
||||
// All three are required. Falling back to Savings Control when one was left blank posted the
|
||||
// interest straight from expense to the member with no payable or paid leg at all, and nobody
|
||||
// could tell from the entries that the product had simply lost its mappings.
|
||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
|
||||
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
|
||||
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE));
|
||||
final Long paidGl = namedGlAccount(productId, MAPPING_INTEREST_PAID);
|
||||
if (payableGl == null || paidGl == null) {
|
||||
final String productName = jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
|
||||
productId);
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.gl.not.mapped", "Savings product '"
|
||||
+ productName + "' has no " + (payableGl == null ? "Savings Interest Payable" : "Savings Interest Paid")
|
||||
+ " GL mapped. Set it under the product's Accounting and generate the report again.");
|
||||
}
|
||||
return new PostingGlAccounts(payableGl, paidGl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts one approved interest posting report, paying each account exactly what was approved.
|
||||
*
|
||||
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
|
||||
* the orchestrator calls this outside any transaction of its own, so each product opens and
|
||||
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
|
||||
* running and waits for locks that transaction may hold. The accrual step immediately before
|
||||
* writes every account's ledger row, and this method reads and updates those same rows: with
|
||||
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
|
||||
* and the job hung with nothing in the log and its currently_running flag left set.
|
||||
* <p>
|
||||
* A report belongs to one branch and one product, and every leg it raises is in that branch, so
|
||||
* the branch's interest cost sits in its own P&L and its payable and paid accounts close at nil
|
||||
* in its own books:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Interest On Savings (branch total) → Cr Savings Interest Payable
|
||||
* Dr Savings Interest Payable → Cr Savings Interest Paid
|
||||
* Dr Savings Interest Paid (once) → Cr each customer of the branch
|
||||
* </pre>
|
||||
*
|
||||
* <p>
|
||||
* Nothing is recomputed. The report's lines are the authority: interest that accrued after the
|
||||
* report was generated is not added, because the approver signed off on these figures and not on
|
||||
* others. The payable and paid accounts are those recorded on the report when it was generated.
|
||||
*
|
||||
* <p>
|
||||
* Everything is dated the day it is actually posted — the business date — not the cycle's end: a
|
||||
* branch that was shut on its posting date posts when it next closes, into a day that is still
|
||||
* open, and the narration names the cycle the interest is for.
|
||||
*
|
||||
* <p>
|
||||
* The whole report settles in one transaction, so a failure leaves nobody half paid and the report
|
||||
* still APPROVED for the next attempt.
|
||||
*
|
||||
* @return the amount distributed
|
||||
*/
|
||||
@Transactional
|
||||
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;
|
||||
public BigDecimal postReport(final long reportId) {
|
||||
final List<Map<String, Object>> reports = jdbcTemplate.queryForList("""
|
||||
SELECT id, savings_product_id, office_id, posting_date, status, total_interest, payable_gl_account_id,
|
||||
paid_gl_account_id
|
||||
FROM m_savings_interest_posting_report
|
||||
WHERE id = ?
|
||||
FOR UPDATE
|
||||
""", reportId);
|
||||
if (reports.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest posting report " + reportId + " does not exist");
|
||||
}
|
||||
final Map<String, Object> report = reports.get(0);
|
||||
if (!"APPROVED".equals(report.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"Savings interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
|
||||
}
|
||||
final long productId = ((Number) report.get("savings_product_id")).longValue();
|
||||
final LocalDate cycleDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||
final LocalDate postedOn = DateUtils.getBusinessLocalDate();
|
||||
final BigDecimal pooled = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
|
||||
final List<Map<String, Object>> lines = jdbcTemplate.queryForList("""
|
||||
SELECT l.id AS line_id, l.savings_account_id, l.office_id, l.interest_amount, l.savings_transaction_id,
|
||||
sa.currency_code
|
||||
FROM m_savings_interest_posting_report_line l
|
||||
JOIN m_savings_account sa ON sa.id = l.savings_account_id
|
||||
WHERE l.report_id = ?
|
||||
ORDER BY l.savings_account_id
|
||||
""", reportId);
|
||||
if (lines.isEmpty()) {
|
||||
throw new IllegalStateException("Savings interest posting report " + reportId + " has no accounts to pay");
|
||||
}
|
||||
final long officeId = report.get("office_id") != null ? ((Number) report.get("office_id")).longValue()
|
||||
: ((Number) lines.get(0).get("office_id")).longValue();
|
||||
|
||||
final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size());
|
||||
|
||||
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
|
||||
final Long payableGl = report.get("payable_gl_account_id") != null
|
||||
? ((Number) report.get("payable_gl_account_id")).longValue()
|
||||
: current.payableGl();
|
||||
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
|
||||
: current.paidGl();
|
||||
|
||||
final String currency = (String) lines.get(0).get("currency_code");
|
||||
// One per report, and deterministic: a retried posting carries the same reference as the legs a
|
||||
// failed attempt rolled back, and two branches posting the same product never share one.
|
||||
final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postedOn, reportId);
|
||||
final String narration = "Savings interest for cycle ending " + cycleDate;
|
||||
|
||||
// Stage 0: the interest cost is raised and the total credited to the payable. Nothing else
|
||||
// credits it on a cash-accounted product — the daily accrual writes no journal entry. An
|
||||
// accrual-accounted product has already done this through its accrual postings.
|
||||
if (isCashAccounted(productId)) {
|
||||
final Long expenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
|
||||
journalEntry(officeId, currency, expenseGl, postedOn, pooled, true, reference, null, null, narration);
|
||||
journalEntry(officeId, currency, payableGl, postedOn, pooled, false, reference, null, null, narration);
|
||||
}
|
||||
|
||||
// Every account whose posting date has arrived or passed and has not yet been paid.
|
||||
//
|
||||
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
|
||||
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
|
||||
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
|
||||
// run on was never paid at all — the cycle simply rolled over with the interest carried into
|
||||
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
|
||||
// to behave when a night is missed.
|
||||
//
|
||||
// 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.posting_date <= ?
|
||||
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"));
|
||||
}
|
||||
|
||||
// The three accounts the bill passes through on its way to the members. Every one is read
|
||||
// from the product's own configuration, so a bank on its own chart of accounts is posted to
|
||||
// the accounts it chose. No GL code is named in this class.
|
||||
//
|
||||
// The product's Accounting tab is the authority, and it is consulted first for all three:
|
||||
// Interest On Savings for the expense, and the standard Interest Payable row for the
|
||||
// payable. Those are the rows a bank actually fills in on the product screen.
|
||||
//
|
||||
// The reserved names in the product's other GL mappings are consulted only after that, as a
|
||||
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
|
||||
// which has no standard row of its own.
|
||||
//
|
||||
// Where a product sets none of them the bill still has somewhere to go: the payable falls
|
||||
// back to the product's Savings Control (interest owed is a liability either way), and the
|
||||
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
|
||||
// posting a product without these mappings has always had.
|
||||
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
|
||||
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
|
||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
|
||||
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
|
||||
|
||||
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
||||
final String currency = (String) due.get(0).get("currency_code");
|
||||
// Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
|
||||
// and the resumed legs have to carry the same reference as the legs already posted, or one
|
||||
// interest posting ends up split across two transaction ids.
|
||||
final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
|
||||
productId);
|
||||
|
||||
// Stage 1: the debt the accrual has been building moves out of the payable and into the
|
||||
// account the payout is made from, so what is still merely owed and what is on its way to
|
||||
// the members are two separate balances rather than one.
|
||||
//
|
||||
// The interest expense itself is not raised here. It is recognised as the interest accrues,
|
||||
// day by day, and posting is the settlement of that recognised debt — raising the expense
|
||||
// again at payout would count the same interest twice on the P&L.
|
||||
//
|
||||
// Skipped where the product names no separate paid account: both legs then resolve to the
|
||||
// payable and this would debit and credit one account for the same amount, saying nothing.
|
||||
final boolean payableIsPaid = payableGl.equals(paidGl);
|
||||
final boolean stage1Applies = !payableIsPaid;
|
||||
// Stage 1: the total moves out of the payable and into the account the payout is made from.
|
||||
// Skipped where both are the same account, which would debit and credit it for nothing.
|
||||
final boolean stage1Applies = !payableGl.equals(paidGl);
|
||||
if (stage1Applies) {
|
||||
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
|
||||
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
|
||||
journalEntry(officeId, currency, payableGl, postedOn, pooled, true, reference, null, null, narration);
|
||||
journalEntry(officeId, currency, paidGl, postedOn, pooled, false, reference, null, null, narration);
|
||||
}
|
||||
|
||||
// Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
|
||||
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
|
||||
// from there instead — debiting the paid account would take the money out of an account
|
||||
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
|
||||
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
|
||||
|
||||
// Stage 2: out to the members, one at a time.
|
||||
// Stage 2: one debit for the branch's total, and under it a credit to each customer. Every leg
|
||||
// carries the report's one transaction id, and every credit names its account and interest
|
||||
// transaction, so who was paid what reads back from the journal alone.
|
||||
journalEntry(officeId, currency, payoutFromGl, postedOn, pooled, true, reference, null, null, narration);
|
||||
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, payoutFromGl, postingDate, amount, true, poolReference,
|
||||
accountId);
|
||||
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
||||
accountId, transactionId);
|
||||
|
||||
// closing_balance is raised here because the interest transaction has just been written
|
||||
// and the accrual for this day has already run: nothing else will fold it in before the
|
||||
// next day reads this row as its opening.
|
||||
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());
|
||||
|
||||
// Both interest columns move together, and that is not a duplicate.
|
||||
//
|
||||
// total_interest_earned_derived is the interest the account has earned to date;
|
||||
// total_interest_posted_derived is how much of that has been paid into the balance.
|
||||
// Screens and validate()'s own check read the unposted interest as earned - posted, so
|
||||
// raising only `posted` drives that difference negative: an account paid 26.52 with
|
||||
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
|
||||
// showed exactly that. The accrual writes pending interest to the ledger and never to
|
||||
// `earned`, so this posting is the only place `earned` is realised.
|
||||
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) + ?,
|
||||
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
|
||||
WHERE id = ?
|
||||
""", amount, amount, amount, accountId);
|
||||
|
||||
distributed = distributed.add(amount);
|
||||
for (final Map<String, Object> line : lines) {
|
||||
distributed = distributed
|
||||
.add(payMember(line, productId, officeId, cycleDate, postedOn, currency, reference, narration, runId));
|
||||
}
|
||||
|
||||
// The whole point of the two control accounts: if this does not hold, nothing commits.
|
||||
// If the credits do not add up to the debit, 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));
|
||||
"Savings interest posting report %d did not balance: total %s, distributed %s", reportId, pooled, distributed));
|
||||
}
|
||||
|
||||
return completeReport(reportId, productId, cycleDate, pooled, distributed, runId, lines.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* One customer's credit: the interest transaction, the Savings Control credit naming the account,
|
||||
* and the account, ledger and report line brought up to date. The branch debit is raised by the
|
||||
* caller.
|
||||
*
|
||||
* @return the amount credited, zero for a line with nothing to pay
|
||||
*/
|
||||
private BigDecimal payMember(final Map<String, Object> line, final long productId, final long officeId,
|
||||
final LocalDate cycleDate, final LocalDate postedOn, final String currency, final String reference,
|
||||
final String narration, final long runId) {
|
||||
final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
final long accountId = ((Number) line.get("savings_account_id")).longValue();
|
||||
|
||||
// A resumed or duplicated attempt must not pay anybody twice. The line itself records the
|
||||
// payment, so this holds even where one account has two cycles posted on the same day.
|
||||
if (line.get("savings_transaction_id") != null) {
|
||||
throw new IllegalStateException("Savings account " + accountId + " was already paid by this report");
|
||||
}
|
||||
|
||||
// The balance as it stands now, not as the report showed it: money moved since the report
|
||||
// was generated, and the transaction's running balance has to follow the account.
|
||||
final BigDecimal currentBalance = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId);
|
||||
final BigDecimal newBalance = currentBalance.add(amount);
|
||||
|
||||
final long transactionId = createInterestPostingTransaction(accountId, officeId, postedOn, amount, newBalance);
|
||||
|
||||
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
|
||||
journalEntry(officeId, currency, savingsControlGl, postedOn, amount, false, reference, accountId, transactionId, narration);
|
||||
|
||||
// Settles the ledger row where it still holds the cycle the report was drawn from. Where the
|
||||
// nightly run has already moved it into the next cycle — a branch that was shut on its posting
|
||||
// date and posts days later — that row is the new cycle's and must not be marked paid; it only
|
||||
// takes the money, because the next day's accrual opens from its closing balance.
|
||||
final int settled = jdbcTemplate.update("""
|
||||
UPDATE m_savings_daily_interest
|
||||
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
||||
closing_balance = closing_balance + ?
|
||||
WHERE savings_account_id = ? AND savings_transaction_id IS NULL AND posting_date = ?
|
||||
""", amount, transactionId, runId, amount, accountId, cycleDate);
|
||||
if (settled == 0) {
|
||||
jdbcTemplate.update("UPDATE m_savings_daily_interest SET closing_balance = closing_balance + ? WHERE savings_account_id = ?",
|
||||
amount, accountId);
|
||||
}
|
||||
|
||||
// Earned is only ever raised to what has now been posted, never added to: the engine may
|
||||
// already have counted this interest as earned when the account was last recalculated,
|
||||
// and adding on top of that doubled it (1.37 paid showed as 2.74 earned). Screens read the
|
||||
// unposted interest as earned - posted, so earned must not fall below posted either.
|
||||
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) + ?,
|
||||
total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0),
|
||||
COALESCE(total_interest_posted_derived, 0) + ?)
|
||||
WHERE id = ?
|
||||
""", amount, amount, amount, accountId);
|
||||
|
||||
jdbcTemplate.update("UPDATE m_savings_interest_posting_report_line SET savings_transaction_id = ? WHERE id = ?",
|
||||
transactionId, ((Number) line.get("line_id")).longValue());
|
||||
|
||||
return amount;
|
||||
}
|
||||
|
||||
private BigDecimal completeReport(final long reportId, final long productId, final LocalDate cycleDate, final BigDecimal pooled,
|
||||
final BigDecimal distributed, final long runId, final int accountCount) {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_run
|
||||
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
|
||||
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
|
||||
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", pooled, distributed, residual, runId);
|
||||
""", pooled, distributed, runId);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_interest_posting_report
|
||||
SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
|
||||
WHERE id = ?
|
||||
""", runId, reportId);
|
||||
|
||||
log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
|
||||
postingDate, distributed, due.size(), residual);
|
||||
log.info("Savings interest posting: report {} (product {}, cycle {}) paid {} across {} accounts", reportId, productId,
|
||||
cycleDate, distributed, accountCount);
|
||||
return distributed;
|
||||
}
|
||||
|
||||
private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
|
||||
/** One run per report. A retry of the same report reuses its run. */
|
||||
private long openRun(final long reportId, final long productId, final long officeId, final LocalDate cycleDate,
|
||||
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
|
||||
(report_id, savings_product_id, office_id, posting_date, account_count, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'PENDING')
|
||||
ON CONFLICT (report_id) 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);
|
||||
""", reportId, productId, officeId, cycleDate, accountCount);
|
||||
return jdbcTemplate.queryForObject("SELECT id FROM m_savings_interest_posting_run WHERE report_id = ?", Long.class, reportId);
|
||||
}
|
||||
|
||||
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
|
||||
@@ -357,19 +389,6 @@ public class SavingsPooledInterestPostingService {
|
||||
""", 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, GL_ACCOUNTS_FOR_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);
|
||||
}
|
||||
|
||||
/**
|
||||
* One journal entry leg.
|
||||
*
|
||||
@@ -384,7 +403,7 @@ public class SavingsPooledInterestPostingService {
|
||||
*/
|
||||
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) {
|
||||
final Long transactionId, final String narration) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
@@ -395,8 +414,7 @@ public class SavingsPooledInterestPostingService {
|
||||
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 ? 2 : 1, amount,
|
||||
debit ? "Savings interest posting" : "Savings interest posted",
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration,
|
||||
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
||||
systemUserId(), systemUserId(), date);
|
||||
}
|
||||
@@ -487,6 +505,12 @@ public class SavingsPooledInterestPostingService {
|
||||
}
|
||||
|
||||
/** The first configured account of those given, in order of preference. */
|
||||
private boolean isCashAccounted(final long productId) {
|
||||
final Integer accountingType = jdbcTemplate.queryForObject("SELECT accounting_type FROM m_savings_product WHERE id = ?",
|
||||
Integer.class, productId);
|
||||
return accountingType != null && accountingType == ACCOUNTING_TYPE_CASH;
|
||||
}
|
||||
|
||||
private static Long firstNonNull(final Long... candidates) {
|
||||
for (final Long candidate : candidates) {
|
||||
if (candidate != null) {
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.sql.Timestamp;
|
||||
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.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The savings interest posting reports, per branch and product: list, detail, generate, approve, the
|
||||
* day-close check, and the head office's branch-wise summary.
|
||||
*
|
||||
* <p>
|
||||
* Posting is not offered here. A branch's approved reports are posted when that branch closes the
|
||||
* day, so there is exactly one way money leaves the payable.
|
||||
*/
|
||||
@Path("/v1/savingsinterestpostingreports")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class SavingsInterestPostingReportApiResource {
|
||||
|
||||
private static final String RESOURCE = "SAVINGSINTERESTREPORT";
|
||||
|
||||
private final SavingsInterestPostingReportService reportService;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
|
||||
@GET
|
||||
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
|
||||
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId,
|
||||
@QueryParam("scope") final String scope) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */
|
||||
@GET
|
||||
@Path("/context")
|
||||
public String userContext() {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
final Long officeId = user.getOffice().getId();
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("officeId", officeId);
|
||||
result.put("officeName", user.getOffice().getName());
|
||||
result.put("headOffice", reportService.isHeadOffice(officeId));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
|
||||
@GET
|
||||
@Path("/due-without-report")
|
||||
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||
return gson.toJson(reportService.dueWithoutReport(date, user, scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* What stops a branch closing the business day: its own reports due and not yet approved. The
|
||||
* day-close screen reads it to warn before the close is attempted.
|
||||
*/
|
||||
@GET
|
||||
@Path("/day-close-check")
|
||||
public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
|
||||
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||
final List<String> blocking = reportService.reportsBlockingDayClose(officeId, businessDate);
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("businessDate", businessDate.toString());
|
||||
result.put("canClose", blocking.isEmpty());
|
||||
result.put("pendingProducts", blocking);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */
|
||||
@GET
|
||||
@Path("/preview")
|
||||
public String preview(@QueryParam("officeId") final Long officeId) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
|
||||
@GET
|
||||
@Path("/summary")
|
||||
public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("productId") final Long productId) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
if (!reportService.isHeadOffice(user.getOffice().getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.summary.head.office.only",
|
||||
"The branch-wise interest summary is for the head office.");
|
||||
}
|
||||
return gson.toJson(reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{reportId}")
|
||||
public String detail(@PathParam("reportId") final Long reportId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
final Map<String, Object> report = plain(reportService.detail(reportId));
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<Map<String, Object>> lines = (List<Map<String, Object>>) report.get("lines");
|
||||
report.put("lines", lines.stream().map(this::plain).toList());
|
||||
return gson.toJson(report);
|
||||
}
|
||||
|
||||
/** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String command(@QueryParam("command") final String command, final String body) {
|
||||
if (!"regenerate".equalsIgnoreCase(command)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
|
||||
"Unsupported command: " + command);
|
||||
}
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
|
||||
final var root = fromJsonHelper.parse(body);
|
||||
final Long productId = fromJsonHelper.extractLongNamed("productId", root);
|
||||
final Long officeIdParam = fromJsonHelper.extractLongNamed("officeId", root);
|
||||
final String postingDate = fromJsonHelper.extractStringNamed("postingDate", root);
|
||||
if (productId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.product.required", "Which product?");
|
||||
}
|
||||
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||
final Long reportId = reportService.regenerate(productId, officeId, date, user);
|
||||
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.GENERATED));
|
||||
}
|
||||
|
||||
/** {@code ?command=approve}. The report's own branch approves it; see the service for the head office's one exception. */
|
||||
@POST
|
||||
@Path("/{reportId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) {
|
||||
if (!"approve".equalsIgnoreCase(command)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
|
||||
"Unsupported command: " + command);
|
||||
}
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("APPROVE_" + RESOURCE);
|
||||
reportService.approve(reportId, user);
|
||||
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.APPROVED));
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw.trim());
|
||||
} catch (final RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.invalid.date",
|
||||
"Dates are expected as yyyy-MM-dd, not " + raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */
|
||||
private Map<String, Object> plain(final Map<String, Object> row) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
row.forEach((key, value) -> {
|
||||
if (value instanceof java.sql.Date d) {
|
||||
out.put(key, d.toLocalDate().toString());
|
||||
} else if (value instanceof Timestamp t) {
|
||||
out.put(key, t.toLocalDateTime().toString());
|
||||
} else {
|
||||
out.put(key, value);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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.jobs;
|
||||
|
||||
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 SavingsInterestPostingReportConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Bean
|
||||
protected Step savingsInterestPostingReportStep(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
|
||||
return new StepBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||
.tasklet(savingsInterestPostingReportTasklet, transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job savingsInterestPostingReportJob(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
|
||||
return new JobBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||
.start(savingsInterestPostingReportStep(savingsInterestPostingReportTasklet)).incrementer(new RunIdIncrementer())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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.jobs;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Noon on the business date: generates the interest posting report for every savings product whose
|
||||
* interest falls due, so it can be reviewed and approved before the branches close the day.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class SavingsInterestPostingReportTasklet implements Tasklet {
|
||||
|
||||
private final SavingsInterestPostingReportService reportService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
reportService.generateDue(DateUtils.getBusinessLocalDate());
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -77,10 +77,13 @@ public class DepositAccountTransactionDataValidator {
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
|
||||
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
|
||||
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName));
|
||||
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
|
||||
DepositsApiConstants.toGlAccountIdParamName, DepositsApiConstants.waivePreClosurePenaltyParamName,
|
||||
DepositsApiConstants.prematureClosureReasonIdParamName));
|
||||
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(
|
||||
DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName));
|
||||
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
@@ -245,6 +248,25 @@ public class DepositAccountTransactionDataValidator {
|
||||
} else if (accountOnClosureType.isReinvest() && isPreMatureClose) {
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId)
|
||||
.failWithCode("reinvest.not.allowed", "Re-Invest is not supported for account pre mature close");
|
||||
} else if (accountOnClosureType.isTransferToGl()) {
|
||||
// Only the FD premature-closure handler books the GL leg. Offering it anywhere else would
|
||||
// fall through to that handler's terminal "withdraw the whole balance" case and silently pay
|
||||
// the deposit out in cash, which is exactly what the partial.reinvest guard above prevents.
|
||||
if (!isPreMatureClose || !DepositAccountType.FIXED_DEPOSIT.equals(depositAccountType)) {
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
|
||||
"transfer.to.gl.not.allowed",
|
||||
"Transfer to a GL account is only supported for fixed-deposit premature closure");
|
||||
}
|
||||
final Long toGlAccountId = this.fromApiJsonHelper.extractLongNamed(DepositsApiConstants.toGlAccountIdParamName, element);
|
||||
baseDataValidator.reset().parameter(DepositsApiConstants.toGlAccountIdParamName).value(toGlAccountId)
|
||||
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
|
||||
} else if (accountOnClosureType.isWithdarwDeposit() && isPreMatureClose) {
|
||||
// Premature closure proceeds have to be traceable into a savings account, so the cash payout
|
||||
// is not offered in the preview (see DepositAccountPreMatureCalculationPlatformServiceImpl)
|
||||
// and is refused here as well -- otherwise a direct API call could still take the cash route.
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
|
||||
"withdraw.deposit.not.allowed",
|
||||
"Paying the deposit out in cash is not supported for account pre mature close -- transfer it to a savings account");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -180,6 +180,7 @@ public class DepositProductDataValidator {
|
||||
}
|
||||
|
||||
validateRecurringDetailForCreate(element, baseDataValidator);
|
||||
validateLienPercentage(element, baseDataValidator);
|
||||
|
||||
if (!hasRdSlabs) {
|
||||
validateChartsData(element, baseDataValidator);
|
||||
@@ -215,6 +216,7 @@ public class DepositProductDataValidator {
|
||||
}
|
||||
|
||||
validateRecurringDepositUpdate(element, baseDataValidator);
|
||||
validateLienPercentage(element, baseDataValidator);
|
||||
|
||||
// RD products now use the CBS base chart as a read-only template, so
|
||||
// product updates no longer require a charts payload.
|
||||
@@ -225,6 +227,40 @@ public class DepositProductDataValidator {
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
}
|
||||
|
||||
/** Lien Marked's loan limit: a share of the deposit's balance, so above 0 and at most 100. Optional. */
|
||||
private void validateLienPercentage(final JsonElement element, final DataValidatorBuilder baseDataValidator) {
|
||||
if (!this.fromApiJsonHelper.parameterExists("lienPercentage", element)) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal lienPercentage = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("lienPercentage", element);
|
||||
baseDataValidator.reset().parameter("lienPercentage").value(lienPercentage).ignoreIfNull().positiveAmount()
|
||||
.notGreaterThanMax(BigDecimal.valueOf(100));
|
||||
validateMissedInstallmentPenalty(element, baseDataValidator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Missed-installment penalty: grace days are whole days from 0, the type is 1 (flat amount) or 2 (% of the
|
||||
* installment), and the amount is positive - at most 100 when it is a percentage. All optional.
|
||||
*/
|
||||
private void validateMissedInstallmentPenalty(final JsonElement element, final DataValidatorBuilder baseDataValidator) {
|
||||
if (this.fromApiJsonHelper.parameterExists("missedInstallmentGraceDays", element)) {
|
||||
final Integer graceDays = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("missedInstallmentGraceDays", element);
|
||||
baseDataValidator.reset().parameter("missedInstallmentGraceDays").value(graceDays).ignoreIfNull().integerZeroOrGreater();
|
||||
}
|
||||
Integer penaltyType = null;
|
||||
if (this.fromApiJsonHelper.parameterExists("missedInstallmentPenaltyType", element)) {
|
||||
penaltyType = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("missedInstallmentPenaltyType", element);
|
||||
baseDataValidator.reset().parameter("missedInstallmentPenaltyType").value(penaltyType).ignoreIfNull().isOneOfTheseValues(1, 2);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("missedInstallmentPenaltyAmount", element)) {
|
||||
final BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("missedInstallmentPenaltyAmount", element);
|
||||
baseDataValidator.reset().parameter("missedInstallmentPenaltyAmount").value(amount).ignoreIfNull().positiveAmount();
|
||||
if (amount != null && Integer.valueOf(2).equals(penaltyType)) {
|
||||
baseDataValidator.reset().parameter("missedInstallmentPenaltyAmount").value(amount).notGreaterThanMax(BigDecimal.valueOf(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDepositDetailForCreate(final JsonElement element, final FromJsonHelper fromApiJsonHelper,
|
||||
final DataValidatorBuilder baseDataValidator, final DepositAccountType accountType) {
|
||||
final String name = fromApiJsonHelper.extractStringNamed(nameParamName, element);
|
||||
|
||||
+3
@@ -67,4 +67,7 @@ public interface DepositAccountDomainService {
|
||||
|
||||
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
|
||||
Map<String, Object> changes);
|
||||
|
||||
/** Names the journal entries of the recurring deposit's interest credits that have no narration yet. */
|
||||
void narrateRdInterestPostings(RecurringDepositAccount account);
|
||||
}
|
||||
|
||||
+305
-15
@@ -19,10 +19,14 @@
|
||||
package org.apache.fineract.portfolio.savings.domain;
|
||||
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
|
||||
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.transferDescriptionParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.time.LocalDate;
|
||||
@@ -36,6 +40,7 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat;
|
||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
|
||||
@@ -46,6 +51,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.portfolio.account.PortfolioAccountType;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
@@ -65,6 +71,8 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
@@ -72,7 +80,9 @@ 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.tds.service.SavingsInterestTdsService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -93,6 +103,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
|
||||
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
|
||||
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsInterestTdsService savingsInterestTdsService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -267,9 +280,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||
} else {
|
||||
// Paid out in cash or to a GL, so the interest never reaches savings: TDS is tracked and taken here.
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.close(user, command, changes);
|
||||
@@ -390,10 +407,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
account.updateClosedStatus();
|
||||
account.updateOnAccountClosureStatus(onClosureType);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
// if(!processMaturityInstructionOnly)
|
||||
@@ -428,14 +448,47 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
|
||||
Long savingsTransactionId = null;
|
||||
// Checked before anything is posted or paid out, so a wrong date is reported as such.
|
||||
account.validateMaturityClosureDate(closedDate);
|
||||
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, closedDate, postReversals);
|
||||
// Paid out late: the days between maturity and today earn the product's post-maturity rate, so every closure
|
||||
// branch below - reinvest, transfer or withdraw - carries it.
|
||||
account.postOverdueMaturityInterest(closedDate, postReversals);
|
||||
final BigDecimal transactionAmount = account.getAccountBalance();
|
||||
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
|
||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||
if (onClosureType.isReinvest()) {
|
||||
BigDecimal reInvestAmount;
|
||||
if (onClosureType.isReinvestPrincipal()) {
|
||||
reInvestAmount = account.getDepositAmount();
|
||||
// getDepositAmount() is the deposit's contracted total (every installment it was meant to receive),
|
||||
// not what actually came in. Capped at the balance, or a deposit with missed installments - or none -
|
||||
// would open its successor with money that was never paid.
|
||||
reInvestAmount = account.getDepositAmount() == null ? account.getAccountBalance()
|
||||
: account.getDepositAmount().min(account.getAccountBalance());
|
||||
// Principal only: the interest part does not go into the new deposit, so it has to be paid
|
||||
// somewhere real first - the savings account named on the request, else the one chosen when
|
||||
// the deposit was opened, else its linked savings account. Previously the full balance was
|
||||
// withdrawn while only the principal was redeposited, so the interest simply vanished.
|
||||
final BigDecimal interestPart = account.getAccountBalance().subtract(reInvestAmount);
|
||||
if (interestPart.signum() > 0) {
|
||||
final Long interestTargetId = resolveRdPayoutSavingsId(account,
|
||||
command.longValueOfParameterNamed(toSavingsAccountIdParamName));
|
||||
if (interestTargetId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.reinvest.principal.needs.savings",
|
||||
"Reinvest Principal Only pays the interest out to a savings account - choose one, or link a Savings account to this deposit.");
|
||||
}
|
||||
final SavingsAccount interestTarget = this.depositAccountAssembler.assembleFrom(interestTargetId,
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final String description = "RD " + account.getAccountNumber() + " interest to savings "
|
||||
+ interestTarget.getAccountNumber() + " (maturity, principal reinvested)";
|
||||
final AccountTransferDTO interestTransfer = new AccountTransferDTO(closedDate, interestPart, PortfolioAccountType.SAVINGS,
|
||||
PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null, null,
|
||||
AccountTransferType.INTEREST_TRANSFER.getValue(), null, null, ExternalId.empty(), null, interestTarget, account,
|
||||
isRegularTransaction, isExceptionForBalanceCheck);
|
||||
narrateTransfer(this.accountTransfersWritePlatformService.transferFunds(interestTransfer), description);
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
}
|
||||
} else {
|
||||
reInvestAmount = account.getAccountBalance();
|
||||
}
|
||||
@@ -451,31 +504,44 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
frequency = frequency == -1 ? 1 : frequency;
|
||||
reinvestedDeposit.generateSchedule(frequencyType, frequency, calendar);
|
||||
reinvestedDeposit.processAccountUponActivation(fmt, postReversals, relaxingDaysConfigForPivotDate);
|
||||
// The opening credit is on the new deposit but its stored balance was never recomputed, so it read 0
|
||||
// until something else touched the account.
|
||||
reinvestedDeposit.updateSavingsAccountSummary(reinvestedDeposit.getTransactions());
|
||||
reinvestedDeposit.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
this.savingsAccountRepository.save(reinvestedDeposit);
|
||||
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
|
||||
autoGenerateAccountNumber(reinvestedDeposit);
|
||||
// The money moves deposit-to-deposit and never touches cash: the new deposit's opening credit and the
|
||||
// old deposit's withdrawal below both post against transfers-in-suspense, which nets to nil. Before
|
||||
// this the opening credit posted no journal entry at all and the withdrawal posted as cash paid out,
|
||||
// leaving the RD control GL short and cash overstated by the whole reinvested amount.
|
||||
final boolean isInternalTransferLeg = true;
|
||||
postJournalEntries(reinvestedDeposit, new HashSet<>(), new HashSet<>(), isInternalTransferLeg);
|
||||
|
||||
final boolean applyWithdrawFee = false;
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
paymentDetail, applyWithdrawFee, isRegularTransaction, isInternalTransferLeg);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
// The withdrawal posted journal entries for everything new on the account (the maturity interest
|
||||
// included), so every transaction now on it is accounted for.
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
|
||||
|
||||
} 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, transactionAmount,
|
||||
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, isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "maturity", transferDescription, locale, fmt,
|
||||
isRegularTransaction);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.close(user, command, changes);
|
||||
@@ -483,10 +549,28 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.savingsAccountRepository.save(account);
|
||||
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||
narrateRdInterestPostings(account);
|
||||
|
||||
return savingsTransactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a recurring deposit's proceeds may be paid when no account was named for this closure: the savings
|
||||
* account chosen when it was opened, else its own linked Savings account (Deposit Account Link).
|
||||
*/
|
||||
private Long resolveRdPayoutSavingsId(final RecurringDepositAccount account, final Long requestedSavingsId) {
|
||||
if (requestedSavingsId != null) {
|
||||
return requestedSavingsId;
|
||||
}
|
||||
if (account.getTransferToSavingsAccountId() != null) {
|
||||
return account.getTransferToSavingsAccountId();
|
||||
}
|
||||
if (account.getLinkedAccountId() != null && "SAVINGS".equalsIgnoreCase(account.getLinkedAccountType())) {
|
||||
return account.getLinkedAccountId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private CalendarInstance getCalendarInstance(final RecurringDepositAccount account, RecurringDepositAccount reinvestedDeposit) {
|
||||
CalendarInstance calendarInstance = null;
|
||||
CalendarInstance parentCalendarInstance = this.calendarInstanceRepository.findByEntityIdAndEntityTypeIdAndCalendarTypeId(
|
||||
@@ -812,6 +896,18 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
Long savingsTransactionId = null;
|
||||
|
||||
// Maker's own "waive penalty" choice on the Premature Close dialog, not the product's stored
|
||||
// preClosurePenalApplicable config -- this is a one-time override for this closure only, read
|
||||
// fresh here since the preview and the actual submit are two separate requests.
|
||||
final boolean waivePreClosurePenalty = command.hasParameter(waivePreClosurePenaltyParamName)
|
||||
&& command.booleanPrimitiveValueOfParameterNamed(waivePreClosurePenaltyParamName);
|
||||
// RBI-mandated waiver reasons waive the penalty regardless of the manual checkbox above -- see
|
||||
// PrematureClosureReasonType. Enforced here (not just trusted from the client) so the actual
|
||||
// closure always matches what the preview showed for the same reason.
|
||||
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType
|
||||
.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName));
|
||||
account.setWaivePreClosurePenaltyForThisRequest(waivePreClosurePenalty || prematureClosureReason.isAutoWaived());
|
||||
|
||||
// post interest
|
||||
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
@@ -831,13 +927,34 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else if (closureType.isTransferToGl()) {
|
||||
final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName);
|
||||
// Taken before the proceeds are read, so the GL receives the payout net of TDS.
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
"GL");
|
||||
final BigDecimal proceeds = account.getAccountBalance();
|
||||
// Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer
|
||||
// account rather than cash/vault -- nothing is paid over the counter here. The offset below then
|
||||
// moves it out of there into the GL the maker picked, leaving Liability Transfer at nil and the
|
||||
// net posting as Dr Savings Control / Cr chosen GL.
|
||||
final boolean isInternalTransfer = true;
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, proceeds, paymentDetail, false,
|
||||
isRegularTransaction, isInternalTransfer);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate,
|
||||
command.stringValueOfParameterNamed(transferDescriptionParamName));
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
"CASH");
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
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);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.prematureClosure(user, command, changes);
|
||||
@@ -848,6 +965,81 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
return savingsTransactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a premature closure's proceeds out of Liability Transfer and into the GL account the maker picked:
|
||||
* Dr the Liability Transfer account / Cr that GL account.
|
||||
* <p>
|
||||
* The withdrawal booked immediately before this was declared an account transfer, so the accounting engine
|
||||
* has already credited Liability Transfer for the same amount -- this entry clears it, leaving the pair
|
||||
* netting to Dr Savings Control / Cr the chosen GL, and Liability Transfer back at nil. Posted through the real
|
||||
* createJournalEntry command rather than written straight to the ledger, so it reverses, reports and
|
||||
* reconciles exactly like any other manual entry.
|
||||
*/
|
||||
private void postClosureProceedsToGlAccount(final SavingsAccount account, final Long toGlAccountId, final BigDecimal amount,
|
||||
final LocalDate closedDate, final String description) {
|
||||
|
||||
final Long suspenseGlAccountId = liabilityTransferGlAccountId();
|
||||
|
||||
final JsonObject debitEntry = new JsonObject();
|
||||
debitEntry.addProperty("glAccountId", suspenseGlAccountId);
|
||||
debitEntry.addProperty("amount", amount);
|
||||
final JsonArray debits = new JsonArray();
|
||||
debits.add(debitEntry);
|
||||
|
||||
final JsonObject creditEntry = new JsonObject();
|
||||
creditEntry.addProperty("glAccountId", toGlAccountId);
|
||||
creditEntry.addProperty("amount", amount);
|
||||
final JsonArray credits = new JsonArray();
|
||||
credits.add(creditEntry);
|
||||
|
||||
final StringBuilder comments = new StringBuilder("Premature closure of fixed deposit ").append(account.getAccountNumber());
|
||||
if (description != null && !description.isBlank()) {
|
||||
comments.append(" -- ").append(description);
|
||||
}
|
||||
|
||||
final String dateFormat = "dd MMMM yyyy";
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("officeId", account.officeId());
|
||||
payload.addProperty("transactionDate", closedDate.format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH)));
|
||||
payload.addProperty("dateFormat", dateFormat);
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("currencyCode", account.getCurrency().getCode());
|
||||
payload.addProperty("comments", comments.toString());
|
||||
payload.add("debits", debits);
|
||||
payload.add("credits", credits);
|
||||
|
||||
final String json = payload.toString();
|
||||
final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL account the withdrawal above credited, and therefore the exact account this entry has to debit
|
||||
* to clear it.
|
||||
* <p>
|
||||
* It is the organisation-wide Liability Transfer financial activity, NOT the product's own Transfers In
|
||||
* Suspense mapping: {@code CashBasedAccountingProcessorForSavings} books an account-transfer withdrawal
|
||||
* as Dr Savings Control / Cr {@code FinancialActivity.LIABILITY_TRANSFER}. Debiting the product mapping
|
||||
* instead leaves the credit standing in Liability Transfer for ever while the two legs of this entry
|
||||
* collapse onto whichever account that mapping names.
|
||||
* <p>
|
||||
* Refusing outright when it is unmapped is deliberate: silently picking another account would put the
|
||||
* closure's money somewhere nobody chose.
|
||||
*/
|
||||
private Long liabilityTransferGlAccountId() {
|
||||
final List<Long> glAccountIds = this.jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ? ORDER BY id DESC",
|
||||
Long.class, AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
|
||||
if (glAccountIds.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.liability.transfer.financial.activity.not.mapped",
|
||||
"No Liability Transfer account is mapped under Accounting > Financial Activity Mappings, so the closure"
|
||||
+ " proceeds cannot be routed to a GL account. Map it first.",
|
||||
AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
|
||||
}
|
||||
return glAccountIds.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1023,10 +1215,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
}
|
||||
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(onClosureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
|
||||
if (alreadyMatured) {
|
||||
account.close(user, command, changes);
|
||||
@@ -1064,6 +1259,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
final Locale locale = command.extractLocale();
|
||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
Long savingsTransactionId = null;
|
||||
// The reason decides the penalty (and, for a deceased holder, lock-in) before any interest is worked out.
|
||||
account.applyPrematureClosureReason(
|
||||
PrematureClosureReasonType.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName)));
|
||||
// post interest
|
||||
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth, postReversals);
|
||||
@@ -1072,29 +1270,51 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
DepositAccountOnClosureType closureType = DepositAccountOnClosureType.fromInt(closureTypeValue);
|
||||
|
||||
if (closureType.isTransferToSavings()) {
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
|
||||
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
|
||||
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
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, isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "premature closure", transferDescription, locale, fmt,
|
||||
isRegularTransaction);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
directPayoutKind(closureType));
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds);
|
||||
}
|
||||
|
||||
account.prematureClosure(user, command, changes);
|
||||
this.savingsAccountRepository.save(account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
|
||||
narrateRdInterestPostings(account);
|
||||
return savingsTransactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the journal entries of every interest credit on the recurring deposit that has none yet - the term's
|
||||
* interest posted at maturity or premature closure, and any post-maturity interest - so the ledger shows whose
|
||||
* interest it is. Called once the closure's own entries are posted, which is the last of them to be written.
|
||||
*/
|
||||
@Override
|
||||
public void narrateRdInterestPostings(final RecurringDepositAccount account) {
|
||||
final String narration = "RD " + account.getAccountNumber() + " interest credited";
|
||||
narrateJournalEntries("""
|
||||
SELECT id FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
|
||||
""", narration, account.getId(), SavingsAccountTransactionType.INTEREST_POSTING.getValue());
|
||||
// An interest credit taken back (a premature closure, or a maturity whose final rate came out lower, credits
|
||||
// the recalculated interest instead): its original entries are already named, so this names only the reversing
|
||||
// entries.
|
||||
narrateJournalEntries("""
|
||||
SELECT id FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = true
|
||||
""", "RD " + account.getAccountNumber() + " interest reversed - recalculated on closure", account.getId(),
|
||||
SavingsAccountTransactionType.INTEREST_POSTING.getValue());
|
||||
}
|
||||
|
||||
private void updateExistingTransactionsDetails(SavingsAccount account, Set<Long> existingTransactionIds,
|
||||
Set<Long> existingReversedTransactionIds) {
|
||||
existingTransactionIds.addAll(account.findExistingTransactionIds());
|
||||
@@ -1111,6 +1331,76 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a closing recurring deposit into a savings account as two credits - its interest, then the principal -
|
||||
* each described with the deposit's account number, so the savings statement shows which RD the money came from
|
||||
* and how much of it is interest.
|
||||
*
|
||||
* <p>
|
||||
* The interest goes first and as an interest transfer: TDS counts as interest only what a payout carries beyond
|
||||
* the interest already swept out of the deposit, so with the interest swept first the principal transfer after it
|
||||
* is never mistaken for interest, and the interest is tracked for TDS on its own credit.
|
||||
*/
|
||||
private void transferRdPayoutToSavings(final RecurringDepositAccount account, final SavingsAccount toSavingsAccount,
|
||||
final LocalDate closedDate, final String closureLabel, final String clerkNote, final Locale locale,
|
||||
final DateTimeFormatter fmt, final boolean isRegularTransaction) {
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final String note = clerkNote == null || clerkNote.isBlank() ? "" : " - " + clerkNote.trim();
|
||||
final String rd = "RD " + account.getAccountNumber();
|
||||
final BigDecimal interest = account.getInterestInBalance();
|
||||
final BigDecimal principal = account.getAccountBalance().subtract(interest);
|
||||
|
||||
if (interest.signum() > 0) {
|
||||
final String description = rd + " interest to savings " + toSavingsAccount.getAccountNumber() + " (" + closureLabel + ")" + note;
|
||||
final Long transferId = this.accountTransfersWritePlatformService.transferFunds(new AccountTransferDTO(closedDate, interest,
|
||||
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null,
|
||||
null, AccountTransferType.INTEREST_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
|
||||
isRegularTransaction, isExceptionForBalanceCheck));
|
||||
narrateTransfer(transferId, description);
|
||||
}
|
||||
if (principal.signum() > 0) {
|
||||
final String description = rd + " principal to savings " + toSavingsAccount.getAccountNumber() + " (" + closureLabel + ")"
|
||||
+ note;
|
||||
final Long transferId = this.accountTransfersWritePlatformService.transferFunds(new AccountTransferDTO(closedDate, principal,
|
||||
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null,
|
||||
null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
|
||||
isRegularTransaction, isExceptionForBalanceCheck));
|
||||
narrateTransfer(transferId, description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a narration onto the journal entries of both sides of a transfer. The entries are posted while the
|
||||
* transfer is still being made, before its own record - and so its description - exists, so they are named here
|
||||
* once it does.
|
||||
*/
|
||||
private void narrateTransfer(final Long transferDetailsId, final String narration) {
|
||||
if (transferDetailsId == null) {
|
||||
return;
|
||||
}
|
||||
narrateJournalEntries("""
|
||||
SELECT from_savings_transaction_id FROM m_account_transfer_transaction WHERE account_transfer_details_id = ?
|
||||
UNION SELECT to_savings_transaction_id FROM m_account_transfer_transaction WHERE account_transfer_details_id = ?
|
||||
""", narration, transferDetailsId, transferDetailsId);
|
||||
}
|
||||
|
||||
/** Sets the narration of the not-yet-narrated journal entries of the savings transactions the query returns. */
|
||||
private void narrateJournalEntries(final String transactionIdsSql, final String narration, final Object... args) {
|
||||
final List<Long> transactionIds = this.jdbcTemplate.queryForList(transactionIdsSql, Long.class, args);
|
||||
for (final Long transactionId : transactionIds) {
|
||||
if (transactionId != null) {
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE acc_gl_journal_entry SET description = ? WHERE savings_transaction_id = ? AND (description IS NULL OR description = '')",
|
||||
narration, transactionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How a closure that does not go to savings paid out, for the TDS tracking record. */
|
||||
private static String directPayoutKind(final DepositAccountOnClosureType closureType) {
|
||||
return closureType != null && closureType.isTransferToGl() ? "GL" : "CASH";
|
||||
}
|
||||
|
||||
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
|
||||
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
|
||||
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
|
||||
|
||||
+100
-22
@@ -58,7 +58,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
|
||||
@@ -84,6 +84,16 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
protected InterestRateChartAssembler chartAssembler;
|
||||
@Transient
|
||||
private ConfigurationDomainService configurationDomainService;
|
||||
/**
|
||||
* Set once per request, immediately before a premature-closure preview or the real closure, from the
|
||||
* maker's own "waive penalty" checkbox on the Premature Close dialog -- never persisted, since it is
|
||||
* a one-time decision for this specific closure rather than a product/account setting. The product's
|
||||
* own {@code accountTermAndPreClosure.isPreClosurePenalApplicable()} configuration is untouched, so
|
||||
* every other premature closure of this same product still applies its penalty as normal; this only
|
||||
* suppresses it for the one request that explicitly asked to waive it.
|
||||
*/
|
||||
@Transient
|
||||
private boolean waivePreClosurePenaltyForThisRequest;
|
||||
|
||||
protected FixedDepositAccount() {
|
||||
//
|
||||
@@ -177,18 +187,17 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
if (this.chart != null) {
|
||||
boolean applyPreMaturePenalty = false;
|
||||
BigDecimal penalInterest = BigDecimal.ZERO;
|
||||
LocalDate depositCloseDate = calculateMaturityDate();
|
||||
// Rate-selection date is always calculateMaturityDate() below -- premature closure prices off
|
||||
// the deposit's own CONTRACTED rate, the same rate the FD was created with and the same one
|
||||
// every regular scheduled interest posting this account ever makes (see this method's 2-arg
|
||||
// overload), so a closure's freshly-recomputed total always matches what the account was
|
||||
// already accruing all along. No separate "actual period held" slab lookup -- deliberately
|
||||
// simple, one rate for the account's whole life.
|
||||
final LocalDate depositCloseDate = calculateMaturityDate();
|
||||
if (isPreMatureClosure) {
|
||||
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) {
|
||||
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) {
|
||||
applyPreMaturePenalty = true;
|
||||
penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest();
|
||||
final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure
|
||||
.depositPreClosureDetail().preClosurePenalInterestOnType();
|
||||
if (preClosurePenalInterestOnType.isWholeTerm()) {
|
||||
depositCloseDate = interestCalculatedUpto();
|
||||
} else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) {
|
||||
depositCloseDate = interestPostingUpToDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +362,7 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
boolean isInterestTransfer = false;
|
||||
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||
List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||
transactions);
|
||||
transactionsForCurrentTermInterestCalculation(transactions));
|
||||
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||
boolean isUserPosting = false;
|
||||
if (postedAsOnTransactionDates.contains(periodInterval.endDate())) {
|
||||
@@ -439,7 +448,7 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final boolean isInterestTransfer = false;
|
||||
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||
transactions);
|
||||
transactionsForCurrentTermInterestCalculation(transactions));
|
||||
|
||||
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
|
||||
@@ -525,6 +534,12 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||
this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType);
|
||||
|
||||
// RBI audit trail: record why, not just that, this closure happened -- see
|
||||
// PrematureClosureReasonType. Absent entirely on a request from before this field existed,
|
||||
// which resolves to INVALID/null here rather than a misleading "Normal".
|
||||
final Integer prematureClosureReasonId = command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName);
|
||||
this.accountTermAndPreClosure.updatePrematureClosureReason(PrematureClosureReasonType.fromInt(prematureClosureReasonId));
|
||||
|
||||
/*
|
||||
* // withdraw deposit amount before closing the account final Money transactionAmountMoney =
|
||||
* Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw =
|
||||
@@ -789,6 +804,13 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
|
||||
}
|
||||
|
||||
/** See {@link #waivePreClosurePenaltyForThisRequest}. Call before {@link #calculatePreMatureAmount} (preview)
|
||||
* or {@link #postPreMaturityInterest} (the real closure) -- both read the flag at the same point in the
|
||||
* same interest calculation this waives a part of. */
|
||||
public void setWaivePreClosurePenaltyForThisRequest(final boolean waive) {
|
||||
this.waivePreClosurePenaltyForThisRequest = waive;
|
||||
}
|
||||
|
||||
public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure,
|
||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
|
||||
|
||||
@@ -807,17 +829,22 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
final Integer financialYearBeginningMonth) {
|
||||
final MathContext mc = MathContext.DECIMAL64;
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
|
||||
try {
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
}
|
||||
|
||||
return interestOnMaturity;
|
||||
} finally {
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
return interestOnMaturity;
|
||||
}
|
||||
|
||||
public void postInterest(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer,
|
||||
@@ -909,12 +936,36 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest already posted for the term currently running, NOT the account's whole lifetime.
|
||||
* <p>
|
||||
* A renewed account keeps every prior term's transactions in {@code this.transactions} -- including
|
||||
* whatever interest was posted (and already paid out, via the renewal's own withdrawal/transfer leg)
|
||||
* for a term that has since closed. Summing all of them here double-counted that stale interest: a
|
||||
* premature closure of the CURRENT term subtracted it from the maturity amount
|
||||
* ({@code accountBalance - interestPostedToDate + interestEarnedTillDate}) even though it was never
|
||||
* part of this term's balance to begin with, silently shortchanging the customer by exactly that
|
||||
* amount -- enough to make a freshly-renewed FD's premature-closure amount come out BELOW its
|
||||
* deposit amount, penalty or no penalty. Scoped to {@link #depositStartDate()} for the same reason
|
||||
* {@link #isRenewedTerm()} and the posting-period calculation already are: interest posted before
|
||||
* the term restarted belongs to a term that has already been settled.
|
||||
*/
|
||||
private Money totalInterestPosted() {
|
||||
final boolean renewedTerm = isRenewedTerm();
|
||||
final LocalDate currentTermStartDate = depositStartDate();
|
||||
Money interestPostedToDate = Money.zero(this.currency);
|
||||
for (final SavingsAccountTransaction transaction : this.transactions) {
|
||||
if (transaction.isInterestPostingAndNotReversed()) {
|
||||
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
|
||||
if (!transaction.isInterestPostingAndNotReversed()) {
|
||||
continue;
|
||||
}
|
||||
// Strictly after, and matching transactionsForCurrentTermInterestCalculation() exactly: the
|
||||
// previous term's closing interest posting is dated the SAME day the new term starts, so an
|
||||
// inclusive bound still counts it here while the posting-period walk excludes it -- leaving
|
||||
// the old term's entire interest subtracted from a maturity amount it was never part of.
|
||||
if (renewedTerm && !DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
|
||||
continue;
|
||||
}
|
||||
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
|
||||
}
|
||||
|
||||
return interestPostedToDate;
|
||||
@@ -960,6 +1011,33 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* A renewed term's opening balance is seeded straight to the term's own principal (see
|
||||
* calculateInterestPayable/projectInterestSchedule) -- which is already the balance AS OF
|
||||
* depositStartDate(), i.e. net of whatever transaction closed out the previous term. That closing
|
||||
* transaction is dated the SAME day the new term starts (a renewal is atomic: the old term is closed
|
||||
* and the new one opened in the same stroke), so without this filter it still falls inside the new
|
||||
* term's posting-period walk and gets applied a SECOND time on top of the already-net seed --
|
||||
* understating the very first day's balance by the old term's entire payout, which silently corrupts
|
||||
* that day's interest for as long as the term keeps re-including it (every premature-closure preview,
|
||||
* and any later maturity recalculation). Scoping to transactions strictly after the term start avoids
|
||||
* the double-count; a genuine new transaction can't land on the renewal day itself for an FD anyway.
|
||||
*/
|
||||
private List<SavingsAccountTransaction> transactionsForCurrentTermInterestCalculation(
|
||||
final List<SavingsAccountTransaction> transactions) {
|
||||
if (!isRenewedTerm()) {
|
||||
return transactions;
|
||||
}
|
||||
final LocalDate currentTermStartDate = depositStartDate();
|
||||
final List<SavingsAccountTransaction> scoped = new ArrayList<>();
|
||||
for (final SavingsAccountTransaction transaction : transactions) {
|
||||
if (DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
|
||||
scoped.add(transaction);
|
||||
}
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
|
||||
private LocalDate interestCalculatedUpto() {
|
||||
LocalDate uptoMaturityDate = calculateMaturityDate();
|
||||
if (uptoMaturityDate != null) {
|
||||
|
||||
+1569
-1288
File diff suppressed because it is too large
Load Diff
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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.DepositAccountWritePlatformService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Tenure Flexibility: lengthens a running recurring deposit's term. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@CommandType(entity = "RECURRINGDEPOSITACCOUNT", action = "EXTENDTENURE")
|
||||
public class ExtendTenureRecurringDepositAccountCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.depositAccountWritePlatformService.extendRecurringDepositTenure(command.entityId(), command);
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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.jobs.applyrdmissedinstallmentpenalty;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositMissedInstallmentPenaltyService;
|
||||
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.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
* Runs the RD missed-installment penalty. The service decides what is due and isolates each installment, so the step
|
||||
* is a thin trigger and the rules live in one place.
|
||||
*/
|
||||
@Configuration
|
||||
public class ApplyRdMissedInstallmentPenaltyJobConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private RecurringDepositMissedInstallmentPenaltyService recurringDepositMissedInstallmentPenaltyService;
|
||||
|
||||
@Bean
|
||||
protected Step applyRdMissedInstallmentPenaltyStep() {
|
||||
return new StepBuilder(JobName.APPLY_RD_MISSED_INSTALLMENT_PENALTY.name(), jobRepository).tasklet((contribution, chunkContext) -> {
|
||||
recurringDepositMissedInstallmentPenaltyService.applyPenalties();
|
||||
return RepeatStatus.FINISHED;
|
||||
}, transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job applyRdMissedInstallmentPenaltyJob() {
|
||||
return new JobBuilder(JobName.APPLY_RD_MISSED_INSTALLMENT_PENALTY.name(), jobRepository).start(applyRdMissedInstallmentPenaltyStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+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.portfolio.savings.jobs.executerdautodebit;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositAutoDebitService;
|
||||
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 ExecuteRdAutoDebitJobConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
private RecurringDepositAutoDebitService recurringDepositAutoDebitService;
|
||||
|
||||
@Bean
|
||||
public ExecuteRdAutoDebitTasklet executeRdAutoDebitTasklet() {
|
||||
return new ExecuteRdAutoDebitTasklet(recurringDepositAutoDebitService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step executeRdAutoDebitStep() {
|
||||
return new StepBuilder(JobName.EXECUTE_RD_AUTO_DEBIT.name(), jobRepository).tasklet(executeRdAutoDebitTasklet(), transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job executeRdAutoDebitJob() {
|
||||
return new JobBuilder(JobName.EXECUTE_RD_AUTO_DEBIT.name(), jobRepository).start(executeRdAutoDebitStep())
|
||||
.incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+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.jobs.executerdautodebit;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositAutoDebitService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Runs RD Auto Debit. The service decides what is due and isolates each installment, so the tasklet stays a thin
|
||||
* trigger and the rules live in one place.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class ExecuteRdAutoDebitTasklet implements Tasklet {
|
||||
|
||||
private final RecurringDepositAutoDebitService service;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||
service.collectDueInstallments();
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+10
@@ -61,6 +61,16 @@ public class UpdateDepositsAccountMaturityDetailsTasklet implements Tasklet {
|
||||
}
|
||||
}
|
||||
|
||||
// Recurring deposits still running: credit any posting period (monthly, quarterly... per product) that ended
|
||||
// before today. The query above only returns deposits that have reached maturity.
|
||||
for (final Long rdId : depositAccountReadPlatformService.retrieveRunningRecurringDepositIds()) {
|
||||
try {
|
||||
depositAccountWritePlatformService.postRecurringDepositPeriodicInterest(rdId);
|
||||
} catch (final Exception ex) {
|
||||
log.error("Periodic interest posting failed for recurring deposit id {}", rdId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("{}: Records affected by updateMaturityDetailsOfDepositAccounts: {}", ThreadLocalContextUtil.getTenant().getName(),
|
||||
depositAccounts.size());
|
||||
return RepeatStatus.FINISHED;
|
||||
|
||||
+46
-7
@@ -19,8 +19,11 @@
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -32,6 +35,7 @@ import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.data.DepositAccountData;
|
||||
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
||||
import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData;
|
||||
@@ -42,6 +46,7 @@ import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.interceptor.TransactionAspectSupport;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class DepositAccountPreMatureCalculationPlatformServiceImpl implements DepositAccountPreMatureCalculationPlatformService {
|
||||
@@ -66,9 +71,17 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
|
||||
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(accountId, depositAccountType);
|
||||
|
||||
DepositAccountData accountData = null;
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
|
||||
.depositAccountOnClosureType(new DepositAccountOnClosureType[] { DepositAccountOnClosureType.WITHDRAW_DEPOSIT,
|
||||
DepositAccountOnClosureType.TRANSFER_TO_SAVINGS });
|
||||
// Premature closure proceeds must stay traceable -- into the customer's savings account, or (fixed
|
||||
// deposits only) straight to a GL account the maker picks. Paying a broken deposit out over the
|
||||
// counter in cash is not offered: WITHDRAW_DEPOSIT is deliberately absent here and rejected outright
|
||||
// by DepositAccountTransactionDataValidator#validateClosing, so a direct API call cannot take it
|
||||
// either. TRANSFER_TO_GL is offered only for fixed deposits because only
|
||||
// DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure books its GL leg.
|
||||
final DepositAccountOnClosureType[] closureTypes = depositAccountType.isFixedDeposit()
|
||||
? new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS,
|
||||
DepositAccountOnClosureType.TRANSFER_TO_GL }
|
||||
: new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS };
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations.depositAccountOnClosureType(closureTypes);
|
||||
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
|
||||
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
|
||||
.retrieveActiveForLookup(account.clientId(), DepositAccountType.SAVINGS_DEPOSIT);
|
||||
@@ -77,21 +90,47 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
|
||||
// calculate interest before one day of closure date
|
||||
final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1);
|
||||
final boolean isPreMatureClosure = true;
|
||||
// Preview-time only -- the real closure re-reads this same box from its own request when it
|
||||
// actually happens (see DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure), so a
|
||||
// maker previewing with the box checked and then submitting without it (or vice versa) gets
|
||||
// exactly what they last confirmed, not what an earlier preview computed.
|
||||
final boolean waivePreClosurePenalty = this.fromJsonHelper.parameterExists(waivePreClosurePenaltyParamName, element)
|
||||
&& this.fromJsonHelper.extractBooleanNamed(waivePreClosurePenaltyParamName, element);
|
||||
// RBI-mandated waiver reasons (premature extension, conversion to another TD, deceased
|
||||
// settlement, nominee/legal heir closure) waive the penalty regardless of the manual checkbox
|
||||
// above -- see PrematureClosureReasonType. This is enforced here, not just on the client, so
|
||||
// the preview a maker sees always matches what the actual closure will compute.
|
||||
final Integer prematureClosureReasonId = this.fromJsonHelper.extractIntegerSansLocaleNamed(prematureClosureReasonIdParamName,
|
||||
element);
|
||||
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType.fromInt(prematureClosureReasonId);
|
||||
final boolean effectiveWaivePenalty = waivePreClosurePenalty || prematureClosureReason.isAutoWaived();
|
||||
|
||||
if (depositAccountType.isFixedDeposit()) {
|
||||
final FixedDepositAccount fd = (FixedDepositAccount) account;
|
||||
accountData = FixedDepositAccountData.preClosureDetails(
|
||||
account.getId(), fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
|
||||
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
|
||||
fd.setWaivePreClosurePenaltyForThisRequest(effectiveWaivePenalty);
|
||||
final BigDecimal maturityAmount = fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
// calculatePreMatureAmount() (via getEffectiveInterestRateAsFraction()) leaves the account's
|
||||
// own nominalAnnualInterestRate set to whichever rate it just resolved for THIS preview --
|
||||
// the deposit's own contracted rate, minus any pre-closure penalty -- so read it back here
|
||||
// rather than re-deriving it, to show the maker exactly what applied.
|
||||
accountData = FixedDepositAccountData.preClosureDetails(account.getId(), maturityAmount, onAccountClosureOptions,
|
||||
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
|
||||
} else if (depositAccountType.isRecurringDeposit()) {
|
||||
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
|
||||
// Same reason rules the real closure applies, so the preview pays exactly what the closure will.
|
||||
rd.applyPrematureClosureReason(prematureClosureReason);
|
||||
accountData = RecurringDepositAccountData.preClosureDetails(
|
||||
account.getId(), rd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
|
||||
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
|
||||
}
|
||||
|
||||
// A preview, not a closure: working the payout out resets the account's own interest rate to the premature
|
||||
// rate and refreshes its interest-earned summary on the managed entity, and committing that left an RD that
|
||||
// was only previewed showing the penalty rate (4.5% instead of 6.5%) as if it had been broken. Nothing the
|
||||
// calculation touched may be saved.
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return accountData;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -61,6 +61,9 @@ public interface DepositAccountReadPlatformService {
|
||||
|
||||
Collection<DepositAccountData> retrieveForMaturityUpdate();
|
||||
|
||||
/** Active recurring deposits that have not reached maturity yet - their posting periods are credited nightly. */
|
||||
Collection<Long> retrieveRunningRecurringDepositIds();
|
||||
|
||||
SavingsAccountTransactionData retrieveRecurringAccountDepositTransactionTemplate(Long accountId);
|
||||
|
||||
Collection<AccountTransferDTO> retrieveDataForInterestTransfer();
|
||||
|
||||
+20
-1
@@ -283,6 +283,18 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
return this.jdbcTemplate.query(sqlBuilder.toString(), DEPOSIT_ACCOUNT_LOOKUP_MAPPER, depositAccountType.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Long> retrieveRunningRecurringDepositIds() {
|
||||
return this.jdbcTemplate.queryForList("""
|
||||
SELECT da.id FROM m_savings_account da
|
||||
JOIN m_deposit_account_term_and_preclosure dat ON dat.savings_account_id = da.id
|
||||
WHERE da.deposit_type_enum = ? AND da.status_enum = ?
|
||||
AND (dat.maturity_date IS NULL OR dat.maturity_date > ?)
|
||||
ORDER BY da.id
|
||||
""", Long.class, DepositAccountType.RECURRING_DEPOSIT.getValue(), SavingsAccountStatusType.ACTIVE.getValue(),
|
||||
DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<DepositAccountData> retrieveForMaturityUpdate() {
|
||||
|
||||
@@ -543,9 +555,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
|
||||
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
|
||||
depositAccountType.getValue());
|
||||
// TRANSFER_TO_GL is excluded for the same reason PARTIAL_REINVEST is filtered above: only one
|
||||
// handler implements it (fixed-deposit PREMATURE closure, which builds its own option list in
|
||||
// DepositAccountPreMatureCalculationPlatformServiceImpl). Offering it on this template -- the
|
||||
// normal Close and Payment screens -- would only get it refused by validateClosing.
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
|
||||
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
|
||||
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
|
||||
.filter(type -> includePartialReinvest || !type.isPartialReinvest()).filter(type -> !type.isTransferToGl())
|
||||
.toArray(DepositAccountOnClosureType[]::new));
|
||||
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
|
||||
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
|
||||
@@ -2053,6 +2069,8 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
sqlBuilder.append("dptp.max_deposit_term_type_enum as maxDepositTermTypeId, ");
|
||||
sqlBuilder.append("dptp.in_multiples_of_deposit_term as inMultiplesOfDepositTerm, ");
|
||||
sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, ");
|
||||
// Lien Marked's loan limit, so the opening screen can default to it and refuse anything above it.
|
||||
sqlBuilder.append("sa.lien_percentage as lienPercentage, ");
|
||||
sqlBuilder.append("sa.account_type as accountType, sa.customer_segment as customerSegment, ");
|
||||
sqlBuilder.append("sa.product_code as productCode, ");
|
||||
sqlBuilder.append("sa.effective_date as effectiveDate, sa.effective_to as effectiveTo, sa.end_date as endDate, ");
|
||||
@@ -2179,6 +2197,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
rs.getBoolean("deceasedSettlement"),
|
||||
rs.getBoolean("allowDebitTxnAfterDueDate")
|
||||
);
|
||||
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
+138
-3
@@ -24,6 +24,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.amountPa
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargeIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dueAsOfDateParamName;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.time.LocalDate;
|
||||
@@ -44,6 +45,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
@@ -147,6 +149,8 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
|
||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final RecurringDepositLienService recurringDepositLienService;
|
||||
private final RecurringDepositProductRules recurringDepositProductRules;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -639,7 +643,12 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
|
||||
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType);
|
||||
checkClientOrGroupActive(account);
|
||||
postInterest(account);
|
||||
if (account instanceof RecurringDepositAccount rdAccount) {
|
||||
// A recurring deposit is credited by its posting periods only - the same credits the nightly job makes.
|
||||
postRecurringDepositPeriodicInterest(rdAccount);
|
||||
} else {
|
||||
postInterest(account);
|
||||
}
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(savingsId) //
|
||||
.withOfficeId(account.officeId()) //
|
||||
@@ -650,6 +659,28 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void postRecurringDepositPeriodicInterest(final Long recurringDepositId) {
|
||||
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(recurringDepositId,
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
postRecurringDepositPeriodicInterest(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits a recurring deposit's completed posting periods (monthly, quarterly... per its product) with their own
|
||||
* journal entries, named after the deposit.
|
||||
*/
|
||||
private void postRecurringDepositPeriodicInterest(final RecurringDepositAccount rdAccount) {
|
||||
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||
final Set<Long> existingReversedTransactionIds = new HashSet<>();
|
||||
updateExistingTransactionsDetails(rdAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||
rdAccount.postPeriodicInterest(this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
|
||||
this.configurationDomainService.retrieveFinancialYearBeginningMonth(), false);
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(rdAccount);
|
||||
postJournalEntries(rdAccount, existingTransactionIds, existingReversedTransactionIds);
|
||||
this.depositAccountDomainService.narrateRdInterestPostings(rdAccount);
|
||||
}
|
||||
|
||||
private void postInterest(final SavingsAccount account) {
|
||||
|
||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||
@@ -931,6 +962,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
checkClientOrGroupActive(account);
|
||||
this.recurringDepositLienService.assertNoActiveLien(savingsId, "closed");
|
||||
|
||||
this.depositAccountDomainService.handleRDAccountClosure(account, paymentDetail, user, command, changes);
|
||||
|
||||
@@ -1047,6 +1079,43 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult extendRecurringDepositTenure(final Long savingsId, final JsonCommand command) {
|
||||
this.context.authenticatedUser();
|
||||
final Integer newDepositPeriod = command.integerValueOfParameterNamed(DepositsApiConstants.depositPeriodParamName);
|
||||
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
checkClientOrGroupActive(account);
|
||||
|
||||
// The added installments follow the deposit's own collection calendar (monthly unless it was opened otherwise).
|
||||
PeriodFrequencyType frequencyType = PeriodFrequencyType.MONTHS;
|
||||
Integer frequency = 1;
|
||||
final CalendarInstance calendarInstance = this.calendarInstanceRepository.findByEntityIdAndEntityTypeIdAndCalendarTypeId(savingsId,
|
||||
CalendarEntityType.SAVINGS.getValue(), CalendarType.COLLECTION.getValue());
|
||||
if (calendarInstance != null && calendarInstance.getCalendar() != null) {
|
||||
final String recurrence = calendarInstance.getCalendar().getRecurrence();
|
||||
frequencyType = CalendarFrequencyType.from(CalendarUtils.getFrequency(recurrence));
|
||||
frequency = CalendarUtils.getInterval(recurrence);
|
||||
frequency = frequency == -1 ? 1 : frequency;
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = account.extendTenure(newDepositPeriod, frequencyType, frequency,
|
||||
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
|
||||
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
|
||||
this.recurringDepositProductRules.validateMaturityWithinProductEndDate(account);
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(savingsId) //
|
||||
.withOfficeId(account.officeId()) //
|
||||
.withClientId(account.clientId()) //
|
||||
.withGroupId(account.groupId()) //
|
||||
.withSavingsId(savingsId) //
|
||||
.with(changes) //
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
@@ -1059,6 +1128,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
checkClientOrGroupActive(account);
|
||||
this.recurringDepositLienService.assertNoActiveLien(savingsId, "closed prematurely");
|
||||
if (account.maturityDate() == null) {
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
||||
@@ -1589,11 +1659,76 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
}
|
||||
}
|
||||
} else if (depositAccountType.isRecurringDeposit()) {
|
||||
((RecurringDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth, postReversals);
|
||||
final RecurringDepositAccount rdAccount = (RecurringDepositAccount) account;
|
||||
// Every night: credit any posting period (the product's Interest Posting Period) that ended before today.
|
||||
rdAccount.postPeriodicInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postReversals);
|
||||
rdAccount.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postReversals);
|
||||
final JsonCommand maturityClosure = rdMaturityInstructionCommand(rdAccount);
|
||||
if (maturityClosure != null) {
|
||||
// Same order as the FD branch above: give the maturity interest its own journal entry first and
|
||||
// mark it posted, so neither the closure's withdrawal nor the final call below posts it again.
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
this.depositAccountDomainService.handleRDAccountClosure(rdAccount, null, this.context.authenticatedUser(),
|
||||
maturityClosure, new HashMap<>());
|
||||
// The closure posted its own entries for everything it created.
|
||||
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
}
|
||||
}
|
||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
|
||||
if (account instanceof RecurringDepositAccount rdAccount) {
|
||||
// The term's interest credited on the maturity date, named in the ledger as soon as it is posted.
|
||||
this.depositAccountDomainService.narrateRdInterestPostings(rdAccount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The close command that carries out a recurring deposit's maturity instruction tonight, or null when it must
|
||||
* wait for staff instead. It runs only when the product has Maturity Instructions on, the deposit has just
|
||||
* matured, it was opened with Transfer to Savings or a Reinvest option, nothing holds a lien on it, and - for a
|
||||
* transfer - there is a savings account to pay into (the one chosen at opening, else the linked Savings account).
|
||||
* Anything else stays Matured, earning the post-maturity rate until someone closes it.
|
||||
*/
|
||||
private JsonCommand rdMaturityInstructionCommand(final RecurringDepositAccount rdAccount) {
|
||||
if (!rdAccount.isMatured() || rdAccount.savingsProduct() == null || !rdAccount.savingsProduct().isMaturityInstructions()) {
|
||||
return null;
|
||||
}
|
||||
final DepositAccountOnClosureType instruction = DepositAccountOnClosureType.fromInt(rdAccount.getOnAccountClosureId());
|
||||
if (!(instruction.isTransferToSavings() || instruction.isReinvest()) || instruction.isPartialReinvest()) {
|
||||
return null;
|
||||
}
|
||||
if (rdAccount.getAccountBalance() == null || rdAccount.getAccountBalance().signum() <= 0) {
|
||||
// Nothing was ever paid in: there is nothing to transfer or reinvest, so a person has to look at it.
|
||||
log.info("RD {} matured with no balance; maturity instruction left for staff", rdAccount.getAccountNumber());
|
||||
return null;
|
||||
}
|
||||
if (!this.recurringDepositLienService.activeLiens(rdAccount.getId()).isEmpty()) {
|
||||
log.info("RD {} matured with a lien on it; maturity instruction left for staff", rdAccount.getAccountNumber());
|
||||
return null;
|
||||
}
|
||||
Long payoutSavingsId = rdAccount.getTransferToSavingsAccountId();
|
||||
if (payoutSavingsId == null && rdAccount.getLinkedAccountId() != null
|
||||
&& "SAVINGS".equalsIgnoreCase(rdAccount.getLinkedAccountType())) {
|
||||
payoutSavingsId = rdAccount.getLinkedAccountId();
|
||||
}
|
||||
// Transfer always needs a destination; Reinvest Principal Only needs one for the interest part.
|
||||
if (payoutSavingsId == null && (instruction.isTransferToSavings() || instruction.isReinvestPrincipal())) {
|
||||
log.warn("RD {} matured with instruction {} but no savings account to pay into; left for staff", rdAccount.getAccountNumber(),
|
||||
instruction.getCode());
|
||||
return null;
|
||||
}
|
||||
final JsonObject closure = new JsonObject();
|
||||
closure.addProperty(SavingsApiConstants.closedOnDateParamName, rdAccount.maturityDate().toString());
|
||||
closure.addProperty(SavingsApiConstants.dateFormatParamName, "yyyy-MM-dd");
|
||||
closure.addProperty(SavingsApiConstants.localeParamName, "en");
|
||||
closure.addProperty(DepositsApiConstants.onAccountClosureIdParamName, instruction.getValue());
|
||||
if (payoutSavingsId != null) {
|
||||
closure.addProperty(DepositsApiConstants.toSavingsAccountIdParamName, payoutSavingsId);
|
||||
}
|
||||
closure.addProperty(DepositsApiConstants.transferDescriptionParamName, "Apply maturity instructions");
|
||||
return JsonCommand.fromJsonElement(rdAccount.getId(), closure, new FromJsonHelper());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
+7
@@ -127,6 +127,7 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
private final BusinessEventNotifierService businessEventNotifierService;
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
|
||||
private final RecurringDepositProductRules recurringDepositProductRules;
|
||||
|
||||
/*
|
||||
* Guaranteed to throw an exception no matter what the data integrity issue is.
|
||||
@@ -302,9 +303,12 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
frequency = frequency == -1 ? 1 : frequency;
|
||||
account.generateSchedule(frequencyType, frequency, calendar);
|
||||
final boolean isPreMatureClosure = false;
|
||||
// The simple/compounding flag must be on the account before its maturity is projected.
|
||||
this.recurringDepositProductRules.applyInterestCalculationType(account);
|
||||
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
account.validateApplicableInterestRate();
|
||||
this.recurringDepositProductRules.validateOnSubmit(account, command);
|
||||
savingAccountRepository.save(account);
|
||||
|
||||
// Save nominees if provided in payload (same transaction)
|
||||
@@ -551,12 +555,15 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
|
||||
frequency = frequency == -1 ? 1 : frequency;
|
||||
account.generateSchedule(frequencyType, frequency, calendar);
|
||||
final boolean isPreMatureClosure = false;
|
||||
// An application is not yet a contract, so an edit (possibly to a different product) re-reads it.
|
||||
this.recurringDepositProductRules.applyInterestCalculationType(account);
|
||||
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
account.validateApplicableInterestRate();
|
||||
this.savingAccountRepository.save(account);
|
||||
|
||||
}
|
||||
this.recurringDepositProductRules.validateOnModify(account, command);
|
||||
|
||||
// update calendar details
|
||||
if (!account.isCalendarInherited()) {
|
||||
|
||||
+42
-1
@@ -19,10 +19,13 @@
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingEnumerations;
|
||||
@@ -202,6 +205,31 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
}
|
||||
}
|
||||
|
||||
private static final Type OTHER_GL_MAPPINGS_TYPE = new TypeToken<List<Map<String, Object>>>() {}.getType();
|
||||
private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken<Map<String, List<Map<String, Object>>>>() {}.getType();
|
||||
|
||||
private static List<Map<String, Object>> parseOtherGlMappings(final String otherGlMappingsJson) {
|
||||
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
|
||||
if (!element.isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
return GSON.fromJson(element, OTHER_GL_MAPPINGS_TYPE);
|
||||
}
|
||||
|
||||
private static Map<String, List<Map<String, Object>>> parseOtherGlMappingsAsMap(final String otherGlMappingsJson) {
|
||||
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
|
||||
if (!element.isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
return GSON.fromJson(element, OTHER_GL_MAPPINGS_MAP_TYPE);
|
||||
}
|
||||
|
||||
private abstract static class DepositProductMapper implements RowMapper<DepositProductData> {
|
||||
|
||||
private final String schemaSql;
|
||||
@@ -221,6 +249,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
sqlBuilder.append("sp.lockin_period_frequency as lockinPeriodFrequency,");
|
||||
sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, ");
|
||||
sqlBuilder.append("sp.accounting_type as accountingType, ");
|
||||
sqlBuilder.append("sp.other_gl_mappings as otherGlMappings, ");
|
||||
sqlBuilder.append("sp.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
|
||||
sqlBuilder.append("sp.withhold_tax as withHoldTax,");
|
||||
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName ");
|
||||
@@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName);
|
||||
}
|
||||
|
||||
return DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
|
||||
final DepositProductData data = DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
|
||||
compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType,
|
||||
lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax,
|
||||
taxGroupData);
|
||||
final String otherGlMappingsJson = rs.getString("otherGlMappings");
|
||||
data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson));
|
||||
data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +499,10 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
sqlBuilder.append("sp.balance_frequency as balanceFrequency, ");
|
||||
sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, ");
|
||||
sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, sp.msme as msme, ");
|
||||
sqlBuilder.append("sp.lien_percentage as lienPercentage, ");
|
||||
sqlBuilder.append("sp.missed_installment_grace_days as missedInstallmentGraceDays, ");
|
||||
sqlBuilder.append("sp.missed_installment_penalty_type as missedInstallmentPenaltyType, ");
|
||||
sqlBuilder.append("sp.missed_installment_penalty_amount as missedInstallmentPenaltyAmount, ");
|
||||
sqlBuilder.append("sp.auto_debit_facility as autoDebitFacility, sp.loan_facility as loanFacility, ");
|
||||
sqlBuilder.append("sp.premature_withdrawal_option as prematureWithdrawalOption, sp.tenure_flexibility as tenureFlexibility, ");
|
||||
sqlBuilder.append("sp.lien_marked as lienMarked, sp.maturity_instructions as maturityInstructions, ");
|
||||
@@ -590,6 +627,10 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
result.setPrematureWithdrawalOption(prematureWithdrawalOption);
|
||||
result.setTenureFlexibility(tenureFlexibility);
|
||||
result.setLienMarked(lienMarked);
|
||||
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
|
||||
result.setMissedInstallmentGraceDays(JdbcSupport.getInteger(rs, "missedInstallmentGraceDays"));
|
||||
result.setMissedInstallmentPenaltyType(JdbcSupport.getInteger(rs, "missedInstallmentPenaltyType"));
|
||||
result.setMissedInstallmentPenaltyAmount(rs.getBigDecimal("missedInstallmentPenaltyAmount"));
|
||||
result.setMaturityInstructions(maturityInstructions);
|
||||
result.setCreditMaturityProceeds(creditMaturityProceeds);
|
||||
result.setPenaltyGracePeriodMissedInstallments(penaltyGracePeriodMissedInstallments);
|
||||
|
||||
+141
-28
@@ -29,19 +29,47 @@ import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonQuery;
|
||||
import org.apache.fineract.infrastructure.core.domain.LocalDateInterval;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
|
||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
|
||||
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsHelper;
|
||||
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
|
||||
import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositAccountInterestCalculationServiceImpl implements RecurringDepositAccountInterestCalculationService {
|
||||
|
||||
private static final String PRODUCT_ID_PARAM = "productId";
|
||||
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final SavingsHelper savingsHelper;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final RecurringDepositProductRules recurringDepositProductRules;
|
||||
|
||||
/** The interest settings of an RD product, as an account opened under it will be calculated with. */
|
||||
private record ProductInterestTerms(SavingsCompoundingInterestPeriodType compounding, SavingsPostingInterestPeriodType posting,
|
||||
SavingsInterestCalculationType calculationType, SavingsInterestCalculationDaysInYearType daysInYear, boolean simpleInterest,
|
||||
BigDecimal minBalanceForInterest, MonetaryCurrency currency) {}
|
||||
|
||||
@Override
|
||||
public HashMap<String, Object> calculateInterest(JsonQuery query) {
|
||||
@@ -53,27 +81,120 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
|
||||
element);
|
||||
LocalDate submittedOnDate = this.fromApiJsonHelper.extractLocalDateNamed("submittedOnDate", element);
|
||||
if (submittedOnDate == null) {
|
||||
submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
submittedOnDate = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
final Long productId = this.fromApiJsonHelper.extractLongNamed(PRODUCT_ID_PARAM, element);
|
||||
final ProductInterestTerms terms = productId == null ? null : productInterestTerms(productId);
|
||||
|
||||
BigDecimal maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
|
||||
interestCompoundingPeriodInMonths, submittedOnDate);
|
||||
|
||||
final HashMap<String, Object> result = new HashMap<>();
|
||||
BigDecimal totalPrincipal = depositAmount.multiply(BigDecimal.valueOf(tenureInMonths));
|
||||
BigDecimal maturityAmount;
|
||||
if (terms != null) {
|
||||
// The product's own settings, through the same engine an opened account is calculated with - so the figure
|
||||
// shown before opening is the maturity amount the account will carry.
|
||||
final List<Map<String, Object>> postings = new ArrayList<>();
|
||||
final BigDecimal totalInterest = projectInterest(terms, depositAmount, annualInterestRate, tenureInMonths.intValue(),
|
||||
submittedOnDate, postings);
|
||||
maturityAmount = totalPrincipal.add(totalInterest);
|
||||
result.put("interestCalculation", terms.simpleInterest() ? "Simple" : "Compounding");
|
||||
result.put("compoundingPeriod", terms.compounding().name());
|
||||
result.put("postingPeriod", terms.posting().name());
|
||||
result.put("postingSchedule", postings);
|
||||
result.put("daysInYear", terms.daysInYear().getValue());
|
||||
} else {
|
||||
maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
|
||||
interestCompoundingPeriodInMonths, submittedOnDate);
|
||||
result.put("daysInYear", 365);
|
||||
}
|
||||
BigDecimal totalInterest = maturityAmount.subtract(totalPrincipal);
|
||||
|
||||
String warning = "This is an approximate calculated amount - it may vary slightly when the account is created";
|
||||
String warning = terms != null ? "Calculated with the product's interest settings, assuming every installment is paid on its due date"
|
||||
: "This is an approximate calculated amount - it may vary slightly when the account is created";
|
||||
|
||||
HashMap<String, Object> result = new HashMap<>();
|
||||
result.put("maturityAmount", maturityAmount);
|
||||
result.put("totalPrincipal", totalPrincipal);
|
||||
result.put("totalInterest", totalInterest);
|
||||
result.put("daysInYear", 365); // Standard for this product as per image
|
||||
result.put("warning", warning);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ProductInterestTerms productInterestTerms(final Long productId) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject("""
|
||||
SELECT interest_compounding_period_enum, interest_posting_period_enum, interest_calculation_type_enum,
|
||||
interest_calculation_days_in_year_type_enum, interest_calculate_selector,
|
||||
min_balance_for_interest_calculation, currency_code, currency_digits, currency_multiplesof
|
||||
FROM m_savings_product WHERE id = ? AND deposit_type_enum = 300
|
||||
""", (rs, rowNum) -> {
|
||||
final Integer selector = (Integer) rs.getObject("interest_calculate_selector");
|
||||
final Integer multiplesOf = (Integer) rs.getObject("currency_multiplesof");
|
||||
return new ProductInterestTerms(SavingsCompoundingInterestPeriodType.fromInt(rs.getInt("interest_compounding_period_enum")),
|
||||
SavingsPostingInterestPeriodType.fromInt(rs.getInt("interest_posting_period_enum")),
|
||||
SavingsInterestCalculationType.fromInt(rs.getInt("interest_calculation_type_enum")),
|
||||
SavingsInterestCalculationDaysInYearType.fromInt(rs.getInt("interest_calculation_days_in_year_type_enum")),
|
||||
this.recurringDepositProductRules.isSimpleInterestOption(selector), rs.getBigDecimal("min_balance_for_interest_calculation"),
|
||||
new MonetaryCurrency(rs.getString("currency_code"), rs.getInt("currency_digits"), multiplesOf));
|
||||
}, productId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest on a deposit of {@code installment} every month from {@code startDate} for {@code months} months, worked
|
||||
* out by the account engine itself (posting periods, compounding periods, daily balances, days in year, simple or
|
||||
* compounding) up to the day before maturity - the same calculation that gives an opened account its maturity
|
||||
* amount. Each posting period's credit is added to {@code postings}.
|
||||
*/
|
||||
private BigDecimal projectInterest(final ProductInterestTerms terms, final BigDecimal installment, final BigDecimal annualRate,
|
||||
final int months, final LocalDate startDate, final List<Map<String, Object>> postings) {
|
||||
final LocalDate termEnd = startDate.plusMonths(months).minusDays(1);
|
||||
final MonetaryCurrency currency = terms.currency();
|
||||
|
||||
final List<SavingsAccountTransactionDetailsForPostingPeriod> deposits = new ArrayList<>();
|
||||
for (int k = 0; k < months; k++) {
|
||||
final LocalDate dueDate = startDate.plusMonths(k);
|
||||
final LocalDate balanceEnd = k == months - 1 ? termEnd : startDate.plusMonths(k + 1L).minusDays(1);
|
||||
final BigDecimal runningBalance = installment.multiply(BigDecimal.valueOf(k + 1L));
|
||||
final int days = LocalDateInterval.create(dueDate, balanceEnd).daysInPeriodInclusiveOfEndDate();
|
||||
deposits.add(new SavingsAccountTransactionDetailsForPostingPeriod(null, dueDate, balanceEnd, runningBalance, installment, currency,
|
||||
days, true, false, false, false, false));
|
||||
}
|
||||
|
||||
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||
final boolean postingAtPeriodEnd = this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||
final List<LocalDateInterval> intervals = this.savingsHelper.determineInterestPostingPeriods(startDate, termEnd, terms.posting(),
|
||||
financialYearBeginningMonth, new ArrayList<>());
|
||||
final BigDecimal rateAsFraction = annualRate.divide(BigDecimal.valueOf(100L), MathContext.DECIMAL64);
|
||||
final Money minBalance = Money.of(currency, terms.minBalanceForInterest() == null ? BigDecimal.ZERO : terms.minBalanceForInterest());
|
||||
|
||||
final List<PostingPeriod> periods = new ArrayList<>();
|
||||
Money periodStartingBalance = Money.zero(currency);
|
||||
for (final LocalDateInterval interval : intervals) {
|
||||
final PostingPeriod period = PostingPeriod.createFrom(interval, periodStartingBalance, deposits, currency, terms.compounding(),
|
||||
terms.calculationType(), rateAsFraction, terms.daysInYear().getValue(), termEnd, new ArrayList<>(), false, minBalance,
|
||||
postingAtPeriodEnd, false, financialYearBeginningMonth);
|
||||
periodStartingBalance = period.closingBalance();
|
||||
periods.add(period);
|
||||
}
|
||||
this.savingsHelper.calculateInterestForAllPostingPeriods(currency, periods, null, false, terms.simpleInterest());
|
||||
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final PostingPeriod period : periods) {
|
||||
final BigDecimal interest = period.getInterestEarned().getAmount();
|
||||
total = total.add(interest);
|
||||
final Map<String, Object> posting = new LinkedHashMap<>();
|
||||
// The last period is credited on the maturity date itself.
|
||||
final LocalDate periodEnd = period.getPeriodInterval().endDate();
|
||||
final LocalDate postedOn = periodEnd.isBefore(termEnd) ? periodEnd : termEnd.plusDays(1);
|
||||
posting.put("date", postedOn.toString());
|
||||
posting.put("interest", interest);
|
||||
postings.add(posting);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public BigDecimal calculateInterestInternal(BigDecimal installmentAmount, BigDecimal annualInterestRate, Long tenureInMonths,
|
||||
Long interestCompoundingPeriodInMonths, LocalDate submittedOnDate) {
|
||||
MathContext mc = MoneyHelper.getMathContext();
|
||||
@@ -89,39 +210,31 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
|
||||
for (int m = 1; m <= totalMonths; m++) {
|
||||
// Add monthly installment at the start of the month
|
||||
currentBalance = currentBalance.add(installmentAmount);
|
||||
|
||||
|
||||
// Get actual days in the current month
|
||||
int daysInMonth = date.lengthOfMonth();
|
||||
|
||||
|
||||
// Calculate interest for the month using daily compounding
|
||||
double multiplier = Math.pow(1.0 + dailyRate, daysInMonth);
|
||||
currentBalance = currentBalance.multiply(BigDecimal.valueOf(multiplier), mc);
|
||||
|
||||
|
||||
// Move to next month
|
||||
date = date.plusMonths(1);
|
||||
}
|
||||
maturityAmount = currentBalance;
|
||||
} else {
|
||||
// Periodic Compounding (Monthly, Quarterly, etc.)
|
||||
BigDecimal monthlyRate = annualInterestRate.divide(BigDecimal.valueOf(1200), mc);
|
||||
BigDecimal i = monthlyRate;
|
||||
int n = totalMonths;
|
||||
|
||||
// Standard RD formula for monthly compounding:
|
||||
// M = P * ((1+i)^n - 1) / i * (1+i)
|
||||
// For other periods, we can approximate or use a loop.
|
||||
// Using loop for consistency with various compounding periods.
|
||||
BigDecimal compoundingFrequencyMonths = BigDecimal.valueOf(interestCompoundingPeriodInMonths);
|
||||
BigDecimal ratePerCompounding = annualInterestRate.divide(BigDecimal.valueOf(100), mc)
|
||||
.multiply(compoundingFrequencyMonths).divide(BigDecimal.valueOf(12), mc);
|
||||
BigDecimal ratePerCompounding = annualInterestRate.divide(BigDecimal.valueOf(100), mc).multiply(compoundingFrequencyMonths)
|
||||
.divide(BigDecimal.valueOf(12), mc);
|
||||
|
||||
for (int m = 1; m <= totalMonths; m++) {
|
||||
BigDecimal monthsRemaining = BigDecimal.valueOf(totalMonths - m + 1);
|
||||
BigDecimal numberOfCompoundings = monthsRemaining.divide(compoundingFrequencyMonths, mc);
|
||||
|
||||
BigDecimal installmentMaturity = installmentAmount.multiply(
|
||||
BigDecimal.ONE.add(ratePerCompounding).pow(numberOfCompoundings.intValue(), mc), mc);
|
||||
|
||||
|
||||
BigDecimal installmentMaturity = installmentAmount
|
||||
.multiply(BigDecimal.ONE.add(ratePerCompounding).pow(numberOfCompoundings.intValue(), mc), mc);
|
||||
|
||||
// Add remaining partial compounding period interest (simple interest for the rest)
|
||||
BigDecimal remainingMonths = monthsRemaining.remainder(compoundingFrequencyMonths);
|
||||
if (remainingMonths.compareTo(BigDecimal.ZERO) > 0) {
|
||||
@@ -129,11 +242,11 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
|
||||
.divide(BigDecimal.valueOf(1200), mc);
|
||||
installmentMaturity = installmentMaturity.add(partialInterest);
|
||||
}
|
||||
|
||||
|
||||
maturityAmount = maturityAmount.add(installmentMaturity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return maturityAmount.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.account.PortfolioAccountType;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* RD Auto Debit: on each due date, collects the installment of every recurring deposit opened with Auto Debit from
|
||||
* the Savings account linked to it, as an ordinary account transfer (debit the savings account, credit the deposit),
|
||||
* so it is booked, journalled and shown on both statements exactly like a transfer the customer made themselves.
|
||||
*
|
||||
* <ul>
|
||||
* <li>Only installments due on or before the business date and not yet fully paid are collected - oldest first, one
|
||||
* transfer each, each in its own transaction.</li>
|
||||
* <li>An installment the savings account cannot cover is skipped and stays overdue; nothing is partly collected, and
|
||||
* the rest of that deposit's installments wait for the next run so they are never paid out of order. Every other
|
||||
* deposit is unaffected.</li>
|
||||
* <li>Retrying is the product's choice ("Allow Debit Txn after due date"): with it on, anything still unpaid is
|
||||
* collected on a later run; with it off, an installment is debited only on its due date and a missed one stays
|
||||
* overdue for the customer to pay (and for the missed-installment penalty, if the product has one).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RecurringDepositAutoDebitService {
|
||||
|
||||
/**
|
||||
* Unpaid installments, plus per deposit what has been due up to today and what has actually been paid in. The two
|
||||
* totals matter because a deposit opened without "adjust advance payments" never allocates money paid before a
|
||||
* due date to that installment - the schedule shows it unpaid though the customer already paid at the counter.
|
||||
*/
|
||||
private static final String DUE_INSTALLMENTS_SQL = """
|
||||
SELECT rd.id AS rd_id, rd.linked_account_id AS savings_id, s.duedate AS due_date,
|
||||
s.deposit_amount - COALESCE(s.deposit_amount_completed_derived, 0) AS outstanding,
|
||||
(SELECT COALESCE(SUM(s2.deposit_amount), 0) FROM m_mandatory_savings_schedule s2
|
||||
WHERE s2.savings_account_id = rd.id AND s2.duedate <= ?) AS total_due,
|
||||
(SELECT COALESCE(SUM(t.amount), 0) FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = rd.id AND t.transaction_type_enum = 1 AND t.is_reversed = false) AS total_paid
|
||||
FROM m_savings_account rd
|
||||
JOIN m_savings_product sp ON sp.id = rd.product_id
|
||||
JOIN m_mandatory_savings_schedule s ON s.savings_account_id = rd.id
|
||||
JOIN m_savings_account sav ON sav.id = rd.linked_account_id
|
||||
WHERE rd.deposit_type_enum = 300
|
||||
AND rd.status_enum = 300
|
||||
AND rd.auto_debit_facility = true
|
||||
AND UPPER(rd.linked_account_type) = 'SAVINGS'
|
||||
AND sav.deposit_type_enum = 100
|
||||
AND sav.status_enum = 300
|
||||
AND COALESCE(s.completed_derived, false) = false
|
||||
AND s.duedate <= ?
|
||||
AND (COALESCE(sp.allow_debit_txn_after_due_date, false) = true OR s.duedate = ?)
|
||||
AND s.deposit_amount > COALESCE(s.deposit_amount_completed_derived, 0)
|
||||
ORDER BY rd.id, s.duedate
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final AccountTransfersWritePlatformService accountTransfersWritePlatformService;
|
||||
private final TransactionTemplate perInstallmentTransaction;
|
||||
|
||||
public RecurringDepositAutoDebitService(final JdbcTemplate jdbcTemplate,
|
||||
final AccountTransfersWritePlatformService accountTransfersWritePlatformService,
|
||||
final PlatformTransactionManager transactionManager) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.accountTransfersWritePlatformService = accountTransfersWritePlatformService;
|
||||
this.perInstallmentTransaction = new TransactionTemplate(transactionManager);
|
||||
this.perInstallmentTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
/** @return how many installments were collected */
|
||||
public int collectDueInstallments() {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
final List<DueInstallment> dueInstallments = this.jdbcTemplate.query(DUE_INSTALLMENTS_SQL,
|
||||
(rs, rowNum) -> new DueInstallment(rs.getLong("rd_id"), rs.getLong("savings_id"), rs.getObject("due_date", LocalDate.class),
|
||||
rs.getBigDecimal("outstanding"), rs.getBigDecimal("total_due").subtract(rs.getBigDecimal("total_paid"))),
|
||||
businessDate, businessDate, businessDate);
|
||||
|
||||
int collected = 0;
|
||||
Long blockedDeposit = null;
|
||||
Long currentDeposit = null;
|
||||
BigDecimal stillShort = BigDecimal.ZERO;
|
||||
for (final DueInstallment installment : dueInstallments) {
|
||||
if (installment.recurringDepositId().equals(blockedDeposit)) {
|
||||
continue;
|
||||
}
|
||||
if (!installment.recurringDepositId().equals(currentDeposit)) {
|
||||
currentDeposit = installment.recurringDepositId();
|
||||
// What the customer still owes up to today - zero when they already paid it at the counter.
|
||||
stillShort = installment.shortfall().max(BigDecimal.ZERO);
|
||||
}
|
||||
final BigDecimal amount = installment.outstanding().min(stillShort);
|
||||
if (amount.signum() <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.perInstallmentTransaction.executeWithoutResult(status -> collect(installment, amount, businessDate));
|
||||
stillShort = stillShort.subtract(amount);
|
||||
collected++;
|
||||
} catch (final RuntimeException e) {
|
||||
// Most often the linked savings account is short of funds. Leave this deposit's remaining
|
||||
// installments for the next run rather than paying a later one ahead of the one that failed.
|
||||
blockedDeposit = installment.recurringDepositId();
|
||||
log.warn("RD Auto Debit could not collect the installment due {} on recurring deposit {} from savings account {}: {}",
|
||||
installment.dueDate(), installment.recurringDepositId(), installment.savingsAccountId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("RD Auto Debit collected {} of {} due installment(s) on {}", collected, dueInstallments.size(), businessDate);
|
||||
return collected;
|
||||
}
|
||||
|
||||
private void collect(final DueInstallment installment, final BigDecimal amount, final LocalDate businessDate) {
|
||||
final SavingsAccount fromSavingsAccount = null;
|
||||
final SavingsAccount toSavingsAccount = null;
|
||||
final boolean isRegularTransaction = true;
|
||||
final boolean isExceptionForBalanceCheck = false;
|
||||
final AccountTransferDTO transfer = new AccountTransferDTO(businessDate, amount, PortfolioAccountType.SAVINGS,
|
||||
PortfolioAccountType.SAVINGS, installment.savingsAccountId(), installment.recurringDepositId(),
|
||||
"RD Auto Debit - installment due " + installment.dueDate(), null, null, null, null, null, null, null,
|
||||
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, fromSavingsAccount,
|
||||
isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(transfer);
|
||||
}
|
||||
|
||||
/** shortfall = everything due on the deposit up to today, less everything actually paid into it. */
|
||||
private record DueInstallment(Long recurringDepositId, Long savingsAccountId, LocalDate dueDate, BigDecimal outstanding,
|
||||
BigDecimal shortfall) {}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Lien on a recurring deposit pledged as security for a loan.
|
||||
*
|
||||
* <p>
|
||||
* The lien itself is the existing guarantor mechanism: adding the deposit as a guarantor account on a loan records a
|
||||
* funding line, and the guarantor module places the amount on hold when the loan is approved/disbursed, releases it as
|
||||
* the loan is repaid, and releases the rest when the loan closes. What this class adds is what that mechanism never
|
||||
* did for a recurring deposit:
|
||||
* <ul>
|
||||
* <li>only an RD whose product has <b>Lien Marked</b> on may be pledged, and never for more than the product's lien
|
||||
* percentage of its balance (the whole balance when none is set), less what it already secures;</li>
|
||||
* <li>a pledged RD cannot be closed - premature or on maturity - while any loan it secures is still pending or
|
||||
* running. Before this, the hold only surfaced as an "insufficient balance" error deep inside the closure, and a pledge
|
||||
* on a loan not yet approved (no hold placed yet) did not stop the deposit being paid out at all;</li>
|
||||
* <li>which loans hold how much, so the RD screens can show it.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositLienService {
|
||||
|
||||
/**
|
||||
* Pledges still in force: the funding line is active and the loan is submitted (100), approved (200) or active
|
||||
* (300). A loan that is closed, rejected or withdrawn no longer binds the deposit.
|
||||
*/
|
||||
private static final String ACTIVE_LIENS_SQL = """
|
||||
SELECT l.id AS loan_id, l.account_no AS loan_account_no, l.loan_status_id AS loan_status,
|
||||
COALESCE(gfd.amount_remaining_derived, gfd.amount) AS lien_amount
|
||||
FROM m_guarantor_funding_details gfd
|
||||
JOIN m_portfolio_account_associations paa ON paa.id = gfd.account_associations_id
|
||||
JOIN m_loan l ON l.id = paa.loan_account_id
|
||||
WHERE paa.linked_savings_account_id = ?
|
||||
AND gfd.status_enum = 100
|
||||
AND l.loan_status_id IN (100, 200, 300)
|
||||
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0
|
||||
ORDER BY l.id
|
||||
""";
|
||||
|
||||
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public record Lien(Long loanId, String loanAccountNo, Integer loanStatus, BigDecimal amount) {}
|
||||
|
||||
/**
|
||||
* The loan limit a deposit carries today: whether its product allows a lien at all, the percentage that applies
|
||||
* (the deposit's own, else its product's, else 100), and what that comes to on the current balance.
|
||||
*/
|
||||
public record LienLimit(boolean lienAllowed, BigDecimal accountPercentage, BigDecimal productPercentage,
|
||||
BigDecimal effectivePercentage, BigDecimal balance, BigDecimal maxLoanAmount) {}
|
||||
|
||||
private static final String LIEN_LIMIT_SQL = """
|
||||
SELECT sp.lien_marked AS lien_marked, sa.lien_percentage AS account_pct, sp.lien_percentage AS product_pct,
|
||||
COALESCE(sa.account_balance_derived, 0) AS balance
|
||||
FROM m_savings_account sa
|
||||
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||
WHERE sa.id = ?
|
||||
""";
|
||||
|
||||
public LienLimit lienLimit(final Long depositAccountId) {
|
||||
final List<LienLimit> rows = this.jdbcTemplate.query(LIEN_LIMIT_SQL, (rs, rowNum) -> {
|
||||
final boolean lienAllowed = rs.getBoolean("lien_marked");
|
||||
final BigDecimal accountPct = rs.getBigDecimal("account_pct");
|
||||
final BigDecimal productPct = rs.getBigDecimal("product_pct");
|
||||
final BigDecimal balance = rs.getBigDecimal("balance");
|
||||
final BigDecimal effective = accountPct != null ? accountPct : productPct != null ? productPct : ONE_HUNDRED;
|
||||
final BigDecimal maxLoan = lienAllowed ? balance.multiply(effective).divide(ONE_HUNDRED, 2, RoundingMode.DOWN)
|
||||
: BigDecimal.ZERO;
|
||||
return new LienLimit(lienAllowed, accountPct, productPct, effective, balance, maxLoan);
|
||||
}, depositAccountId);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
public List<Lien> activeLiens(final Long depositAccountId) {
|
||||
return this.jdbcTemplate.query(ACTIVE_LIENS_SQL, (rs, rowNum) -> new Lien(rs.getLong("loan_id"), rs.getString("loan_account_no"),
|
||||
rs.getInt("loan_status"), rs.getBigDecimal("lien_amount")), depositAccountId);
|
||||
}
|
||||
|
||||
public BigDecimal totalLien(final Long depositAccountId) {
|
||||
return activeLiens(depositAccountId).stream().map(Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
/** Refuses to pay out a recurring deposit that still secures a loan. */
|
||||
public void assertNoActiveLien(final Long depositAccountId, final String action) {
|
||||
final List<Lien> liens = activeLiens(depositAccountId);
|
||||
if (liens.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal total = liens.stream().map(Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
final String loans = liens.stream().map(lien -> lien.loanAccountNo() + " (" + money(lien.amount()) + ")")
|
||||
.collect(Collectors.joining(", "));
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.marked",
|
||||
"This recurring deposit has a lien of " + money(total) + " for loan(s) " + loans + ". It cannot be " + action
|
||||
+ " until the loan is repaid or the guarantee is released.",
|
||||
depositAccountId, total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules for pledging an account as loan security. Only recurring deposits are checked here; other account types
|
||||
* keep the guarantor module's existing behaviour.
|
||||
*
|
||||
* @param excludingLoanId
|
||||
* the loan being edited, whose own earlier pledge must not count against the new amount
|
||||
*/
|
||||
public void validatePledge(final SavingsAccount account, final BigDecimal amount, final Long excludingLoanId) {
|
||||
if (account == null || !account.depositAccountType().isRecurringDeposit()) {
|
||||
return;
|
||||
}
|
||||
if (account.savingsProduct() == null || !account.savingsProduct().isLienMarked()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.not.allowed",
|
||||
"Recurring deposit " + account.getAccountNumber() + " cannot be pledged: its product does not have Lien Marked enabled.",
|
||||
account.getId());
|
||||
}
|
||||
if (!account.isActive()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.account.not.active",
|
||||
"Recurring deposit " + account.getAccountNumber() + " is not active, so it cannot be pledged.", account.getId());
|
||||
}
|
||||
final BigDecimal alreadyPledged = activeLiens(account.getId()).stream()
|
||||
.filter(lien -> excludingLoanId == null || !excludingLoanId.equals(lien.loanId())).map(Lien::amount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
// The lien percentage caps the total lien at that share of the balance as it stands now, when the loan is
|
||||
// taken (90% of 1,00,000 = 90,000 across every loan the deposit secures). The deposit's own percentage, set
|
||||
// when it was opened, wins; else the product's; neither means the whole balance.
|
||||
final BigDecimal lienPercentage = account.getLienPercentage() != null ? account.getLienPercentage()
|
||||
: account.savingsProduct().getLienPercentage();
|
||||
final BigDecimal balance = account.getAccountBalance();
|
||||
final BigDecimal pledgeable = lienPercentage == null ? balance
|
||||
: balance.multiply(lienPercentage).divide(ONE_HUNDRED, 2, RoundingMode.DOWN);
|
||||
final BigDecimal available = pledgeable.subtract(alreadyPledged);
|
||||
if (amount != null && amount.compareTo(available) > 0) {
|
||||
final String limitText = lienPercentage == null ? "balance " + balance.toPlainString()
|
||||
: lienPercentage.stripTrailingZeros().toPlainString() + "% of balance " + balance.toPlainString() + " = "
|
||||
+ pledgeable.toPlainString();
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.exceeds.limit",
|
||||
"Recurring deposit " + account.getAccountNumber() + " can secure at most " + available.max(BigDecimal.ZERO).toPlainString()
|
||||
+ " (" + limitText + ", less " + money(alreadyPledged) + " already pledged); "
|
||||
+ money(amount) + " was requested.",
|
||||
account.getId(), amount, available);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rupees to the paisa for messages - "10000.00", not the stored "10000.000000". */
|
||||
private static String money(final BigDecimal amount) {
|
||||
return amount == null ? "0.00" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Missed-installment penalty for recurring deposits (product setting "Penalty / Grace Period for Missed
|
||||
* Installments").
|
||||
*
|
||||
* <p>
|
||||
* Every day, each installment still unpaid when its due date plus the product's grace days has passed is charged
|
||||
* the product's penalty once - a flat amount, or a percentage of the installment. The penalty is the system charge
|
||||
* "RD Missed Installment Penalty" added to the deposit and paid out of its balance, so it shows on the statement as
|
||||
* a charge and is booked to the product's Income From Penalties account like any other penalty.
|
||||
*
|
||||
* <ul>
|
||||
* <li>One penalty per installment, ever: m_rd_installment_penalty records which installments have been charged,
|
||||
* and an installment paid late afterwards is not charged again.</li>
|
||||
* <li>Paying an installment within the grace days costs nothing.</li>
|
||||
* <li>When the deposit does not yet hold enough to pay the penalty (the first installment missed, say), the charge
|
||||
* stays on the account as due and every later run tries again.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RecurringDepositMissedInstallmentPenaltyService {
|
||||
|
||||
public static final String PENALTY_CHARGE_NAME = "RD Missed Installment Penalty";
|
||||
public static final int PENALTY_TYPE_FLAT = 1;
|
||||
public static final int PENALTY_TYPE_PERCENT = 2;
|
||||
|
||||
private static final String DATE_FORMAT = "yyyy-MM-dd";
|
||||
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
/** Unpaid installments of active RDs whose product has the penalty on and that have not been penalised yet. */
|
||||
private static final String CANDIDATES_SQL = """
|
||||
SELECT s.id AS installment_id, rd.id AS rd_id, s.duedate AS due_date, s.deposit_amount AS installment_amount,
|
||||
COALESCE(sp.missed_installment_grace_days, 0) AS grace_days,
|
||||
COALESCE(sp.missed_installment_penalty_type, 1) AS penalty_type,
|
||||
sp.missed_installment_penalty_amount AS penalty_amount,
|
||||
(SELECT COALESCE(SUM(s2.deposit_amount), 0) FROM m_mandatory_savings_schedule s2
|
||||
WHERE s2.savings_account_id = rd.id AND s2.duedate <= s.duedate) AS due_through_installment,
|
||||
(SELECT COALESCE(SUM(t.amount), 0) FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = rd.id AND t.transaction_type_enum = 1 AND t.is_reversed = false) AS total_paid
|
||||
FROM m_savings_account rd
|
||||
JOIN m_savings_product sp ON sp.id = rd.product_id
|
||||
JOIN m_mandatory_savings_schedule s ON s.savings_account_id = rd.id
|
||||
WHERE rd.deposit_type_enum = 300
|
||||
AND rd.status_enum = 300
|
||||
AND sp.penalty_grace_period_missed_installments = true
|
||||
AND COALESCE(sp.missed_installment_penalty_amount, 0) > 0
|
||||
AND COALESCE(s.completed_derived, false) = false
|
||||
AND s.deposit_amount > COALESCE(s.deposit_amount_completed_derived, 0)
|
||||
AND s.duedate < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM m_rd_installment_penalty p WHERE p.installment_id = s.id)
|
||||
ORDER BY rd.id, s.duedate
|
||||
""";
|
||||
|
||||
/**
|
||||
* Penalty charges still unpaid on active recurring deposits. Found by the penalty charge definition rather than
|
||||
* through the tracking table, so nothing depends on the charge id being known at the moment it was added.
|
||||
*/
|
||||
private static final String UNPAID_SQL = """
|
||||
SELECT sac.savings_account_id AS rd_id, sac.id AS charge_id, sac.amount_outstanding_derived AS outstanding
|
||||
FROM m_savings_account_charge sac
|
||||
JOIN m_savings_account rd ON rd.id = sac.savings_account_id
|
||||
WHERE sac.charge_id = ?
|
||||
AND rd.deposit_type_enum = 300
|
||||
AND rd.status_enum = 300
|
||||
AND sac.is_active = true
|
||||
AND COALESCE(sac.is_paid_derived, false) = false
|
||||
AND COALESCE(sac.waived, false) = false
|
||||
AND sac.amount_outstanding_derived > 0
|
||||
ORDER BY sac.id
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final TransactionTemplate ownTransaction;
|
||||
|
||||
public RecurringDepositMissedInstallmentPenaltyService(final JdbcTemplate jdbcTemplate,
|
||||
final DepositAccountWritePlatformService depositAccountWritePlatformService, final FromJsonHelper fromJsonHelper,
|
||||
final PlatformTransactionManager transactionManager) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
|
||||
this.fromJsonHelper = fromJsonHelper;
|
||||
this.ownTransaction = new TransactionTemplate(transactionManager);
|
||||
this.ownTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* paidInFull: everything due up to and including this installment has actually been paid into the deposit - e.g.
|
||||
* in advance at the counter, which a deposit opened without "adjust advance payments" never allocates to the
|
||||
* installment itself. Such an installment only looks unpaid and must not be penalised.
|
||||
*/
|
||||
private record Candidate(Long installmentId, Long rdId, LocalDate dueDate, BigDecimal installmentAmount, int graceDays, int penaltyType,
|
||||
BigDecimal penaltyAmount, boolean paidInFull) {}
|
||||
|
||||
private record Unpaid(Long rdId, Long chargeId, BigDecimal outstanding) {}
|
||||
|
||||
/** @return how many installments were newly penalised */
|
||||
public int applyPenalties() {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
final Long penaltyChargeId = penaltyChargeId();
|
||||
if (penaltyChargeId == null) {
|
||||
log.warn("Charge '{}' not found - missed-installment penalties cannot be applied", PENALTY_CHARGE_NAME);
|
||||
return 0;
|
||||
}
|
||||
|
||||
final List<Candidate> candidates = this.jdbcTemplate.query(CANDIDATES_SQL,
|
||||
(rs, rowNum) -> new Candidate(rs.getLong("installment_id"), rs.getLong("rd_id"), rs.getObject("due_date", LocalDate.class),
|
||||
rs.getBigDecimal("installment_amount"), rs.getInt("grace_days"), rs.getInt("penalty_type"),
|
||||
rs.getBigDecimal("penalty_amount"),
|
||||
rs.getBigDecimal("total_paid").compareTo(rs.getBigDecimal("due_through_installment")) >= 0),
|
||||
businessDate);
|
||||
|
||||
int penalised = 0;
|
||||
for (final Candidate candidate : candidates) {
|
||||
// Grace: due on the 10th with 5 grace days is penalised from the 16th.
|
||||
if (!candidate.dueDate().plusDays(candidate.graceDays()).isBefore(businessDate) || candidate.paidInFull()) {
|
||||
continue;
|
||||
}
|
||||
final BigDecimal penalty = penaltyFor(candidate);
|
||||
if (penalty.signum() <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.ownTransaction.executeWithoutResult(status -> charge(candidate, penalty, penaltyChargeId, businessDate));
|
||||
penalised++;
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("Could not charge the missed-installment penalty on RD {} (installment due {}): {}", candidate.rdId(),
|
||||
candidate.dueDate(), e.getMessage());
|
||||
}
|
||||
}
|
||||
// Collect everything now due - tonight's penalties and any an earlier run could not cover.
|
||||
collectUnpaid(penaltyChargeId, businessDate);
|
||||
log.info("RD missed-installment penalty: {} installment(s) penalised on {}", penalised, businessDate);
|
||||
return penalised;
|
||||
}
|
||||
|
||||
/** Flat amount, or the percentage of this installment, rounded to the paisa. */
|
||||
static BigDecimal penaltyFor(final Candidate candidate) {
|
||||
if (candidate.penaltyType() == PENALTY_TYPE_PERCENT) {
|
||||
return candidate.installmentAmount().multiply(candidate.penaltyAmount()).divide(ONE_HUNDRED, 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return candidate.penaltyAmount().setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** Records the installment as penalised and adds the penalty charge to the deposit, as one unit. */
|
||||
private void charge(final Candidate candidate, final BigDecimal penalty, final Long penaltyChargeId, final LocalDate businessDate) {
|
||||
this.jdbcTemplate.update("""
|
||||
INSERT INTO m_rd_installment_penalty (installment_id, savings_account_id, penalty_amount, applied_on_date)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", candidate.installmentId(), candidate.rdId(), penalty, businessDate);
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("chargeId", penaltyChargeId);
|
||||
body.addProperty("amount", penalty);
|
||||
body.addProperty("dueDate", businessDate.toString());
|
||||
body.addProperty("dateFormat", DATE_FORMAT);
|
||||
body.addProperty("locale", "en");
|
||||
final CommandProcessingResult result = this.depositAccountWritePlatformService.addSavingsAccountCharge(command(body, candidate.rdId()),
|
||||
DepositAccountType.RECURRING_DEPOSIT);
|
||||
// The charge's id is only known here if the add happened to flush; keep the link when it is, for reference.
|
||||
if (result.getResourceId() != null) {
|
||||
this.jdbcTemplate.update("UPDATE m_rd_installment_penalty SET savings_account_charge_id = ? WHERE installment_id = ?",
|
||||
result.getResourceId(), candidate.installmentId());
|
||||
}
|
||||
}
|
||||
|
||||
private void collectUnpaid(final Long penaltyChargeId, final LocalDate businessDate) {
|
||||
final List<Unpaid> unpaid = this.jdbcTemplate.query(UNPAID_SQL,
|
||||
(rs, rowNum) -> new Unpaid(rs.getLong("rd_id"), rs.getLong("charge_id"), rs.getBigDecimal("outstanding")), penaltyChargeId);
|
||||
for (final Unpaid charge : unpaid) {
|
||||
tryPay(charge.rdId(), charge.chargeId(), charge.outstanding(), businessDate);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pays the penalty from the deposit's balance; if it cannot be covered yet the charge simply stays due. */
|
||||
private void tryPay(final Long rdId, final Long chargeId, final BigDecimal amount, final LocalDate businessDate) {
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("amount", amount);
|
||||
body.addProperty("dueDate", businessDate.toString());
|
||||
body.addProperty("dateFormat", DATE_FORMAT);
|
||||
body.addProperty("locale", "en");
|
||||
try {
|
||||
this.ownTransaction.executeWithoutResult(status -> this.depositAccountWritePlatformService.payCharge(rdId, chargeId,
|
||||
command(body, rdId), DepositAccountType.RECURRING_DEPOSIT));
|
||||
} catch (final RuntimeException e) {
|
||||
log.info("Missed-installment penalty {} on RD {} left due (not collected yet): {}", chargeId, rdId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Long penaltyChargeId() {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForObject(
|
||||
"SELECT id FROM m_charge WHERE name = ? AND is_deleted = false AND is_active = true ORDER BY id LIMIT 1", Long.class,
|
||||
PENALTY_CHARGE_NAME);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A command carrying the deposit's id the way the charge endpoints receive it from the API. */
|
||||
private JsonCommand command(final JsonObject body, final Long rdId) {
|
||||
return new JsonCommand(null, body.toString(), body, this.fromJsonHelper, null, rdId, null, null, null, null, rdId, null, null,
|
||||
null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.savings.domain.DepositProductAmountDetails;
|
||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProduct;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsProduct;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The recurring-deposit product settings that only mean something when an account is opened against them. Each one
|
||||
* used to be stored on the product and then ignored; they are enforced here, on the server, so a client that skips
|
||||
* the screen's own checks cannot open an account the product does not allow.
|
||||
*
|
||||
* <ul>
|
||||
* <li>Effective Date - the first day the product can be used. No account may be opened before it.</li>
|
||||
* <li>Effective To - the last day the product is on sale. No new account may be opened after it; accounts already
|
||||
* open run to maturity as normal.</li>
|
||||
* <li>End Date - the day the product is wound up. No account may be opened that would still be running after it, so
|
||||
* every account opened under the product has matured by then.</li>
|
||||
* <li>Deposit Amount Min / Max - bounds on the installment the customer commits to each period.</li>
|
||||
* <li>Nomination Facility - when on, at least one nominee is required; when off, nominees cannot be recorded.</li>
|
||||
* <li>Auto Debit Facility - only offered when the product allows it, and only with a linked Savings account to debit,
|
||||
* since that is where the installment is collected from.</li>
|
||||
* <li>Interest Calculation Type - copied onto the account as its simple/compounding flag (see
|
||||
* {@link #isSimpleInterestOption}).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RecurringDepositProductRules {
|
||||
|
||||
/** Label of the InterestCalculationTypeOption code value that switches a recurring deposit to simple interest. */
|
||||
public static final String SIMPLE_INTEREST_OPTION_LABEL = "Simple";
|
||||
|
||||
private static final String LINKED_ACCOUNT_TYPE_SAVINGS = "SAVINGS";
|
||||
|
||||
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private final CodeValueRepository codeValueRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Fixes the account's simple/compounding flag from its product. Must run before the maturity amount is projected,
|
||||
* which is calculated with it.
|
||||
*/
|
||||
public void applyInterestCalculationType(final RecurringDepositAccount account) {
|
||||
account.setSimpleInterest(isSimpleInterestOption(account.savingsProduct().getInterestCalculateSelector()));
|
||||
}
|
||||
|
||||
/** Rules for a brand-new application. Runs after the maturity date is known, which End Date is checked against. */
|
||||
public void validateOnSubmit(final RecurringDepositAccount account, final JsonCommand command) {
|
||||
final SavingsProduct product = account.savingsProduct();
|
||||
applyProductPreClosurePenalty(account, product);
|
||||
applyLienPercentage(account, product, command, true);
|
||||
validateOpeningWindow(account, product);
|
||||
validateInstallmentAmount(account, product);
|
||||
validateNomination(product, command);
|
||||
validateAutoDebit(account, product);
|
||||
validateIntroducer(account, product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules for an application edited before approval. Nominees are only checked when the edit sends them, since an
|
||||
* edit that leaves them out keeps the ones already recorded.
|
||||
*/
|
||||
public void validateOnModify(final RecurringDepositAccount account, final JsonCommand command) {
|
||||
final SavingsProduct product = account.savingsProduct();
|
||||
applyProductPreClosurePenalty(account, product);
|
||||
applyLienPercentage(account, product, command, false);
|
||||
validateOpeningWindow(account, product);
|
||||
validateInstallmentAmount(account, product);
|
||||
if (hasNomineesProperty(command)) {
|
||||
validateNomination(product, command);
|
||||
}
|
||||
validateAutoDebit(account, product);
|
||||
validateIntroducer(account, product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a product's Interest Calculation Type is "Simple". The option is a tenant-editable code value, so it is
|
||||
* matched by label rather than by a hard-coded id; anything else - no selection, "Compounding", an unknown id - is
|
||||
* compounding, which is how every recurring deposit was calculated before the option was honoured.
|
||||
*/
|
||||
public boolean isSimpleInterestOption(final Integer interestCalculateSelector) {
|
||||
if (interestCalculateSelector == null) {
|
||||
return false;
|
||||
}
|
||||
return this.codeValueRepository.findById(interestCalculateSelector.longValue())
|
||||
.map(codeValue -> SIMPLE_INTEREST_OPTION_LABEL.equalsIgnoreCase(StringUtils.trim(codeValue.getLabel()))).orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of this deposit may be given as loan. Only a Lien Marked product allows a lien at all; the account's
|
||||
* percentage may be lowered from the product's but never raised above it. When the application sends none, a new
|
||||
* account takes the product's percentage and an edited one keeps what it had.
|
||||
*/
|
||||
private void applyLienPercentage(final RecurringDepositAccount account, final SavingsProduct product, final JsonCommand command,
|
||||
final boolean isSubmit) {
|
||||
if (!product.isLienMarked()) {
|
||||
account.setLienPercentage(null);
|
||||
return;
|
||||
}
|
||||
final BigDecimal productLimit = product.getLienPercentage() == null ? ONE_HUNDRED : product.getLienPercentage();
|
||||
if (!command.parameterExists("lienPercentage") || command.bigDecimalValueOfParameterNamed("lienPercentage") == null) {
|
||||
if (isSubmit) {
|
||||
account.setLienPercentage(product.getLienPercentage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
final BigDecimal requested = command.bigDecimalValueOfParameterNamed("lienPercentage");
|
||||
if (requested.signum() <= 0 || requested.compareTo(productLimit) > 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.percentage.out.of.range",
|
||||
"Loan against this deposit must be more than 0% and at most " + productLimit.stripTrailingZeros().toPlainString()
|
||||
+ "% (the limit of product '" + product.getName() + "'); " + requested.stripTrailingZeros().toPlainString() + "% was entered.",
|
||||
requested, productLimit);
|
||||
}
|
||||
account.setLienPercentage(requested);
|
||||
}
|
||||
|
||||
private void validateOpeningWindow(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
final LocalDate openedOn = account.getSubmittedOnDate();
|
||||
if (openedOn == null) {
|
||||
return;
|
||||
}
|
||||
final LocalDate effectiveFrom = product.getEffectiveDate();
|
||||
if (effectiveFrom != null && openedOn.isBefore(effectiveFrom)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.product.not.yet.effective",
|
||||
"Product '" + product.getName() + "' can only be used from " + effectiveFrom + "; this application is dated "
|
||||
+ openedOn + ".",
|
||||
effectiveFrom, openedOn);
|
||||
}
|
||||
final LocalDate effectiveTo = product.getEffectiveTo();
|
||||
if (effectiveTo != null && openedOn.isAfter(effectiveTo)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.product.closed.for.new.accounts",
|
||||
"Product '" + product.getName() + "' stopped accepting new accounts after " + effectiveTo
|
||||
+ "; this application is dated " + openedOn + ".",
|
||||
effectiveTo, openedOn);
|
||||
}
|
||||
final LocalDate endDate = product.getEndDate();
|
||||
final LocalDate maturityDate = account.maturityDate();
|
||||
if (endDate != null && maturityDate != null && maturityDate.isAfter(endDate)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.maturity.after.product.end.date",
|
||||
"This deposit would mature on " + maturityDate + ", after product '" + product.getName() + "' ends on " + endDate
|
||||
+ ". Choose a shorter tenure.",
|
||||
maturityDate, endDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Apply Penal Interest (less)" is the product's decision, not the account's: whatever the application sent, the
|
||||
* deposit takes the product's penalty - on or off, the same percentage and the same period (Whole Term / Till
|
||||
* Premature Withdrawal). A product without the penalty means no penal interest on premature closure.
|
||||
*/
|
||||
private void applyProductPreClosurePenalty(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
if (product instanceof RecurringDepositProduct rdProduct && rdProduct.depositProductTermAndPreClosure() != null) {
|
||||
account.applyProductPreClosurePenalty(rdProduct.depositProductTermAndPreClosure().depositPreClosureDetail());
|
||||
}
|
||||
}
|
||||
|
||||
/** A deposit whose tenure is extended must still mature on or before its product's End Date. */
|
||||
public void validateMaturityWithinProductEndDate(final RecurringDepositAccount account) {
|
||||
final SavingsProduct product = account.savingsProduct();
|
||||
final LocalDate endDate = product.getEndDate();
|
||||
final LocalDate maturityDate = account.maturityDate();
|
||||
if (endDate != null && maturityDate != null && maturityDate.isAfter(endDate)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.maturity.after.product.end.date",
|
||||
"This deposit would mature on " + maturityDate + ", after product '" + product.getName() + "' ends on " + endDate
|
||||
+ ". Choose a shorter tenure.",
|
||||
maturityDate, endDate);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateInstallmentAmount(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
if (!(product instanceof RecurringDepositProduct rdProduct) || rdProduct.depositProductTermAndPreClosure() == null) {
|
||||
return;
|
||||
}
|
||||
final DepositProductAmountDetails limits = rdProduct.depositProductTermAndPreClosure().depositProductAmountDetails();
|
||||
final BigDecimal installment = account.getRecurringDetail() == null ? null
|
||||
: account.getRecurringDetail().mandatoryRecommendedDepositAmount();
|
||||
if (limits == null || installment == null) {
|
||||
return;
|
||||
}
|
||||
final BigDecimal min = limits.getMinDepositAmount();
|
||||
if (min != null && min.signum() > 0 && installment.compareTo(min) < 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.installment.below.product.minimum",
|
||||
"Installment amount " + installment.stripTrailingZeros().toPlainString() + " is below the product minimum of " + min.stripTrailingZeros().toPlainString()
|
||||
+ ".",
|
||||
installment, min);
|
||||
}
|
||||
final BigDecimal max = limits.getMaxDepositAmount();
|
||||
if (max != null && max.signum() > 0 && installment.compareTo(max) > 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.installment.above.product.maximum",
|
||||
"Installment amount " + installment.stripTrailingZeros().toPlainString() + " is above the product maximum of " + max.stripTrailingZeros().toPlainString()
|
||||
+ ".",
|
||||
installment, max);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateNomination(final SavingsProduct product, final JsonCommand command) {
|
||||
final boolean hasNominees = hasNominees(command);
|
||||
if (product.isNominationFacility() && !hasNominees) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nominee.required",
|
||||
"Product '" + product.getName() + "' has Nomination Facility enabled - add at least one nominee.");
|
||||
}
|
||||
if (!product.isNominationFacility() && hasNominees) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nomination.not.offered",
|
||||
"Product '" + product.getName() + "' does not offer a Nomination Facility, so nominees cannot be recorded.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Introducer Required" on the product: the deposit must name an introducer - an existing, active customer of the
|
||||
* bank, and not the depositor themselves.
|
||||
*/
|
||||
private void validateIntroducer(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
if (!product.isIntroducerRequired()) {
|
||||
return;
|
||||
}
|
||||
final Long introducerId = account.getIntroducerClientId();
|
||||
if (introducerId == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.required",
|
||||
"Product '" + product.getName() + "' requires an introducer - choose an existing customer as introducer.");
|
||||
}
|
||||
if (introducerId.equals(account.clientId())) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.self",
|
||||
"The depositor cannot be their own introducer.");
|
||||
}
|
||||
final Integer active = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_client WHERE id = ? AND status_enum = 300",
|
||||
Integer.class, introducerId);
|
||||
if (active == null || active == 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.not.active",
|
||||
"The introducer must be an active customer of the bank.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAutoDebit(final RecurringDepositAccount account, final SavingsProduct product) {
|
||||
if (!Boolean.TRUE.equals(account.getAutoDebitFacility())) {
|
||||
return;
|
||||
}
|
||||
if (!product.isAutoDebitFacility()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.not.offered",
|
||||
"Product '" + product.getName() + "' does not offer Auto Debit.");
|
||||
}
|
||||
final Long linkedAccountId = account.getLinkedAccountId();
|
||||
if (linkedAccountId == null || !LINKED_ACCOUNT_TYPE_SAVINGS.equalsIgnoreCase(account.getLinkedAccountType())) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.linked.savings.required",
|
||||
"Auto Debit collects each installment from the linked account, so link an active Savings account to use it.");
|
||||
}
|
||||
final Integer matches = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND client_id = ? AND deposit_type_enum = 100 AND status_enum = 300",
|
||||
Integer.class, linkedAccountId, account.clientId());
|
||||
if (matches == null || matches == 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.linked.savings.not.active",
|
||||
"Linked account " + linkedAccountId + " is not an active Savings account of this client, so Auto Debit cannot use it.",
|
||||
linkedAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasNomineesProperty(final JsonCommand command) {
|
||||
final JsonObject root = command.parsedJson().getAsJsonObject();
|
||||
return root.has("nominees") || root.has("nominee");
|
||||
}
|
||||
|
||||
private static boolean hasNominees(final JsonCommand command) {
|
||||
final JsonObject root = command.parsedJson().getAsJsonObject();
|
||||
final JsonElement nominees = root.get("nominees");
|
||||
if (nominees != null && nominees.isJsonArray()) {
|
||||
return !nominees.getAsJsonArray().isEmpty();
|
||||
}
|
||||
final JsonElement nominee = root.get("nominee");
|
||||
return nominee != null && nominee.isJsonObject();
|
||||
}
|
||||
}
|
||||
+32
-4
@@ -374,8 +374,10 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
// 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());
|
||||
// A simple-interest recurring deposit must post here exactly what its own maturity projection promised, so the
|
||||
// flag is the same one SavingsAccount#calculateInterestUsing honours.
|
||||
this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods,
|
||||
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled);
|
||||
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled, savingsAccountData.isSimpleInterest());
|
||||
|
||||
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
|
||||
|
||||
@@ -437,14 +439,38 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
return false;
|
||||
}
|
||||
|
||||
protected Collection<Long> interestTransferWithdrawalTransactionIds(final SavingsAccountData savingsAccountData) {
|
||||
if (savingsAccountData.getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount(savingsAccountData)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return this.savingsHelper.fetchPostInterestTransactionIds(savingsAccountData.getId());
|
||||
}
|
||||
|
||||
private boolean isTransferInterestToOtherAccount(final SavingsAccountData savingsAccountData) {
|
||||
return savingsAccountData.isTransferInterestToLinkedAccount()
|
||||
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
|
||||
}
|
||||
|
||||
protected List<SavingsAccountTransactionData> excludeInterestTransferWithdrawals(
|
||||
final List<SavingsAccountTransactionData> transactions, final SavingsAccountData savingsAccountData) {
|
||||
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
|
||||
if (interestTransferWithdrawalIds.isEmpty()) {
|
||||
return transactions;
|
||||
}
|
||||
return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<SavingsAccountTransactionData> retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) {
|
||||
final List<SavingsAccountTransactionData> listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData);
|
||||
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
|
||||
|
||||
final List<SavingsAccountTransactionData> orderedNonInterestPostingTransactions = new ArrayList<>();
|
||||
|
||||
for (final SavingsAccountTransactionData transaction : listOfTransactionsSorted) {
|
||||
if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())
|
||||
&& transaction.isNotReversed() && !transaction.isReversalTransaction()) {
|
||||
&& transaction.isNotReversed() && !transaction.isReversalTransaction()
|
||||
&& !interestTransferWithdrawalIds.contains(transaction.getId())) {
|
||||
orderedNonInterestPostingTransactions.add(transaction);
|
||||
}
|
||||
}
|
||||
@@ -664,10 +690,12 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
|
||||
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransactionData> accountTransactionsSorted,
|
||||
final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) {
|
||||
final List<SavingsAccountTransactionData> filteredTransactions = excludeInterestTransferWithdrawals(accountTransactionsSorted,
|
||||
savingsAccountData);
|
||||
// loop over transactions in reverse
|
||||
LocalDate endOfBalanceDate = interestPostingUpToDate;
|
||||
for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) {
|
||||
final SavingsAccountTransactionData transaction = accountTransactionsSorted.get(i);
|
||||
for (int i = filteredTransactions.size() - 1; i >= 0; i--) {
|
||||
final SavingsAccountTransactionData transaction = filteredTransactions.get(i);
|
||||
if (transaction.isNotReversed() && !transaction.isReversalTransaction()
|
||||
&& !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) {
|
||||
transaction.updateCumulativeBalanceAndDates(MonetaryCurrency.fromCurrencyData(savingsAccountData.getCurrency()),
|
||||
|
||||
+11
-1
@@ -436,8 +436,16 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
public List<SavingsAccountData> retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize,
|
||||
final Integer status, final Long maxSavingsId) {
|
||||
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
|
||||
// A product on the daily-interest timetable is paid by the pooled posting instead
|
||||
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
|
||||
// FD products are never on that timetable, so they still post here. A recurring deposit (deposit_type_enum
|
||||
// 300) is left out: its posting periods are credited by the nightly deposit maturity job instead
|
||||
// (RecurringDepositAccount#postPeriodicInterest), at the chart rate and on deposits alone. The filters sit inside the paging subquery so a page is never cut short by
|
||||
// accounts that are then dropped.
|
||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id ";
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? and a.deposit_type_enum <> 300 "
|
||||
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
|
||||
+ "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
sql = sql
|
||||
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
|
||||
@@ -464,6 +472,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
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("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, ");
|
||||
sqlBuilder.append("sa.is_simple_interest as isSimpleInterest, ");
|
||||
sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, ");
|
||||
sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, ");
|
||||
sqlBuilder.append("sa.status_enum as statusEnum, ");
|
||||
@@ -833,6 +842,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
// 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"));
|
||||
savingsAccountData.setSimpleInterest(rs.getBoolean("isSimpleInterest"));
|
||||
}
|
||||
|
||||
if (!transMap.containsValue(transactionId)) {
|
||||
|
||||
+8
-4
@@ -106,6 +106,8 @@ import org.apache.fineract.commands.domain.CommandSourceRepository;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
|
||||
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
|
||||
import org.apache.fineract.portfolio.savings.service.RecurringDepositProductRules;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.savings.service.DepositAccountOnHoldTransactionReadPlatformService;
|
||||
@@ -297,7 +299,8 @@ public class SavingsConfiguration {
|
||||
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
|
||||
WorkingDaysRepositoryWrapper workingDaysRepository,
|
||||
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
|
||||
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate
|
||||
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate,
|
||||
RecurringDepositLienService recurringDepositLienService, RecurringDepositProductRules recurringDepositProductRules
|
||||
|
||||
) {
|
||||
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
|
||||
@@ -307,7 +310,7 @@ public class SavingsConfiguration {
|
||||
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
|
||||
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
|
||||
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
|
||||
denominationBreakdownWriter, jdbcTemplate);
|
||||
denominationBreakdownWriter, jdbcTemplate, recurringDepositLienService, recurringDepositProductRules);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -323,13 +326,14 @@ public class SavingsConfiguration {
|
||||
FromJsonHelper fromJsonHelper, CalendarInstanceRepository calendarInstanceRepository,
|
||||
ConfigurationDomainService configurationDomainService, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
|
||||
BusinessEventNotifierService businessEventNotifierService, SavingsAccountAssembler savingsAccountAssembler,
|
||||
ClientNomineeWritePlatformService clientNomineeWritePlatformService) {
|
||||
ClientNomineeWritePlatformService clientNomineeWritePlatformService,
|
||||
RecurringDepositProductRules recurringDepositProductRules) {
|
||||
return new DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository,
|
||||
fixedDepositAccountRepository, recurringDepositAccountRepository, depositAccountAssembler, depositAccountDataValidator,
|
||||
accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository, noteRepository, staffRepository,
|
||||
savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler, accountAssociationsRepository,
|
||||
fromJsonHelper, calendarInstanceRepository, configurationDomainService, accountNumberFormatRepository,
|
||||
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService);
|
||||
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService, recurringDepositProductRules);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+4
-2
@@ -281,7 +281,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
+ "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, "
|
||||
+ "m.dd_limit_amount "
|
||||
+ "from m_sponsor_dd_gl_mapping m "
|
||||
+ "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||
// Left join: DD Payable moved to the sponsor bank (0570) and became nullable here (0573), so
|
||||
// an inner join dropped the whole row and the settings screen reopened blank.
|
||||
+ "left join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
|
||||
+ "left join acc_gl_account clearing on clearing.id = m.cheque_clearing_gl_account_id "
|
||||
+ "left join acc_gl_account cash on cash.id = m.cash_gl_account_id "
|
||||
+ "left join acc_gl_account charges on charges.id = m.charges_income_gl_account_id "
|
||||
@@ -310,7 +312,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
|
||||
final Long id = rs.getLong("id");
|
||||
final Long sponsorBankId = rs.getObject("sponsor_bank_id") != null ? rs.getLong("sponsor_bank_id") : null;
|
||||
final String sponsorBankName = rs.getString("sponsor_bank_name");
|
||||
final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id");
|
||||
final Long ddPayableGlAccountId = rs.getObject("dd_payable_gl_account_id") != null ? rs.getLong("dd_payable_gl_account_id") : null;
|
||||
final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name");
|
||||
final String ddPayableGlCode = rs.getString("dd_payable_gl_code");
|
||||
final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null;
|
||||
|
||||
+2
-2
@@ -52,10 +52,10 @@ public class TdsInterestApiResource {
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Deposit interest per customer for a financial year",
|
||||
description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.")
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear) {
|
||||
public String retrieve(@QueryParam("financialYear") final Integer financialYear, @QueryParam("clientId") final Long clientId) {
|
||||
this.context.authenticatedUser();
|
||||
final List<TdsDeducteeData> deductees = this.readPlatformService
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear());
|
||||
.retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear(), clientId);
|
||||
return this.toApiJsonSerializer.serialize(deductees);
|
||||
}
|
||||
|
||||
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* 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.tds.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.tds.service.TdsSql;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The two TDS records the bank keeps by hand: the declarations customers give at the counter, and the
|
||||
* challans Head Office remits the deducted tax with.
|
||||
*
|
||||
* <p>
|
||||
* Both feed the deduction and the reports directly. A declaration stops the deduction from the day it
|
||||
* is accepted; a challan is what the remittance report reconciles the deducted tax against.
|
||||
*/
|
||||
@Path("/v1/tds")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class TdsRecordsApiResource {
|
||||
|
||||
private static final String DECLARATION = "TDSDECLARATION";
|
||||
private static final String CHALLAN = "TDSCHALLAN";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ─────────────────────────── declarations ───────────────────────────
|
||||
|
||||
/** A customer's declarations, every year and revoked ones included, newest year first. */
|
||||
@GET
|
||||
@Path("declarations")
|
||||
public String declarations(@QueryParam("clientId") final Long clientId, @QueryParam("fy") final String fy) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
if (clientId == null && fy == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declarations.filter.required", "Give a clientId or a fy.");
|
||||
}
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final StringBuilder where = new StringBuilder(" WHERE o.hierarchy LIKE ?");
|
||||
params.add(user.getOffice().getHierarchy() + "%");
|
||||
if (clientId != null) {
|
||||
where.append(" AND d.client_id = ?");
|
||||
params.add(clientId);
|
||||
}
|
||||
if (fy != null) {
|
||||
where.append(" AND d.fy_label = ?");
|
||||
params.add(fy);
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT d.id, d.client_id, c.display_name AS client_name, d.fy_label, d.form_type, d.uin, d.accepted_on,
|
||||
d.estimated_income, d.is_revoked, d.revoked_on, d.revoke_reason
|
||||
FROM m_tds_declaration d
|
||||
JOIN m_client c ON c.id = d.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
""" + where + " ORDER BY d.fy_label DESC, d.accepted_on DESC", params.toArray());
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("clientId", r.get("client_id"));
|
||||
o.put("clientName", r.get("client_name"));
|
||||
o.put("fy", r.get("fy_label"));
|
||||
o.put("formType", r.get("form_type"));
|
||||
o.put("uin", r.get("uin"));
|
||||
o.put("acceptedOn", str(r.get("accepted_on")));
|
||||
o.put("estimatedIncome", r.get("estimated_income"));
|
||||
o.put("revoked", r.get("is_revoked"));
|
||||
o.put("revokedOn", str(r.get("revoked_on")));
|
||||
o.put("revokeReason", r.get("revoke_reason"));
|
||||
out.add(o);
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a declaration. One live declaration per customer per year; the form must be the one the
|
||||
* year's configuration names (Form 121 from 2026-27, 15G/15H before), the date must fall in the year
|
||||
* and not in the future, and the customer must have a PAN - a declaration without one is invalid.
|
||||
*/
|
||||
@POST
|
||||
@Path("declarations")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String createDeclaration(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + DECLARATION);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
|
||||
final Long clientId = requiredLong(root, "clientId");
|
||||
assertInScope(user, clientId);
|
||||
final String fy = requiredText(root, "fy");
|
||||
final String formType = requiredText(root, "formType").toUpperCase();
|
||||
final LocalDate acceptedOn = requiredDate(root, "acceptedOn");
|
||||
final String uin = optionalText(root, "uin");
|
||||
final BigDecimal estimatedIncome = root.has("estimatedIncome") && !root.get("estimatedIncome").isJsonNull()
|
||||
&& !root.get("estimatedIncome").getAsString().isBlank() ? root.get("estimatedIncome").getAsBigDecimal() : null;
|
||||
|
||||
final List<Map<String, Object>> cfg = jdbcTemplate.queryForList("SELECT declaration_form FROM m_tds_fy_config WHERE fy_label = ?",
|
||||
fy);
|
||||
if (cfg.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.fy.not.configured",
|
||||
"Financial year " + fy + " is not set up under Organization › TDS Configuration.");
|
||||
}
|
||||
final String yearForm = (String) cfg.get(0).get("declaration_form");
|
||||
final boolean merged = "121".equals(yearForm);
|
||||
if (merged ? !"121".equals(formType) : !("15G".equals(formType) || "15H".equals(formType))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.year",
|
||||
merged ? "From " + fy + " the declaration is Form 121." : "For " + fy + " the declaration is Form 15G or 15H.");
|
||||
}
|
||||
|
||||
final int startYear = Integer.parseInt(fy.substring(0, 4));
|
||||
final LocalDate fyStart = LocalDate.of(startYear, 4, 1);
|
||||
final LocalDate fyEnd = LocalDate.of(startYear + 1, 3, 31);
|
||||
if (acceptedOn.isBefore(fyStart) || acceptedOn.isAfter(fyEnd)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.outside.year",
|
||||
"The acceptance date must fall inside " + fy + ".");
|
||||
}
|
||||
if (acceptedOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.in.future",
|
||||
"A declaration cannot be accepted on a future date.");
|
||||
}
|
||||
|
||||
final Map<String, Object> person = jdbcTemplate.queryForMap("""
|
||||
SELECT c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan
|
||||
FROM m_client c WHERE c.id = ?
|
||||
""", clientId);
|
||||
if (!TdsSql.isWellFormedPan((String) person.get("pan"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.pan.required",
|
||||
"A declaration is valid only with the customer's PAN. Record a PAN on the KYC first.");
|
||||
}
|
||||
if (!merged) {
|
||||
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
|
||||
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), fyEnd);
|
||||
if (senior != "15H".equals(formType)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.age",
|
||||
senior ? "This customer is 60 or over in " + fy + ", so the form is 15H." : "This customer is under 60 in " + fy
|
||||
+ ", so the form is 15G.");
|
||||
}
|
||||
}
|
||||
|
||||
final Integer live = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_tds_declaration WHERE client_id = ? AND fy_label = ? AND is_revoked = false", Integer.class,
|
||||
clientId, fy);
|
||||
if (live != null && live > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.duplicate",
|
||||
"This customer already has a declaration on record for " + fy + ". Revoke it first to replace it.");
|
||||
}
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_declaration (client_id, fy_label, form_type, uin, accepted_on, estimated_income,
|
||||
is_revoked, created_by, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, false, ?, now())
|
||||
""", clientId, fy, formType, uin, acceptedOn, estimatedIncome, user.getId());
|
||||
return declarations(clientId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraws a declaration from a date. Interest credited from then on is deducted again; what was
|
||||
* paid gross while it stood stays paid gross, because it was lawful at the time.
|
||||
*/
|
||||
@POST
|
||||
@Path("declarations/{id}/revoke")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String revokeDeclaration(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + DECLARATION);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
final List<Long> owner = jdbcTemplate.queryForList("SELECT client_id FROM m_tds_declaration WHERE id = ? AND is_revoked = false",
|
||||
Long.class, id);
|
||||
if (owner.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.declaration.not.found", "No live declaration " + id + ".");
|
||||
}
|
||||
assertInScope(user, owner.get(0));
|
||||
final String reason = requiredText(root, "reason");
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_tds_declaration
|
||||
SET is_revoked = true, revoked_on = ?, revoke_reason = ?, revoked_by = ?
|
||||
WHERE id = ?
|
||||
""", DateUtils.getBusinessLocalDate(), reason, user.getId(), id);
|
||||
return declarations(owner.get(0), null);
|
||||
}
|
||||
|
||||
// ─────────────────────────── challans ───────────────────────────
|
||||
|
||||
/** Every challan entered for the year, reversed ones included so the trail stays visible. */
|
||||
@GET
|
||||
@Path("challans")
|
||||
public String challans(@QueryParam("fy") final String fy) {
|
||||
this.context.authenticatedUser();
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT ch.id, ch.fy_label, ch.deduction_month, ch.amount, ch.challan_no, ch.bsr_code, ch.deposited_on,
|
||||
ch.paid_from_gl_account_id, g.gl_code, g.name AS gl_name, ch.journal_transaction_id, ch.remarks,
|
||||
ch.is_reversed
|
||||
FROM m_tds_challan ch
|
||||
JOIN acc_gl_account g ON g.id = ch.paid_from_gl_account_id
|
||||
WHERE ch.fy_label = ?
|
||||
ORDER BY ch.deduction_month, ch.deposited_on, ch.id
|
||||
""", fy);
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Map<String, Object> o = new LinkedHashMap<>();
|
||||
o.put("id", r.get("id"));
|
||||
o.put("fy", r.get("fy_label"));
|
||||
o.put("month", r.get("deduction_month"));
|
||||
o.put("amount", r.get("amount"));
|
||||
o.put("challanNo", r.get("challan_no"));
|
||||
o.put("bsrCode", r.get("bsr_code"));
|
||||
o.put("depositedOn", str(r.get("deposited_on")));
|
||||
o.put("paidFromGlAccountId", r.get("paid_from_gl_account_id"));
|
||||
o.put("paidFrom", r.get("gl_code") + " · " + r.get("gl_name"));
|
||||
o.put("journalTransactionId", r.get("journal_transaction_id"));
|
||||
o.put("remarks", r.get("remarks"));
|
||||
o.put("reversed", r.get("is_reversed"));
|
||||
out.add(o);
|
||||
}
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax actually deducted, month by month, for the year: the TDS debits on savings accounts for FD/RD
|
||||
* interest received there. This is what the challans must add up to.
|
||||
*/
|
||||
@GET
|
||||
@Path("deducted")
|
||||
public String deducted(@QueryParam("fy") final String fy) {
|
||||
this.context.authenticatedUser();
|
||||
requireFy(fy);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT TO_CHAR(d.deducted_on, 'YYYY-MM') AS month, SUM(d.amount) AS amount, COUNT(*) AS postings
|
||||
FROM m_tds_deduction d
|
||||
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE d.fy_label = ?
|
||||
GROUP BY 1 ORDER BY 1
|
||||
""", fy);
|
||||
// The business date, not the wall clock: whether a challan is overdue is judged on the day the
|
||||
// books are on.
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("asAt", DateUtils.getBusinessLocalDate().toString());
|
||||
out.put("months", rows);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/** Accounts the tax can be paid out of: active, postable asset accounts (bank, cash). */
|
||||
@GET
|
||||
@Path("challans/template")
|
||||
public String challanTemplate() {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_" + CHALLAN);
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT id, gl_code AS "glCode", name FROM acc_gl_account
|
||||
WHERE disabled = false AND account_usage = 1 AND classification_enum = 1
|
||||
ORDER BY gl_code
|
||||
""");
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("paidFromOptions", accounts);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a remittance and posts it: Dr TDS payable / Cr the account paid from, at Head Office.
|
||||
* Only Head Office remits - the bank files one return under one TAN.
|
||||
*/
|
||||
@POST
|
||||
@Path("challans")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String createChallan(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + CHALLAN);
|
||||
assertHeadOffice(user);
|
||||
final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject();
|
||||
|
||||
final String fy = requireFy(requiredText(root, "fy"));
|
||||
final String month = requiredText(root, "month");
|
||||
if (!month.matches("\\d{4}-\\d{2}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.month.invalid", "Month must look like 2026-06.");
|
||||
}
|
||||
final int startYear = Integer.parseInt(fy.substring(0, 4));
|
||||
final LocalDate monthStart = LocalDate.parse(month + "-01");
|
||||
if (monthStart.isBefore(LocalDate.of(startYear, 4, 1)) || monthStart.isAfter(LocalDate.of(startYear + 1, 3, 1))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.month.outside.year", "The month must fall inside " + fy + ".");
|
||||
}
|
||||
final BigDecimal amount = root.get("amount").getAsBigDecimal();
|
||||
if (amount.signum() <= 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.amount.invalid", "Amount must be more than zero.");
|
||||
}
|
||||
final String challanNo = requiredText(root, "challanNo");
|
||||
final String bsrCode = requiredText(root, "bsrCode");
|
||||
if (!bsrCode.matches("\\d{7}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.bsr.invalid", "BSR code is 7 digits.");
|
||||
}
|
||||
final LocalDate depositedOn = requiredDate(root, "depositedOn");
|
||||
if (depositedOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.date.in.future", "Deposit date cannot be in the future.");
|
||||
}
|
||||
final Long paidFrom = requiredLong(root, "paidFromGlAccountId");
|
||||
final String remarks = optionalText(root, "remarks");
|
||||
|
||||
final Integer dup = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_tds_challan WHERE challan_no = ? AND bsr_code = ? AND deposited_on = ? AND is_reversed = false",
|
||||
Integer.class, challanNo, bsrCode, depositedOn);
|
||||
if (dup != null && dup > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.duplicate", "This challan is already on record.");
|
||||
}
|
||||
|
||||
final Long tdsPayable = tdsPayableGl();
|
||||
final long officeId = user.getOffice().getId();
|
||||
final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class);
|
||||
final String txnId = "TDSCH-" + UUID.randomUUID().toString().substring(0, 18);
|
||||
final String narration = "TDS remitted for " + month + " · challan " + challanNo + " · BSR " + bsrCode;
|
||||
journalEntry(officeId, currency, tdsPayable, depositedOn, amount, true, txnId, narration, user.getId());
|
||||
journalEntry(officeId, currency, paidFrom, depositedOn, amount, false, txnId, narration, user.getId());
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_challan (fy_label, deduction_month, amount, challan_no, bsr_code, deposited_on,
|
||||
paid_from_gl_account_id, office_id, journal_transaction_id, remarks,
|
||||
is_reversed, created_by, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, now())
|
||||
""", fy, month, amount, challanNo, bsrCode, depositedOn, paidFrom, officeId, txnId, remarks, user.getId());
|
||||
return challans(fy);
|
||||
}
|
||||
|
||||
/** Undoes a challan entered in error: the entry is reversed with an opposite one, the row kept. */
|
||||
@POST
|
||||
@Path("challans/{id}/reverse")
|
||||
@Transactional
|
||||
public String reverseChallan(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("UPDATE_" + CHALLAN);
|
||||
assertHeadOffice(user);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT fy_label, amount, paid_from_gl_account_id, office_id, journal_transaction_id, challan_no FROM m_tds_challan"
|
||||
+ " WHERE id = ? AND is_reversed = false",
|
||||
id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.not.found", "No live challan " + id + ".");
|
||||
}
|
||||
final Map<String, Object> ch = rows.get(0);
|
||||
final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class);
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final String txnId = "TDSCHR-" + UUID.randomUUID().toString().substring(0, 17);
|
||||
final String narration = "Reversal of TDS challan " + ch.get("challan_no");
|
||||
final long officeId = ((Number) ch.get("office_id")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) ch.get("amount");
|
||||
journalEntry(officeId, currency, tdsPayableGl(), today, amount, false, txnId, narration, user.getId());
|
||||
journalEntry(officeId, currency, ((Number) ch.get("paid_from_gl_account_id")).longValue(), today, amount, true, txnId, narration,
|
||||
user.getId());
|
||||
jdbcTemplate.update("UPDATE m_tds_challan SET is_reversed = true WHERE id = ?", id);
|
||||
return challans((String) ch.get("fy_label"));
|
||||
}
|
||||
|
||||
// ─────────────────────────── helpers ───────────────────────────
|
||||
|
||||
/** Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. */
|
||||
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 String narration, final Long userId) {
|
||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||
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,
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, false, false, true, 0, 0, ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration, userId, userId, date);
|
||||
}
|
||||
|
||||
private Long tdsPayableGl() {
|
||||
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
|
||||
Long.class);
|
||||
if (gl.isEmpty() || gl.get(0) == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.payable.gl.not.configured",
|
||||
"No TDS payable account is set. Choose one under Organization › TDS Configuration.");
|
||||
}
|
||||
return gl.get(0);
|
||||
}
|
||||
|
||||
private void assertHeadOffice(final AppUser user) {
|
||||
if (user.getOffice().getParent() != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.challan.head.office.only",
|
||||
"TDS is remitted by Head Office. A branch login cannot record a challan.");
|
||||
}
|
||||
}
|
||||
|
||||
/** A user may act only on customers of their own office or the offices below it. */
|
||||
private void assertInScope(final AppUser user, final Long clientId) {
|
||||
final Integer n = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_client c JOIN m_office o ON o.id = c.office_id
|
||||
WHERE c.id = ? AND o.hierarchy LIKE ?
|
||||
""", Integer.class, clientId, user.getOffice().getHierarchy() + "%");
|
||||
if (n == null || n == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.client.out.of.scope",
|
||||
"Customer " + clientId + " does not belong to your office.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireFy(final String fy) {
|
||||
if (fy == null || !fy.matches("\\d{4}-\\d{2}")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds.fy.invalid", "Financial year must look like 2026-27.");
|
||||
}
|
||||
return fy;
|
||||
}
|
||||
|
||||
private static String str(final Object v) {
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static String optionalText(final JsonObject root, final String name) {
|
||||
if (!root.has(name) || root.get(name).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String v = root.get(name).getAsString().trim();
|
||||
return v.isEmpty() ? null : v;
|
||||
}
|
||||
|
||||
private static String requiredText(final JsonObject root, final String name) {
|
||||
final String v = optionalText(root, name);
|
||||
if (v == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".required", name + " is required.");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static Long requiredLong(final JsonObject root, final String name) {
|
||||
try {
|
||||
return Long.valueOf(requiredText(root, name));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a number.");
|
||||
}
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd. */
|
||||
private static LocalDate requiredDate(final JsonObject root, final String name) {
|
||||
try {
|
||||
return LocalDate.parse(requiredText(root, name));
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a date like 2026-09-15.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** A 15G / 15H / Form 121 declaration: one customer, one financial year, from the day it was accepted. */
|
||||
public record TdsDeclarationData(Long id, Long clientId, String fy, String formType, String uin, LocalDate acceptedOn,
|
||||
BigDecimal estimatedIncome, boolean revoked, LocalDate revokedOn, String revokeReason) implements Serializable {}
|
||||
+18
-9
@@ -19,12 +19,13 @@
|
||||
package org.apache.fineract.portfolio.tds.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A customer whose deposit interest the branch must consider for deduction under section 194A,
|
||||
* together with the accounts and the interest actually credited to them.
|
||||
* A customer whose deposit interest the branch must consider for deduction, together with the
|
||||
* accounts and the interest actually credited to them.
|
||||
*
|
||||
* <p>
|
||||
* The section bites on time deposits, so savings accounts are carried here as well but marked by
|
||||
@@ -32,23 +33,31 @@ import java.util.List;
|
||||
* separately. Sending only the fixed and recurring deposits would hide from the branch that the
|
||||
* customer holds a savings balance at all, which is the first thing an officer checks when a
|
||||
* customer disputes a deduction.
|
||||
*
|
||||
* <p>
|
||||
* The customer here is the <em>deductee</em>: a minor's deposit is listed under the guardian and a
|
||||
* joint deposit under its primary holder, because that is whose PAN the tax is deducted against.
|
||||
*/
|
||||
public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type, List<TdsAccountData> accounts) implements Serializable {
|
||||
String branchName, List<TdsDeclarationData> declarations, List<TdsAccountData> accounts) implements Serializable {
|
||||
|
||||
/**
|
||||
* One deposit account and the interest posted to it.
|
||||
* One deposit account, the interest posted to it and the TDS actually deducted from it.
|
||||
*
|
||||
* @param depositTypeCode
|
||||
* Fineract's own code - {@code depositAccountType.fixedDeposit},
|
||||
* {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether
|
||||
* the charge applies at all, so it is sent as the platform states it rather than as
|
||||
* a flag this report worked out for itself.
|
||||
* @param clientId
|
||||
* the deductee, which is the account holder unless the account is a minor's or joint
|
||||
* @param holderClientId
|
||||
* the account's own holder, shown when it differs from the deductee
|
||||
*/
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus,
|
||||
Long clientId, LocalDate openedOn, List<TdsInterestCreditData> credits) implements Serializable {}
|
||||
public record TdsAccountData(Long id, String accountNo, String depositTypeCode, Long clientId, Long holderClientId,
|
||||
String holderName, String accountStatus, LocalDate openedOn, List<TdsInterestCreditData> credits,
|
||||
List<TdsInterestCreditData> deductions) implements Serializable {}
|
||||
|
||||
/** One interest posting: the date it was credited and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo)
|
||||
implements Serializable {}
|
||||
/** One interest posting, or one TDS deduction: the date and the amount. */
|
||||
public record TdsInterestCreditData(LocalDate date, BigDecimal amount, String accountNo) implements Serializable {}
|
||||
}
|
||||
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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.tds.service;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository;
|
||||
import org.apache.fineract.portfolio.tax.domain.TaxComponent;
|
||||
import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* TDS on FD/RD interest, tracked and deducted where the interest is received.
|
||||
*
|
||||
* <p>
|
||||
* Normally that is the customer's savings account: every time money moves from a fixed or recurring
|
||||
* deposit into savings, the interest in it is recorded against the deductee (see
|
||||
* {@link TdsSql#deducteeOf}) and any TDS due is debited from that savings account. The periodic
|
||||
* interest sweep is all interest; a maturity or closure payout is principal and interest together, so
|
||||
* only the interest not already received is counted. The FD and RD are not debited.
|
||||
*
|
||||
* <p>
|
||||
* When a maturity or closure pays out in cash, by cheque or to a GL account instead, the interest never
|
||||
* reaches savings, so it is tracked at the payout and the TDS comes out of the payout itself. A full
|
||||
* reinvestment pays nothing out and is not taxed here.
|
||||
*
|
||||
* <p>
|
||||
* The rules, all for one financial year and one deductee:
|
||||
* <ol>
|
||||
* <li>The FD/RD interest received, across every branch, is added up. The savings account's own interest
|
||||
* is not - it is outside the charge.</li>
|
||||
* <li>A declaration shields the interest received from the day it is accepted. Interest received
|
||||
* before it stays liable.</li>
|
||||
* <li>Nothing is deducted while the total stays within the threshold (senior or general).</li>
|
||||
* <li>Once over it, the whole year's interest is liable - not merely the excess - at the PAN rate, or
|
||||
* the higher rate without a valid PAN.</li>
|
||||
* <li>What is deducted now is that liability less everything already deducted this year, so the credit
|
||||
* that crosses the threshold catches up the earlier ones, and nothing is ever deducted twice.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* The tax is posted as a withhold-tax transaction through a tax component whose credit account is the
|
||||
* TDS payable account set on Organization › TDS Configuration, so the passbook shows it and the
|
||||
* account's own accounting writes Dr deposit liability / Cr TDS payable.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SavingsInterestTdsService {
|
||||
|
||||
/** The tax component every TDS deduction is booked through. Kept in step with the GL setting. */
|
||||
public static final String TDS_COMPONENT_NAME = "TDS on deposit interest";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final TaxComponentRepository taxComponentRepository;
|
||||
private final EntityManager entityManager;
|
||||
private final SavingsAccountRepositoryWrapper savingsAccountRepository;
|
||||
private final SavingsAccountTransactionRepository savingsAccountTransactionRepository;
|
||||
private final JournalEntryWritePlatformService journalEntryWritePlatformService;
|
||||
|
||||
/**
|
||||
* Called once a savings-to-savings transfer has been saved.
|
||||
*
|
||||
* @param deposit
|
||||
* the credit on the receiving savings account
|
||||
*/
|
||||
public void onTransfer(final SavingsAccount from, final SavingsAccount to, final SavingsAccountTransaction deposit,
|
||||
final AccountTransferType transferType) {
|
||||
if (from == null || to == null || deposit == null || deposit.getId() == null || !isTimeDeposit(from)) {
|
||||
return;
|
||||
}
|
||||
final DepositAccountType toType = to.depositAccountType();
|
||||
if (toType == null || !toType.isSavingsDeposit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final boolean sweep = transferType != null && transferType.isInterestTransfer();
|
||||
final BigDecimal interest = sweep ? deposit.getAmount()
|
||||
: interestInPayout(from.getId(), postedFromDatabase(from.getId()), deposit.getAmount());
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final LocalDate date = deposit.getTransactionDate();
|
||||
final Year year = Year.of(date);
|
||||
final Long deductee = deducteeOf(from, year);
|
||||
if (deductee == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Long receiptId = insertReceipt(deductee, to.getId(), deposit.getId(), from.getId(), sweep ? "INTEREST" : "PAYOUT", year,
|
||||
date, interest);
|
||||
|
||||
final Due due = dueFor(deductee, year, date, BigDecimal.ZERO);
|
||||
if (due == null) {
|
||||
return;
|
||||
}
|
||||
final Set<Long> existingIds = new HashSet<>(to.findExistingTransactionIds());
|
||||
final Set<Long> existingReversedIds = new HashSet<>(to.findExistingReversedTransactionIds());
|
||||
final SavingsAccountTransaction tds = to.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
|
||||
// Saved on its own first, as Fineract does for every new transaction: saving only the account
|
||||
// merges it, and the id lands on the merged copy - this instance stays null, which broke both
|
||||
// the deduction row and the journal entry's transaction reference.
|
||||
this.savingsAccountTransactionRepository.saveAndFlush(tds);
|
||||
this.savingsAccountRepository.saveAndFlush(to);
|
||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(
|
||||
to.deriveAccountingBridgeData(to.getCurrency().getCode(), existingIds, existingReversedIds, false, false));
|
||||
recordDeduction(deductee, to.getId(), tds.getId(), from.getId(), receiptId, year, date, due);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest paid straight out of an FD/RD at maturity or closure - in cash, by cheque, or to a GL
|
||||
* account - never reaches a savings account, so it is tracked here instead, and any TDS it makes due
|
||||
* is taken out of the payout itself.
|
||||
*
|
||||
* <p>
|
||||
* Call just before the closing withdrawal is worked out: the deduction is added to the deposit, so a
|
||||
* withdrawal of the account balance pays out what is left. The deposit is not saved here - the
|
||||
* closing withdrawal saves it and posts the deduction's accounting along with its own. Then call
|
||||
* {@link #recordDirectPayout}.
|
||||
*
|
||||
* @param kind
|
||||
* CASH or GL, for the tracking record
|
||||
* @return what to record once the withdrawal exists, or null when the payout carried no interest
|
||||
*/
|
||||
public DirectPayout deductOnDirectPayout(final SavingsAccount deposit, final LocalDate date, final String kind) {
|
||||
if (deposit == null || deposit.getId() == null || date == null || !isTimeDeposit(deposit)) {
|
||||
return null;
|
||||
}
|
||||
// From memory: the maturity or pre-closure interest posted moments ago is not saved yet.
|
||||
BigDecimal posted = BigDecimal.ZERO;
|
||||
for (final SavingsAccountTransaction t : deposit.getTransactions()) {
|
||||
if (t.isInterestPostingAndNotReversed() && !t.isReversalTransaction()) {
|
||||
posted = posted.add(t.getAmount());
|
||||
}
|
||||
}
|
||||
final BigDecimal interest = interestInPayout(deposit.getId(), posted, deposit.getAccountBalance());
|
||||
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
final Year year = Year.of(date);
|
||||
final Long deductee = deducteeOf(deposit, year);
|
||||
if (deductee == null) {
|
||||
return null;
|
||||
}
|
||||
final Due due = dueFor(deductee, year, date, interest);
|
||||
final SavingsAccountTransaction tds = due == null ? null
|
||||
: deposit.deductTds(date, due.amount(), Map.of(due.component(), due.amount()));
|
||||
return new DirectPayout(deposit.getId(), deductee, year, date, interest, kind, due, tds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the tracking row and the deduction for a direct payout, once the closing withdrawal has been
|
||||
* saved, and marks the deduction as already posted so the caller's own journal run does not post it a
|
||||
* second time - the withdrawal posted it.
|
||||
*/
|
||||
public void recordDirectPayout(final DirectPayout payout, final SavingsAccountTransaction withdrawal,
|
||||
final Set<Long> alreadyPostedTransactionIds) {
|
||||
if (payout == null || withdrawal == null || withdrawal.getId() == null) {
|
||||
return;
|
||||
}
|
||||
final Long receiptId = insertReceipt(payout.deductee(), payout.accountId(), withdrawal.getId(), payout.accountId(), payout.kind(),
|
||||
payout.year(), payout.date(), payout.interest());
|
||||
if (payout.tds() != null && payout.tds().getId() != null) {
|
||||
recordDeduction(payout.deductee(), payout.accountId(), payout.tds().getId(), payout.accountId(), receiptId, payout.year(),
|
||||
payout.date(), payout.due());
|
||||
if (alreadyPostedTransactionIds != null) {
|
||||
alreadyPostedTransactionIds.add(payout.tds().getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What {@link #deductOnDirectPayout} worked out, held until the withdrawal is saved. */
|
||||
public record DirectPayout(Long accountId, Long deductee, Year year, LocalDate date, BigDecimal interest, String kind, Due due,
|
||||
SavingsAccountTransaction tds) {}
|
||||
|
||||
/** TDS due now, and what it was worked out on. */
|
||||
public record Due(BigDecimal amount, BigDecimal ratePercent, BigDecimal liableInterest, TaxComponent component) {}
|
||||
|
||||
/** One Indian financial year. */
|
||||
public record Year(LocalDate start, LocalDate end, String label) {
|
||||
|
||||
static Year of(final LocalDate date) {
|
||||
final int startYear = TdsSql.fyStartYear(date);
|
||||
return new Year(LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31), TdsSql.fyLabel(startYear));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTimeDeposit(final SavingsAccount account) {
|
||||
final DepositAccountType type = account.depositAccountType();
|
||||
return type != null && (type.isFixedDeposit() || type.isRecurringDeposit());
|
||||
}
|
||||
|
||||
private Long deducteeOf(final SavingsAccount deposit, final Year year) {
|
||||
return jdbcTemplate.queryForObject("SELECT " + TdsSql.deducteeOf("sa") + " FROM m_savings_account sa WHERE sa.id = ?", Long.class,
|
||||
year.start(), deposit.getId());
|
||||
}
|
||||
|
||||
private Long insertReceipt(final Long deductee, final Long receivingAccountId, final Long txnId, final Long sourceAccountId,
|
||||
final String kind, final Year year, final LocalDate date, final BigDecimal interest) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_tds_interest_receipt (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id,
|
||||
kind, fy_label, received_on, interest_amount, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, now()) RETURNING id
|
||||
""", Long.class, deductee, receivingAccountId, txnId, sourceAccountId, kind, year.label(), date, interest);
|
||||
}
|
||||
|
||||
private void recordDeduction(final Long deductee, final Long accountId, final Long txnId, final Long sourceAccountId,
|
||||
final Long receiptId, final Year year, final LocalDate date, final Due due) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_tds_deduction (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, receipt_id,
|
||||
fy_label, deducted_on, amount, rate_percentage, interest_base, created_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
|
||||
""", deductee, accountId, txnId, sourceAccountId, receiptId, year.label(), date, due.amount(), due.ratePercent(),
|
||||
due.liableInterest());
|
||||
}
|
||||
|
||||
/**
|
||||
* The TDS due for the deductee now, or null when nothing is.
|
||||
*
|
||||
* @param extraInterest
|
||||
* interest received on {@code date} that is not on record yet
|
||||
*/
|
||||
private Due dueFor(final Long deductee, final Year year, final LocalDate date, final BigDecimal extraInterest) {
|
||||
final List<Map<String, Object>> cfgRows = jdbcTemplate.queryForList("""
|
||||
SELECT threshold_general, threshold_senior, rate_pan_percentage, rate_no_pan_percentage, round_to
|
||||
FROM m_tds_fy_config WHERE fy_label = ?
|
||||
""", year.label());
|
||||
if (cfgRows.isEmpty()) {
|
||||
// Tracked, but not deducted: the TDS screens show the same gap and the administrator fixes it there.
|
||||
log.warn("No TDS configuration for FY {}; interest for client {} tracked but not taxed", year.label(), deductee);
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> cfg = cfgRows.get(0);
|
||||
|
||||
final List<LocalDate> accepted = jdbcTemplate.queryForList("""
|
||||
SELECT accepted_on FROM m_tds_declaration
|
||||
WHERE client_id = ? AND fy_label = ? AND is_revoked = false
|
||||
ORDER BY accepted_on
|
||||
""", LocalDate.class, deductee, year.label());
|
||||
final LocalDate shieldedFrom = accepted.isEmpty() ? year.end().plusDays(1) : accepted.get(0);
|
||||
|
||||
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
|
||||
SELECT COALESCE(SUM(r.interest_amount), 0) AS interest,
|
||||
COALESCE(SUM(CASE WHEN r.received_on < ? THEN r.interest_amount END), 0) AS liable
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.deductee_client_id = ? AND r.fy_label = ? AND r.received_on <= ?
|
||||
""", shieldedFrom, deductee, year.label(), date);
|
||||
final BigDecimal yearInterest = ((BigDecimal) totals.get("interest")).add(extraInterest);
|
||||
final BigDecimal liableInterest = ((BigDecimal) totals.get("liable"))
|
||||
.add(date.isBefore(shieldedFrom) ? extraInterest : BigDecimal.ZERO);
|
||||
final BigDecimal deducted = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(d.amount), 0)
|
||||
FROM m_tds_deduction d
|
||||
JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE d.deductee_client_id = ? AND d.fy_label = ?
|
||||
""", BigDecimal.class, deductee, year.label());
|
||||
|
||||
final Map<String, Object> person = jdbcTemplate.queryForMap("""
|
||||
SELECT c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan
|
||||
FROM m_client c WHERE c.id = ?
|
||||
""", deductee);
|
||||
final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth");
|
||||
final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), year.end());
|
||||
final BigDecimal threshold = (BigDecimal) cfg.get(senior ? "threshold_senior" : "threshold_general");
|
||||
if (yearInterest.compareTo(threshold) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final boolean panOk = TdsSql.isWellFormedPan((String) person.get("pan"));
|
||||
final BigDecimal ratePercent = (BigDecimal) cfg.get(panOk ? "rate_pan_percentage" : "rate_no_pan_percentage");
|
||||
final int roundTo = ((Number) cfg.get("round_to")).intValue();
|
||||
final BigDecimal liability = TdsSql.roundToMultiple(liableInterest.multiply(ratePercent).movePointLeft(2), roundTo);
|
||||
final BigDecimal already = deducted == null ? BigDecimal.ZERO : deducted;
|
||||
final BigDecimal amount = liability.subtract(already);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
log.info("TDS {} due for client {} (FY {}: interest {}, liability {}, already deducted {})", amount, deductee, year.label(),
|
||||
yearInterest, liability, already);
|
||||
return new Due(amount, ratePercent, liableInterest, tdsComponent(ratePercent));
|
||||
}
|
||||
|
||||
private BigDecimal postedFromDatabase(final Long accountId) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
|
||||
""", BigDecimal.class, accountId, TdsSql.INTEREST_POSTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* The interest inside a maturity or closure payout: interest posted on the deposit that has not been
|
||||
* received already - through the interest sweep or an earlier payout. Capped at the payout.
|
||||
*
|
||||
* <p>
|
||||
* On a premature closure the months already swept to savings stand as they were; only the interest
|
||||
* for the days since is paid out now, and only that is counted.
|
||||
*/
|
||||
private BigDecimal interestInPayout(final Long sourceAccountId, final BigDecimal posted, final BigDecimal payout) {
|
||||
final BigDecimal swept = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(att.amount), 0)
|
||||
FROM m_account_transfer_transaction att
|
||||
JOIN m_account_transfer_details d ON d.id = att.account_transfer_details_id
|
||||
WHERE d.from_savings_account_id = ? AND d.transfer_type = ? AND att.is_reversed = false
|
||||
""", BigDecimal.class, sourceAccountId, AccountTransferType.INTEREST_TRANSFER.getValue());
|
||||
final BigDecimal paidOut = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(r.interest_amount), 0)
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.source_account_id = ? AND r.kind <> 'INTEREST'
|
||||
""", BigDecimal.class, sourceAccountId);
|
||||
final BigDecimal unpaid = posted.subtract(swept).subtract(paidOut);
|
||||
if (unpaid.compareTo(BigDecimal.ZERO) <= 0 || payout == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return unpaid.min(payout);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tax component the deduction is booked through, created on first use and re-pointed whenever
|
||||
* the TDS payable account on the configuration screen changes.
|
||||
*/
|
||||
private TaxComponent tdsComponent(final BigDecimal ratePercent) {
|
||||
final List<Long> gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'",
|
||||
Long.class);
|
||||
if (gl.isEmpty() || gl.get(0) == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.tds.payable.gl.not.configured",
|
||||
"TDS is due on this interest but no TDS payable account is set. Choose one under Organization › TDS Configuration.");
|
||||
}
|
||||
final Long glId = gl.get(0);
|
||||
final List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM m_tax_component WHERE name = ? ORDER BY id", Long.class,
|
||||
TDS_COMPONENT_NAME);
|
||||
final Long id;
|
||||
boolean repointed = false;
|
||||
if (existing.isEmpty()) {
|
||||
id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_tax_component (name, percentage, credit_account_type_enum, credit_account_id, start_date,
|
||||
createdby_id, created_date, lastmodifiedby_id, lastmodified_date)
|
||||
SELECT ?, ?, 2, ?, DATE '2000-01-01', MIN(u.id), now(), MIN(u.id), now() FROM m_appuser u
|
||||
RETURNING id
|
||||
""", Long.class, TDS_COMPONENT_NAME, ratePercent, glId);
|
||||
} else {
|
||||
id = existing.get(0);
|
||||
repointed = jdbcTemplate.update("UPDATE m_tax_component SET credit_account_type_enum = 2, credit_account_id = ? WHERE id = ?"
|
||||
+ " AND (credit_account_id IS DISTINCT FROM ? OR credit_account_type_enum IS DISTINCT FROM 2)", glId, id, glId) > 0;
|
||||
}
|
||||
final TaxComponent component = taxComponentRepository.findById(id).orElseThrow();
|
||||
// Written behind JPA's back, so a copy already in this session would still credit the old account.
|
||||
if (repointed) {
|
||||
entityManager.refresh(component);
|
||||
}
|
||||
return component;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -29,6 +29,8 @@ public interface TdsInterestReadPlatformService {
|
||||
* @param financialYear
|
||||
* the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to
|
||||
* 31 Mar 2027
|
||||
* @param clientId
|
||||
* one customer only, or null for everyone in the user's scope
|
||||
*/
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear);
|
||||
List<TdsDeducteeData> retrieveForFinancialYear(int financialYear, Long clientId);
|
||||
}
|
||||
|
||||
+122
-65
@@ -27,6 +27,8 @@ 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.apache.fineract.portfolio.tds.data.TdsDeclarationData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData;
|
||||
import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData;
|
||||
@@ -35,8 +37,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reads the interest the bank actually credited, so the deduction is worked out from the ledger
|
||||
* rather than from a balance and a rate.
|
||||
* Reads the interest the bank actually credited, and the TDS it actually deducted, so the register is
|
||||
* worked out from the ledger rather than from a balance and a rate.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -44,109 +46,150 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService {
|
||||
|
||||
/**
|
||||
* Interest posting - NOT a deposit, a withdrawal, a fee or a transfer.
|
||||
* Every account, grouped under the client whose PAN carries its deduction, with what counts for TDS
|
||||
* inside the year.
|
||||
*
|
||||
* <p>
|
||||
* This single condition is what makes the figure an interest figure. A deposit is the
|
||||
* customer's own money arriving and is not income; a withdrawal and a fee are not income
|
||||
* either. Only what the bank itself credited as interest can be deducted against, so the
|
||||
* transaction type is filtered here rather than being inferred later from an amount.
|
||||
*
|
||||
* @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING
|
||||
*/
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
|
||||
/**
|
||||
* Every deposit the customer holds, with the interest credited to each inside the year.
|
||||
* On a fixed or recurring deposit, "interest" is the FD/RD interest that reached the customer's
|
||||
* savings account (m_tds_interest_receipt) and "deductions" the TDS debited there for it
|
||||
* (m_tds_deduction) - TDS is tracked and taken where the interest is received, and the deposit
|
||||
* itself is never debited. Interest still sitting in a deposit is not counted until it is paid out.
|
||||
* Rows whose savings transaction has been reversed are left out: reversed money was never received.
|
||||
*
|
||||
* <p>
|
||||
* Savings accounts are included deliberately. Section 194A does not reach them, and the caller
|
||||
* keeps their interest out of the aggregate - but the branch still has to see that the customer
|
||||
* holds one, and the row on screen shows it as exempt rather than leaving it out entirely.
|
||||
* On a savings account, "interest" is its own interest postings. The charge does not reach them, and
|
||||
* the caller keeps them out of the aggregate - but the branch still has to see them.
|
||||
*
|
||||
* <p>
|
||||
* Reversed postings are excluded: a reversed posting was never income, and leaving it in would
|
||||
* deduct tax on interest the customer never received.
|
||||
* Scoped to the signed-in user's office and the offices below it, on the deductee's office. A branch
|
||||
* login therefore receives only its own customers. Each customer still arrives with every one of
|
||||
* their deposits, wherever held, because the threshold is tested on the bank as a whole.
|
||||
*/
|
||||
private static final String SQL = """
|
||||
WITH acc AS (
|
||||
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.activatedon_date, sa.account_status,
|
||||
sa.client_id AS holder_id, %s AS deductee_id
|
||||
FROM m_savings_account sa
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
),
|
||||
lines AS (
|
||||
SELECT t.savings_account_id AS account_id, 'C' AS kind, t.transaction_date AS d, t.amount AS amt
|
||||
FROM m_savings_account_transaction t
|
||||
JOIN m_savings_account s ON s.id = t.savings_account_id AND s.deposit_type_enum = 100
|
||||
WHERE t.transaction_type_enum = ? AND t.is_reversed = false AND t.transaction_date BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT r.source_account_id, 'C', r.received_on, r.interest_amount
|
||||
FROM m_tds_interest_receipt r
|
||||
JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE r.received_on BETWEEN ? AND ?
|
||||
UNION ALL
|
||||
SELECT dd.source_account_id, 'D', dd.deducted_on, dd.amount
|
||||
FROM m_tds_deduction dd
|
||||
JOIN m_savings_account_transaction t ON t.id = dd.savings_transaction_id AND t.is_reversed = false
|
||||
WHERE dd.deducted_on BETWEEN ? AND ?
|
||||
)
|
||||
SELECT c.id AS client_id,
|
||||
c.account_no AS customer_id,
|
||||
c.display_name AS name,
|
||||
c.date_of_birth AS date_of_birth,
|
||||
k.pan_number AS pan,
|
||||
k.form15_type AS form15_type,
|
||||
(SELECT k.pan_number FROM m_client_kyc k
|
||||
WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan,
|
||||
o.name AS branch_name,
|
||||
sa.id AS account_id,
|
||||
sa.account_no AS account_no,
|
||||
sa.activatedon_date AS opened_on,
|
||||
CASE sa.deposit_type_enum
|
||||
a.id AS account_id,
|
||||
a.account_no AS account_no,
|
||||
a.activatedon_date AS opened_on,
|
||||
a.account_status AS account_status,
|
||||
a.holder_id AS holder_id,
|
||||
h.display_name AS holder_name,
|
||||
CASE a.deposit_type_enum
|
||||
WHEN 100 THEN 'depositAccountType.savingsDeposit'
|
||||
WHEN 200 THEN 'depositAccountType.fixedDeposit'
|
||||
WHEN 300 THEN 'depositAccountType.recurringDeposit'
|
||||
ELSE 'depositAccountType.unknown' END AS deposit_type_code,
|
||||
t.transaction_date AS credit_date,
|
||||
t.amount AS credit_amount
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_office o ON o.id = c.office_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_savings_account_transaction t
|
||||
ON t.savings_account_id = sa.id
|
||||
AND t.transaction_type_enum = ?
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_date >= ?
|
||||
AND t.transaction_date <= ?
|
||||
WHERE sa.client_id IS NOT NULL
|
||||
ORDER BY c.display_name, sa.account_no, t.transaction_date
|
||||
""";
|
||||
l.kind AS line_kind,
|
||||
l.d AS txn_date,
|
||||
l.amt AS txn_amount
|
||||
FROM acc a
|
||||
JOIN m_client c ON c.id = a.deductee_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
JOIN m_client h ON h.id = a.holder_id
|
||||
LEFT JOIN lines l ON l.account_id = a.id
|
||||
WHERE o.hierarchy LIKE ?
|
||||
AND (CAST(? AS BIGINT) IS NULL OR c.id = CAST(? AS BIGINT))
|
||||
ORDER BY c.display_name, a.account_no, l.d
|
||||
""".formatted(TdsSql.deducteeOf("sa"));
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
|
||||
@Override
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear) {
|
||||
public List<TdsDeducteeData> retrieveForFinancialYear(final int financialYear, final Long onlyClientId) {
|
||||
// The Indian financial year, 1 April to 31 March.
|
||||
final LocalDate from = LocalDate.of(financialYear, 4, 1);
|
||||
final LocalDate to = LocalDate.of(financialYear + 1, 3, 31);
|
||||
final java.sql.Date f = java.sql.Date.valueOf(from);
|
||||
final java.sql.Date t = java.sql.Date.valueOf(to);
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
||||
|
||||
final Map<Long, Deductee> byClient = new LinkedHashMap<>();
|
||||
|
||||
jdbcTemplate.query(SQL, rs -> {
|
||||
final long clientId = rs.getLong("client_id");
|
||||
final Deductee deductee = byClient.computeIfAbsent(clientId,
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"),
|
||||
readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"),
|
||||
readString(rs, "form15_type")));
|
||||
id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), readString(rs, "pan"),
|
||||
readDate(rs, "date_of_birth"), readString(rs, "branch_name")));
|
||||
|
||||
final long accountId = rs.getLong("account_id");
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id,
|
||||
readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on")));
|
||||
final Account account = deductee.accounts.computeIfAbsent(accountId,
|
||||
id -> new Account(id, readString(rs, "account_no"), readString(rs, "deposit_type_code"), readLong(rs, "holder_id"),
|
||||
readString(rs, "holder_name"), readString(rs, "account_status"), readDate(rs, "opened_on")));
|
||||
|
||||
// Null where the outer join found no posting inside the year: the account is still
|
||||
// listed, it simply earned nothing in the window.
|
||||
final java.sql.Date creditDate = rs.getDate("credit_date");
|
||||
if (creditDate != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("credit_amount");
|
||||
account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo));
|
||||
// Null where the outer join found nothing inside the year: the account is still listed, it
|
||||
// simply earned nothing in the window.
|
||||
final java.sql.Date date = rs.getDate("txn_date");
|
||||
if (date != null) {
|
||||
final BigDecimal amount = rs.getBigDecimal("txn_amount");
|
||||
final TdsInterestCreditData line = new TdsInterestCreditData(date.toLocalDate(), amount, account.accountNo);
|
||||
if ("D".equals(rs.getString("line_kind"))) {
|
||||
account.deductions.add(line);
|
||||
} else {
|
||||
account.credits.add(line);
|
||||
}
|
||||
}
|
||||
}, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to));
|
||||
}, f, TdsSql.INTEREST_POSTING, f, t, f, t, f, t, hierarchy, onlyClientId, onlyClientId);
|
||||
|
||||
final Map<Long, List<TdsDeclarationData>> declarations = declarationsFor(TdsSql.fyLabel(financialYear));
|
||||
|
||||
final List<TdsDeducteeData> result = new ArrayList<>();
|
||||
for (final Deductee d : byClient.values()) {
|
||||
final List<TdsAccountData> accounts = new ArrayList<>();
|
||||
for (final Account a : d.accounts.values()) {
|
||||
// accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses
|
||||
// to decide whose PAN carries the deduction. Fineract does not model it on the
|
||||
// savings account, so it is left null and the caller falls back to the account
|
||||
// holder rather than inventing a joint holder who may not exist.
|
||||
accounts.add(
|
||||
new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits));
|
||||
accounts.add(new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, d.clientId, a.holderId, a.holderName, a.accountStatus,
|
||||
a.openedOn, a.credits, a.deductions));
|
||||
}
|
||||
result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName,
|
||||
d.form15Type, accounts));
|
||||
declarations.getOrDefault(d.clientId, List.of()), accounts));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<Long, List<TdsDeclarationData>> declarationsFor(final String fy) {
|
||||
final Map<Long, List<TdsDeclarationData>> out = new LinkedHashMap<>();
|
||||
jdbcTemplate.query("""
|
||||
SELECT id, client_id, fy_label, form_type, uin, accepted_on, estimated_income
|
||||
FROM m_tds_declaration
|
||||
WHERE fy_label = ? AND is_revoked = false
|
||||
ORDER BY accepted_on
|
||||
""", rs -> {
|
||||
final TdsDeclarationData d = new TdsDeclarationData(rs.getLong("id"), rs.getLong("client_id"), rs.getString("fy_label"),
|
||||
rs.getString("form_type"), rs.getString("uin"), readDate(rs, "accepted_on"), rs.getBigDecimal("estimated_income"),
|
||||
false, null, null);
|
||||
out.computeIfAbsent(d.clientId(), k -> new ArrayList<>()).add(d);
|
||||
}, fy);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String readString(final ResultSet rs, final String column) {
|
||||
try {
|
||||
return rs.getString(column);
|
||||
@@ -155,6 +198,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
}
|
||||
}
|
||||
|
||||
private static Long readLong(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final long v = rs.getLong(column);
|
||||
return rs.wasNull() ? null : v;
|
||||
} catch (final SQLException e) {
|
||||
throw new IllegalStateException("Could not read column " + column, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate readDate(final ResultSet rs, final String column) {
|
||||
try {
|
||||
final java.sql.Date value = rs.getDate(column);
|
||||
@@ -173,18 +225,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
private final String pan;
|
||||
private final LocalDate dateOfBirth;
|
||||
private final String branchName;
|
||||
private final String form15Type;
|
||||
private final Map<Long, Account> accounts = new LinkedHashMap<>();
|
||||
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth,
|
||||
String branchName, String form15Type) {
|
||||
private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, String branchName) {
|
||||
this.clientId = clientId;
|
||||
this.customerId = customerId;
|
||||
this.name = name;
|
||||
this.pan = pan;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
this.branchName = branchName;
|
||||
this.form15Type = form15Type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,13 +242,21 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo
|
||||
private final Long id;
|
||||
private final String accountNo;
|
||||
private final String depositTypeCode;
|
||||
private final Long holderId;
|
||||
private final String holderName;
|
||||
private final String accountStatus;
|
||||
private final LocalDate openedOn;
|
||||
private final List<TdsInterestCreditData> credits = new ArrayList<>();
|
||||
private final List<TdsInterestCreditData> deductions = new ArrayList<>();
|
||||
|
||||
private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) {
|
||||
private Account(Long id, String accountNo, String depositTypeCode, Long holderId, String holderName, String accountStatus,
|
||||
LocalDate openedOn) {
|
||||
this.id = id;
|
||||
this.accountNo = accountNo;
|
||||
this.depositTypeCode = depositTypeCode;
|
||||
this.holderId = holderId;
|
||||
this.holderName = holderName;
|
||||
this.accountStatus = accountStatus;
|
||||
this.openedOn = openedOn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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.tds.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* The TDS rules both the deduction and the report must agree on, kept in one place so the passbook
|
||||
* and the register cannot drift apart.
|
||||
*/
|
||||
public final class TdsSql {
|
||||
|
||||
public static final int INTEREST_POSTING = 3;
|
||||
public static final int WITHHOLD_TAX = 18;
|
||||
public static final int FIXED_DEPOSIT = 200;
|
||||
public static final int RECURRING_DEPOSIT = 300;
|
||||
|
||||
/** A well-formed PAN with a recognised holder type - the same test the screens apply. */
|
||||
private static final Pattern PAN = Pattern.compile("^[A-Z]{5}[0-9]{4}[A-Z]$");
|
||||
private static final String PAN_HOLDER_TYPES = "ABCFGHJLPT";
|
||||
|
||||
private TdsSql() {}
|
||||
|
||||
/**
|
||||
* The client whose PAN carries the deduction for an account, as SQL over the alias given.
|
||||
*
|
||||
* <ul>
|
||||
* <li>A minor's account follows the guardian - the interest is clubbed into the guardian's income.
|
||||
* Minor means under 18 on the first day of the year, so one year has one deductee.</li>
|
||||
* <li>A joint account follows the holder flagged primary - one deposit, one deductee.</li>
|
||||
* <li>Anything else follows the account holder.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Takes one bind parameter: the financial year's first day.
|
||||
*/
|
||||
public static String deducteeOf(final String sa) {
|
||||
return """
|
||||
(CASE
|
||||
WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'MINOR' AND %1$s.guardian_client_id IS NOT NULL
|
||||
AND (%1$s.minor_date_of_birth IS NULL
|
||||
OR %1$s.minor_date_of_birth > CAST(? AS DATE) - INTERVAL '18 years')
|
||||
THEN %1$s.guardian_client_id
|
||||
WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'JOINT'
|
||||
THEN COALESCE((SELECT MIN(j.client_id) FROM m_savings_account_joint_holders j
|
||||
WHERE j.savings_account_id = %1$s.id AND j.is_primary = true), %1$s.client_id)
|
||||
ELSE %1$s.client_id
|
||||
END)""".formatted(sa);
|
||||
}
|
||||
|
||||
public static boolean isWellFormedPan(final String pan) {
|
||||
final String p = pan == null ? "" : pan.trim().toUpperCase();
|
||||
return PAN.matcher(p).matches() && PAN_HOLDER_TYPES.indexOf(p.charAt(3)) >= 0;
|
||||
}
|
||||
|
||||
/** Age reached by the last day of the year: 60 at any point in it makes a senior citizen. */
|
||||
public static boolean isSeniorInFy(final LocalDate dateOfBirth, final LocalDate fyEnd) {
|
||||
return dateOfBirth != null && !dateOfBirth.plusYears(60).isAfter(fyEnd);
|
||||
}
|
||||
|
||||
public static int fyStartYear(final LocalDate date) {
|
||||
return date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||
}
|
||||
|
||||
/** '2026-27' for any date from 1 Apr 2026 to 31 Mar 2027. */
|
||||
public static String fyLabel(final int startYear) {
|
||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||
}
|
||||
|
||||
public static BigDecimal roundToMultiple(final BigDecimal value, final int to) {
|
||||
if (to <= 1) {
|
||||
return value.setScale(0, RoundingMode.HALF_UP);
|
||||
}
|
||||
final BigDecimal step = BigDecimal.valueOf(to);
|
||||
return value.divide(step, 0, RoundingMode.HALF_UP).multiply(step);
|
||||
}
|
||||
}
|
||||
+8
@@ -321,6 +321,14 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
||||
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
|
||||
|
||||
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
|
||||
if (parsedElement.isJsonObject()) {
|
||||
// the stored payload keeps a "[PROTECTED]" placeholder for the password rather than
|
||||
// the real value; the actual new password is applied below via request.getPasswordHash(),
|
||||
// so these keys must not reach AppUser.update() or it will try to re-encode the placeholder
|
||||
final JsonObject payloadObject = parsedElement.getAsJsonObject();
|
||||
payloadObject.remove("password");
|
||||
payloadObject.remove("repeatPassword");
|
||||
}
|
||||
final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper);
|
||||
|
||||
final Map<String, Object> changes = userToUpdate.update(replay, null, null);
|
||||
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
|
||||
+1
@@ -32,4 +32,5 @@
|
||||
<include file="parts/0009_set_and_encrypt_ro_if_not_exists.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0010_set_datetime_precision.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0011_create_api_client.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0012_seed_default_platform_api_client.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Seeds ONE fixed, well-known platform-operator API key on every deployment, so
|
||||
#/platform-admin/menu-access works out of the box the first time the app is stood up,
|
||||
without anyone needing shell/DB access to mint the first key by hand. It is deliberately
|
||||
the SAME raw value on every deployment/tenant (requested behaviour) rather than a random
|
||||
one generated per install: this is a deliberate trade-off, since this value is visible to
|
||||
anyone who can read this source file, exactly like a default admin password shipped in a
|
||||
seed script. It is a bootstrap convenience, not a secret. Rotate it for any deployment that
|
||||
is not purely local/dev: insert a new row with a real random key's hash (see
|
||||
ApiClientCredentialService's hashing) and either deactivate or DELETE this row. POST
|
||||
/v1/apiclients is intentionally disabled (see ApiClientAdminApiResource), so rotation is a
|
||||
direct m_api_client write, same as this seed.
|
||||
|
||||
Raw key (only shown here, since it is fixed and not a secret to rotate away from):
|
||||
cbsx_default_platform_operator_key_2026
|
||||
api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase, matching
|
||||
ApiClientCredentialService#sha256Hex. -->
|
||||
<changeSet id="0012_seed_default_platform_api_client" author="cbs-nitesh">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_api_client"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_api_client
|
||||
WHERE api_key_hash = '9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<insert tableName="m_api_client">
|
||||
<column name="client_name" value="Default Platform Operator (rotate before real production use)"/>
|
||||
<column name="api_key_hash" value="9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd"/>
|
||||
<column name="api_key_prefix" value="cbsx_default"/>
|
||||
<column name="allowed_ips"/>
|
||||
<column name="created_by"/>
|
||||
<column name="created_by_tenant" value="ALL"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+17
-4
@@ -144,8 +144,11 @@
|
||||
<include file="parts/0582_form_a_saved_report.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0583_save_rbi_form_a_return_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0584_form9_slr_assets.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0585_form1_statutory_heads.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0586_form9_return_heads.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0587_form9_default_mappings.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0588_form9_map_dividend_round_off.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
|
||||
<<<<<<< HEAD
|
||||
<!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own
|
||||
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
|
||||
never appeared on either side of this merge as a real conflict - the collision only became
|
||||
@@ -156,7 +159,17 @@
|
||||
<include file="parts/0584_add_manage_survey_permissions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0585_add_loan_cob_catchup_permission.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||
=======
|
||||
<include file="parts/0585_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
|
||||
>>>>>>> dev-latest
|
||||
<include file="parts/0587_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0591_cashier_allocation_time_and_deposit_counter_office.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0592_enable_teller_cashier_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0592_tds_declarations_and_challans.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0593_tds_interest_receipts_and_deductions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0594_recurring_deposit_product_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0595_recurring_deposit_lien_percentage.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0596_recurring_deposit_missed_installment_penalty.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0597_enable_recurring_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||
|
||||
<!--
|
||||
The line items of the statutory Form 1 a co-operative bank actually files - Banking
|
||||
Regulation (Co-operative Societies) Rules 1966, Form 1 under Rule 5, sections 18(1) and
|
||||
24(3) - so each can be mapped to this bank's own GL accounts.
|
||||
|
||||
The return already modelled had one liabilities figure and one holdings figure. The filed
|
||||
form is structured quite differently, and the difference is not cosmetic:
|
||||
|
||||
- Liabilities and assets are each split between the BANKING SYSTEM (part I and part III)
|
||||
and everyone else (part II). The split matters because the two are netted against each
|
||||
other to arrive at the figure the ratios are charged on.
|
||||
|
||||
- Balances with other banks are split again between current accounts (VI) and every other
|
||||
kind of balance (VII), because only the current-account ones count toward the cash
|
||||
reserve, while the rest count toward the liquidity requirement.
|
||||
|
||||
- Part VI and VII name the counterparties the rules name: the Reserve Bank, the State
|
||||
Co-operative Bank and the District Central Co-operative Bank. A single "balances with
|
||||
other banks" head cannot report these separately, and the form requires it.
|
||||
|
||||
Heads are seeded as MEMO. Each one's treatment on the return - added into the base, netted
|
||||
off it, or counted toward a requirement - is a regulatory question this migration is not
|
||||
the place to settle, and a seeded guess would be indistinguishable from a decision the bank
|
||||
made. An administrator changes the usage as each head's role is established.
|
||||
|
||||
No head is mapped to an account: the right account differs per bank, and Basoda's chart is
|
||||
not this tenant's.
|
||||
|
||||
Every changeSet is guarded so this migration is a safe no-op where already applied.
|
||||
-->
|
||||
|
||||
<changeSet author="fineract" id="0585-1-seed-form1-banking-system-liabilities">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_I_%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<!--
|
||||
Part I: liabilities in India TO the banking system. Reported apart from liabilities to
|
||||
everyone else because the banking system's claims on the bank are netted against the
|
||||
bank's claims on it (part III) before the ratios are charged.
|
||||
-->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_I_A_I_SBI_CURRENT"/>
|
||||
<column name="label" value="I.a.i Current account credit balances of SBI and its subsidiaries"/>
|
||||
<column name="description" value="Form 1 Part I.a(i). Credit balances in current accounts that the State Bank of India, its subsidiary banks and the corresponding new banks maintain WITH this bank. A liability to the banking system, not to a depositor."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="500"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_I_A_II_OTHER_DEMAND_BS"/>
|
||||
<column name="label" value="I.a.ii Other demand liabilities to the banking system"/>
|
||||
<column name="description" value="Form 1 Part I.a(ii). Demand liabilities owed to banks other than those named in I.a(i)."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="501"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_I_B_TIME_BS"/>
|
||||
<column name="label" value="I.b Time liabilities to the banking system"/>
|
||||
<column name="description" value="Form 1 Part I.b. Time liabilities owed to the banking system."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="502"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0585-2-seed-form1-banking-system-assets">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_III_%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<!--
|
||||
Part III: assets in India WITH the banking system. Netted against part I to give the net
|
||||
position, which is what the form's item IV reports.
|
||||
-->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_III_A_SBI_CURRENT"/>
|
||||
<column name="label" value="III.a Current account balances with SBI and its subsidiaries"/>
|
||||
<column name="description" value="Form 1 Part III.a. Credit balances THIS bank holds in current accounts with the State Bank of India, its subsidiary banks and the corresponding new banks. Item VIII reports the excess of this over I.a(i)."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="510"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_III_B_I_OTHER_BALANCES"/>
|
||||
<column name="label" value="III.b.i Balances with the banking system in all other accounts"/>
|
||||
<column name="description" value="Form 1 Part III.b(i). Balances held with the banking system in accounts other than those in III.a."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="511"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_III_B_II_MONEY_AT_CALL_BS"/>
|
||||
<column name="label" value="III.b.ii Money at call and short notice with the banking system"/>
|
||||
<column name="description" value="Form 1 Part III.b(ii). Funds lent to the banking system at call or short notice."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="512"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_III_B_IV_ADVANCES_BS"/>
|
||||
<column name="label" value="III.b.iv Advances and other assets with the banking system"/>
|
||||
<column name="description" value="Form 1 Part III.b(iv). Advances to the banking system and any other assets held with it."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="513"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0585-3-seed-form1-current-account-balances">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VI_%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<!--
|
||||
Part VI: balances in CURRENT accounts, by counterparty. These are what the cash reserve
|
||||
is actually maintained in, alongside cash on hand, so the form names each counterparty
|
||||
separately rather than reporting one combined figure.
|
||||
|
||||
The Reserve Bank head duplicates nothing: an existing custom head of the same name was
|
||||
added ad hoc, whereas this one carries the form's own item number so the return can find
|
||||
it by position rather than by what somebody typed.
|
||||
-->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_VI_A_RBI_CURRENT"/>
|
||||
<column name="label" value="VI.a Balance in current account with the Reserve Bank of India"/>
|
||||
<column name="description" value="Form 1 Part VI.a. Counts toward the cash reserve actually maintained (item X = V + VI + VIII)."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="520"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_VI_B_STATE_COOP_CURRENT"/>
|
||||
<column name="label" value="VI.b Balance in current account with the State Co-operative Bank"/>
|
||||
<column name="description" value="Form 1 Part VI.b. Current account with the State Co-operative Bank of the state concerned. Counts toward the cash reserve actually maintained."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="521"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_VI_C_DCCB_CURRENT"/>
|
||||
<column name="label" value="VI.c Balance in current account with the District Central Co-operative Bank"/>
|
||||
<column name="description" value="Form 1 Part VI.c. Current account with the District Central Co-operative Bank of the district concerned. Counts toward the cash reserve actually maintained."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="522"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0585-4-seed-form1-other-balances">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VII_%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<!--
|
||||
Part VII: balances of ALL OTHER types - term deposits with those banks, and government
|
||||
securities. Kept apart from part VI because the two do different work: VI counts toward
|
||||
the cash reserve, VII toward the liquidity requirement. Collapsing them would let a term
|
||||
deposit satisfy the cash reserve, which it cannot.
|
||||
-->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_VII_A_STATE_COOP_OTHER"/>
|
||||
<column name="label" value="VII.a Balances of all other types with the State Co-operative Bank"/>
|
||||
<column name="description" value="Form 1 Part VII.a. Deposits and other balances with the State Co-operative Bank, other than the current account in VI.b."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="530"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_VII_B_DCCB_OTHER"/>
|
||||
<column name="label" value="VII.b Balances of all other types with the District Central Co-operative Bank"/>
|
||||
<column name="description" value="Form 1 Part VII.b. Deposits and other balances with the District Central Co-operative Bank, other than the current account in VI.c."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="531"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM1_VII_C_GOVT_SECURITIES"/>
|
||||
<column name="label" value="VII.c Investment in Government securities"/>
|
||||
<column name="description" value="Form 1 Part VII.c. Government securities held. The principal component of the assets maintained against the liquidity requirement (item XII)."/>
|
||||
<column name="role_group" value="RESERVE"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="532"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+908
@@ -0,0 +1,908 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||
|
||||
<!--
|
||||
Form IX - Return on Assets and Liabilities, return code R408.
|
||||
|
||||
A DIFFERENT return from Form 9 (SLR) despite the name. Form 9/SLR reports one ratio:
|
||||
liabilities, the liquid assets held against them, and whether the bank complied. Form IX
|
||||
reports the bank's WHOLE balance sheet - every class of liability and every class of asset -
|
||||
and its two sides must agree. Filed monthly, as at the month end.
|
||||
|
||||
Reported in RUPEES THOUSANDS, not rupees. The conversion happens once, on the finished
|
||||
figure, never per account: the filed return this was built from rounded each line
|
||||
independently and came out with its two sides 2 thousand apart, then printed a
|
||||
"DIFF ADJUSTED" line rather than saying so.
|
||||
|
||||
Every head here is seeded UNMAPPED, in role_group FORM9 so it sits under its own filter on
|
||||
the configuration screen and cannot be confused with a Form 1 head. A head nobody maps
|
||||
reports nil, which on this return is a real answer - a bank with no borrowings from the
|
||||
EXIM Bank files that line empty.
|
||||
|
||||
role_usage on this return says which side of the balance sheet a head belongs to and how it
|
||||
folds into the totals:
|
||||
LIABILITY - a liability head; enters Total Liabilities
|
||||
ASSET - an asset head; enters Total Assets
|
||||
MEMO - stated on the face of the form but entering no total (the "of which" splits)
|
||||
-->
|
||||
|
||||
<changeSet author="fineract" id="0586-0-widen-role-usage-for-asset">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
</preConditions>
|
||||
<!--
|
||||
0575 constrained role_usage to four values and 0584 widened it to five. ASSET is a
|
||||
sixth, and it has to be admitted BEFORE any row below can carry it - otherwise every
|
||||
asset insert is rejected by the constraint rather than by anything meaningful, and the
|
||||
migration aborts part-way with half the heads seeded.
|
||||
|
||||
ASSET is not ASSET_NET. That one is netted OFF the liability base to arrive at NDTL;
|
||||
this one is added INTO Total Assets on a return that has no such base. Reusing ASSET_NET
|
||||
here would have subtracted Form IX's assets from Form 1's liabilities.
|
||||
|
||||
Dropped and recreated rather than altered: a CHECK constraint has no in-place edit, and
|
||||
recreating it keeps the guard rather than quietly leaving the column unconstrained.
|
||||
-->
|
||||
<sql>
|
||||
ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage;
|
||||
ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage
|
||||
CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'ASSET', 'MEMO'));
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- ══════════════════ A. LIABILITIES ══════════════════ -->
|
||||
|
||||
<changeSet author="fineract" id="0586-1-seed-form9-capital-and-reserves">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE role_group = 'FORM9'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
|
||||
<!-- 1. Paid Up capital -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L1_A_CAPITAL_INDIVIDUALS"/>
|
||||
<column name="label" value="1(a) Paid up capital - Individuals"/>
|
||||
<column name="description" value="Share capital subscribed by individual members. In a co-operative bank this is usually the whole of the paid up capital, and it is the linking share and shareholder accounts together."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1010"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L1_B_CAPITAL_COOP_SOCIETIES"/>
|
||||
<column name="label" value="1(b) Paid up capital - Co-operative Societies"/>
|
||||
<column name="description" value="Share capital subscribed by co-operative societies."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1011"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L1_C_CAPITAL_STATE_GOVT"/>
|
||||
<column name="label" value="1(c) Paid up capital - State Government"/>
|
||||
<column name="description" value="Share capital subscribed by the State Government, including a co-operative bank holding on the State's behalf."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1012"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L1_D_CAPITAL_OTHERS"/>
|
||||
<column name="label" value="1(d) Paid up capital - Others"/>
|
||||
<column name="description" value="Share capital from any subscriber not covered by 1(a) to 1(c)."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1013"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 2. Reserve fund etc. -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L2_A_STATUTORY_RESERVE"/>
|
||||
<column name="label" value="2(a) Statutory Reserves"/>
|
||||
<column name="description" value="The reserve fund a co-operative bank is required by statute to build out of its profits."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1020"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L2_B_AGRI_CREDIT_FUND"/>
|
||||
<column name="label" value="2(b) Agricultural Credit (Stabilisation) Fund"/>
|
||||
<column name="description" value="Fund held against agricultural credit losses."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1021"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L2_C_DIVIDEND_EQUALISATION"/>
|
||||
<column name="label" value="2(c) Dividend Equalisation Fund"/>
|
||||
<column name="description" value="Fund set aside to even out dividend across years."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1022"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L2_D_SPECIAL_BAD_DEBT"/>
|
||||
<column name="label" value="2(d) Special Bad Debt Reserve"/>
|
||||
<column name="description" value="Reserve earmarked against specific bad debts, where the bank keeps one apart from its general bad and doubtful debt reserve."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1023"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L2_E_BAD_DOUBTFUL_RESERVE"/>
|
||||
<column name="label" value="2(e) Bad and Doubtful Debt Reserve"/>
|
||||
<column name="description" value="The bad and doubtful debt reserve TOGETHER WITH the provision held against non-performing assets. Both are mapped to this one head: a bank that maps only its bad-debt reserve here understates the line by the whole of its NPA provision, which on a filed return is the difference between a reserve that covers its advances and one that does not."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1024"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L2_F_INVESTMENT_DEPRECIATION"/>
|
||||
<column name="label" value="2(f) Investment Depreciation Reserve"/>
|
||||
<column name="description" value="Reserve held against depreciation in the value of the bank's investments."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1025"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L2_G_OTHER_FUNDS"/>
|
||||
<column name="label" value="2(g) Other Funds and Reserves"/>
|
||||
<column name="description" value="Every other fund the bank carries - building, staff welfare, development, charitable, computer, training, vehicle, contingent provision and the rest. Map each such account here: the return prints one row per account under its own name, which is the 'Other Funds and Reserves - Specify' sheet of the RBI workbook."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1026"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 3. Principal/Subsidiary State Partnership Fund -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L3_A_PARTNERSHIP_CCB"/>
|
||||
<column name="label" value="3(a) State Partnership Fund - share capital of Central Co-operative Banks"/>
|
||||
<column name="description" value="Principal or subsidiary State partnership fund held for the share capital of central co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1030"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L3_B_PARTNERSHIP_PACS"/>
|
||||
<column name="label" value="3(b) State Partnership Fund - share capital of Primary Agricultural Credit Societies"/>
|
||||
<column name="description" value="State partnership fund held for the share capital of primary agricultural credit societies."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1031"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L3_C_PARTNERSHIP_OTHER"/>
|
||||
<column name="label" value="3(c) State Partnership Fund - share capital of Other Societies"/>
|
||||
<column name="description" value="State partnership fund held for the share capital of any other society."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1032"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0586-2-seed-form9-demand-liabilities">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L4_%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
|
||||
<!-- 4(a) Current deposits -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_A1_CURRENT_CCB"/>
|
||||
<column name="label" value="4(a)(i) Current deposits from Central Co-operative Banks"/>
|
||||
<column name="description" value="Current account deposits held for central co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1040"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_A2_CURRENT_PCB"/>
|
||||
<column name="label" value="4(a)(ii) Current deposits from Primary Co-operative Banks"/>
|
||||
<column name="description" value="Current account deposits held for primary co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1041"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_A3_CURRENT_SOCIETIES"/>
|
||||
<column name="label" value="4(a)(iii) Current deposits from Other Societies"/>
|
||||
<column name="description" value="Current account deposits held for societies other than co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1042"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_A4_CURRENT_INDIVIDUALS"/>
|
||||
<column name="label" value="4(a)(iv) Current deposits from Individuals, Firms, Associations etc."/>
|
||||
<column name="description" value="Current account deposits of ordinary customers. In most urban co-operative banks this is the whole of the current deposit line."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1043"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_A5_CURRENT_RESERVE_FUND"/>
|
||||
<column name="label" value="4(a)(v) Current deposits - Reserve fund deposits of societies in the bank's area"/>
|
||||
<column name="description" value="Reserve fund deposits that societies within the bank's area of operation are required to keep with it."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1044"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 4(b) Savings deposits - DEMAND portion -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS"/>
|
||||
<column name="label" value="4(b)(iv) Savings deposits (demand portion) from Individuals, Firms, Associations etc."/>
|
||||
<column name="description" value="The DEMAND share of ordinary savings balances. Map the savings account here with the demand percentage as its portion, and the same account under 5(b)(iv) with the remainder: the two shares together must be the whole balance. The split is the bank's own, settled with its auditors, and is NOT the split used on Form 1 - that return charges its ratios on a differently drawn base."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1048"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_B_SAVINGS_DEMAND_OTHER"/>
|
||||
<column name="label" value="4(b)(i-iii,v) Savings deposits (demand portion) from Co-operative Banks and Societies"/>
|
||||
<column name="description" value="The demand share of savings balances held for central co-operative banks, primary co-operative banks, other societies, and societies' reserve fund deposits."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1049"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 4(c) Borrowings from banks - demand -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_C_BORROWINGS_DEMAND"/>
|
||||
<column name="label" value="4(c) Borrowings from banks (demand)"/>
|
||||
<column name="description" value="Demand borrowings from the Reserve Bank, State Bank, Development Bank, National Bank, EXIM Bank, National Co-operative Development Corporation, State Co-operative Bank, Central Co-operative Bank or any other lender. Map each borrowing account here; the return prints one row per account under its lender's name."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1050"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 4(d) Other demand liabilities -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L4_D_OTHER_DEMAND"/>
|
||||
<column name="label" value="4(d) Other Demand Liabilities"/>
|
||||
<column name="description" value="Everything else the bank owes on demand - dividend payable, share capital payable, sundry creditors, suspense, staff dues, tax deducted at source, GST payable, credit balances in loan accounts, bills payable, provisions for income tax and audit fees. Map each such account here; the return prints one row per account, which is the 'Other Demand Liabilities - Specify' sheet of the RBI workbook."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1051"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0586-3-seed-form9-time-liabilities">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L5_%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
|
||||
<!-- 5(a) Fixed deposits -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_A1_FD_CCB"/>
|
||||
<column name="label" value="5(a)(i) Fixed deposits from Central Co-operative Banks"/>
|
||||
<column name="description" value="Fixed deposits held for central co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1060"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_A2_FD_PCB"/>
|
||||
<column name="label" value="5(a)(ii) Fixed deposits from Primary Co-operative Banks"/>
|
||||
<column name="description" value="Fixed deposits held for primary co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1061"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_A3_FD_SOCIETIES"/>
|
||||
<column name="label" value="5(a)(iii) Fixed deposits from Other Societies"/>
|
||||
<column name="description" value="Fixed deposits held for societies other than co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1062"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_A4_FD_INDIVIDUALS"/>
|
||||
<column name="label" value="5(a)(iv) Fixed deposits from Individuals, Firms, Associations etc."/>
|
||||
<column name="description" value="Fixed deposits of ordinary customers, whatever the scheme they were taken under - single, double, or any named deposit product that is a term deposit."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1063"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_A5_FD_RESERVE_FUND"/>
|
||||
<column name="label" value="5(a)(v) Fixed deposits - Reserve fund deposits of societies in the bank's area"/>
|
||||
<column name="description" value="Reserve fund deposits of societies within the bank's area of operation, held as term deposits."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1064"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- Of which: the maturity split the form asks for on 5(a) -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_A_FD_UPTO_1YR"/>
|
||||
<column name="label" value="Of 5(a): Fixed deposits up to 1 year"/>
|
||||
<column name="description" value="The share of fixed deposits maturing within a year. Stated on the face of the form and entering no total - it is a split of 5(a), not an addition to it."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="1065"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_A_FD_ABOVE_1YR"/>
|
||||
<column name="label" value="Of 5(a): Fixed deposits above 1 year"/>
|
||||
<column name="description" value="The share of fixed deposits maturing after a year. A split of 5(a), entering no total."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="1066"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 5(b) Savings deposits - TIME portion -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS"/>
|
||||
<column name="label" value="5(b)(iv) Savings deposits (time portion) from Individuals, Firms, Associations etc."/>
|
||||
<column name="description" value="The TIME share of ordinary savings balances - the remainder of the account after the demand share taken at 4(b)(iv). The two together must be the whole balance, or the return reports less in savings than the bank holds."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1070"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_B_SAVINGS_TIME_OTHER"/>
|
||||
<column name="label" value="5(b)(i-iii,v) Savings deposits (time portion) from Co-operative Banks and Societies"/>
|
||||
<column name="description" value="The time share of savings balances held for central co-operative banks, primary co-operative banks, other societies, and societies' reserve fund deposits."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1071"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 5(c) Borrowings from banks - time -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_C_BORROWINGS_TIME"/>
|
||||
<column name="label" value="5(c) Borrowings from banks (time)"/>
|
||||
<column name="description" value="Term borrowings from the Reserve Bank, State Bank, Development Bank, National Bank, EXIM Bank, National Co-operative Development Corporation, State Co-operative Bank, Central Co-operative Bank or any other lender."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1072"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 5(d) Cash certificates, recurring deposits -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_D_CASH_CERT_RD"/>
|
||||
<column name="label" value="5(d) Cash Certificates, Recurring Deposits etc."/>
|
||||
<column name="description" value="Recurring deposits, cash certificates and the bank's named small-savings schemes - mini deposits, monthly income schemes and the like. Every term product that is not a fixed deposit belongs here."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1073"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 5(e) Other time liabilities -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L5_E_OTHER_TIME"/>
|
||||
<column name="label" value="5(e) Other Time Liabilities"/>
|
||||
<column name="description" value="Time liabilities not covered above, chiefly interest accrued on deposits but not yet paid. Map each such account here; the return prints one row per account, which is the 'Other Time Liabilities - Specify' sheet of the RBI workbook."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1074"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 6-9 -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L6_BRANCH_ADJUSTMENTS"/>
|
||||
<column name="label" value="6. Branch Adjustments (liabilities)"/>
|
||||
<column name="description" value="Net inter-branch balances standing to the credit side at the month end."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1080"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L7_OVERDUE_INTEREST_RESERVE"/>
|
||||
<column name="label" value="7. Overdue Interest Reserve"/>
|
||||
<column name="description" value="Interest charged on overdue advances and held in reserve rather than taken to income. It has a matching entry in Interest Receivable on the asset side, so leaving one mapped and the other not breaks the balance by exactly this figure."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1081"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_L9_BALANCE_OF_PROFIT"/>
|
||||
<column name="label" value="9. Balance of Profit"/>
|
||||
<column name="description" value="Profit for the year carried on the balance sheet. A loss is reported on the asset side at item 12 instead, never as a negative here."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="LIABILITY"/>
|
||||
<column name="position" valueNumeric="1082"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!-- ══════════════════ B. ASSETS ══════════════════ -->
|
||||
|
||||
<changeSet author="fineract" id="0586-4-seed-form9-cash-and-banks">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A1_CASH_IN_HAND"/>
|
||||
<column name="label" value="1. Cash in Hand"/>
|
||||
<column name="description" value="Cash the bank itself holds - vault and teller balances at the month end."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2010"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 2. Balance with banks - CURRENT -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A2_A_CUR_RBI"/>
|
||||
<column name="label" value="2(a) Balance with the Reserve Bank of India (current)"/>
|
||||
<column name="description" value="Current account balance with the Reserve Bank of India."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2020"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A2_B_CUR_SBI"/>
|
||||
<column name="label" value="2(b) Balance with the State Bank of India and its subsidiaries (current)"/>
|
||||
<column name="description" value="Current account balances with the State Bank of India, its subsidiary banks and the corresponding new banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2021"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A2_C_CUR_BANKING_SYSTEM"/>
|
||||
<column name="label" value="2(c) Balance with the Banking System (current)"/>
|
||||
<column name="description" value="Current account balances with commercial banks - the private and public sector banks the bank keeps operating, clearing, ATM, UPI, IMPS and RTGS accounts with. Not co-operative banks, which have their own lines below."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2022"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A2_D_CUR_STATE_COOP"/>
|
||||
<column name="label" value="2(d) Balance with the State Co-operative Bank of the State concerned (current)"/>
|
||||
<column name="description" value="Current account balance with the apex co-operative bank of the State."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2023"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A2_E_CUR_DCCB"/>
|
||||
<column name="label" value="2(e) Balance with the Central Co-operative Bank of the District concerned (current)"/>
|
||||
<column name="description" value="Current account balance with the district central co-operative bank."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2024"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A2_F_CUR_OTHER_COOP"/>
|
||||
<column name="label" value="2(f) Balance with other Co-operative Banks (current)"/>
|
||||
<column name="description" value="Current account balances with co-operative banks other than the State and district central co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2025"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 2. Balance with banks - SAVINGS -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A2_SAV_BANKING_SYSTEM"/>
|
||||
<column name="label" value="2. Balance with the Banking System (savings)"/>
|
||||
<column name="description" value="Savings account balances the bank holds with commercial banks. The form reports current and savings in separate columns, so a savings balance mapped to the current head would be shown in the wrong one."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2030"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A2_SAV_COOP"/>
|
||||
<column name="label" value="2. Balance with Co-operative Banks (savings)"/>
|
||||
<column name="description" value="Savings account balances with the State co-operative bank, the district central co-operative bank, or any other co-operative bank."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2031"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 3. Money at call and short notice -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A3_MONEY_AT_CALL"/>
|
||||
<column name="label" value="3. Money at Call and Short Notice"/>
|
||||
<column name="description" value="Money lent at call or short notice to the State Bank and its subsidiaries, the banking system, the State co-operative bank, the district central co-operative bank or other co-operative banks. Ordinary fixed deposits with banks do NOT belong here - they are investments at 4(g)."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2040"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0586-5-seed-form9-investments">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A4_%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_A1_CENTRAL_GOVT_SEC"/>
|
||||
<column name="label" value="4(a)(i) Investments in Central Government Securities"/>
|
||||
<column name="description" value="Central Government securities, including treasury bills."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2050"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_A2_STATE_GOVT_SEC"/>
|
||||
<column name="label" value="4(a)(ii) Investments in State Government Securities"/>
|
||||
<column name="description" value="State Government securities. Securities held in the available-for-sale book belong here too unless the bank classifies them elsewhere."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2051"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_B_LAND_MORTGAGE_DEBENTURES"/>
|
||||
<column name="label" value="4(b) Debentures of Land Mortgage Banks"/>
|
||||
<column name="description" value="Debentures issued by land development or land mortgage banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2052"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_C_OTHER_TRUSTEE_SEC"/>
|
||||
<column name="label" value="4(c) Other Trustee Securities"/>
|
||||
<column name="description" value="Trustee securities other than Government securities and land mortgage bank debentures."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2053"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_D_SHARES_STATE_COOP"/>
|
||||
<column name="label" value="4(d) Shares of the State Co-operative Banks"/>
|
||||
<column name="description" value="Shareholding in the apex co-operative bank of the State."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2054"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_E_SHARES_DCCB"/>
|
||||
<column name="label" value="4(e) Shares of the Central Co-operative Banks"/>
|
||||
<column name="description" value="Shareholding in the district central co-operative bank."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2055"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_F_SHARES_OTHER_COOP"/>
|
||||
<column name="label" value="4(f) Shares of other Co-operative Institutions"/>
|
||||
<column name="description" value="Shareholding in co-operative institutions other than the State and district central co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2056"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_G1_FD_SBI"/>
|
||||
<column name="label" value="4(g)(i) Fixed deposits with the State Bank of India and its subsidiaries"/>
|
||||
<column name="description" value="Term deposits placed with the State Bank of India and its subsidiary banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2057"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_G2_FD_BANKING_SYSTEM"/>
|
||||
<column name="label" value="4(g)(ii) Fixed deposits with the Banking System (companies)"/>
|
||||
<column name="description" value="Term deposits and investments placed with commercial banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2058"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_G3_FD_STATE_COOP"/>
|
||||
<column name="label" value="4(g)(iii) Fixed deposits with the State Co-operative Bank of the State concerned"/>
|
||||
<column name="description" value="Term deposits and investments placed with the apex co-operative bank of the State."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2059"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_G4_FD_DCCB"/>
|
||||
<column name="label" value="4(g)(iv) Fixed deposits with the Central Co-operative Bank of the district concerned"/>
|
||||
<column name="description" value="Term deposits and investments placed with the district central co-operative bank."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2060"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_G5_FD_OTHER_COOP"/>
|
||||
<column name="label" value="4(g)(v) Fixed deposits with other Co-operative Banks"/>
|
||||
<column name="description" value="Term deposits placed with co-operative banks other than the State and district central co-operative banks."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2061"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A4_H_OTHER_INVESTMENTS"/>
|
||||
<column name="label" value="4(h) Other Investments"/>
|
||||
<column name="description" value="Investments outside every class above - SIDBI and other development-institution deposits, and non-SLR investments. Map each such account here; the return prints one row per account, which is the 'Others Investments - Specify' sheet of the RBI workbook."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2062"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 5. Investments out of the State Partnership Fund -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A5_PARTNERSHIP_INVESTMENTS"/>
|
||||
<column name="label" value="5. Investments out of the Principal/Subsidiary State Partnership Fund"/>
|
||||
<column name="description" value="Shares of central co-operative banks, primary agricultural credit societies or other societies, bought out of the State partnership fund reported at liability 3."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2070"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0586-6-seed-form9-advances-and-other-assets">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_rbi_reserve_role"/>
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A6_%'
|
||||
</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
|
||||
<!-- 6. Loans and advances -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_A_SEASONAL_AGRI"/>
|
||||
<column name="label" value="6(a) Loans for Seasonal Agricultural Operations"/>
|
||||
<column name="description" value="Advances for seasonal agricultural operations."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2080"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_B_MARKETING_CROPS"/>
|
||||
<column name="label" value="6(b) Loans for Marketing of Crops"/>
|
||||
<column name="description" value="Advances for the marketing of crops, secured or unsecured."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2081"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_C_MEDIUM_TERM_AGRI"/>
|
||||
<column name="label" value="6(c) Loans for Medium Term Agricultural purposes"/>
|
||||
<column name="description" value="Medium term advances for agricultural purposes."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2082"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_D_WEAVERS_SOCIETIES"/>
|
||||
<column name="label" value="6(d) Loans for Weavers Societies"/>
|
||||
<column name="description" value="Advances to weavers societies."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2083"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_E_INDUSTRIAL_SHORT"/>
|
||||
<column name="label" value="6(e)(i) Loans for other Industrial purposes - Short Term"/>
|
||||
<column name="description" value="Short term advances for industrial purposes. Map secured and unsecured accounts separately using the liability class on each mapping row, so the form can print its Secured and Unsecured columns."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2084"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_E_INDUSTRIAL_MEDIUM"/>
|
||||
<column name="label" value="6(e)(ii) Loans for other Industrial purposes - Medium Term"/>
|
||||
<column name="description" value="Medium term advances for industrial purposes."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2085"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_F_OTHER_SHORT"/>
|
||||
<column name="label" value="6(f)(i) Loans for Other purposes - Short Term"/>
|
||||
<column name="description" value="Short term advances for purposes other than agriculture and industry - cash credits, overdrafts, loans against deposits and against security, warehouse receipt finance. For an urban co-operative bank this is usually the largest line on the return."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2086"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_F_OTHER_MEDIUM"/>
|
||||
<column name="label" value="6(f)(ii) Loans for Other purposes - Medium Term"/>
|
||||
<column name="description" value="Medium term advances for purposes other than agriculture and industry."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2087"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_F_OTHER_LONG"/>
|
||||
<column name="label" value="6(f)(iii) Loans for Other purposes - Long Term"/>
|
||||
<column name="description" value="Long term advances for purposes other than agriculture and industry - term loans and property mortgage limits."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2088"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 6(g) Explanation lines - stated, entering no total -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_G_OVERDUE_ADVANCES"/>
|
||||
<column name="label" value="6(g)(i) Of the above advances, those which are overdue"/>
|
||||
<column name="description" value="Advances standing overdue at the month end. Stated on the face of the form as an explanation of item 6 and entering no total - it is part of the advances already counted, not an addition to them. The form's overdue percentage is computed from it."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="2089"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A6_G_PRIORITY_SECTOR"/>
|
||||
<column name="label" value="6(g)(ii) Advances outstanding in respect of the Priority Sector"/>
|
||||
<column name="description" value="Advances to the priority sector. An explanation of item 6, entering no total; the form's priority-sector percentage is computed from it."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="MEMO"/>
|
||||
<column name="position" valueNumeric="2090"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
|
||||
<!-- 7-12 -->
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A7_BILLS_PURCHASED"/>
|
||||
<column name="label" value="7. Bills Purchased and Discounted"/>
|
||||
<column name="description" value="Bills the bank has purchased or discounted and not yet realised."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2100"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A8_INTEREST_RECEIVABLE"/>
|
||||
<column name="label" value="8. Interest Receivable (of which overdue)"/>
|
||||
<column name="description" value="Interest earned but not yet received. It matches the Overdue Interest Reserve on the liability side, so mapping one without the other breaks the balance by exactly this figure."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2101"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A9_FIXED_ASSETS"/>
|
||||
<column name="label" value="9. Premises, Furniture, Fixtures and Other Fixed Assets"/>
|
||||
<column name="description" value="Land and buildings, furniture and fixtures, computer hardware and software, plant and machinery, electrical installations and security systems - at their written down value."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2102"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A10_BRANCH_ADJUSTMENTS"/>
|
||||
<column name="label" value="10. Branch Adjustments (assets)"/>
|
||||
<column name="description" value="Net inter-branch balances standing to the debit side at the month end."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2103"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A11_I_TANGIBLE_ASSETS"/>
|
||||
<column name="label" value="11(i) Other Assets - Tangible"/>
|
||||
<column name="description" value="Tangible assets outside every class above - advances to employees, stationery and printing stock, deposits with utilities, accrued interest on investments."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2104"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A11_II_INTANGIBLE_ASSETS"/>
|
||||
<column name="label" value="11(ii) Other Assets - Intangible"/>
|
||||
<column name="description" value="Intangible assets - deferred revenue expenditure, goodwill and the like."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2105"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
<insert tableName="m_rbi_reserve_role">
|
||||
<column name="config_key" value="FORM9_A12_BALANCE_OF_LOSS"/>
|
||||
<column name="label" value="12. Balance of Loss"/>
|
||||
<column name="description" value="Accumulated loss carried on the balance sheet. A profit is reported on the liability side at item 9 instead, never as a negative here."/>
|
||||
<column name="role_group" value="FORM9"/>
|
||||
<column name="role_usage" value="ASSET"/>
|
||||
<column name="position" valueNumeric="2106"/>
|
||||
<column name="created_on" valueComputed="now()"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+2
-2
@@ -31,7 +31,7 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet author="CBS" id="0585-1-fix-saving-product-temp-od-min-limit">
|
||||
<changeSet author="CBS" id="0587-1-fix-saving-product-temp-od-min-limit">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_saving_product_temp"/>
|
||||
@@ -46,7 +46,7 @@
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0585-2-fix-saving-product-temp-od-excess-drawing">
|
||||
<changeSet author="CBS" id="0587-2-fix-saving-product-temp-od-excess-drawing">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_saving_product_temp"/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user