From a3fdfb920d8857d10a6255746aee26d784963bff Mon Sep 17 00:00:00 2001 From: Attila Budai Date: Mon, 1 Dec 2025 15:44:42 +0100 Subject: [PATCH] FINERACT-2312: full term configuration added --- .../loanaccount/api/LoanApiConstants.java | 2 + .../loanaccount/data/LoanAccountData.java | 16 +- .../portfolio/loanaccount/domain/Loan.java | 26 ++- .../loanproduct/LoanProductConstants.java | 1 + .../api/LoanProductsApiResourceSwagger.java | 6 + .../loanproduct/data/LoanProductData.java | 133 +++++++------ .../loanproduct/domain/LoanProduct.java | 9 +- .../domain/LoanProductTrancheDetails.java | 6 +- .../api/LoansApiResourceSwagger.java | 8 + .../LoanApplicationValidator.java | 27 ++- .../service/LoanAssemblerImpl.java | 20 +- .../service/LoanProductAssembler.java | 5 +- .../LoanProductTrancheDetailsUpdateUtil.java | 9 + .../service/LoanReadPlatformServiceImpl.java | 5 +- .../LoanProductDataValidator.java | 40 +++- .../LoanProductReadPlatformServiceImpl.java | 22 ++- .../db/changelog/tenant/changelog-tenant.xml | 1 + .../0207_add_allow_full_term_for_tranche.xml | 39 ++++ .../loanaccount/domain/LoanBuilder.java | 8 +- .../LoanProductValidationStepDefinitions.java | 2 +- ...oanDisbursementDetailsIntegrationTest.java | 187 ++++++++++++++++++ .../loans/LoanApplicationTestBuilder.java | 9 + .../common/loans/LoanProductTestBuilder.java | 11 ++ 23 files changed, 492 insertions(+), 100 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0207_add_allow_full_term_for_tranche.xml diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanApiConstants.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanApiConstants.java index fa651a64c..ace70a9b5 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanApiConstants.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanApiConstants.java @@ -195,6 +195,8 @@ public interface LoanApiConstants { String INTEREST_RECOGNITION_ON_DISBURSEMENT_DATE = "interestRecognitionOnDisbursementDate"; + String ALLOW_FULL_TERM_FOR_TRANCHE = "allowFullTermForTranche"; + // Loan Summary Transaction Types List LOAN_SUMMARY_TRANSACTION_TYPES = List.of(LoanTransactionType.CHARGE_ADJUSTMENT, // LoanTransactionType.CHARGEBACK, // diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanAccountData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanAccountData.java index bca5e59bd..d97a52b9d 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanAccountData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanAccountData.java @@ -269,6 +269,7 @@ public class LoanAccountData { private Boolean enableInstallmentLevelDelinquency; private LocalDate lastClosedBusinessDate; private Boolean chargedOff; + private Boolean allowFullTermForTranche; private Boolean enableDownPayment; private BigDecimal disbursedAmountPercentageForDownPayment; @@ -480,12 +481,12 @@ public class LoanAccountData { final BigDecimal disbursedAmountPercentageForDownPayment, final boolean enableAutoRepaymentForDownPayment, final boolean enableInstallmentLevelDelinquency, final EnumOptionData loanScheduleType, final EnumOptionData loanScheduleProcessingType, final Integer fixedLength, final StringEnumOptionData chargeOffBehaviour, - final boolean isInterestRecognitionOnDisbursementDate, final StringEnumOptionData daysInYearCustomStrategy, - final boolean enableIncomeCapitalization, final StringEnumOptionData capitalizedIncomeCalculationType, - final StringEnumOptionData capitalizedIncomeStrategy, StringEnumOptionData capitalizedIncomeType, - final boolean enableBuyDownFee, final StringEnumOptionData buyDownFeeCalculationType, - final StringEnumOptionData buyDownFeeStrategy, final StringEnumOptionData buyDownFeeIncomeType, - final boolean merchantBuyDownFee) { + final boolean isInterestRecognitionOnDisbursementDate, final boolean allowFullTermForTranche, + final StringEnumOptionData daysInYearCustomStrategy, final boolean enableIncomeCapitalization, + final StringEnumOptionData capitalizedIncomeCalculationType, final StringEnumOptionData capitalizedIncomeStrategy, + StringEnumOptionData capitalizedIncomeType, final boolean enableBuyDownFee, + final StringEnumOptionData buyDownFeeCalculationType, final StringEnumOptionData buyDownFeeStrategy, + final StringEnumOptionData buyDownFeeIncomeType, final boolean merchantBuyDownFee) { final CollectionData delinquent = CollectionData.template(); @@ -531,7 +532,8 @@ public class LoanAccountData { .setEnableInstallmentLevelDelinquency(enableInstallmentLevelDelinquency).setLoanScheduleType(loanScheduleType) .setLoanScheduleProcessingType(loanScheduleProcessingType).setFixedLength(fixedLength) .setChargeOffBehaviour(chargeOffBehaviour).setInterestRecognitionOnDisbursementDate(isInterestRecognitionOnDisbursementDate) - .setDaysInYearCustomStrategy(daysInYearCustomStrategy).setEnableIncomeCapitalization(enableIncomeCapitalization) + .setAllowFullTermForTranche(allowFullTermForTranche).setDaysInYearCustomStrategy(daysInYearCustomStrategy) + .setEnableIncomeCapitalization(enableIncomeCapitalization) .setCapitalizedIncomeCalculationType(capitalizedIncomeCalculationType) .setCapitalizedIncomeStrategy(capitalizedIncomeStrategy).setCapitalizedIncomeType(capitalizedIncomeType) .setEnableBuyDownFee(enableBuyDownFee).setBuyDownFeeCalculationType(buyDownFeeCalculationType) 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 601b8c6bb..d8bfda8bc 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,9 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "enable_installment_level_delinquency", nullable = false) private boolean enableInstallmentLevelDelinquency = false; + @Column(name = "allow_full_term_for_tranche", nullable = false) + private boolean allowFullTermForTranche = false; + public static Loan newIndividualLoanApplication(final String accountNo, final Client client, final AccountType loanType, final LoanProduct loanProduct, final Fund fund, final Staff officer, final CodeValue loanPurpose, final LoanRepaymentScheduleTransactionProcessor transactionProcessingStrategy, @@ -436,12 +439,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { final Boolean createStandingInstructionAtDisbursement, final Boolean isFloatingInterestRate, final BigDecimal interestRateDifferential, final List rates, final BigDecimal fixedPrincipalPercentagePerInstallment, final ExternalId externalId, final LoanApplicationTerms loanApplicationTerms, final Boolean enableInstallmentLevelDelinquency, - final LocalDate submittedOnDate) { + final LocalDate submittedOnDate, final Boolean allowFullTermForTranche) { return new Loan(accountNo, client, null, loanType, fund, officer, loanPurpose, transactionProcessingStrategy, loanProduct, loanRepaymentScheduleDetail, null, loanCharges, collateral, null, fixedEmiAmount, disbursementDetails, maxOutstandingLoanBalance, createStandingInstructionAtDisbursement, isFloatingInterestRate, interestRateDifferential, rates, fixedPrincipalPercentagePerInstallment, externalId, loanApplicationTerms, enableInstallmentLevelDelinquency, - submittedOnDate); + submittedOnDate, allowFullTermForTranche); } public static Loan newGroupLoanApplication(final String accountNo, final Group group, final AccountType loanType, @@ -453,12 +456,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { final Boolean createStandingInstructionAtDisbursement, final Boolean isFloatingInterestRate, final BigDecimal interestRateDifferential, final List rates, final BigDecimal fixedPrincipalPercentagePerInstallment, final ExternalId externalId, final LoanApplicationTerms loanApplicationTerms, final Boolean enableInstallmentLevelDelinquency, - final LocalDate submittedOnDate) { + final LocalDate submittedOnDate, final Boolean allowFullTermForTranche) { return new Loan(accountNo, null, group, loanType, fund, officer, loanPurpose, transactionProcessingStrategy, loanProduct, loanRepaymentScheduleDetail, null, loanCharges, null, syncDisbursementWithMeeting, fixedEmiAmount, disbursementDetails, maxOutstandingLoanBalance, createStandingInstructionAtDisbursement, isFloatingInterestRate, interestRateDifferential, rates, fixedPrincipalPercentagePerInstallment, externalId, loanApplicationTerms, enableInstallmentLevelDelinquency, - submittedOnDate); + submittedOnDate, allowFullTermForTranche); } public static Loan newIndividualLoanApplicationFromGroup(final String accountNo, final Client client, final Group group, @@ -470,12 +473,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { final Boolean createStandingInstructionAtDisbursement, final Boolean isFloatingInterestRate, final BigDecimal interestRateDifferential, final List rates, final BigDecimal fixedPrincipalPercentagePerInstallment, final ExternalId externalId, final LoanApplicationTerms loanApplicationTerms, final Boolean enableInstallmentLevelDelinquency, - final LocalDate submittedOnDate) { + final LocalDate submittedOnDate, final Boolean allowFullTermForTranche) { return new Loan(accountNo, client, group, loanType, fund, officer, loanPurpose, transactionProcessingStrategy, loanProduct, loanRepaymentScheduleDetail, null, loanCharges, null, syncDisbursementWithMeeting, fixedEmiAmount, disbursementDetails, maxOutstandingLoanBalance, createStandingInstructionAtDisbursement, isFloatingInterestRate, interestRateDifferential, rates, fixedPrincipalPercentagePerInstallment, externalId, loanApplicationTerms, enableInstallmentLevelDelinquency, - submittedOnDate); + submittedOnDate, allowFullTermForTranche); } protected Loan() { @@ -491,7 +494,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { final Boolean createStandingInstructionAtDisbursement, final Boolean isFloatingInterestRate, final BigDecimal interestRateDifferential, final List rates, final BigDecimal fixedPrincipalPercentagePerInstallment, final ExternalId externalId, final LoanApplicationTerms loanApplicationTerms, final Boolean enableInstallmentLevelDelinquency, - final LocalDate submittedOnDate) { + final LocalDate submittedOnDate, final Boolean allowFullTermForTranche) { this.loanRepaymentScheduleDetail = loanRepaymentScheduleDetail; this.isFloatingInterestRate = isFloatingInterestRate; @@ -572,6 +575,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { this.loanInterestRecalculationDetails.updateLoan(this); } this.enableInstallmentLevelDelinquency = enableInstallmentLevelDelinquency; + this.allowFullTermForTranche = allowFullTermForTranche; this.getLoanProductRelatedDetail() .setEnableAccrualActivityPosting(loanProduct.getLoanProductRelatedDetail().isEnableAccrualActivityPosting()); } @@ -1783,6 +1787,14 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { this.enableInstallmentLevelDelinquency = enableInstallmentLevelDelinquency; } + public boolean isAllowFullTermForTranche() { + return this.allowFullTermForTranche; + } + + public void updateAllowFullTermForTranche(boolean allowFullTermForTranche) { + this.allowFullTermForTranche = allowFullTermForTranche; + } + public void deductFromNetDisbursalAmount(final BigDecimal subtrahend) { this.netDisbursalAmount = this.netDisbursalAmount.subtract(subtrahend); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/LoanProductConstants.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/LoanProductConstants.java index 7a3784a39..9039ad0bb 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/LoanProductConstants.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/LoanProductConstants.java @@ -72,6 +72,7 @@ public interface LoanProductConstants { String MULTI_DISBURSE_LOAN_PARAMETER_NAME = "multiDisburseLoan"; String MAX_TRANCHE_COUNT_PARAMETER_NAME = "maxTrancheCount"; String OUTSTANDING_LOAN_BALANCE_PARAMETER_NAME = "outstandingLoanBalance"; + String ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME = "allowFullTermForTranche"; String GRACE_ON_ARREARS_AGEING_PARAMETER_NAME = "graceOnArrearsAgeing"; String OVERDUE_DAYS_FOR_NPA_PARAMETER_NAME = "overdueDaysForNPA"; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java index 7dc3a16c3..e558aedf7 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java @@ -309,6 +309,8 @@ public final class LoanProductsApiResourceSwagger { // Multi Disburse @Schema(example = "true") public Boolean multiDisburseLoan; + @Schema(example = "false", description = "Allow full term length for each tranche disbursement. Only available for PROGRESSIVE schedule type with multi-disbursement enabled.") + public Boolean allowFullTermForTranche; @Schema(example = "50") public Integer principalThresholdForLastInstallment; @Schema(example = "true") @@ -1481,6 +1483,8 @@ public final class LoanProductsApiResourceSwagger { public Boolean isRatesEnabled; @Schema(example = "true") public Boolean multiDisburseLoan; + @Schema(example = "false", description = "Allow full term length for each tranche disbursement. Only available for PROGRESSIVE schedule type with multi-disbursement enabled.") + public Boolean allowFullTermForTranche; @Schema(example = "3") public Integer maxTrancheCount; @Schema(example = "36000.000000") @@ -1794,6 +1798,8 @@ public final class LoanProductsApiResourceSwagger { // Multi Disburse @Schema(example = "true") public Boolean multiDisburseLoan; + @Schema(example = "false", description = "Allow full term length for each tranche disbursement. Only available for PROGRESSIVE schedule type with multi-disbursement enabled.") + public Boolean allowFullTermForTranche; @Schema(example = "50") public Integer principalThresholdForLastInstallment; @Schema(example = "true") diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java index cb6edc56c..8a4e2598d 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java @@ -217,6 +217,7 @@ public class LoanProductData implements Serializable { private final Boolean allowApprovedDisbursedAmountsOverApplied; private final String overAppliedCalculationType; private final Integer overAppliedNumber; + private final Boolean allowFullTermForTranche; private final BigDecimal principalThresholdForLastInstallment; @@ -332,6 +333,7 @@ public class LoanProductData implements Serializable { final Boolean allowApprovedDisbursedAmountsOverApplied = false; final String overAppliedCalculationType = null; final Integer overAppliedNumber = null; + final Boolean allowFullTermForTranche = null; final LoanProductGuaranteeData productGuaranteeData = null; final boolean holdGuaranteeFunds = false; @@ -392,21 +394,21 @@ public class LoanProductData implements Serializable { includeInBorrowerCycle, useBorrowerCycle, startDate, closeDate, status, externalId, principalVariations, interestRateVariations, numberOfRepaymentVariations, multiDisburseLoan, maxTrancheCount, outstandingLoanBalance, disallowExpectedDisbursements, allowApprovedDisbursedAmountsOverApplied, overAppliedCalculationType, overAppliedNumber, - graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, isInterestRecalculationEnabled, - interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, productGuaranteeData, - principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, - installmentAmountInMultiplesOf, loanProductConfigurableAttributes, isLinkedToFloatingInterestRates, floatingRateId, - floatingRateName, interestRateDifferential, minDifferentialLendingRate, defaultDifferentialLendingRate, - maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed, minimumGap, maximumGap, - syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, rates, isRatesEnabled, - fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, - overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, - paymentAllocation, creditAllocation, repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, - loanScheduleProcessingType, fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, - interestRecognitionOnDisbursementDate, daysInYearTypeCustomStrategy, enableIncomeCapitalization, - capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee, - buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, writeOffReasonsToExpenseMappings, - writeOffReasonOptions); + allowFullTermForTranche, graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, + isInterestRecalculationEnabled, interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, + productGuaranteeData, principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, + canDefineInstallmentAmount, installmentAmountInMultiplesOf, loanProductConfigurableAttributes, + isLinkedToFloatingInterestRates, floatingRateId, floatingRateName, interestRateDifferential, minDifferentialLendingRate, + defaultDifferentialLendingRate, maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, + isVariableInstallmentsAllowed, minimumGap, maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, + isEqualAmortization, rateOptions, rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, + delinquencyBucket, dueDaysForRepaymentEvent, overDueDaysForRepaymentEvent, enableDownPayment, + disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, paymentAllocation, creditAllocation, + repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, loanScheduleProcessingType, fixedLength, + enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, interestRecognitionOnDisbursementDate, + daysInYearTypeCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy, + capitalizedIncomeType, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, + merchantBuyDownFee, writeOffReasonsToExpenseMappings, writeOffReasonOptions); } @@ -473,6 +475,7 @@ public class LoanProductData implements Serializable { final Boolean allowApprovedDisbursedAmountsOverApplied = false; final String overAppliedCalculationType = null; final Integer overAppliedNumber = null; + final Boolean allowFullTermForTranche = null; final EnumOptionData daysInMonthType = null; final EnumOptionData daysInYearType = null; @@ -532,21 +535,21 @@ public class LoanProductData implements Serializable { includeInBorrowerCycle, useBorrowerCycle, startDate, closeDate, status, externalId, principalVariations, interestRateVariations, numberOfRepaymentVariations, multiDisburseLoan, maxTrancheCount, outstandingLoanBalance, disallowExpectedDisbursements, allowApprovedDisbursedAmountsOverApplied, overAppliedCalculationType, overAppliedNumber, - graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, isInterestRecalculationEnabled, - interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, productGuaranteeData, - principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, - installmentAmountInMultiplesOf, loanProductConfigurableAttributes, isLinkedToFloatingInterestRates, floatingRateId, - floatingRateName, interestRateDifferential, minDifferentialLendingRate, defaultDifferentialLendingRate, - maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed, minimumGap, maximumGap, - syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, rates, isRatesEnabled, - fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, - overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, - paymentAllocation, creditAllocation, repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, - loanScheduleProcessingType, fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, - interestRecognitionOnDisbursementDate, daysInYearTypeCustomStrategy, enableIncomeCapitalization, - capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee, - buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, writeOffReasonsToExpenseMappings, - writeOffReasonOptions); + allowFullTermForTranche, graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, + isInterestRecalculationEnabled, interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, + productGuaranteeData, principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, + canDefineInstallmentAmount, installmentAmountInMultiplesOf, loanProductConfigurableAttributes, + isLinkedToFloatingInterestRates, floatingRateId, floatingRateName, interestRateDifferential, minDifferentialLendingRate, + defaultDifferentialLendingRate, maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, + isVariableInstallmentsAllowed, minimumGap, maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, + isEqualAmortization, rateOptions, rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, + delinquencyBucket, dueDaysForRepaymentEvent, overDueDaysForRepaymentEvent, enableDownPayment, + disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, paymentAllocation, creditAllocation, + repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, loanScheduleProcessingType, fixedLength, + enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, interestRecognitionOnDisbursementDate, + daysInYearTypeCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy, + capitalizedIncomeType, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, + merchantBuyDownFee, writeOffReasonsToExpenseMappings, writeOffReasonOptions); } @@ -619,6 +622,7 @@ public class LoanProductData implements Serializable { final Boolean allowApprovedDisbursedAmountsOverApplied = false; final String overAppliedCalculationType = null; final Integer overAppliedNumber = null; + final Boolean allowFullTermForTranche = null; final EnumOptionData daysInMonthType = CommonEnumerations.daysInMonthType(DaysInMonthType.ACTUAL); final EnumOptionData daysInYearType = CommonEnumerations.daysInYearType(DaysInYearType.ACTUAL); @@ -679,21 +683,21 @@ public class LoanProductData implements Serializable { includeInBorrowerCycle, useBorrowerCycle, startDate, closeDate, status, externalId, principalVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, multiDisburseLoan, maxTrancheCount, outstandingLoanBalance, disallowExpectedDisbursements, allowApprovedDisbursedAmountsOverApplied, overAppliedCalculationType, - overAppliedNumber, graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, isInterestRecalculationEnabled, - interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, productGuaranteeData, - principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, - installmentAmountInMultiplesOf, loanProductConfigurableAttributes, isLinkedToFloatingInterestRates, floatingRateId, - floatingRateName, interestRateDifferential, minDifferentialLendingRate, defaultDifferentialLendingRate, - maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed, minimumGap, maximumGap, - syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, rates, isRatesEnabled, - fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, - overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, - paymentAllocation, creditAllocation, repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, - loanScheduleProcessingType, fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, - interestRecognitionOnDisbursementDate, daysInYearTypeCustomStrategy, enableIncomeCapitalization, - capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee, - buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, writeOffReasonsToExpenseMappings, - writeOffReasonOptions); + overAppliedNumber, allowFullTermForTranche, graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, + isInterestRecalculationEnabled, interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, + productGuaranteeData, principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, + canDefineInstallmentAmount, installmentAmountInMultiplesOf, loanProductConfigurableAttributes, + isLinkedToFloatingInterestRates, floatingRateId, floatingRateName, interestRateDifferential, minDifferentialLendingRate, + defaultDifferentialLendingRate, maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, + isVariableInstallmentsAllowed, minimumGap, maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, + isEqualAmortization, rateOptions, rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, + delinquencyBucket, dueDaysForRepaymentEvent, overDueDaysForRepaymentEvent, enableDownPayment, + disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, paymentAllocation, creditAllocation, + repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, loanScheduleProcessingType, fixedLength, + enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, interestRecognitionOnDisbursementDate, + daysInYearTypeCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy, + capitalizedIncomeType, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, + merchantBuyDownFee, writeOffReasonsToExpenseMappings, writeOffReasonOptions); } @@ -760,6 +764,7 @@ public class LoanProductData implements Serializable { final Boolean allowApprovedDisbursedAmountsOverApplied = false; final String overAppliedCalculationType = null; final Integer overAppliedNumber = null; + final Boolean allowFullTermForTranche = null; final EnumOptionData daysInMonthType = CommonEnumerations.daysInMonthType(DaysInMonthType.ACTUAL); final EnumOptionData daysInYearType = CommonEnumerations.daysInYearType(DaysInYearType.ACTUAL); @@ -820,21 +825,21 @@ public class LoanProductData implements Serializable { includeInBorrowerCycle, useBorrowerCycle, startDate, closeDate, status, externalId, principalVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, multiDisburseLoan, maxTrancheCount, outstandingLoanBalance, disallowExpectedDisbursements, allowApprovedDisbursedAmountsOverApplied, overAppliedCalculationType, - overAppliedNumber, graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, isInterestRecalculationEnabled, - interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, productGuaranteeData, - principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, - installmentAmountInMultiplesOf, loanProductConfigurableAttributes, isLinkedToFloatingInterestRates, floatingRateId, - floatingRateName, interestRateDifferential, minDifferentialLendingRate, defaultDifferentialLendingRate, - maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed, minimumGap, maximumGap, - syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, rates, isRatesEnabled, - fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, - overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, - paymentAllocation, creditAllocationData, repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, - loanScheduleProcessingType, fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, - interestRecognitionOnDisbursementDate, daysInYearTypeCustomStrategy, enableIncomeCapitalization, - capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee, - buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee, writeOffReasonsToExpenseMappings, - writeOffReasonOptions); + overAppliedNumber, allowFullTermForTranche, graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, + isInterestRecalculationEnabled, interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, + productGuaranteeData, principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, + canDefineInstallmentAmount, installmentAmountInMultiplesOf, loanProductConfigurableAttributes, + isLinkedToFloatingInterestRates, floatingRateId, floatingRateName, interestRateDifferential, minDifferentialLendingRate, + defaultDifferentialLendingRate, maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, + isVariableInstallmentsAllowed, minimumGap, maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, + isEqualAmortization, rateOptions, rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, + delinquencyBucket, dueDaysForRepaymentEvent, overDueDaysForRepaymentEvent, enableDownPayment, + disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, paymentAllocation, creditAllocationData, + repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType, loanScheduleProcessingType, fixedLength, + enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, interestRecognitionOnDisbursementDate, + daysInYearTypeCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy, + capitalizedIncomeType, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, + merchantBuyDownFee, writeOffReasonsToExpenseMappings, writeOffReasonOptions); } public static LoanProductData withAccountingDetails(final LoanProductData productData, final Map accountingMappings, @@ -873,9 +878,9 @@ public class LoanProductData implements Serializable { Collection numberOfRepaymentVariations, Boolean multiDisburseLoan, Integer maxTrancheCount, BigDecimal outstandingLoanBalance, final Boolean disallowExpectedDisbursements, final Boolean allowApprovedDisbursedAmountsOverApplied, final String overAppliedCalculationType, - final Integer overAppliedNumber, final Integer graceOnArrearsAgeing, final Integer overdueDaysForNPA, - final EnumOptionData daysInMonthType, final EnumOptionData daysInYearType, final boolean isInterestRecalculationEnabled, - final LoanProductInterestRecalculationData interestRecalculationData, + final Integer overAppliedNumber, final Boolean allowFullTermForTranche, final Integer graceOnArrearsAgeing, + final Integer overdueDaysForNPA, final EnumOptionData daysInMonthType, final EnumOptionData daysInYearType, + final boolean isInterestRecalculationEnabled, final LoanProductInterestRecalculationData interestRecalculationData, final Integer minimumDaysBetweenDisbursalAndFirstRepayment, boolean holdGuaranteeFunds, final LoanProductGuaranteeData loanProductGuaranteeData, final BigDecimal principalThresholdForLastInstallment, final boolean accountMovesOutOfNPAOnlyOnArrearsCompletion, boolean canDefineInstallmentAmount, @@ -1000,6 +1005,7 @@ public class LoanProductData implements Serializable { this.allowApprovedDisbursedAmountsOverApplied = allowApprovedDisbursedAmountsOverApplied; this.overAppliedCalculationType = overAppliedCalculationType; this.overAppliedNumber = overAppliedNumber; + this.allowFullTermForTranche = allowFullTermForTranche; this.graceOnArrearsAgeing = graceOnArrearsAgeing; this.overdueDaysForNPA = overdueDaysForNPA; @@ -1189,6 +1195,7 @@ public class LoanProductData implements Serializable { this.allowApprovedDisbursedAmountsOverApplied = productData.allowApprovedDisbursedAmountsOverApplied; this.overAppliedCalculationType = productData.overAppliedCalculationType; this.overAppliedNumber = productData.overAppliedNumber; + this.allowFullTermForTranche = productData.allowFullTermForTranche; this.minimumDaysBetweenDisbursalAndFirstRepayment = productData.minimumDaysBetweenDisbursalAndFirstRepayment; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProduct.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProduct.java index b4d3da989..9f7920963 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProduct.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProduct.java @@ -288,7 +288,7 @@ public class LoanProduct extends AbstractPersistableCustom { 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.fund = fund; this.transactionProcessingStrategyCode = transactionProcessingStrategyCode; @@ -364,7 +364,8 @@ public class LoanProduct extends AbstractPersistableCustom { loanConfigurableAttributes.updateLoanProduct(this); } - this.loanProductTrancheDetails = new LoanProductTrancheDetails(multiDisburseLoan, maxTrancheCount, outstandingLoanBalance); + this.loanProductTrancheDetails = new LoanProductTrancheDetails(multiDisburseLoan, maxTrancheCount, outstandingLoanBalance, + allowFullTermForTranche); this.overdueDaysForNPA = overdueDaysForNPA; this.productInterestRecalculationDetails = productInterestRecalculationDetails; this.minimumDaysBetweenDisbursalAndFirstRepayment = minimumDaysBetweenDisbursalAndFirstRepayment; @@ -763,4 +764,8 @@ public class LoanProduct extends AbstractPersistableCustom { this.enableInstallmentLevelDelinquency = enableInstallmentLevelDelinquency; } + public boolean isAllowFullTermForTranche() { + return this.loanProductTrancheDetails != null && this.loanProductTrancheDetails.isAllowFullTermForTranche(); + } + } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductTrancheDetails.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductTrancheDetails.java index 2a7142adc..5a552f63f 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductTrancheDetails.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductTrancheDetails.java @@ -38,15 +38,19 @@ public class LoanProductTrancheDetails { @Column(name = "max_outstanding_loan_balance", scale = 6, precision = 19) private BigDecimal outstandingLoanBalance; + @Column(name = "allow_full_term_for_tranche") + private boolean allowFullTermForTranche; + protected LoanProductTrancheDetails() { // TODO Auto-generated constructor stub } public LoanProductTrancheDetails(final boolean multiDisburseLoan, final Integer maxTrancheCount, - final BigDecimal outstandingLoanBalance) { + final BigDecimal outstandingLoanBalance, final boolean allowFullTermForTranche) { this.multiDisburseLoan = multiDisburseLoan; this.maxTrancheCount = maxTrancheCount; this.outstandingLoanBalance = outstandingLoanBalance; + this.allowFullTermForTranche = allowFullTermForTranche; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java index 40d97e3fc..8d97966ca 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java @@ -603,6 +603,8 @@ final class LoansApiResourceSwagger { public String writeoffReason; public GetLoansLoanIdLinkedAccount linkedAccount; public Set disbursementDetails; + @Schema(example = "false", description = "Allow full term length for each tranche disbursement") + public Boolean allowFullTermForTranche; @Schema(example = "1100.000000") public BigDecimal fixedEmiAmount; @Schema(example = "35000.000000") @@ -1198,6 +1200,8 @@ final class LoansApiResourceSwagger { public List transactions; @Schema(description = "Set of GetLoansLoanIdDisbursementDetails") public Set disbursementDetails; + @Schema(example = "false", description = "Allow full term length for each tranche disbursement") + public Boolean allowFullTermForTranche; @Schema(description = "Delinquent data") public GetLoansLoanIdDelinquencySummary delinquent; @Schema(description = "Set of charges") @@ -1344,6 +1348,8 @@ final class LoansApiResourceSwagger { public String externalId; @Schema(description = "List of PostLoansDisbursementData") public List disbursementData; + @Schema(example = "false", description = "Allow full term length for each tranche disbursement") + public Boolean allowFullTermForTranche; @Schema(description = "Maximum allowed outstanding balance") public BigDecimal maxOutstandingLoanBalance; @Schema(example = "[2011, 10, 20]") @@ -1550,6 +1556,8 @@ final class LoansApiResourceSwagger { public List charges; public List collateral; public List disbursementData; + @Schema(example = "false", description = "Allow full term length for each tranche disbursement") + public Boolean allowFullTermForTranche; @Schema(example = "HORIZONTAL") public String loanScheduleProcessingType; @Schema(example = "false") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java index 10d4c3f8d..6401f9957 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanApplicationValidator.java @@ -175,7 +175,8 @@ public final class LoanApplicationValidator { LoanProductConstants.LOAN_SCHEDULE_PROCESSING_TYPE, LoanProductConstants.FIXED_LENGTH, LoanProductConstants.ENABLE_INSTALLMENT_LEVEL_DELINQUENCY, LoanProductConstants.ENABLE_DOWN_PAYMENT, LoanProductConstants.ENABLE_AUTO_REPAYMENT_DOWN_PAYMENT, LoanProductConstants.DISBURSED_AMOUNT_PERCENTAGE_DOWN_PAYMENT, - LoanApiConstants.INTEREST_RECOGNITION_ON_DISBURSEMENT_DATE, LoanApiConstants.daysInYearCustomStrategyParameterName)); + LoanApiConstants.INTEREST_RECOGNITION_ON_DISBURSEMENT_DATE, LoanApiConstants.daysInYearCustomStrategyParameterName, + LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE)); public static final String LOANAPPLICATION_UNDO = "loanapplication.undo"; private final FromJsonHelper fromApiJsonHelper; @@ -324,6 +325,18 @@ public final class LoanApplicationValidator { } } + if (this.fromApiJsonHelper.parameterExists(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE, element)) { + final Boolean allowFullTermForTranche = this.fromApiJsonHelper + .extractBooleanNamed(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE, element); + baseDataValidator.reset().parameter(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE).value(allowFullTermForTranche) + .ignoreIfNull().validateForBooleanValue(); + + if (Boolean.TRUE.equals(allowFullTermForTranche) && !loanProduct.isAllowFullTermForTranche()) { + baseDataValidator.reset().parameter(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE).failWithCode("not.allowed.by.product", + "Full term tranche cannot be enabled because the loan product does not allow it"); + } + } + BigDecimal fixedPrincipalPercentagePerInstallment = this.fromApiJsonHelper .extractBigDecimalWithLocaleNamed(LoanApiConstants.fixedPrincipalPercentagePerInstallmentParamName, element); baseDataValidator.reset().parameter(LoanApiConstants.fixedPrincipalPercentagePerInstallmentParamName) @@ -946,6 +959,18 @@ public final class LoanApplicationValidator { } } + if (this.fromApiJsonHelper.parameterExists(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE, element)) { + final Boolean allowFullTermForTranche = this.fromApiJsonHelper + .extractBooleanNamed(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE, element); + baseDataValidator.reset().parameter(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE).value(allowFullTermForTranche) + .ignoreIfNull().validateForBooleanValue(); + + if (Boolean.TRUE.equals(allowFullTermForTranche) && !loanProduct.isAllowFullTermForTranche()) { + baseDataValidator.reset().parameter(LoanApiConstants.ALLOW_FULL_TERM_FOR_TRANCHE).failWithCode("not.allowed.by.product", + "Full term tranche cannot be enabled because the loan product does not allow it"); + } + } + BigDecimal fixedPrincipalPercentagePerInstallment = this.fromApiJsonHelper .extractBigDecimalWithLocaleNamed(LoanApiConstants.fixedPrincipalPercentagePerInstallmentParamName, element); baseDataValidator.reset().parameter(LoanApiConstants.fixedPrincipalPercentagePerInstallmentParamName) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAssemblerImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAssemblerImpl.java index 0f251096b..9944ac03b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAssemblerImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAssemblerImpl.java @@ -248,6 +248,12 @@ public class LoanAssemblerImpl implements LoanAssembler { isEnableInstallmentLevelDelinquency = loanProduct.isEnableInstallmentLevelDelinquency(); } + Boolean allowFullTermForTranche = this.fromApiJsonHelper + .extractBooleanNamed(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME, element); + if (allowFullTermForTranche == null) { + allowFullTermForTranche = loanProduct.isAllowFullTermForTranche(); + } + final boolean isHolidayEnabled = this.configurationDomainService.isRescheduleRepaymentsOnHolidaysEnabled(); Long officeId = client != null ? client.getOffice().getId() : group.getOffice().getId(); final List holidays = this.holidayRepository.findByOfficeIdAndGreaterThanDate(officeId, @@ -262,19 +268,19 @@ public class LoanAssemblerImpl implements LoanAssembler { syncDisbursementWithMeeting, fixedEmiAmount, disbursementDetails, maxOutstandingLoanBalance, createStandingInstructionAtDisbursement, isFloatingInterestRate, interestRateDifferential, rates, fixedPrincipalPercentagePerInstallment, externalId, loanApplicationTerms, isEnableInstallmentLevelDelinquency, - submittedOnDate); + submittedOnDate, allowFullTermForTranche); } else if (group != null) { loanApplication = Loan.newGroupLoanApplication(accountNo, group, loanAccountType, loanProduct, fund, loanOfficer, loanPurpose, transactionProcessingStrategy, loanProductRelatedDetail, loanCharges, syncDisbursementWithMeeting, fixedEmiAmount, disbursementDetails, maxOutstandingLoanBalance, createStandingInstructionAtDisbursement, isFloatingInterestRate, interestRateDifferential, rates, fixedPrincipalPercentagePerInstallment, externalId, loanApplicationTerms, - isEnableInstallmentLevelDelinquency, submittedOnDate); + isEnableInstallmentLevelDelinquency, submittedOnDate, allowFullTermForTranche); } else if (client != null) { loanApplication = Loan.newIndividualLoanApplication(accountNo, client, loanAccountType, loanProduct, fund, loanOfficer, loanPurpose, transactionProcessingStrategy, loanProductRelatedDetail, loanCharges, collateral, fixedEmiAmount, disbursementDetails, maxOutstandingLoanBalance, createStandingInstructionAtDisbursement, isFloatingInterestRate, interestRateDifferential, rates, fixedPrincipalPercentagePerInstallment, externalId, loanApplicationTerms, - isEnableInstallmentLevelDelinquency, submittedOnDate); + isEnableInstallmentLevelDelinquency, submittedOnDate, allowFullTermForTranche); } else { throw new IllegalStateException("No loan application exists for either a client or group (or both)."); } @@ -843,6 +849,14 @@ public class LoanAssemblerImpl implements LoanAssembler { loan.updateEnableInstallmentLevelDelinquency(enableInstallmentLevelDelinquency); } + // update allow full term for tranche + if (command.isChangeInBooleanParameterNamed(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME, + loan.isAllowFullTermForTranche())) { + final Boolean allowFullTermForTranche = command + .booleanObjectValueOfParameterNamed(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME); + loan.updateAllowFullTermForTranche(allowFullTermForTranche); + } + if (changes.containsKey("recalculateLoanSchedule")) { changes.remove("recalculateLoanSchedule"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanProductAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanProductAssembler.java index 07644c8d5..ddc728a97 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanProductAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanProductAssembler.java @@ -267,6 +267,9 @@ public class LoanProductAssembler { final Integer overAppliedNumber = command.integerValueOfParameterNamed(LoanProductConstants.OVER_APPLIED_NUMBER); + final boolean allowFullTermForTranche = command.parameterExists(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME) + && command.booleanPrimitiveValueOfParameterNamed(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME); + final Integer dueDaysForRepaymentEvent = command.integerValueOfParameterNamed(LoanProductConstants.DUE_DAYS_FOR_REPAYMENT_EVENT); final Integer overDueDaysForRepaymentEvent = command .integerValueOfParameterNamed(LoanProductConstants.OVER_DUE_DAYS_FOR_REPAYMENT_EVENT); @@ -353,7 +356,7 @@ public class LoanProductAssembler { enableAccrualActivityPosting, supportedInterestRefundTypes, chargeOffBehaviour, interestRecognitionOnDisbursementDate, daysInYearCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, - merchantBuyDownFee); + merchantBuyDownFee, allowFullTermForTranche); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanProductTrancheDetailsUpdateUtil.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanProductTrancheDetailsUpdateUtil.java index 045097cc2..37f4dfd8c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanProductTrancheDetailsUpdateUtil.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanProductTrancheDetailsUpdateUtil.java @@ -54,9 +54,18 @@ public class LoanProductTrancheDetailsUpdateUtil { actualChanges.put(LoanProductConstants.OUTSTANDING_LOAN_BALANCE_PARAMETER_NAME, newValue); loanProductTrancheDetails.setOutstandingLoanBalance(newValue); } + + if (command.isChangeInBooleanParameterNamed(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME, + loanProductTrancheDetails.isAllowFullTermForTranche())) { + final boolean newValue = command + .booleanPrimitiveValueOfParameterNamed(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME); + actualChanges.put(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME, newValue); + loanProductTrancheDetails.setAllowFullTermForTranche(newValue); + } } else { loanProductTrancheDetails.setMaxTrancheCount(null); loanProductTrancheDetails.setOutstandingLoanBalance(null); + loanProductTrancheDetails.setAllowFullTermForTranche(false); } } } 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 9b0545397..cc8595aa5 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 @@ -865,7 +865,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa + " topup.topup_amount as topupAmount, l.last_closed_business_date as lastClosedBusinessDate,l.overpaidon_date as overpaidOnDate, " + " l.is_charged_off as isChargedOff, l.charge_off_reason_cv_id as chargeOffReasonId, codec.code_value as chargeOffReason, l.charged_off_on_date as chargedOffOnDate, l.enable_down_payment as enableDownPayment, l.disbursed_amount_percentage_for_down_payment as disbursedAmountPercentageForDownPayment, l.enable_auto_repayment_for_down_payment as enableAutoRepaymentForDownPayment," + " cobu.username as chargedOffByUsername, cobu.firstname as chargedOffByFirstname, cobu.lastname as chargedOffByLastname, l.loan_schedule_type as loanScheduleType, l.loan_schedule_processing_type as loanScheduleProcessingType, " - + " l.charge_off_behaviour as chargeOffBehaviour, l.interest_recognition_on_disbursement_date as interestRecognitionOnDisbursementDate " // + + " l.charge_off_behaviour as chargeOffBehaviour, l.interest_recognition_on_disbursement_date as interestRecognitionOnDisbursementDate, l.allow_full_term_for_tranche as allowFullTermForTranche " // + " from m_loan l" // + " join m_product_loan lp on lp.id = l.product_id" // + " left join m_loan_recalculation_details lir on lir.loan_id = l.id join m_currency rc on rc." @@ -1238,6 +1238,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa final Integer fixedLength = JdbcSupport.getInteger(rs, "fixedLength"); final LoanChargeOffBehaviour chargeOffBehaviour = LoanChargeOffBehaviour.valueOf(rs.getString("chargeOffBehaviour")); final boolean interestRecognitionOnDisbursementDate = rs.getBoolean("interestRecognitionOnDisbursementDate"); + final boolean allowFullTermForTranche = rs.getBoolean("allowFullTermForTranche"); final StringEnumOptionData daysInYearCustomStrategy = ApiFacingEnum.getStringEnumOptionData(DaysInYearCustomStrategyType.class, rs.getString("daysInYearCustomStrategy")); final boolean enableIncomeCapitalization = rs.getBoolean("enableIncomeCapitalization"); @@ -1274,7 +1275,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService, Loa lastClosedBusinessDate, overpaidOnDate, isChargedOff, enableDownPayment, disbursedAmountPercentageForDownPayment, enableAutoRepaymentForDownPayment, enableInstallmentLevelDelinquency, loanScheduleType.asEnumOptionData(), loanScheduleProcessingType.asEnumOptionData(), fixedLength, chargeOffBehaviour.getValueAsStringEnumOptionData(), - interestRecognitionOnDisbursementDate, daysInYearCustomStrategy, enableIncomeCapitalization, + interestRecognitionOnDisbursementDate, allowFullTermForTranche, daysInYearCustomStrategy, enableIncomeCapitalization, capitalizedIncomeCalculationType, capitalizedIncomeStrategy, capitalizedIncomeType, enableBuyDownFee, buyDownFeeCalculationType, buyDownFeeStrategy, buyDownFeeIncomeType, merchantBuyDownFee); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java index 2bc26a6b7..49242644d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java @@ -208,7 +208,8 @@ public final class LoanProductDataValidator { LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(), LoanProductAccountingParams.INCOME_FROM_BUY_DOWN.getValue(), LoanProductConstants.MERCHANT_BUY_DOWN_FEE_PARAM_NAME, LoanProductAccountingParams.CAPITALIZED_INCOME_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue(), // - LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue() // + LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue(), // + LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME // )); private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName, @@ -1062,6 +1063,43 @@ public final class LoanProductDataValidator { baseDataValidator.reset().parameter(INTEREST_TYPE).value(interestType).ignoreIfNull().inMinMaxRange(0, 1); } + // Determine effective values for allowFullTermForTranche validation + // For updates, fall back to existing product values if not in request + Boolean effectiveMultiDisburseLoan = multiDisburseLoan; + if (!this.fromApiJsonHelper.parameterExists(LoanProductConstants.MULTI_DISBURSE_LOAN_PARAMETER_NAME, element) + && loanProduct != null) { + effectiveMultiDisburseLoan = loanProduct.isMultiDisburseLoan(); + } + + String effectiveLoanScheduleType = LoanScheduleType.CUMULATIVE.toString(); + if (fromApiJsonHelper.parameterExists(LoanProductConstants.LOAN_SCHEDULE_TYPE, element)) { + effectiveLoanScheduleType = fromApiJsonHelper.extractStringNamed(LoanProductConstants.LOAN_SCHEDULE_TYPE, element); + } else if (loanProduct != null) { + effectiveLoanScheduleType = loanProduct.getLoanProductRelatedDetail().getLoanScheduleType().toString(); + } + + Boolean effectiveAllowFullTermForTranche = false; + if (this.fromApiJsonHelper.parameterExists(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME, element)) { + effectiveAllowFullTermForTranche = this.fromApiJsonHelper + .extractBooleanNamed(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME, element); + baseDataValidator.reset().parameter(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME) + .value(effectiveAllowFullTermForTranche).ignoreIfNull().validateForBooleanValue(); + } else if (loanProduct != null && loanProduct.getLoanProductTrancheDetails() != null) { + effectiveAllowFullTermForTranche = loanProduct.getLoanProductTrancheDetails().isAllowFullTermForTranche(); + } + + // Validate: allowFullTermForTranche requires multi-disburse and PROGRESSIVE schedule + if (Boolean.TRUE.equals(effectiveAllowFullTermForTranche)) { + if (!Boolean.TRUE.equals(effectiveMultiDisburseLoan)) { + baseDataValidator.reset().parameter(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME).failWithCode( + "requires.multi.disburse.loan", "Full term tranche can only be enabled for multi-disbursement loan products"); + } + if (!LoanScheduleType.PROGRESSIVE.toString().equals(effectiveLoanScheduleType)) { + baseDataValidator.reset().parameter(LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME).failWithCode( + "requires.progressive.schedule.type", "Full term tranche can only be enabled for PROGRESSIVE loan schedule type"); + } + } + final String overAppliedCalculationType = this.fromApiJsonHelper.extractStringNamed(OVER_APPLIED_CALCULATION_TYPE, element); baseDataValidator.reset().parameter(OVER_APPLIED_CALCULATION_TYPE).value(overAppliedCalculationType).notExceedingLengthOf(10); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java index 4444f42ad..d10879c14 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java @@ -254,7 +254,7 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo + "lp.min_days_between_disbursal_and_first_repayment As minimumDaysBetweenDisbursalAndFirstRepayment, " + "lp.amortization_method_enum as amortizationMethod, lp.arrearstolerance_amount as tolerance, " + "lp.accounting_type as accountingType, lp.include_in_borrower_cycle as includeInBorrowerCycle,lp.use_borrower_cycle as useBorrowerCycle, lp.start_date as startDate, lp.close_date as closeDate, " - + "lp.allow_multiple_disbursals as multiDisburseLoan, lp.max_disbursals as maxTrancheCount, lp.max_outstanding_loan_balance as outstandingLoanBalance, " + + "lp.allow_multiple_disbursals as multiDisburseLoan, lp.max_disbursals as maxTrancheCount, lp.max_outstanding_loan_balance as outstandingLoanBalance, lp.allow_full_term_for_tranche as allowFullTermForTranche, " + "lp.disallow_expected_disbursements as disallowExpectedDisbursements, lp.allow_approved_disbursed_amounts_over_applied as allowApprovedDisbursedAmountsOverApplied, lp.over_applied_calculation_type as overAppliedCalculationType, over_applied_number as overAppliedNumber, " + "lp.days_in_month_enum as daysInMonth, lp.days_in_year_enum as daysInYear, lp.interest_recalculation_enabled as isInterestRecalculationEnabled, " + "lp.can_define_fixed_emi_amount as canDefineInstallmentAmount, lp.installment_amount_in_multiples_of as installmentAmountInMultiplesOf, " @@ -439,6 +439,7 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo final Boolean allowApprovedDisbursedAmountsOverApplied = rs.getBoolean("allowApprovedDisbursedAmountsOverApplied"); final String overAppliedCalculationType = rs.getString("overAppliedCalculationType"); final Integer overAppliedNumber = rs.getInt("overAppliedNumber"); + final Boolean allowFullTermForTranche = rs.getBoolean("allowFullTermForTranche"); final int daysInMonth = JdbcSupport.getInteger(rs, "daysInMonth"); final EnumOptionData daysInMonthType = CommonEnumerations.daysInMonthType(daysInMonth); @@ -591,15 +592,16 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo principalVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, multiDisburseLoan, maxTrancheCount, outstandingLoanBalance, disallowExpectedDisbursements, allowApprovedDisbursedAmountsOverApplied, overAppliedCalculationType, overAppliedNumber, - graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, isInterestRecalculationEnabled, - interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuaranteeFunds, loanProductGuaranteeData, - principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, - installmentAmountInMultiplesOf, allowAttributeOverrides, isLinkedToFloatingInterestRates, floatingRateId, - floatingRateName, interestRateDifferential, minDifferentialLendingRate, defaultDifferentialLendingRate, - maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableIntallmentsAllowed, minimumGap, - maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, this.rates, - isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, - dueDaysForRepaymentEvent, overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageForDownPayment, + allowFullTermForTranche, graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, + isInterestRecalculationEnabled, interestRecalculationData, minimumDaysBetweenDisbursalAndFirstRepayment, + holdGuaranteeFunds, loanProductGuaranteeData, principalThresholdForLastInstallment, + accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, installmentAmountInMultiplesOf, + allowAttributeOverrides, isLinkedToFloatingInterestRates, floatingRateId, floatingRateName, interestRateDifferential, + minDifferentialLendingRate, defaultDifferentialLendingRate, maxDifferentialLendingRate, + isFloatingInterestRateCalculationAllowed, isVariableIntallmentsAllowed, minimumGap, maximumGap, + syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, this.rates, isRatesEnabled, + fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, + overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageForDownPayment, enableAutoRepaymentForDownPayment, advancedPaymentData, creditAllocationData, repaymentStartDateType, enableInstallmentLevelDelinquency, loanScheduleType.asEnumOptionData(), loanScheduleProcessingType.asEnumOptionData(), fixedLength, enableAccrualActivityPosting, supportedInterestRefundTypes, diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index b5dabf2d0..2b164480b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -225,4 +225,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0207_add_allow_full_term_for_tranche.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0207_add_allow_full_term_for_tranche.xml new file mode 100644 index 000000000..80b0041d8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0207_add_allow_full_term_for_tranche.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/LoanBuilder.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/LoanBuilder.java index fc5a72094..1db4500c5 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/LoanBuilder.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/LoanBuilder.java @@ -80,6 +80,7 @@ public class LoanBuilder { private ExternalId externalId = ExternalId.empty(); private LoanApplicationTerms loanApplicationTerms = mock(LoanApplicationTerms.class); private Boolean enableInstallmentLevelDelinquency = false; + private Boolean allowFullTermForTranche = false; private LocalDate submittedOnDate = LocalDate.now(ZoneId.systemDefault()); private LocalDate approvedOnDate; private LocalDate expectedDisbursementDate; @@ -115,7 +116,7 @@ public class LoanBuilder { transactionProcessor, loanRepaymentScheduleDetail, charges, collateral, fixedEmiAmount, disbursementDetails, maxOutstandingLoanBalance, createStandingInstructionAtDisbursement, isFloatingInterestRate, interestRateDifferential, rates, fixedPrincipalPercentagePerInstallment, externalId, loanApplicationTerms, enableInstallmentLevelDelinquency, - submittedOnDate); + submittedOnDate, allowFullTermForTranche); if (id != null) { loan.setId(id); @@ -338,6 +339,11 @@ public class LoanBuilder { return this; } + public LoanBuilder withAllowFullTermForTranche(Boolean allowFullTermForTranche) { + this.allowFullTermForTranche = allowFullTermForTranche; + return this; + } + public LoanBuilder withSubmittedOnDate(LocalDate submittedOnDate) { this.submittedOnDate = submittedOnDate; return this; diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/LoanProductValidationStepDefinitions.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/LoanProductValidationStepDefinitions.java index 35c071523..e1e729729 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/LoanProductValidationStepDefinitions.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/LoanProductValidationStepDefinitions.java @@ -72,7 +72,7 @@ public class LoanProductValidationStepDefinitions implements En { private LoanProduct newLoanProduct(boolean allowMultipleDisbursals) { LoanProduct lp = new LoanProduct(); - lp.setLoanProductTrancheDetails(new LoanProductTrancheDetails(allowMultipleDisbursals, null, null)); + lp.setLoanProductTrancheDetails(new LoanProductTrancheDetails(allowMultipleDisbursals, null, null, false)); return lp; } } 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 996f31da2..6d683485b 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 @@ -33,15 +33,19 @@ import java.math.BigDecimal; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.AdvancedPaymentData; import org.apache.fineract.client.models.GetLoanProductsProductIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdDisbursementDetails; import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod; import org.apache.fineract.client.models.GetLoansLoanIdRepaymentSchedule; import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.PaymentAllocationOrder; import org.apache.fineract.client.models.PostLoansLoanIdResponse; import org.apache.fineract.client.models.PutLoansLoanIdResponse; import org.apache.fineract.integrationtests.common.ClientHelper; @@ -53,6 +57,8 @@ import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder; import org.apache.fineract.integrationtests.common.loans.LoanStatusChecker; import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; +import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -693,4 +699,185 @@ public class LoanDisbursementDetailsIntegrationTest { equalTo("error.msg.loan.disbursal.amount.can't.be.greater.than.maximum.applied.loan.amount.calculation")) .expectStatusCode(403).build(); } + + private AdvancedPaymentData createDefaultPaymentAllocation(String futureInstallmentAllocationRule) { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType("DEFAULT"); + advancedPaymentData.setFutureInstallmentAllocationRule(futureInstallmentAllocationRule); + + List paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_PRINCIPAL, PaymentAllocationType.PAST_DUE_INTEREST, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_PRINCIPAL, + PaymentAllocationType.DUE_INTEREST, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private List getPaymentAllocationOrder(PaymentAllocationType... paymentAllocationTypes) { + AtomicInteger integer = new AtomicInteger(1); + return Arrays.stream(paymentAllocationTypes).map(pat -> { + PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); + paymentAllocationOrder.setPaymentAllocationRule(pat.name()); + paymentAllocationOrder.setOrder(integer.getAndIncrement()); + return paymentAllocationOrder; + }).toList(); + } + + @Test + public void testCreateLoanProductWithFullTermTrancheEnabled() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withInterestTypeAsDecliningBalance().withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); + + GetLoanProductsProductIdResponse loanProduct = this.loanTransactionHelper.getLoanProduct(loanProductId); + assertNotNull(loanProduct); + assertEquals(true, loanProduct.getMultiDisburseLoan()); + assertEquals(true, loanProduct.getAllowFullTermForTranche()); + log.info("-------------------LOAN PRODUCT WITH allowFullTermForTranche CREATED SUCCESSFULLY-------"); + } + + @Test + public void testCreateLoanProductWithFullTermTrancheOnCumulativeShouldFail() { + final ResponseSpecification errorResponse = new ResponseSpecBuilder() + .expectBody("userMessageGlobalisationCode", equalTo("validation.msg.validation.errors.exist")) + .expectBody("errors[0].userMessageGlobalisationCode", + equalTo("validation.msg.loanproduct.allowFullTermForTranche.requires.progressive.schedule.type")) + .expectStatusCode(400).build(); + + final LoanTransactionHelper validationErrorHelper = new LoanTransactionHelper(this.requestSpec, errorResponse); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withInterestTypeAsDecliningBalance().withMultiDisburse().withLoanScheduleType(LoanScheduleType.CUMULATIVE) + .withAllowFullTermForTranche(true).build(null); + + validationErrorHelper.getLoanProductId(loanProductJSON); + log.info("-------------------LOAN PRODUCT WITH allowFullTermForTranche ON CUMULATIVE FAILED AS EXPECTED-------"); + } + + @Test + public void testCreateLoanProductWithFullTermTrancheOnSingleDisburseShouldFail() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final ResponseSpecification errorResponse = new ResponseSpecBuilder() + .expectBody("userMessageGlobalisationCode", equalTo("validation.msg.validation.errors.exist")) + .expectBody("errors[0].userMessageGlobalisationCode", + equalTo("validation.msg.loanproduct.allowFullTermForTranche.requires.multi.disburse.loan")) + .expectStatusCode(400).build(); + + final LoanTransactionHelper validationErrorHelper = new LoanTransactionHelper(this.requestSpec, errorResponse); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withInterestTypeAsDecliningBalance().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).build(null); + + validationErrorHelper.getLoanProductId(loanProductJSON); + log.info("-------------------LOAN PRODUCT WITH allowFullTermForTranche ON SINGLE DISBURSE FAILED AS EXPECTED-------"); + } + + @Test + public void testUpdateLoanProductPreservesAllowFullTermForTranche() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withInterestTypeAsDecliningBalance().withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).build(null); + + final Integer loanProductId = this.loanTransactionHelper.getLoanProductId(loanProductJSON); + log.info("------------------LOAN PRODUCT CREATED WITH ID----------- {}", loanProductId); + + GetLoanProductsProductIdResponse loanProduct = this.loanTransactionHelper.getLoanProduct(loanProductId); + assertNotNull(loanProduct); + assertEquals(true, loanProduct.getAllowFullTermForTranche()); + + org.apache.fineract.client.models.PutLoanProductsProductIdRequest updateRequest = new org.apache.fineract.client.models.PutLoanProductsProductIdRequest(); + updateRequest.setDescription("Updated description"); + this.loanTransactionHelper.updateLoanProduct((long) loanProductId, updateRequest); + + GetLoanProductsProductIdResponse updatedProduct = this.loanTransactionHelper.getLoanProduct(loanProductId); + assertNotNull(updatedProduct); + assertEquals(true, updatedProduct.getAllowFullTermForTranche()); + assertEquals("Updated description", updatedProduct.getDescription()); + log.info("-------------------LOAN PRODUCT UPDATE PRESERVED allowFullTermForTranche FLAG-------"); + } + + @Test + public void testLoanInheritsAllowFullTermForTrancheFromProduct() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withInterestTypeAsDecliningBalance().withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).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); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 March 2014", "5000")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 April 2014", "5000")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("10000").withLoanTermFrequency("12") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("12").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("1").withExpectedDisbursementDate("01 March 2014") + .withTranches(createTranches).withSubmittedOnDate("01 March 2014") + .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); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanId); + assertNotNull(loanDetails); + assertEquals(true, loanDetails.getAllowFullTermForTranche()); + log.info("-------------------LOAN INHERITED allowFullTermForTranche FROM PRODUCT SUCCESSFULLY-------"); + } + + @Test + public void testLoanLevelOverrideOfAllowFullTermForTranche() { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation("NEXT_INSTALLMENT"); + + final String loanProductJSON = new LoanProductTestBuilder().withAmortizationTypeAsEqualInstallments() + .withInterestTypeAsDecliningBalance().withMoratorium("", "").withInterestCalculationPeriodTypeAsRepaymentPeriod(true) + .withInterestTypeAsDecliningBalance().withMultiDisburse().withLoanScheduleType(LoanScheduleType.PROGRESSIVE) + .addAdvancedPaymentAllocation(defaultAllocation).withAllowFullTermForTranche(true).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); + log.info("------------------CLIENT CREATED WITH ID----------- {}", clientId); + + List createTranches = new ArrayList<>(); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 March 2014", "5000")); + createTranches.add(this.loanTransactionHelper.createTrancheDetail(null, "01 April 2014", "5000")); + + final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("10000").withLoanTermFrequency("12") + .withLoanTermFrequencyAsMonths().withNumberOfRepayments("12").withRepaymentEveryAfter("1") + .withRepaymentFrequencyTypeAsMonths().withInterestRatePerPeriod("1").withExpectedDisbursementDate("01 March 2014") + .withTranches(createTranches).withSubmittedOnDate("01 March 2014") + .withRepaymentStrategy(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY).withAllowFullTermForTranche(false) + .build(clientId.toString(), loanProductId.toString(), null); + + final Integer loanId = this.loanTransactionHelper.getLoanId(loanApplicationJSON); + log.info("------------------LOAN CREATED WITH ID----------- {}", loanId); + + GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanId); + assertNotNull(loanDetails); + assertEquals(false, loanDetails.getAllowFullTermForTranche()); + log.info("-------------------LOAN LEVEL OVERRIDE OF allowFullTermForTranche WORKED SUCCESSFULLY-------"); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanApplicationTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanApplicationTestBuilder.java index 469b06cb2..f7463eb3e 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanApplicationTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanApplicationTestBuilder.java @@ -88,6 +88,7 @@ public class LoanApplicationTestBuilder { private boolean enableDownPayment = false; private boolean enableAutoRepaymentForDownPayment = false; private String disbursedAmountPercentageDownPayment; + private Boolean allowFullTermForTranche = null; public String build(final String clientID, final String groupID, final String loanProductId, final String savingsID) { final HashMap map = new HashMap<>(); @@ -220,6 +221,9 @@ public class LoanApplicationTestBuilder { if (disbursedAmountPercentageDownPayment != null) { map.put("disbursedAmountPercentageDownPayment", disbursedAmountPercentageDownPayment); } + if (allowFullTermForTranche != null) { + map.put("allowFullTermForTranche", allowFullTermForTranche); + } LOG.info("Loan Application request : {} ", map); return new Gson().toJson(map); } @@ -468,4 +472,9 @@ public class LoanApplicationTestBuilder { return this; } + public LoanApplicationTestBuilder withAllowFullTermForTranche(final Boolean allowFullTermForTranche) { + this.allowFullTermForTranche = allowFullTermForTranche; + return this; + } + } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java index 8ae4bcc1d..2b706f6db 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java @@ -113,6 +113,7 @@ public class LoanProductTestBuilder { private Account feeAndPenaltyAssetAccount; private Boolean multiDisburseLoan = false; + private Boolean allowFullTermForTranche = false; private final String outstandingLoanBalance = "35000"; private String maxTrancheCount = "3"; private Boolean disallowExpectedDisbursements = false; @@ -225,6 +226,7 @@ public class LoanProductTestBuilder { } if (this.multiDisburseLoan) { map.put("multiDisburseLoan", this.multiDisburseLoan); + map.put("allowFullTermForTranche", this.allowFullTermForTranche); map.put("maxTrancheCount", this.maxTrancheCount); map.put("outstandingLoanBalance", this.outstandingLoanBalance); map.put("disallowExpectedDisbursements", this.disallowExpectedDisbursements); @@ -242,6 +244,10 @@ public class LoanProductTestBuilder { map.put("maxTrancheCount", this.maxTrancheCount); map.put("outstandingLoanBalance", this.outstandingLoanBalance); } + // Always send allowFullTermForTranche when it's true (for validation testing of single-disburse scenarios) + if (this.allowFullTermForTranche && !this.multiDisburseLoan) { + map.put("allowFullTermForTranche", this.allowFullTermForTranche); + } if (this.fullAccountingConfig != null) { map.putAll(this.fullAccountingConfig.toMap()); @@ -742,6 +748,11 @@ public class LoanProductTestBuilder { return this; } + public LoanProductTestBuilder withAllowFullTermForTranche(boolean allowFullTermForTranche) { + this.allowFullTermForTranche = allowFullTermForTranche; + return this; + } + public LoanProductTestBuilder withFeeToIncomeAccountMapping(final Long chargeId, final Long accountId) { if (this.feeToIncomeAccountMappings == null) { this.feeToIncomeAccountMappings = new ArrayList<>();