FINERACT-2312: Accruals added for savings accounts (#4885)

This commit is contained in:
Christopher Sayhaed Giron Vega
2025-08-20 15:00:09 -06:00
committed by GitHub
parent c936c19b85
commit b349cf6831
24 changed files with 1074 additions and 5 deletions
@@ -58,7 +58,7 @@ public enum JobName {
PURGE_EXTERNAL_EVENTS("Purge External Events"), //
PURGE_PROCESSED_COMMANDS("Purge Processed Commands"), //
ACCRUAL_ACTIVITY_POSTING("Accrual Activity Posting"), //
;
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_SAVINGS_WITH_INCOME_POSTED_AS_TRANSACTIONS("Add Accrual Transactions For Savings"); //
private final String name;
@@ -104,6 +104,7 @@ public class SavingsApiConstants {
public static final String activeParamName = "active";
public static final String nameParamName = "name";
public static final String shortNameParamName = "shortName";
public static final String interestReceivableAccount = "interestReceivableAccountId";
public static final String descriptionParamName = "description";
public static final String currencyCodeParamName = "currencyCode";
public static final String digitsAfterDecimalParamName = "digitsAfterDecimal";
@@ -64,6 +64,10 @@ public final class PostingPeriod {
private Integer financialYearBeginningMonth;
public void setOverdraftInterestRateAsFraction(BigDecimal overdraftInterestRateAsFraction) {
this.overdraftInterestRateAsFraction = overdraftInterestRateAsFraction;
}
public static PostingPeriod createFrom(final LocalDateInterval periodInterval, final Money periodStartingBalance,
final List<SavingsAccountTransactionDetailsForPostingPeriod> orderedListOfTransactions, final MonetaryCurrency currency,
final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType,
@@ -545,4 +549,10 @@ public final class PostingPeriod {
return this.financialYearBeginningMonth;
}
// public List<CompoundingPeriod> getCompoundingPeriods() {return compoundingPeriods;}
public Money getClosingBalance() {
return closingBalance;
}
}
@@ -28,6 +28,7 @@ import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivi
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
import org.apache.fineract.accounting.journalentry.data.SavingsDTO;
import org.apache.fineract.accounting.journalentry.data.SavingsTransactionDTO;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.office.domain.Office;
import org.springframework.stereotype.Component;
@@ -182,9 +183,21 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
else if (savingsTransactionDTO.getTransactionType().isAccrual()) {
// Post journal entry for Accrual Recognition
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
AccrualAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(),
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
if (MathUtil.isGreaterThanZero(overdraftAmount)) {
this.helper.createAccrualBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode,
AccrualAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), savingsProductId, paymentTypeId, savingsId,
transactionId, transactionDate, amount, isReversal);
this.helper.createAccrualBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), savingsProductId, paymentTypeId, savingsId,
transactionId, transactionDate, amount, isReversal);
} else {
this.helper.createAccrualBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode,
AccrualAccountsForSavings.INTEREST_RECEIVABLE.getValue(), savingsProductId, paymentTypeId, savingsId,
transactionId, transactionDate, amount, isReversal);
this.helper.createAccrualBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode,
AccrualAccountsForSavings.INCOME_FROM_INTEREST.getValue(), savingsProductId, paymentTypeId, savingsId,
transactionId, transactionDate, amount, isReversal);
}
}
}
@@ -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.addaccrualtransactionforsavings;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.savings.service.SavingsAccrualWritePlatformService;
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 AddAccrualTransactionForSavingsConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private SavingsAccrualWritePlatformService savingsAccrualWritePlatformService;
@Bean
protected Step addAccrualTransactionForSavingsStep() {
return new StepBuilder(JobName.ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_SAVINGS_WITH_INCOME_POSTED_AS_TRANSACTIONS.name(), jobRepository)
.tasklet(addAccrualTransactionForSavingsTasklet(), transactionManager).build();
}
@Bean
public Job addAccrualTransactionForSavingsJob() {
return new JobBuilder(JobName.ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_SAVINGS_WITH_INCOME_POSTED_AS_TRANSACTIONS.name(), jobRepository)
.start(addAccrualTransactionForSavingsStep()).incrementer(new RunIdIncrementer()).build();
}
@Bean
public AddAccrualTransactionForSavingsTasklet addAccrualTransactionForSavingsTasklet() {
return new AddAccrualTransactionForSavingsTasklet(savingsAccrualWritePlatformService);
}
}
@@ -0,0 +1,50 @@
/**
* 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.addaccrualtransactionforsavings;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.MultiException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException;
import org.apache.fineract.portfolio.savings.service.SavingsAccrualWritePlatformService;
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;
@RequiredArgsConstructor
public class AddAccrualTransactionForSavingsTasklet implements Tasklet {
private final SavingsAccrualWritePlatformService savingsAccrualWritePlatformService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
try {
addPeriodicAccruals(DateUtils.getBusinessLocalDate());
} catch (MultiException e) {
throw new JobExecutionException(e);
}
return RepeatStatus.FINISHED;
}
private void addPeriodicAccruals(final LocalDate tilldate) throws MultiException {
savingsAccrualWritePlatformService.addAccrualEntries(tilldate);
}
}
@@ -64,7 +64,9 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountSubStatusEnumDat
import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
@@ -1386,4 +1388,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
public Long retrieveAccountIdByExternalId(final ExternalId externalId) {
return savingsAccountRepositoryWrapper.findIdByExternalId(externalId);
}
@Override
public List<SavingsAccrualData> retrievePeriodicAccrualData(LocalDate tillDate, SavingsAccount savings) {
Long savingsId = (savings != null) ? savings.getId() : null;
Integer status = SavingsAccountStatusType.ACTIVE.getValue();
Integer accountingRule = AccountingRuleType.ACCRUAL_PERIODIC.getValue();
return this.savingsAccountRepositoryWrapper.findAccrualData(tillDate, savingsId, status, accountingRule);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.service;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.exception.MultiException;
public interface SavingsAccrualWritePlatformService {
void addAccrualEntries(LocalDate tillDate) throws MultiException;
}
@@ -0,0 +1,182 @@
/**
* 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.MathContext;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
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.infrastructure.jobs.exception.JobExecutionException;
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.data.SavingsAccrualData;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.apache.fineract.portfolio.savings.domain.SavingsHelper;
import org.apache.fineract.portfolio.savings.domain.interest.CompoundInterestValues;
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class SavingsAccrualWritePlatformServiceImpl implements SavingsAccrualWritePlatformService {
private final SavingsAccountReadPlatformService savingsAccountReadPlatformService;
private final SavingsAccountAssembler savingsAccountAssembler;
private final SavingsAccountRepositoryWrapper savingsAccountRepository;
private final SavingsHelper savingsHelper;
private final ConfigurationDomainService configurationDomainService;
private final SavingsAccountDomainService savingsAccountDomainService;
@Transactional
@Override
public void addAccrualEntries(LocalDate tillDate) throws JobExecutionException {
final List<SavingsAccrualData> savingsAccrualData = savingsAccountReadPlatformService.retrievePeriodicAccrualData(tillDate, null);
final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth();
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final MathContext mc = MoneyHelper.getMathContext();
List<Throwable> errors = new ArrayList<>();
for (SavingsAccrualData savingsAccrual : savingsAccrualData) {
try {
if (savingsAccrual.getDepositType().isSavingsDeposit() && savingsAccrual.getIsAllowOverdraft()) {
if (!savingsAccrual.getIsTypeInterestReceivable()) {
continue;
}
}
SavingsAccount savingsAccount = savingsAccountAssembler.assembleFrom(savingsAccrual.getId(), false);
LocalDate fromDate = savingsAccrual.getAccruedTill();
if (fromDate == null) {
fromDate = savingsAccount.getActivationDate();
}
log.debug("Processing savings account {} from date {} till date {}", savingsAccrual.getAccountNo(), fromDate, tillDate);
addAccrualTransactions(savingsAccount, fromDate, tillDate, financialYearBeginningMonth,
isSavingsInterestPostingAtCurrentPeriodEnd, mc, null);
} catch (Exception e) {
log.error("Failed to add accrual transaction for savings {} : {}", savingsAccrual.getAccountNo(), e.getMessage());
errors.add(e.getCause());
}
}
if (!errors.isEmpty()) {
throw new JobExecutionException(errors);
}
}
private void addAccrualTransactions(SavingsAccount savingsAccount, final LocalDate fromDate, final LocalDate tillDate,
final Integer financialYearBeginningMonth, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final MathContext mc,
final Function<LocalDate, String> refNoProvider) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
existingTransactionIds.addAll(savingsAccount.findExistingTransactionIds());
existingReversedTransactionIds.addAll(savingsAccount.findExistingReversedTransactionIds());
List<LocalDate> postedAsOnTransactionDates = savingsAccount.getManualPostingDates();
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType
.fromInt(savingsAccount.getInterestCalculationType());
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
.fromInt(savingsAccount.getInterestPostingPeriodType());
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
.fromInt(savingsAccount.getInterestCalculationDaysInYearType());
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(fromDate, tillDate,
postingPeriodType, financialYearBeginningMonth, postedAsOnTransactionDates);
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
final MonetaryCurrency currency = savingsAccount.getCurrency();
Money periodStartingBalance = Money.zero(currency);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType
.fromInt(savingsAccount.getInterestCalculationType());
final BigDecimal interestRateAsFraction = savingsAccount.getEffectiveInterestRateAsFractionAccrual(mc, tillDate);
final Collection<Long> interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(savingsAccount.getId());
boolean isInterestTransfer = false;
final Money minBalanceForInterestCalculation = Money.of(currency, savingsAccount.getMinBalanceForInterestCalculation());
List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = savingsAccount
.toSavingsAccountTransactionDetailsForPostingPeriodList();
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
if (DateUtils.isDateInTheFuture(periodInterval.endDate())) {
continue;
}
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
final PostingPeriod postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance,
savingsAccountTransactionDetailsForPostingPeriodList, currency, compoundingPeriodType, interestCalculationType,
interestRateAsFraction, daysInYearType.getValue(), tillDate, interestPostTransactions, isInterestTransfer,
minBalanceForInterestCalculation, isSavingsInterestPostingAtCurrentPeriodEnd, isUserPosting,
financialYearBeginningMonth);
postingPeriod.setOverdraftInterestRateAsFraction(
savingsAccount.getNominalAnnualInterestRateOverdraft().divide(BigDecimal.valueOf(100), mc));
periodStartingBalance = postingPeriod.closingBalance();
allPostingPeriods.add(postingPeriod);
}
BigDecimal compoundedInterest = BigDecimal.ZERO;
BigDecimal unCompoundedInterest = BigDecimal.ZERO;
final CompoundInterestValues compoundInterestValues = new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
final List<LocalDate> accrualTransactionDates = savingsAccount.retrieveOrderedAccrualTransactions().stream()
.map(transaction -> transaction.getTransactionDate()).toList();
LocalDate accruedTillDate = fromDate;
for (PostingPeriod period : allPostingPeriods) {
period.calculateInterest(compoundInterestValues);
final LocalDate endDate = period.getPeriodInterval().endDate();
if (!accrualTransactionDates.contains(period.getPeriodInterval().endDate())
&& !MathUtil.isZero(period.closingBalance().getAmount())) {
String refNo = (refNoProvider != null) ? refNoProvider.apply(endDate) : null;
SavingsAccountTransaction savingsAccountTransaction = SavingsAccountTransaction.accrual(savingsAccount,
savingsAccount.office(), period.getPeriodInterval().endDate(), period.getInterestEarned().abs(), false, refNo);
savingsAccountTransaction.setRunningBalance(period.getClosingBalance());
savingsAccountTransaction.setOverdraftAmount(period.getInterestEarned());
savingsAccount.addTransaction(savingsAccountTransaction);
}
}
savingsAccount.setAccruedTillDate(accruedTillDate);
savingsAccountRepository.saveAndFlush(savingsAccount);
savingsAccountDomainService.postJournalEntries(savingsAccount, existingTransactionIds, existingReversedTransactionIds, false);
}
}
@@ -34,6 +34,7 @@
<!-- Add new module to the end of this modules list (to keep the existing auto-increment identifiers) -->
<include file="db/changelog/tenant/module/loan/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<include file="db/changelog/tenant/module/investor/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<include file="db/changelog/tenant/module/savings/parts/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<includeAll path="db/custom-changelog" errorIfMissingOrEmpty="false" context="tenant_db AND !initial_switch AND custom_changelog"/>
<include file="/db/changelog/tenant/module/progressiveloan/module-changelog-master.xml" context="tenant_db AND !initial_switch"/>
<!-- Scripts to run after the modules were initialized -->
@@ -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.data;
import java.time.LocalDate;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
@Data
@RequiredArgsConstructor
public class SavingsAccrualData {
private final Long id;
private final String accountNo;
private final LocalDate accruedTill;
private final Boolean isTypeInterestReceivable;
private final Boolean isAllowOverdraft;
private final Integer depositType;
public DepositAccountType getDepositType() {
final EnumOptionData depositType = SavingsEnumerations.depositType(this.depositType);
DepositAccountType depositAccountType = DepositAccountType.fromInt(depositType.getId().intValue());
return depositAccountType;
}
}
@@ -71,6 +71,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.configuration.service.TemporaryConfigurationServiceContainer;
@@ -337,6 +338,9 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
@JoinColumn(name = "tax_group_id")
private TaxGroup taxGroup;
@Column(name = "accrued_till_date")
private LocalDate accruedTillDate;
@Column(name = "total_savings_amount_on_hold", scale = 6, precision = 19, nullable = true)
private BigDecimal savingsOnHoldAmount;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY)
@@ -926,6 +930,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc);
}
public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) {
return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc);
}
@SuppressWarnings("unused")
protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate upToInterestCalculationDate) {
return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc);
@@ -939,6 +947,11 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return isAllowOverdraft() && !MathUtil.isEmpty(getOverdraftLimit()) && !MathUtil.isEmpty(nominalAnnualInterestRateOverdraft);
}
public List<SavingsAccountTransaction> retrieveOrderedAccrualTransactions() {
return retrieveListOfTransactions().stream().filter(SavingsAccountTransaction::isAccrual)
.sorted(new SavingsAccountTransactionComparator()).collect(Collectors.toList());
}
protected List<SavingsAccountTransaction> retreiveOrderedNonInterestPostingTransactions() {
final List<SavingsAccountTransaction> listOfTransactionsSorted = retrieveListOfTransactions();
@@ -3841,10 +3854,21 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return this.withHoldTax;
}
public void setAccruedTillDate(LocalDate accruedTillDate) {
this.accruedTillDate = accruedTillDate;
}
public List<SavingsAccountTransactionDetailsForPostingPeriod> toSavingsAccountTransactionDetailsForPostingPeriodList(
List<SavingsAccountTransaction> transactions) {
return transactions.stream()
.map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft))
.toList();
}
public List<SavingsAccountTransactionDetailsForPostingPeriod> toSavingsAccountTransactionDetailsForPostingPeriodList() {
return retreiveOrderedNonInterestPostingTransactions().stream()
.map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft))
.toList();
}
}
@@ -19,8 +19,10 @@
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.LockModeType;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -70,4 +72,26 @@ public interface SavingsAccountRepository extends JpaRepository<SavingsAccount,
@Query("SELECT sa.id FROM SavingsAccount sa WHERE sa.externalId = :externalId")
Long findIdByExternalId(@Param("externalId") ExternalId externalId);
@Query("""
SELECT new org.apache.fineract.portfolio.savings.data.SavingsAccrualData(
savings.id,
savings.accountNumber,
savings.accruedTillDate,
CASE WHEN apm.financialAccountType = 18 THEN TRUE ELSE FALSE END,
msp.allowOverdraft,
savings.depositType
)
FROM SavingsAccount savings
LEFT JOIN SavingsProduct msp ON msp = savings.product
LEFT JOIN ProductToGLAccountMapping apm ON apm.productId = msp.id and (apm.financialAccountType = 18 or apm.financialAccountType IS NULL)
WHERE savings.status = :status
AND (savings.nominalAnnualInterestRate IS NOT NULL AND savings.nominalAnnualInterestRate > 0)
AND msp.accountingRule = :accountingRule
AND ( savings.closedOnDate <= :tillDate OR savings.closedOnDate IS NULL)
AND ( savings.accruedTillDate <= :tillDate OR savings.accruedTillDate IS NULL )
ORDER BY savings.id
""")
List<SavingsAccrualData> findAccrualData(@Param("tillDate") LocalDate tillDate, @Param("savingsId") Long savingsId,
@Param("status") Integer status, @Param("accountingRule") Integer accountingRule);
}
@@ -22,6 +22,7 @@ import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
@@ -179,4 +180,10 @@ public class SavingsAccountRepositoryWrapper {
public Long findIdByExternalId(final ExternalId externalId) {
return this.repository.findIdByExternalId(externalId);
}
@Transactional(readOnly = true)
public List<SavingsAccrualData> findAccrualData(final LocalDate tillDate, final Long savingsId, final Integer status,
final Integer accountingRule) {
return this.repository.findAccrualData(tillDate, savingsId, status, accountingRule);
}
}
@@ -198,6 +198,14 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat
date, amount, isReversed, isManualTransaction, lienTransaction, refNo);
}
public static SavingsAccountTransaction accrual(final SavingsAccount savingsAccount, final Office office, final LocalDate date,
final Money amount, final boolean isManualTransaction, final String refNo) {
final boolean isReversed = false;
final Boolean lienTransaction = false;
return new SavingsAccountTransaction(savingsAccount, office, SavingsAccountTransactionType.ACCRUAL.getValue(), date, amount,
isReversed, isManualTransaction, lienTransaction, refNo);
}
public static SavingsAccountTransaction interestPosting(final SavingsAccount savingsAccount, final Office office, final LocalDate date,
final Money amount, final boolean isManualTransaction) {
final boolean isReversed = false;
@@ -415,7 +423,7 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat
return Money.of(currency, this.overdraftAmount);
}
void setOverdraftAmount(Money overdraftAmount) {
public void setOverdraftAmount(Money overdraftAmount) {
this.overdraftAmount = overdraftAmount == null ? null : overdraftAmount.getAmount();
}
@@ -511,6 +519,10 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat
return this.isDeposit() || this.isWithdrawal() || this.isChargeTransaction() || this.isDividendPayout() || this.isInterestPosting();
}
public boolean isAccrual() {
return getTransactionType().isAccrual();
}
public boolean isInterestPostingAndNotReversed() {
return getTransactionType().isInterestPosting() && isNotReversed();
}
@@ -27,6 +27,8 @@ import org.apache.fineract.infrastructure.core.service.SearchParameters;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.data.SavingsAccountData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
public interface SavingsAccountReadPlatformService {
@@ -69,4 +71,6 @@ public interface SavingsAccountReadPlatformService {
List<SavingsAccountTransactionData> retrieveAllTransactionData(List<String> refNo);
Long retrieveAccountIdByExternalId(ExternalId externalId);
List<SavingsAccrualData> retrievePeriodicAccrualData(LocalDate tillDate, SavingsAccount savings);
}
@@ -23,4 +23,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">
<!-- Sequence is starting from 2000 to make it easier to move existing liquibase changesets here -->
<include file="parts/2001_add_savings_accrual_job.xml" relativeToChangelogFile="true" />
<include file="parts/2002_add_savings_accrual_permission.xml" relativeToChangelogFile="true" />
<include file="parts/2003_add_accrued_till_date_to_savings_account.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,46 @@
<?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.3.xsd">
<changeSet author="fineract" id="1">
<insert tableName="job">
<column name="name" value="Add Accrual Transactions For Savings"/>
<column name="display_name" value="Add Accrual Transactions For Savings"/>
<column name="cron_expression" value="0 1 0 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Add Accrual Transactions For Savings _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="false"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="0"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
<column name="short_name" value="ADD_ATFS"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,34 @@
<?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.3.xsd">
<changeSet author="fineract" id="1">
<insert tableName="m_permission">
<column name="grouping" value="accounting"/>
<column name="code" value="EXECUTEFORSAVINGS"/>
<column name="entity_name" value="PERIODICACCRUALACCOUNTINGFORSAVINGS"/>
<column name="action_name" value="EXECUTE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,31 @@
<?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.3.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_savings_account">
<column name="accrued_till_date" type="DATE" defaultValueComputed="NULL" />
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,251 @@
/**
* 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.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.CommonConstants;
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.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 SavingsAccrualAccountingIntegrationTest {
private static final Logger LOG = LoggerFactory.getLogger(SavingsAccrualAccountingIntegrationTest.class);
private ResponseSpecification responseSpec;
private RequestSpecification requestSpec;
private SavingsAccountHelper savingsAccountHelper;
private SchedulerJobHelper schedulerJobHelper;
private JournalEntryHelper journalEntryHelper;
private AccountHelper accountHelper;
@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.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec);
this.schedulerJobHelper = new SchedulerJobHelper(this.requestSpec);
this.journalEntryHelper = new JournalEntryHelper(this.requestSpec, this.responseSpec);
this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec);
}
@Test
public void testPositiveAccrualPostsCorrectJournalEntries() {
// --- ARRANGE ---
LOG.info("------------------------- INITIATING POSITIVE ACCRUAL ACCOUNTING TEST -------------------------");
final int daysToSubtract = 10;
final String amount = "10000";
final Account savingsReferenceAccount = this.accountHelper.createAssetAccount("Savings Reference");
final Account interestOnSavingsAccount = this.accountHelper.createExpenseAccount("Interest on Savings (Expense)");
final Account savingsControlAccount = this.accountHelper.createLiabilityAccount("Savings Control");
final Account interestPayableAccount = this.accountHelper.createLiabilityAccount("Interest Payable (Liability)");
final Account incomeFromFeesAccount = this.accountHelper.createIncomeAccount("Income from Fees");
final Account[] accountList = { savingsReferenceAccount, savingsControlAccount, interestOnSavingsAccount, interestPayableAccount,
incomeFromFeesAccount };
final SavingsProductHelper productHelper = new SavingsProductHelper().withNominalAnnualInterestRate(new BigDecimal("10.0"))
.withAccountingRuleAsAccrualBased(accountList)
.withSavingsReferenceAccountId(savingsReferenceAccount.getAccountID().toString())
.withSavingsControlAccountId(savingsControlAccount.getAccountID().toString())
.withInterestOnSavingsAccountId(interestOnSavingsAccount.getAccountID().toString())
.withInterestPayableAccountId(interestPayableAccount.getAccountID().toString())
.withIncomeFromFeeAccountId(incomeFromFeesAccount.getAccountID().toString());
final Integer savingsProductId = SavingsProductHelper.createSavingsProduct(productHelper.build(), this.requestSpec,
this.responseSpec);
Assertions.assertNotNull(savingsProductId, "Failed to create savings product.");
final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020");
final LocalDate startDate = LocalDate.now(Utils.getZoneIdOfTenant()).minusDays(daysToSubtract);
final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate);
final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId,
SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString);
this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString);
this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString);
this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, amount, startDateString, CommonConstants.RESPONSE_RESOURCE_ID);
// --- ACT ---
schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings");
// --- ASSERT ---
List<HashMap> accrualTransactions = getAccrualTransactions(savingsAccountId);
Assertions.assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found.");
Number firstTransactionIdNumber = (Number) accrualTransactions.get(0).get("id");
ArrayList<HashMap> journalEntries = journalEntryHelper.getJournalEntriesByTransactionId("S" + firstTransactionIdNumber.intValue());
Assertions.assertFalse(journalEntries.isEmpty(), "No journal entries found for positive accrual.");
boolean debitFound = false;
boolean creditFound = false;
for (Map<String, Object> entry : journalEntries) {
String entryType = (String) ((HashMap) entry.get("entryType")).get("value");
Integer accountId = ((Number) entry.get("glAccountId")).intValue();
if ("DEBIT".equals(entryType) && accountId.equals(interestOnSavingsAccount.getAccountID())) {
debitFound = true;
}
if ("CREDIT".equals(entryType) && accountId.equals(interestPayableAccount.getAccountID())) {
creditFound = true;
}
}
Assertions.assertTrue(debitFound, "DEBIT to Interest on Savings (Expense) Account not found for positive accrual.");
Assertions.assertTrue(creditFound, "CREDIT to Interest Payable (Liability) Account not found for positive accrual.");
BigDecimal interest = getCalculateAccrualsForDay(productHelper, amount);
for (HashMap accrual : accrualTransactions) {
BigDecimal amountAccrualTransaccion = BigDecimal.valueOf((Double) accrual.get("amount"));
Assertions.assertEquals(interest, amountAccrualTransaccion);
}
LOG.info("VALIDATE AMOUNT AND ACCOUNT");
}
@Test
public void testNegativeAccrualPostsCorrectJournalEntries() {
// --- ARRANGE ---
LOG.info("------------------------- INITIATING NEGATIVE ACCRUAL (OVERDRAFT) ACCOUNTING TEST -------------------------");
final int daysToSubtract = 10;
final String amount = "10000";
final Account savingsReferenceAccount = this.accountHelper.createAssetAccount("Savings Reference");
final Account overdraftPortfolioControl = this.accountHelper.createAssetAccount("Overdraft Portfolio");
final Account interestReceivableAccount = this.accountHelper.createAssetAccount("Interest Receivable (Asset)");
final Account savingsControlAccount = this.accountHelper.createLiabilityAccount("Savings Control");
final Account interestPayableAccount = this.accountHelper.createLiabilityAccount("Interest Payable");
final Account overdraftInterestIncomeAccount = this.accountHelper.createIncomeAccount("Overdraft Interest Income");
final Account expenseAccount = this.accountHelper.createExpenseAccount("Interest on Savings (Expense)");
final Account[] accountList = { savingsReferenceAccount, savingsControlAccount, expenseAccount, overdraftInterestIncomeAccount };
final String overdraftLimit = "10000";
final String overdraftInterestRate = "21.0";
final SavingsProductHelper productHelper = new SavingsProductHelper()
.withNominalAnnualInterestRate(new BigDecimal(overdraftInterestRate)).withAccountingRuleAsAccrualBased(accountList)
.withOverDraftRate(overdraftLimit, overdraftInterestRate)
.withSavingsReferenceAccountId(savingsReferenceAccount.getAccountID().toString())
.withSavingsControlAccountId(savingsControlAccount.getAccountID().toString())
.withInterestReceivableAccountId(interestReceivableAccount.getAccountID().toString())
.withIncomeFromInterestId(overdraftInterestIncomeAccount.getAccountID().toString())
.withInterestPayableAccountId(interestPayableAccount.getAccountID().toString())
.withInterestOnSavingsAccountId(expenseAccount.getAccountID().toString())
.withOverdraftPortfolioControlId(overdraftPortfolioControl.getAccountID().toString());
final Integer savingsProductId = SavingsProductHelper.createSavingsProduct(productHelper.build(), this.requestSpec,
this.responseSpec);
Assertions.assertNotNull(savingsProductId, "Savings product with overdraft creation failed.");
final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020");
final LocalDate startDate = LocalDate.now(Utils.getZoneIdOfTenant()).minusDays(daysToSubtract);
final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate);
final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId,
SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString);
this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString);
this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString);
this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsAccountId, "10000", startDateString,
CommonConstants.RESPONSE_RESOURCE_ID);
// --- ACT ---
schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings");
// --- ASSERT ---
List<HashMap> accrualTransactions = getAccrualTransactions(savingsAccountId);
Assertions.assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found for overdraft.");
Number firstTransactionIdNumber = (Number) accrualTransactions.get(0).get("id");
ArrayList<HashMap> journalEntries = journalEntryHelper.getJournalEntriesByTransactionId("S" + firstTransactionIdNumber.intValue());
Assertions.assertFalse(journalEntries.isEmpty(), "No journal entries found for negative accrual.");
boolean debitFound = false;
boolean creditFound = false;
for (Map<String, Object> entry : journalEntries) {
String entryType = (String) ((HashMap) entry.get("entryType")).get("value");
Integer accountId = ((Number) entry.get("glAccountId")).intValue();
if ("DEBIT".equals(entryType) && accountId.equals(interestReceivableAccount.getAccountID())) {
debitFound = true;
}
if ("CREDIT".equals(entryType) && accountId.equals(overdraftInterestIncomeAccount.getAccountID())) {
creditFound = true;
}
}
Assertions.assertTrue(debitFound, "DEBIT to Interest Receivable (Asset) Account not found for negative accrual.");
Assertions.assertTrue(creditFound, "CREDIT to Overdraft Interest Income Account not found for negative accrual.");
BigDecimal interest = getCalculateAccrualsForDay(productHelper, amount);
for (HashMap accrual : accrualTransactions) {
BigDecimal amountAccrualTransaccion = BigDecimal.valueOf((Double) accrual.get("amount"));
Assertions.assertEquals(interest, amountAccrualTransaccion);
}
LOG.info("VALIDATE AMOUNT AND ACCOUNT");
}
private List<HashMap> getAccrualTransactions(Integer savingsAccountId) {
List<HashMap> allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId);
List<HashMap> accrualTransactions = new ArrayList<>();
for (HashMap transaction : allTransactions) {
Map<String, Object> type = (Map<String, Object>) transaction.get("transactionType");
if (type != null && Boolean.TRUE.equals(type.get("accrual"))) {
accrualTransactions.add(transaction);
}
}
return accrualTransactions;
}
private BigDecimal getCalculateAccrualsForDay(SavingsProductHelper productHelper, String amount) {
BigDecimal interest = BigDecimal.ZERO;
BigDecimal interestRateAsFraction = productHelper.getNominalAnnualInterestRate().divide(new BigDecimal(100.00));
BigDecimal realBalanceForInterestCalculation = new BigDecimal(amount);
final BigDecimal multiplicand = BigDecimal.ONE.divide(productHelper.getInterestCalculationDaysInYearType(), MathContext.DECIMAL64);
final BigDecimal dailyInterestRate = interestRateAsFraction.multiply(multiplicand, MathContext.DECIMAL64);
final BigDecimal periodicInterestRate = dailyInterestRate.multiply(BigDecimal.valueOf(1), MathContext.DECIMAL64);
interest = realBalanceForInterestCalculation.multiply(periodicInterestRate, MathContext.DECIMAL64)
.setScale(productHelper.getDecimalCurrency(), RoundingMode.HALF_EVEN);
return interest;
}
}
@@ -0,0 +1,145 @@
/**
* 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.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.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.CommonConstants;
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.integrationtests.common.savings.SavingsStatusChecker;
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 SavingsAccrualIntegrationTest {
private static final Logger LOG = LoggerFactory.getLogger(SavingsAccrualIntegrationTest.class);
private ResponseSpecification responseSpec;
private RequestSpecification requestSpec;
private SavingsAccountHelper savingsAccountHelper;
private SchedulerJobHelper schedulerJobHelper;
private JournalEntryHelper journalEntryHelper;
private AccountHelper accountHelper;
@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.savingsAccountHelper = new SavingsAccountHelper(this.requestSpec, this.responseSpec);
this.schedulerJobHelper = new SchedulerJobHelper(this.requestSpec);
this.journalEntryHelper = new JournalEntryHelper(this.requestSpec, this.responseSpec);
this.accountHelper = new AccountHelper(this.requestSpec, this.responseSpec);
}
@Test
public void testAccrualsAreGeneratedForTenDayPeriod() {
// --- ARRANGE ---
final Account assetAccount = this.accountHelper.createAssetAccount();
final Account liabilityAccount = this.accountHelper.createLiabilityAccount();
final Account incomeAccount = this.accountHelper.createIncomeAccount();
final Account expenseAccount = this.accountHelper.createExpenseAccount();
final String interestRate = "10.0";
final int daysToTest = 10;
final SavingsProductHelper productHelper = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily()
.withInterestPostingPeriodTypeAsMonthly().withInterestCalculationPeriodTypeAsDailyBalance()
.withNominalAnnualInterestRate(new BigDecimal(interestRate))
.withAccountingRuleAsAccrualBased(new Account[] { assetAccount, liabilityAccount, incomeAccount, expenseAccount });
final Integer savingsProductId = SavingsProductHelper.createSavingsProduct(productHelper.build(), this.requestSpec,
this.responseSpec);
Assertions.assertNotNull(savingsProductId, "Error creating savings product.");
final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020");
Assertions.assertNotNull(clientId, "Error creating client.");
final LocalDate startDate = LocalDate.now(Utils.getZoneIdOfTenant()).minusDays(daysToTest);
final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate);
final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId,
SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString);
Assertions.assertNotNull(savingsAccountId, "Error applying for savings account.");
this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString);
this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString);
final HashMap<String, Object> savingsStatus = SavingsStatusChecker.getStatusOfSavings(this.requestSpec, this.responseSpec,
savingsAccountId);
SavingsStatusChecker.verifySavingsIsActive(savingsStatus);
this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, "10000", startDateString, CommonConstants.RESPONSE_RESOURCE_ID);
// --- ACT ---
schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings");
// --- ASSERT ---
List<HashMap> allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId);
List<HashMap> accrualTransactions = new ArrayList<>();
for (HashMap transaction : allTransactions) {
Map<String, Object> type = (Map<String, Object>) transaction.get("transactionType");
if (type != null && Boolean.TRUE.equals(type.get("accrual"))) {
accrualTransactions.add(transaction);
}
}
Assertions.assertFalse(accrualTransactions.isEmpty(), "No accrual transactions were found.");
long daysBetween = ChronoUnit.DAYS.between(startDate, LocalDate.now(Utils.getZoneIdOfTenant()));
long actualNumberOfTransactions = accrualTransactions.size();
Assertions.assertTrue(actualNumberOfTransactions >= daysBetween && actualNumberOfTransactions <= daysBetween + 1, "For a period of "
+ daysBetween + " days, a close number of transactions was expected, but found " + actualNumberOfTransactions);
BigDecimal principal = new BigDecimal("10000");
BigDecimal rate = new BigDecimal(interestRate).divide(new BigDecimal(100));
BigDecimal daysInYear = new BigDecimal("365");
BigDecimal expectedTotalAccrual = principal.multiply(rate).divide(daysInYear, 8, RoundingMode.HALF_EVEN)
.multiply(new BigDecimal(actualNumberOfTransactions)).setScale(2, RoundingMode.HALF_EVEN);
BigDecimal actualTotalAccrual = savingsAccountHelper.getTotalAccrualAmount(savingsAccountId);
Assertions.assertEquals(0, expectedTotalAccrual.compareTo(actualTotalAccrual),
"The total accrual (" + actualTotalAccrual + ") does not match the expected (" + expectedTotalAccrual + ")");
}
}
@@ -50,6 +50,7 @@ import org.apache.fineract.client.util.JSON;
import org.apache.fineract.integrationtests.common.CommonConstants;
import org.apache.fineract.integrationtests.common.FineractClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.accounting.Account;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.Workbook;
import org.junit.jupiter.api.Assertions;
@@ -1475,4 +1476,29 @@ public class SavingsAccountHelper {
return Utils.performServerGet(requestSpec, responseSpec, url, "");
}
public Integer createSavingsProductWithAccrualAccounting(final Account assetAccount, final Account liabilityAccount,
final Account incomeAccount, final Account expenseAccount, final String interestRate) {
SavingsProductHelper productHelper = new SavingsProductHelper();
final Account[] accountList = { assetAccount, liabilityAccount, incomeAccount, expenseAccount };
final String savingsProductJSON = productHelper.withInterestCompoundingPeriodTypeAsDaily().withInterestPostingPeriodTypeAsMonthly()
.withInterestCalculationPeriodTypeAsDailyBalance().withAccountingRuleAsAccrualBased(accountList)
.withNominalAnnualInterestRate(new BigDecimal(interestRate)).build();
return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec);
}
public BigDecimal getTotalAccrualAmount(Integer savingsId) {
List<HashMap> transactions = getSavingsTransactions(savingsId);
BigDecimal total = BigDecimal.ZERO;
for (HashMap tx : transactions) {
Map<String, Object> type = (Map<String, Object>) tx.get("transactionType");
if (type != null && Boolean.TRUE.equals(type.get("accrual"))) {
total = total.add(new BigDecimal(String.valueOf(tx.get("amount"))));
}
}
return total.setScale(2, java.math.RoundingMode.HALF_UP);
}
}
@@ -101,6 +101,7 @@ public class SavingsProductHelper {
private Boolean withgsimID = null;
private Integer gsimID = null;
private String nominalAnnualInterestRateOverdraft = null;
private String interestPayableAccountId;
private String interestReceivableAccountId = null;
// TODO: Rewrite to use fineract-client instead!
@@ -163,6 +164,14 @@ public class SavingsProductHelper {
map.put("daysToEscheat", this.daysToEscheat);
}
if (this.accountingRule.equals(ACCRUAL_PERIODIC) && this.interestReceivableAccountId != null) {
map.put("interestReceivableAccountId", this.interestReceivableAccountId);
}
if (this.accountingRule.equals(ACCRUAL_PERIODIC)) {
if (this.interestReceivableAccountId != null) {
map.put("interestReceivableAccountId", this.interestReceivableAccountId);
}
}
String savingsProductCreateJson = new Gson().toJson(map);
LOG.info("{}", savingsProductCreateJson);
@@ -304,6 +313,58 @@ public class SavingsProductHelper {
return this;
}
public SavingsProductHelper withSavingsReferenceAccountId(final String savingsReferenceAccountId) {
this.savingsReferenceAccountId = savingsReferenceAccountId;
return this;
}
public SavingsProductHelper withSavingsControlAccountId(final String savingsControlAccountId) {
this.savingsControlAccountId = savingsControlAccountId;
return this;
}
public SavingsProductHelper withInterestOnSavingsAccountId(final String interestOnSavingsAccountId) {
this.interestOnSavingsAccountId = interestOnSavingsAccountId;
return this;
}
public SavingsProductHelper withIncomeFromFeeAccountId(final String incomeFromFeeAccountId) {
this.incomeFromFeeAccountId = incomeFromFeeAccountId;
return this;
}
public SavingsProductHelper withInterestPayableAccountId(final String interestPayableAccountId) {
this.interestPayableAccountId = interestPayableAccountId;
return this;
}
public SavingsProductHelper withOverdraftPortfolioControlId(final String overdraftPortfolioControlId) {
this.overdraftPortfolioControlId = overdraftPortfolioControlId;
return this;
}
public SavingsProductHelper withInterestReceivableAccountId(final String interestReceivableAccountId) {
this.interestReceivableAccountId = interestReceivableAccountId;
return this;
}
public SavingsProductHelper withIncomeFromInterestId(final String incomeFromInterestId) {
this.incomeFromInterestId = incomeFromInterestId;
return this;
}
public BigDecimal getNominalAnnualInterestRate() {
return new BigDecimal(nominalAnnualInterestRate);
}
public BigDecimal getInterestCalculationDaysInYearType() {
return new BigDecimal(interestCalculationDaysInYearType);
}
public Integer getDecimalCurrency() {
return Integer.parseInt(DIGITS_AFTER_DECIMAL);
}
// TODO: Rewrite to use fineract-client instead!
// Example: org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper.disburseLoan(java.lang.Long,
// org.apache.fineract.client.models.PostLoansLoanIdRequest)