FINERACT-2059: Re-aging repayment schedule handling

This commit is contained in:
Adam Saghy
2024-03-19 10:30:18 +01:00
parent 314758abb5
commit d955bc26cb
21 changed files with 413 additions and 114 deletions
@@ -24,7 +24,8 @@ public interface LoanReAgingApiConstants {
String dateFormatParameterName = "dateFormat";
String externalIdParameterName = "externalId";
String frequency = "frequency";
String frequencyType = "frequencyType";
String frequencyNumber = "frequencyNumber";
String startDate = "startDate";
String numberOfInstallments = "numberOfInstallments";
}
@@ -2166,8 +2166,9 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
}
private LocalDate determineExpectedMaturityDate() {
final int numberOfInstallments = this.repaymentScheduleInstallments.size();
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments().stream()
.filter(i -> !i.isDownPayment() && !i.isAdditional()).toList();
final int numberOfInstallments = installments.size();
LocalDate maturityDate = installments.get(numberOfInstallments - 1).getDueDate();
ListIterator<LoanRepaymentScheduleInstallment> iterator = installments.listIterator(numberOfInstallments);
while (iterator.hasPrevious()) {
@@ -3432,7 +3433,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
final List<LoanTransaction> repaymentsOrWaivers = new ArrayList<>();
List<LoanTransaction> trans = getLoanTransactions();
for (final LoanTransaction transaction : trans) {
if (transaction.isNotReversed() && (transaction.isChargeOff() || !transaction.isNonMonetaryTransaction())) {
if (transaction.isNotReversed() && (transaction.isChargeOff() || transaction.isReAge() || transaction.isReAmortize()
|| !transaction.isNonMonetaryTransaction())) {
repaymentsOrWaivers.add(transaction);
}
}
@@ -3670,10 +3672,10 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
}
private LocalDate getNextUnpaidInstallmentDueDate() {
LocalDate nextUnpaidInstallmentDate = null;
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
LocalDate currentBusinessDate = DateUtils.getBusinessLocalDate();
LocalDate expectedMaturityDate = determineExpectedMaturityDate();
LocalDate nextUnpaidInstallmentDate = expectedMaturityDate;
for (final LoanRepaymentScheduleInstallment installment : installments) {
boolean isCurrentDateBeforeInstallmentAndLoanPeriod = DateUtils.isBefore(currentBusinessDate, installment.getDueDate())
@@ -5664,7 +5666,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
lastCompoundingDate = compoundingDetail.getEffectiveDate();
}
List<LoanRepaymentScheduleInstallment> installments = getRepaymentScheduleInstallments();
LoanRepaymentScheduleInstallment lastInstallment = installments.get(installments.size() - 1);
LoanRepaymentScheduleInstallment lastInstallment = LoanRepaymentScheduleInstallment
.getLastNonDownPaymentInstallment(installments);
reverseTransactionsPostEffectiveDate(incomeTransactions, lastInstallment.getDueDate());
reverseTransactionsPostEffectiveDate(accrualTransactions, lastInstallment.getDueDate());
}
@@ -29,6 +29,7 @@ import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -145,6 +146,9 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
@Column(name = "is_down_payment", nullable = false)
private boolean isDownPayment;
@Column(name = "is_re_aged", nullable = false)
private boolean isReAged;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, mappedBy = "loanRepaymentScheduleInstallment")
private Set<LoanInterestRecalcualtionAdditionalDetails> loanCompoundingDetails = new HashSet<>();
@@ -223,6 +227,36 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
this.obligationsMet = false;
}
public LoanRepaymentScheduleInstallment(Loan loan, Integer installmentNumber, LocalDate fromDate, LocalDate dueDate,
BigDecimal principal, BigDecimal interestCharged, BigDecimal feeChargesCharged, BigDecimal penaltyCharges,
BigDecimal creditedPrincipal, BigDecimal creditedFee, BigDecimal creditedPenalty, boolean additional, boolean isDownPayment,
boolean isReAged) {
this.loan = loan;
this.installmentNumber = installmentNumber;
this.fromDate = fromDate;
this.dueDate = dueDate;
this.principal = principal;
this.interestCharged = interestCharged;
this.feeChargesCharged = feeChargesCharged;
this.penaltyCharges = penaltyCharges;
this.creditedPrincipal = creditedPrincipal;
this.creditedFee = creditedFee;
this.creditedPenalty = creditedPenalty;
this.additional = additional;
this.isDownPayment = isDownPayment;
this.isReAged = isReAged;
}
public static LoanRepaymentScheduleInstallment newReAgedInstallment(final Loan loan, final Integer installmentNumber,
final LocalDate fromDate, final LocalDate dueDate, final BigDecimal principal) {
return new LoanRepaymentScheduleInstallment(loan, installmentNumber, fromDate, dueDate, principal, null, null, null, null, null,
null, false, false, true);
}
public static LoanRepaymentScheduleInstallment getLastNonDownPaymentInstallment(List<LoanRepaymentScheduleInstallment> installments) {
return installments.stream().filter(i -> !i.isDownPayment()).reduce((first, second) -> second).orElseThrow();
}
private BigDecimal defaultToNullIfZero(final BigDecimal value) {
BigDecimal result = value;
if (BigDecimal.ZERO.compareTo(value) == 0) {
@@ -400,6 +434,10 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
return this.installmentNumber.compareTo(o.installmentNumber);
}
public int compareToByDueDate(LoanRepaymentScheduleInstallment o) {
return this.dueDate.compareTo(o.dueDate);
}
public boolean isPrincipalNotCompleted(final MonetaryCurrency currency) {
return !isPrincipalCompleted(currency);
}
@@ -1022,4 +1060,8 @@ public class LoanRepaymentScheduleInstallment extends AbstractAuditableWithUTCDa
public enum PaymentAction {
PAY, UNPAY
}
public boolean isReAged() {
return isReAged;
}
}
@@ -865,7 +865,8 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
|| LoanTransactionType.MARKED_FOR_RESCHEDULING.equals(getTypeOf())
|| LoanTransactionType.APPROVE_TRANSFER.equals(getTypeOf()) || LoanTransactionType.INITIATE_TRANSFER.equals(getTypeOf())
|| LoanTransactionType.REJECT_TRANSFER.equals(getTypeOf()) || LoanTransactionType.WITHDRAW_TRANSFER.equals(getTypeOf())
|| LoanTransactionType.CHARGE_OFF.equals(getTypeOf()));
|| LoanTransactionType.CHARGE_OFF.equals(getTypeOf()) || LoanTransactionType.REAMORTIZE.equals(getTypeOf())
|| LoanTransactionType.REAGE.equals(getTypeOf()));
}
public void updateOutstandingLoanBalance(BigDecimal outstandingLoanBalance) {
@@ -61,7 +61,9 @@ public enum LoanTransactionType {
CHARGE_ADJUSTMENT(26, "loanTransactionType.chargeAdjustment"), //
CHARGE_OFF(27, "loanTransactionType.chargeOff"), //
DOWN_PAYMENT(28, "loanTransactionType.downPayment"), //
REAGE(29, "loanTransactionType.reAge"), REAMORTIZE(30, "loanTransactionType.reAmortize");
REAGE(29, "loanTransactionType.reAge"), //
REAMORTIZE(30, "loanTransactionType.reAmortize"), //
;
private final Integer value;
private final String code;
@@ -40,8 +40,11 @@ public class LoanReAgeParameter extends AbstractAuditableWithUTCDateTimeCustom {
private Long loanTransactionId;
@Enumerated(EnumType.STRING)
@Column(name = "frequency", nullable = false)
private PeriodFrequencyType frequency;
@Column(name = "frequency_type", nullable = false)
private PeriodFrequencyType frequencyType;
@Column(name = "frequency_number", nullable = false)
private Integer frequencyNumber;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@@ -479,7 +479,8 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
loanTransaction.resetDerivedComponents();
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
final Comparator<LoanRepaymentScheduleInstallment> byDate = Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate);
installments.sort(byDate);
List<LoanRepaymentScheduleInstallment> installmentToBeProcessed = installments.stream().filter(i -> !i.isDownPayment())
.sorted(byDate).toList();
final Money zeroMoney = Money.zero(currency);
Money transactionAmount = loanTransaction.getAmount(currency);
Money principalPortion = MathUtil.negativeToZero(loanTransaction.getAmount(currency).minus(overpaymentHolder.getMoneyObject()));
@@ -492,7 +493,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
final LocalDate transactionDate = loanTransaction.getTransactionDate();
boolean loanTransactionMapped = false;
LocalDate pastDueDate = null;
for (final LoanRepaymentScheduleInstallment currentInstallment : installments) {
for (final LoanRepaymentScheduleInstallment currentInstallment : installmentToBeProcessed) {
pastDueDate = currentInstallment.getDueDate();
if (!currentInstallment.isAdditional() && DateUtils.isAfter(currentInstallment.getDueDate(), transactionDate)) {
currentInstallment.addToCreditedPrincipal(transactionAmount.getAmount());
@@ -526,7 +527,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
// New installment will be added (N+1 scenario)
if (!loanTransactionMapped) {
if (loanTransaction.getTransactionDate().equals(pastDueDate)) {
LoanRepaymentScheduleInstallment currentInstallment = installments.get(installments.size() - 1);
LoanRepaymentScheduleInstallment currentInstallment = installmentToBeProcessed.get(installmentToBeProcessed.size() - 1);
currentInstallment.addToCreditedPrincipal(transactionAmount.getAmount());
currentInstallment.addToPrincipal(transactionDate, transactionAmount);
if (repaidAmount.isGreaterThanZero()) {
@@ -848,7 +849,8 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
protected void addChargeOnlyRepaymentInstallmentIfRequired(Set<LoanCharge> charges,
List<LoanRepaymentScheduleInstallment> installments) {
if (!CollectionUtils.isEmpty(charges) && !CollectionUtils.isEmpty(installments)) {
LoanRepaymentScheduleInstallment latestRepaymentScheduleInstalment = installments.get(installments.size() - 1);
LoanRepaymentScheduleInstallment latestRepaymentScheduleInstalment = installments.stream().filter(i -> !i.isDownPayment())
.reduce((first, second) -> second).orElseThrow();
LocalDate installmentDueDate = null;
LoanCharge latestCharge = getLatestLoanChargeWithSpecificDueDate(charges);
@@ -867,7 +869,6 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, false, null);
installment.markAsAdditional();
loan.addLoanRepaymentScheduleInstallment(installment);
}
}
}
@@ -22,7 +22,6 @@ import static java.math.BigDecimal.ZERO;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toList;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum.CHARGEBACK;
import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.REAMORTIZE;
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.FEE;
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.INTEREST;
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PENALTY;
@@ -44,11 +43,14 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.NotImplementedException;
@@ -71,6 +73,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.SingleLoanChargeRepaymentScheduleProcessingWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParameter;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
@@ -84,12 +88,15 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@Slf4j
@RequiredArgsConstructor
public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY = "advanced-payment-allocation-strategy";
public final SingleLoanChargeRepaymentScheduleProcessingWrapper loanChargeProcessor = new SingleLoanChargeRepaymentScheduleProcessingWrapper();
private final LoanReAgingParameterRepository reAgingParameterRepository;
@Override
public String getCode() {
return ADVANCED_PAYMENT_ALLOCATION_STRATEGY;
@@ -144,6 +151,10 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
}
}
// Remove re-aged and additional (N+1) installments (if applicable), those will be recreated during the
// reprocessing
installments.removeIf(LoanRepaymentScheduleInstallment::isReAged);
installments.removeIf(LoanRepaymentScheduleInstallment::isAdditional);
addChargeOnlyRepaymentInstallmentIfRequired(charges, installments);
@@ -185,6 +196,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
ctx.getOverpaymentHolder());
case WAIVE_CHARGES -> log.debug("WAIVE_CHARGES transaction will not be processed.");
case REAMORTIZE -> handleReAmortization(loanTransaction, ctx.getCurrency(), ctx.getInstallments());
case REAGE -> handleReAge(loanTransaction, ctx);
// TODO: Cover rest of the transaction types
default -> {
log.warn("Unhandled transaction processing for transaction type: {}", loanTransaction.getTypeOf());
@@ -201,7 +213,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
.toList();
List<LoanRepaymentScheduleInstallment> futureInstallments = installments.stream() //
.filter(installment -> installment.getDueDate().isAfter(transactionDate)) //
.filter(installment -> !installment.isAdditional() && !installment.isDownPayment()) //
.filter(installment -> !installment.isAdditional() && !installment.isDownPayment() && !installment.isReAged()) //
.toList();
BigDecimal overallOverDuePrincipal = ZERO;
@@ -1279,4 +1291,62 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private Money aggregatedInterestPortion;
private Money aggregatedPenaltyChargesPortion;
}
private void handleReAge(LoanTransaction loanTransaction, TransactionCtx ctx) {
MonetaryCurrency currency = ctx.getCurrency();
List<LoanRepaymentScheduleInstallment> installments = ctx.getInstallments();
// Either we have the transaction id or we need to fetch it from context
Long loanTransactionId = loanTransaction.getId() != null ? loanTransaction.getId()
: ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().get(loanTransaction);
LoanReAgeParameter reAgeParameter = reAgingParameterRepository.findByLoanTransactionId(loanTransactionId).orElseThrow();
AtomicReference<Money> outstandingPrincipalBalance = new AtomicReference<>(Money.zero(currency));
installments.forEach(i -> {
Money principalOutstanding = i.getPrincipalOutstanding(currency);
if (principalOutstanding.isGreaterThanZero()) {
outstandingPrincipalBalance.set(outstandingPrincipalBalance.get().add(principalOutstanding));
i.addToPrincipal(loanTransaction.getTransactionDate(), principalOutstanding.negated());
}
});
Money calculatedPrincipal = outstandingPrincipalBalance.get().dividedBy(reAgeParameter.getNumberOfInstallments(),
MoneyHelper.getRoundingMode());
Integer installmentAmountInMultiplesOf = loanTransaction.getLoan().getLoanProduct().getInstallmentAmountInMultiplesOf();
if (installmentAmountInMultiplesOf != null) {
calculatedPrincipal = Money.roundToMultiplesOf(calculatedPrincipal, installmentAmountInMultiplesOf);
}
Money adjustCalculatedPrincipal = outstandingPrincipalBalance.get()
.minus(calculatedPrincipal.multipliedBy(reAgeParameter.getNumberOfInstallments()));
LoanRepaymentScheduleInstallment lastNormalInstallment = installments.stream().filter(i -> !i.isDownPayment())
.reduce((first, second) -> second).orElseThrow();
LoanRepaymentScheduleInstallment reAgedInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(
lastNormalInstallment.getLoan(), lastNormalInstallment.getInstallmentNumber() + 1, lastNormalInstallment.getDueDate(),
reAgeParameter.getStartDate(), calculatedPrincipal.getAmount());
installments.add(reAgedInstallment);
for (int i = 1; i < reAgeParameter.getNumberOfInstallments(); i++) {
LocalDate calculatedDueDate = calculateReAgedInstallmentDueDate(reAgeParameter, reAgedInstallment.getDueDate());
reAgedInstallment = LoanRepaymentScheduleInstallment.newReAgedInstallment(reAgedInstallment.getLoan(),
reAgedInstallment.getInstallmentNumber() + 1, reAgedInstallment.getDueDate(), calculatedDueDate,
calculatedPrincipal.getAmount());
installments.add(reAgedInstallment);
}
reAgedInstallment.addToPrincipal(loanTransaction.getTransactionDate(), adjustCalculatedPrincipal);
reprocessInstallmentsOrder(installments);
}
private void reprocessInstallmentsOrder(List<LoanRepaymentScheduleInstallment> installments) {
AtomicInteger counter = new AtomicInteger(0);
installments.stream().sorted(LoanRepaymentScheduleInstallment::compareToByDueDate)
.forEachOrdered(i -> i.updateInstallmentNumber(counter.getAndIncrement()));
}
private LocalDate calculateReAgedInstallmentDueDate(LoanReAgeParameter reAgeParameter, LocalDate dueDate) {
return switch (reAgeParameter.getFrequencyType()) {
case DAYS -> dueDate.plusDays(reAgeParameter.getFrequencyNumber());
case WEEKS -> dueDate.plusWeeks(reAgeParameter.getFrequencyNumber());
case MONTHS -> dueDate.plusMonths(reAgeParameter.getFrequencyNumber());
case YEARS -> dueDate.plusYears(reAgeParameter.getFrequencyNumber());
default -> throw new UnsupportedOperationException(reAgeParameter.getFrequencyType().getCode());
};
}
}
@@ -42,4 +42,5 @@
<include relativeToChangelogFile="true" file="parts/1017_add_fee_and_penalty_adjustments_to_loan.xml"/>
<include relativeToChangelogFile="true" file="parts/1018_rename_credited_principal_back_to_credits_amount.xml"/>
<include relativeToChangelogFile="true" file="parts/1019_add_fixed_length.xml"/>
<include relativeToChangelogFile="true" file="parts/1020_add_re_aged_flag_to_loan_installment.xml"/>
</databaseChangeLog>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_loan_repayment_schedule">
<column name="is_re_aged" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -285,8 +285,10 @@ final class LoanTransactionsApiResourceSwagger {
public Long writeoffReasonId;
// command=reAge START
@Schema(example = "frequency")
public String frequency;
@Schema(example = "frequencyType")
public String frequencyType;
@Schema(example = "frequencyNumber")
public Integer frequencyNumber;
@Schema(example = "startDate")
public String startDate;
@Schema(example = "numberOfInstallments")
@@ -1854,7 +1854,8 @@ public abstract class AbstractCumulativeLoanScheduleGenerator implements LoanSch
holidayDetailDTO);
updateMapWithAmount(principalPortionMap, unprocessed, applicableDate);
installment.addPrincipalAmount(unprocessed);
LoanRepaymentScheduleInstallment lastInstallment = installments.get(installments.size() - 1);
LoanRepaymentScheduleInstallment lastInstallment = installments.stream().filter(i -> !i.isDownPayment())
.reduce((first, second) -> second).orElseThrow();
lastInstallment.updatePrincipal(lastInstallment.getPrincipal(unprocessed.getCurrency()).plus(unprocessed).getAmount());
lastInstallment.payPrincipalComponent(detail.getTransactionDate(), unprocessed);
}
@@ -181,6 +181,12 @@ public abstract class AbstractProgressiveLoanScheduleGenerator implements LoanSc
// }
}
// If the disbursement happened after maturity date
if (loanApplicationTerms.isMultiDisburseLoan()) {
processDisbursements(loanApplicationTerms, chargesDueAtTimeOfDisbursement, scheduleParams, periods,
DateUtils.getBusinessLocalDate().plusDays(1));
}
// determine fees and penalties for charges which depends on total
// loan interest
updatePeriodsWithCharges(currency, scheduleParams, periods, nonCompoundingCharges, mc);
@@ -498,7 +498,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
}
}
if (!changes.isEmpty()) {
loan.updateLoanScheduleDependentDerivedFields();
loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan);
final String noteText = command.stringValueOfParameterNamed("note");
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -41,12 +42,18 @@ import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionComparator;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParameter;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -61,6 +68,8 @@ public class LoanReAgingServiceImpl {
private final BusinessEventNotifierService businessEventNotifierService;
private final LoanTransactionRepository loanTransactionRepository;
private final LoanReAgingParameterRepository reAgingParameterRepository;
private final LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory;
private final NoteRepository noteRepository;
public CommandProcessingResult reAge(Long loanId, JsonCommand command) {
Loan loan = loanAssembler.assembleFrom(loanId);
@@ -77,6 +86,15 @@ public class LoanReAgingServiceImpl {
LoanReAgeParameter reAgeParameter = createReAgeParameter(reAgeTransaction, command);
reAgingParameterRepository.saveAndFlush(reAgeParameter);
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory
.determineProcessor(loan.transactionProcessingStrategy());
loanRepaymentScheduleTransactionProcessor.processLatestTransaction(reAgeTransaction,
new LoanRepaymentScheduleTransactionProcessor.TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(),
loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney())));
loan.updateLoanScheduleDependentDerivedFields();
persistNote(loan, command, changes);
// delinquency recalculation will be triggered by the event in a decoupled way via a listener
businessEventNotifierService.notifyPostBusinessEvent(new LoanReAgeBusinessEvent(loan));
businessEventNotifierService.notifyPostBusinessEvent(new LoanReAgeTransactionBusinessEvent(reAgeTransaction));
@@ -91,15 +109,6 @@ public class LoanReAgingServiceImpl {
.with(changes).build();
}
private LoanReAgeParameter createReAgeParameter(LoanTransaction reAgeTransaction, JsonCommand command) {
// TODO: these parameters should be checked when the validations are implemented
PeriodFrequencyType periodFrequencyType = command.enumValueOfParameterNamed(LoanReAgingApiConstants.frequency,
PeriodFrequencyType.class);
LocalDate startDate = command.dateValueOfParameterNamed(LoanReAgingApiConstants.startDate);
Integer numberOfInstallments = command.integerValueOfParameterNamed(LoanReAgingApiConstants.numberOfInstallments);
return new LoanReAgeParameter(reAgeTransaction.getId(), periodFrequencyType, startDate, numberOfInstallments);
}
public CommandProcessingResult undoReAge(Long loanId, JsonCommand command) {
Loan loan = loanAssembler.assembleFrom(loanId);
reAgingValidator.validateUndoReAge(loan, command);
@@ -115,6 +124,10 @@ public class LoanReAgingServiceImpl {
reverseReAgeTransaction(reAgeTransaction, command);
loanTransactionRepository.saveAndFlush(reAgeTransaction);
reProcessLoanTransactions(reAgeTransaction.getLoan());
loan.updateLoanScheduleDependentDerivedFields();
persistNote(loan, command, changes);
// delinquency recalculation will be triggered by the event in a decoupled way via a listener
businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoReAgeBusinessEvent(loan));
businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoReAgeTransactionBusinessEvent(reAgeTransaction));
@@ -156,4 +169,35 @@ public class LoanReAgingServiceImpl {
return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE.getValue(), transactionDate, txPrincipalAmount,
txPrincipalAmount, ZERO, ZERO, ZERO, null, false, null, txExternalId);
}
private LoanReAgeParameter createReAgeParameter(LoanTransaction reAgeTransaction, JsonCommand command) {
// TODO: these parameters should be checked when the validations are implemented
PeriodFrequencyType periodFrequencyType = command.enumValueOfParameterNamed(LoanReAgingApiConstants.frequencyType,
PeriodFrequencyType.class);
LocalDate startDate = command.dateValueOfParameterNamed(LoanReAgingApiConstants.startDate);
Integer numberOfInstallments = command.integerValueOfParameterNamed(LoanReAgingApiConstants.numberOfInstallments);
Integer periodFrequencyNumber = command.integerValueOfParameterNamed(LoanReAgingApiConstants.frequencyNumber);
return new LoanReAgeParameter(reAgeTransaction.getId(), periodFrequencyType, periodFrequencyNumber, startDate,
numberOfInstallments);
}
private void reProcessLoanTransactions(Loan loan) {
final List<LoanTransaction> filteredTransactions = loan.getLoanTransactions().stream().filter(LoanTransaction::isNotReversed)
.filter(t -> t.isChargeOff() || !t.isNonMonetaryTransaction()).sorted(LoanTransactionComparator.INSTANCE).toList();
final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory
.determineProcessor(loan.transactionProcessingStrategy());
loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(loan.getDisbursementDate(), filteredTransactions,
loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges());
}
private void persistNote(Loan loan, JsonCommand command, Map<String, Object> changes) {
if (command.hasParameter("note")) {
final String note = command.stringValueOfParameterNamed("note");
final Note newNote = Note.loanNote(loan, note);
changes.put("note", note);
this.noteRepository.saveAndFlush(newNote);
}
}
}
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.loanaccount.starter;
import java.util.List;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.CreocoreLoanRepaymentScheduleTransactionProcessor;
@@ -103,8 +104,9 @@ public class LoanAccountAutoStarter {
@Bean
@Conditional(AdvancedPaymentScheduleTransactionProcessorCondition.class)
public AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor() {
return new AdvancedPaymentScheduleTransactionProcessor();
public AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor(
LoanReAgingParameterRepository reAgingParameterRepository) {
return new AdvancedPaymentScheduleTransactionProcessor(reAgingParameterRepository);
}
}
@@ -71,4 +71,12 @@
</column>
</addColumn>
</changeSet>
<changeSet id="4" author="fineract">
<addColumn tableName="m_loan_reage_parameter">
<column name="frequency_number" type="SMALLINT" defaultValueNumeric="1">
<constraints nullable="false"/>
</column>
</addColumn>
<renameColumn tableName="m_loan_reage_parameter" oldColumnName="frequency" newColumnName="frequency_type" columnDataType="VARCHAR(100)"/>
</changeSet>
</databaseChangeLog>
@@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgingParameterRepository;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
@@ -89,6 +90,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
private static final MonetaryCurrency MONETARY_CURRENCY = new MonetaryCurrency("USD", 2, 1);
private static final MockedStatic<MoneyHelper> MONEY_HELPER = mockStatic(MoneyHelper.class);
private AdvancedPaymentScheduleTransactionProcessor underTest;
private LoanReAgingParameterRepository reAgingParameterRepository = Mockito.mock(LoanReAgingParameterRepository.class);
@BeforeAll
public static void init() {
@@ -102,7 +104,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
@BeforeEach
public void setUp() {
underTest = new AdvancedPaymentScheduleTransactionProcessor();
underTest = new AdvancedPaymentScheduleTransactionProcessor(reAgingParameterRepository);
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null));
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
@@ -378,11 +378,12 @@ public abstract class BaseLoanIntegrationTest {
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId));
}
protected void reAgeLoan(Long loanId, String frequency, String startDate, Integer numberOfInstallments) {
protected void reAgeLoan(Long loanId, String frequencyType, int frequencyNumber, String startDate, Integer numberOfInstallments) {
PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest();
request.setDateFormat(DATETIME_PATTERN);
request.setLocale("en");
request.setFrequency(frequency);
request.setFrequencyType(frequencyType);
request.setFrequencyNumber(frequencyNumber);
request.setStartDate(startDate);
request.setNumberOfInstallments(numberOfInstallments);
loanTransactionHelper.reAge(loanId, request);
@@ -798,6 +799,13 @@ public abstract class BaseLoanIntegrationTest {
assertEquals(paidLate, period.getTotalPaidLateForPeriod());
}
protected void checkMaturityDates(long loanId, LocalDate expectedMaturityDate, LocalDate actualMaturityDate) {
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertEquals(expectedMaturityDate, loanDetails.getTimeline().getExpectedMaturityDate());
assertEquals(actualMaturityDate, loanDetails.getTimeline().getActualMaturityDate());
}
@RequiredArgsConstructor
public static class BatchRequestBuilder {
@@ -937,6 +945,7 @@ public abstract class BaseLoanIntegrationTest {
public static final Integer MONTHS = 2;
public static final String MONTHS_STRING = "MONTHS";
public static final String DAYS_STRING = "DAYS";
}
public static class InterestCalculationPeriodType {
@@ -120,9 +120,6 @@ public class LoanDelinquencyDetailsNextPaymentDateConfigurationTest extends Base
businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("25 December 2023")
.dateFormat(DATETIME_PATTERN).locale("en"));
// delinquency null next payment date for date after maturity date
verifyLoanDelinquencyNextPaymentDate(loanId, "", true);
} finally {
// reset global config
GlobalConfigurationHelper.updateLoanNextPaymentDateConfiguration(this.requestSpec, this.responseSpec,
@@ -238,10 +235,6 @@ public class LoanDelinquencyDetailsNextPaymentDateConfigurationTest extends Base
businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BUSINESS_DATE.getName()).date("25 December 2023")
.dateFormat(DATETIME_PATTERN).locale("en"));
// delinquency null next payment date for date after maturity date
verifyLoanDelinquencyNextPaymentDate(loanId, "", true);
} finally {
// reset global config
GlobalConfigurationHelper.updateLoanNextPaymentDateConfiguration(this.requestSpec, this.responseSpec,
@@ -19,10 +19,15 @@
package org.apache.fineract.integrationtests.loan.reaging;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.fineract.client.models.PostChargesResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdChargesResponse;
import org.apache.fineract.client.models.PostLoansLoanIdResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansRequest;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.integrationtests.BaseLoanIntegrationTest;
@@ -40,13 +45,17 @@ public class LoanReAgingIntegrationTest extends BaseLoanIntegrationTest {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 1;
int numberOfRepayments = 3;
int repaymentEvery = 1;
// Create Loan Product
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() //
.numberOfRepayments(numberOfRepayments) //
.repaymentEvery(repaymentEvery) //
.installmentAmountInMultiplesOf(null) //
.enableDownPayment(true) //
.disbursedAmountPercentageForDownPayment(BigDecimal.valueOf(25)) //
.enableAutoRepaymentForDownPayment(true) //
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()); //
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
@@ -74,102 +83,75 @@ public class LoanReAgingIntegrationTest extends BaseLoanIntegrationTest {
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023") //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(312.5, "Down Payment", "01 January 2023") //
);
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(0, null, "01 January 2023"), //
installment(1250.0, false, "01 February 2023") //
installment(1250, null, "01 January 2023"), //
installment(312.5, true, "01 January 2023"), //
installment(312.5, false, "01 February 2023"), //
installment(312.5, false, "01 March 2023"), //
installment(312.5, false, "01 April 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 4, 1), LocalDate.of(2023, 4, 1));
createdLoanId.set(loanId);
});
runAt("02 February 2023", () -> {
runAt("11 April 2023", () -> {
long loanId = createdLoanId.get();
// create re-age transaction
reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, "02 February 2023", 6);
// create charge
double chargeAmount = 10.0;
PostChargesResponse chargeResult = createCharge(chargeAmount);
Long chargeId = chargeResult.getResourceId();
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(1250.0, "Re-age", "02 February 2023") //
);
// TODO: verify installments when schedule generation is implemented
});
}
@Test
public void test_LoanUndoReAgeTransaction_Works() {
AtomicLong createdLoanId = new AtomicLong();
runAt("01 January 2023", () -> {
// Create Client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
int numberOfRepayments = 1;
int repaymentEvery = 1;
// Create Loan Product
PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() //
.numberOfRepayments(numberOfRepayments) //
.repaymentEvery(repaymentEvery) //
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()); //
PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product);
Long loanProductId = loanProductResponse.getResourceId();
// Apply and Approve Loan
double amount = 1250.0;
PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)//
.transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)//
.repaymentEvery(repaymentEvery)//
.loanTermFrequency(numberOfRepayments)//
.repaymentFrequencyType(RepaymentFrequencyType.MONTHS)//
.loanTermFrequencyType(RepaymentFrequencyType.MONTHS);
PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest);
PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(),
approveLoanRequest(amount, "01 January 2023"));
Long loanId = approvedLoanResult.getLoanId();
// disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January 2023");
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023") //
);
// add charge after maturity
PostLoansLoanIdChargesResponse loanChargeResult = addLoanCharge(loanId, chargeId, "11 April 2023", chargeAmount);
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(0, null, "01 January 2023"), //
installment(1250.0, false, "01 February 2023") //
installment(1250, null, "01 January 2023"), //
installment(312.5, true, "01 January 2023"), //
installment(312.5, false, "01 February 2023"), //
installment(312.5, false, "01 March 2023"), //
installment(312.5, false, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023") //
);
createdLoanId.set(loanId);
checkMaturityDates(loanId, LocalDate.of(2023, 4, 1), LocalDate.of(2023, 4, 1));
});
runAt("02 February 2023", () -> {
runAt("12 April 2023", () -> {
long loanId = createdLoanId.get();
// create re-age transaction
reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, "02 February 2023", 6);
reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "12 April 2023", 4);
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(1250.0, "Re-age", "02 February 2023") //
transaction(312.5, "Down Payment", "01 January 2023"), //
transaction(937.5, "Re-age", "12 April 2023") //
);
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, true, "01 January 2023"), //
installment(0, true, "01 February 2023"), //
installment(0, true, "01 March 2023"), //
installment(0, true, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023"), //
installment(234.38, false, "12 April 2023"), //
installment(234.38, false, "12 May 2023"), //
installment(234.38, false, "12 June 2023"), //
installment(234.36, false, "12 July 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 7, 12), LocalDate.of(2023, 7, 12));
});
runAt("03 February 2023", () -> {
runAt("13 April 2023", () -> {
long loanId = createdLoanId.get();
// create re-age transaction
@@ -178,10 +160,106 @@ public class LoanReAgingIntegrationTest extends BaseLoanIntegrationTest {
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
reversedTransaction(1250.0, "Re-age", "02 February 2023") //
transaction(312.5, "Down Payment", "01 January 2023"), //
reversedTransaction(937.5, "Re-age", "12 April 2023") //
);
// TODO: verify installments when schedule generation is implemented
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, true, "01 January 2023"), //
installment(312.5, false, "01 February 2023"), //
installment(312.5, false, "01 March 2023"), //
installment(312.5, false, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 4, 1), LocalDate.of(2023, 4, 1));
});
String repaymentExternalId = UUID.randomUUID().toString();
runAt("13 April 2023", () -> {
long loanId = createdLoanId.get();
loanTransactionHelper.makeLoanRepayment(loanId, new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN)
.transactionDate("13 April 2023").locale("en").transactionAmount(100.0).externalId(repaymentExternalId));
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(312.5, "Down Payment", "01 January 2023"), //
reversedTransaction(937.5, "Re-age", "12 April 2023"), //
transaction(100.0, "Repayment", "13 April 2023") //
);
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, 0, 0, 0, 0.0, true, "01 January 2023"), //
installment(312.5, 0, 0, 0, 212.5, false, "01 February 2023"), //
installment(312.5, 0, 0, 0, 312.5, false, "01 March 2023"), //
installment(312.5, 0, 0, 0, 312.5, false, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023") //
);
// create re-age transaction
reAgeLoan(loanId, RepaymentFrequencyType.DAYS_STRING, 30, "13 April 2023", 3);
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(312.5, "Down Payment", "01 January 2023"), //
reversedTransaction(937.5, "Re-age", "12 April 2023"), //
transaction(100.0, "Repayment", "13 April 2023"), //
transaction(837.5, "Re-age", "13 April 2023") //
);
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, 0, 0, 0, 0.0, true, "01 January 2023"), //
installment(100.0, 0, 0, 0, 0.0, true, "01 February 2023"), //
installment(0, 0, 0, 0, 0.0, true, "01 March 2023"), //
installment(0, 0, 0, 0, 0.0, true, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 10.0, false, "11 April 2023"), //
installment(279.17, 0, 0, 0, 279.17, false, "13 April 2023"), //
installment(279.17, 0, 0, 0, 279.17, false, "13 May 2023"), //
installment(279.16, 0, 0, 0, 279.16, false, "12 June 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 6, 12), LocalDate.of(2023, 6, 12));
});
runAt("14 April 2023", () -> {
long loanId = createdLoanId.get();
// disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(100.0), "14 April 2023");
// verify transactions
verifyTransactions(loanId, //
transaction(1250.0, "Disbursement", "01 January 2023"), //
transaction(312.5, "Down Payment", "01 January 2023"), //
reversedTransaction(937.5, "Re-age", "12 April 2023"), //
transaction(100.0, "Repayment", "13 April 2023"), //
transaction(837.5, "Re-age", "13 April 2023"), //
transaction(100.0, "Disbursement", "14 April 2023"), //
transaction(25.0, "Down Payment", "14 April 2023") //
);
// verify schedule
verifyRepaymentSchedule(loanId, //
installment(1250, null, "01 January 2023"), //
installment(312.5, 0, 0, 0, 0.0, true, "01 January 2023"), //
installment(100.0, 0, 0, 0, 0.0, true, "01 February 2023"), //
installment(0, 0, 0, 0, 0.0, true, "01 March 2023"), //
installment(0, 0, 0, 0, 0.0, true, "01 April 2023"), //
installment(0.0, 0.0, 10.0, 0.0, true, "11 April 2023"), //
installment(279.17, 0, 0, 0, 264.17, false, "13 April 2023"), //
installment(100, null, "14 April 2023"), //
installment(25.0, 0, 0, 0, 25.0, false, "14 April 2023"), //
installment(316.67, 0, 0, 0, 316.67, false, "13 May 2023"), //
installment(316.66, 0, 0, 0, 316.66, false, "12 June 2023") //
);
checkMaturityDates(loanId, LocalDate.of(2023, 6, 12), LocalDate.of(2023, 6, 12));
});
}
}