FINERACT-2274: Rework Accrual service

This commit is contained in:
Oleksii Novikov
2025-06-01 18:03:22 +02:00
committed by Adam Saghy
parent fa405f09fd
commit f6f8c6f4a5
11 changed files with 419 additions and 225 deletions
@@ -0,0 +1,24 @@
/**
* 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.loanaccount.data;
import java.math.BigDecimal;
public record CumulativeIncomeFromIncomePosting(BigDecimal interestAmount, BigDecimal feeAmount, BigDecimal penaltyAmount) {
}
@@ -0,0 +1,34 @@
/**
* 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.loanaccount.data;
import java.math.BigDecimal;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
public interface TransactionPortionsForForeclosure {
LoanTransactionType getTransactionType();
BigDecimal getInterestPortion();
BigDecimal getFeeChargesPortion();
BigDecimal getPenaltyChargesPortion();
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.loanaccount.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
@@ -25,7 +26,18 @@ import org.springframework.data.repository.query.Param;
public interface LoanChargePaidByRepository extends JpaRepository<LoanChargePaidBy, Long>, JpaSpecificationExecutor<LoanCharge> {
@Query("select lp from LoanChargePaidBy lp where lp.loanCharge=:loanCharge and lp.installmentNumber=:installmentNumber")
LoanChargePaidBy getLoanChargePaidByLoanCharge(@Param("loanCharge") LoanCharge loanCharge,
@Param("installmentNumber") Integer installmentNo);
@Query("""
SELECT lcpb
FROM LoanChargePaidBy lcpb
JOIN lcpb.loanTransaction lt
JOIN lcpb.loanCharge lc
WHERE lt.loan = :loan
AND lt.reversed = false
AND (lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL
OR lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL_ADJUSTMENT)
AND (lt.feeChargesPortion > 0 OR lt.penaltyChargesPortion > 0)
AND lcpb.installmentNumber IS NULL
""")
List<LoanChargePaidBy> findChargePaidByMappingsWithoutInstallmentNumber(@Param("loan") Loan loan);
}
@@ -18,12 +18,16 @@
*/
package org.apache.fineract.portfolio.loanaccount.domain;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.portfolio.loanaccount.data.CumulativeIncomeFromIncomePosting;
import org.apache.fineract.portfolio.loanaccount.data.LoanScheduleDelinquencyData;
import org.apache.fineract.portfolio.loanaccount.data.TransactionPortionsForForeclosure;
import org.apache.fineract.portfolio.loanaccount.data.UnpaidChargeData;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -32,31 +36,8 @@ import org.springframework.data.repository.query.Param;
public interface LoanTransactionRepository extends JpaRepository<LoanTransaction, Long>, JpaSpecificationExecutor<LoanTransaction> {
String FIND_ID_BY_EXTERNAL_ID = "SELECT lt.id FROM LoanTransaction lt WHERE lt.externalId = :externalId";
String FIND_LOAN_ID_BY_ID = "SELECT lt.loan.id FROM LoanTransaction lt WHERE lt.id = :id";
Optional<LoanTransaction> findByIdAndLoanId(Long transactionId, Long loanId);
@Query("""
SELECT lt FROM LoanTransaction lt
WHERE lt.loan.id = :loanId
AND lt.typeOf IN :transactionTypes
AND lt.id NOT IN :existingTransactionIds
AND lt.reversed = false
""")
List<LoanTransaction> findNewTransactionsByLoanIdAndTypeAndNotInIds(@Param("loanId") Long loanId,
@Param("transactionTypes") List<LoanTransactionType> transactionTypes,
@Param("existingTransactionIds") List<Long> existingTransactionIds);
@Query("""
SELECT lt FROM LoanTransaction lt
WHERE lt.loan.id = :loanId
AND lt.typeOf IN :transactionTypes
AND lt.reversed = false
""")
List<LoanTransaction> findNewTransactionsByLoanIdAndType(@Param("loanId") Long loanId,
@Param("transactionTypes") List<LoanTransactionType> transactionTypes);
@Query("""
SELECT new org.apache.fineract.portfolio.loanaccount.data.LoanScheduleDelinquencyData(
lt.loan.id,
@@ -72,7 +53,7 @@ public interface LoanTransactionRepository extends JpaRepository<LoanTransaction
Collection<LoanScheduleDelinquencyData> fetchLoanTransactionsByTypeAndLessOrEqualDate(
@Param("transactionType") LoanTransactionType transactionType, @Param("businessDate") LocalDate businessDate);
@Query(FIND_ID_BY_EXTERNAL_ID)
@Query("SELECT lt.id FROM LoanTransaction lt WHERE lt.externalId = :externalId")
Long findIdByExternalId(@Param("externalId") ExternalId externalId);
@Query("""
@@ -88,9 +69,41 @@ public interface LoanTransactionRepository extends JpaRepository<LoanTransaction
""")
List<UnpaidChargeData> fetchTotalUnpaidChargesForLoan(@Param("loan") Loan loan);
@Query(FIND_LOAN_ID_BY_ID)
@Query("SELECT lt.loan.id FROM LoanTransaction lt WHERE lt.id = :id")
Optional<Long> findLoanIdById(@Param("id") Long id);
@Query("""
SELECT COALESCE(SUM(lt.unrecognizedIncomePortion), 0)
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.WAIVE_INTEREST
AND lt.reversed = false
AND lt.dateOf <= :toDate
""")
BigDecimal findTotalUnrecognizedIncomeFromInterestWaiverByLoanAndDate(@Param("loan") Loan loan, @Param("toDate") LocalDate toDate);
@Query("""
SELECT COALESCE(SUM(CASE WHEN lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL THEN lt.interestPortion
WHEN lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL_ADJUSTMENT THEN -lt.interestPortion
ELSE 0 END), 0)
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
""")
BigDecimal findTotalInterestAccruedAmount(@Param("loan") Loan loan);
@Query("""
SELECT COALESCE(SUM(lt.interestPortion), 0)
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL
AND lt.dateOf > :fromDate
AND lt.dateOf <= :dueDate
""")
BigDecimal findAccrualInterestInPeriod(@Param("loan") Loan loan, @Param("fromDate") LocalDate fromDate,
@Param("dueDate") LocalDate dueDate);
@Query("""
SELECT CASE WHEN COUNT(lt) > 0 THEN false ELSE true END
FROM LoanTransaction lt
@@ -133,4 +146,163 @@ public interface LoanTransactionRepository extends JpaRepository<LoanTransaction
""")
List<Long> findReversedTransactionIdsByLoan(@Param("loan") Loan loan);
@Query("""
SELECT
lt.typeOf AS transactionType,
lt.interestPortion AS interestPortion,
lt.feeChargesPortion AS feeChargesPortion,
lt.penaltyChargesPortion AS penaltyChargesPortion
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.dateOf <= :tillDate
AND lt.typeOf NOT IN (
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.DISBURSEMENT,
org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.REPAYMENT_AT_DISBURSEMENT
)
ORDER BY lt.dateOf
""")
List<TransactionPortionsForForeclosure> findTransactionDataForForeclosureIncome(@Param("loan") Loan loan,
@Param("tillDate") LocalDate tillDate);
@Query("""
SELECT lt
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf IN :types
AND lt.dateOf = :transactionDate
""")
Optional<LoanTransaction> findNonReversedByLoanAndTypesAndDate(@Param("loan") Loan loan, @Param("type") Set<LoanTransactionType> types,
@Param("transactionDate") LocalDate transactionDate);
@Query("""
SELECT lt
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf IN :types
AND lt.dateOf > :transactionDate
ORDER BY lt.dateOf
""")
List<LoanTransaction> findNonReversedByLoanAndTypesAndAfterDate(@Param("loan") Loan loan,
@Param("types") Set<LoanTransactionType> types, @Param("transactionDate") LocalDate transactionDate);
@Query("""
SELECT CASE WHEN COUNT(lt) > 0 THEN true ELSE false END
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf IN :types
AND lt.dateOf >= :accrualDate
""")
boolean existsNonReversedByLoanAndTypesAndOnOrAfterDate(@Param("loan") Loan loan, @Param("types") Set<LoanTransactionType> types,
@Param("accrualDate") LocalDate accrualDate);
@Query("""
SELECT lt
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf IN :types
AND lt.id NOT IN :existingTransactionIds
""")
List<LoanTransaction> findNonReversedByLoanAndTypesAndNotInIds(@Param("loan") Loan loan, @Param("types") Set<LoanTransactionType> types,
@Param("existingTransactionIds") List<Long> existingTransactionIds);
@Query("""
SELECT lt
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf IN :types
""")
List<LoanTransaction> findNonReversedByLoanAndTypes(@Param("loan") Loan loan, @Param("types") Set<LoanTransactionType> types);
@Query("""
SELECT lt
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.dateOf >= :date
AND lt.typeOf IN :types
ORDER BY lt.dateOf
""")
List<LoanTransaction> findNonReversedByLoanAndTypesAndOnOrAfterDate(@Param("loan") Loan loan,
@Param("types") Set<LoanTransactionType> types, @Param("date") LocalDate date);
@Query("""
SELECT new org.apache.fineract.portfolio.loanaccount.data.CumulativeIncomeFromIncomePosting(
COALESCE(SUM(lt.interestPortion), 0),
COALESCE(SUM(lt.feeChargesPortion), 0),
COALESCE(SUM(lt.penaltyChargesPortion), 0)
)
FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.INCOME_POSTING
""")
CumulativeIncomeFromIncomePosting findCumulativeIncomeByLoanAndType(@Param("loan") Loan loan);
@Query("""
SELECT COALESCE(SUM(CASE WHEN lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL THEN lcpb.amount
WHEN lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL_ADJUSTMENT THEN -lcpb.amount
ELSE 0 END), 0)
FROM LoanChargePaidBy lcpb
JOIN lcpb.loanTransaction lt
WHERE lcpb.loanCharge = :loanCharge
AND lt.reversed = false
""")
BigDecimal findChargeAccrualAmount(@Param("loanCharge") LoanCharge loanCharge);
@Query("""
SELECT COALESCE(SUM(lt.unrecognizedIncomePortion), 0)
FROM LoanChargePaidBy lcpb
JOIN lcpb.loanTransaction lt
WHERE lcpb.loanCharge = :loanCharge
AND lt.reversed = false
AND lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.WAIVE_CHARGES
AND lt.dateOf <= :tillDate
""")
BigDecimal findChargeUnrecognizedWaivedAmount(@Param("loanCharge") LoanCharge loanCharge, @Param("tillDate") LocalDate tillDate);
@Query("""
SELECT MAX(lt.dateOf) FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
AND lt.typeOf IN :types
""")
Optional<LocalDate> findLastNonReversedTransactionDateByLoanAndTypes(@Param("loan") Loan loan,
@Param("types") Set<LoanTransactionType> types);
@Query("""
SELECT lt FROM LoanTransaction lt
WHERE lt.loan = :loan
AND (
(lt.reversed = true AND lt.id IN :existingTransactionIds AND lt.id NOT IN :existingReversedTransactionIds)
OR (lt.id NOT IN :existingTransactionIds)
)
""")
List<LoanTransaction> findTransactionsForAccountingBridge(@Param("loan") Loan loan,
@Param("existingTransactionIds") List<Long> existingTransactionIds,
@Param("existingReversedTransactionIds") List<Long> existingReversedTransactionIds);
@Query("""
SELECT lt FROM LoanTransaction lt
WHERE lt.loan = :loan
AND (
(lt.reversed = true AND lt.id IN :existingTransactionIds)
OR (lt.id NOT IN :existingTransactionIds)
)
""")
List<LoanTransaction> findTransactionsForAccountingBridge(@Param("loan") Loan loan,
@Param("existingTransactionIds") List<Long> existingTransactionIds);
@Query("""
SELECT lt FROM LoanTransaction lt
WHERE lt.loan = :loan
AND lt.reversed = false
""")
List<LoanTransaction> findNonReversedTransactionsByLoan(@Param("loan") Loan loan);
}
@@ -35,6 +35,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
import org.springframework.stereotype.Component;
@@ -42,6 +43,8 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class LoanAccountingBridgeMapper {
private final LoanTransactionRepository loanTransactionRepository;
public List<AccountingBridgeDataDTO> deriveAccountingBridgeDataForChargeOff(final String currencyCode,
final List<Long> existingTransactionIds, final List<Long> existingReversedTransactionIds, final boolean isAccountTransfer,
final Loan loan) {
@@ -72,16 +75,20 @@ public class LoanAccountingBridgeMapper {
public AccountingBridgeDataDTO deriveAccountingBridgeData(final String currencyCode, final List<Long> existingTransactionIds,
final List<Long> existingReversedTransactionIds, final boolean isAccountTransfer, final Loan loan) {
final List<AccountingBridgeLoanTransactionDTO> newLoanTransactions = new ArrayList<>();
for (final LoanTransaction transaction : loan.getLoanTransactions()) {
if (transaction.isReversed() && existingTransactionIds.contains(transaction.getId())
&& !existingReversedTransactionIds.contains(transaction.getId())) {
newLoanTransactions.add(mapToLoanTransactionData(transaction, currencyCode));
} else if (!existingTransactionIds.contains(transaction.getId())) {
newLoanTransactions.add(mapToLoanTransactionData(transaction, currencyCode));
}
List<LoanTransaction> transactions;
if (existingTransactionIds == null || existingTransactionIds.isEmpty()) {
transactions = loanTransactionRepository.findNonReversedTransactionsByLoan(loan);
} else if (existingReversedTransactionIds == null || existingReversedTransactionIds.isEmpty()) {
transactions = loanTransactionRepository.findTransactionsForAccountingBridge(loan, existingTransactionIds);
} else {
transactions = loanTransactionRepository.findTransactionsForAccountingBridge(loan, existingTransactionIds,
existingReversedTransactionIds);
}
final List<AccountingBridgeLoanTransactionDTO> newLoanTransactions = transactions.stream() //
.map(transaction -> mapToLoanTransactionData(transaction, currencyCode)) //
.toList();
return new AccountingBridgeDataDTO(loan.getId(), loan.productId(), loan.getOfficeId(), currencyCode,
loan.getSummary().getTotalInterestCharged(), loan.isCashBasedAccountingEnabledOnLoanProduct(),
loan.isUpfrontAccrualAccountingEnabledOnLoanProduct(), loan.isPeriodicAccrualAccountingEnabledOnLoanProduct(),
@@ -24,4 +24,5 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan;
public interface LoanAccrualTransactionBusinessEventService {
void raiseBusinessEventForAccrualTransactions(Loan loan, List<Long> existingTransactionIds);
}
@@ -18,7 +18,11 @@
*/
package org.apache.fineract.portfolio.loanaccount.service;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL_ADJUSTMENT;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanAccrualAdjustmentTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanAccrualTransactionCreatedBusinessEvent;
@@ -37,17 +41,17 @@ public class LoanAccrualTransactionBusinessEventServiceImpl implements LoanAccru
@Override
public void raiseBusinessEventForAccrualTransactions(final Loan loan, final List<Long> existingTransactionIds) {
final List<LoanTransactionType> transactionTypes = List.of(LoanTransactionType.ACCRUAL, LoanTransactionType.ACCRUAL_ADJUSTMENT);
final List<LoanTransaction> transactions = existingTransactionIds.isEmpty()
? loanTransactionRepository.findNewTransactionsByLoanIdAndType(loan.getId(), transactionTypes)
: loanTransactionRepository.findNewTransactionsByLoanIdAndTypeAndNotInIds(loan.getId(), transactionTypes,
existingTransactionIds);
final Set<LoanTransactionType> accrualTypes = Set.of(ACCRUAL, ACCRUAL_ADJUSTMENT);
final List<LoanTransaction> accrualTransactions = existingTransactionIds.isEmpty()
? loanTransactionRepository.findNonReversedByLoanAndTypes(loan, accrualTypes)
: loanTransactionRepository.findNonReversedByLoanAndTypesAndNotInIds(loan, accrualTypes, existingTransactionIds);
for (final LoanTransaction transaction : transactions) {
accrualTransactions.forEach(transaction -> {
final LoanTransactionBusinessEvent businessEvent = transaction.isAccrual()
? new LoanAccrualTransactionCreatedBusinessEvent(transaction)
: new LoanAccrualAdjustmentTransactionBusinessEvent(transaction);
businessEventNotifierService.notifyPostBusinessEvent(businessEvent);
}
});
}
}
@@ -28,11 +28,11 @@ import org.springframework.lang.NonNull;
public interface LoanAccrualsProcessingService {
void addPeriodicAccruals(@NotNull LocalDate tilldate) throws MultiException;
void addPeriodicAccruals(@NotNull LocalDate tillDate) throws MultiException;
void addPeriodicAccruals(@NotNull LocalDate tilldate, @NotNull Loan loan) throws MultiException;
void addPeriodicAccruals(@NotNull LocalDate tillDate, @NotNull Loan loan) throws MultiException;
void addAccruals(@NotNull LocalDate tilldate) throws MultiException;
void addAccruals(@NotNull LocalDate tillDate) throws MultiException;
void reprocessExistingAccruals(@NotNull Loan loan);
@@ -30,7 +30,6 @@ import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
@@ -39,7 +38,6 @@ import org.springframework.transaction.annotation.Transactional;
@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class LoanTransactionReadService {
@PersistenceContext