Skip to content

Commit

Permalink
FINERACT-2060: Accrual reverse replay logic and Handling
Browse files Browse the repository at this point in the history
  • Loading branch information
Marta Jankovics committed Oct 30, 2024
2 parents 6255603 + b053c90 commit d3bbccb
Show file tree
Hide file tree
Showing 35 changed files with 1,395 additions and 470 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ If you are interested in contributing to this project, but perhaps don't quite k
REQUIREMENTS
============
* `Java >= 17` (Azul Zulu JVM is tested by our CI on GitHub Actions)
* MariaDB `11.2`
* MariaDB `11.4`

You can run the required version of the database server in a container, instead of having to install it, like this:

docker run --name mariadb-11.2 -p 3306:3306 -e MARIADB_ROOT_PASSWORD=mysql -d mariadb:11.2
docker run --name mariadb-11.4 -p 3306:3306 -e MARIADB_ROOT_PASSWORD=mysql -d mariadb:11.4

and stop and destroy it like this:

docker rm -f mariadb-11.2
docker rm -f mariadb-11.4

<br>Beware that this database container database keeps its state inside the container and not on the host filesystem. It is lost when you destroy (rm) this container. This is typically fine for development. See [Caveats: Where to Store Data on the database container documentation](https://hub.docker.com/_/mariadb) re. how to make it persistent instead of ephemeral.<br>

Expand Down
2 changes: 1 addition & 1 deletion config/docker/compose/mariadb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ version: "3.8"
services:
mariadb:
container_name: mariadb
image: mariadb:11.2
image: mariadb:11.4
volumes:
- ${PWD}/config/docker/mysql/conf.d/server_collation.cnf:/etc/mysql/conf.d/server_collation.cnf:ro
- ${PWD}/config/docker/mysql/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:Z,ro
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ public static OffsetDateTime getOffsetDateTimeOfTenant(ChronoUnit truncate) {
return truncate == null ? now : now.truncatedTo(truncate);
}

@NotNull
public static OffsetDateTime getOffsetDateTimeOfTenantFromLocalDate(@NotNull final LocalDate date) {
return OffsetDateTime.of(date.atStartOfDay(), getOffsetDateTimeOfTenant().getOffset());
}

public static LocalDateTime getLocalDateTimeOfSystem() {
return getLocalDateTimeOfSystem(null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3350,6 +3350,7 @@ Feature: LoanRepayment
| 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false |
| 22 August 2024 | Accrual | 5.0 | 0.0 | 0.0 | 0.0 | 5.0 | 0.0 | false |
| 23 August 2024 | Repayment | 10.0 | 0.0 | 0.0 | 0.0 | 5.0 | 0.0 | false |
| 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false |
Then Loan Repayment schedule has 2 periods, with the following data for periods:
Expand All @@ -3369,9 +3370,10 @@ Feature: LoanRepayment
| 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false |
| 22 August 2024 | Accrual | 5.0 | 0.0 | 0.0 | 0.0 | 5.0 | 0.0 | false |
| 23 August 2024 | Repayment | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | false |
| 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false |
| 25 August 2024 | Accrual | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | false |
| 25 August 2024 | Accrual | 5.0 | 0.0 | 0.0 | 0.0 | 5.0 | 0.0 | false |
Then Loan Repayment schedule has 2 periods, with the following data for periods:
| Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ public enum LoanTermVariationType {
PRINCIPAL_AMOUNT(3, "loanTermType.principalAmount"), //
DUE_DATE(4, "loanTermType.dueDate"), //
INSERT_INSTALLMENT(5, "loanTermType.insertInstallment"), //
DELETE_INSTALLMENT(6, "loanTermType.deleteInstallment"), GRACE_ON_INTEREST(7, "loanTermType.graceOnInterest"), GRACE_ON_PRINCIPAL(8,
"loanTermType.graceOnPrincipal"), EXTEND_REPAYMENT_PERIOD(9,
"loanTermType.extendRepaymentPeriod"), INTEREST_RATE_FROM_INSTALLMENT(10, "loanTermType.interestRateFromInstallment");
DELETE_INSTALLMENT(6, "loanTermType.deleteInstallment"), //
GRACE_ON_INTEREST(7, "loanTermType.graceOnInterest"), //
GRACE_ON_PRINCIPAL(8, "loanTermType.graceOnPrincipal"), //
EXTEND_REPAYMENT_PERIOD(9, "loanTermType.extendRepaymentPeriod"), //
INTEREST_RATE_FROM_INSTALLMENT(10, "loanTermType.interestRateFromInstallment"); //

private final Integer value;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ private boolean isObligationsMetOnDisbursementDate(LocalDate disbursementDate,
&& disbursementDate.equals(loanRepaymentScheduleInstallment.getObligationsMetOnDate());
}

private boolean isNotObligationsMet(LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment) {
protected boolean isNotObligationsMet(LoanRepaymentScheduleInstallment loanRepaymentScheduleInstallment) {
return !loanRepaymentScheduleInstallment.isObligationsMet() && loanRepaymentScheduleInstallment.getObligationsMetOnDate() == null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
/**
* 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.util.Map;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsDataWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
Expand All @@ -72,6 +74,7 @@
import org.apache.fineract.portfolio.loanaccount.domain.LoanPaymentAllocationRule;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleProcessingWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTermVariations;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
Expand Down Expand Up @@ -175,8 +178,6 @@ public Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> repr
currentInstallment.updateObligationsMet(currency, disbursementDate);
}

List<ChargeOrTransaction> chargeOrTransactions = createSortedChargesAndTransactionsList(loanTransactions, charges);

MoneyHolder overpaymentHolder = new MoneyHolder(Money.zero(currency));
final Loan loan = loanTransactions.get(0).getLoan();
final Integer installmentAmountInMultiplesOf = loan.getLoanProduct().getInstallmentAmountInMultiplesOf();
Expand All @@ -186,17 +187,26 @@ public Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> repr
ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder,
changedTransactionDetail, scheduleModel);

LoanTermVariationsDataWrapper loanTermVariations = Optional
.ofNullable(loan.getActiveLoanTermVariations()).map(loanTermVariationsSet -> loanTermVariationsSet.stream()
.map(LoanTermVariations::toData).collect(Collectors.toCollection(ArrayList::new)))
.map(LoanTermVariationsDataWrapper::new).orElse(null);
List<ChangeOperation> changeOperations = createSortedChangeList(loanTermVariations, loanTransactions, charges);

List<LoanTransaction> overpaidTransactions = new ArrayList<>();
for (final ChargeOrTransaction chargeOrTransaction : chargeOrTransactions) {
if (chargeOrTransaction.isTransaction()) {
LoanTransaction transaction = chargeOrTransaction.getLoanTransaction().get();
for (final ChangeOperation changeOperation : changeOperations) {
if (changeOperation.isInterestRateChange()) {
final LoanTermVariationsData interestRateChange = changeOperation.getInterestRateChange().get();
processInterestRateChange(installments, interestRateChange, scheduleModel);
} else if (changeOperation.isTransaction()) {
LoanTransaction transaction = changeOperation.getLoanTransaction().get();
processSingleTransaction(transaction, ctx);
transaction = getProcessedTransaction(changedTransactionDetail, transaction);
if (transaction.isOverPaid() && transaction.isRepaymentLikeType()) { // TODO CREDIT, DEBIT
overpaidTransactions.add(transaction);
}
} else {
LoanCharge loanCharge = chargeOrTransaction.getLoanCharge().get();
LoanCharge loanCharge = changeOperation.getLoanCharge().get();
processSingleCharge(loanCharge, currency, installments, disbursementDate);
if (!loanCharge.isFullyPaid() && !overpaidTransactions.isEmpty()) {
overpaidTransactions = processOverpaidTransactions(overpaidTransactions, ctx);
Expand All @@ -210,13 +220,19 @@ public Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> repr
createNewTransaction(oldTransaction, newTransaction, ctx);
}
recalculateInterestForDate(ThreadLocalContextUtil.getBusinessDate(), ctx);
List<LoanTransaction> txs = chargeOrTransactions.stream() //
.filter(ChargeOrTransaction::isTransaction) //
List<LoanTransaction> txs = changeOperations.stream() //
.filter(ChangeOperation::isTransaction) //
.map(e -> e.getLoanTransaction().get()).toList();
reprocessInstallments(disbursementDate, txs, installments, currency);
return Pair.of(changedTransactionDetail, scheduleModel);
}

@Override
public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List<LoanTransaction> loanTransactions,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges) {
return reprocessProgressiveLoanTransactions(disbursementDate, loanTransactions, currency, installments, charges).getLeft();
}

@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
public ProgressiveLoanInterestScheduleModel calculateInterestScheduleModel(@NotNull Long loanId) {
Loan loan = loanAssembler.assembleFrom(loanId);
Expand All @@ -236,18 +252,36 @@ public ProgressiveLoanInterestScheduleModel calculateInterestScheduleModel(Local
return reprocessProgressiveLoanTransactions(disbursementDate, transactions, currency, installments, charges).getRight();
}

@Override
public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List<LoanTransaction> loanTransactions,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges) {
return reprocessProgressiveLoanTransactions(disbursementDate, loanTransactions, currency, installments, charges).getLeft();
}

@NotNull
private static LoanTransaction getProcessedTransaction(ChangedTransactionDetail changedTransactionDetail, LoanTransaction transaction) {
LoanTransaction newTransaction = changedTransactionDetail.getNewTransactionMappings().get(transaction.getId());
return newTransaction == null ? transaction : newTransaction;
}

private void processInterestRateChange(final List<LoanRepaymentScheduleInstallment> installments,
final LoanTermVariationsData interestRateChange, final ProgressiveLoanInterestScheduleModel scheduleModel) {
final LocalDate interestRateChangeSubmittedOnDate = interestRateChange.getTermVariationApplicableFrom();
final BigDecimal newInterestRate = interestRateChange.getDecimalValue();
emiCalculator.changeInterestRate(scheduleModel, interestRateChangeSubmittedOnDate, newInterestRate);
processInterestRateChangeOnInstallments(scheduleModel, interestRateChangeSubmittedOnDate, installments);
}

private void processInterestRateChangeOnInstallments(final ProgressiveLoanInterestScheduleModel scheduleModel,
final LocalDate interestRateChangeSubmittedOnDate, final List<LoanRepaymentScheduleInstallment> installments) {
installments.stream() //
.filter(installment -> isNotObligationsMet(installment)
&& !interestRateChangeSubmittedOnDate.isAfter(installment.getDueDate()))
.forEach(installment -> updateInstallmentIfInterestPeriodPresent(scheduleModel, installment)); //
}

private void updateInstallmentIfInterestPeriodPresent(final ProgressiveLoanInterestScheduleModel scheduleModel,
final LoanRepaymentScheduleInstallment installment) {
emiCalculator.findRepaymentPeriod(scheduleModel, installment.getDueDate()).ifPresent(interestRepaymentPeriod -> {
installment.updateInterestCharged(interestRepaymentPeriod.getDueInterest().getAmount());
installment.updatePrincipal(interestRepaymentPeriod.getDuePrincipal().getAmount());
});
}

@Override
public void processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) {
switch (loanTransaction.getTypeOf()) {
Expand Down Expand Up @@ -762,17 +796,29 @@ private void processSingleCharge(LoanCharge loanCharge, MonetaryCurrency currenc
}

@NotNull
private List<ChargeOrTransaction> createSortedChargesAndTransactionsList(List<LoanTransaction> loanTransactions,
Set<LoanCharge> charges) {
List<ChargeOrTransaction> chargeOrTransactions = new ArrayList<>();
private List<ChangeOperation> createSortedChangeList(final LoanTermVariationsDataWrapper loanTermVariations,
final List<LoanTransaction> loanTransactions, final Set<LoanCharge> charges) {
List<ChangeOperation> changeOperations = new ArrayList<>();
if (loanTermVariations != null && !loanTermVariations.getInterestRateFromInstallment().isEmpty()) {
changeOperations.addAll(loanTermVariations.getInterestRateFromInstallment().stream().map(ChangeOperation::new).toList());
}
if (charges != null) {
chargeOrTransactions.addAll(charges.stream().map(ChargeOrTransaction::new).toList());
changeOperations.addAll(charges.stream().map(ChangeOperation::new).toList());
}
if (loanTransactions != null) {
chargeOrTransactions.addAll(loanTransactions.stream().map(ChargeOrTransaction::new).toList());
changeOperations.addAll(loanTransactions.stream().map(ChangeOperation::new).toList());
}
Collections.sort(changeOperations);
return changeOperations;
}

private void handleDisbursement(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) {
// TODO: Fix this and enhance EMICalculator to support reamortization and reaging
if (disbursementTransaction.getLoan().isInterestBearing()) {
handleDisbursementWithEMICalculator(disbursementTransaction, transactionCtx);
} else {
handleDisbursementWithoutEMICalculator(disbursementTransaction, transactionCtx);
}
Collections.sort(chargeOrTransactions);
return chargeOrTransactions;
}

private void handleDisbursementWithEMICalculator(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) {
Expand Down Expand Up @@ -820,15 +866,6 @@ private void handleDisbursementWithEMICalculator(LoanTransaction disbursementTra
allocateOverpayment(disbursementTransaction, transactionCtx);
}

private void handleDisbursement(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) {
// TODO: Fix this and enhance EMICalculator to support reamortization and reaging
if (disbursementTransaction.getLoan().isInterestBearing()) {
handleDisbursementWithEMICalculator(disbursementTransaction, transactionCtx);
} else {
handleDisbursementWithoutEMICalculator(disbursementTransaction, transactionCtx);
}
}

private void handleDisbursementWithoutEMICalculator(LoanTransaction disbursementTransaction, TransactionCtx transactionCtx) {
disbursementTransaction.resetDerivedComponents();
final MathContext mc = MoneyHelper.getMathContext();
Expand Down Expand Up @@ -923,7 +960,6 @@ private void recalculateInterestForDate(LocalDate currentDate, ProgressiveTransa
if (overdueInstallmentsSortedByInstallmentNumber.isEmpty()) {
return;
}
ProgressiveLoanInterestScheduleModel model = ctx.getModel();
MonetaryCurrency currency = ctx.getCurrency();
Money overDuePrincipal = Money.zero(currency);
Money aggregatedOverDuePrincipal = Money.zero(currency);
Expand Down
Loading

0 comments on commit d3bbccb

Please sign in to comment.