FINERACT-2354: Introduce reaging preview API

This commit is contained in:
Oleksii Novikov
2025-11-11 12:04:51 +01:00
committed by Adam Saghy
parent ed8e9d1376
commit 3efafa778e
19 changed files with 1772 additions and 446 deletions
@@ -27,6 +27,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.ws.rs.BeanParam;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
@@ -63,14 +65,17 @@ import org.apache.fineract.infrastructure.core.service.CommandParameterUtil;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
import org.apache.fineract.portfolio.loanaccount.data.LoanRepaymentScheduleInstallmentData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargePaidByReadService;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingService;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadPlatformService;
import org.springframework.data.domain.Page;
@@ -104,6 +109,7 @@ public class LoanTransactionsApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final PaymentTypeReadPlatformService paymentTypeReadPlatformService;
private final LoanChargePaidByReadService loanChargePaidByReadService;
private final LoanReAgingService loanReAgingService;
@GET
@Path("{loanId}/transactions/template")
@@ -777,4 +783,26 @@ public class LoanTransactionsApiResource {
throw new IllegalArgumentException("loanId and loanExternalId cannot be both null");
}
}
@GET
@Path("{loanId}/transactions/reage-preview")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Preview Re-Age Schedule", description = "Generates a preview of the re-aged loan schedule based on the provided parameters without creating any transactions or modifying the loan.")
public LoanScheduleData previewReAgeSchedule(@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId,
@Valid @BeanParam final ReAgePreviewRequest reAgePreviewRequest) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return loanReAgingService.previewReAge(loanId, null, reAgePreviewRequest);
}
@GET
@Path("external-id/{loanExternalId}/transactions/reage-preview")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Preview Re-Age Schedule", description = "Generates a preview of the re-aged loan schedule based on the provided parameters without creating any transactions or modifying the loan.")
public LoanScheduleData previewReAgeSchedule(
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId,
@Valid @BeanParam final ReAgePreviewRequest reAgePreviewRequest) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return loanReAgingService.previewReAge(null, loanExternalId, reAgePreviewRequest);
}
}
@@ -0,0 +1,81 @@
/**
* 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.api.request;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.QueryParam;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.validation.constraints.EnumValue;
import org.apache.fineract.validation.constraints.LocalDate;
import org.apache.fineract.validation.constraints.Locale;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@LocalDate(dateField = "startDate", formatField = "dateFormat", localeField = "locale")
public class ReAgePreviewRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@QueryParam("frequencyNumber")
@Parameter(description = "The frequency number for the re-aging schedule", required = true)
@NotNull(message = "{org.apache.fineract.reage.frequency-number.not-blank}")
@Min(value = 1, message = "{org.apache.fineract.reage.frequency-number.min}")
private Integer frequencyNumber;
@QueryParam("frequencyType")
@Parameter(description = "The frequency type (DAYS, WEEKS, MONTHS, YEARS)", required = true)
@NotBlank(message = "{org.apache.fineract.reage.frequency-type.not-blank}")
@EnumValue(enumClass = PeriodFrequencyType.class, message = "{org.apache.fineract.frequency-type.invalid}")
private String frequencyType;
@QueryParam("startDate")
@Parameter(description = "The start date for the re-aging schedule", required = true)
@NotBlank(message = "{org.apache.fineract.reage.start-date.not-blank}")
private String startDate;
@QueryParam("numberOfInstallments")
@Parameter(description = "The number of installments for the re-aged loan", required = true)
@NotNull(message = "{org.apache.fineract.reage.number-of-installments.not-blank}")
@Min(value = 1, message = "{org.apache.fineract.reage.number-of-installments.min}")
private Integer numberOfInstallments;
@QueryParam("dateFormat")
@Parameter(description = "The date format used for the startDate parameter", required = true)
@NotBlank(message = "{org.apache.fineract.businessdate.date-format.not-blank}")
private String dateFormat;
@QueryParam("locale")
@Parameter(description = "The locale to use for formatting", required = true)
@NotBlank(message = "{org.apache.fineract.businessdate.locale.not-blank}")
@Locale
private String locale;
}
@@ -24,7 +24,7 @@ import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.DataIntegrityErrorHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingServiceImpl;
import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service;
@@ -34,7 +34,7 @@ import org.springframework.stereotype.Service;
@CommandType(entity = "LOAN", action = "REAGE")
public class LoanReAgingCommandHandler implements NewCommandSourceHandler {
private final LoanReAgingServiceImpl loanReAgingService;
private final LoanReAgingService loanReAgingService;
private final DataIntegrityErrorHandler dataIntegrityErrorHandler;
@Override
@@ -24,7 +24,7 @@ import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.DataIntegrityErrorHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingServiceImpl;
import org.apache.fineract.portfolio.loanaccount.service.reaging.LoanReAgingService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service;
@@ -34,7 +34,7 @@ import org.springframework.stereotype.Service;
@CommandType(entity = "LOAN", action = "UNDO_REAGE")
public class LoanUndoReAgingCommandHandler implements NewCommandSourceHandler {
private final LoanReAgingServiceImpl loanReAgingService;
private final LoanReAgingService loanReAgingService;
private final DataIntegrityErrorHandler dataIntegrityErrorHandler;
@Override
@@ -31,8 +31,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -103,7 +101,6 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanApprovalData;
import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByData;
import org.apache.fineract.portfolio.loanaccount.data.LoanInterestRecalculationData;
import org.apache.fineract.portfolio.loanaccount.data.LoanRepaymentScheduleInstallmentData;
import org.apache.fineract.portfolio.loanaccount.data.LoanSchedulePeriodDataWrapper;
import org.apache.fineract.portfolio.loanaccount.data.LoanStatusEnumData;
import org.apache.fineract.portfolio.loanaccount.data.LoanSummaryData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionData;
@@ -136,7 +133,6 @@ import org.apache.fineract.portfolio.loanaccount.domain.reamortization.LoanReAmo
import org.apache.fineract.portfolio.loanaccount.exception.LoanNotFoundException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePeriodData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OverdueLoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
@@ -158,9 +154,7 @@ import org.springframework.dao.DataAccessException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.domain.Pageable;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.lang.NonNull;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
@@ -188,7 +182,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
private final ConfigurationDomainService configurationDomainService;
private final AccountDetailsReadPlatformService accountDetailsReadPlatformService;
private final ColumnValidator columnValidator;
protected final DatabaseSpecificSQLGenerator sqlGenerator;
private final DatabaseSpecificSQLGenerator sqlGenerator;
private final DelinquencyReadPlatformService delinquencyReadPlatformService;
private final LoanTransactionRepository loanTransactionRepository;
private final LoanChargePaidByReadService loanChargePaidByReadService;
@@ -201,6 +195,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
private final LoanBuyDownFeeBalanceRepository loanBuyDownFeeBalanceRepository;
private final InterestRefundServiceDelegate interestRefundServiceDelegate;
private final LoanMaximumAmountCalculator loanMaximumAmountCalculator;
private final LoanRepaymentScheduleService loanRepaymentScheduleService;
@Override
public LoanAccountData retrieveOne(final Long loanId) {
@@ -280,19 +275,9 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, Collection<DisbursementData> disbursementData,
Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData, boolean isInterestRecalculationEnabled,
LoanScheduleType loanScheduleType) {
try {
this.context.authenticatedUser();
final LoanScheduleResultSetExtractor fullResultsetExtractor = new LoanScheduleResultSetExtractor(
repaymentScheduleRelatedLoanData, disbursementData, capitalizedIncomeData, isInterestRecalculationEnabled,
loanScheduleType);
final String sql = "select " + fullResultsetExtractor.schema() + " where ls.loan_id = ? order by ls.loan_id, ls.installment";
return this.jdbcTemplate.query(sql, fullResultsetExtractor, loanId); // NOSONAR
} catch (final EmptyResultDataAccessException e) {
throw new LoanNotFoundException(loanId, e);
}
this.context.authenticatedUser();
return loanRepaymentScheduleService.findLoanScheduleData(loanId, repaymentScheduleRelatedLoanData, disbursementData,
capitalizedIncomeData, isInterestRecalculationEnabled, loanScheduleType);
}
@Override
@@ -1339,373 +1324,6 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa
}
}
private static final class LoanScheduleResultSetExtractor implements ResultSetExtractor<LoanScheduleData> {
private final CurrencyData currency;
private final DisbursementData disbursement;
private final BigDecimal totalFeeChargesDueAtDisbursement;
private final Collection<DisbursementData> disbursementData;
private final Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData;
private final LoanScheduleType loanScheduleType;
private LocalDate lastDueDate;
private BigDecimal outstandingLoanPrincipalBalance;
private boolean excludePastUnDisbursed;
LoanScheduleResultSetExtractor(final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData,
Collection<DisbursementData> disbursementData, Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData,
boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) {
this.currency = repaymentScheduleRelatedLoanData.getCurrency();
this.disbursement = repaymentScheduleRelatedLoanData.disbursementData();
this.capitalizedIncomeData = capitalizedIncomeData;
this.totalFeeChargesDueAtDisbursement = repaymentScheduleRelatedLoanData.getTotalFeeChargesAtDisbursement();
this.lastDueDate = this.disbursement.disbursementDate();
this.outstandingLoanPrincipalBalance = this.disbursement.getPrincipal();
this.disbursementData = disbursementData;
this.excludePastUnDisbursed = isInterestRecalculationEnabled;
this.loanScheduleType = loanScheduleType;
}
public String schema() {
return " ls.loan_id as loanId, ls.installment as period, ls.fromdate as fromDate, ls.duedate as dueDate, ls.obligations_met_on_date as obligationsMetOnDate, ls.completed_derived as complete,"
+ " ls.principal_amount as principalDue, ls.principal_completed_derived as principalPaid, ls.principal_writtenoff_derived as principalWrittenOff, ls.is_additional as isAdditional, "
+ " ls.interest_amount as interestDue, ls.interest_completed_derived as interestPaid, ls.interest_waived_derived as interestWaived, ls.interest_writtenoff_derived as interestWrittenOff, "
+ " ls.fee_charges_amount as feeChargesDue, ls.fee_charges_completed_derived as feeChargesPaid, ls.fee_charges_waived_derived as feeChargesWaived, ls.fee_charges_writtenoff_derived as feeChargesWrittenOff, "
+ " ls.penalty_charges_amount as penaltyChargesDue, ls.penalty_charges_completed_derived as penaltyChargesPaid, ls.penalty_charges_waived_derived as penaltyChargesWaived, "
+ " ls.penalty_charges_writtenoff_derived as penaltyChargesWrittenOff, ls.total_paid_in_advance_derived as totalPaidInAdvanceForPeriod, "
+ " ls.total_paid_late_derived as totalPaidLateForPeriod, ls.credits_amount as principalCredits, ls.credited_fee as feeCredits, ls.credited_penalty as penaltyCredits, ls.is_down_payment isDownPayment, "
+ " ls.accrual_interest_derived as accrualInterest " + " from m_loan_repayment_schedule ls ";
}
@Override
public LoanScheduleData extractData(@NonNull final ResultSet rs) throws SQLException, DataAccessException {
BigDecimal waivedChargeAmount = BigDecimal.ZERO;
for (DisbursementData disbursementDetail : disbursementData) {
waivedChargeAmount = waivedChargeAmount.add(disbursementDetail.getWaivedChargeAmount());
}
final LoanSchedulePeriodData disbursementPeriod = LoanSchedulePeriodData.disbursementOnlyPeriod(
this.disbursement.disbursementDate(), this.disbursement.getPrincipal(), this.totalFeeChargesDueAtDisbursement,
this.disbursement.isDisbursed());
final List<LoanSchedulePeriodData> periods = new ArrayList<>();
final MonetaryCurrency monCurrency = new MonetaryCurrency(this.currency.getCode(), this.currency.getDecimalPlaces(),
this.currency.getInMultiplesOf());
BigDecimal totalPrincipalDisbursed = BigDecimal.ZERO;
BigDecimal disbursementChargeAmount = this.totalFeeChargesDueAtDisbursement;
if (disbursementData.isEmpty()) {
periods.add(disbursementPeriod);
totalPrincipalDisbursed = Money.of(monCurrency, this.disbursement.getPrincipal()).getAmount();
} else {
if (!this.disbursement.isDisbursed()) {
excludePastUnDisbursed = false;
}
for (DisbursementData data : disbursementData) {
if (data.getChargeAmount() != null) {
disbursementChargeAmount = disbursementChargeAmount.subtract(data.getChargeAmount());
}
}
this.outstandingLoanPrincipalBalance = BigDecimal.ZERO;
}
Money totalPrincipalExpected = Money.zero(monCurrency);
Money totalPrincipalPaid = Money.zero(monCurrency);
Money totalInterestCharged = Money.zero(monCurrency);
Money totalFeeChargesCharged = Money.zero(monCurrency);
Money totalPenaltyChargesCharged = Money.zero(monCurrency);
Money totalWaived = Money.zero(monCurrency);
Money totalWrittenOff = Money.zero(monCurrency);
Money totalRepaymentExpected = Money.zero(monCurrency);
Money totalRepayment = Money.zero(monCurrency);
Money totalPaidInAdvance = Money.zero(monCurrency);
Money totalPaidLate = Money.zero(monCurrency);
Money totalOutstanding = Money.zero(monCurrency);
Money totalCredits = Money.zero(monCurrency);
// update totals with details of fees charged during disbursement
totalFeeChargesCharged = totalFeeChargesCharged.plus(disbursementPeriod.getFeeChargesDue().subtract(waivedChargeAmount));
totalRepaymentExpected = totalRepaymentExpected.plus(disbursementPeriod.getFeeChargesDue()).minus(waivedChargeAmount);
totalRepayment = totalRepayment.plus(disbursementPeriod.getFeeChargesPaid()).minus(waivedChargeAmount);
totalOutstanding = totalOutstanding.plus(disbursementPeriod.getFeeChargesDue()).minus(disbursementPeriod.getFeeChargesPaid());
Integer loanTermInDays = 0;
Set<Long> disbursementPeriodIds = new HashSet<>();
while (rs.next()) {
final Integer period = JdbcSupport.getInteger(rs, "period");
LocalDate fromDate = JdbcSupport.getLocalDate(rs, "fromDate");
final LocalDate dueDate = JdbcSupport.getLocalDate(rs, "dueDate");
final LocalDate obligationsMetOnDate = JdbcSupport.getLocalDate(rs, "obligationsMetOnDate");
final boolean complete = rs.getBoolean("complete");
List<LoanSchedulePeriodDataWrapper> combinedDataList = new ArrayList<>();
combinedDataList.addAll(
collectEligibleDisbursementData(loanScheduleType, disbursementData, fromDate, dueDate, disbursementPeriodIds));
combinedDataList.addAll(collectEligibleCapitalizedIncomeData(fromDate, dueDate, disbursementPeriodIds));
combinedDataList.sort(this::sortPeriodDataHolders);
fillLoanSchedulePeriodData(periods, combinedDataList, disbursementChargeAmount, waivedChargeAmount);
BigDecimal disbursedAmount = calculateDisbursedAmount(combinedDataList);
// Add the Charge back or Credits to the initial amount to avoid negative balance
final BigDecimal principalCredits = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalCredits");
final BigDecimal feeCredits = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeCredits");
final BigDecimal penaltyCredits = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyCredits");
final BigDecimal credits = principalCredits.add(feeCredits).add(penaltyCredits);
this.outstandingLoanPrincipalBalance = this.outstandingLoanPrincipalBalance.add(principalCredits);
totalPrincipalDisbursed = totalPrincipalDisbursed.add(disbursedAmount);
Integer daysInPeriod = 0;
if (fromDate != null) {
daysInPeriod = DateUtils.getExactDifferenceInDays(fromDate, dueDate);
loanTermInDays = loanTermInDays + daysInPeriod;
}
final BigDecimal principalDue = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalDue");
totalPrincipalExpected = totalPrincipalExpected.plus(principalDue);
final BigDecimal principalPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalPaid");
totalPrincipalPaid = totalPrincipalPaid.plus(principalPaid);
final BigDecimal principalWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "principalWrittenOff");
final BigDecimal principalOutstanding = principalDue.subtract(principalPaid).subtract(principalWrittenOff);
final BigDecimal interestExpectedDue = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "interestDue");
totalInterestCharged = totalInterestCharged.plus(interestExpectedDue);
final BigDecimal interestPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "interestPaid");
final BigDecimal interestWaived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "interestWaived");
final BigDecimal interestWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "interestWrittenOff");
final BigDecimal accrualInterest = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accrualInterest");
final BigDecimal interestActualDue = interestExpectedDue.subtract(interestWaived).subtract(interestWrittenOff);
final BigDecimal interestOutstanding = interestActualDue.subtract(interestPaid);
final BigDecimal feeChargesExpectedDue = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeChargesDue");
totalFeeChargesCharged = totalFeeChargesCharged.plus(feeChargesExpectedDue);
final BigDecimal feeChargesPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeChargesPaid");
final BigDecimal feeChargesWaived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeChargesWaived");
final BigDecimal feeChargesWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "feeChargesWrittenOff");
final BigDecimal feeChargesActualDue = feeChargesExpectedDue.subtract(feeChargesWaived).subtract(feeChargesWrittenOff);
final BigDecimal feeChargesOutstanding = feeChargesActualDue.subtract(feeChargesPaid);
final BigDecimal penaltyChargesExpectedDue = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyChargesDue");
totalPenaltyChargesCharged = totalPenaltyChargesCharged.plus(penaltyChargesExpectedDue);
final BigDecimal penaltyChargesPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyChargesPaid");
final BigDecimal penaltyChargesWaived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyChargesWaived");
final BigDecimal penaltyChargesWrittenOff = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "penaltyChargesWrittenOff");
final BigDecimal totalPaidInAdvanceForPeriod = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs,
"totalPaidInAdvanceForPeriod");
final BigDecimal totalPaidLateForPeriod = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalPaidLateForPeriod");
final BigDecimal penaltyChargesActualDue = penaltyChargesExpectedDue.subtract(penaltyChargesWaived)
.subtract(penaltyChargesWrittenOff);
final BigDecimal penaltyChargesOutstanding = penaltyChargesActualDue.subtract(penaltyChargesPaid);
final BigDecimal totalExpectedCostOfLoanForPeriod = interestExpectedDue.add(feeChargesExpectedDue)
.add(penaltyChargesExpectedDue);
final BigDecimal totalDueForPeriod = principalDue.add(totalExpectedCostOfLoanForPeriod);
final BigDecimal totalPaidForPeriod = principalPaid.add(interestPaid).add(feeChargesPaid).add(penaltyChargesPaid);
final BigDecimal totalWaivedForPeriod = interestWaived.add(feeChargesWaived).add(penaltyChargesWaived);
totalWaived = totalWaived.plus(totalWaivedForPeriod);
final BigDecimal totalWrittenOffForPeriod = principalWrittenOff.add(interestWrittenOff).add(feeChargesWrittenOff)
.add(penaltyChargesWrittenOff);
totalWrittenOff = totalWrittenOff.plus(totalWrittenOffForPeriod);
final BigDecimal totalOutstandingForPeriod = principalOutstanding.add(interestOutstanding).add(feeChargesOutstanding)
.add(penaltyChargesOutstanding);
totalRepaymentExpected = totalRepaymentExpected.plus(totalDueForPeriod);
totalRepayment = totalRepayment.plus(totalPaidForPeriod);
totalPaidInAdvance = totalPaidInAdvance.plus(totalPaidInAdvanceForPeriod);
totalPaidLate = totalPaidLate.plus(totalPaidLateForPeriod);
totalOutstanding = totalOutstanding.plus(totalOutstandingForPeriod);
totalCredits = totalCredits.add(credits);
if (fromDate == null) {
fromDate = this.lastDueDate;
}
BigDecimal outstandingPrincipalBalanceOfLoan = this.outstandingLoanPrincipalBalance.subtract(principalDue);
// update based on current period values
this.lastDueDate = dueDate;
this.outstandingLoanPrincipalBalance = this.outstandingLoanPrincipalBalance.subtract(principalDue);
final boolean isDownPayment = rs.getBoolean("isDownPayment");
LoanSchedulePeriodData periodData;
periodData = LoanSchedulePeriodData.periodWithPayments(period, fromDate, dueDate, obligationsMetOnDate, complete,
principalDue, principalPaid, principalWrittenOff, principalOutstanding, outstandingPrincipalBalanceOfLoan,
interestExpectedDue, interestPaid, interestWaived, interestWrittenOff, interestOutstanding, feeChargesExpectedDue,
feeChargesPaid, feeChargesWaived, feeChargesWrittenOff, feeChargesOutstanding, penaltyChargesExpectedDue,
penaltyChargesPaid, penaltyChargesWaived, penaltyChargesWrittenOff, penaltyChargesOutstanding, totalPaidForPeriod,
totalPaidInAdvanceForPeriod, totalPaidLateForPeriod, totalWaivedForPeriod, totalWrittenOffForPeriod, credits,
isDownPayment, accrualInterest);
periods.add(periodData);
}
return new LoanScheduleData(this.currency, periods, loanTermInDays, totalPrincipalDisbursed, totalPrincipalExpected.getAmount(),
totalPrincipalPaid.getAmount(), totalInterestCharged.getAmount(), totalFeeChargesCharged.getAmount(),
totalPenaltyChargesCharged.getAmount(), totalWaived.getAmount(), totalWrittenOff.getAmount(),
totalRepaymentExpected.getAmount(), totalRepayment.getAmount(), totalPaidInAdvance.getAmount(),
totalPaidLate.getAmount(), totalOutstanding.getAmount(), totalCredits.getAmount());
}
private List<LoanSchedulePeriodDataWrapper> collectEligibleDisbursementData(LoanScheduleType loanScheduleType,
Collection<DisbursementData> disbursementData, LocalDate fromDate, LocalDate dueDate, Set<Long> disbursementPeriodIds) {
List<LoanSchedulePeriodDataWrapper> disbursementDataList = new ArrayList<>();
boolean hasMultipleTranchesOnSameDate = hasMultipleTranchesOnSameDate(disbursementData);
if (hasMultipleTranchesOnSameDate) {
Map<LocalDate, List<DisbursementData>> disbursementsByDate = new HashMap<>();
for (final DisbursementData data : disbursementData) {
boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate);
boolean isEligible = ((fromDate.equals(this.disbursement.disbursementDate())
&& data.disbursementDate().equals(fromDate))
|| (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate))
|| canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed))
&& !disbursementPeriodIds.contains(data.getId());
if (isEligible) {
disbursementsByDate.computeIfAbsent(data.disbursementDate(), k -> new ArrayList<>()).add(data);
disbursementPeriodIds.add(data.getId());
}
}
for (Map.Entry<LocalDate, List<DisbursementData>> entry : disbursementsByDate.entrySet()) {
List<DisbursementData> sameDateDisbursements = entry.getValue();
if (sameDateDisbursements.size() > 1) {
List<DisbursementData> disbursedTranches = sameDateDisbursements.stream().filter(DisbursementData::isDisbursed)
.collect(Collectors.toList());
if (!disbursedTranches.isEmpty()) {
for (DisbursementData data : disbursedTranches) {
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
}
} else {
for (DisbursementData data : sameDateDisbursements) {
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
}
}
} else {
DisbursementData data = sameDateDisbursements.get(0);
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
}
}
} else {
for (final DisbursementData data : disbursementData) {
boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate);
boolean isEligible = ((fromDate.equals(this.disbursement.disbursementDate())
&& data.disbursementDate().equals(fromDate))
|| (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate))
|| canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed))
&& !disbursementPeriodIds.contains(data.getId());
if (isEligible) {
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
disbursementPeriodIds.add(data.getId());
}
}
}
return disbursementDataList;
}
private boolean hasMultipleTranchesOnSameDate(Collection<DisbursementData> disbursementData) {
if (disbursementData == null || disbursementData.size() <= 1) {
return false;
}
return disbursementData.stream().collect(Collectors.groupingBy(DisbursementData::disbursementDate, Collectors.counting()))
.values().stream().anyMatch(count -> count > 1);
}
private List<LoanSchedulePeriodDataWrapper> collectEligibleCapitalizedIncomeData(LocalDate fromDate, LocalDate dueDate,
Set<Long> disbursementPeriodIds) {
List<LoanSchedulePeriodDataWrapper> capitalizedIncomeDataList = new ArrayList<>();
// Collect eligible capitalized income data
for (LoanTransactionRepaymentPeriodData data : capitalizedIncomeData) {
boolean isEligible = canAddCapitalizedIncomeData(data, fromDate, dueDate)
&& !disbursementPeriodIds.contains(data.getTransactionId());
if (isEligible) {
capitalizedIncomeDataList.add(new LoanSchedulePeriodDataWrapper(data, data.getDate(), false));
disbursementPeriodIds.add(data.getTransactionId());
}
}
return capitalizedIncomeDataList;
}
private void fillLoanSchedulePeriodData(List<LoanSchedulePeriodData> periods, List<LoanSchedulePeriodDataWrapper> combinedDataList,
BigDecimal disbursementChargeAmount, BigDecimal waivedChargeAmount) {
// Process all collected data in chronological order
for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) {
LoanSchedulePeriodData periodData;
if (dataItem.isDisbursement()) {
// Process disbursement data
DisbursementData data = (DisbursementData) dataItem.getData();
periodData = createLoanSchedulePeriodData(data, disbursementChargeAmount, waivedChargeAmount);
} else {
// Process capitalized income data
LoanTransactionRepaymentPeriodData data = (LoanTransactionRepaymentPeriodData) dataItem.getData();
periodData = createLoanSchedulePeriodData(data);
}
// Common processing for both data types
periods.add(periodData);
this.outstandingLoanPrincipalBalance = this.outstandingLoanPrincipalBalance.add(periodData.getPrincipalDisbursed());
}
}
private BigDecimal calculateDisbursedAmount(List<LoanSchedulePeriodDataWrapper> combinedDataList) {
BigDecimal disbursedAmount = BigDecimal.ZERO;
for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) {
if (dataItem.isDisbursement()) {
DisbursementData data = (DisbursementData) dataItem.getData();
disbursedAmount = disbursedAmount.add(data.getPrincipal());
}
}
return disbursedAmount;
}
private int sortPeriodDataHolders(LoanSchedulePeriodDataWrapper item1, LoanSchedulePeriodDataWrapper item2) {
int dateComparison = item1.getDate().compareTo(item2.getDate());
if (dateComparison == 0 && item1.isDisbursement() != item2.isDisbursement()) {
// If dates are equal, prioritize disbursement data
return item1.isDisbursement() ? -1 : 1;
}
return dateComparison;
}
private LoanSchedulePeriodData createLoanSchedulePeriodData(final DisbursementData data, BigDecimal disbursementChargeAmount,
BigDecimal waivedChargeAmount) {
BigDecimal chargeAmount = data.getChargeAmount() == null ? disbursementChargeAmount
: disbursementChargeAmount.add(data.getChargeAmount()).subtract(waivedChargeAmount);
return LoanSchedulePeriodData.disbursementOnlyPeriod(data.disbursementDate(), data.getPrincipal(), chargeAmount,
data.isDisbursed());
}
private LoanSchedulePeriodData createLoanSchedulePeriodData(final LoanTransactionRepaymentPeriodData data) {
BigDecimal feeCharges = Objects.isNull(data.getFeeChargesPortion()) ? BigDecimal.ZERO : data.getFeeChargesPortion();
return LoanSchedulePeriodData.disbursementOnlyPeriod(data.getDate(), data.getAmount(), feeCharges, !data.isReversed());
}
private boolean canAddDisbursementData(DisbursementData data, boolean isDueForDisbursement, boolean excludePastUnDisbursed) {
return (!excludePastUnDisbursed || data.isDisbursed() || !DateUtils.isBeforeBusinessDate(data.disbursementDate()))
&& isDueForDisbursement;
}
private boolean canAddCapitalizedIncomeData(LoanTransactionRepaymentPeriodData data, LocalDate fromDate, LocalDate dueDate) {
return !data.isReversed() && DateUtils.isDateInRangeFromInclusiveToExclusive(fromDate, dueDate, data.getDate());
}
}
protected static class LoanTransactionsMapper implements RowMapper<LoanTransactionData> {
private final DatabaseSpecificSQLGenerator sqlGenerator;
@@ -0,0 +1,418 @@
/**
* 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.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.data.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.LoanSchedulePeriodDataWrapper;
import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallmentRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanSchedulePeriodData;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LoanRepaymentScheduleService {
private final LoanRepaymentScheduleInstallmentRepository loanRepaymentScheduleInstallmentRepository;
public LoanScheduleData findLoanScheduleData(final Long loanId, final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData,
Collection<DisbursementData> disbursementData, Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData,
boolean isInterestRecalculationEnabled, LoanScheduleType loanScheduleType) {
final List<LoanRepaymentScheduleInstallment> installments = this.loanRepaymentScheduleInstallmentRepository.findByLoanId(loanId);
return extractLoanScheduleData(installments, repaymentScheduleRelatedLoanData, disbursementData, capitalizedIncomeData,
isInterestRecalculationEnabled, loanScheduleType);
}
public LoanScheduleData extractLoanScheduleData(final List<LoanRepaymentScheduleInstallment> installments,
final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData, Collection<DisbursementData> disbursementData,
Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData, boolean isInterestRecalculationEnabled,
LoanScheduleType loanScheduleType) {
final CurrencyData currency = repaymentScheduleRelatedLoanData.getCurrency();
final DisbursementData disbursement = repaymentScheduleRelatedLoanData.disbursementData();
final BigDecimal totalFeeChargesDueAtDisbursement = repaymentScheduleRelatedLoanData.getTotalFeeChargesAtDisbursement();
LocalDate lastDueDate = disbursement.disbursementDate();
BigDecimal outstandingLoanPrincipalBalance = disbursement.getPrincipal();
boolean excludePastUnDisbursed = isInterestRecalculationEnabled;
BigDecimal waivedChargeAmount = BigDecimal.ZERO;
for (DisbursementData disbursementDetail : disbursementData) {
waivedChargeAmount = waivedChargeAmount.add(disbursementDetail.getWaivedChargeAmount());
}
final LoanSchedulePeriodData disbursementPeriod = LoanSchedulePeriodData.disbursementOnlyPeriod(disbursement.disbursementDate(),
disbursement.getPrincipal(), totalFeeChargesDueAtDisbursement, disbursement.isDisbursed());
final List<LoanSchedulePeriodData> periods = new ArrayList<>();
final MonetaryCurrency monCurrency = new MonetaryCurrency(currency.getCode(), currency.getDecimalPlaces(),
currency.getInMultiplesOf());
BigDecimal totalPrincipalDisbursed = BigDecimal.ZERO;
BigDecimal disbursementChargeAmount = totalFeeChargesDueAtDisbursement;
if (disbursementData.isEmpty()) {
periods.add(disbursementPeriod);
totalPrincipalDisbursed = Money.of(monCurrency, disbursement.getPrincipal()).getAmount();
} else {
if (!disbursement.isDisbursed()) {
excludePastUnDisbursed = false;
}
for (DisbursementData data : disbursementData) {
if (data.getChargeAmount() != null) {
disbursementChargeAmount = disbursementChargeAmount.subtract(data.getChargeAmount());
}
}
outstandingLoanPrincipalBalance = BigDecimal.ZERO;
}
Money totalPrincipalExpected = Money.zero(monCurrency);
Money totalPrincipalPaid = Money.zero(monCurrency);
Money totalInterestCharged = Money.zero(monCurrency);
Money totalFeeChargesCharged = Money.zero(monCurrency);
Money totalPenaltyChargesCharged = Money.zero(monCurrency);
Money totalWaived = Money.zero(monCurrency);
Money totalWrittenOff = Money.zero(monCurrency);
Money totalRepaymentExpected = Money.zero(monCurrency);
Money totalRepayment = Money.zero(monCurrency);
Money totalPaidInAdvance = Money.zero(monCurrency);
Money totalPaidLate = Money.zero(monCurrency);
Money totalOutstanding = Money.zero(monCurrency);
Money totalCredits = Money.zero(monCurrency);
// update totals with details of fees charged during disbursement
totalFeeChargesCharged = totalFeeChargesCharged.plus(disbursementPeriod.getFeeChargesDue().subtract(waivedChargeAmount));
totalRepaymentExpected = totalRepaymentExpected.plus(disbursementPeriod.getFeeChargesDue()).minus(waivedChargeAmount);
totalRepayment = totalRepayment.plus(disbursementPeriod.getFeeChargesPaid()).minus(waivedChargeAmount);
totalOutstanding = totalOutstanding.plus(disbursementPeriod.getFeeChargesDue()).minus(disbursementPeriod.getFeeChargesPaid());
Integer loanTermInDays = 0;
Set<Long> disbursementPeriodIds = new HashSet<>();
for (LoanRepaymentScheduleInstallment installment : installments) {
final Integer period = installment.getInstallmentNumber();
LocalDate fromDate = installment.getFromDate();
final LocalDate dueDate = installment.getDueDate();
final LocalDate obligationsMetOnDate = installment.getObligationsMetOnDate();
final boolean complete = installment.isObligationsMet();
List<LoanSchedulePeriodDataWrapper> combinedDataList = new ArrayList<>();
combinedDataList.addAll(collectEligibleDisbursementData(loanScheduleType, disbursementData, fromDate, dueDate,
disbursementPeriodIds, disbursement, excludePastUnDisbursed));
combinedDataList.addAll(collectEligibleCapitalizedIncomeData(capitalizedIncomeData, fromDate, dueDate, disbursementPeriodIds));
combinedDataList.sort(this::sortPeriodDataHolders);
outstandingLoanPrincipalBalance = fillLoanSchedulePeriodData(periods, combinedDataList, disbursementChargeAmount,
waivedChargeAmount, outstandingLoanPrincipalBalance);
BigDecimal disbursedAmount = calculateDisbursedAmount(combinedDataList);
// Add the Charge back or Credits to the initial amount to avoid negative balance
final BigDecimal principalCredits = installment.getCreditedPrincipal() != null ? installment.getCreditedPrincipal()
: BigDecimal.ZERO;
final BigDecimal feeCredits = installment.getCreditedFee() != null ? installment.getCreditedFee() : BigDecimal.ZERO;
final BigDecimal penaltyCredits = installment.getCreditedPenalty() != null ? installment.getCreditedPenalty() : BigDecimal.ZERO;
final BigDecimal credits = principalCredits.add(feeCredits).add(penaltyCredits);
outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.add(principalCredits);
totalPrincipalDisbursed = totalPrincipalDisbursed.add(disbursedAmount);
Integer daysInPeriod = 0;
if (fromDate != null) {
daysInPeriod = DateUtils.getExactDifferenceInDays(fromDate, dueDate);
loanTermInDays = loanTermInDays + daysInPeriod;
}
final BigDecimal principalDue = installment.getPrincipal() != null ? installment.getPrincipal() : BigDecimal.ZERO;
totalPrincipalExpected = totalPrincipalExpected.plus(principalDue);
final BigDecimal principalPaid = installment.getPrincipalCompleted() != null ? installment.getPrincipalCompleted()
: BigDecimal.ZERO;
totalPrincipalPaid = totalPrincipalPaid.plus(principalPaid);
final BigDecimal principalWrittenOff = installment.getPrincipalWrittenOff() != null ? installment.getPrincipalWrittenOff()
: BigDecimal.ZERO;
final BigDecimal principalOutstanding = principalDue.subtract(principalPaid).subtract(principalWrittenOff);
final BigDecimal interestExpectedDue = installment.getInterestCharged() != null ? installment.getInterestCharged()
: BigDecimal.ZERO;
totalInterestCharged = totalInterestCharged.plus(interestExpectedDue);
final BigDecimal interestPaid = installment.getInterestPaid() != null ? installment.getInterestPaid() : BigDecimal.ZERO;
final BigDecimal interestWaived = installment.getInterestWaived() != null ? installment.getInterestWaived() : BigDecimal.ZERO;
final BigDecimal interestWrittenOff = installment.getInterestWrittenOff() != null ? installment.getInterestWrittenOff()
: BigDecimal.ZERO;
final BigDecimal accrualInterest = installment.getInterestAccrued() != null ? installment.getInterestAccrued()
: BigDecimal.ZERO;
final BigDecimal interestActualDue = interestExpectedDue.subtract(interestWaived).subtract(interestWrittenOff);
final BigDecimal interestOutstanding = interestActualDue.subtract(interestPaid);
final BigDecimal feeChargesExpectedDue = installment.getFeeChargesCharged() != null ? installment.getFeeChargesCharged()
: BigDecimal.ZERO;
totalFeeChargesCharged = totalFeeChargesCharged.plus(feeChargesExpectedDue);
final BigDecimal feeChargesPaid = installment.getFeeChargesPaid() != null ? installment.getFeeChargesPaid() : BigDecimal.ZERO;
final BigDecimal feeChargesWaived = installment.getFeeChargesWaived() != null ? installment.getFeeChargesWaived()
: BigDecimal.ZERO;
final BigDecimal feeChargesWrittenOff = installment.getFeeChargesWrittenOff() != null ? installment.getFeeChargesWrittenOff()
: BigDecimal.ZERO;
final BigDecimal feeChargesActualDue = feeChargesExpectedDue.subtract(feeChargesWaived).subtract(feeChargesWrittenOff);
final BigDecimal feeChargesOutstanding = feeChargesActualDue.subtract(feeChargesPaid);
final BigDecimal penaltyChargesExpectedDue = installment.getPenaltyCharges() != null ? installment.getPenaltyCharges()
: BigDecimal.ZERO;
totalPenaltyChargesCharged = totalPenaltyChargesCharged.plus(penaltyChargesExpectedDue);
final BigDecimal penaltyChargesPaid = installment.getPenaltyChargesPaid() != null ? installment.getPenaltyChargesPaid()
: BigDecimal.ZERO;
final BigDecimal penaltyChargesWaived = installment.getPenaltyChargesWaived() != null ? installment.getPenaltyChargesWaived()
: BigDecimal.ZERO;
final BigDecimal penaltyChargesWrittenOff = installment.getPenaltyChargesWrittenOff() != null
? installment.getPenaltyChargesWrittenOff()
: BigDecimal.ZERO;
final BigDecimal totalPaidInAdvanceForPeriod = installment.getTotalPaidInAdvance() != null ? installment.getTotalPaidInAdvance()
: BigDecimal.ZERO;
final BigDecimal totalPaidLateForPeriod = installment.getTotalPaidLate() != null ? installment.getTotalPaidLate()
: BigDecimal.ZERO;
final BigDecimal penaltyChargesActualDue = penaltyChargesExpectedDue.subtract(penaltyChargesWaived)
.subtract(penaltyChargesWrittenOff);
final BigDecimal penaltyChargesOutstanding = penaltyChargesActualDue.subtract(penaltyChargesPaid);
final BigDecimal totalExpectedCostOfLoanForPeriod = interestExpectedDue.add(feeChargesExpectedDue)
.add(penaltyChargesExpectedDue);
final BigDecimal totalDueForPeriod = principalDue.add(totalExpectedCostOfLoanForPeriod);
final BigDecimal totalPaidForPeriod = principalPaid.add(interestPaid).add(feeChargesPaid).add(penaltyChargesPaid);
final BigDecimal totalWaivedForPeriod = interestWaived.add(feeChargesWaived).add(penaltyChargesWaived);
totalWaived = totalWaived.plus(totalWaivedForPeriod);
final BigDecimal totalWrittenOffForPeriod = principalWrittenOff.add(interestWrittenOff).add(feeChargesWrittenOff)
.add(penaltyChargesWrittenOff);
totalWrittenOff = totalWrittenOff.plus(totalWrittenOffForPeriod);
final BigDecimal totalOutstandingForPeriod = principalOutstanding.add(interestOutstanding).add(feeChargesOutstanding)
.add(penaltyChargesOutstanding);
totalRepaymentExpected = totalRepaymentExpected.plus(totalDueForPeriod);
totalRepayment = totalRepayment.plus(totalPaidForPeriod);
totalPaidInAdvance = totalPaidInAdvance.plus(totalPaidInAdvanceForPeriod);
totalPaidLate = totalPaidLate.plus(totalPaidLateForPeriod);
totalOutstanding = totalOutstanding.plus(totalOutstandingForPeriod);
totalCredits = totalCredits.add(credits);
if (fromDate == null) {
fromDate = lastDueDate;
}
BigDecimal outstandingPrincipalBalanceOfLoan = outstandingLoanPrincipalBalance.subtract(principalDue);
// update based on current period values
lastDueDate = dueDate;
outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.subtract(principalDue);
final boolean isDownPayment = installment.isDownPayment();
LoanSchedulePeriodData periodData;
periodData = LoanSchedulePeriodData.periodWithPayments(period, fromDate, dueDate, obligationsMetOnDate, complete, principalDue,
principalPaid, principalWrittenOff, principalOutstanding, outstandingPrincipalBalanceOfLoan, interestExpectedDue,
interestPaid, interestWaived, interestWrittenOff, interestOutstanding, feeChargesExpectedDue, feeChargesPaid,
feeChargesWaived, feeChargesWrittenOff, feeChargesOutstanding, penaltyChargesExpectedDue, penaltyChargesPaid,
penaltyChargesWaived, penaltyChargesWrittenOff, penaltyChargesOutstanding, totalPaidForPeriod,
totalPaidInAdvanceForPeriod, totalPaidLateForPeriod, totalWaivedForPeriod, totalWrittenOffForPeriod, credits,
isDownPayment, accrualInterest);
periods.add(periodData);
}
return new LoanScheduleData(currency, periods, loanTermInDays, totalPrincipalDisbursed, totalPrincipalExpected.getAmount(),
totalPrincipalPaid.getAmount(), totalInterestCharged.getAmount(), totalFeeChargesCharged.getAmount(),
totalPenaltyChargesCharged.getAmount(), totalWaived.getAmount(), totalWrittenOff.getAmount(),
totalRepaymentExpected.getAmount(), totalRepayment.getAmount(), totalPaidInAdvance.getAmount(), totalPaidLate.getAmount(),
totalOutstanding.getAmount(), totalCredits.getAmount());
}
private List<LoanSchedulePeriodDataWrapper> collectEligibleDisbursementData(LoanScheduleType loanScheduleType,
Collection<DisbursementData> disbursementData, LocalDate fromDate, LocalDate dueDate, Set<Long> disbursementPeriodIds,
DisbursementData mainDisbursement, boolean excludePastUnDisbursed) {
List<LoanSchedulePeriodDataWrapper> disbursementDataList = new ArrayList<>();
boolean hasMultipleTranchesOnSameDate = hasMultipleTranchesOnSameDate(disbursementData);
if (hasMultipleTranchesOnSameDate) {
Map<LocalDate, List<DisbursementData>> disbursementsByDate = new HashMap<>();
for (final DisbursementData data : disbursementData) {
boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate);
boolean isEligible = ((fromDate.equals(mainDisbursement.disbursementDate()) && data.disbursementDate().equals(fromDate))
|| (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate))
|| canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed))
&& !disbursementPeriodIds.contains(data.getId());
if (isEligible) {
disbursementsByDate.computeIfAbsent(data.disbursementDate(), k -> new ArrayList<>()).add(data);
disbursementPeriodIds.add(data.getId());
}
}
for (Map.Entry<LocalDate, List<DisbursementData>> entry : disbursementsByDate.entrySet()) {
List<DisbursementData> sameDateDisbursements = entry.getValue();
if (sameDateDisbursements.size() > 1) {
List<DisbursementData> disbursedTranches = sameDateDisbursements.stream().filter(DisbursementData::isDisbursed)
.collect(Collectors.toList());
if (!disbursedTranches.isEmpty()) {
for (DisbursementData data : disbursedTranches) {
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
}
} else {
for (DisbursementData data : sameDateDisbursements) {
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
}
}
} else {
DisbursementData data = sameDateDisbursements.get(0);
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
}
}
} else {
for (final DisbursementData data : disbursementData) {
boolean isDueForDisbursement = data.isDueForDisbursement(loanScheduleType, fromDate, dueDate);
boolean isEligible = ((fromDate.equals(mainDisbursement.disbursementDate()) && data.disbursementDate().equals(fromDate))
|| (fromDate.equals(dueDate) && data.disbursementDate().equals(fromDate))
|| canAddDisbursementData(data, isDueForDisbursement, excludePastUnDisbursed))
&& !disbursementPeriodIds.contains(data.getId());
if (isEligible) {
disbursementDataList.add(new LoanSchedulePeriodDataWrapper(data, data.disbursementDate(), true));
disbursementPeriodIds.add(data.getId());
}
}
}
return disbursementDataList;
}
private boolean hasMultipleTranchesOnSameDate(Collection<DisbursementData> disbursementData) {
if (disbursementData == null || disbursementData.size() <= 1) {
return false;
}
return disbursementData.stream().collect(Collectors.groupingBy(DisbursementData::disbursementDate, Collectors.counting())).values()
.stream().anyMatch(count -> count > 1);
}
private List<LoanSchedulePeriodDataWrapper> collectEligibleCapitalizedIncomeData(
Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData, LocalDate fromDate, LocalDate dueDate,
Set<Long> disbursementPeriodIds) {
List<LoanSchedulePeriodDataWrapper> capitalizedIncomeDataList = new ArrayList<>();
// Collect eligible capitalized income data
for (LoanTransactionRepaymentPeriodData data : capitalizedIncomeData) {
boolean isEligible = canAddCapitalizedIncomeData(data, fromDate, dueDate)
&& !disbursementPeriodIds.contains(data.getTransactionId());
if (isEligible) {
capitalizedIncomeDataList.add(new LoanSchedulePeriodDataWrapper(data, data.getDate(), false));
disbursementPeriodIds.add(data.getTransactionId());
}
}
return capitalizedIncomeDataList;
}
private BigDecimal fillLoanSchedulePeriodData(List<LoanSchedulePeriodData> periods,
List<LoanSchedulePeriodDataWrapper> combinedDataList, BigDecimal disbursementChargeAmount, BigDecimal waivedChargeAmount,
BigDecimal outstandingLoanPrincipalBalance) {
// Process all collected data in chronological order
for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) {
LoanSchedulePeriodData periodData;
if (dataItem.isDisbursement()) {
// Process disbursement data
DisbursementData data = (DisbursementData) dataItem.getData();
periodData = createLoanSchedulePeriodData(data, disbursementChargeAmount, waivedChargeAmount);
} else {
// Process capitalized income data
LoanTransactionRepaymentPeriodData data = (LoanTransactionRepaymentPeriodData) dataItem.getData();
periodData = createLoanSchedulePeriodData(data);
}
// Common processing for both data types
periods.add(periodData);
outstandingLoanPrincipalBalance = outstandingLoanPrincipalBalance.add(periodData.getPrincipalDisbursed());
}
return outstandingLoanPrincipalBalance;
}
private BigDecimal calculateDisbursedAmount(List<LoanSchedulePeriodDataWrapper> combinedDataList) {
BigDecimal disbursedAmount = BigDecimal.ZERO;
for (LoanSchedulePeriodDataWrapper dataItem : combinedDataList) {
if (dataItem.isDisbursement()) {
DisbursementData data = (DisbursementData) dataItem.getData();
disbursedAmount = disbursedAmount.add(data.getPrincipal());
}
}
return disbursedAmount;
}
private int sortPeriodDataHolders(LoanSchedulePeriodDataWrapper item1, LoanSchedulePeriodDataWrapper item2) {
int dateComparison = item1.getDate().compareTo(item2.getDate());
if (dateComparison == 0 && item1.isDisbursement() != item2.isDisbursement()) {
// If dates are equal, prioritize disbursement data
return item1.isDisbursement() ? -1 : 1;
}
return dateComparison;
}
private LoanSchedulePeriodData createLoanSchedulePeriodData(final DisbursementData data, BigDecimal disbursementChargeAmount,
BigDecimal waivedChargeAmount) {
BigDecimal chargeAmount = data.getChargeAmount() == null ? disbursementChargeAmount
: disbursementChargeAmount.add(data.getChargeAmount()).subtract(waivedChargeAmount);
return LoanSchedulePeriodData.disbursementOnlyPeriod(data.disbursementDate(), data.getPrincipal(), chargeAmount,
data.isDisbursed());
}
private LoanSchedulePeriodData createLoanSchedulePeriodData(final LoanTransactionRepaymentPeriodData data) {
BigDecimal feeCharges = Objects.isNull(data.getFeeChargesPortion()) ? BigDecimal.ZERO : data.getFeeChargesPortion();
return LoanSchedulePeriodData.disbursementOnlyPeriod(data.getDate(), data.getAmount(), feeCharges, !data.isReversed());
}
private boolean canAddDisbursementData(DisbursementData data, boolean isDueForDisbursement, boolean excludePastUnDisbursed) {
return (!excludePastUnDisbursed || data.isDisbursed() || !DateUtils.isBeforeBusinessDate(data.disbursementDate()))
&& isDueForDisbursement;
}
private boolean canAddCapitalizedIncomeData(LoanTransactionRepaymentPeriodData data, LocalDate fromDate, LocalDate dueDate) {
return !data.isReversed() && DateUtils.isDateInRangeFromInclusiveToExclusive(fromDate, dueDate, data.getDate());
}
}
@@ -90,6 +90,14 @@ public class ReprocessLoanTransactionsServiceImpl implements ReprocessLoanTransa
handleChangedDetail(changedTransactionDetail);
}
@Override
public void reprocessTransactionsWithoutChecks(final Loan loan, final LocalDate transactionDate,
final List<LoanTransaction> newTransactions) {
final List<LoanTransaction> transactions = loanTransactionRepository.findNonReversedTransactionsForReprocessingByLoan(loan);
transactions.addAll(newTransactions);
reprocessTransactionsAndFetchChangedTransactions(loan, transactions);
}
@Override
public void processPostDisbursementTransactions(final Loan loan) {
loanTransactionProcessingService.processPostDisbursementTransactions(loan).ifPresent(this::handleChangedDetail);
@@ -22,10 +22,13 @@ import static java.math.BigDecimal.ZERO;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
@@ -33,6 +36,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.event.business.domain.loan.reaging.LoanReAgeBusinessEvent;
@@ -40,14 +44,20 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.reaging.Loa
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.reaging.LoanReAgeTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.reaging.LoanUndoReAgeTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants;
import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
import org.apache.fineract.portfolio.loanaccount.data.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLoanData;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeInterestHandlingType;
@@ -56,8 +66,12 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.Loa
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData;
import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.service.LoanRepaymentScheduleService;
import org.apache.fineract.portfolio.loanaccount.service.LoanScheduleService;
import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService;
import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService;
@@ -69,7 +83,7 @@ import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class LoanReAgingServiceImpl {
public class LoanReAgingService {
private final LoanAssembler loanAssembler;
private final LoanReAgingValidator reAgingValidator;
@@ -83,40 +97,22 @@ public class LoanReAgingServiceImpl {
private final LoanScheduleService loanScheduleService;
private final ReprocessLoanTransactionsService reprocessLoanTransactionsService;
private final CodeValueRepository codeValueRepository;
private final LoanRepaymentScheduleService loanRepaymentScheduleService;
private final LoanReadPlatformService loanReadPlatformService;
private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository;
public CommandProcessingResult reAge(Long loanId, JsonCommand command) {
Loan loan = loanAssembler.assembleFrom(loanId);
public CommandProcessingResult reAge(final Long loanId, final JsonCommand command) {
final Loan loan = loanAssembler.assembleFrom(loanId);
reAgingValidator.validateReAge(loan, command);
Map<String, Object> changes = new LinkedHashMap<>();
final LoanTransaction reAgeTransaction = createReAgeTransaction(loan, command);
loanTransactionRepository.saveAndFlush(reAgeTransaction);
processReAgeTransaction(loan, reAgeTransaction, true);
loan.updateLoanScheduleDependentDerivedFields();
final Map<String, Object> changes = new LinkedHashMap<>();
changes.put(LoanReAgingApiConstants.localeParameterName, command.locale());
changes.put(LoanReAgingApiConstants.dateFormatParameterName, command.dateFormat());
LoanTransaction reAgeTransaction = createReAgeTransaction(loan, command);
LoanReAgeParameter reAgeParameter = createReAgeParameter(reAgeTransaction, command);
reAgeTransaction.setLoanReAgeParameter(reAgeParameter);
loanTransactionRepository.saveAndFlush(reAgeTransaction);
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory
.determineProcessor(loan.transactionProcessingStrategy());
if (reAgeTransaction.getTransactionDate().isBefore(reAgeTransaction.getSubmittedOnDate())
&& !loan.isInterestBearingAndInterestRecalculationEnabled()) {
reprocessLoanTransactionsService.reprocessTransactionsWithPostTransactionChecks(loan, reAgeTransaction.getTransactionDate());
} else if (loan.isInterestBearingAndInterestRecalculationEnabled()) {
if (loan.isProgressiveSchedule() && ((loan.hasChargeOffTransaction() && loan.hasAccelerateChargeOffStrategy())
|| loan.hasContractTerminationTransaction()
|| (loan.isInterestRecalculationEnabled() && loan.hasReAgingTransaction()))) {
final ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null);
loanScheduleService.regenerateRepaymentSchedule(loan, scheduleGeneratorDTO);
}
final List<LoanTransaction> loanTransactions = loanTransactionRepository.findNonReversedTransactionsForReprocessingByLoan(loan);
loanTransactions.add(reAgeTransaction);
reprocessLoanTransactionsService.reprocessParticularTransactions(loan, loanTransactions);
} else {
loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAgeTransaction,
new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(),
new MoneyHolder(loan.getTotalOverpaidAsMoney()), null));
}
loan.updateLoanScheduleDependentDerivedFields();
persistNote(loan, command, changes);
// delinquency recalculation will be triggered by the event in a decoupled way via a listener
@@ -133,9 +129,38 @@ public class LoanReAgingServiceImpl {
.with(changes).build();
}
@Transactional(readOnly = true)
public LoanScheduleData previewReAge(final Long loanId, final String loanExternalId, final ReAgePreviewRequest reAgePreviewRequest) {
final Loan loan = loanId != null ? loanAssembler.assembleFrom(loanId)
: loanAssembler.assembleFrom(ExternalIdFactory.produce(loanExternalId), false);
return previewReAge(loan, reAgePreviewRequest);
}
private LoanScheduleData previewReAge(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) {
reAgingValidator.validateReAge(loan, reAgePreviewRequest);
final LoanTransaction reAgeTransaction = createReAgeTransactionFromPreviewRequest(loan, reAgePreviewRequest);
processReAgeTransaction(loan, reAgeTransaction, false);
loan.updateLoanScheduleDependentDerivedFields();
final CurrencyData currencyData = new CurrencyData(loan.getCurrencyCode(), null, loan.getCurrency().getDigitsAfterDecimal(),
loan.getCurrency().getInMultiplesOf(), null, null);
final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedLoanData = new RepaymentScheduleRelatedLoanData(
loan.getDisbursementDate(), loan.getDisbursementDate(), currencyData, loan.getPrincipal().getAmount(),
loan.getInArrearsTolerance().getAmount(), ZERO);
final Collection<DisbursementData> disbursementData = loanReadPlatformService.retrieveLoanDisbursementDetails(loan.getId());
final Collection<LoanTransactionRepaymentPeriodData> capitalizedIncomeData = loanCapitalizedIncomeBalanceRepository
.findRepaymentPeriodDataByLoanId(loan.getId());
final List<LoanRepaymentScheduleInstallment> sortedInstallments = loan.getRepaymentScheduleInstallments().stream()
.sorted(Comparator.comparingInt(LoanRepaymentScheduleInstallment::getInstallmentNumber)).collect(Collectors.toList());
return loanRepaymentScheduleService.extractLoanScheduleData(sortedInstallments, repaymentScheduleRelatedLoanData, disbursementData,
capitalizedIncomeData, loan.isInterestRecalculationEnabled(), loan.getLoanProductRelatedDetail().getLoanScheduleType());
}
public CommandProcessingResult undoReAge(Long loanId, JsonCommand command) {
Loan loan = loanAssembler.assembleFrom(loanId);
reAgingValidator.validateUndoReAge(loan, command);
reAgingValidator.validateUndoReAge(loan);
Map<String, Object> changes = new LinkedHashMap<>();
changes.put(LoanReAgingApiConstants.localeParameterName, command.locale());
@@ -170,6 +195,35 @@ public class LoanReAgingServiceImpl {
.with(changes).build();
}
private void processReAgeTransaction(final Loan loan, final LoanTransaction reAgeTransaction, final boolean withPostTransactionChecks) {
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory
.determineProcessor(loan.transactionProcessingStrategy());
if (reAgeTransaction.getTransactionDate().isBefore(reAgeTransaction.getSubmittedOnDate())
&& !loan.isInterestBearingAndInterestRecalculationEnabled()) {
if (withPostTransactionChecks) {
reprocessLoanTransactionsService.reprocessTransactionsWithPostTransactionChecks(loan,
reAgeTransaction.getTransactionDate());
} else {
reprocessLoanTransactionsService.reprocessTransactionsWithoutChecks(loan, reAgeTransaction.getTransactionDate(),
List.of(reAgeTransaction));
}
} else if (loan.isInterestBearingAndInterestRecalculationEnabled()) {
if (loan.isProgressiveSchedule() && ((loan.hasChargeOffTransaction() && loan.hasAccelerateChargeOffStrategy())
|| loan.hasContractTerminationTransaction()
|| (loan.isInterestRecalculationEnabled() && loan.hasReAgingTransaction()))) {
final ScheduleGeneratorDTO scheduleGeneratorDTO = loanUtilService.buildScheduleGeneratorDTO(loan, null);
loanScheduleService.regenerateRepaymentSchedule(loan, scheduleGeneratorDTO);
}
final List<LoanTransaction> loanTransactions = loanTransactionRepository.findNonReversedTransactionsForReprocessingByLoan(loan);
loanTransactions.add(reAgeTransaction);
reprocessLoanTransactionsService.reprocessParticularTransactions(loan, loanTransactions);
} else {
loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAgeTransaction,
new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(),
new MoneyHolder(loan.getTotalOverpaidAsMoney()), null));
}
}
private void reverseReAgeTransaction(LoanTransaction reAgeTransaction, JsonCommand command) {
ExternalId reversalExternalId = externalIdFactory.createFromCommand(command, LoanReAgingApiConstants.externalIdParameterName);
loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(reAgeTransaction.getLoan(), reAgeTransaction,
@@ -199,8 +253,13 @@ public class LoanReAgingServiceImpl {
Money txPrincipal = loan.getTotalPrincipalOutstandingUntil(transactionDate);
BigDecimal txPrincipalAmount = txPrincipal.getAmount();
return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate, txPrincipalAmount, txPrincipalAmount,
ZERO, ZERO, ZERO, null, false, null, txExternalId);
final LoanTransaction reAgeTransaction = new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate,
txPrincipalAmount, txPrincipalAmount, ZERO, ZERO, ZERO, null, false, null, txExternalId);
final LoanReAgeParameter reAgeParameter = createReAgeParameter(reAgeTransaction, command);
reAgeTransaction.setLoanReAgeParameter(reAgeParameter);
return reAgeTransaction;
}
private LoanReAgeParameter createReAgeParameter(LoanTransaction reAgeTransaction, JsonCommand command) {
@@ -235,4 +294,43 @@ public class LoanReAgingServiceImpl {
this.noteRepository.saveAndFlush(newNote);
}
}
private LoanTransaction createReAgeTransactionFromPreviewRequest(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) {
LocalDate transactionDate = DateUtils.getBusinessLocalDate();
final Locale locale = reAgePreviewRequest.getLocale() != null ? Locale.forLanguageTag(reAgePreviewRequest.getLocale())
: Locale.getDefault();
final LocalDate startDate = JsonParserHelper.convertFrom(reAgePreviewRequest.getStartDate(), LoanReAgingApiConstants.startDate,
reAgePreviewRequest.getDateFormat(), locale);
if (transactionDate.isAfter(startDate)) {
transactionDate = startDate;
}
final Money txPrincipal = loan.getTotalPrincipalOutstandingUntil(transactionDate);
final BigDecimal txPrincipalAmount = txPrincipal.getAmount();
final LoanTransaction reAgeTransaction = new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate,
txPrincipalAmount, txPrincipalAmount, ZERO, ZERO, ZERO, null, false, null, null);
final LoanReAgeParameter reAgeParameter = createReAgeParameterFromPreviewRequest(reAgeTransaction, reAgePreviewRequest);
reAgeTransaction.setLoanReAgeParameter(reAgeParameter);
return reAgeTransaction;
}
private LoanReAgeParameter createReAgeParameterFromPreviewRequest(final LoanTransaction reAgeTransaction,
final ReAgePreviewRequest reAgePreviewRequest) {
final PeriodFrequencyType periodFrequencyType = PeriodFrequencyType.valueOf(reAgePreviewRequest.getFrequencyType());
final Locale locale = reAgePreviewRequest.getLocale() != null ? Locale.forLanguageTag(reAgePreviewRequest.getLocale())
: Locale.getDefault();
final LocalDate startDate = JsonParserHelper.convertFrom(reAgePreviewRequest.getStartDate(), LoanReAgingApiConstants.startDate,
reAgePreviewRequest.getDateFormat(), locale);
final Integer numberOfInstallments = reAgePreviewRequest.getNumberOfInstallments();
final Integer periodFrequencyNumber = reAgePreviewRequest.getFrequencyNumber();
final LoanReAgeInterestHandlingType reAgeInterestHandlingType = LoanReAgeInterestHandlingType.DEFAULT;
return new LoanReAgeParameter(reAgeTransaction, periodFrequencyType, periodFrequencyNumber, startDate, numberOfInstallments,
reAgeInterestHandlingType, null);
}
}
@@ -24,6 +24,7 @@ import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
@@ -33,8 +34,10 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper;
import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants;
import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
@@ -57,6 +60,12 @@ public class LoanReAgingValidator {
validateReAgeOutstandingBalance(loan, command);
}
public void validateReAge(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) {
validateReAgeRequest(loan, reAgePreviewRequest);
validateReAgeBusinessRules(loan);
validateReAgeOutstandingBalance(loan, reAgePreviewRequest);
}
private void validateReAgeRequest(Loan loan, JsonCommand command) {
List<ApiParameterError> dataValidationErrors = new ArrayList<>();
DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.reAge");
@@ -145,7 +154,7 @@ public class LoanReAgingValidator {
}
}
public void validateUndoReAge(Loan loan, JsonCommand command) {
public void validateUndoReAge(Loan loan) {
validateUndoReAgeBusinessRules(loan);
}
@@ -181,4 +190,42 @@ public class LoanReAgingValidator {
}
}
private void validateReAgeRequest(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.reAge");
final Locale locale = reAgePreviewRequest.getLocale() != null ? Locale.forLanguageTag(reAgePreviewRequest.getLocale())
: Locale.getDefault();
final LocalDate startDate = JsonParserHelper.convertFrom(reAgePreviewRequest.getStartDate(), LoanReAgingApiConstants.startDate,
reAgePreviewRequest.getDateFormat(), locale);
if (loan.isProgressiveSchedule()) {
baseDataValidator.reset().parameter(LoanReAgingApiConstants.startDate).value(startDate)
.validateDateAfterOrEqual(loan.getDisbursementDate());
} else {
baseDataValidator.reset().parameter(LoanReAgingApiConstants.startDate).value(startDate)
.validateDateAfter(loan.getMaturityDate());
}
throwExceptionIfValidationErrorsExist(dataValidationErrors);
}
private void validateReAgeOutstandingBalance(final Loan loan, final ReAgePreviewRequest reAgePreviewRequest) {
final LocalDate businessDate = getBusinessLocalDate();
Locale locale = reAgePreviewRequest.getLocale() != null ? Locale.forLanguageTag(reAgePreviewRequest.getLocale())
: Locale.getDefault();
final LocalDate startDate = JsonParserHelper.convertFrom(reAgePreviewRequest.getStartDate(), LoanReAgingApiConstants.startDate,
reAgePreviewRequest.getDateFormat(), locale);
final boolean isBackdated = businessDate.isAfter(startDate);
if (isBackdated) {
return;
}
if (loan.getSummary().getTotalPrincipalOutstanding().compareTo(java.math.BigDecimal.ZERO) == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.reage.no.outstanding.balance.to.reage",
"Loan cannot be re-aged as there are no outstanding balances to be re-aged", loan.getId());
}
}
}
@@ -152,6 +152,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanOfficerService;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService;
import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformServiceImpl;
import org.apache.fineract.portfolio.loanaccount.service.LoanRefundService;
import org.apache.fineract.portfolio.loanaccount.service.LoanRepaymentScheduleService;
import org.apache.fineract.portfolio.loanaccount.service.LoanScheduleService;
import org.apache.fineract.portfolio.loanaccount.service.LoanStatusChangePlatformService;
import org.apache.fineract.portfolio.loanaccount.service.LoanStatusChangePlatformServiceImpl;
@@ -350,7 +351,8 @@ public class LoanAccountConfiguration {
LoanTransactionProcessingService loanTransactionProcessingService, LoanBalanceService loanBalanceService,
LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository,
LoanBuyDownFeeBalanceRepository loanBuyDownFeeBalanceRepository,
@Lazy InterestRefundServiceDelegate interestRefundServiceDelegate, LoanMaximumAmountCalculator loanMaximumAmountCalculator) {
@Lazy InterestRefundServiceDelegate interestRefundServiceDelegate, LoanMaximumAmountCalculator loanMaximumAmountCalculator,
LoanRepaymentScheduleService loanRepaymentScheduleService) {
return new LoanReadPlatformServiceImpl(jdbcTemplate, context, loanRepositoryWrapper, applicationCurrencyRepository,
loanProductReadPlatformService, clientReadPlatformService, groupReadPlatformService, loanDropdownReadPlatformService,
fundReadPlatformService, chargeReadPlatformService, codeValueReadPlatformService, calendarReadPlatformService,
@@ -359,7 +361,7 @@ public class LoanAccountConfiguration {
delinquencyReadPlatformService, loanTransactionRepository, loanChargePaidByReadService, loanTransactionRelationReadService,
loanForeclosureValidator, loanTransactionMapper, loanTransactionProcessingService, loanBalanceService,
loanCapitalizedIncomeBalanceRepository, loanBuyDownFeeBalanceRepository, interestRefundServiceDelegate,
loanMaximumAmountCalculator);
loanMaximumAmountCalculator, loanRepaymentScheduleService);
}
@Bean
@@ -0,0 +1,361 @@
/**
* 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.api;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest;
import org.hibernate.validator.HibernateValidator;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ContextConfiguration;
@Slf4j
@SpringBootTest
@ContextConfiguration(classes = { ReAgePreviewRequestValidationTest.TestConfig.class })
class ReAgePreviewRequestValidationTest {
@Configuration
@Import({ MessageSourceAutoConfiguration.class })
static class TestConfig {
@Bean
public Validator validator() {
return Validation.byProvider(HibernateValidator.class).configure().buildValidatorFactory().getValidator();
}
}
@Autowired
private Validator validator;
@Test
void invalidAllBlank() {
var params = ReAgePreviewRequest.builder().build();
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(6);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyNumber"));
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType"));
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("startDate"));
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("numberOfInstallments"));
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("dateFormat"));
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("locale"));
}
@Test
void invalidFrequencyNumberNull() {
var params = validParams();
params.setFrequencyNumber(null);
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyNumber"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyNumber' is mandatory."));
}
@Test
void invalidFrequencyNumberZero() {
var params = validParams();
params.setFrequencyNumber(0);
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyNumber"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyNumber' must be at least 1."));
}
@Test
void invalidFrequencyNumberNegative() {
var params = validParams();
params.setFrequencyNumber(-1);
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyNumber"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyNumber' must be at least 1."));
}
@Test
void invalidFrequencyTypeNull() {
var params = validParams();
params.setFrequencyType(null);
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyType' is mandatory."));
}
@Test
void invalidFrequencyTypeEmpty() {
var params = validParams();
params.setFrequencyType("");
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'frequencyType' is mandatory."));
}
@Test
void invalidFrequencyTypeBlank() {
var params = validParams();
params.setFrequencyType(" ");
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType"));
}
@Test
void invalidFrequencyTypeInvalidEnum() {
var params = validParams();
params.setFrequencyType("NOT_A_VALID_ENUM");
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("frequencyType"));
assertThat(errors).anyMatch(e -> e.getMessage()
.equals("The parameter 'frequencyType' must be valid PeriodFrequencyType value. Provided value: 'NOT_A_VALID_ENUM'."));
}
@Test
void invalidStartDateNull() {
var params = validParams();
params.setStartDate(null);
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("startDate"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'startDate' is mandatory."));
}
@Test
void invalidStartDateEmpty() {
var params = validParams();
params.setStartDate("");
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("startDate"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'startDate' is mandatory."));
}
@Test
void invalidStartDateBlank() {
var params = validParams();
params.setStartDate(" ");
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("startDate"));
}
@Test
void invalidStartDateFormat() {
var params = validParams();
params.setStartDate("2025-05-12");
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getMessage().equals("Wrong local date fields."));
}
@Test
void invalidNumberOfInstallmentsNull() {
var params = validParams();
params.setNumberOfInstallments(null);
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("numberOfInstallments"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'numberOfInstallments' is mandatory."));
}
@Test
void invalidNumberOfInstallmentsZero() {
var params = validParams();
params.setNumberOfInstallments(0);
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("numberOfInstallments"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'numberOfInstallments' must be at least 1."));
}
@Test
void invalidNumberOfInstallmentsNegative() {
var params = validParams();
params.setNumberOfInstallments(-1);
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("numberOfInstallments"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'numberOfInstallments' must be at least 1."));
}
@Test
void invalidDateFormatNull() {
var params = validParams();
params.setDateFormat(null);
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("dateFormat"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'dateFormat' is mandatory."));
}
@Test
void invalidDateFormatEmpty() {
var params = validParams();
params.setDateFormat("");
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("dateFormat"));
}
@Test
void invalidDateFormatMismatch() {
var params = validParams();
params.setDateFormat("yyyy-MM-dd");
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getMessage().equals("Wrong local date fields."));
}
@Test
void invalidLocaleNull() {
var params = validParams();
params.setLocale(null);
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("locale"));
assertThat(errors).anyMatch(e -> e.getMessage().equals("The parameter 'locale' is mandatory."));
}
@Test
void invalidLocaleEmpty() {
var params = validParams();
params.setLocale("");
var errors = validator.validate(params);
assertThat(errors).hasSizeGreaterThanOrEqualTo(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("locale"));
}
@Test
void invalidLocaleInvalidFormat() {
var params = validParams();
params.setLocale("invalid-locale");
var errors = validator.validate(params);
assertThat(errors).hasSize(1);
assertThat(errors).anyMatch(e -> e.getPropertyPath().toString().equals("locale"));
}
@Test
void validAllParams() {
var params = validParams();
var errors = validator.validate(params);
assertThat(errors).isEmpty();
}
@Test
void validParamsWithDaysFrequency() {
var params = validParams();
params.setFrequencyType("DAYS");
params.setFrequencyNumber(30);
var errors = validator.validate(params);
assertThat(errors).isEmpty();
}
@Test
void validParamsWithWeeksFrequency() {
var params = validParams();
params.setFrequencyType("WEEKS");
params.setFrequencyNumber(4);
var errors = validator.validate(params);
assertThat(errors).isEmpty();
}
@Test
void validParamsWithYearsFrequency() {
var params = validParams();
params.setFrequencyType("YEARS");
params.setFrequencyNumber(1);
var errors = validator.validate(params);
assertThat(errors).isEmpty();
}
@Test
void validParamsWithAlternativeLocale() {
var params = validParams();
params.setLocale("en_US");
var errors = validator.validate(params);
assertThat(errors).isEmpty();
}
private ReAgePreviewRequest validParams() {
return ReAgePreviewRequest.builder().frequencyNumber(1).frequencyType("MONTHS").startDate("12-05-2025").numberOfInstallments(6)
.dateFormat("dd-MM-yyyy").locale("en").build();
}
}
@@ -113,7 +113,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.externalId.exceeds.max.length");
}
@@ -137,7 +137,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.startDate.cannot.be.blank");
}
@@ -161,7 +161,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.frequencyType.cannot.be.blank");
}
@@ -185,7 +185,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.frequencyNumber.cannot.be.blank");
}
@@ -210,7 +210,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.frequencyNumber.not.greater.than.zero");
}
@@ -234,7 +234,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.numberOfInstallments.cannot.be.blank");
}
@@ -259,7 +259,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.numberOfInstallments.not.greater.than.zero");
}
@@ -284,7 +284,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.numberOfInstallments.not.greater.than.zero");
}
@@ -301,7 +301,7 @@ class LoanReAgingValidatorTest {
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist");
assertThat(result.getErrors().get(0).getUserMessageGlobalisationCode())
assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode())
.isEqualTo("validation.msg.loan.reAge.startDate.is.less.than.date");
}
@@ -371,10 +371,9 @@ class LoanReAgingValidatorTest {
List<LoanTransaction> transactions = List.of(loanTransaction(LoanTransactionType.DISBURSEMENT, actualDate.minusDays(3)));
Loan loan = loan();
given(loan.getLoanTransactions()).willReturn(transactions);
JsonCommand command = jsonCommand();
// when
GeneralPlatformDomainRuleException result = assertThrows(GeneralPlatformDomainRuleException.class,
() -> underTest.validateUndoReAge(loan, command));
() -> underTest.validateUndoReAge(loan));
// then
assertThat(result).isNotNull();
assertThat(result.getGlobalisationMessageCode()).isEqualTo("error.msg.loan.reage.reaging.transaction.missing");
@@ -390,9 +389,8 @@ class LoanReAgingValidatorTest {
OffsetDateTime.of(actualDate, LocalTime.of(9, 0), ZoneOffset.UTC)));
Loan loan = loan();
given(loan.getLoanTransactions()).willReturn(transactions);
JsonCommand command = jsonCommand();
// when
underTest.validateUndoReAge(loan, command);
underTest.validateUndoReAge(loan);
// then no exception thrown
}