diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/JsonCommand.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/JsonCommand.java index 23a21556a..5b74dfcd3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/JsonCommand.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/api/JsonCommand.java @@ -178,7 +178,7 @@ public final class JsonCommand { this.parsedCommand = parsedCommand; this.resourceId = resourceId; this.commandId = null; - this.jsonCommand = null; + this.jsonCommand = parsedCommand.toString(); this.fromApiJsonHelper = fromApiJsonHelper; this.entityName = null; this.subresourceId = null; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanReAgingApiConstants.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanReAgingApiConstants.java index 701cb65c1..fff896a51 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanReAgingApiConstants.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanReAgingApiConstants.java @@ -31,4 +31,7 @@ public interface LoanReAgingApiConstants { String reAgeInterestHandlingParamName = "reAgeInterestHandling"; String reasonCodeValueIdParamName = "reasonCodeValueId"; + + String transactionAmountParamName = "transactionAmount"; + String noteParamName = "note"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java index eefdb54a3..1751816a8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingService.java @@ -36,9 +36,11 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.event.business.domain.loan.reaging.LoanReAgeBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.loan.reaging.LoanUndoReAgeBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.reaging.LoanReAgeTransactionBusinessEvent; @@ -55,7 +57,6 @@ import org.apache.fineract.portfolio.loanaccount.data.RepaymentScheduleRelatedLo import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; -import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; @@ -66,12 +67,10 @@ import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFou import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleData; import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository; import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator; -import org.apache.fineract.portfolio.loanaccount.service.InterestScheduleModelRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; import org.apache.fineract.portfolio.loanaccount.service.LoanRepaymentScheduleService; import org.apache.fineract.portfolio.loanaccount.service.LoanScheduleService; -import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionService; import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService; import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService; import org.apache.fineract.portfolio.note.domain.Note; @@ -89,7 +88,6 @@ public class LoanReAgingService { private final ExternalIdFactory externalIdFactory; private final BusinessEventNotifierService businessEventNotifierService; private final LoanTransactionRepository loanTransactionRepository; - private final LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory; private final NoteRepository noteRepository; private final LoanChargeValidator loanChargeValidator; private final LoanUtilService loanUtilService; @@ -99,8 +97,6 @@ public class LoanReAgingService { private final LoanRepaymentScheduleService loanRepaymentScheduleService; private final LoanReadPlatformService loanReadPlatformService; private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository; - private final InterestScheduleModelRepositoryWrapper modelRepository; - private final LoanTransactionService loanTransactionService; public CommandProcessingResult reAge(final Long loanId, final JsonCommand command) { final Loan loan = loanAssembler.assembleFrom(loanId); @@ -240,7 +236,15 @@ public class LoanReAgingService { } // in case of a reaging transaction, only the outstanding principal amount until the business date is considered Money txPrincipal = loan.getTotalPrincipalOutstandingUntil(transactionDate); - BigDecimal txPrincipalAmount = txPrincipal.getAmount(); + final BigDecimal txPrincipalAmount = txPrincipal.getAmount(); + if (command.hasParameter(LoanReAgingApiConstants.transactionAmountParamName)) { + final BigDecimal transactionAmount = command + .bigDecimalValueOfParameterNamed(LoanReAgingApiConstants.transactionAmountParamName); + if (!MathUtil.isEqualTo(txPrincipalAmount, transactionAmount)) { + throw new GeneralPlatformDomainRuleException("error.msg.loan.reage.amount.not.match.with.calculated.reage.amount", + "re-age amount is not matching with the calculated re-age amount", txPrincipalAmount); + } + } final LoanTransaction reAgeTransaction = new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.REAGE, transactionDate, txPrincipalAmount, txPrincipalAmount, ZERO, ZERO, ZERO, null, false, null, txExternalId); @@ -275,10 +279,10 @@ public class LoanReAgingService { } private void persistNote(Loan loan, JsonCommand command, Map changes) { - if (command.hasParameter("note")) { - final String note = command.stringValueOfParameterNamed("note"); + if (command.hasParameter(LoanReAgingApiConstants.noteParamName)) { + final String note = command.stringValueOfParameterNamed(LoanReAgingApiConstants.noteParamName); final Note newNote = Note.loanNote(loan, note); - changes.put("note", note); + changes.put(LoanReAgingApiConstants.noteParamName, note); this.noteRepository.saveAndFlush(newNote); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java index 3936e8798..565e96403 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidator.java @@ -20,22 +20,30 @@ package org.apache.fineract.portfolio.loanaccount.service.reaging; import static org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.JsonParserHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants; import org.apache.fineract.portfolio.loanaccount.api.LoanReAgingApiConstants; import org.apache.fineract.portfolio.loanaccount.api.request.ReAgePreviewRequest; @@ -54,8 +62,17 @@ public class LoanReAgingValidator { private final LoanTransactionRepository loanTransactionRepository; private final CodeValueRepository codeValueRepository; + private final FromJsonHelper fromApiJsonHelper; + + private final List reAgeSupportedParameters = List.of(LoanReAgingApiConstants.externalIdParameterName, + LoanReAgingApiConstants.startDate, LoanReAgingApiConstants.frequencyType, LoanReAgingApiConstants.frequencyNumber, + LoanReAgingApiConstants.numberOfInstallments, LoanReAgingApiConstants.reAgeInterestHandlingParamName, + LoanReAgingApiConstants.reasonCodeValueIdParamName, LoanReAgingApiConstants.transactionAmountParamName, + LoanReAgingApiConstants.localeParameterName, LoanReAgingApiConstants.dateFormatParameterName, + LoanReAgingApiConstants.noteParamName); public void validateReAge(Loan loan, JsonCommand command) { + validateJSONAndCheckForUnsupportedParams(command.json()); validateReAgeRequest(loan, command); validateReAgeBusinessRules(loan); validateReAgeOutstandingBalance(loan, command); @@ -67,6 +84,15 @@ public class LoanReAgingValidator { validateReAgeOutstandingBalance(loan, reAgePreviewRequest); } + private void validateJSONAndCheckForUnsupportedParams(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, reAgeSupportedParameters); + } + private void validateReAgeRequest(Loan loan, JsonCommand command) { List dataValidationErrors = new ArrayList<>(); DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.reAge"); @@ -110,6 +136,10 @@ public class LoanReAgingValidator { } } + final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed(LoanReAgingApiConstants.transactionAmountParamName); + baseDataValidator.reset().parameter(LoanReAgingApiConstants.transactionAmountParamName).value(transactionAmount).ignoreIfNull() + .positiveAmount(); + throwExceptionIfValidationErrorsExist(dataValidationErrors); } @@ -193,7 +223,8 @@ public class LoanReAgingValidator { return; } - if (loan.getSummary().getTotalPrincipalOutstanding().compareTo(java.math.BigDecimal.ZERO) == 0) { + final BigDecimal totalPrincipalOutstanding = loan.getSummary().getTotalPrincipalOutstanding(); + if (MathUtil.isZero(totalPrincipalOutstanding)) { throw new GeneralPlatformDomainRuleException("error.msg.loan.reage.no.outstanding.balance.to.reage", "Loan cannot be re-aged as there are no outstanding balances to be re-aged", loan.getId()); } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java index 969b950b8..87d09b3a9 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/reaging/LoanReAgingValidatorTest.java @@ -70,6 +70,9 @@ class LoanReAgingValidatorTest { @Mock private LoanTransactionRepository loanTransactionRepository; + @Mock + private FromJsonHelper fromApiJsonHelper; + @InjectMocks private LoanReAgingValidator underTest; @@ -288,6 +291,32 @@ class LoanReAgingValidatorTest { .isEqualTo("validation.msg.loan.reAge.numberOfInstallments.not.greater.than.zero"); } + @Test + public void testValidateReAge_ShouldThrowException_WhenTransactionAmountIsZero() { + // given + Loan loan = loan(); + JsonCommand command = makeJsonCommand(""" + { + "externalId": "12345", + "dateFormat": "%s", + "locale": "en", + "startDate": "%s", + "frequencyType": "MONTHS", + "frequencyNumber": 1, + "numberOfInstallments": 1, + "transactionAmount": 0 + } + """.formatted(DATE_FORMAT, formatDate(afterMaturity))); + // when + PlatformApiDataValidationException result = assertThrows(PlatformApiDataValidationException.class, + () -> underTest.validateReAge(loan, command)); + // then + assertThat(result).isNotNull(); + assertThat(result.getGlobalisationMessageCode()).isEqualTo("validation.msg.validation.errors.exist"); + assertThat(result.getErrors().getFirst().getUserMessageGlobalisationCode()) + .isEqualTo("validation.msg.loan.reAge.transactionAmount.not.greater.than.zero"); + } + @Test public void testValidateReAge_ShouldThrowException_WhenStartDateIsBeforeMaturity() { // given diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index 4bf242ae1..77444cb53 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -1016,6 +1016,11 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { protected void reAgeLoan(Long loanId, String frequencyType, int frequencyNumber, String startDate, Integer numberOfInstallments, String reAgeInterestHandling) { + reAgeLoan(loanId, frequencyType, frequencyNumber, startDate, numberOfInstallments, reAgeInterestHandling, null); + } + + protected void reAgeLoan(Long loanId, String frequencyType, int frequencyNumber, String startDate, Integer numberOfInstallments, + String reAgeInterestHandling, Double transactionAmount) { PostLoansLoanIdTransactionsRequest request = new PostLoansLoanIdTransactionsRequest(); request.setDateFormat(DATETIME_PATTERN); request.setLocale("en"); @@ -1024,6 +1029,9 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { request.setStartDate(startDate); request.setNumberOfInstallments(numberOfInstallments); request.setReAgeInterestHandling(reAgeInterestHandling); + if (transactionAmount != null) { + request.transactionAmount(transactionAmount); + } loanTransactionHelper.reAge(loanId, request); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java index 7fe5ee87a..2b2c728af 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/reaging/LoanReAgingIntegrationTest.java @@ -949,6 +949,81 @@ public class LoanReAgingIntegrationTest extends BaseLoanIntegrationTest { }); } + @Test + public void test_LoanReAgeTransactionWithTransactionAmount() { + AtomicLong createdLoanId = new AtomicLong(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 1; + + // Create Loan Product + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProductWithAdvancedPaymentAllocation() // + .numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .enableDownPayment(true) // + .disbursedAmountPercentageForDownPayment(BigDecimal.valueOf(25)) // + .enableAutoRepaymentForDownPayment(true) // + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue()); // + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 1250.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .transactionProcessingStrategyCode(LoanProductTestBuilder.ADVANCED_PAYMENT_ALLOCATION_STRATEGY)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS); + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + Long loanId = approvedLoanResult.getLoanId(); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January 2023"); + createdLoanId.set(loanId); + }); + + runAt("12 April 2023", () -> { + long loanId = createdLoanId.get(); + + // try re-age transaction with transaction amount in Zero + CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, + () -> reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "12 April 2023", 4, + LoanReAgeInterestHandlingType.EQUAL_AMORTIZATION_FULL_INTEREST.name(), 0.0)); + assertEquals(400, exception.getResponse().code()); + assertTrue(exception.getMessage().contains("validation.msg.loan.reAge.transactionAmount.not.greater.than.zero")); + + // try re-age transaction with transaction amount lower than outstanding + exception = assertThrows(CallFailedRuntimeException.class, () -> reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, + "12 April 2023", 4, LoanReAgeInterestHandlingType.EQUAL_AMORTIZATION_FULL_INTEREST.name(), 900.0)); + assertEquals(403, exception.getResponse().code()); + assertTrue(exception.getMessage().contains("error.msg.loan.reage.amount.not.match.with.calculated.reage.amount")); + + // try re-age transaction with transaction amount higher than outstanding + exception = assertThrows(CallFailedRuntimeException.class, () -> reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, + "12 April 2023", 4, LoanReAgeInterestHandlingType.EQUAL_AMORTIZATION_FULL_INTEREST.name(), 5000.0)); + assertEquals(403, exception.getResponse().code()); + assertTrue(exception.getMessage().contains("error.msg.loan.reage.amount.not.match.with.calculated.reage.amount")); + + reAgeLoan(loanId, RepaymentFrequencyType.MONTHS_STRING, 1, "12 April 2023", 4, + LoanReAgeInterestHandlingType.EQUAL_AMORTIZATION_FULL_INTEREST.name(), 937.5); + + checkMaturityDates(loanId, LocalDate.of(2023, 7, 12), LocalDate.of(2023, 7, 12)); + }); + } + private HashMap getReAgeTemplate(Long loanId) { final String GET_REAGE_TEMPLATE_URL = "/fineract-provider/api/v1/loans/" + loanId + "/transactions/template?command=reAge&" + Utils.TENANT_IDENTIFIER;