FINERACT-1981: Pay-off schedule handling

This commit is contained in:
Janos Meszaros
2024-09-13 13:26:32 +02:00
committed by Adam Saghy
parent e94ce7f1df
commit 49e9005109
10 changed files with 352 additions and 304 deletions
@@ -20,7 +20,7 @@ package org.apache.fineract.test.data.loanproduct;
public enum DefaultLoanProduct implements LoanProduct {
LP1, LP1_DUE_DATE, LP1_INTEREST_FLAT, LP1_INTEREST_DECLINING_BALANCE_PERIOD_SAME_AS_PAYMENT, LP1_INTEREST_DECLINING_BALANCE_PERIOD_DAILY, LP1_1MONTH_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_MONTHLY, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_REDUCE_NR_INST, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_RESCH_NEXT_REP, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE, LP1_INTEREST_DECLINING_BALANCE_SAR_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE_MULTIDISB, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_INTEREST_FLAT, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_INTEREST_FLAT, LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT, LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT_INTEREST, LP2_DOWNPAYMENT, LP2_DOWNPAYMENT_AUTO, LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION, LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION, LP2_DOWNPAYMENT_INTEREST, LP2_DOWNPAYMENT_INTEREST_AUTO, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_VERTICAL, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_INSTALLMENT_LEVEL_DELINQUENCY, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROG_SCHEDULE_HOR_INST_LVL_DELINQUENCY_CREDIT_ALLOCATION, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH;
LP1, LP1_DUE_DATE, LP1_INTEREST_FLAT, LP1_INTEREST_DECLINING_BALANCE_PERIOD_SAME_AS_PAYMENT, LP1_INTEREST_DECLINING_BALANCE_PERIOD_DAILY, LP1_1MONTH_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_MONTHLY, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_REDUCE_NR_INST, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_RESCH_NEXT_REP, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE, LP1_INTEREST_DECLINING_BALANCE_SAR_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE_MULTIDISB, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_INTEREST_FLAT, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_INTEREST_FLAT, LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT, LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT_INTEREST, LP2_DOWNPAYMENT, LP2_DOWNPAYMENT_AUTO, LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION, LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION, LP2_DOWNPAYMENT_INTEREST, LP2_DOWNPAYMENT_INTEREST_AUTO, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_VERTICAL, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_INSTALLMENT_LEVEL_DELINQUENCY, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROG_SCHEDULE_HOR_INST_LVL_DELINQUENCY_CREDIT_ALLOCATION, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH, LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE;
@Override
public String getName() {
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.test.factory;
import static org.apache.fineract.test.data.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@@ -64,17 +66,20 @@ public class LoanProductsRequestFactory {
public static final String NAME_PREFIX_INTEREST_FLAT_LP2 = "LP2InterestFlat-";
public static final String NAME_PREFIX_INTEREST_DECLINING = "LP1InterestDeclining-";
public static final String NAME_PREFIX_INTEREST_DECLINING_RECALCULATION = "LP1InterestDecliningRecalculation-";
public static final String NAME_PREFIX_LP2_EMI = "LP2Emi-";
public static final String SHORT_NAME_PREFIX = "p";
public static final String SHORT_NAME_PREFIX_INTEREST = "i";
public static final String SHORT_NAME_PREFIX_EMI = "e";
public static final String DATE_FORMAT = "dd MMMM yyyy";
public static final String LOCALE_EN = "en";
public static final String DESCRIPTION = "Pay in 30 days product";
public static final String DESCRIPTION_LP2 = "Pay in 4 product";
public static final String DESCRIPTION_INTEREST_FLAT = "Pay in 30 days product with 12% interest - FLAT";
public static final String DESCRIPTION_INTEREST_FLAT_LP2 = "Pay in 4 product with 12% interest - FLAT";
public static final String DESCRIPTION_INTEREST_DECLINING = "Pay in 30 days product with 12% interest - DECLINING BALANCE";
public static final String DESCRIPTION = "LP1 product";
public static final String DESCRIPTION_LP2 = "LP2 product";
public static final String DESCRIPTION_INTEREST_FLAT = "LP1 product with 12% interest - FLAT";
public static final String DESCRIPTION_INTEREST_FLAT_LP2 = "LP2 product with 12% interest - FLAT";
public static final String DESCRIPTION_INTEREST_DECLINING = "LP1 product with 12% interest - DECLINING BALANCE";
public static final String DESCRIPTION_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_MONTHLY = "LP1-1MONTH with 12% DECLINING BALANCE interest, interest period: Daily, Interest recalculation-Monthly, Compounding:Interest";
public static final String DESCRIPTION_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE = "LP1 with 12% DECLINING BALANCE interest, interest period: Daily, Interest recalculation-Daily, Compounding:none";
public static final String DESCRIPTION_LP2_EMI = "LP2 product with EMI";
public static final Long FUND_ID = FundId.LENDER_A.value;
public static final String CURRENCY_CODE = "EUR";
public static final Integer INTEREST_RATE_FREQUENCY_TYPE_MONTH = InterestRateFrequencyType.MONTH.value;
@@ -87,6 +92,7 @@ public class LoanProductsRequestFactory {
public static final Integer INTEREST_CALCULATION_PERIOD_TYPE_SAME_AS_REPAYMENT = InterestCalculationPeriodTime.SAME_AS_REPAYMENT_PERIOD.value;
public static final Integer INTEREST_CALCULATION_PERIOD_TYPE_DAILY = InterestCalculationPeriodTime.DAILY.value;
public static final String TRANSACTION_PROCESSING_STRATEGY_CODE = TransactionProcessingStrategyCode.PENALTIES_FEES_INTEREST_PRINCIPAL_ORDER.value;
public static final String TRANSACTION_PROCESSING_STRATEGY_CODE_ADVANCED = TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION.value;
public static final Integer DAYS_IN_YEAR_TYPE = DaysInYearType.ACTUAL.value;
public static final Integer DAYS_IN_MONTH_TYPE = DaysInMonthType.ACTUAL.value;
public static final Integer LOAN_ACCOUNTING_RULE = AccountingRule.ACCRUAL_PERIODIC.value;
@@ -874,4 +880,115 @@ public class LoanProductsRequestFactory {
.chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD))//
.incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF));//
}
public PostLoanProductsRequest defaultLoanProductsRequestLP2Emi() {
String name = Utils.randomNameGenerator(NAME_PREFIX_LP2_EMI, 4);
String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_EMI, 3);
List<Integer> principalVariationsForBorrowerCycle = new ArrayList<>();
List<Integer> numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>();
List<Integer> interestRateVariationsForBorrowerCycle = new ArrayList<>();
List<ChargeData> charges = new ArrayList<>();
List<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings = new ArrayList<>();
List<GetLoanFeeToIncomeAccountMappings> feeToIncomeAccountMappings = new ArrayList<>();
List<GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings = new ArrayList<>();
GetLoanPaymentChannelToFundSourceMappings loanPaymentChannelToFundSourceMappings = new GetLoanPaymentChannelToFundSourceMappings();
loanPaymentChannelToFundSourceMappings.fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.FUND_RECEIVABLES));
loanPaymentChannelToFundSourceMappings.paymentTypeId(paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER));
paymentChannelToFundSourceMappings.add(loanPaymentChannelToFundSourceMappings);
return new PostLoanProductsRequest()//
.name(name)//
.shortName(shortName)//
.description(DESCRIPTION_LP2_EMI)//
.loanScheduleType("PROGRESSIVE") //
.interestCalculationPeriodType(InterestCalculationPeriodTime.DAILY.value)//
.transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION.getValue())//
.fundId(FUND_ID)//
.startDate(null)//
.closeDate(null)//
.includeInBorrowerCycle(false)//
.currencyCode(CURRENCY_CODE)//
.digitsAfterDecimal(2)//
.inMultiplesOf(0)//
.useBorrowerCycle(false)//
.minPrincipal(10.0)//
.principal(1000.0)//
.maxPrincipal(10000.0)//
.minNumberOfRepayments(1)//
.numberOfRepayments(4)//
.maxNumberOfRepayments(30)//
.isLinkedToFloatingInterestRates(false)//
.minInterestRatePerPeriod((double) 0)//
.interestRatePerPeriod((double) 12)//
.maxInterestRatePerPeriod((double) 60)//
.interestRateFrequencyType(INTEREST_RATE_FREQUENCY_TYPE_YEAR)//
.repaymentEvery(15)//
.repaymentStartDateType(1)//
.repaymentFrequencyType(REPAYMENT_FREQUENCY_TYPE_DAYS)//
.principalVariationsForBorrowerCycle(principalVariationsForBorrowerCycle)//
.numberOfRepaymentVariationsForBorrowerCycle(numberOfRepaymentVariationsForBorrowerCycle)//
.interestRateVariationsForBorrowerCycle(interestRateVariationsForBorrowerCycle)//
.amortizationType(AMORTIZATION_TYPE)//
.interestType(INTEREST_TYPE_DECLINING_BALANCE)//
.isEqualAmortization(false)//
.interestCalculationPeriodType(INTEREST_CALCULATION_PERIOD_TYPE_DAILY)//
.transactionProcessingStrategyCode(TRANSACTION_PROCESSING_STRATEGY_CODE_ADVANCED)//
.daysInYearType(DAYS_IN_YEAR_TYPE)//
.daysInMonthType(DAYS_IN_MONTH_TYPE)//
.canDefineInstallmentAmount(true)//
.graceOnArrearsAgeing(3)//
.overdueDaysForNPA(179)//
.accountMovesOutOfNPAOnlyOnArrearsCompletion(false)//
.principalThresholdForLastInstallment(50)//
.allowVariableInstallments(false)//
.canUseForTopup(false)//
.isInterestRecalculationEnabled(false)//
.holdGuaranteeFunds(false)//
.multiDisburseLoan(false)//
.allowAttributeOverrides(new AllowAttributeOverrides()//
.amortizationType(true)//
.interestType(true)//
.transactionProcessingStrategyCode(true)//
.interestCalculationPeriodType(true)//
.inArrearsTolerance(true)//
.repaymentEvery(true)//
.graceOnPrincipalAndInterestPayment(true)//
.graceOnArrearsAgeing(true))//
.allowPartialPeriodInterestCalcualtion(false)//
.maxTrancheCount(10)//
.outstandingLoanBalance(10000.0)//
.charges(charges)//
.accountingRule(LOAN_ACCOUNTING_RULE)//
.fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.SUSPENSE_CLEARING_ACCOUNT))//
.loanPortfolioAccountId(accountTypeResolver.resolve(DefaultAccountType.LOANS_RECEIVABLE))//
.transfersInSuspenseAccountId(accountTypeResolver.resolve(DefaultAccountType.TRANSFER_IN_SUSPENSE_ACCOUNT))//
.interestOnLoanAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME))//
.incomeFromFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_INCOME))//
.incomeFromPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_INCOME))//
.incomeFromRecoveryAccountId(accountTypeResolver.resolve(DefaultAccountType.RECOVERIES))//
.writeOffAccountId(accountTypeResolver.resolve(DefaultAccountType.WRITTEN_OFF))//
.overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OVERPAYMENT_ACCOUNT))//
.receivableInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.receivableFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.receivablePenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.dateFormat(DATE_FORMAT)//
.locale(LOCALE_EN)//
.disallowExpectedDisbursements(false)//
.delinquencyBucketId(DELINQUENCY_BUCKET_ID.longValue())//
.goodwillCreditAccountId(accountTypeResolver.resolve(DefaultAccountType.GOODWILL_EXPENSE_ACCOUNT))//
.incomeFromGoodwillCreditInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))//
.incomeFromGoodwillCreditFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.incomeFromGoodwillCreditPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.paymentChannelToFundSourceMappings(paymentChannelToFundSourceMappings)//
.penaltyToIncomeAccountMappings(penaltyToIncomeAccountMappings)//
.feeToIncomeAccountMappings(feeToIncomeAccountMappings)//
.incomeFromChargeOffInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))//
.incomeFromChargeOffFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.chargeOffExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT))//
.chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD))//
.incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF));//
}
}
@@ -36,6 +36,8 @@ import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.services.LoanProductsApi;
import org.apache.fineract.test.data.AdvancePaymentsAdjustmentType;
import org.apache.fineract.test.data.ChargeProductType;
import org.apache.fineract.test.data.DaysInMonthType;
import org.apache.fineract.test.data.DaysInYearType;
import org.apache.fineract.test.data.InterestCalculationPeriodTime;
import org.apache.fineract.test.data.RecalculationRestFrequencyType;
import org.apache.fineract.test.data.TransactionProcessingStrategyCode;
@@ -476,6 +478,34 @@ public class LoanProductGlobalInitializerStep implements FineractGlobalInitializ
.createLoanProduct(loanProductsRequestDownPaymentAdvPmtAllocFixedLength).execute();
TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH,
responseLoanProductsRequestDownPaymentAdvPmtAllocFixedLength);
// LP2 with progressive loan schedule + horizontal + interest EMI + 360/30
// + interest recalculation, preClosureInterestCalculationStrategy= till preclose,
// interestRecalculationCompoundingMethod = none
// Frequency for recalculate Outstanding Principal: Daily, Frequency Interval for recalculation: 1
// (LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE)
String name38 = DefaultLoanProduct.LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE.getName();
PostLoanProductsRequest loanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcTillPreclose = loanProductsRequestFactory
.defaultLoanProductsRequestLP2Emi()//
.name(name38)//
.daysInYearType(DaysInYearType.DAYS360.value)//
.daysInMonthType(DaysInMonthType.DAYS30.value)//
.isInterestRecalculationEnabled(true)//
.preClosureInterestCalculationStrategy(1)//
.rescheduleStrategyMethod(4)//
.interestRecalculationCompoundingMethod(0)//
.recalculationRestFrequencyType(2)//
.recalculationRestFrequencyInterval(1)//
.paymentAllocation(List.of(//
createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), //
createPaymentAllocation("GOODWILL_CREDIT", "LAST_INSTALLMENT"), //
createPaymentAllocation("MERCHANT_ISSUED_REFUND", "REAMORTIZATION"), //
createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT")));//
Response<PostLoanProductsResponse> responseLoanProductsRequestLP2AdvancedpaymentInterest36030InterestRecalcTillPreCloese = loanProductsApi
.createLoanProduct(loanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcTillPreclose).execute();
TestContext.INSTANCE.set(
TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE,
responseLoanProductsRequestLP2AdvancedpaymentInterest36030InterestRecalcTillPreCloese);
}
public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule,
@@ -33,12 +33,14 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.apache.fineract.avro.loan.v1.LoanTransactionAdjustmentDataV1;
import org.apache.fineract.avro.loan.v1.LoanTransactionDataV1;
import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.GetLoansLoanIdTransactions;
import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTemplateResponse;
import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTransactionIdResponse;
import org.apache.fineract.client.models.GetUsersUserIdResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
@@ -66,6 +68,7 @@ import org.apache.fineract.test.support.TestContextKey;
import org.springframework.beans.factory.annotation.Autowired;
import retrofit2.Response;
@Slf4j
public class LoanRepaymentStepDef extends AbstractStepDef {
public static final String DATE_FORMAT = "dd MMMM yyyy";
@@ -561,4 +564,16 @@ public class LoanRepaymentStepDef extends AbstractStepDef {
}
}
@When("Loan Pay-off is made on {string}")
public void makeLoanPayOff(String transactionDate) throws IOException {
Response<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
long loanId1 = loanResponse.body().getLoanId();
Response<GetLoansLoanIdTransactionsTemplateResponse> response = loanTransactionsApi
.retrieveTransactionTemplate(loanId1, "prepayLoan", DATE_FORMAT, transactionDate, DEFAULT_LOCALE).execute();
Double transactionAmount = response.body().getAmount();
log.info("--- Loan Pay-off with amount: {} ---", transactionAmount);
makeRepayment("AUTOPAY", transactionDate, transactionAmount, null);
}
}
@@ -77,6 +77,7 @@ public abstract class TestContextKey {
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION_PROGRESSIVE_LOAN_SCHEDULE_INSTALLMENT_LEVEL_DELINQUENCY = "loanProductCreateResponseLP2DownPaymentProgressiveLoanScheduleInstallmentLevelDelinquency";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROG_SCHEDULE_HOR_INST_LVL_DELINQUENCY_CREDIT_ALLOCATION = "loanProductCreateResponseLP2DownPaymentProgressiveLoanScheduleHorizontalInstallmentLevelDelinquencyCreditAllocation";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH = "loanProductCreateResponseLP2DownPaymentProgressiveLoanScheduleFixedLength";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationTillPreClose";
public static final String CHARGE_FOR_LOAN_PERCENT_LATE_CREATE_RESPONSE = "ChargeForLoanPercentLateCreateResponse";
public static final String CHARGE_FOR_LOAN_PERCENT_LATE_AMOUNT_PLUS_INTEREST_CREATE_RESPONSE = "ChargeForLoanPercentLateAmountPlusInterestCreateResponse";
public static final String CHARGE_FOR_LOAN_PERCENT_PROCESSING_CREATE_RESPONSE = "ChargeForLoanPercentProcessingCreateResponse";
@@ -5523,7 +5523,6 @@ Feature: Loan
When Admin successfully disburse the loan on "01 February 2024" with "1000" EUR transaction amount
Then LoanDetails has fixedLength field with int value: 60
Scenario: Actual disbursement date is in the past with advanced payment allocation product + submitted on date repaymentStartDateType
When Admin sets repaymentStartDateType for "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product to "SUBMITTED_ON_DATE"
When Admin set "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule
@@ -5536,3 +5535,64 @@ Feature: Loan
Then Loan status has changed to "Approved"
Then Admin fails to disburse the loan on "31 December 2022" with "500" EUR transaction amount because disbursement date is earlier than "01 January 2023"
When Admin sets repaymentStartDateType for "LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION" loan product to "DISBURSEMENT_DATE"
Scenario: Early pay-off loan with interest
When Admin sets the business date to "01 January 2024"
When Admin creates a client with random data
When Admin set "LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE" loan product "DEFAULT" transaction type to "NEXT_INSTALLMENT" future installment allocation rule
When Admin creates a fully customized loan with the following data:
| LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy |
| LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE | 01 January 2024 | 100 | 7 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 6 | MONTHS | 1 | MONTHS | 6 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
And Admin successfully approves the loan on "01 January 2024" with "100" amount and expected disbursement date on "01 January 2024"
When Admin successfully disburse the loan on "01 January 2024" with "100" EUR transaction amount
Then Loan Repayment schedule has 6 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 |
| | | 01 January 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | |
| 1 | 31 | 01 February 2024 | | 83.57 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 2 | 29 | 01 March 2024 | | 67.05 | 16.52 | 0.49 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 3 | 31 | 01 April 2024 | | 50.43 | 16.62 | 0.39 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 4 | 30 | 01 May 2024 | | 33.71 | 16.72 | 0.29 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 5 | 31 | 01 June 2024 | | 16.9 | 16.81 | 0.2 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 6 | 30 | 01 July 2024 | | 0.0 | 16.9 | 0.1 | 0.0 | 0.0 | 17.0 | 0.0 | 0.0 | 0.0 | 17.0 |
Then Loan Repayment schedule has the following data in Total row:
| Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| 100.0 | 2.05 | 0.0 | 0.0 | 102.05 | 0.0 | 0.0 | 0.0 | 102.05 |
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false |
When Admin sets the business date to "01 February 2024"
And Customer makes "AUTOPAY" repayment on "01 February 2024" with 17.01 EUR transaction amount
Then Loan Repayment schedule has 6 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 |
| | | 01 January 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | |
| 1 | 31 | 01 February 2024 | 01 February 2024 | 83.57 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 17.01 | 0.0 | 0.0 | 0.0 |
| 2 | 29 | 01 March 2024 | | 67.05 | 16.52 | 0.49 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 3 | 31 | 01 April 2024 | | 50.43 | 16.62 | 0.39 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 4 | 30 | 01 May 2024 | | 33.71 | 16.72 | 0.29 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 5 | 31 | 01 June 2024 | | 16.9 | 16.81 | 0.2 | 0.0 | 0.0 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 |
| 6 | 30 | 01 July 2024 | | 0.0 | 16.9 | 0.1 | 0.0 | 0.0 | 17.0 | 0.0 | 0.0 | 0.0 | 17.0 |
Then Loan Repayment schedule has the following data in Total row:
| Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| 100.0 | 2.05 | 0.0 | 0.0 | 102.05 | 17.01 | 0.0 | 0.0 | 85.04 |
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false |
| 01 February 2024 | Repayment | 17.01 | 16.43 | 0.58 | 0.0 | 0.0 | 83.57 | false |
When Admin sets the business date to "15 February 2024"
When Loan Pay-off is made on "15 February 2024"
# When Customer makes "AUTOPAY" repayment on "15 February 2024" with 83.81 EUR transaction amount
Then Loan Repayment schedule has 6 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 |
| | | 01 January 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | |
| 1 | 31 | 01 February 2024 | 01 February 2024 | 83.57 | 16.43 | 0.58 | 0.0 | 0.0 | 17.01 | 17.01 | 0.0 | 0.0 | 0.0 |
| 2 | 29 | 01 March 2024 | 15 February 2024 | 66.8 | 16.77 | 0.24 | 0.0 | 0.0 | 17.01 | 17.01 | 17.01 | 0.0 | 0.0 |
| 3 | 31 | 01 April 2024 | 15 February 2024 | 49.79 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | 17.01 | 17.01 | 0.0 | 0.0 |
| 4 | 30 | 01 May 2024 | 15 February 2024 | 32.78 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | 17.01 | 17.01 | 0.0 | 0.0 |
| 5 | 31 | 01 June 2024 | 15 February 2024 | 15.77 | 17.01 | 0.0 | 0.0 | 0.0 | 17.01 | 17.01 | 17.01 | 0.0 | 0.0 |
| 6 | 30 | 01 July 2024 | | 0.0 | 15.77 | 0.0 | 0.0 | 0.0 | 15.77 | 15.77 | 15.77 | 0.0 | 0.0 |
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false |
| 01 February 2024 | Repayment | 17.01 | 16.43 | 0.58 | 0.0 | 0.0 | 83.57 | false |
| 15 February 2024 | Repayment | 83.81 | 83.57 | 0.24 | 0.0 | 0.0 | 0.0 | false |
When Admin set "LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE" loan product "DEFAULT" transaction type to "LAST_INSTALLMENT" future installment allocation rule
@@ -46,20 +46,24 @@ public class OutstandingAmountsDTO {
.plus(penaltyCharges());
}
public void plusPrincipal(Money principal) {
public OutstandingAmountsDTO plusPrincipal(Money principal) {
this.principal = this.principal.plus(principal);
return this;
}
public void plusInterest(Money interest) {
public OutstandingAmountsDTO plusInterest(Money interest) {
this.interest = this.interest.plus(interest);
return this;
}
public void plusFeeCharges(Money feeCharges) {
public OutstandingAmountsDTO plusFeeCharges(Money feeCharges) {
this.feeCharges = this.feeCharges.plus(feeCharges);
return this;
}
public void plusPenaltyCharges(Money penaltyCharges) {
public OutstandingAmountsDTO plusPenaltyCharges(Money penaltyCharges) {
this.penaltyCharges = this.penaltyCharges.plus(penaltyCharges);
return this;
}
}
@@ -55,6 +55,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
@@ -76,6 +77,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.reaging.LoanReAgeParamet
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.TransactionCtx;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestRepaymentModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.ProgressiveLoanInterestScheduleModel;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
@@ -140,12 +142,13 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
throw new NotImplementedException();
}
@Override
public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List<LoanTransaction> loanTransactions,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges) {
// only for progressive loans
public Pair<ChangedTransactionDetail, ProgressiveLoanInterestScheduleModel> reprocessProgressiveLoanTransactions(
LocalDate disbursementDate, List<LoanTransaction> loanTransactions, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges) {
final ChangedTransactionDetail changedTransactionDetail = new ChangedTransactionDetail();
if (loanTransactions.isEmpty()) {
return changedTransactionDetail;
return Pair.of(changedTransactionDetail, null);
}
if (charges != null) {
for (final LoanCharge loanCharge : charges) {
@@ -180,10 +183,18 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
chargeOrTransaction.getLoanCharge()
.ifPresent(loanCharge -> processSingleCharge(loanCharge, currency, installments, disbursementDate));
}
List<LoanTransaction> txs = chargeOrTransactions.stream().map(ChargeOrTransaction::getLoanTransaction).filter(Optional::isPresent)
List<LoanTransaction> txs = chargeOrTransactions.stream() //
.map(ChargeOrTransaction::getLoanTransaction) //
.filter(Optional::isPresent) //
.map(Optional::get).toList();
reprocessInstallments(disbursementDate, txs, installments, currency);
return changedTransactionDetail;
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();
}
@Override
@@ -276,7 +287,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
return originalTransaction.get();
} else { // when there is no id, then it might be that the original transaction is changed, so we need to look
// it up from the Ctx.
// it up from the Ctx.
Long originalChargebackTransactionId = ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().get(loanTransaction);
Collection<LoanTransaction> updatedTransactions = ctx.getChangedTransactionDetail().getNewTransactionMappings().values();
Optional<LoanTransaction> updatedTransaction = updatedTransactions.stream().filter(tr -> tr.getLoanTransactionRelations()
@@ -733,19 +744,18 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
LoanPaymentAllocationRule defaultPaymentAllocationRule = paymentAllocationRules.stream()
.filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow();
Money transactionAmountUnprocessed = null;
Money transactionAmountUnprocessed = transactionCtx.getOverpaymentHolder().getMoneyObject();
Money zero = Money.zero(transactionCtx.getCurrency());
Balances balances = new Balances(zero, zero, zero, zero);
if (LoanScheduleProcessingType.HORIZONTAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx.getCurrency(),
transactionCtx.getInstallments(), transactionCtx.getOverpaymentHolder().getMoneyObject(),
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
defaultPaymentAllocationRule, transactionMappings, Set.of(), balances);
} else if (LoanScheduleProcessingType.VERTICAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(),
transactionCtx.getInstallments(), transactionCtx.getOverpaymentHolder().getMoneyObject(),
defaultPaymentAllocationRule, transactionMappings, Set.of(), balances);
transactionCtx.getInstallments(), transactionAmountUnprocessed, defaultPaymentAllocationRule, transactionMappings,
Set.of(), balances);
}
transactionCtx.getOverpaymentHolder().setMoneyObject(transactionAmountUnprocessed);
loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings);
@@ -1104,9 +1114,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
if (LoanScheduleProcessingType.HORIZONTAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx.getCurrency(),
transactionCtx.getInstallments(), transactionAmountUnprocessed, paymentAllocationRule, transactionMappings,
transactionCtx.getCharges(), balances);
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
paymentAllocationRule, transactionMappings, transactionCtx.getCharges(), balances);
} else if (LoanScheduleProcessingType.VERTICAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(),
@@ -1120,26 +1129,31 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
handleOverpayment(transactionAmountUnprocessed, loanTransaction, transactionCtx.getOverpaymentHolder());
}
private Money processPeriodsHorizontally(LoanTransaction loanTransaction, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Money transactionAmountUnprocessed,
LoanPaymentAllocationRule paymentAllocationRule, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
Set<LoanCharge> charges, Balances balances) {
private Money processPeriodsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx,
Money transactionAmountUnprocessed, LoanPaymentAllocationRule paymentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges, Balances balances) {
LinkedHashMap<DueType, List<PaymentAllocationType>> paymentAllocationsMap = paymentAllocationRule.getAllocationTypes().stream()
.collect(Collectors.groupingBy(PaymentAllocationType::getDueType, LinkedHashMap::new,
mapping(Function.identity(), toList())));
for (Map.Entry<DueType, List<PaymentAllocationType>> paymentAllocationsEntry : paymentAllocationsMap.entrySet()) {
transactionAmountUnprocessed = processAllocationsHorizontally(loanTransaction, currency, installments,
transactionAmountUnprocessed, paymentAllocationsEntry.getValue(),
paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings, charges, balances);
transactionAmountUnprocessed = processAllocationsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
paymentAllocationsEntry.getValue(), paymentAllocationRule.getFutureInstallmentAllocationRule(), transactionMappings,
charges, balances);
}
return transactionAmountUnprocessed;
}
private Money processAllocationsHorizontally(LoanTransaction loanTransaction, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Money transactionAmountUnprocessed,
List<PaymentAllocationType> paymentAllocationTypes, FutureInstallmentAllocationRule futureInstallmentAllocationRule,
private Money processAllocationsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx,
Money transactionAmountUnprocessed, List<PaymentAllocationType> paymentAllocationTypes,
FutureInstallmentAllocationRule futureInstallmentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges, Balances balances) {
if (transactionAmountUnprocessed.isZero()) {
return transactionAmountUnprocessed;
}
MonetaryCurrency currency = transactionCtx.getCurrency();
List<LoanRepaymentScheduleInstallment> installments = transactionCtx.getInstallments();
Money paidPortion;
boolean exit = false;
do {
@@ -1209,15 +1223,55 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
for (LoanRepaymentScheduleInstallment inAdvanceInstallment : inAdvanceInstallments) {
Set<LoanCharge> inAdvanceInstallmentCharges = getLoanChargesOfInstallment(charges, inAdvanceInstallment,
firstNormalInstallmentNumber);
// Adjust the portion for the last installment
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment);
}
LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping(
transactionMappings, loanTransaction, inAdvanceInstallment, currency);
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances,
LoanRepaymentScheduleInstallment.PaymentAction.PAY);
Loan loan = loanTransaction.getLoan();
if (transactionCtx instanceof ProgressiveTransactionCtx ctx && loan.isInterestBearing()
&& loan.getLoanProductRelatedDetail().isInterestRecalculationEnabled()) {
ProgressiveLoanInterestScheduleModel model = ctx.getModel();
LocalDate transactionDate = loanTransaction.getTransactionDate();
LocalDate payDate = inAdvanceInstallment.getFromDate().isAfter(transactionDate)
? inAdvanceInstallment.getFromDate()
: transactionDate;
ProgressiveLoanInterestRepaymentModel payableDetails = emiCalculator
.getPayableDetails(model, inAdvanceInstallment.getDueDate(), payDate).orElseThrow();
switch (paymentAllocationType) {
case IN_ADVANCE_INTEREST ->
inAdvanceInstallment.updateInterestCharged(payableDetails.getInterestDue().getAmount());
case IN_ADVANCE_PRINCIPAL ->
inAdvanceInstallment.updatePrincipal(payableDetails.getPrincipalDue().getAmount());
default -> {
}
}
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
transactionAmountUnprocessed, loanTransactionToRepaymentScheduleMapping,
inAdvanceInstallmentCharges, balances, LoanRepaymentScheduleInstallment.PaymentAction.PAY);
switch (paymentAllocationType) {
case IN_ADVANCE_PRINCIPAL -> {
emiCalculator.addBalanceCorrection(model, payDate,
payableDetails.getOutstandingBalance().multipliedBy(-1));
emiCalculator.addBalanceCorrection(model, payDate,
payableDetails.getPrincipalDue().minus(paidPortion));
}
case IN_ADVANCE_INTEREST -> emiCalculator.addBalanceCorrection(model, payDate,
payableDetails.getInterestDue().minus(paidPortion));
default -> {
}
}
} else {
// Adjust the portion for the last installment
if (inAdvanceInstallment.equals(inAdvanceInstallments.get(numberOfInstallments - 1))) {
evenPortion = evenPortion.add(balanceAdjustment);
}
paidPortion = processPaymentAllocation(paymentAllocationType, inAdvanceInstallment, loanTransaction,
evenPortion, loanTransactionToRepaymentScheduleMapping, inAdvanceInstallmentCharges, balances,
LoanRepaymentScheduleInstallment.PaymentAction.PAY);
}
transactionAmountUnprocessed = transactionAmountUnprocessed.minus(paidPortion);
}
} else {
@@ -18,11 +18,10 @@
*/
package org.apache.fineract.portfolio.loanaccount.loanschedule.domain;
import static java.time.temporal.ChronoUnit.DAYS;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
@@ -30,14 +29,12 @@ import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.ApplicationCurrency;
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.DisbursementData;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData;
@@ -46,6 +43,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
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.loanschedule.data.LoanScheduleDTO;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleModelDownPaymentPeriod;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleParams;
@@ -247,77 +245,38 @@ public class ProgressiveLoanScheduleGenerator implements LoanScheduleGenerator {
public OutstandingAmountsDTO calculatePrepaymentAmount(MonetaryCurrency currency, LocalDate onDate,
LoanApplicationTerms loanApplicationTerms, MathContext mc, Loan loan, HolidayDetailDTO holidayDetailDTO,
LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor) {
return switch (loanApplicationTerms.getPreClosureInterestCalculationStrategy()) {
case TILL_PRE_CLOSURE_DATE -> {
log.debug("calculating prepayment amount till pre closure date (Strategy A)");
OutstandingAmountsDTO outstandingAmounts = new OutstandingAmountsDTO(currency);
AtomicBoolean firstAfterPayoff = new AtomicBoolean(true);
loan.getRepaymentScheduleInstallments().forEach(installment -> {
boolean isInstallmentAfterPayoff = installment.getDueDate().isAfter(onDate);
List<LoanRepaymentScheduleInstallment> installments = loan.getRepaymentScheduleInstallments();
outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency));
if (isInstallmentAfterPayoff) {
if (firstAfterPayoff.getAndSet(false)) {
outstandingAmounts.plusInterest(calculatePayableInterest(loan, installment, onDate));
} else {
log.debug("Installment {} - {} is after payoff, not counting interest", installment.getFromDate(),
installment.getDueDate());
}
} else {
log.debug("adding interest for {} - {}: {}", installment.getFromDate(), installment.getDueDate(),
installment.getInterestOutstanding(currency));
outstandingAmounts.plusInterest(installment.getInterestOutstanding(currency));
}
outstandingAmounts.plusFeeCharges(installment.getFeeChargesOutstanding(currency));
outstandingAmounts.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency));
});
yield outstandingAmounts;
}
case TILL_REST_FREQUENCY_DATE -> {
log.debug("calculating prepayment amount till rest frequency date (Strategy B)");
OutstandingAmountsDTO outstandingAmounts = new OutstandingAmountsDTO(currency);
loan.getRepaymentScheduleInstallments().forEach(installment -> {
boolean isPayoffBeforeInstallment = installment.getFromDate().isBefore(onDate);
outstandingAmounts.plusPrincipal(installment.getPrincipalOutstanding(currency));
if (isPayoffBeforeInstallment) {
outstandingAmounts.plusInterest(installment.getInterestOutstanding(currency));
} else {
log.debug("Payoff after installment {}, not counting interest", installment.getDueDate());
}
outstandingAmounts.plusFeeCharges(installment.getFeeChargesOutstanding(currency));
outstandingAmounts.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency));
});
yield outstandingAmounts;
}
case NONE -> throw new UnsupportedOperationException("Pre-closure interest calculation strategy not supported");
LocalDate transactionDate = switch (loanApplicationTerms.getPreClosureInterestCalculationStrategy()) {
case TILL_PRE_CLOSURE_DATE -> onDate;
case TILL_REST_FREQUENCY_DATE -> // find due date of current installment
installments.stream().filter(it -> it.getFromDate().isBefore(onDate) && it.getDueDate().isAfter(onDate)).findFirst()
.orElseThrow(() -> new IllegalStateException("No installment found for transaction date: " + onDate)).getDueDate();
case NONE -> throw new IllegalStateException("Unexpected PreClosureInterestCalculationStrategy: NONE");
};
}
private Money calculatePayableInterest(Loan loan, LoanRepaymentScheduleInstallment installment, LocalDate onDate) {
RoundingMode roundingMode = MoneyHelper.getRoundingMode();
MonetaryCurrency currency = loan.getCurrency();
Money originalInterest = installment.getInterestCharged(currency);
log.debug("calculating interest for {} from {} to {}", originalInterest, installment.getFromDate(), installment.getDueDate());
LocalDate start = installment.getFromDate();
Money payableInterest = Money.zero(currency);
while (!start.isEqual(onDate)) {
long between = DAYS.between(start, installment.getDueDate());
Money dailyInterest = originalInterest.minus(payableInterest).dividedBy(between, roundingMode);
log.debug("Daily interest is {}: {} / {}, total: {}", dailyInterest, originalInterest.minus(payableInterest), between,
payableInterest.add(dailyInterest));
payableInterest = payableInterest.add(dailyInterest);
start = start.plusDays(1);
if (!(loanRepaymentScheduleTransactionProcessor instanceof AdvancedPaymentScheduleTransactionProcessor processor)) {
throw new IllegalStateException("Expected an AdvancedPaymentScheduleTransactionProcessor");
}
ProgressiveLoanInterestScheduleModel model = processor.reprocessProgressiveLoanTransactions(loan.getDisbursementDate(),
loan.retrieveListOfTransactionsForReprocessing(), currency, installments, loan.getActiveCharges()).getRight();
payableInterest = payableInterest.minus(installment.getInterestPaid(currency).minus(installment.getInterestWaived(currency)));
LoanRepaymentScheduleInstallment actualInstallment = installments.stream()
.filter(it -> transactionDate.isAfter(it.getFromDate()) && !transactionDate.isAfter(it.getDueDate())).findFirst()
.orElse(installments.get(0));
log.debug("Payable interest is {}", payableInterest);
return payableInterest;
ProgressiveLoanInterestRepaymentModel result = emiCalculator
.getPayableDetails(model, actualInstallment.getDueDate(), transactionDate).orElseThrow();
OutstandingAmountsDTO amounts = new OutstandingAmountsDTO(currency) //
.principal(result.getOutstandingBalance()) //
.interest(result.getInterestDue());
installments.forEach(installment -> amounts //
.plusFeeCharges(installment.getFeeChargesOutstanding(currency))
.plusPenaltyCharges(installment.getPenaltyChargesOutstanding(currency)));
return amounts;
}
// Private, internal methods
@@ -1,192 +0,0 @@
/**
* 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.loanschedule.domain;
import static java.math.BigDecimal.ZERO;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_REST_FREQUENCY_DATE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
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.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.data.OutstandingAmountsDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.test.util.ReflectionTestUtils;
class ProgressiveLoanScheduleGeneratorTest {
static class TestRow {
LocalDate fromDate;
LocalDate dueDate;
BigDecimal balance;
BigDecimal principal;
BigDecimal interest;
BigDecimal fee;
BigDecimal penalty;
boolean paid;
TestRow(LocalDate fromDate, LocalDate dueDate, BigDecimal balance, BigDecimal principal, BigDecimal interest, BigDecimal fee,
BigDecimal penalty, boolean paid) {
this.fromDate = fromDate;
this.dueDate = dueDate;
this.balance = balance;
this.principal = principal;
this.interest = interest;
this.fee = fee;
this.penalty = penalty;
this.paid = paid;
}
}
private ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(null, null);
private MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null);
private HolidayDetailDTO holidays = new HolidayDetailDTO(false, null, null);
LoanRepaymentScheduleTransactionProcessor processor = mock(LoanRepaymentScheduleTransactionProcessor.class);
static {
ConfigurationDomainService domainService = mock(ConfigurationDomainService.class);
when(domainService.getRoundingMode()).thenReturn(RoundingMode.HALF_UP.ordinal());
ReflectionTestUtils.setField(MoneyHelper.class, "staticConfigurationDomainService", domainService);
}
@BeforeAll
public static void beforeAll() {
((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).setLevel(ch.qos.logback.classic.Level.DEBUG);
}
@AfterAll
public static void afterAll() {
((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).setLevel(Level.INFO);
}
public List<TestRow> testRows() {
return List.of(
new TestRow(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1), BigDecimal.valueOf(83.57), BigDecimal.valueOf(16.43),
BigDecimal.valueOf(0.58), ZERO, ZERO, true),
new TestRow(LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), BigDecimal.valueOf(67.05), BigDecimal.valueOf(16.52),
BigDecimal.valueOf(0.49), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1), BigDecimal.valueOf(50.43), BigDecimal.valueOf(16.62),
BigDecimal.valueOf(0.39), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1), BigDecimal.valueOf(33.71), BigDecimal.valueOf(16.72),
BigDecimal.valueOf(0.29), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1), BigDecimal.valueOf(16.90), BigDecimal.valueOf(16.81),
BigDecimal.valueOf(0.20), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1), BigDecimal.valueOf(00.90), BigDecimal.valueOf(16.90),
BigDecimal.valueOf(0.10), ZERO, ZERO, false));
}
@Test
public void calculatePrepaymentAmount_TILL_PRE_CLOSURE_DATE() {
LoanApplicationTerms terms = mock(LoanApplicationTerms.class);
when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_PRE_CLOSURE_DATE);
Loan loan = prepareLoanWithInstallments(testRows());
OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 2, 15), terms, MathContext.DECIMAL32,
loan, holidays, processor);
assertEquals(BigDecimal.valueOf(83.84), amounts.getTotalOutstanding().getAmount());
}
@Test
public void calculatePrepaymentAmount_TILL_REST_FREQUENCY_DATE() {
LoanApplicationTerms terms = mock(LoanApplicationTerms.class);
when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_REST_FREQUENCY_DATE);
Loan loan = prepareLoanWithInstallments(testRows());
OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 2, 15), terms, MathContext.DECIMAL32,
loan, holidays, processor);
assertEquals(BigDecimal.valueOf(84.06), amounts.getTotalOutstanding().getAmount());
}
@Test
public void calculateSameDayPayoff_TILL_PRE_CLOSURE_DATE() {
LoanApplicationTerms terms = mock(LoanApplicationTerms.class);
when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_PRE_CLOSURE_DATE);
Loan loan = prepareLoanWithInstallments(List.of(
new TestRow(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100),
BigDecimal.valueOf(2), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100),
BigDecimal.valueOf(2), ZERO, ZERO, false)));
OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 1, 1), terms, MathContext.DECIMAL32,
loan, holidays, processor);
assertEquals(BigDecimal.valueOf(200.0).longValue(), amounts.getTotalOutstanding().getAmount().longValue());
}
@Test
public void calculateSameDayPayoff_TILL_REST_FREQUENCY_DATE() {
LoanApplicationTerms terms = mock(LoanApplicationTerms.class);
when(terms.getPreClosureInterestCalculationStrategy()).thenReturn(TILL_REST_FREQUENCY_DATE);
Loan loan = prepareLoanWithInstallments(List.of(
new TestRow(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100),
BigDecimal.valueOf(2), ZERO, ZERO, false),
new TestRow(LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1), BigDecimal.valueOf(102), BigDecimal.valueOf(100),
BigDecimal.valueOf(2), ZERO, ZERO, false)));
OutstandingAmountsDTO amounts = generator.calculatePrepaymentAmount(usd, LocalDate.of(2024, 1, 1), terms, MathContext.DECIMAL32,
loan, holidays, processor);
assertEquals(BigDecimal.valueOf(200.0).longValue(), amounts.getTotalOutstanding().getAmount().longValue());
}
@NotNull
private Loan prepareLoanWithInstallments(List<TestRow> rows) {
Loan loan = mock(Loan.class);
List<LoanRepaymentScheduleInstallment> installments = createInstallments(rows, loan, usd);
when(loan.getRepaymentScheduleInstallments()).thenReturn(installments);
when(loan.getCurrency()).thenReturn(usd);
return loan;
}
private List<LoanRepaymentScheduleInstallment> createInstallments(List<TestRow> rows, Loan loan, MonetaryCurrency usd) {
AtomicInteger count = new AtomicInteger(1);
return rows.stream().map(row -> {
LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan, count.incrementAndGet(), row.fromDate,
row.dueDate, row.principal, row.interest, row.fee, row.penalty, true, null, null, row.paid);
if (row.paid) {
installment.payPrincipalComponent(row.fromDate, Money.of(usd, row.principal));
installment.payInterestComponent(row.fromDate, Money.of(usd, row.interest));
installment.updateObligationMet(true);
}
return installment;
}).toList();
}
}