diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index 4776e322bb6..992a0dae7e1 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -182,6 +182,7 @@ public enum DefaultLoanProduct implements LoanProduct { LP2_ADV_PYMNT_360_30_ZERO_INTEREST_CHARGE_OFF_ACCRUAL_ACTIVITY, // LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_PRINCIPAL_FIRST, // LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_360_30_USD, // + LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE, // ; @Override diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index 0fd7cceefd0..afe1063e47b 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -4318,6 +4318,39 @@ public void initialize() throws Exception { TestContext.INSTANCE.set( TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_360_30_USD, responseLoanProductsResponseAdvCustomPaymentAllocationProgressiveLoanScheduleHorizontalUSD); + + // LP2 with progressive loan schedule + horizontal + interest recalculation daily EMI + 360/30 + + // multidisbursement with full term tranche enabled + // Frequency for recalculate Outstanding Principal: Daily, Frequency Interval for recalculation: 1 + // (LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE) + String name170 = DefaultLoanProduct.LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE + .getName(); + PostLoanProductsRequest loanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseFullTermTranche = loanProductsRequestFactory + .defaultLoanProductsRequestLP2Emi()// + .name(name170)// + .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")))// + .multiDisburseLoan(true)// + .disallowExpectedDisbursements(true)// + .allowFullTermForTranche(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0);// + PostLoanProductsResponse responseLoanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseFullTermTranche = createLoanProductIdempotent( + loanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseFullTermTranche); + TestContext.INSTANCE.set( + TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE, + responseLoanProductsRequestLP2AdvancedpaymentInterestEmi36030InterestRecalcDailyMultiDisburseFullTermTranche); } public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 56e52d42c72..1285378693e 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -293,5 +293,6 @@ public abstract class TestContextKey { public static final String LP1_INTEREST_FLAT_DAILY_ACTUAL_ACTUAL_MULTIDISB_EXPECT_TRANCHES = "loanProductCreateResponseLP1InterestFlatDailyActualActualMultiDisbursementExpectTranches"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_360_30_ZERO_INTEREST_CHARGE_OFF_ACCRUAL_ACTIVITY = "loanProductCreateResponseLP2AdvancedPaymentZeroInterestChargeOffBehaviourAccrualActivity"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADVANCED_CUSTOM_PAYMENT_ALLOCATION_PROGRESSIVE_LOAN_SCHEDULE_PRINCIPAL_FIRST = "loanProductCreateResponseLP2AdvancedPaymentHorizontalPrincipalFirst"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationDailyMultidisburseFullTermTranche"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_360_30_USD = "loanProductCreateResponseLP2AdvancedPaymentHorizontal36030Usd"; } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature new file mode 100644 index 00000000000..dece132e6de --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanDelayedScheduleCaptures.feature @@ -0,0 +1,139 @@ +@DelayedScheduleCapturesFeature +Feature: Full Term Tranche - Schedule handling and Calculations + + @TestRailId:C4366 + Scenario: Verify full term tranche interest bearing progressive loan - Schedule handling and Calculations - Disbursement on Installment Date - UC1 + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + 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_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE | 01 January 2024 | 200 | 9.4822 | 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 "200" 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.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 2 | 29 | 01 March 2024 | | 67.19 | 16.47 | 0.66 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 3 | 31 | 01 April 2024 | | 50.59 | 16.6 | 0.53 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 4 | 30 | 01 May 2024 | | 33.86 | 16.73 | 0.4 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 5 | 31 | 01 June 2024 | | 17.0 | 16.86 | 0.27 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 6 | 30 | 01 July 2024 | | 0.0 | 17.0 | 0.13 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + 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.78 | 0.0 | 0.0 | 102.78 | 0.0 | 0.0 | 0.0 | 102.78 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | +# --- 2nd disbursement on installment date --- + When Admin sets the business date to "01 February 2024" + When Admin successfully disburse the loan on "01 February 2024" with "100" EUR transaction amount + Then Loan Repayment schedule has 7 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.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | | | 01 February 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 29 | 01 March 2024 | | 150.85 | 32.81 | 1.45 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 3 | 31 | 01 April 2024 | | 117.78 | 33.07 | 1.19 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 4 | 30 | 01 May 2024 | | 84.45 | 33.33 | 0.93 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 5 | 31 | 01 June 2024 | | 50.86 | 33.59 | 0.67 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 6 | 30 | 01 July 2024 | | 17.0 | 33.86 | 0.4 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 7 | 31 | 01 August 2024 | | 0.0 | 17.0 | 0.13 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 200.0 | 5.56 | 0.0 | 0.0 | 205.56 | 0.0 | 0.0 | 0.0 | 205.56 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | + | 01 February 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 200.0 | false | false | + + @TestRailId:C4367 + Scenario: Verify full term tranche interest bearing progressive loan - Schedule handling and Calculations - Disbursement mid-period - UC2 + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + 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_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE | 01 January 2024 | 200 | 9.4822 | 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 "200" 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.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 2 | 29 | 01 March 2024 | | 67.19 | 16.47 | 0.66 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 3 | 31 | 01 April 2024 | | 50.59 | 16.6 | 0.53 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 4 | 30 | 01 May 2024 | | 33.86 | 16.73 | 0.4 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 5 | 31 | 01 June 2024 | | 17.0 | 16.86 | 0.27 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 6 | 30 | 01 July 2024 | | 0.0 | 17.0 | 0.13 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + 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.78 | 0.0 | 0.0 | 102.78 | 0.0 | 0.0 | 0.0 | 102.78 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | +# --- 2nd disbursement mid-period (Feb 15) --- + When Admin sets the business date to "15 February 2024" + When Admin successfully disburse the loan on "15 February 2024" with "100" EUR transaction amount + Then Loan Repayment schedule has 7 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.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | | | 15 February 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 2 | 29 | 01 March 2024 | | 150.59 | 33.07 | 1.13 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 3 | 31 | 01 April 2024 | | 117.58 | 33.01 | 1.19 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 4 | 30 | 01 May 2024 | | 84.31 | 33.27 | 0.93 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 5 | 31 | 01 June 2024 | | 50.78 | 33.53 | 0.67 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 6 | 30 | 01 July 2024 | | 16.92 | 33.86 | 0.4 | 0.0 | 0.0 | 34.26 | 0.0 | 0.0 | 0.0 | 34.26 | + | 7 | 31 | 01 August 2024 | | 0.0 | 16.92 | 0.13 | 0.0 | 0.0 | 17.05 | 0.0 | 0.0 | 0.0 | 17.05 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 200.0 | 5.24 | 0.0 | 0.0 | 205.24 | 0.0 | 0.0 | 0.0 | 205.24 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | + | 15 February 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 200.0 | false | false | + + @TestRailId:C4368 + Scenario: Verify full term tranche interest bearing progressive loan - Schedule handling and Calculations - Both disbursements before first repayment - UC3 + When Admin sets the business date to "01 January 2024" + When Admin creates a client with random data + 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_RECALC_DAILY_MULTIDISBURSE_FULL_TERM_TRANCHE | 01 January 2024 | 200 | 9.4822 | 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 "200" 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.66 | 16.34 | 0.79 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 2 | 29 | 01 March 2024 | | 67.19 | 16.47 | 0.66 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 3 | 31 | 01 April 2024 | | 50.59 | 16.6 | 0.53 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 4 | 30 | 01 May 2024 | | 33.86 | 16.73 | 0.4 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 5 | 31 | 01 June 2024 | | 17.0 | 16.86 | 0.27 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + | 6 | 30 | 01 July 2024 | | 0.0 | 17.0 | 0.13 | 0.0 | 0.0 | 17.13 | 0.0 | 0.0 | 0.0 | 17.13 | + 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.78 | 0.0 | 0.0 | 102.78 | 0.0 | 0.0 | 0.0 | 102.78 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | +# --- 2nd disbursement before first repayment date (Jan 15) - no term extension --- + When Admin sets the business date to "15 January 2024" + When Admin successfully disburse the loan on "15 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 | | | | + | | | 15 January 2024 | | 100.0 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 01 February 2024 | | 167.02 | 32.98 | 1.22 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 2 | 29 | 01 March 2024 | | 134.14 | 32.88 | 1.32 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 3 | 31 | 01 April 2024 | | 101.0 | 33.14 | 1.06 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 4 | 30 | 01 May 2024 | | 67.6 | 33.4 | 0.8 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 5 | 31 | 01 June 2024 | | 33.93 | 33.67 | 0.53 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + | 6 | 30 | 01 July 2024 | | 0.0 | 33.93 | 0.27 | 0.0 | 0.0 | 34.2 | 0.0 | 0.0 | 0.0 | 34.2 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 200.0 | 5.2 | 0.0 | 0.0 | 205.2 | 0.0 | 0.0 | 0.0 | 205.2 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | Replayed | + | 01 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 100.0 | false | false | + | 15 January 2024 | Disbursement | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | 200.0 | false | false | diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/RepaymentScheduleRelatedLoanData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/RepaymentScheduleRelatedLoanData.java index c4997ca9d83..3f2b621f915 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/RepaymentScheduleRelatedLoanData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/RepaymentScheduleRelatedLoanData.java @@ -20,6 +20,7 @@ import java.math.BigDecimal; import java.time.LocalDate; +import lombok.Getter; import org.apache.fineract.organisation.monetary.data.CurrencyData; /** @@ -34,10 +35,19 @@ public class RepaymentScheduleRelatedLoanData { private final BigDecimal netDisbursalAmount; private final BigDecimal inArrearsTolerance; private final BigDecimal totalFeeChargesAtDisbursement; + @Getter + private final boolean allowFullTermForTranche; public RepaymentScheduleRelatedLoanData(final LocalDate expectedDisbursementDate, final LocalDate actualDisbursementDate, final CurrencyData currency, final BigDecimal principal, final BigDecimal inArrearsTolerance, final BigDecimal totalFeeChargesAtDisbursement) { + this(expectedDisbursementDate, actualDisbursementDate, currency, principal, inArrearsTolerance, totalFeeChargesAtDisbursement, + false); + } + + public RepaymentScheduleRelatedLoanData(final LocalDate expectedDisbursementDate, final LocalDate actualDisbursementDate, + final CurrencyData currency, final BigDecimal principal, final BigDecimal inArrearsTolerance, + final BigDecimal totalFeeChargesAtDisbursement, final boolean allowFullTermForTranche) { this.expectedDisbursementDate = expectedDisbursementDate; this.actualDisbursementDate = actualDisbursementDate; this.currency = currency; @@ -45,6 +55,7 @@ public RepaymentScheduleRelatedLoanData(final LocalDate expectedDisbursementDate this.inArrearsTolerance = inArrearsTolerance; this.totalFeeChargesAtDisbursement = totalFeeChargesAtDisbursement; this.netDisbursalAmount = this.principal.subtract(this.totalFeeChargesAtDisbursement); + this.allowFullTermForTranche = allowFullTermForTranche; } public LocalDate disbursementDate() { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index d8bfda8bc66..fa1984c86b1 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -427,6 +427,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "enable_installment_level_delinquency", nullable = false) private boolean enableInstallmentLevelDelinquency = false; + @Getter @Column(name = "allow_full_term_for_tranche", nullable = false) private boolean allowFullTermForTranche = false; @@ -1787,10 +1788,6 @@ public void updateEnableInstallmentLevelDelinquency(boolean enableInstallmentLev this.enableInstallmentLevelDelinquency = enableInstallmentLevelDelinquency; } - public boolean isAllowFullTermForTranche() { - return this.allowFullTermForTranche; - } - public void updateAllowFullTermForTranche(boolean allowFullTermForTranche) { this.allowFullTermForTranche = allowFullTermForTranche; } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleProcessingWrapper.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleProcessingWrapper.java index 2ddf2b24658..ba233d7260a 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleProcessingWrapper.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanRepaymentScheduleProcessingWrapper.java @@ -253,6 +253,10 @@ public static boolean isInPeriod(LocalDate targetDate, LocalDate fromDate, Local : DateUtils.isDateInRangeFromExclusiveToInclusive(targetDate, fromDate, toDate); } + public static boolean isInPeriodFromInclusiveToExclusive(final LocalDate targetDate, final LocalDate fromDate, final LocalDate toDate) { + return DateUtils.isDateInRangeFromInclusiveToExclusive(fromDate, toDate, targetDate); + } + public static boolean isBeforePeriod(LocalDate targetDate, LoanRepaymentScheduleInstallment installment, boolean isFirstPeriod) { LocalDate fromDate = installment.getFromDate(); return isFirstPeriod ? DateUtils.isBefore(targetDate, fromDate) : !DateUtils.isAfter(targetDate, fromDate); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java index fc85244e2fa..297ce1b0e46 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanApplicationTerms.java @@ -27,6 +27,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import lombok.Getter; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.data.CurrencyData; @@ -247,6 +248,8 @@ public final class LoanApplicationTerms { private LoanBuyDownFeeStrategy buyDownFeeStrategy; private LoanBuyDownFeeIncomeType buyDownFeeIncomeType; private boolean merchantBuyDownFee; + @Getter + private boolean allowFullTermForTranche = false; private LoanApplicationTerms(Builder builder) { this.currency = builder.currency; @@ -292,6 +295,7 @@ private LoanApplicationTerms(Builder builder) { this.buyDownFeeStrategy = builder.buyDownFeeStrategy; this.buyDownFeeIncomeType = builder.buyDownFeeIncomeType; this.merchantBuyDownFee = builder.merchantBuyDownFee; + this.allowFullTermForTranche = builder.allowFullTermForTranche; this.interestMethod = builder.interestMethod; this.allowPartialPeriodInterestCalculation = builder.allowPartialPeriodInterestCalculation; } @@ -333,6 +337,7 @@ public static class Builder { private LoanBuyDownFeeStrategy buyDownFeeStrategy; private LoanBuyDownFeeIncomeType buyDownFeeIncomeType; private boolean merchantBuyDownFee; + private boolean allowFullTermForTranche; private boolean allowPartialPeriodInterestCalculation; public Builder interestMethod(InterestMethod interestMethod) { @@ -500,6 +505,11 @@ public Builder merchantBuyDownFee(boolean value) { return this; } + public Builder allowFullTermForTranche(boolean value) { + this.allowFullTermForTranche = value; + return this; + } + public LoanApplicationTerms build() { return new LoanApplicationTerms(this); } @@ -542,7 +552,8 @@ public static LoanApplicationTerms assembleFrom(LoanRepaymentScheduleModelData m .submittedOnDate(modelData.scheduleGenerationStartDate()).seedDate(seedDate) .interestRecognitionOnDisbursementDate(modelData.interestRecognitionOnDisbursementDate()) .daysInYearCustomStrategy(modelData.daysInYearCustomStrategy()).interestMethod(modelData.interestMethod()) - .allowPartialPeriodInterestCalculation(modelData.allowPartialPeriodInterestCalculation()).mc(mc).build(); + .allowPartialPeriodInterestCalculation(modelData.allowPartialPeriodInterestCalculation()) + .allowFullTermForTranche(modelData.allowFullTermForTranche()).mc(mc).build(); } public static LoanApplicationTerms assembleFrom(final CurrencyData currency, final Integer loanTermFrequency, @@ -579,7 +590,7 @@ public static LoanApplicationTerms assembleFrom(final CurrencyData currency, fin final LoanCapitalizedIncomeStrategy capitalizedIncomeStrategy, final LoanCapitalizedIncomeType capitalizedIncomeType, final boolean enableBuyDownFee, final LoanBuyDownFeeCalculationType buyDownFeeCalculationType, final LoanBuyDownFeeStrategy buyDownFeeStrategy, final LoanBuyDownFeeIncomeType buyDownFeeIncomeType, - final boolean merchantBuyDownFee) { + final boolean merchantBuyDownFee, final boolean allowFullTermForTranche) { final LoanRescheduleStrategyMethod rescheduleStrategyMethod = null; final CalendarHistoryDataWrapper calendarHistoryDataWrapper = null; @@ -601,7 +612,7 @@ public static LoanApplicationTerms assembleFrom(final CurrencyData currency, fin fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, interestRecognitionOnDisbursementDate, daysInYearCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee, - buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee); + buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, allowFullTermForTranche); } @@ -622,7 +633,7 @@ public static LoanApplicationTerms assembleFrom(final CurrencyData currency, fin final boolean isSkipRepaymentOnFirstDayOfMonth, final HolidayDetailDTO holidayDetailDTO, final boolean allowCompoundingOnEod, final boolean isFirstRepaymentDateAllowedOnHoliday, final boolean isInterestToBeRecoveredFirstWhenGreaterThanEMI, final BigDecimal fixedPrincipalPercentagePerInstallment, final boolean isPrincipalCompoundingDisabledForOverdueLoans, - final RepaymentStartDateType repaymentStartDateType, final LocalDate submittedOnDate) { + final RepaymentStartDateType repaymentStartDateType, final LocalDate submittedOnDate, final boolean allowFullTermForTranche) { final Integer numberOfRepayments = loanProductRelatedDetail.getNumberOfRepayments(); final Integer repaymentEvery = loanProductRelatedDetail.getRepayEvery(); @@ -680,7 +691,7 @@ public static LoanApplicationTerms assembleFrom(final CurrencyData currency, fin loanProductRelatedDetail.getCapitalizedIncomeStrategy(), loanProductRelatedDetail.getCapitalizedIncomeType(), loanProductRelatedDetail.isEnableBuyDownFee(), loanProductRelatedDetail.getBuyDownFeeCalculationType(), loanProductRelatedDetail.getBuyDownFeeStrategy(), loanProductRelatedDetail.getBuyDownFeeIncomeType(), - loanProductRelatedDetail.isMerchantBuyDownFee()); + loanProductRelatedDetail.isMerchantBuyDownFee(), allowFullTermForTranche); } private LoanApplicationTerms(final CurrencyData currency, final Integer loanTermFrequency, @@ -716,7 +727,7 @@ private LoanApplicationTerms(final CurrencyData currency, final Integer loanTerm final LoanCapitalizedIncomeStrategy capitalizedIncomeStrategy, final LoanCapitalizedIncomeType capitalizedIncomeType, final boolean enableBuyDownFee, final LoanBuyDownFeeCalculationType buyDownFeeCalculationType, final LoanBuyDownFeeStrategy buyDownFeeStrategy, final LoanBuyDownFeeIncomeType buyDownFeeIncomeType, - final boolean merchantBuyDownFee) { + final boolean merchantBuyDownFee, final boolean allowFullTermForTranche) { this.currency = currency; this.loanTermFrequency = loanTermFrequency; @@ -827,6 +838,7 @@ private LoanApplicationTerms(final CurrencyData currency, final Integer loanTerm this.buyDownFeeStrategy = buyDownFeeStrategy; this.buyDownFeeIncomeType = buyDownFeeIncomeType; this.merchantBuyDownFee = merchantBuyDownFee; + this.allowFullTermForTranche = allowFullTermForTranche; } public Money adjustPrincipalIfLastRepaymentPeriod(final Money principalForPeriod, final Money totalCumulativePrincipalToDate, @@ -1703,7 +1715,7 @@ public ILoanConfigurationDetails toLoanConfigurationDetails() { repaymentEvery, numberOfRepayments, isInterestChargedFromDateSameAsDisbursalDateEnabled != null && isInterestChargedFromDateSameAsDisbursalDateEnabled, daysInYearCustomStrategy, allowPartialPeriodInterestCalculation, interestRecalculationEnabled, recalculationFrequencyType, - preClosureInterestCalculationStrategy); + preClosureInterestCalculationStrategy, allowFullTermForTranche); } public Integer getLoanTermFrequency() { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanRepaymentScheduleModelData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanRepaymentScheduleModelData.java index fe6b1e055a1..66fca947cbe 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanRepaymentScheduleModelData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanRepaymentScheduleModelData.java @@ -35,5 +35,6 @@ public record LoanRepaymentScheduleModelData(@NotNull LocalDate scheduleGenerati @NotNull boolean downPaymentEnabled, @NotNull DaysInMonthType daysInMonth, @NotNull DaysInYearType daysInYear, BigDecimal downPaymentPercentage, Integer installmentAmountInMultiplesOf, Integer fixedLength, @NotNull Boolean interestRecognitionOnDisbursementDate, @Nullable DaysInYearCustomStrategyType daysInYearCustomStrategy, - @NotNull InterestMethod interestMethod, @NotNull boolean allowPartialPeriodInterestCalculation) { + @NotNull InterestMethod interestMethod, @NotNull boolean allowPartialPeriodInterestCalculation, + @NotNull boolean allowFullTermForTranche) { } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTermVariationsMapper.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTermVariationsMapper.java index 7fd5591e755..a4b909c6d6a 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTermVariationsMapper.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanTermVariationsMapper.java @@ -120,7 +120,8 @@ public LoanApplicationTerms constructLoanApplicationTerms(final ScheduleGenerato scheduleGeneratorDTO.getNumberOfdays(), scheduleGeneratorDTO.isSkipRepaymentOnFirstDayofMonth(), holidayDetailDTO, allowCompoundingOnEod, scheduleGeneratorDTO.isFirstRepaymentDateAllowedOnHoliday(), scheduleGeneratorDTO.isInterestToBeRecoveredFirstWhenGreaterThanEMI(), loan.getFixedPrincipalPercentagePerInstallment(), - scheduleGeneratorDTO.isPrincipalCompoundingDisabledForOverdueLoans(), repaymentStartDateType, loan.getSubmittedOnDate()); + scheduleGeneratorDTO.isPrincipalCompoundingDisabledForOverdueLoans(), repaymentStartDateType, loan.getSubmittedOnDate(), + loan.isAllowFullTermForTranche()); } private BigDecimal constructFloatingInterestRates(final BigDecimal annualNominalInterestRate, final FloatingRateDTO floatingRateDTO, diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanConfigurationDetails.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanConfigurationDetails.java index a774dae5620..977e9a513cb 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanConfigurationDetails.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanConfigurationDetails.java @@ -58,6 +58,8 @@ public class LoanConfigurationDetails implements ILoanConfigurationDetails { private final RecalculationFrequencyType restFrequencyType; @Getter private final LoanPreCloseInterestCalculationStrategy preCloseInterestCalculationStrategy; + @Getter + private final boolean allowFullTermForTranche; public LoanConfigurationDetails(CurrencyData currency, BigDecimal interestRatePerPeriod, BigDecimal annualNominalInterestRate, Integer interestChargingGrace, Integer interestPaymentGrace, Integer principalGrace, @@ -67,7 +69,7 @@ public LoanConfigurationDetails(CurrencyData currency, BigDecimal interestRatePe Integer numberOfRepayments, boolean interestRecognitionOnDisbursementDate, DaysInYearCustomStrategyType daysInYearCustomStrategy, boolean allowPartialPeriodInterestCalculation, boolean isInterestRecalculationEnabled, RecalculationFrequencyType restFrequencyType, - LoanPreCloseInterestCalculationStrategy preCloseInterestCalculationStrategy) { + LoanPreCloseInterestCalculationStrategy preCloseInterestCalculationStrategy, boolean allowFullTermForTranche) { this.currency = currency; this.interestRatePerPeriod = interestRatePerPeriod; this.annualNominalInterestRate = annualNominalInterestRate; @@ -89,6 +91,7 @@ public LoanConfigurationDetails(CurrencyData currency, BigDecimal interestRatePe this.isInterestRecalculationEnabled = isInterestRecalculationEnabled; this.restFrequencyType = restFrequencyType; this.preCloseInterestCalculationStrategy = preCloseInterestCalculationStrategy; + this.allowFullTermForTranche = allowFullTermForTranche; } private Integer defaultToNullIfZero(final Integer value) { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/ILoanConfigurationDetails.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/ILoanConfigurationDetails.java index ef5095198f4..4ec89f0fd0c 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/ILoanConfigurationDetails.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/ILoanConfigurationDetails.java @@ -73,4 +73,6 @@ public interface ILoanConfigurationDetails { RecalculationFrequencyType getRestFrequencyType(); LoanPreCloseInterestCalculationStrategy getPreCloseInterestCalculationStrategy(); + + boolean isAllowFullTermForTranche(); } diff --git a/fineract-progressive-loan-embeddable-schedule-generator/misc/Main.java b/fineract-progressive-loan-embeddable-schedule-generator/misc/Main.java index 1b7d006eb66..e3fd40de21c 100644 --- a/fineract-progressive-loan-embeddable-schedule-generator/misc/Main.java +++ b/fineract-progressive-loan-embeddable-schedule-generator/misc/Main.java @@ -62,7 +62,7 @@ public static void main(String[] args) throws InterruptedException { final InterestMethod interestMethod = InterestMethod.DECLINING_BALANCE; final boolean allowPartialPeriodInterestCalculation = true; - var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments, repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType, daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate, dasInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation); + var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments, repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType, daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate, dasInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation, false); final LoanSchedulePlan plan = calculator.generate(mc, config); printPlan(plan); diff --git a/fineract-progressive-loan-embeddable-schedule-generator/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGeneratorTest.java b/fineract-progressive-loan-embeddable-schedule-generator/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGeneratorTest.java index 0eea0ff98fa..7bf3e57311e 100644 --- a/fineract-progressive-loan-embeddable-schedule-generator/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGeneratorTest.java +++ b/fineract-progressive-loan-embeddable-schedule-generator/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/EmbeddableProgressiveLoanScheduleGeneratorTest.java @@ -67,7 +67,7 @@ void testGenerate() { var config = new LoanRepaymentScheduleModelData(startDate, currency, disbursedAmount, disbursementDate, noRepayments, repaymentFrequency, repaymentFrequencyType, annualNominalInterestRate, isDownPaymentEnabled, daysInMonthType, daysInYearType, downPaymentPercentage, installmentAmountInMultiplesOf, fixedLength, interestRecognitionOnDisbursementDate, - daysInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation); + daysInYearCustomStrategy, interestMethod, allowPartialPeriodInterestCalculation, false); final LoanSchedulePlan plan = calculator.generate(mc, config); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index a0b789a7fe0..52c4a3267b1 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -119,6 +119,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType; import org.apache.fineract.portfolio.loanproduct.domain.DueType; import org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocationRule; +import org.apache.fineract.portfolio.loanproduct.domain.ILoanConfigurationDetails; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; import org.apache.fineract.portfolio.util.InstallmentProcessingHelper; @@ -1595,7 +1596,7 @@ private void handleDisbursementWithEMICalculator(LoanTransaction disbursementTra } disbursementTransaction.resetDerivedComponents(); - recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, transactionDate, currency); + recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, disbursementTransaction, currency); allocateOverpayment(disbursementTransaction, transactionCtx); } @@ -1669,7 +1670,7 @@ private void handleCapitalizedIncomeWithEMICalculator(LoanTransaction capitalize Money amortizableAmount = capitalizedIncomeTransaction.getAmount(currency); emiCalculator.addCapitalizedIncome(model, transactionDate, amortizableAmount); - recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, transactionDate, currency); + recalculateRepaymentPeriodsWithEMICalculation(amortizableAmount, model, installments, capitalizedIncomeTransaction, currency); allocateOverpayment(capitalizedIncomeTransaction, transactionCtx); } @@ -1689,9 +1690,11 @@ private void handleCapitalizedIncomeWithoutEMICalculator(LoanTransaction capital allocateOverpayment(capitalizedIncomeTransaction, transactionCtx); } - private void recalculateRepaymentPeriodsWithEMICalculation(Money amortizableAmount, ProgressiveLoanInterestScheduleModel model, - List installments, LocalDate transactionDate, MonetaryCurrency currency) { - boolean isPostMaturityDisbursement = installments.stream().filter(i -> !i.isDownPayment() && !i.isAdditional()) + private void recalculateRepaymentPeriodsWithEMICalculation(final Money amortizableAmount, + final ProgressiveLoanInterestScheduleModel model, final List installments, + final LoanTransaction loanTransaction, final MonetaryCurrency currency) { + final LocalDate transactionDate = loanTransaction.getTransactionDate(); + final boolean isPostMaturityDisbursement = installments.stream().filter(i -> !i.isDownPayment() && !i.isAdditional()) .allMatch(i -> i.getDueDate().isBefore(transactionDate)); if (amortizableAmount.isGreaterThanZero()) { @@ -1703,14 +1706,34 @@ private void recalculateRepaymentPeriodsWithEMICalculation(Money amortizableAmou } } + final ListIterator iterator = installments.listIterator(); + final AtomicInteger installmentCounter = new AtomicInteger(); + final ILoanConfigurationDetails loanProductRelatedDetail = model.loanProductRelatedDetail(); + model.repaymentPeriods().forEach(rm -> { - LoanRepaymentScheduleInstallment installment = installments.stream().filter( - ri -> ri.getDueDate().equals(rm.getDueDate()) && !ri.isDownPayment() && !ri.getDueDate().isBefore(transactionDate)) - .findFirst().orElse(null); - if (installment != null) { + LoanRepaymentScheduleInstallment installment = null; + while (iterator.hasNext() && (installment == null || installment.isAdditional() || installment.isDownPayment())) { + installment = iterator.next(); + installmentCounter.getAndIncrement(); + } + + if (installment != null && installment.getDueDate().equals(rm.getDueDate()) + && !installment.getDueDate().isBefore(transactionDate)) { installment.updatePrincipal(rm.getDuePrincipal().getAmount()); installment.updateInterestCharged(rm.getDueInterest().getAmount()); installment.updateObligationsMet(currency, transactionDate); + } else { + if (loanProductRelatedDetail != null && loanProductRelatedDetail.isAllowFullTermForTranche() + && loanProductRelatedDetail.getNumberOfRepayments() > 0 && !rm.getDueDate().isBefore(transactionDate)) { + installmentCounter.getAndIncrement(); + final LoanRepaymentScheduleInstallment newInstallment = new LoanRepaymentScheduleInstallment( + loanTransaction.getLoan(), installmentCounter.get(), rm.getFromDate(), rm.getDueDate(), + rm.getDuePrincipal().getAmount(), rm.getDueInterest().getAmount(), null, null, null, null, null, null, + false, false, false); + + newInstallment.updateObligationsMet(currency, transactionDate); + iterator.add(newInstallment); + } } }); } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanConfigurationDetailsMapper.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanConfigurationDetailsMapper.java index 9e80895dc0a..487c7069ed1 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanConfigurationDetailsMapper.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/mapper/LoanConfigurationDetailsMapper.java @@ -56,7 +56,8 @@ public static ILoanConfigurationDetails map(Loan loan) { loanProductRelatedDetail.getRepaymentPeriodFrequencyType(), loanProductRelatedDetail.getRepayEvery(), loanProductRelatedDetail.getNumberOfRepayments(), loanProductRelatedDetail.isInterestRecognitionOnDisbursementDate(), loanProductRelatedDetail.getDaysInYearCustomStrategy(), loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation(), - loan.isInterestRecalculationEnabled(), getRestFrequencyType(loan), getPreCloseInterestCalculationStrategy(loan)); + loan.isInterestRecalculationEnabled(), getRestFrequencyType(loan), getPreCloseInterestCalculationStrategy(loan), + loan.isAllowFullTermForTranche()); } private static RecalculationFrequencyType getRestFrequencyType(Loan loan) { diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java index 041e7c97f7c..83868d57895 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculator.java @@ -18,6 +18,9 @@ */ package org.apache.fineract.portfolio.loanproduct.calc; +import static java.math.BigDecimal.ZERO; +import static org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleProcessingWrapper.isInPeriodFromInclusiveToExclusive; + import jakarta.annotation.Nonnull; import jakarta.validation.constraints.NotNull; import java.math.BigDecimal; @@ -134,12 +137,112 @@ private void addDisbursement(final ProgressiveLoanInterestScheduleModel schedule scheduleModel.repaymentPeriods().stream().filter(rp -> !operation.getSubmittedOnDate().isAfter(rp.getFromDate())) .forEach(rp -> rp.setTotalDisbursedAmount(rp.getTotalDisbursedAmount().add(operation.getAmount()))); - scheduleModel - .changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), operation.getAmount(), - scheduleModel.zero(), scheduleModel.zero()) - .ifPresent((repaymentPeriod) -> calculateEMIValueAndRateFactors( - getEffectiveRepaymentDueDate(scheduleModel, repaymentPeriod, operation.getSubmittedOnDate()), scheduleModel, - operation)); + final int numberOfRepayments = scheduleModel.loanProductRelatedDetail().getNumberOfRepayments(); + if (scheduleModel.loanProductRelatedDetail().isAllowFullTermForTranche() && numberOfRepayments > 0) { + addFullTermTrancheDisbursement(scheduleModel, operation); + } else { + scheduleModel + .changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), operation.getAmount(), + scheduleModel.zero(), scheduleModel.zero()) + .ifPresent((repaymentPeriod) -> calculateEMIValueAndRateFactors( + getEffectiveRepaymentDueDate(scheduleModel, repaymentPeriod, operation.getSubmittedOnDate()), scheduleModel, + operation)); + } + } + + private void addFullTermTrancheDisbursement(final ProgressiveLoanInterestScheduleModel scheduleModel, + final EmiChangeOperation operation) { + final MathContext mc = scheduleModel.mc(); + final LocalDate disbursementDate = operation.getSubmittedOnDate(); + final Money disbursedAmount = operation.getAmount(); + final ILoanConfigurationDetails loanProductRelatedDetail = scheduleModel.loanProductRelatedDetail(); + final Optional firstDisbursedPeriod = scheduleModel.repaymentPeriods().stream() + .filter(period -> isInPeriodFromInclusiveToExclusive(disbursementDate, period.getFromDate(), period.getDueDate())) + .findFirst(); + final LocalDate firstDisbursedPeriodStartDate = firstDisbursedPeriod.isPresent() ? firstDisbursedPeriod.get().getFromDate() + : scheduleModel.getMaturityDate(); + + final LoanApplicationTerms loanApplicationTerms = buildLoanApplicationTerms(loanProductRelatedDetail, firstDisbursedPeriodStartDate, + disbursedAmount, mc); + + final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel = generateTemporaryScheduleModel(loanApplicationTerms, mc, + firstDisbursedPeriodStartDate, disbursementDate); + + mergeNewScheduleModelWithExistingOne(scheduleModel, temporaryReAgedScheduleModel, operation); + } + + private LoanApplicationTerms buildLoanApplicationTerms(final ILoanConfigurationDetails loanProductRelatedDetail, + final LocalDate firstDisbursedPeriodStartDate, final Money disbursedAmount, final MathContext mc) { + return new LoanApplicationTerms.Builder() + // Loan basics + .currency(loanProductRelatedDetail.getCurrencyData()).principal(disbursedAmount) + .repaymentsStartingFromDate(firstDisbursedPeriodStartDate).downPaymentPercentage(ZERO) + .seedDate(firstDisbursedPeriodStartDate).inArrearsTolerance(Money.zero(loanProductRelatedDetail.getCurrencyData())) + // Term & frequency + .loanTermFrequency(loanProductRelatedDetail.getNumberOfRepayments()) + .loanTermPeriodFrequencyType(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()) + .numberOfRepayments(loanProductRelatedDetail.getNumberOfRepayments()) + .repaymentEvery(loanProductRelatedDetail.getRepayEvery()) + .repaymentPeriodFrequencyType(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()) + // Interest configuration + .interestRatePerPeriod(loanProductRelatedDetail.getAnnualNominalInterestRate()) + .annualNominalInterestRate(loanProductRelatedDetail.getAnnualNominalInterestRate()) + .interestRatePeriodFrequencyType(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()) + .interestMethod(loanProductRelatedDetail.getInterestMethod()) + .interestRecognitionOnDisbursementDate(loanProductRelatedDetail.isInterestRecognitionOnDisbursementDate()) + // Day count conventions + .daysInMonthType(DaysInMonthType.fromInt(loanProductRelatedDetail.getDaysInMonthType())) + .daysInYearType(DaysInYearType.fromInt(loanProductRelatedDetail.getDaysInYearType())) + .daysInYearCustomStrategy(loanProductRelatedDetail.getDaysInYearCustomStrategy()) + // Flags & options + .isDownPaymentEnabled(false) + .allowPartialPeriodInterestCalculation(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()) + // Technical + .mc(mc).build(); + } + + private void mergeNewScheduleModelWithExistingOne(final ProgressiveLoanInterestScheduleModel scheduleModel, + final ProgressiveLoanInterestScheduleModel temporaryScheduleModel, final EmiChangeOperation operation) { + final List newPeriods = temporaryScheduleModel.repaymentPeriods(); + + if (newPeriods.isEmpty()) { + return; + } + + final List existingRepaymentPeriods = scheduleModel.repaymentPeriods(); + + scheduleModel.changeOutstandingBalanceAndUpdateInterestPeriods(operation.getSubmittedOnDate(), operation.getAmount(), + scheduleModel.zero(), scheduleModel.zero()); + + for (final RepaymentPeriod newPeriod : newPeriods) { + final Money newPrincipal = newPeriod.getDuePrincipal(); + final Money newInterest = newPeriod.getDueInterest(); + + final Optional existingRepaymentPeriod = existingRepaymentPeriods.stream() + .filter(ep -> ep.getFromDate().isEqual(newPeriod.getFromDate()) && ep.getDueDate().equals(newPeriod.getDueDate())) + .findFirst(); + + if (existingRepaymentPeriod.isPresent()) { + existingRepaymentPeriod.get().setEmi(existingRepaymentPeriod.get().getEmi().add(newPrincipal.add(newInterest))); + existingRepaymentPeriod.get().setOriginalEmi(existingRepaymentPeriod.get().getEmi()); + calculateRateFactorForRepaymentPeriod(existingRepaymentPeriod.get(), scheduleModel); + } else { + final RepaymentPeriod rp = RepaymentPeriod.create( + !existingRepaymentPeriods.isEmpty() ? existingRepaymentPeriods.getLast() : null, + (newPeriod.equals(newPeriods.getFirst()) && !existingRepaymentPeriods.isEmpty()) + ? existingRepaymentPeriods.getLast().getDueDate() + : newPeriod.getFromDate(), + newPeriod.getDueDate(), newPrincipal.add(newInterest), scheduleModel.mc(), + scheduleModel.loanProductRelatedDetail()); + rp.setTotalDisbursedAmount(scheduleModel.getLastRepaymentPeriod().getTotalDisbursedAmount()); + + existingRepaymentPeriods.add(rp); + calculateRateFactorForRepaymentPeriod(rp, scheduleModel); + } + } + + calculateOutstandingBalance(scheduleModel); + calculateLastUnpaidRepaymentPeriodEMI(scheduleModel, operation.getSubmittedOnDate()); } private LocalDate getEffectiveRepaymentDueDate(final ProgressiveLoanInterestScheduleModel scheduleModel, @@ -590,10 +693,10 @@ public void updateModelRepaymentPeriodsDuringReAge(final ProgressiveLoanInterest moveOutstandingAmountsFromPeriodsBeforeTransactionDate(scheduleModel.repaymentPeriods(), targetDate); - final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel = generateTemporaryReAgedScheduleModel(loanApplicationTerms, - mc, reAgePeriodStartDate); + final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel = generateTemporaryScheduleModel(loanApplicationTerms, mc, + reAgePeriodStartDate, reAgePeriodStartDate); - mergeNewInterestScheduleModelWithExistingOne(scheduleModel, temporaryReAgedScheduleModel, reAgeFirstDueDate, targetDate, + attachTemporaryScheduleModelReAgedPeriodsToExistingModel(scheduleModel, temporaryReAgedScheduleModel, reAgeFirstDueDate, targetDate, paidBalancesFromTransactionDate); } @@ -853,11 +956,11 @@ private boolean shouldRecalculateTillInstallmentDueDate(final ILoanConfiguration } /** - * * Merging the new temporary model of re-aged repayment periods and existing one together. After that recalculate - * the balances of the updated model and also recalculate the EMI if the EMI of the last repayment period differs - * significantly from other periods. + * * Attaching re-aged repayment periods of the new temporary model to existing model repayment periods. After that + * recalculate the balances of the updated model and also recalculate the EMI if the EMI of the last repayment + * period differs significantly from other periods. */ - private void mergeNewInterestScheduleModelWithExistingOne(final ProgressiveLoanInterestScheduleModel scheduleModel, + private void attachTemporaryScheduleModelReAgedPeriodsToExistingModel(final ProgressiveLoanInterestScheduleModel scheduleModel, final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel, final LocalDate reAgeFirstDueDate, final LocalDate targetDate, final OutstandingDetails paidBalancesFromTransactionDate) { final List newPeriods = temporaryReAgedScheduleModel.repaymentPeriods(); @@ -914,18 +1017,18 @@ private void mergeNewInterestScheduleModelWithExistingOne(final ProgressiveLoanI } /** - * * Generates temporary interestScheduleModel with re-aged repayment periods + * * Generates temporary interestScheduleModel with particular disbursement date */ @NotNull - private ProgressiveLoanInterestScheduleModel generateTemporaryReAgedScheduleModel(final LoanApplicationTerms loanApplicationTerms, - final MathContext mc, final LocalDate periodStartDate) { + private ProgressiveLoanInterestScheduleModel generateTemporaryScheduleModel(final LoanApplicationTerms loanApplicationTerms, + final MathContext mc, final LocalDate periodStartDate, final LocalDate disbursementDate) { final List expectedRepaymentPeriods = scheduledDateGenerator.generateRepaymentPeriods(mc, periodStartDate, loanApplicationTerms, null); final ProgressiveLoanInterestScheduleModel temporaryReAgedScheduleModel = generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanApplicationTerms.toLoanConfigurationDetails(), loanApplicationTerms.getInstallmentAmountInMultiplesOf(), mc); - addDisbursement(temporaryReAgedScheduleModel, EmiChangeOperation.disburse(periodStartDate, loanApplicationTerms.getPrincipal())); + addDisbursement(temporaryReAgedScheduleModel, EmiChangeOperation.disburse(disbursementDate, loanApplicationTerms.getPrincipal())); return temporaryReAgedScheduleModel; } diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGeneratorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGeneratorTest.java index e192fe5fe1e..06d86fe6ad1 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGeneratorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleGeneratorTest.java @@ -65,7 +65,7 @@ void testGenerateLoanSchedule() { LoanRepaymentScheduleModelData modelData = new LoanRepaymentScheduleModelData(LocalDate.of(2024, 1, 1), CURRENCY, DISBURSEMENT_AMOUNT, DISBURSEMENT_DATE, NUMBER_OF_REPAYMENTS, REPAYMENT_FREQUENCY, REPAYMENT_FREQUENCY_TYPE, NOMINAL_INTEREST_RATE, false, DaysInMonthType.DAYS_30, DaysInYearType.DAYS_360, null, null, null, false, null, - InterestMethod.DECLINING_BALANCE, true); + InterestMethod.DECLINING_BALANCE, true, false); ScheduledDateGenerator scheduledDateGenerator = new DefaultScheduledDateGenerator(); ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(scheduledDateGenerator, emiCalculator, @@ -103,7 +103,7 @@ void testGenerateLoanScheduleWithDownPayment() { LoanRepaymentScheduleModelData modelData = new LoanRepaymentScheduleModelData(LocalDate.of(2024, 1, 1), CURRENCY, DISBURSEMENT_AMOUNT_100, LocalDate.of(2024, 1, 1), NUMBER_OF_REPAYMENTS, REPAYMENT_FREQUENCY, REPAYMENT_FREQUENCY_TYPE, NOMINAL_INTEREST_RATE, true, DaysInMonthType.DAYS_30, DaysInYearType.DAYS_360, DOWN_PAYMENT_PORTION, null, null, false, - null, InterestMethod.DECLINING_BALANCE, true); + null, InterestMethod.DECLINING_BALANCE, true, false); ScheduledDateGenerator scheduledDateGenerator = new DefaultScheduledDateGenerator(); ProgressiveLoanScheduleGenerator generator = new ProgressiveLoanScheduleGenerator(scheduledDateGenerator, emiCalculator, diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java index c67c5a8bd0f..9fb3cfc26ad 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/calc/ProgressiveEMICalculatorTest.java @@ -235,6 +235,7 @@ public void test_emi_calculator_performance() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -582,6 +583,7 @@ public void test_balance_correction_on0215_disbursedAmt100_dayInYears360_daysInM Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); threadLocalContextUtil.when(ThreadLocalContextUtil::getBusinessDate).thenReturn(LocalDate.of(2024, 2, 15)); @@ -1348,6 +1350,7 @@ public void test_dailyInterest_chargeback_disbursedAmt1000_dayInYears360_daysInM Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestModel = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -2476,6 +2479,7 @@ public void test_S1_full_chargeback_on_due_date_before_maturity_date() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -2533,6 +2537,7 @@ public void test_S2_S3_partial_and_full_chargeback_on_due_date_before_maturity_d Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -2601,6 +2606,7 @@ public void test_S4_full_chargeback_in_middle_of_instalment_before_maturity_date Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -2953,6 +2959,7 @@ public void test_leap_year_only_actual_for_loan_S1() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -2991,6 +2998,7 @@ public void test_leap_year_only_actual_for_loan_S2() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -3028,6 +3036,7 @@ public void test_leap_year_only_actual_for_loan_S3() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -3068,6 +3077,7 @@ public void test_leap_year_only_actual_for_loan_S4() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -3107,6 +3117,7 @@ public void test_leap_year_only_actual_for_loan_S5() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(2); Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -3143,6 +3154,7 @@ public void test_leap_year_only_actual_no_effect_on_360_loan() { Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); Mockito.when(loanProductRelatedDetail.getDaysInYearCustomStrategy()) .thenReturn(DaysInYearCustomStrategyType.FEB_29_PERIOD_ONLY); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -3363,6 +3375,7 @@ public void testFlatDaily_1_Month_360_30() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(4); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue()); @@ -3402,6 +3415,7 @@ public void testFlatDaily_1_week_Actual_Actual() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.WEEKS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(4); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue()); Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); @@ -3441,6 +3455,7 @@ public void testFlatDaily_30_Days_Actual_Actual() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.DAYS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(4); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(30); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.ACTUAL.getValue()); Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.ACTUAL.getValue()); @@ -3530,6 +3545,7 @@ void test_sameAsRepayment_week_repay_every_1_periods_10() { Mockito.when(loanProductRelatedDetail.getInterestCalculationPeriodMethod()) .thenReturn(InterestCalculationPeriodMethod.SAME_AS_REPAYMENT_PERIOD); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); @@ -3558,6 +3574,7 @@ void test_sameAsRepayment_month_repay_every_2_periods_8() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(8); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(2); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final List expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate); final Integer installmentAmountInMultiplesOf = null; @@ -3587,6 +3604,7 @@ void test_sameAsRepayment_month_repay_every_1_periods_3_second_disbursement_on_r Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true); @@ -3620,6 +3638,7 @@ void test_sameAsRepayment_month_repay_every_1_periods_3_second_disbursement_on_r Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(false); @@ -3653,6 +3672,7 @@ void test_sameAsRepayment_month_repay_every_1_periods_3() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final List expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate); @@ -3691,6 +3711,7 @@ void test_sameAsRepayment_month_repay_every_1_periods_20() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(20); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final List expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate); final Integer installmentAmountInMultiplesOf = null; @@ -3732,6 +3753,7 @@ void test_sameAsRepayment_month_repay_every_1_periods_24() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(24); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final List expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate); final Integer installmentAmountInMultiplesOf = null; @@ -3806,6 +3828,7 @@ void test_sameAsRepayment_month_repay_every_1_periods_3__not_calculate_exact_day Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(false); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); final List expectedRepaymentPeriods = generateExpectedRepaymentPeriods(disbursementDate); @@ -3855,6 +3878,7 @@ void test_3_of_1_month_period_multi_disbursement__exact_days() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true); @@ -3892,6 +3916,7 @@ void test_3_of_1_month_period_multi_disbursement__exact_days__on_period_due_date Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(true); @@ -3928,6 +3953,7 @@ void test_3_of_1_month_period_multi_disbursement__NOT_exact_days__different_days Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(false); @@ -3986,6 +4012,7 @@ void test_3_of_1_month_period_multi_disbursement__NOT_exact_days() { Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(3); Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(false); Mockito.when(loanProductRelatedDetail.isAllowPartialPeriodInterestCalculation()).thenReturn(false); @@ -5152,4 +5179,72 @@ private ProgressiveLoanInterestScheduleModel copyJson(ProgressiveLoanInterestSch return interestScheduleModelService.fromJson(json, toCopy.loanProductRelatedDetail(), toCopy.mc(), toCopy.installmentAmountInMultiplesOf()); } + + @Test + public void test_fullTermTranche_disbursedAmt200_2ndOnDueDate_dayInYears360_daysInMonth30_repayEvery1Month() { + // Create 7 periods (6 original + 1 extension for second tranche) + final List expectedRepaymentPeriods = new ArrayList<>(); + + expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1))); + expectedRepaymentPeriods.add(repayment(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1))); + expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1))); + expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1))); + expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1))); + expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1))); + expectedRepaymentPeriods.add(repayment(7, LocalDate.of(2024, 7, 1), LocalDate.of(2024, 8, 1))); + + final BigDecimal interestRate = BigDecimal.valueOf(9.4822); + final Integer installmentAmountInMultiplesOf = null; + + Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate); + Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue()); + Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue()); + Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS); + Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1); + Mockito.when(loanProductRelatedDetail.getCurrencyData()).thenReturn(currency); + Mockito.when(loanProductRelatedDetail.getNumberOfRepayments()).thenReturn(6); + // Enable full term tranche feature + Mockito.when(loanProductRelatedDetail.isAllowFullTermForTranche()).thenReturn(true); + + final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generatePeriodInterestScheduleModel( + expectedRepaymentPeriods, loanProductRelatedDetail, installmentAmountInMultiplesOf, mc); + + // First disbursement: 100 on Jan 1 -> should set EMI ~17.13 on periods 0-5 + final Money disbursedAmount1 = toMoney(100.0); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount1); + + // Check EMI after first disbursement - periods 0-5 should have ~17.13 + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(0).getEmi()), 0.01, + "Period 0 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(1).getEmi()), 0.01, + "Period 1 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(2).getEmi()), 0.01, + "Period 2 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(3).getEmi()), 0.01, + "Period 3 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(4).getEmi()), 0.01, + "Period 4 EMI after first disbursement"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(5).getEmi()), 0.01, + "Period 5 EMI after first disbursement"); + Assertions.assertEquals(0.0, toDouble(interestSchedule.repaymentPeriods().get(6).getEmi()), 0.01, + "Period 6 EMI after first disbursement (should be 0)"); + + // Second disbursement: 100 on Feb 1 -> should ADD EMI ~17.13 to periods 1-6 + final Money disbursedAmount2 = toMoney(100.0); + emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 2, 1), disbursedAmount2); + + // Verify EMI values: + // Period 0: EMI = 17.13 (only first tranche) + // Periods 1-5: EMI = 34.26 (both tranches) + // Period 6: EMI = 17.13 (only second tranche) + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(0).getEmi()), 0.01, + "Period 0 EMI (single tranche)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(1).getEmi()), 0.01, "Period 1 EMI (aggregated)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(2).getEmi()), 0.01, "Period 2 EMI (aggregated)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(3).getEmi()), 0.01, "Period 3 EMI (aggregated)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(4).getEmi()), 0.01, "Period 4 EMI (aggregated)"); + Assertions.assertEquals(34.26, toDouble(interestSchedule.repaymentPeriods().get(5).getEmi()), 0.01, "Period 5 EMI (aggregated)"); + Assertions.assertEquals(17.13, toDouble(interestSchedule.repaymentPeriods().get(6).getEmi()), 0.01, + "Period 6 EMI (single tranche)"); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java index 01d0e2eddee..306230d6825 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResource.java @@ -527,7 +527,8 @@ public String retrieveAll(@Context final UriInfo uriInfo, RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData = new RepaymentScheduleRelatedLoanData( i.getTimeline().getExpectedDisbursementDate(), i.getTimeline().getActualDisbursementDate(), i.getCurrency(), - i.getPrincipal(), i.getInArrearsTolerance(), i.getFeeChargesAtDisbursementCharged()); + i.getPrincipal(), i.getInArrearsTolerance(), i.getFeeChargesAtDisbursementCharged(), + Boolean.TRUE.equals(i.getAllowFullTermForTranche())); LoanScheduleData repaymentSchedule = loanReadPlatformService.retrieveRepaymentSchedule(loanId, repaymentScheduleRelatedData, disbursementData, capitalizedIncomeData, i.isInterestRecalculationEnabled(), LoanScheduleType.fromEnumOptionData(i.getLoanScheduleType())); @@ -1098,7 +1099,8 @@ private String retrieveLoan(final Long loanId, final String loanExternalIdStr, b loanBasicDetails.getTimeline().getExpectedDisbursementDate(), loanBasicDetails.getTimeline().getActualDisbursementDate(), loanBasicDetails.getCurrency(), loanBasicDetails.getPrincipal(), loanBasicDetails.getInArrearsTolerance(), - loanBasicDetails.getFeeChargesAtDisbursementCharged()); + loanBasicDetails.getFeeChargesAtDisbursementCharged(), + Boolean.TRUE.equals(loanBasicDetails.getAllowFullTermForTranche())); repaymentSchedule = this.loanReadPlatformService.retrieveRepaymentSchedule(resolvedLoanId, repaymentScheduleRelatedData, disbursementData, capitalizedIncomeData, loanBasicDetails.isInterestRecalculationEnabled(), LoanScheduleType.fromEnumOptionData(loanBasicDetails.getLoanScheduleType())); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java index 768a38a64be..d07d48b59b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/service/LoanScheduleAssembler.java @@ -533,6 +533,11 @@ private LoanApplicationTerms assembleLoanApplicationTermsFrom(final JsonElement .extractBooleanNamed(LoanApiConstants.INTEREST_RECOGNITION_ON_DISBURSEMENT_DATE, element); } + boolean allowFullTermForTranche = loanProduct.isAllowFullTermForTranche(); + if (this.fromApiJsonHelper.parameterExists(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE, element)) { + allowFullTermForTranche = this.fromApiJsonHelper.extractBooleanNamed(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE, element); + } + return LoanApplicationTerms.assembleFrom(applicationCurrency.toData(), loanTermFrequency, loanTermPeriodFrequencyType, numberOfRepayments, repaymentEvery, repaymentPeriodFrequencyType, nthDay, weekDayType, amortizationMethod, interestMethod, interestRatePerPeriod, interestRatePeriodFrequencyType, annualNominalInterestRate, interestCalculationPeriodMethod, @@ -561,7 +566,7 @@ private LoanApplicationTerms assembleLoanApplicationTermsFrom(final JsonElement loanProduct.getLoanProductRelatedDetail().getBuyDownFeeCalculationType(), loanProduct.getLoanProductRelatedDetail().getBuyDownFeeStrategy(), loanProduct.getLoanProductRelatedDetail().getBuyDownFeeIncomeType(), - loanProduct.getLoanProductRelatedDetail().isMerchantBuyDownFee()); + loanProduct.getLoanProductRelatedDetail().isMerchantBuyDownFee(), allowFullTermForTranche); } private CalendarInstance createCalendarForSameAsRepayment(final Integer repaymentEvery, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java index 7003b0b73e5..669206db30a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanReadPlatformServiceImpl.java @@ -259,7 +259,7 @@ public LoanAccountData fetchRepaymentScheduleData(LoanAccountData accountData) { final RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData = new RepaymentScheduleRelatedLoanData( accountData.getTimeline().getExpectedDisbursementDate(), accountData.getTimeline().getActualDisbursementDate(), accountData.getCurrency(), accountData.getPrincipal(), accountData.getInArrearsTolerance(), - accountData.getFeeChargesAtDisbursementCharged()); + accountData.getFeeChargesAtDisbursementCharged(), Boolean.TRUE.equals(accountData.getAllowFullTermForTranche())); final Collection disbursementData = retrieveLoanDisbursementDetails(accountData.getId()); List capitalizedIncomeData = loanCapitalizedIncomeBalanceRepository diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java index f929fdc8b36..c5fb2085b68 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/DefaultScheduledDateGeneratorTest.java @@ -91,7 +91,7 @@ public void test_generateRepaymentPeriods() { DaysInMonthType.ACTUAL, DaysInYearType.ACTUAL, false, null, null, null, null, null, ZERO, null, NONE, null, ZERO, EMPTY_LIST, true, 0, false, holidayDetailDTO, false, false, false, null, false, false, null, false, DISBURSEMENT_DATE, submittedOnDate, CUMULATIVE, LoanScheduleProcessingType.HORIZONTAL, null, false, null, null, false, null, false, null, null, - null, false, null, null, null, false); + null, false, null, null, null, false, false); // when List result = underTest.generateRepaymentPeriods(mathContext, expectedDisbursementDate, @@ -172,7 +172,7 @@ private LoanApplicationTerms createLoanApplicationTerms(LocalDate dueRepaymentPe EMPTY_LIST, BigDecimal.valueOf(36_000L), null, DaysInMonthType.ACTUAL, DaysInYearType.ACTUAL, false, null, null, null, null, null, ZERO, null, NONE, null, ZERO, EMPTY_LIST, true, 0, false, holidayDetailDTO, false, false, false, null, false, false, null, false, DISBURSEMENT_DATE, submittedOnDate, CUMULATIVE, LoanScheduleProcessingType.HORIZONTAL, null, false, null, null, - false, null, false, null, null, null, false, null, null, null, false); + false, null, false, null, null, null, false, null, null, null, false, false); } private HolidayDetailDTO createHolidayDTO() { diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java index 6d683485b8d..291fb332e07 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanDisbursementDetailsIntegrationTest.java @@ -880,4 +880,258 @@ public void testLoanLevelOverrideOfAllowFullTermForTranche() { assertEquals(false, loanDetails.getAllowFullTermForTranche()); log.info("-------------------LOAN LEVEL OVERRIDE OF allowFullTermForTranche WORKED SUCCESSFULLY-------"); } + + @Test + public void testFullTermTranche_S1_DisbursementOnInstallmentDate() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withinterestRatePerPeriod("9.4822").withInterestRateFrequencyTypeAsYear().withMultiDisburse() + .withLoanScheduleType(LoanScheduleType.PROGRESSIVE).addAdvancedPaymentAllocation(defaultAllocation) + .withAllowFullTermForTranche(true).withDaysInYear("360").withMinPrincipal("100").build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2024"); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 January 2024", "100")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 February 2024", "100")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("200").withLoanTermFrequency("6") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("6").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("9.4822").withExpectedDisbursementDate("01 January 2024") + .withTranches(createTranches).withSubmittedOnDate("01 January 2024") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + this.loanTransactionHelper.approveLoanWithApproveAmount("01 January 2024", "01 January 2024", "200", loanId, createTranches); + log.info("-------------------LOAN APPROVED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 January 2024", loanId, "100"); + log.info("-------------------FIRST TRANCHE DISBURSED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 February 2024", loanId, "100"); + log.info("-------------------SECOND TRANCHE DISBURSED-------"); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(loanDetails); + + GetLoansLoanIdRepaymentSchedule schedule = loanDetails.getRepaymentSchedule(); + assertNotNull(schedule); + + List periods = schedule.getPeriods(); + assertNotNull(periods); + assertEquals(9, periods.size(), "Total periods should be 9 (2 disbursements + 7 repayment periods)"); + + // Count disbursement periods (no period number) and repayment periods (with period number) + long disbursementPeriods = periods.stream().filter(p -> p.getPeriod() == null).count(); + long repaymentPeriods = periods.stream().filter(p -> p.getPeriod() != null).count(); + assertEquals(2, disbursementPeriods, "Should have 2 disbursement periods"); + assertEquals(7, repaymentPeriods, "Should have 7 repayment periods"); + + log.info("-------------------S1 TEST: SCHEDULE VALIDATION-------"); + log.info("Schedule structure validated: 2 disbursement + 7 repayment periods"); + + // Close the loan to allow LoanTestLifecycleExtension cleanup to succeed + closeFullTermTrancheLoan(loanId, "01 August 2024"); + } + + @Test + public void testFullTermTranche_S2_MidPeriodDisbursement() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withinterestRatePerPeriod("9.4822").withInterestRateFrequencyTypeAsYear().withMultiDisburse() + .withLoanScheduleType(LoanScheduleType.PROGRESSIVE).addAdvancedPaymentAllocation(defaultAllocation) + .withAllowFullTermForTranche(true).withDaysInYear("360").withMinPrincipal("100").build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2024"); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 January 2024", "100")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "15 February 2024", "100")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("200").withLoanTermFrequency("6") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("6").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("9.4822").withExpectedDisbursementDate("01 January 2024") + .withTranches(createTranches).withSubmittedOnDate("01 January 2024") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + this.loanTransactionHelper.approveLoanWithApproveAmount("01 January 2024", "01 January 2024", "200", loanId, createTranches); + log.info("-------------------LOAN APPROVED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 January 2024", loanId, "100"); + log.info("-------------------FIRST TRANCHE DISBURSED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("15 February 2024", loanId, "100"); + log.info("-------------------SECOND TRANCHE DISBURSED (MID-PERIOD)-------"); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(loanDetails); + + GetLoansLoanIdRepaymentSchedule schedule = loanDetails.getRepaymentSchedule(); + assertNotNull(schedule); + + List periods = schedule.getPeriods(); + assertNotNull(periods); + assertEquals(9, periods.size(), "Total periods should be 9 (2 disbursements + 7 repayment periods)"); + + // Count disbursement periods (no period number) and repayment periods (with period number) + long disbursementPeriods = periods.stream().filter(p -> p.getPeriod() == null).count(); + long repaymentPeriods = periods.stream().filter(p -> p.getPeriod() != null).count(); + assertEquals(2, disbursementPeriods, "Should have 2 disbursement periods"); + assertEquals(7, repaymentPeriods, "Should have 7 repayment periods"); + + log.info("-------------------S2 TEST: SCHEDULE VALIDATION-------"); + log.info("Schedule structure validated: 2 disbursement + 7 repayment periods (mid-period disbursement)"); + + // Close the loan to allow LoanTestLifecycleExtension cleanup to succeed + closeFullTermTrancheLoan(loanId, "01 August 2024"); + } + + @Test + public void testFullTermTranche_S3_BothBeforeFirstRepayment() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withinterestRatePerPeriod("9.4822").withInterestRateFrequencyTypeAsYear().withMultiDisburse() + .withLoanScheduleType(LoanScheduleType.PROGRESSIVE).addAdvancedPaymentAllocation(defaultAllocation) + .withAllowFullTermForTranche(true).withDaysInYear("360").withMinPrincipal("100").build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2024"); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 January 2024", "100")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "15 January 2024", "100")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("200").withLoanTermFrequency("6") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("6").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("9.4822").withExpectedDisbursementDate("01 January 2024") + .withTranches(createTranches).withSubmittedOnDate("01 January 2024") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + this.loanTransactionHelper.approveLoanWithApproveAmount("01 January 2024", "01 January 2024", "200", loanId, createTranches); + log.info("-------------------LOAN APPROVED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 January 2024", loanId, "100"); + log.info("-------------------FIRST TRANCHE DISBURSED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("15 January 2024", loanId, "100"); + log.info("-------------------SECOND TRANCHE DISBURSED (BEFORE FIRST REPAYMENT)-------"); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(loanDetails); + + GetLoansLoanIdRepaymentSchedule schedule = loanDetails.getRepaymentSchedule(); + assertNotNull(schedule); + + List periods = schedule.getPeriods(); + assertNotNull(periods); + assertEquals(8, periods.size(), "Total periods should be 8 (2 disbursements + 6 repayment periods - NO EXTENSION)"); + + // Count disbursement periods (no period number) and repayment periods (with period number) + long disbursementPeriods = periods.stream().filter(p -> p.getPeriod() == null).count(); + long repaymentPeriods = periods.stream().filter(p -> p.getPeriod() != null).count(); + assertEquals(2, disbursementPeriods, "Should have 2 disbursement periods"); + assertEquals(6, repaymentPeriods, "Should have 6 repayment periods (no term extension)"); + + log.info("-------------------S3 TEST: SCHEDULE VALIDATION-------"); + log.info("Schedule structure validated: 2 disbursement + 6 repayment periods (no term extension)"); + log.info("Both disbursements before first repayment date result in same maturity date"); + + // Close the loan to allow LoanTestLifecycleExtension cleanup to succeed + closeFullTermTrancheLoan(loanId, "01 July 2024"); + } + + @Test + public void testFullTermTrancheBackwardCompatibility() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductWithoutFlag = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withinterestRatePerPeriod("9.4822").withInterestRateFrequencyTypeAsYear().withMultiDisburse() + .withLoanScheduleType(LoanScheduleType.PROGRESSIVE).addAdvancedPaymentAllocation(defaultAllocation) + .withAllowFullTermForTranche(false).withDaysInYear("360").withMinPrincipal("100").build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductWithoutFlag); + log.info("------------------LOAN PRODUCT CREATED WITH allowFullTermForTranche=false ID----------- {}", loanProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2024"); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 January 2024", "100")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 February 2024", "100")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("200").withLoanTermFrequency("6") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("6").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("9.4822").withExpectedDisbursementDate("01 January 2024") + .withTranches(createTranches).withSubmittedOnDate("01 January 2024") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + this.loanTransactionHelper.approveLoanWithApproveAmount("01 January 2024", "01 January 2024", "200", loanId, createTranches); + log.info("-------------------LOAN APPROVED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 January 2024", loanId, "100"); + log.info("-------------------FIRST TRANCHE DISBURSED-------"); + + loanTransactionHelper.disburseLoanWithTransactionAmount("01 February 2024", loanId, "100"); + log.info("-------------------SECOND TRANCHE DISBURSED-------"); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + assertNotNull(loanDetails); + + GetLoansLoanIdRepaymentSchedule schedule = loanDetails.getRepaymentSchedule(); + assertNotNull(schedule); + + List periods = schedule.getPeriods(); + assertNotNull(periods); + + log.info("-------------------BACKWARD COMPATIBILITY TEST: SCHEDULE VALIDATION-------"); + log.info("Expected: OLD behavior when allowFullTermForTranche=false"); + log.info("Schedule should NOT use full term tranche logic - should match existing multi-disburse behavior"); + } + + /** + * Helper method to close a loan by making a full prepayment. This ensures the loan is closed before the + * LoanTestLifecycleExtension cleanup runs. + */ + private void closeFullTermTrancheLoan(Integer loanId, String lastRepaymentDate) { + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId); + BigDecimal outstandingAmount = loanDetails.getSummary().getTotalOutstanding(); + + if (outstandingAmount != null && outstandingAmount.compareTo(BigDecimal.ZERO) > 0) { + log.info("-------------------CLOSING LOAN {} WITH PREPAYMENT OF {} ON {}-------", loanId, outstandingAmount, lastRepaymentDate); + this.loanTransactionHelper.makeLoanRepayment(lastRepaymentDate, outstandingAmount.floatValue(), loanId); + } + } }