FINERACT-2354: Validation of Re-age amount during submission

This commit is contained in:
Jose Alberto Hernandez
2025-12-08 09:53:03 +01:00
committed by Adam Saghy
parent 4c22e2a9fc
commit a58bdf49b6
7 changed files with 162 additions and 12 deletions
@@ -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;
@@ -31,4 +31,7 @@ public interface LoanReAgingApiConstants {
String reAgeInterestHandlingParamName = "reAgeInterestHandling";
String reasonCodeValueIdParamName = "reasonCodeValueId";
String transactionAmountParamName = "transactionAmount";
String noteParamName = "note";
}
@@ -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);
}
@@ -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());
}
@@ -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
@@ -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);
}
@@ -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;