Skip to content

Commit

Permalink
FINERACT-1806: Advanced accounting - Charge-off reason
Browse files Browse the repository at this point in the history
  • Loading branch information
adamsaghy committed Jan 7, 2025
1 parent b7e6f35 commit e1d043b
Show file tree
Hide file tree
Showing 10 changed files with 159 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ List<ProductToGLAccountMapping> findAllChargeOffReasonsMappings(@Param("productI
@Param("productType") int productType);

@Query("select mapping from ProductToGLAccountMapping mapping where mapping.chargeOffReason.id =:chargeOffReasonId")
ProductToGLAccountMapping findChargeOffReasonMappingById(@Param("chargeOffReasonId") Integer chargeOffReasonId);
ProductToGLAccountMapping findChargeOffReasonMappingById(@Param("chargeOffReasonId") Long chargeOffReasonId);

@Query("select mapping from ProductToGLAccountMapping mapping where mapping.productId =:productId AND mapping.productType =:productType AND mapping.charge IS NULL AND mapping.paymentType IS NULL AND mapping.chargeOffReason IS NULL")
List<ProductToGLAccountMapping> findAllRegularMappings(@Param("productId") Long productId, @Param("productType") Integer productType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2190,13 +2190,9 @@ private Map<String, Object> buildAccountingMapForChargeOffDateCriteria(final Str
boolean isBeforeChargeOffDate) {
final Map<String, Object> accountingBridgeDataChargeOff = new LinkedHashMap<>(
getAccountingBridgeDataGenericAttributes(currencyCode, isAccountTransfer));
if (isBeforeChargeOffDate) {
accountingBridgeDataChargeOff.put("isChargeOff", false);
accountingBridgeDataChargeOff.put("isFraud", isFraud());
} else {
accountingBridgeDataChargeOff.put("isChargeOff", isChargedOff());
accountingBridgeDataChargeOff.put("isFraud", isFraud());
}
accountingBridgeDataChargeOff.put("isChargeOff", !isBeforeChargeOffDate && isChargedOff());
accountingBridgeDataChargeOff.put("isFraud", isFraud());
accountingBridgeDataChargeOff.put("chargeOffReasonCodeValue", fetchChargeOffReasonId());
return accountingBridgeDataChargeOff;
}

Expand Down Expand Up @@ -2274,6 +2270,8 @@ public Map<String, Object> deriveAccountingBridgeData(final String currencyCode,
accountingBridgeData.put("periodicAccrualBasedAccountingEnabled", isPeriodicAccrualAccountingEnabledOnLoanProduct());
accountingBridgeData.put("isAccountTransfer", isAccountTransfer);
accountingBridgeData.put("isChargeOff", isChargedOff());
accountingBridgeData.put("chargeOffReasonCodeValue", fetchChargeOffReasonId());

accountingBridgeData.put("isFraud", isFraud());

final List<Map<String, Object>> newLoanTransactions = new ArrayList<>();
Expand All @@ -2290,6 +2288,10 @@ public Map<String, Object> deriveAccountingBridgeData(final String currencyCode,
return accountingBridgeData;
}

private Long fetchChargeOffReasonId() {
return isChargedOff() && getChargeOffReason() != null ? getChargeOffReason().getId() : null;
}

public Money getReceivableInterest(final LocalDate tillDate) {
Money receivableInterest = Money.zero(getCurrency());
for (final LoanTransaction transaction : this.loanTransactions) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ public class LoanDTO {
private boolean markedAsChargeOff;
@Setter
private boolean markedAsFraud;
private Integer chargeOffReasonCodeValue;
private Long chargeOffReasonCodeValue;
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public LoanDTO populateLoanDtoFromMap(final Map<String, Object> accountingBridge
boolean isAccountTransfer = (Boolean) accountingBridgeData.get("isAccountTransfer");
boolean isLoanMarkedAsChargeOff = (Boolean) accountingBridgeData.get("isChargeOff");
boolean isLoanMarkedAsFraud = (Boolean) accountingBridgeData.get("isFraud");
final Integer chargeOffReasonCodeValue = (Integer) accountingBridgeData.get("chargeOffReasonCodeValue");
final Long chargeOffReasonCodeValue = (Long) accountingBridgeData.get("chargeOffReasonCodeValue");

@SuppressWarnings("unchecked")
final List<Map<String, Object>> newTransactionsMap = (List<Map<String, Object>>) accountingBridgeData.get("newLoanTransactions");
Expand Down Expand Up @@ -177,7 +177,7 @@ public LoanDTO populateLoanDtoFromMap(final Map<String, Object> accountingBridge
chargeOffReasonCodeValue);
}

public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Integer chargeOffReasonCodeValue) {
public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long chargeOffReasonCodeValue) {
return accountMappingRepository.findChargeOffReasonMappingById(chargeOffReasonCodeValue);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ private void createJournalEntriesForChargeOff(LoanDTO loanDTO, LoanTransactionDT
GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder();

// need to fetch if there are account mappings (always one)
Integer chargeOffReasonCodeValue = loanDTO.getChargeOffReasonCodeValue();
Long chargeOffReasonCodeValue = loanDTO.getChargeOffReasonCodeValue();

ProductToGLAccountMapping mapping = chargeOffReasonCodeValue != null
? helper.getChargeOffMappingByCodeValue(chargeOffReasonCodeValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ private PostCodeValueDataResponse() {

@Schema(example = "4")
public Long resourceId;
@Schema(example = "4")
public Long subResourceId;
}

@Schema(description = "PutCodeValuesDataRequest")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
@ExtendWith(MockitoExtension.class)
class CreateJournalEntriesForChargeOffLoanTest {

private static final Integer chargeOffReasons = 15;
private static final Long chargeOffReasons = 15L;

@Mock
private AccountingProcessorHelper helper;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ public abstract class BaseLoanIntegrationTest {
protected BusinessDateHelper businessDateHelper = new BusinessDateHelper();
protected DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATETIME_PATTERN);
protected GlobalConfigurationHelper globalConfigurationHelper = new GlobalConfigurationHelper();
protected final CodeHelper codeHelper = new CodeHelper();

protected static void validateRepaymentPeriod(GetLoansLoanIdResponse loanDetails, Integer index, LocalDate dueDate, double principalDue,
double principalPaid, double principalOutstanding, double paidInAdvance, double paidLate) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,21 @@
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.apache.fineract.client.models.AllowAttributeOverrides;
import org.apache.fineract.client.models.GetCodesResponse;
import org.apache.fineract.client.models.GetLoansLoanIdResponse;
import org.apache.fineract.client.models.PostChargeOffReasonToExpenseAccountMappings;
import org.apache.fineract.client.models.PostCodeValueDataResponse;
import org.apache.fineract.client.models.PostCodeValuesDataRequest;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdChargesChargeIdRequest;
import org.apache.fineract.client.models.PostLoansLoanIdChargesChargeIdResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
Expand All @@ -54,6 +63,7 @@
import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper;
import org.apache.fineract.integrationtests.common.system.CodeHelper;
import org.apache.fineract.integrationtests.inlinecob.InlineLoanCOBHelper;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

Expand Down Expand Up @@ -789,6 +799,125 @@ public void noIncomeRecognitionAfterChargeOff() {
}
}

@Test
public void advancedAccountingForChargeOff() {
runAt("02 January 2023", () -> {
final Account chargeOffDelinquentExpenseAccount = accountHelper
.createExpenseAccount("delinquent_expense_for_charge_off_reason");
GetCodesResponse chargeOffReasonCode = fetchChargeOffReasonCode();
PostCodeValueDataResponse chargeOffReason = codeHelper.createCodeValue(chargeOffReasonCode.getId(),
new PostCodeValuesDataRequest().name(Utils.uniqueRandomStringGenerator("DELINQUENT_", 6)).isActive(true).position(10));
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId();
PostLoanProductsResponse productsResponse = createLoanProductWithAdvancedChargeOffAccounting(chargeOffReason,
chargeOffDelinquentExpenseAccount);
// Apply and Approve Loan
Long loanId = applyAndApproveLoan(clientId, productsResponse.getResourceId(), "01 January 2023", 1000.0, 1);
// Disburse Loan
disburseLoan(loanId, BigDecimal.valueOf(1000.00), "01 January 2023");

PostLoansLoanIdTransactionsResponse chargeOffTransaction = this.loanTransactionHelper.chargeOffLoan(loanId,
new PostLoansLoanIdTransactionsRequest().transactionDate("02 January 2023").locale("en").dateFormat("dd MMMM yyyy")
.chargeOffReasonId(chargeOffReason.getSubResourceId()));
// verify journal entries
verifyTRJournalEntries(chargeOffTransaction.getResourceId(), journalEntry(1000.0, loansReceivableAccount, "CREDIT"), //
journalEntry(1000.0, chargeOffDelinquentExpenseAccount, "DEBIT"));
});
}

private PostLoanProductsResponse createLoanProductWithAdvancedChargeOffAccounting(PostCodeValueDataResponse chargeOffReason,
Account chargeOffDelinquentExpenseAccount) {
return this.loanTransactionHelper.createLoanProduct(new PostLoanProductsRequest()
.name(Utils.uniqueRandomStringGenerator("LOAN_PRODUCT_", 6))//
.shortName(Utils.uniqueRandomStringGenerator("", 4))//
.description("Loan Product Description")//
.includeInBorrowerCycle(false)//
.currencyCode("USD")//
.digitsAfterDecimal(2)//
.inMultiplesOf(0)//
.installmentAmountInMultiplesOf(1)//
.useBorrowerCycle(false)//
.minPrincipal(100.0)//
.principal(1000.0)//
.maxPrincipal(100000.0)//
.minNumberOfRepayments(1)//
.numberOfRepayments(1)//
.maxNumberOfRepayments(30)//
.isLinkedToFloatingInterestRates(false)//
.minInterestRatePerPeriod((double) 0)//
.interestRatePerPeriod(0.0)//
.maxInterestRatePerPeriod((double) 100)//
.interestRateFrequencyType(2)//
.repaymentEvery(30)//
.repaymentFrequencyType(0L)//
.amortizationType(1)//
.interestType(0)//
.isEqualAmortization(false)//
.interestCalculationPeriodType(1)//
.transactionProcessingStrategyCode(
LoanProductTestBuilder.DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_STRATEGY)//
.loanScheduleType(LoanScheduleType.CUMULATIVE.toString()) //
.daysInYearType(1)//
.daysInMonthType(1)//
.canDefineInstallmentAmount(true)//
.graceOnArrearsAgeing(3)//
.overdueDaysForNPA(179)//
.accountMovesOutOfNPAOnlyOnArrearsCompletion(false)//
.principalThresholdForLastInstallment(50)//
.allowVariableInstallments(false)//
.canUseForTopup(false)//
.isInterestRecalculationEnabled(false)//
.holdGuaranteeFunds(false)//
.multiDisburseLoan(true)//
.allowAttributeOverrides(new AllowAttributeOverrides()//
.amortizationType(true)//
.interestType(true)//
.transactionProcessingStrategyCode(true)//
.interestCalculationPeriodType(true)//
.inArrearsTolerance(true)//
.repaymentEvery(true)//
.graceOnPrincipalAndInterestPayment(true)//
.graceOnArrearsAgeing(true))//
.allowPartialPeriodInterestCalcualtion(true)//
.maxTrancheCount(10)//
.outstandingLoanBalance(10000.0)//
.charges(Collections.emptyList())//
.accountingRule(3)//
.fundSourceAccountId(fundSource.getAccountID().longValue())//
.loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue())//
.transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue())//
.interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue())//
.incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue())//
.incomeFromPenaltyAccountId(penaltyIncomeAccount.getAccountID().longValue())//
.incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue())//
.writeOffAccountId(writtenOffAccount.getAccountID().longValue())//
.overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue())//
.receivableInterestAccountId(interestReceivableAccount.getAccountID().longValue())//
.receivableFeeAccountId(feeReceivableAccount.getAccountID().longValue())//
.receivablePenaltyAccountId(penaltyReceivableAccount.getAccountID().longValue())//
.goodwillCreditAccountId(goodwillExpenseAccount.getAccountID().longValue())//
.incomeFromGoodwillCreditInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())//
.incomeFromGoodwillCreditFeesAccountId(feeChargeOffAccount.getAccountID().longValue())//
.incomeFromGoodwillCreditPenaltyAccountId(feeChargeOffAccount.getAccountID().longValue())//
.incomeFromChargeOffInterestAccountId(interestIncomeChargeOffAccount.getAccountID().longValue())//
.incomeFromChargeOffFeesAccountId(feeChargeOffAccount.getAccountID().longValue())//
.incomeFromChargeOffPenaltyAccountId(penaltyChargeOffAccount.getAccountID().longValue())//
.chargeOffExpenseAccountId(chargeOffExpenseAccount.getAccountID().longValue())//
.chargeOffFraudExpenseAccountId(chargeOffFraudExpenseAccount.getAccountID().longValue())//
.addChargeOffReasonToExpenseAccountMappingsItem(
new PostChargeOffReasonToExpenseAccountMappings().chargeOffReasonCodeValueId(chargeOffReason.getSubResourceId())
.expenseAccountId(chargeOffDelinquentExpenseAccount.getAccountID().longValue()))
.dateFormat(DATETIME_PATTERN)//
.locale("en_GB")//
.disallowExpectedDisbursements(true)//
.allowApprovedDisbursedAmountsOverApplied(true)//
.overAppliedCalculationType("percentage")//
.overAppliedNumber(50));
}

