This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push: new a6fa1adc99 FINERACT-2312: Post interest with adjustments for savings accounts a6fa1adc99 is described below commit a6fa1adc99f62af7882930e5b4dbaee01077d7ff Author: Juan-Pablo-Alvarez <work_...@hotmail.com> AuthorDate: Wed Jul 23 18:09:25 2025 -0600 FINERACT-2312: Post interest with adjustments for savings accounts --- .../portfolio/savings/data/SavingsAccountData.java | 10 + .../data/SavingsAccountTransactionData.java | 71 +++- .../domain/interest/CompoundInterestHelper.java | 8 +- .../domain/interest/CompoundInterestValues.java | 2 +- .../savings/domain/interest/PostingPeriod.java | 10 +- .../SavingsAccountInterestPostingServiceImpl.java | 143 ++++++- .../SavingsAccountReadPlatformServiceImpl.java | 17 + ...countWritePlatformServiceJpaRepositoryImpl.java | 18 + .../service/SavingsSchedularInterestPoster.java | 52 +-- .../SavingsInterestPostingTest.java | 443 +++++++++++++++++++++ .../common/savings/SavingsProductHelper.java | 13 +- 11 files changed, 726 insertions(+), 61 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java index e1394661ce..42c5ab2fb3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Locale; import java.util.Set; import lombok.Getter; +import lombok.Setter; import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; import org.apache.fineract.infrastructure.core.data.EnumOptionData; @@ -47,6 +48,7 @@ import org.apache.fineract.portfolio.tax.data.TaxGroupData; /** * Immutable data object representing a savings account. */ +@Setter @Getter @JsonLocalDateArrayFormat public final class SavingsAccountData implements Serializable { @@ -144,6 +146,13 @@ public final class SavingsAccountData implements Serializable { private transient Long glAccountIdForSavingsControl; private transient Long glAccountIdForInterestOnSavings; + private Long glAccountIdForInterestPayable; + private Long glAccountIdForOverdraftPorfolio; + private Long glAccountIdForInterestReceivable; + + private BigDecimal interestPosting; + private BigDecimal overdraftPosting; + public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate, BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum, EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum, @@ -964,4 +973,5 @@ public final class SavingsAccountData implements Serializable { public boolean isIsDormancyTrackingActive() { return this.isDormancyTrackingActive; } + } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionData.java index bf1028b5fd..47236df405 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountTransactionData.java @@ -105,6 +105,10 @@ public final class SavingsAccountTransactionData implements Serializable { private BigDecimal overdraftAmount; private transient Long modifiedId; private transient String refNo; + private Boolean isOverdraft; + + private Long accountCredit; + private Long accountDebit; private SavingsAccountTransactionData(final Long id, final SavingsAccountTransactionEnumData transactionType, final PaymentDetailData paymentDetailData, final Long savingsId, final String savingsAccountNo, final LocalDate transactionDate, @@ -112,7 +116,7 @@ public final class SavingsAccountTransactionData implements Serializable { final boolean reversed, final AccountTransferData transfer, final Collection<PaymentTypeData> paymentTypeOptions, final LocalDate submittedOnDate, final boolean interestedPostedAsOn, final String submittedByUsername, final String note, final Boolean isReversal, final Long originalTransactionId, boolean isManualTransaction, final Boolean lienTransaction, - final Long releaseTransactionId, final String reasonForBlock) { + final Long releaseTransactionId, final String reasonForBlock, final Boolean isOverdraft) { this.id = id; this.transactionType = transactionType; TransactionEntryType entryType = null; @@ -146,6 +150,7 @@ public final class SavingsAccountTransactionData implements Serializable { this.lienTransaction = lienTransaction; this.releaseTransactionId = releaseTransactionId; this.reasonForBlock = reasonForBlock; + this.isOverdraft = isOverdraft; } private static SavingsAccountTransactionData createData(final Long id, final SavingsAccountTransactionEnumData transactionType, @@ -156,7 +161,7 @@ public final class SavingsAccountTransactionData implements Serializable { final Boolean lienTransaction) { return new SavingsAccountTransactionData(id, transactionType, paymentDetailData, accountId, accountNo, date, currency, amount, outstandingChargeAmount, runningBalance, reversed, transfer, paymentTypeOptions, submittedOnDate, interestedPostedAsOn, - submittedByUsername, note, null, null, false, lienTransaction, null, null); + submittedByUsername, note, null, null, false, lienTransaction, null, null, false); } public static SavingsAccountTransactionData create(final Long id, final SavingsAccountTransactionEnumData transactionType, @@ -167,7 +172,8 @@ public final class SavingsAccountTransactionData implements Serializable { final Boolean lienTransaction, final Long releaseTransactionId, final String reasonForBlock) { return new SavingsAccountTransactionData(id, transactionType, paymentDetailData, savingsId, savingsAccountNo, date, currency, amount, outstandingChargeAmount, runningBalance, reversed, transfer, null, submittedOnDate, interestedPostedAsOn, - submittedByUsername, note, isReversal, originalTransactionId, false, lienTransaction, releaseTransactionId, reasonForBlock); + submittedByUsername, note, isReversal, originalTransactionId, false, lienTransaction, releaseTransactionId, reasonForBlock, + false); } public static SavingsAccountTransactionData create(final Long id, final SavingsAccountTransactionEnumData transactionType, @@ -235,10 +241,10 @@ public final class SavingsAccountTransactionData implements Serializable { private static SavingsAccountTransactionData createImport(final SavingsAccountTransactionEnumData transactionType, final PaymentDetailData paymentDetailData, final Long savingsAccountId, final String accountNumber, final LocalDate transactionDate, final BigDecimal transactionAmount, final boolean reversed, final LocalDate submittedOnDate, - boolean isManualTransaction, final Boolean lienTransaction) { + boolean isManualTransaction, final Boolean lienTransaction, final Boolean isOverdraft) { SavingsAccountTransactionData data = new SavingsAccountTransactionData(null, transactionType, paymentDetailData, savingsAccountId, accountNumber, transactionDate, null, transactionAmount, null, null, reversed, null, null, submittedOnDate, false, null, - null, null, null, isManualTransaction, lienTransaction, null, null); + null, null, null, isManualTransaction, lienTransaction, null, null, isOverdraft); // duplicated import fields data.savingsAccountId = savingsAccountId; data.accountNumber = accountNumber; @@ -251,14 +257,14 @@ public final class SavingsAccountTransactionData implements Serializable { return createImport(accountTransaction.getTransactionType(), accountTransaction.getPaymentDetailData(), accountTransaction.getSavingsAccountId(), null, accountTransaction.getTransactionDate(), accountTransaction.getAmount(), accountTransaction.isReversed(), accountTransaction.getSubmittedOnDate(), accountTransaction.isManualTransaction(), - accountTransaction.getLienTransaction()); + accountTransaction.getLienTransaction(), false); } public static SavingsAccountTransactionData importInstance(BigDecimal transactionAmount, LocalDate transactionDate, Long paymentTypeId, String accountNumber, String checkNumber, String routingCode, String receiptNumber, String bankNumber, String note, Long savingsAccountId, SavingsAccountTransactionEnumData transactionType, Integer rowIndex, String locale, String dateFormat) { SavingsAccountTransactionData data = createImport(transactionType, null, savingsAccountId, accountNumber, transactionDate, - transactionAmount, false, transactionDate, false, false); + transactionAmount, false, transactionDate, false, false, false); data.rowIndex = rowIndex; data.paymentTypeId = paymentTypeId; data.checkNumber = checkNumber; @@ -272,10 +278,11 @@ public final class SavingsAccountTransactionData implements Serializable { } private static SavingsAccountTransactionData createImport(SavingsAccountTransactionEnumData transactionType, Long savingsAccountId, - LocalDate transactionDate, BigDecimal transactionAmount, final LocalDate submittedOnDate, boolean isManualTransaction) { + LocalDate transactionDate, BigDecimal transactionAmount, final LocalDate submittedOnDate, boolean isManualTransaction, + Boolean isOverdraft) { // import transaction return createImport(transactionType, null, savingsAccountId, null, transactionDate, transactionAmount, false, submittedOnDate, - isManualTransaction, false); + isManualTransaction, false, isOverdraft); } public static SavingsAccountTransactionData interestPosting(final SavingsAccountData savingsAccount, final LocalDate date, @@ -285,17 +292,28 @@ public final class SavingsAccountTransactionData implements Serializable { SavingsAccountTransactionEnumData transactionType = new SavingsAccountTransactionEnumData( savingsAccountTransactionType.getValue().longValue(), savingsAccountTransactionType.getCode(), savingsAccountTransactionType.getValue().toString()); - return createImport(transactionType, savingsAccount.getId(), date, amount.getAmount(), submittedOnDate, isManualTransaction); + return createImport(transactionType, savingsAccount.getId(), date, amount.getAmount(), submittedOnDate, isManualTransaction, false); + } + + public static SavingsAccountTransactionData accrual(final SavingsAccountData savingsAccount, final LocalDate date, final Money amount, + final boolean isManualTransaction) { + final LocalDate submittedOnDate = DateUtils.getBusinessLocalDate(); + final SavingsAccountTransactionType savingsAccountTransactionType = SavingsAccountTransactionType.ACCRUAL; + SavingsAccountTransactionEnumData transactionType = new SavingsAccountTransactionEnumData( + savingsAccountTransactionType.getValue().longValue(), savingsAccountTransactionType.getCode(), + savingsAccountTransactionType.getValue().toString()); + return createImport(transactionType, savingsAccount.getId(), date, amount.getAmount(), submittedOnDate, isManualTransaction, false); } public static SavingsAccountTransactionData overdraftInterest(final SavingsAccountData savingsAccount, final LocalDate date, - final Money amount, final boolean isManualTransaction) { + final Money amount, final boolean isManualTransaction, final Boolean isOverdraft) { final LocalDate submittedOnDate = DateUtils.getBusinessLocalDate(); final SavingsAccountTransactionType savingsAccountTransactionType = SavingsAccountTransactionType.OVERDRAFT_INTEREST; SavingsAccountTransactionEnumData transactionType = new SavingsAccountTransactionEnumData( savingsAccountTransactionType.getValue().longValue(), savingsAccountTransactionType.getCode(), savingsAccountTransactionType.getValue().toString()); - return createImport(transactionType, savingsAccount.getId(), date, amount.getAmount(), submittedOnDate, isManualTransaction); + return createImport(transactionType, savingsAccount.getId(), date, amount.getAmount(), submittedOnDate, isManualTransaction, + isOverdraft); } public static SavingsAccountTransactionData withHoldTax(final SavingsAccountData savingsAccount, final LocalDate date, @@ -306,7 +324,7 @@ public final class SavingsAccountTransactionData implements Serializable { savingsAccountTransactionType.getValue().longValue(), savingsAccountTransactionType.getCode(), savingsAccountTransactionType.getValue().toString()); SavingsAccountTransactionData accountTransaction = createImport(transactionType, savingsAccount.getId(), date, amount.getAmount(), - submittedOnDate, false); + submittedOnDate, false, false); accountTransaction.addTaxDetails(taxDetails); return accountTransaction; } @@ -387,10 +405,11 @@ public final class SavingsAccountTransactionData implements Serializable { final MonetaryCurrency currency = openingBalance.getCurrency(); Money endOfDayBalance = openingBalance.copy(); if (isDeposit() || isDividendPayoutAndNotReversed()) { - endOfDayBalance = openingBalance.plus(getAmount()); + endOfDayBalance = Money.of(currency, this.runningBalance); } else if (isWithdrawal() || isChargeTransactionAndNotReversed()) { - - if (openingBalance.isGreaterThanZero()) { + if (isWithdrawal()) { + endOfDayBalance = Money.of(currency, this.runningBalance); + } else if (openingBalance.isGreaterThanZero()) { endOfDayBalance = openingBalance.minus(getAmount()); } else { endOfDayBalance = Money.of(currency, this.runningBalance); @@ -400,6 +419,14 @@ public final class SavingsAccountTransactionData implements Serializable { return EndOfDayBalance.from(getTransactionDate(), openingBalance, endOfDayBalance, this.balanceNumberOfDays); } + public EndOfDayBalance toEndOfDayBalanceDates(final Money openingBalance, LocalDateInterval date) { + final MonetaryCurrency currency = openingBalance.getCurrency(); + Money endOfDayBalance = Money.of(currency, this.runningBalance); + + return EndOfDayBalance.from(getTransactionDate(), openingBalance, endOfDayBalance, + this.balanceNumberOfDays != null ? this.balanceNumberOfDays : date.endDate().getDayOfMonth()); + } + public boolean isChargeTransactionAndNotReversed() { return this.transactionType.isChargeTransaction() && isNotReversed(); } @@ -427,7 +454,9 @@ public final class SavingsAccountTransactionData implements Serializable { if (isDeposit() || isDividendPayoutAndNotReversed()) { endOfDayBalance = endOfDayBalance.plus(getAmount()); } else if (isWithdrawal() || isChargeTransactionAndNotReversed()) { - if (endOfDayBalance.isGreaterThanZero() || isAllowOverdraft) { + if (endOfDayBalance.isLessThanZero() && isAllowOverdraft) { + endOfDayBalance = Money.of(currency, this.runningBalance); + } else if (endOfDayBalance.isGreaterThanZero() || isAllowOverdraft) { endOfDayBalance = endOfDayBalance.minus(getAmount()); } else { endOfDayBalance = Money.of(currency, this.runningBalance); @@ -658,4 +687,12 @@ public final class SavingsAccountTransactionData implements Serializable { public TransactionEntryType getEntryType() { return entryType; } + + public void setAccountCredit(Long accountCredit) { + this.accountCredit = accountCredit; + } + + public void setAccountDebit(Long accountDebit) { + this.accountDebit = accountDebit; + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java index 4b918c0198..46f588dd66 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java @@ -47,9 +47,14 @@ public class CompoundInterestHelper { // total interest earned in previous periods but not yet recognised BigDecimal compoundedInterest = BigDecimal.ZERO; BigDecimal unCompoundedInterest = BigDecimal.ZERO; + LocalDate endDay = DateUtils.getBusinessLocalDate(); final CompoundInterestValues compoundInterestValues = new CompoundInterestValues(compoundedInterest, unCompoundedInterest); for (final PostingPeriod postingPeriod : allPeriods) { + if (postingPeriod.dateOfPostingTransaction().getMonth() != endDay.getMonth()) { + compoundInterestValues.setCompoundedInterest(interestEarned.getAmount()); + } + final BigDecimal interestEarnedThisPeriod = postingPeriod.calculateInterest(compoundInterestValues); final Money moneyToBePostedForPeriod = Money.of(currency, interestEarnedThisPeriod); @@ -61,8 +66,9 @@ public class CompoundInterestHelper { // calculation. if (!(postingPeriod.isInterestTransfered() || !interestTransferEnabled || (lockUntil != null && !DateUtils.isAfter(postingPeriod.dateOfPostingTransaction(), lockUntil)))) { - compoundInterestValues.setcompoundedInterest(BigDecimal.ZERO); + compoundInterestValues.setCompoundedInterest(BigDecimal.ZERO); } + endDay = postingPeriod.dateOfPostingTransaction(); } return interestEarned; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestValues.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestValues.java index 09a871e8db..68cfc07cd7 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestValues.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestValues.java @@ -38,7 +38,7 @@ public class CompoundInterestValues { return this.uncompoundedInterest; } - public void setcompoundedInterest(BigDecimal interestToBeCompounded) { + public void setCompoundedInterest(BigDecimal interestToBeCompounded) { this.compoundedInterest = interestToBeCompounded; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java index a9decdc097..65130e998c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java @@ -26,6 +26,8 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.TreeSet; +import lombok.Getter; +import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; @@ -34,6 +36,8 @@ import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodTyp import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; +@Setter +@Getter public final class PostingPeriod { private final LocalDateInterval periodInterval; @@ -64,6 +68,8 @@ public final class PostingPeriod { private Integer financialYearBeginningMonth; + private boolean overdraftInterest = false; + public void setOverdraftInterestRateAsFraction(BigDecimal overdraftInterestRateAsFraction) { this.overdraftInterestRateAsFraction = overdraftInterestRateAsFraction; } @@ -301,7 +307,7 @@ public final class PostingPeriod { if (compoundingPeriodEndDate.equals(compoundingPeriod.getPeriodInterval().endDate())) { BigDecimal interestCompounded = compoundInterestValues.getcompoundedInterest().add(unCompoundedInterest); - compoundInterestValues.setcompoundedInterest(interestCompounded); + compoundInterestValues.setCompoundedInterest(interestCompounded); compoundInterestValues.setZeroForInterestToBeUncompounded(); } interestEarned = interestEarned.add(interestUnrounded); @@ -314,7 +320,7 @@ public final class PostingPeriod { } public Money getInterestEarned() { - return this.interestEarnedRounded; + return this.interestEarnedRounded != null ? this.interestEarnedRounded : Money.zero(this.currency); } private static List<CompoundingPeriod> compoundingPeriodsInPostingPeriod(final LocalDateInterval postingPeriodInterval, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java index b952b24a51..79831d54f5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java @@ -32,6 +32,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.savings.DepositAccountType; @@ -81,11 +82,17 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI for (final PostingPeriod interestPostingPeriod : postingPeriods) { final LocalDate interestPostingTransactionDate = interestPostingPeriod.dateOfPostingTransaction(); final Money interestEarnedToBePostedForPeriod = interestPostingPeriod.getInterestEarned(); + final Boolean isOverdraft = interestPostingPeriod.isOverdraftInterest(); if (!DateUtils.isAfter(interestPostingTransactionDate, interestPostingUpToDate)) { interestPostedToDate = interestPostedToDate.plus(interestEarnedToBePostedForPeriod); - final SavingsAccountTransactionData postingTransaction = findInterestPostingTransactionFor(interestPostingTransactionDate, - savingsAccountData); + SavingsAccountTransactionData postingTransaction = null; + if (this.depositAccountType(savingsAccountData).isSavingsDeposit() && savingsAccountData.isAllowOverdraft()) { + postingTransaction = findInterestPostingTransactionForInterest(interestPostingTransactionDate, savingsAccountData, + isOverdraft); + } else { + postingTransaction = findInterestPostingTransactionFor(interestPostingTransactionDate, savingsAccountData); + } if (postingTransaction == null) { SavingsAccountTransactionData newPostingTransaction; @@ -95,7 +102,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI } else { newPostingTransaction = SavingsAccountTransactionData.overdraftInterest(savingsAccountData, interestPostingTransactionDate, interestEarnedToBePostedForPeriod.negated(), - interestPostingPeriod.isUserPosting()); + interestPostingPeriod.isUserPosting(), isOverdraft); } savingsAccountData.updateTransactions(newPostingTransaction); @@ -131,7 +138,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI } else { newPostingTransaction = SavingsAccountTransactionData.overdraftInterest(savingsAccountData, interestPostingTransactionDate, interestEarnedToBePostedForPeriod.negated(), - interestPostingPeriod.isUserPosting()); + interestPostingPeriod.isUserPosting(), isOverdraft); } savingsAccountData.updateTransactions(newPostingTransaction); @@ -190,6 +197,36 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI return transaction; } + private Money appendPostingPeriodIfAny(final LocalDateInterval periodInterval, Money periodStartingBalance, + final List<SavingsAccountTransactionData> txs, final MonetaryCurrency monetaryCurrency, + final SavingsCompoundingInterestPeriodType compoundingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final BigDecimal interestRateAsFraction, final int daysInYear, final LocalDate upToInterestCalculationDate, + final Collection<Long> interestPostTransactions, final boolean isInterestTransfer, final Money minBalanceForInterestCalculation, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final BigDecimal overdraftInterestRateAsFraction, + final Money minOverdraftForInterestCalculation, final boolean isUserPosting, final Integer financialYearBeginningMonth, + final boolean allowOverdraft, final List<PostingPeriod> allPostingPeriods, Boolean isOverdraftTransacction) { + + if (txs == null || txs.isEmpty()) { + return periodStartingBalance; + } + + final PostingPeriod postingPeriod = PostingPeriod.createFromDTO(periodInterval, periodStartingBalance, txs, monetaryCurrency, + compoundingPeriodType, interestCalculationType, interestRateAsFraction, daysInYear, upToInterestCalculationDate, + interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation, isSavingsInterestPostingAtCurrentPeriodEnd, + overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, isUserPosting, financialYearBeginningMonth, + allowOverdraft); + + periodStartingBalance = postingPeriod.closingBalance(); + postingPeriod.setOverdraftInterest(isOverdraftTransacction); + + if (!(MathUtil.isZero(postingPeriod.getOpeningBalance().getAmount()) + && MathUtil.isZero(postingPeriod.closingBalance().getAmount()))) { + allPostingPeriods.add(postingPeriod); + } + + return periodStartingBalance; + } + public List<PostingPeriod> calculateInterestUsing(final MathContext mc, final LocalDate upToInterestCalculationDate, boolean isInterestTransfer, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final SavingsAccountData savingsAccountData) { @@ -268,16 +305,40 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI if (postedAsOnDates.contains(periodInterval.endDate().plusDays(1))) { isUserPosting = true; } - final PostingPeriod postingPeriod = PostingPeriod.createFromDTO(periodInterval, periodStartingBalance, - retreiveOrderedNonInterestPostingTransactions(savingsAccountData), monetaryCurrency, compoundingPeriodType, - interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), upToInterestCalculationDate, - interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation, - isSavingsInterestPostingAtCurrentPeriodEnd, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, - isUserPosting, financialYearBeginningMonth, savingsAccountData.isAllowOverdraft()); + if (savingsAccountData.isAllowOverdraft() && !MathUtil.isZero(savingsAccountData.getGlAccountIdForInterestReceivable())) { - periodStartingBalance = postingPeriod.closingBalance(); + List<SavingsAccountTransactionData> overdraftTxs = listForOverdraft(savingsAccountData, periodInterval); + List<SavingsAccountTransactionData> interestPostingTxs = listForInterestPosting(savingsAccountData, periodInterval, + monetaryCurrency); - allPostingPeriods.add(postingPeriod); + boolean isOverdraftAccountType = isOverdraftAccount(savingsAccountData, periodInterval, monetaryCurrency); + + List<SavingsAccountTransactionData> primaryInterestPublication = isOverdraftAccountType ? overdraftTxs : interestPostingTxs; + List<SavingsAccountTransactionData> secondaryInterestPublication = isOverdraftAccountType ? interestPostingTxs + : overdraftTxs; + + periodStartingBalance = appendPostingPeriodIfAny(periodInterval, periodStartingBalance, primaryInterestPublication, + monetaryCurrency, compoundingPeriodType, interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), + upToInterestCalculationDate, interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation, + isSavingsInterestPostingAtCurrentPeriodEnd, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, + isUserPosting, financialYearBeginningMonth, savingsAccountData.isAllowOverdraft(), allPostingPeriods, + isOverdraftAccountType ? true : false); + + periodStartingBalance = appendPostingPeriodIfAny(periodInterval, periodStartingBalance, secondaryInterestPublication, + monetaryCurrency, compoundingPeriodType, interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), + upToInterestCalculationDate, interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation, + isSavingsInterestPostingAtCurrentPeriodEnd, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, + isUserPosting, financialYearBeginningMonth, savingsAccountData.isAllowOverdraft(), allPostingPeriods, + isOverdraftAccountType ? false : true); + + } else { + periodStartingBalance = appendPostingPeriodIfAny(periodInterval, periodStartingBalance, + retreiveOrderedNonInterestPostingTransactions(savingsAccountData), monetaryCurrency, compoundingPeriodType, + interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), upToInterestCalculationDate, + interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation, + isSavingsInterestPostingAtCurrentPeriodEnd, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, + isUserPosting, financialYearBeginningMonth, savingsAccountData.isAllowOverdraft(), allPostingPeriods, false); + } } this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods, @@ -297,6 +358,52 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI return allPostingPeriods; } + private List<SavingsAccountTransactionData> listForOverdraft(final SavingsAccountData savingsAccountData, + final LocalDateInterval periodInterval) { + List<SavingsAccountTransactionData> overdraftTransactionsInPeriod = new ArrayList<>(); + for (SavingsAccountTransactionData lists : retreiveOrderedNonInterestPostingTransactions(savingsAccountData)) { + if (MathUtil.isLessThanZero(lists.getRunningBalance()) && periodInterval.startDate().getMonth() == lists.getDate().getMonth()) { + overdraftTransactionsInPeriod.add(lists); + + } + } + return overdraftTransactionsInPeriod; + + } + + private List<SavingsAccountTransactionData> listForInterestPosting(final SavingsAccountData savingsAccountData, + final LocalDateInterval periodInterval, final MonetaryCurrency currency) { + + final List<SavingsAccountTransactionData> nonOverdraftTransactions = new ArrayList<>(); + + for (final SavingsAccountTransactionData tx : retreiveOrderedNonInterestPostingTransactions(savingsAccountData)) { + if (periodInterval.startDate().getMonth() == tx.getDate().getMonth()) { + final Money runningBalance = Money.of(currency, tx.getRunningBalance()); + + if (runningBalance.isGreaterThanZero() && !runningBalance.isZero()) { + nonOverdraftTransactions.add(tx); + } + } + } + return nonOverdraftTransactions; + } + + private Boolean isOverdraftAccount(final SavingsAccountData savingsAccountData, final LocalDateInterval periodInterval, + final MonetaryCurrency currency) { + + for (SavingsAccountTransactionData tx : retreiveOrderedNonInterestPostingTransactions(savingsAccountData)) { + if (MathUtil.isLessThanZero(tx.getRunningBalance()) && periodInterval.startDate().getMonth() == tx.getDate().getMonth()) { + return true; + } else if (periodInterval.startDate().getMonth() == tx.getDate().getMonth()) { + final Money runningBalance = Money.of(currency, tx.getRunningBalance()); + if (!runningBalance.isZero()) { + return false; + } + } + } + return false; + } + private List<SavingsAccountTransactionData> retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) { final List<SavingsAccountTransactionData> listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData); @@ -509,6 +616,18 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI return postingTransation; } + protected SavingsAccountTransactionData findInterestPostingTransactionForInterest(final LocalDate postingDate, + final SavingsAccountData savingsAccountData, boolean isOverdraft) { + SavingsAccountTransactionData postingTransation = null; + List<SavingsAccountTransactionData> transactions = savingsAccountData.getSavingsAccountTransactionData(); + postingTransation = transactions.stream().filter(t -> { + Boolean interestSearch = isOverdraft ? t.isOverdraftInterestAndNotReversed() : t.isInterestPostingAndNotReversed(); + return interestSearch && t.occursOn(postingDate) && !t.isReversalTransaction(); + }).findFirst().orElse(null); + + return postingTransation; + } + protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransactionData> accountTransactionsSorted, final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) { // loop over transactions in reverse diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index a5de90375b..96084d007b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -337,6 +337,9 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead "msac.id as chargeId, msac.amount as chargeAmount, msac.charge_time_enum as chargeTimeType, msac.is_penalty as isPenaltyCharge, "); sqlBuilder.append("txd.id as taxDetailsId, txd.amount as taxAmount, "); sqlBuilder.append("apm.gl_account_id as glAccountIdForInterestOnSavings, apm1.gl_account_id as glAccountIdForSavingsControl, "); + sqlBuilder.append( + "apm2.gl_account_id as glAccountIdForInterestReceivable,apm3.gl_account_id as glAccountIdForOverdraftPorfolio, "); + sqlBuilder.append("apm4.gl_account_id as glAccountIdForInterestPayable, "); sqlBuilder.append( "mtc.id as taxComponentId, mtc.debit_account_id as debitAccountId, mtc.credit_account_id as creditAccountId, mtc.percentage as taxPercentage "); sqlBuilder.append("from m_savings_account sa "); @@ -356,6 +359,9 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead "left join acc_product_mapping apm on apm.product_type = 2 and apm.product_id = sp.id and apm.financial_account_type=3 "); sqlBuilder.append( "left join acc_product_mapping apm1 on apm1.product_type = 2 and apm1.product_id = sp.id and apm1.financial_account_type=2 "); + sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 "); + sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 "); + sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 "); this.schemaSql = sqlBuilder.toString(); } @@ -409,6 +415,11 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead final Long glAccountIdForInterestOnSavings = rs.getLong("glAccountIdForInterestOnSavings"); final Long glAccountIdForSavingsControl = rs.getLong("glAccountIdForSavingsControl"); + final Long glAccountIdForOverdraftPorfolio = rs.getLong("glAccountIdForOverdraftPorfolio"); + final Long glAccountIdForInterestReceivable = rs.getLong("glAccountIdForInterestReceivable"); + + final Long glAccountIdForInterestPayable = rs.getLong("glAccountIdForInterestPayable"); + final Long productId = rs.getLong("productId"); final Integer accountType = rs.getInt("accountingType"); final AccountingRuleType accountingRuleType = AccountingRuleType.fromInt(accountType); @@ -565,6 +576,12 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead savingsAccountData.setClientData(clientData); savingsAccountData.setGroupGeneralData(groupGeneralData); savingsAccountData.setSavingsProduct(savingsProductData); + + savingsAccountData.setGlAccountIdForInterestReceivable(glAccountIdForInterestReceivable); + savingsAccountData.setGlAccountIdForOverdraftPorfolio(glAccountIdForOverdraftPorfolio); + + savingsAccountData.setGlAccountIdForInterestPayable(glAccountIdForInterestPayable); + savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings); savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index b6705759cb..f30712b509 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -595,6 +595,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi for (SavingsAccountTransactionData accountTransaction : transactions) { if (accountTransaction.getId() == null) { savingsAccountData.setNewSavingsAccountTransactionData(accountTransaction); + selectAccountId(accountTransaction, savingsAccountData); } } } @@ -604,6 +605,23 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi return savingsAccountData; } + public void selectAccountId(SavingsAccountTransactionData accountTransaction, SavingsAccountData savingsAccountData) { + SavingsAccountTransactionType transactionType = SavingsAccountTransactionType + .fromInt(accountTransaction.getTransactionType().getId().intValue()); + if (transactionType.isOverDraftInterestPosting()) { + if (MathUtil.isGreaterThanZero(accountTransaction.getRunningBalance())) { + accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForSavingsControl()); + accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForInterestReceivable()); + } else { + accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForOverdraftPorfolio()); + accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForInterestReceivable()); + } + } else { + accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestPayable()); + accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForSavingsControl()); + } + } + @Override public CommandProcessingResult reverseTransaction(final Long savingsId, final Long transactionId, final boolean allowAccountTransferModification, final JsonCommand command) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java index 295d3f55ae..913fbdb31e 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java @@ -108,33 +108,33 @@ public class SavingsSchedularInterestPoster { for (SavingsAccountTransactionData savingsAccountTransactionData : savingsAccountTransactionDataList) { if (savingsAccountTransactionData.getId() == null) { final String key = savingsAccountTransactionData.getRefNo(); - if (savingsAccountTransactionDataHashMap.containsKey(key)) { - final SavingsAccountTransactionData dataFromFetch = savingsAccountTransactionDataHashMap.get(key); - savingsAccountTransactionData.setId(dataFromFetch.getId()); - if (savingsAccountData.getGlAccountIdForSavingsControl() != 0 - && savingsAccountData.getGlAccountIdForInterestOnSavings() != 0) { - OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime(); - paramsForGLInsertion.add(new Object[] { savingsAccountData.getGlAccountIdForSavingsControl(), - savingsAccountData.getOfficeId(), null, currencyCode, - SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), - savingsAccountTransactionData.getId(), null, false, null, false, - savingsAccountTransactionData.getTransactionDate(), JournalEntryType.CREDIT.getValue().longValue(), - savingsAccountTransactionData.getAmount(), null, JournalEntryType.CREDIT.getValue().longValue(), - savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, null, - savingsAccountTransactionData.getTransactionDate(), null, userId, userId, - DateUtils.getBusinessLocalDate() }); - - paramsForGLInsertion.add(new Object[] { savingsAccountData.getGlAccountIdForInterestOnSavings(), - savingsAccountData.getOfficeId(), null, currencyCode, - SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), - savingsAccountTransactionData.getId(), null, false, null, false, - savingsAccountTransactionData.getTransactionDate(), JournalEntryType.DEBIT.getValue().longValue(), - savingsAccountTransactionData.getAmount(), null, JournalEntryType.DEBIT.getValue().longValue(), - savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, null, - savingsAccountTransactionData.getTransactionDate(), null, userId, userId, - DateUtils.getBusinessLocalDate() }); - } + final Boolean isOverdraft = savingsAccountTransactionData.getIsOverdraft(); + final SavingsAccountTransactionData dataFromFetch = savingsAccountTransactionDataHashMap.get(key); + savingsAccountTransactionData.setId(dataFromFetch.getId()); + if (savingsAccountData.getGlAccountIdForSavingsControl() != 0 + && savingsAccountData.getGlAccountIdForInterestOnSavings() != 0) { + OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime(); + paramsForGLInsertion.add( + new Object[] { savingsAccountTransactionData.getAccountCredit(), savingsAccountData.getOfficeId(), null, + currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), + savingsAccountTransactionData.getId(), null, false, null, false, + savingsAccountTransactionData.getTransactionDate(), JournalEntryType.CREDIT.getValue().longValue(), + savingsAccountTransactionData.getAmount(), null, JournalEntryType.CREDIT.getValue().longValue(), + savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, + null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId, + DateUtils.getBusinessLocalDate() }); + + paramsForGLInsertion + .add(new Object[] { savingsAccountTransactionData.getAccountDebit(), savingsAccountData.getOfficeId(), null, + currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), + savingsAccountTransactionData.getId(), null, false, null, false, + savingsAccountTransactionData.getTransactionDate(), JournalEntryType.DEBIT.getValue().longValue(), + savingsAccountTransactionData.getAmount(), null, JournalEntryType.DEBIT.getValue().longValue(), + savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, + null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId, + DateUtils.getBusinessLocalDate() }); } + } } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java new file mode 100644 index 0000000000..7e03e49487 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsInterestPostingTest.java @@ -0,0 +1,443 @@ +/** + * 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.integrationtests; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.CommonConstants; +import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper; +import org.apache.fineract.integrationtests.common.SchedulerJobHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.accounting.AccountHelper; +import org.apache.fineract.integrationtests.common.accounting.JournalEntryHelper; +import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper; +import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; +import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SavingsInterestPostingTest { + + private static final Logger LOG = LoggerFactory.getLogger(SavingsInterestPostingTest.class); + private static ResponseSpecification responseSpec; + private static RequestSpecification requestSpec; + private AccountHelper accountHelper; + private SavingsAccountHelper savingsAccountHelper; + private SchedulerJobHelper schedulerJobHelper; + public static final String MINIMUM_OPENING_BALANCE = "1000.0"; + private GlobalConfigurationHelper globalConfigurationHelper; + private SavingsProductHelper productHelper; + private JournalEntryHelper journalEntryHelper; + + @BeforeEach + public void setup() { + Utils.initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + this.schedulerJobHelper = new SchedulerJobHelper(this.requestSpec); + this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec); + this.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec); + this.journalEntryHelper = new JournalEntryHelper(this.requestSpec, this.responseSpec); + this.globalConfigurationHelper = new GlobalConfigurationHelper(); + } + + @Test + public void testPostInterestWithOverdraftProduct() { + try { + final String amount = "10000"; + final String jobName = "Post Interest For Savings"; + + final Account assetAccount = accountHelper.createAssetAccount(); + final Account incomeAccount = accountHelper.createIncomeAccount(); + final Account expenseAccount = accountHelper.createExpenseAccount(); + final Account liabilityAccount = accountHelper.createLiabilityAccount(); + final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); + final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); + final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + + final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( + interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), + interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); + + final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); + final LocalDate startDate = LocalDate.of(LocalDate.now(Utils.getZoneIdOfTenant()).getYear(), 2, 1); + final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + + final Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId, + SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); + savingsAccountHelper.approveSavingsOnDate(accountId, startDateString); + savingsAccountHelper.activateSavings(accountId, startDateString); + savingsAccountHelper.depositToSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + + // Simulate time passing - update business date to March + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(true)); + LocalDate marchDate = LocalDate.of(startDate.getYear(), 3, 1); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, marchDate); + + schedulerJobHelper.executeAndAwaitJob(jobName); + + long days = ChronoUnit.DAYS.between(startDate, marchDate); + BigDecimal expected = calcInterestPosting(productHelper, amount, days); + + List<HashMap> txs = getInterestTransactions(accountId); + for (HashMap tx : txs) { + Assertions.assertEquals(expected, BigDecimal.valueOf(((Double) tx.get("amount")))); + } + + long interestCount = countInterestOnDate(accountId, marchDate); + long overdraftCount = countOverdraftOnDate(accountId, marchDate); + Assertions.assertEquals(1L, interestCount, "Expected exactly one INTEREST posting on posting date"); + Assertions.assertEquals(0L, overdraftCount, "Expected NO OVERDRAFT posting on posting date"); + } finally { + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(false)); + } + } + + @Test + public void testOverdraftInterestWithOverdraftProduct() { + try { + final String amount = "10000"; + final String jobName = "Post Interest For Savings"; + + final Account assetAccount = accountHelper.createAssetAccount(); + final Account incomeAccount = accountHelper.createIncomeAccount(); + final Account expenseAccount = accountHelper.createExpenseAccount(); + final Account liabilityAccount = accountHelper.createLiabilityAccount(); + final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); + final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); + final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + + final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( + interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), + interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); + + final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); + final LocalDate startDate = LocalDate.of(LocalDate.now(Utils.getZoneIdOfTenant()).getYear(), 2, 1); + final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + + final Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId, + SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); + savingsAccountHelper.approveSavingsOnDate(accountId, startDateString); + savingsAccountHelper.activateSavings(accountId, startDateString); + savingsAccountHelper.withdrawalFromSavingsAccount(accountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + + // Simulate time passing - update business date to March + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(true)); + LocalDate marchDate = LocalDate.of(startDate.getYear(), 3, 1); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, marchDate); + + schedulerJobHelper.executeAndAwaitJob(jobName); + + long days = ChronoUnit.DAYS.between(startDate, marchDate); + BigDecimal expected = calcOverdraftPosting(productHelper, amount, days); + + List<HashMap> txs = getInterestTransactions(accountId); + Assertions.assertEquals(expected, BigDecimal.valueOf(((Double) txs.get(0).get("amount")))); + + BigDecimal runningBalance = BigDecimal.valueOf(((Double) txs.get(0).get("runningBalance"))); + Assertions.assertTrue(MathUtil.isLessThanZero(runningBalance), "Running balance is not less than zero"); + + long interestCount = countInterestOnDate(accountId, marchDate); + long overdraftCount = countOverdraftOnDate(accountId, marchDate); + Assertions.assertEquals(0L, interestCount, "Expected NO INTEREST posting on posting date"); + Assertions.assertEquals(1L, overdraftCount, "Expected exactly one OVERDRAFT posting on posting date"); + } finally { + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(false)); + } + } + + @Test + public void testOverdraftAndInterestPosting_WithOverdraftProduct_WhitBalanceLessZero() { + try { + final String amountDeposit = "10000"; + final String amountWithdrawal = "20000"; + final String jobName = "Post Interest For Savings"; + + final Account assetAccount = accountHelper.createAssetAccount(); + final Account incomeAccount = accountHelper.createIncomeAccount(); + final Account expenseAccount = accountHelper.createExpenseAccount(); + final Account liabilityAccount = accountHelper.createLiabilityAccount(); + final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); + final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); + final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + + final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( + interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), + interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); + + final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); + final LocalDate startDate = LocalDate.of(LocalDate.now(Utils.getZoneIdOfTenant()).getYear(), 2, 1); + final String startStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + + final Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId, + SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startStr); + savingsAccountHelper.approveSavingsOnDate(accountId, startStr); + savingsAccountHelper.activateSavings(accountId, startStr); + savingsAccountHelper.depositToSavingsAccount(accountId, amountDeposit, startStr, CommonConstants.RESPONSE_RESOURCE_ID); + + final LocalDate withdrawalDate = LocalDate.of(startDate.getYear(), 2, 16); + final String withdrawalStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(withdrawalDate); + savingsAccountHelper.withdrawalFromSavingsAccount(accountId, amountWithdrawal, withdrawalStr, + CommonConstants.RESPONSE_RESOURCE_ID); + + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(true)); + LocalDate marchDate = LocalDate.of(startDate.getYear(), 3, 1); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, marchDate); + + schedulerJobHelper.executeAndAwaitJob(jobName); + + List<HashMap> txs = getInterestTransactions(accountId); + for (HashMap tx : txs) { + BigDecimal amt = BigDecimal.valueOf(((Double) tx.get("amount"))); + @SuppressWarnings("unchecked") + Map<String, Object> typeMap = (Map<String, Object>) tx.get("transactionType"); + SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue()); + + if (type.isInterestPosting()) { + long days = ChronoUnit.DAYS.between(startDate, withdrawalDate); + BigDecimal expected = calcInterestPosting(productHelper, amountDeposit, days); + Assertions.assertEquals(expected, amt); + } else { + long days = ChronoUnit.DAYS.between(withdrawalDate, marchDate); + BigDecimal overdraftBase = new BigDecimal(amountWithdrawal).subtract(new BigDecimal(amountDeposit)); + BigDecimal expected = calcOverdraftPosting(productHelper, overdraftBase.toString(), days); + Assertions.assertEquals(expected, amt); + } + } + + Assertions.assertEquals(1L, countInterestOnDate(accountId, marchDate), "Expected exactly one INTEREST posting on posting date"); + Assertions.assertEquals(1L, countOverdraftOnDate(accountId, marchDate), + "Expected exactly one OVERDRAFT posting on posting date"); + } finally { + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(false)); + } + } + + @Test + public void testOverdraftAndInterestPosting_WithOverdraftProduct_WhitBalanceGreaterZero() { + try { + final String amountDeposit = "20000"; + final String amountWithdrawal = "10000"; + final String jobName = "Post Interest For Savings"; + + final Account assetAccount = accountHelper.createAssetAccount(); + final Account incomeAccount = accountHelper.createIncomeAccount(); + final Account expenseAccount = accountHelper.createExpenseAccount(); + final Account liabilityAccount = accountHelper.createLiabilityAccount(); + final Account interestReceivableAccount = accountHelper.createAssetAccount("interestReceivableAccount"); + final Account savingsControlAccount = accountHelper.createLiabilityAccount("Savings Control"); + final Account interestPayableAccount = accountHelper.createLiabilityAccount("Interest Payable"); + + final Integer productId = createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed( + interestPayableAccount.getAccountID().toString(), savingsControlAccount.getAccountID().toString(), + interestReceivableAccount.getAccountID().toString(), assetAccount, incomeAccount, expenseAccount, liabilityAccount); + + final Integer clientId = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2025"); + final LocalDate startDate = LocalDate.of(LocalDate.now(Utils.getZoneIdOfTenant()).getYear(), 2, 1); + final String startStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + + final Integer accountId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, productId, + SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startStr); + savingsAccountHelper.approveSavingsOnDate(accountId, startStr); + savingsAccountHelper.activateSavings(accountId, startStr); + savingsAccountHelper.withdrawalFromSavingsAccount(accountId, amountWithdrawal, startStr, CommonConstants.RESPONSE_RESOURCE_ID); + + final LocalDate depositDate = LocalDate.of(startDate.getYear(), 2, 16); + final String depositStr = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(depositDate); + savingsAccountHelper.depositToSavingsAccount(accountId, amountDeposit, depositStr, CommonConstants.RESPONSE_RESOURCE_ID); + + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(true)); + LocalDate marchDate = LocalDate.of(startDate.getYear(), 3, 1); + BusinessDateHelper.updateBusinessDate(requestSpec, responseSpec, BusinessDateType.BUSINESS_DATE, marchDate); + + schedulerJobHelper.executeAndAwaitJob(jobName); + + List<HashMap> txs = getInterestTransactions(accountId); + for (HashMap tx : txs) { + BigDecimal amt = BigDecimal.valueOf(((Double) tx.get("amount"))); + @SuppressWarnings("unchecked") + Map<String, Object> typeMap = (Map<String, Object>) tx.get("transactionType"); + SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue()); + + if (type.isOverDraftInterestPosting()) { + long days = ChronoUnit.DAYS.between(startDate, depositDate); + BigDecimal expected = calcOverdraftPosting(productHelper, amountWithdrawal, days); + Assertions.assertEquals(expected, amt); + } else { + long days = ChronoUnit.DAYS.between(depositDate, marchDate); + BigDecimal positiveBase = new BigDecimal(amountDeposit).subtract(new BigDecimal(amountWithdrawal)); + BigDecimal expected = calcInterestPosting(productHelper, positiveBase.toString(), days); + Assertions.assertEquals(expected, amt); + } + } + + Assertions.assertEquals(1L, countOverdraftOnDate(accountId, marchDate), + "Expected exactly one OVERDRAFT posting on posting date"); + Assertions.assertEquals(1L, countInterestOnDate(accountId, marchDate), "Expected exactly one INTEREST posting on posting date"); + } finally { + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, + new PutGlobalConfigurationsRequest().enabled(false)); + } + } + + private List<HashMap> getInterestTransactions(Integer savingsAccountId) { + List<HashMap> all = savingsAccountHelper.getSavingsTransactions(savingsAccountId); + List<HashMap> filtered = new ArrayList<>(); + for (HashMap tx : all) { + @SuppressWarnings("unchecked") + Map<String, Object> txType = (Map<String, Object>) tx.get("transactionType"); + SavingsAccountTransactionType type = SavingsAccountTransactionType.fromInt(((Double) txType.get("id")).intValue()); + if (type.isInterestPosting() || type.isOverDraftInterestPosting()) { + filtered.add(tx); + } + } + return filtered; + } + + public Integer createSavingsProductWithAccrualAccountingWithOutOverdraftAllowed(final String interestPayableAccount, + final String savingsControlAccount, final String interestReceivableAccount, final Account... accounts) { + + LOG.info("------------------------------CREATING NEW SAVINGS PRODUCT WITHOUT OVERDRAFT ---------------------------------------"); + this.productHelper = new SavingsProductHelper().withOverDraftRate("100000", "21") + .withAccountInterestReceivables(interestReceivableAccount).withSavingsControlAccountId(savingsControlAccount) + .withInterestPayableAccountId(interestPayableAccount).withInterestCompoundingPeriodTypeAsAnnually() + .withInterestPostingPeriodTypeAsMonthly().withInterestCalculationPeriodTypeAsDailyBalance() + .withAccountingRuleAsAccrualBased(accounts); + + final String savingsProductJSON = this.productHelper.build(); + return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + } + + private BigDecimal calcInterestPosting(SavingsProductHelper productHelper, String amount, long days) { + BigDecimal rate = productHelper.getNominalAnnualInterestRate().divide(new BigDecimal("100.00")); + BigDecimal principal = new BigDecimal(amount); + BigDecimal dayFactor = BigDecimal.ONE.divide(productHelper.getInterestCalculationDaysInYearType(), MathContext.DECIMAL64); + BigDecimal dailyRate = rate.multiply(dayFactor, MathContext.DECIMAL64); + BigDecimal periodRate = dailyRate.multiply(BigDecimal.valueOf(days), MathContext.DECIMAL64); + return principal.multiply(periodRate, MathContext.DECIMAL64).setScale(productHelper.getDecimalCurrency(), RoundingMode.HALF_EVEN); + } + + private BigDecimal calcOverdraftPosting(SavingsProductHelper productHelper, String amount, long days) { + BigDecimal rate = productHelper.getNominalAnnualInterestRateOverdraft().divide(new BigDecimal("100.00")); + BigDecimal principal = new BigDecimal(amount); + BigDecimal dayFactor = BigDecimal.ONE.divide(productHelper.getInterestCalculationDaysInYearType(), MathContext.DECIMAL64); + BigDecimal dailyRate = rate.multiply(dayFactor, MathContext.DECIMAL64); + BigDecimal periodRate = dailyRate.multiply(BigDecimal.valueOf(days), MathContext.DECIMAL64); + return principal.multiply(periodRate, MathContext.DECIMAL64).setScale(productHelper.getDecimalCurrency(), RoundingMode.HALF_EVEN); + } + + // ========================= + // Helpers robustos de FECHA/TIPO + // ========================= + + @SuppressWarnings("unchecked") + private LocalDate coerceToLocalDate(HashMap tx) { + // Posibles claves de fecha devueltas por Fineract + String[] candidateKeys = new String[] { "date", "transactionDate", "submittedOnDate", "createdDate" }; + + for (String key : candidateKeys) { + Object v = tx.get(key); + if (v == null) { + continue; + } + + // Caso 1: arreglo [yyyy, MM, dd] + if (v instanceof List<?>) { + List<?> arr = (List<?>) v; + if (arr.size() >= 3 && arr.get(0) instanceof Number && arr.get(1) instanceof Number && arr.get(2) instanceof Number) { + int year = ((Number) arr.get(0)).intValue(); + int month = ((Number) arr.get(1)).intValue(); + int day = ((Number) arr.get(2)).intValue(); + return LocalDate.of(year, month, day); + } + } + + // Caso 2: string con distintos formatos + if (v instanceof String) { + String s = (String) v; + DateTimeFormatter[] fmts = new DateTimeFormatter[] { DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US), + DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.US), DateTimeFormatter.ofPattern("yyyy-MM-dd") }; + for (DateTimeFormatter f : fmts) { + try { + return LocalDate.parse(s, f); + } catch (Exception ignore) { + // intentionally ignored + } + } + } + } + // Si ninguna clave/forma se pudo parsear + return null; + } + + private boolean isDate(HashMap tx, LocalDate expected) { + LocalDate got = coerceToLocalDate(tx); + return got != null && got.isEqual(expected); + } + + @SuppressWarnings("unchecked") + private SavingsAccountTransactionType txType(HashMap tx) { + Map<String, Object> typeMap = (Map<String, Object>) tx.get("transactionType"); + return SavingsAccountTransactionType.fromInt(((Double) typeMap.get("id")).intValue()); + } + + private long countInterestOnDate(Integer accountId, LocalDate date) { + List<HashMap> all = savingsAccountHelper.getSavingsTransactions(accountId); + return all.stream().filter(tx -> isDate(tx, date)).map(this::txType).filter(SavingsAccountTransactionType::isInterestPosting) + .count(); + } + + private long countOverdraftOnDate(Integer accountId, LocalDate date) { + List<HashMap> all = savingsAccountHelper.getSavingsTransactions(accountId); + return all.stream().filter(tx -> isDate(tx, date)).map(this::txType) + .filter(SavingsAccountTransactionType::isOverDraftInterestPosting).count(); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/savings/SavingsProductHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/savings/SavingsProductHelper.java index 2aa7725cc0..89a4f7487d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/savings/SavingsProductHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/savings/SavingsProductHelper.java @@ -168,8 +168,8 @@ public class SavingsProductHelper { map.put("interestReceivableAccountId", this.interestReceivableAccountId); } if (this.accountingRule.equals(ACCRUAL_PERIODIC)) { - if (this.interestReceivableAccountId != null) { - map.put("interestReceivableAccountId", this.interestReceivableAccountId); + if (this.savingsControlAccountId != null) { + map.put("savingsControlAccountId", this.savingsControlAccountId); } } @@ -202,6 +202,11 @@ public class SavingsProductHelper { return this; } + public SavingsProductHelper withInterestCompoundingPeriodTypeAsAnnually() { + this.interestCompoundingPeriodType = ANNUAL; + return this; + } + public SavingsProductHelper withInterestPostingPeriodTypeAsMonthly() { this.interestPostingPeriodType = MONTHLY; return this; @@ -357,6 +362,10 @@ public class SavingsProductHelper { return new BigDecimal(nominalAnnualInterestRate); } + public BigDecimal getNominalAnnualInterestRateOverdraft() { + return new BigDecimal(nominalAnnualInterestRateOverdraft); + } + public BigDecimal getInterestCalculationDaysInYearType() { return new BigDecimal(interestCalculationDaysInYearType); }