FINERACT-1806: Charge-off validation

This commit is contained in:
Adam Saghy
2023-02-23 10:22:10 +01:00
parent f000de5364
commit 77da04fa7d
20 changed files with 836 additions and 53 deletions
@@ -3614,4 +3614,12 @@ public class CommandWrapperBuilder {
this.href = "/loans/" + loanId + "/transactions?command=charge-off";
return this;
}
public CommandWrapperBuilder undoChargeOff(final Long loanId) {
this.actionName = "UNDOCHARGEOFF";
this.entityName = "LOAN";
this.loanId = loanId;
this.href = "/loans/" + loanId + "/transactions?command=undo-charge-off";
return this;
}
}
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.business.domain.loan.transaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
public class LoanUndoChargeOffBusinessEvent extends LoanTransactionBusinessEvent {
private static final String TYPE = "LoanUndoChargeOffBusinessEvent";
public LoanUndoChargeOffBusinessEvent(LoanTransaction value) {
super(value);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -77,6 +77,7 @@ import org.springframework.stereotype.Component;
public class LoanTransactionsApiResource {
public static final String CHARGE_OFF_COMMAND_VALUE = "charge-off";
public static final String UNDO_CHARGE_OFF_COMMAND_VALUE = "undo-charge-off";
private final Set<String> responseDataParameters = new HashSet<>(Arrays.asList("id", "type", "date", "currency", "amount", "externalId",
LoanApiConstants.REVERSAL_EXTERNAL_ID_PARAMNAME, LoanApiConstants.REVERSED_ON_DATE_PARAMNAME));
@@ -463,6 +464,8 @@ public class LoanTransactionsApiResource {
commandRequest = builder.creditBalanceRefund(resolvedLoanId).build();
} else if (CommandParameterUtil.is(commandParam, CHARGE_OFF_COMMAND_VALUE)) {
commandRequest = builder.chargeOff(resolvedLoanId).build();
} else if (CommandParameterUtil.is(commandParam, UNDO_CHARGE_OFF_COMMAND_VALUE)) {
commandRequest = builder.undoChargeOff(resolvedLoanId).build();
}
if (commandRequest == null) {
@@ -7027,10 +7027,29 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
return getLoanTransactions().stream().filter(predicate).toList();
}
public LoanTransaction findChargedOffTransaction() {
return getLoanTransactions().stream() //
.filter(LoanTransaction::isNotReversed) //
.filter(LoanTransaction::isChargeOff) //
.findFirst() //
.orElse(null);
}
public void handleMaturityDateActivate() {
if (this.expectedMaturityDate != null && this.actualMaturityDate == null) {
this.actualMaturityDate = this.expectedMaturityDate;
}
}
public LoanTransaction getLastUserTransaction() {
return getLoanTransactions().stream() //
.filter(LoanTransaction::isNotReversed) //
.filter(t -> !(t.isAccrualTransaction() || t.isIncomePosting())) //
.reduce((first, second) -> second) //
.orElse(null);
}
public LocalDate getChargedOffOnDate() {
return chargedOffOnDate;
}
}
@@ -161,7 +161,12 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
final boolean isRecoveryRepayment, final String chargeRefundChargeType, boolean isAccountTransfer,
HolidayDetailDTO holidayDetailDto, Boolean isHolidayValidationDone, final boolean isLoanToLoanTransfer) {
checkClientOrGroupActive(loan);
if (loan.isChargedOff() && transactionDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loan.getId()
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loan.getId());
}
LoanBusinessEvent repaymentEvent = getLoanRepaymentTypeBusinessEvent(repaymentTransactionType, isRecoveryRepayment, loan);
businessEventNotifierService.notifyPreBusinessEvent(repaymentEvent);
@@ -357,6 +362,12 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
final Integer transactionType, Integer installmentNumber) {
boolean isAccountTransfer = true;
checkClientOrGroupActive(loan);
if (loan.isChargedOff() && transactionDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loan.getId()
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loan.getId());
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanChargePaymentPreBusinessEvent(loan));
final List<Long> existingTransactionIds = new ArrayList<>();
@@ -436,6 +447,12 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
boolean isAccountTransfer = true;
final Loan loan = this.loanAccountAssembler.assembleFrom(accountId);
checkClientOrGroupActive(loan);
if (loan.isChargedOff() && transactionDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loan.getId()
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loan.getId());
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanRefundPreBusinessEvent(loan));
final List<Long> existingTransactionIds = new ArrayList<>();
final List<Long> existingReversedTransactionIds = new ArrayList<>();
@@ -482,6 +499,12 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
final PaymentDetail paymentDetail, final String noteText, final ExternalId txnExternalId, final boolean isLoanToLoanTransfer) {
final Loan loan = this.loanAccountAssembler.assembleFrom(loanId);
checkClientOrGroupActive(loan);
if (loan.isChargedOff() && transactionDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loan.getId()
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loan.getId());
}
boolean isAccountTransfer = true;
final List<Long> existingTransactionIds = new ArrayList<>();
final List<Long> existingReversedTransactionIds = new ArrayList<>();
@@ -508,6 +531,13 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
@Override
public void reverseTransfer(final LoanTransaction loanTransaction) {
if (loanTransaction.getLoan().isChargedOff()
&& loanTransaction.getTransactionDate().isBefore(loanTransaction.getLoan().getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date",
"Loan transaction: " + loanTransaction.getId()
+ " reversal is not allowed before or on the date when the loan got charged-off",
loanTransaction.getId());
}
loanTransaction.reverse();
saveLoanTransactionWithDataIntegrityViolationChecks(loanTransaction);
}
@@ -633,6 +663,13 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
@Override
public LoanTransaction creditBalanceRefund(final Loan loan, final LocalDate transactionDate, final BigDecimal transactionAmount,
final String noteText, final ExternalId externalId, PaymentDetail paymentDetail) {
if (loan.isChargedOff() && transactionDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loan.getId()
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loan.getId());
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanCreditBalanceRefundPreBusinessEvent(loan));
final List<Long> existingTransactionIds = new ArrayList<>();
final List<Long> existingReversedTransactionIds = new ArrayList<>();
@@ -670,6 +707,12 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
final List<Long> existingReversedTransactionIds = new ArrayList<>();
final Money refundAmount = Money.of(loan.getCurrency(), transactionAmount);
if (loan.isChargedOff() && transactionDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loan.getId()
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loan.getId());
}
final LoanTransaction newRefundTransaction = LoanTransaction.refundForActiveLoan(loan.getOffice(), refundAmount, paymentDetail,
transactionDate, txnExternalId);
final boolean allowTransactionsOnHoliday = this.configurationDomainService.allowTransactionsOnHolidayEnabled();
@@ -702,6 +745,12 @@ public class LoanAccountDomainServiceJpa implements LoanAccountDomainService {
@Override
public LoanTransaction foreCloseLoan(final Loan loan, final LocalDate foreClosureDate, final String noteText,
final ExternalId externalId, Map<String, Object> changes) {
if (loan.isChargedOff() && foreClosureDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loan.getId()
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loan.getId());
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanForeClosurePreBusinessEvent(loan));
MonetaryCurrency currency = loan.getCurrency();
List<LoanTransaction> newTransactions = new ArrayList<>();
@@ -0,0 +1,43 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.loanaccount.service.LoanWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@CommandType(entity = "LOAN", action = "UNDOCHARGEOFF")
public class UndoChargeOffLoanCommandHandler implements NewCommandSourceHandler {
private final LoanWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return writePlatformService.undoChargeOff(command);
}
}
@@ -39,6 +39,7 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -131,6 +132,11 @@ public class LoanRescheduleRequestWritePlatformServiceImpl implements LoanResche
// use the loan id to get a Loan entity object
final Loan loan = this.loanAssembler.assembleFrom(loanId);
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.charged.off",
"Loan: " + loanId + " reschedule installment is not allowed. Loan Account is Charged-off", loanId);
}
// validate the request in the JsonCommand object passed as
// parameter
this.loanRescheduleRequestDataValidator.validateForCreateAction(jsonCommand, loan);
@@ -33,6 +33,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
@@ -41,6 +42,7 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError;
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.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -131,6 +133,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatformService {
@@ -173,7 +176,10 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo
Loan loan = this.loanAssembler.assembleFrom(loanId);
checkClientOrGroupActive(loan);
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.charged.off",
"Adding charge to Loan: " + loanId + " is not allowed. Loan Account is Charged-off", loanId);
}
List<LoanDisbursementDetails> loanDisburseDetails = loan.getDisbursementDetails();
final Long chargeDefinitionId = command.longValueOfParameterNamed("chargeId");
final Charge chargeDefinition = this.chargeRepository.findOneWithNotFoundDetection(chargeDefinitionId);
@@ -360,7 +366,13 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo
throw new LoanChargeWaiveCannotBeReversedException(
LoanChargeWaiveCannotBeReversedException.LoanChargeWaiveCannotUndoReason.LOAN_INACTIVE, loanCharge.getId());
}
if (loan.isChargedOff() && (loanTransaction.getTransactionDate().isBefore(loan.getChargedOffOnDate())
|| loanTransaction.getTransactionDate().isEqual(loan.getChargedOffOnDate()))) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date",
"Undo Loan transaction: " + loanTransaction.getId()
+ " is not allowed before or on the date when the loan got charged-off",
loanTransaction.getId());
}
final Map<String, Object> changes = new LinkedHashMap<>();
businessEventNotifierService.notifyPreBusinessEvent(new LoanWaiveChargeUndoBusinessEvent(loanCharge));
@@ -713,6 +725,10 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo
public void applyOverdueChargesForLoan(final Long loanId, Collection<OverdueLoanScheduleData> overdueLoanScheduleDataList) {
Loan loan = this.loanAssembler.assembleFrom(loanId);
if (loan.isChargedOff()) {
log.warn("Adding charge to Loan: {} is not allowed. Loan Account is Charged-off", loanId);
return;
}
final List<Long> existingTransactionIds = loan.findExistingTransactionIds();
final List<Long> existingReversedTransactionIds = loan.findExistingReversedTransactionIds();
boolean runInterestRecalculation = false;
@@ -112,4 +112,7 @@ public interface LoanWritePlatformService {
CommandProcessingResult markLoanAsFraud(Long loanId, JsonCommand command);
CommandProcessingResult chargeOff(JsonCommand command);
@Transactional
CommandProcessingResult undoChargeOff(JsonCommand command);
}
@@ -80,6 +80,7 @@ import org.apache.fineract.infrastructure.event.business.domain.loan.LoanWithdra
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanChargeOffPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanChargeOffPreBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanDisbursalTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanUndoChargeOffBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanUndoWrittenOffBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanWaiveInterestBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanWrittenOffPostBusinessEvent;
@@ -286,6 +287,16 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
Loan loan = this.loanAssembler.assembleFrom(loanId);
// Fail fast if client/group is not active or actual loan status disallows disbursal
checkClientOrGroupActive(loan);
final LocalDate actualDisbursementDate = command.localDateValueOfParameterNamed("actualDisbursementDate");
if (loan.isChargedOff() && actualDisbursementDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loanId
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loanId);
}
if (loan.loanProduct().isDisallowExpectedDisbursements()) {
List<LoanDisbursementDetails> filteredList = loan.getDisbursementDetails().stream()
.filter(disbursementDetails -> disbursementDetails.actualDisbursementDate() == null).toList();
@@ -326,8 +337,6 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
}
}
final LocalDate actualDisbursementDate = command.localDateValueOfParameterNamed("actualDisbursementDate");
// validate ActualDisbursement Date Against Expected Disbursement Date
LoanProduct loanProduct = loan.loanProduct();
if (loanProduct.syncExpectedWithDisbursementDate()) {
@@ -805,6 +814,10 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
Loan loan = this.loanAssembler.assembleFrom(loanId);
checkClientOrGroupActive(loan);
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.charged.off",
"Undo Loan: " + loanId + " disbursement is not allowed. Loan Account is Charged-off", loanId);
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanUndoDisbursalBusinessEvent(loan));
removeLoanCycle(loan);
final List<Long> existingTransactionIds = new ArrayList<>();
@@ -1057,6 +1070,12 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
throw new PlatformServiceUnavailableException("error.msg.loan.written.off.update.not.allowed",
"Loan transaction:" + transactionId + " update not allowed as loan status is written off", transactionId);
}
if (loan.isChargedOff() && (transactionToAdjust.getTransactionDate().isBefore(loan.getChargedOffOnDate())
|| transactionToAdjust.getTransactionDate().isEqual(loan.getChargedOffOnDate()))) {
throw new GeneralPlatformDomainRuleException("error.msg.adjusted.transaction.date.cannot.be.earlier.than.charge.off.date",
"Loan transaction: " + transactionId + " adjustment is not allowed before or on the date when the loan got charged-off",
transactionId);
}
if (transactionToAdjust.hasChargebackLoanTransactionRelations()) {
throw new PlatformServiceUnavailableException("error.msg.loan.transaction.update.not.allowed",
@@ -1411,6 +1430,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
changes.put("transactionDate", command.stringValueOfParameterNamed("transactionDate"));
changes.put("locale", command.locale());
changes.put("dateFormat", command.dateFormat());
LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
final Loan loan = this.loanAssembler.assembleFrom(loanId);
if (command.hasParameter("writeoffReasonId")) {
Long writeoffReasonId = command.longValueOfParameterNamed("writeoffReasonId");
@@ -1421,6 +1441,12 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
}
checkClientOrGroupActive(loan);
if (loan.isChargedOff() && transactionDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loanId
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loanId);
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanWrittenOffPreBusinessEvent(loan));
entityDatatableChecksWritePlatformService.runTheCheckForProduct(loanId, EntityTables.LOAN.getName(),
StatusEnum.WRITE_OFF.getCode().longValue(), EntityTables.LOAN.getForeignKeyColumnNameOnDatatable(), loan.productId());
@@ -1479,6 +1505,14 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final Loan loan = this.loanAssembler.assembleFrom(loanId);
checkClientOrGroupActive(loan);
LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
if (loan.isChargedOff() && transactionDate.isBefore(loan.getChargedOffOnDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.transaction.date.cannot.be.earlier.than.charge.off.date", "Loan: "
+ loanId
+ " backdated transaction is not allowed. Transaction date cannot be earlier than the charge-off date of the loan",
loanId);
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanCloseBusinessEvent(loan));
final Map<String, Object> changes = new LinkedHashMap<>();
@@ -1579,6 +1613,10 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final Loan loan = this.loanAssembler.assembleFrom(loanId);
checkClientOrGroupActive(loan);
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.charged.off",
"Loan: " + loanId + " Close as rescheduled is not allowed. Loan Account is Charged-off", loanId);
}
removeLoanCycle(loan);
businessEventNotifierService.notifyPreBusinessEvent(new LoanCloseAsRescheduleBusinessEvent(loan));
@@ -1929,6 +1967,11 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
loan.getExpectedFirstRepaymentOnDate(), presentMeetingDate);
}
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.charged.off",
"Loan: " + loan.getId() + " reschedule is not allowed. Loan Account is Charged-off", loan.getId());
}
Boolean isSkipRepaymentOnFirstMonth = false;
int numberOfDays = 0;
boolean isSkipRepaymentOnFirstMonthEnabled = configurationDomainService.isSkippingMeetingOnFirstDayOfMonthEnabled();
@@ -2219,6 +2262,10 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final Loan loan = this.loanAssembler.assembleFrom(loanId);
checkClientOrGroupActive(loan);
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.charged.off",
"Update Loan: " + loanId + " disbursement details is not allowed. Loan Account is Charged-off", loanId);
}
final Map<String, Object> actualChanges = new LinkedHashMap<>();
LocalDate expectedDisbursementDate = loan.getExpectedDisbursedOnLocalDate();
if (!loan.loanProduct().isMultiDisburseLoan()) {
@@ -2314,6 +2361,10 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final Loan loan = this.loanAssembler.assembleFrom(loanId);
checkClientOrGroupActive(loan);
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.charged.off",
"Update Loan: " + loanId + " disbursement details is not allowed. Loan Account is Charged-off", loanId);
}
LoanDisbursementDetails loanDisbursementDetails = loan.fetchLoanDisbursementsById(disbursementId);
this.loanEventApiJsonValidator.validateUpdateDisbursementDateAndAmount(command.json(), loanDisbursementDetails);
@@ -2565,6 +2616,10 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final LocalDate recalculateFromDate = loan.getLastRepaymentDate();
validateIsMultiDisbursalLoanAndDisbursedMoreThanOneTranche(loan);
checkClientOrGroupActive(loan);
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.charged.off",
"Undo Loan: " + loanId + " last disbursement is not allowed. Loan Account is Charged-off", loanId);
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanUndoLastDisbursalBusinessEvent(loan));
final MonetaryCurrency currency = loan.getCurrency();
@@ -2655,16 +2710,31 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
command.stringValueOfParameterNamed(LoanApiConstants.transactionDateParamName));
changes.put(LoanApiConstants.localeParameterName, command.locale());
changes.put(LoanApiConstants.dateFormatParameterName, command.dateFormat());
// TODO: add business logic validation (transaction date cannot be future, cannot be earlier than latest
// transactions, etc)
Loan loan = loanAssembler.assembleFrom(command.getLoanId());
businessEventNotifierService.notifyPreBusinessEvent(new LoanChargeOffPreBusinessEvent(loan));
final LocalDate transactionDate = command.localDateValueOfParameterNamed(LoanApiConstants.transactionDateParamName);
final ExternalId txnExternalId = externalIdFactory.createFromCommand(command, LoanApiConstants.externalIdParameterName);
final AppUser currentUser = getAppUserIfPresent();
Loan loan = loanAssembler.assembleFrom(command.getLoanId());
final Long loanId = loan.getId();
if (!loan.isOpen()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.not.active",
"Loan: " + loanId + " Charge-off is not allowed. Loan Account is not Active", loanId);
}
if (loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.already.charged.off",
"Loan: " + loanId + " is already charged-off", loanId);
}
if (transactionDate.isBefore(loan.getLastUserTransactionDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.charge.off.is.before.than.the.last.user.transaction",
"Loan: " + loanId + " charge-off cannot be executed. User transaction was found after the charge-off transaction date!",
loanId);
}
if (loan.isInterestBearing()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.interest.bearing",
"Loan: " + loanId + " Charge-off is not allowed. Loan Account is interest bearing", loanId);
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanChargeOffPreBusinessEvent(loan));
if (command.hasParameter(LoanApiConstants.chargeOffReasonIdParamName)) {
Long chargeOffReasonId = command.longValueOfParameterNamed(LoanApiConstants.chargeOffReasonIdParamName);
CodeValue chargeOffReason = this.codeValueRepository
@@ -2703,6 +2773,49 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
.with(changes).build();
}
@Override
@Transactional
public CommandProcessingResult undoChargeOff(JsonCommand command) {
final Long loanId = command.getLoanId();
final Loan loan = this.loanAssembler.assembleFrom(loanId);
final List<Long> existingTransactionIds = loan.findExistingTransactionIds();
final List<Long> existingReversedTransactionIds = loan.findExistingReversedTransactionIds();
checkClientOrGroupActive(loan);
if (!loan.isOpen()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.not.active",
"Loan: " + loanId + " Undo Charge-off is not allowed. Loan Account is not Active", loanId);
}
if (!loan.isChargedOff()) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.is.not.charged.off", "Loan: " + loanId + " is not charged-off",
loanId);
}
LoanTransaction chargedOffTransaction = loan.findChargedOffTransaction();
if (chargedOffTransaction == null) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.charge.off.transaction.not.found",
"Loan: " + loanId + " charge-off transaction was not found", loanId);
}
if (!chargedOffTransaction.equals(loan.getLastUserTransaction())) {
throw new GeneralPlatformDomainRuleException("error.msg.loan.charge.off.is.not.the.last.user.transaction",
"Loan: " + loanId + " charge-off cannot be undone. User transaction was found after charge-off!", loanId);
}
businessEventNotifierService.notifyPreBusinessEvent(new LoanUndoChargeOffBusinessEvent(chargedOffTransaction));
chargedOffTransaction.reverse();
loan.liftChargeOff();
loanTransactionRepository.saveAndFlush(chargedOffTransaction);
saveLoanWithDataIntegrityViolationChecks(loan);
postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoChargeOffBusinessEvent(chargedOffTransaction));
return new CommandProcessingResultBuilder() //
.withOfficeId(loan.getOfficeId()) //
.withClientId(loan.getClientId()) //
.withGroupId(loan.getGroupId()) //
.withLoanId(loanId) //
.withEntityId(chargedOffTransaction.getId()) //
.withEntityExternalId(chargedOffTransaction.getExternalId()) //
.build();
}
private void validateIsMultiDisbursalLoanAndDisbursedMoreThanOneTranche(Loan loan) {
if (!loan.isMultiDisburmentLoan()) {
final String errorMessage = "loan.product.does.not.support.multiple.disbursals.cannot.undo.last.disbursal";
@@ -113,4 +113,5 @@
<include file="parts/0091_modify_parameter_value_type_in_job_parameters_table.xml" relativeToChangelogFile="true" />
<include file="parts/0092_add_periodic_accrual_entries_business_step.xml" relativeToChangelogFile="true" />
<include file="parts/0093_update_general_accounting_table_reports.xml" relativeToChangelogFile="true" />
<include file="parts/0094_add_external_event_default_configuration.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="1">
<insert tableName="m_external_event_configuration">
<column name="type" value="LoanUndoChargeOffBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet id="2" author="fineract">
<insert tableName="m_permission">
<column name="grouping" value="transaction_loan"/>
<column name="code" value="UNDOCHARGEOFF_LOAN"/>
<column name="entity_name" value="LOAN"/>
<column name="action_name" value="UNDOCHARGEOFF"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -95,7 +95,7 @@ public class ExternalEventConfigurationValidationServiceTest {
"ShareAccountApproveBusinessEvent", "ShareAccountCreateBusinessEvent", "ShareProductDividentsCreateBusinessEvent",
"LoanChargeAdjustmentPostBusinessEvent", "LoanChargeAdjustmentPreBusinessEvent", "LoanDelinquencyRangeChangeBusinessEvent",
"LoanAccountsStayedLockedBusinessEvent", "MockBusinessEvent", "LoanChargeOffPreBusinessEvent",
"LoanChargeOffPostBusinessEvent");
"LoanChargeOffPostBusinessEvent", "LoanUndoChargeOffBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -171,7 +171,8 @@ public class ExternalEventConfigurationValidationServiceTest {
"SavingsPostInterestBusinessEvent", "SavingsRejectBusinessEvent", "SavingsWithdrawalBusinessEvent",
"ShareAccountApproveBusinessEvent", "ShareAccountCreateBusinessEvent", "ShareProductDividentsCreateBusinessEvent",
"LoanChargeAdjustmentPostBusinessEvent", "LoanChargeAdjustmentPreBusinessEvent", "LoanDelinquencyRangeChangeBusinessEvent",
"LoanAccountsStayedLockedBusinessEvent", "LoanChargeOffPreBusinessEvent", "LoanChargeOffPostBusinessEvent");
"LoanAccountsStayedLockedBusinessEvent", "LoanChargeOffPreBusinessEvent", "LoanChargeOffPostBusinessEvent",
"LoanUndoChargeOffBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -19,18 +19,23 @@
package org.apache.fineract.portfolio.loanaccount.domain;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType;
import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
/**
@@ -76,6 +81,95 @@ public class LoanTest {
assertEquals(0, chargeIds.size());
}
/**
* Tests {@link Loan#findChargedOffTransaction()} with empty list
*/
@Test
public void testFindChargedOffTransactionEmptyList() {
final Loan loan = new Loan();
final LoanTransaction chargedOffTransaction = loan.findChargedOffTransaction();
assertNull(chargedOffTransaction);
}
/**
* Tests {@link Loan#findChargedOffTransaction()} where there is no charge-off transaction
*/
@Test
public void testFindChargedOffTransactionNoChargeOffTransaction() {
final Loan loan = new Loan();
final LoanTransaction loanTransaction = new LoanTransaction();
ReflectionTestUtils.setField(loan, "loanTransactions", List.of(loanTransaction));
final LoanTransaction chargedOffTransaction = loan.findChargedOffTransaction();
assertNull(chargedOffTransaction);
}
/**
* Tests {@link Loan#findChargedOffTransaction()} where there is charge-off transaction
*/
@Test
public void testFindChargedOffTransactionWithChargeOffTransaction() {
final Loan loan = new Loan();
final LoanTransaction loanTransaction = new LoanTransaction();
final LoanTransaction loanTransaction2 = Mockito.mock(LoanTransaction.class);
final LoanTransaction loanTransaction3 = new LoanTransaction();
when(loanTransaction2.isNotReversed()).thenReturn(Boolean.TRUE);
when(loanTransaction2.isChargeOff()).thenReturn(Boolean.TRUE);
ReflectionTestUtils.setField(loan, "loanTransactions", List.of(loanTransaction, loanTransaction2, loanTransaction3));
final LoanTransaction chargedOffTransaction = loan.findChargedOffTransaction();
assertNotNull(chargedOffTransaction);
assertEquals(loanTransaction2, chargedOffTransaction);
}
/**
* Tests {@link Loan#findChargedOffTransaction()} where there is charge-off transaction but reversed
*/
@Test
public void testFindChargedOffTransactionWithReversedChargeOffTransaction() {
final Loan loan = new Loan();
final LoanTransaction loanTransaction = new LoanTransaction();
final LoanTransaction loanTransaction2 = Mockito.mock(LoanTransaction.class);
final LoanTransaction loanTransaction3 = new LoanTransaction();
when(loanTransaction2.isReversed()).thenReturn(Boolean.TRUE);
when(loanTransaction2.isChargeOff()).thenReturn(Boolean.TRUE);
ReflectionTestUtils.setField(loan, "loanTransactions", List.of(loanTransaction, loanTransaction2, loanTransaction3));
final LoanTransaction chargedOffTransaction = loan.findChargedOffTransaction();
assertNull(chargedOffTransaction);
}
/**
* Tests {@link Loan#getLastUserTransaction()} with empty list
*/
@Test
public void testGetLastUserTransactionEmptyList() {
final Loan loan = new Loan();
final LoanTransaction userTransaction = loan.getLastUserTransaction();
assertNull(userTransaction);
}
/**
* Tests {@link Loan#getLastUserTransaction()} where there are user transactions
*/
@Test
public void testGetLastUserTransaction() {
final Loan loan = new Loan();
final LoanTransaction loanTransaction = Mockito.mock(LoanTransaction.class);
when(loanTransaction.isNotReversed()).thenReturn(Boolean.TRUE);
when(loanTransaction.isAccrualTransaction()).thenReturn(Boolean.FALSE);
final LoanTransaction loanTransaction2 = Mockito.mock(LoanTransaction.class);
when(loanTransaction2.isNotReversed()).thenReturn(Boolean.TRUE);
when(loanTransaction2.isAccrualTransaction()).thenReturn(Boolean.FALSE);
final LoanTransaction loanTransaction3 = Mockito.mock(LoanTransaction.class);
when(loanTransaction3.isNotReversed()).thenReturn(Boolean.TRUE);
when(loanTransaction3.isAccrualTransaction()).thenReturn(Boolean.TRUE);
ReflectionTestUtils.setField(loan, "loanTransactions", List.of(loanTransaction, loanTransaction2, loanTransaction3));
final LoanTransaction userTransaction = loan.getLastUserTransaction();
assertNotNull(userTransaction);
assertEquals(loanTransaction2, userTransaction);
}
/**
* Builds a new loan charge.
*