This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new a58bdf49b6 FINERACT-2354: Validation of Re-age amount during submission
a58bdf49b6 is described below
commit a58bdf49b6c7d0dd8c274403c0f71671130a3211
Author: Jose Alberto Hernandez <[email protected]>
AuthorDate: Thu Nov 27 12:34:25 2025 -0500
FINERACT-2354: Validation of Re-age amount during submission
---
.../infrastructure/core/api/JsonCommand.java | 2 +-
.../loanaccount/api/LoanReAgingApiConstants.java | 3 +
.../service/reaging/LoanReAgingService.java | 24 ++++---
.../service/reaging/LoanReAgingValidator.java | 33 +++++++++-
.../service/reaging/LoanReAgingValidatorTest.java | 29 +++++++++
.../integrationtests/BaseLoanIntegrationTest.java | 8 +++
.../loan/reaging/LoanReAgingIntegrationTest.java | 75 ++++++++++++++++++++++
7 files changed, 162 insertions(+), 12 deletions(-)
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 23a21556a8..5b74dfcd37 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 701cb65c1f..fff896a51c 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 eefdb54a3b..1751816a8c 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<String,
Object> 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 3936e87983..565e964039 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<String> 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<Map<String, Object>>()
{}.getType();
+ fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json,
reAgeSupportedParameters);
+ }
+
private void validateReAgeRequest(Loan loan, JsonCommand command) {
List<ApiParameterError> 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 969b950b8a..87d09b3a9d 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 4bf242ae1a..77444cb535 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 7fe5ee87ae..2b2c728aff 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<String, Object> getReAgeTemplate(Long loanId) {
final String GET_REAGE_TEMPLATE_URL =
"/fineract-provider/api/v1/loans/" + loanId +
"/transactions/template?command=reAge&"
+ Utils.TENANT_IDENTIFIER;