private GetCodesResponse fetchChargeOffReasonCode() {
return codeHelper.retrieveCodes().stream().filter(c -> "ChargeOffReasons".equals(c.getName())).findFirst().orElseThrow();
}

private Integer createLoanAccount(final Integer clientID, final Integer loanProductID, final String externalId) {

String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("1000").withLoanTermFrequency("1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import org.apache.fineract.client.models.GetCodesResponse;
import org.apache.fineract.client.models.PostCodeValueDataResponse;
import org.apache.fineract.client.models.PostCodeValuesDataRequest;
import org.apache.fineract.integrationtests.client.IntegrationTest;
import org.apache.fineract.integrationtests.common.Utils;

public final class CodeHelper {
public final class CodeHelper extends IntegrationTest {

private static final String COUNTRY_CODE_NAME = "COUNTRY";
private static final String STATE_CODE_NAME = "STATE";
private static final String ADDRESS_TYPE_CODE_NAME = "ADDRESS_TYPE";
private static final String CHARGE_OFF_REASONS_CODE_NAME = "ChargeOffReasons";

private CodeHelper() {

}

public static final String CODE_ID_ATTRIBUTE_NAME = "id";
public static final String RESPONSE_ID_ATTRIBUTE_NAME = "resourceId";
public static final String SUBRESPONSE_ID_ATTRIBUTE_NAME = "subResourceId";
Expand Down Expand Up @@ -280,4 +280,11 @@ public static Object updateCodeValue(final RequestSpecification requestSpec, fin
getTestCodeValueAsJSON(codeValueName, description, position), jsonAttributeToGetback);
}

public PostCodeValueDataResponse createCodeValue(Long codeId, PostCodeValuesDataRequest request) {
return ok(fineract().codeValues.createCodeValue(codeId, request));
}

public List<GetCodesResponse> retrieveCodes() {
return ok(fineract().codes.retrieveCodes());
}
}

0 comments on commit e1d043b

Please sign in to comment.