FINERACT-1806: Charge-off API

This commit is contained in:
Adam Saghy
2023-01-06 13:42:54 +01:00
committed by Arnold Gálovics
parent bd052acab0
commit c7504680b7
30 changed files with 549 additions and 43 deletions
@@ -724,6 +724,30 @@
"null",
"org.apache.fineract.avro.loan.v1.DelinquencyRangeDataV1"
]
},
{
"default": null,
"name": "overpaidOnDate",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "lastClosedBusinessDate",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "chargedOff",
"type": [
"null",
"boolean"
]
}
]
}
@@ -242,6 +242,38 @@
"null",
"string"
]
},
{
"default": null,
"name": "chargedOffOnDate",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "chargedOffByUsername",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "chargedOffByFirstname",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "chargedOffByLastname",
"type": [
"null",
"string"
]
}
]
}
@@ -410,6 +410,22 @@
"null",
"bigdecimal"
]
},
{
"default": null,
"name": "chargeOffReasonId",
"type": [
"null",
"long"
]
},
{
"default": null,
"name": "chargeOffReason",
"type": [
"null",
"string"
]
}
]
}
@@ -3606,4 +3606,12 @@ public class CommandWrapperBuilder {
this.href = "/externaleventconfiguration";
return this;
}
public CommandWrapperBuilder chargeOff(final Long loanId) {
this.actionName = "CHARGEOFF";
this.entityName = "LOAN";
this.loanId = loanId;
this.href = "/loans/" + loanId + "/transactions?command=charge-off";
return this;
}
}
@@ -470,15 +470,17 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
.append(" l.closedon_date as closedOnDate,")
.append(" cbu.username as closedByUsername, cbu.firstname as closedByFirstname, cbu.lastname as closedByLastname,")
.append(" la.overdue_since_date_derived as overdueSinceDate, ")
.append(" l.writtenoffon_date as writtenOffOnDate, l.expected_maturedon_date as expectedMaturityDate")
.append(" from m_loan l ").append("LEFT JOIN m_product_loan AS lp ON lp.id = l.product_id")
.append(" l.writtenoffon_date as writtenOffOnDate, l.expected_maturedon_date as expectedMaturityDate, ")
.append(" l.charged_off_on_date as chargedOffOnDate, cobu.username as chargedOffByUsername, ")
.append(" cobu.firstname as chargedOffByFirstname, cobu.lastname as chargedOffByLastname ").append(" from m_loan l ")
.append("LEFT JOIN m_product_loan AS lp ON lp.id = l.product_id")
.append(" left join m_appuser sbu on sbu.id = l.created_by")
.append(" left join m_appuser rbu on rbu.id = l.rejectedon_userid")
.append(" left join m_appuser wbu on wbu.id = l.withdrawnon_userid")
.append(" left join m_appuser abu on abu.id = l.approvedon_userid")
.append(" left join m_appuser dbu on dbu.id = l.disbursedon_userid")
.append(" left join m_appuser cbu on cbu.id = l.closedon_userid")
.append(" left join m_appuser cobu on cobu.id = l.charged_off_by_userid")
.append(" left join m_loan_arrears_aging la on la.loan_id = l.id")
.append(" left join glim_accounts glim on glim.id=l.glim_id");
@@ -541,6 +543,12 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final LocalDate expectedMaturityDate = JdbcSupport.getLocalDate(rs, "expectedMaturityDate");
final LocalDate overdueSinceDate = JdbcSupport.getLocalDate(rs, "overdueSinceDate");
final LocalDate chargedOffOnDate = JdbcSupport.getLocalDate(rs, "chargedOffOnDate");
final String chargedOffByUsername = rs.getString("chargedOffByUsername");
final String chargedOffByFirstname = rs.getString("chargedOffByFirstname");
final String chargedOffByLastname = rs.getString("chargedOffByLastname");
Boolean inArrears = (overdueSinceDate != null);
final LoanApplicationTimelineData timeline = new LoanApplicationTimelineData(submittedOnDate, submittedByUsername,
@@ -548,7 +556,8 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
withdrawnOnDate, withdrawnByUsername, withdrawnByFirstname, withdrawnByLastname, approvedOnDate, approvedByUsername,
approvedByFirstname, approvedByLastname, expectedDisbursementDate, actualDisbursementDate, disbursedByUsername,
disbursedByFirstname, disbursedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname,
expectedMaturityDate, writtenOffOnDate, closedByUsername, closedByFirstname, closedByLastname);
expectedMaturityDate, writtenOffOnDate, closedByUsername, closedByFirstname, closedByLastname, chargedOffOnDate,
chargedOffByUsername, chargedOffByFirstname, chargedOffByLastname);
return new LoanAccountSummaryData(id, accountNo, parentAccountNumber, externalId, productId, loanProductName,
shortLoanProductName, loanStatus, loanType, loanCycle, timeline, inArrears, originalLoan, loanBalance, amountPaid);
@@ -133,7 +133,8 @@ public interface LoanApiConstants {
String installmentAmountParamName = "installmentAmount";
// loan write off
String WRITEOFFREASONS = "WriteOffReasons";
// loan charge-off
String CHARGE_OFF_REASONS = "ChargeOffReasons";
// fore closure constants
String transactionDateParamName = "transactionDate";
String noteParamName = "note";
@@ -153,6 +154,7 @@ public interface LoanApiConstants {
String fixedPrincipalPercentagePerInstallmentParamName = "fixedPrincipalPercentagePerInstallment";
String LOAN_ASSOCIATIONS_ALL = "all";
String chargeOffReasonIdParamName = "chargeOffReasonId";
// Reversal Transation Data
String REVERSAL_EXTERNAL_ID_PARAMNAME = "reversalExternalId";
@@ -76,6 +76,7 @@ import org.springframework.stereotype.Component;
@Tag(name = "Loan Transactions", description = "Capabilities include loan repayment's, interest waivers and the ability to 'adjust' an existing transaction. An 'adjustment' of a transaction is really a 'reversal' of existing transaction followed by creation of a new transaction with the provided details.")
public class LoanTransactionsApiResource {
public static final String CHARGE_OFF_COMMAND_VALUE = "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));
@@ -103,7 +104,8 @@ public class LoanTransactionsApiResource {
+ "loans/1/transactions/template?command=recoverypayment" + "\n" + "loans/1/transactions/template?command=prepayLoan" + "\n"
+ "loans/1/transactions/template?command=refundbycash" + "\n" + "loans/1/transactions/template?command=refundbytransfer" + "\n"
+ "loans/1/transactions/template?command=foreclosure" + "\n"
+ "loans/1/transactions/template?command=creditBalanceRefund (returned 'amount' field will have the overpaid value)")
+ "loans/1/transactions/template?command=creditBalanceRefund (returned 'amount' field will have the overpaid value)" + "\n"
+ "loans/1/transactions/template?command=charge-off" + "\n")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.GetLoansLoanIdTransactionsTemplateResponse.class))) })
public String retrieveTransactionTemplate(@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId,
@@ -129,7 +131,8 @@ public class LoanTransactionsApiResource {
+ "loans/1/transactions/template?command=recoverypayment" + "\n" + "loans/1/transactions/template?command=prepayLoan" + "\n"
+ "loans/1/transactions/template?command=refundbycash" + "\n" + "loans/1/transactions/template?command=refundbytransfer" + "\n"
+ "loans/1/transactions/template?command=foreclosure" + "\n"
+ "loans/1/transactions/template?command=creditBalanceRefund (returned 'amount' field will have the overpaid value)")
+ "loans/1/transactions/template?command=creditBalanceRefund (returned 'amount' field will have the overpaid value)" + "\n"
+ "loans/1/transactions/template?command=charge-off" + "\n")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.GetLoansLoanIdTransactionsTemplateResponse.class))) })
public String retrieveTransactionTemplate(
@@ -224,7 +227,8 @@ public class LoanTransactionsApiResource {
+ " | Undo Loan Write-off | \n" + "loans/1/transactions?command=recoverypayment" + " | Make Recovery Payment | \n"
+ "loans/1/transactions?command=refundByCash" + " | Make a Refund of an Active Loan by Cash | \n"
+ "loans/1/transactions?command=foreclosure" + " | Foreclosure of an Active Loan | \n"
+ "loans/1/transactions?command=creditBalanceRefund" + " | Credit Balance Refund" + " | \n")
+ "loans/1/transactions?command=creditBalanceRefund" + " | Credit Balance Refund" + " | \n"
+ "loans/external-id/7dd80a7c-ycba-a446-t378-91eb6f53e854/transactions?command=charge-off" + " | Charge-off Loan" + " | \n")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.PostLoansLoanIdTransactionsRequest.class)))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.PostLoansLoanIdTransactionsResponse.class))) })
@@ -257,6 +261,7 @@ public class LoanTransactionsApiResource {
+ "loans/external-id/7dd80a7c-ycba-a446-t378-91eb6f53e854/transactions?command=foreclosure"
+ " | Foreclosure of an Active Loan | \n"
+ "loans/external-id/7dd80a7c-ycba-a446-t378-91eb6f53e854/transactions?command=creditBalanceRefund" + " | Credit Balance Refund"
+ " | \n" + "loans/external-id/7dd80a7c-ycba-a446-t378-91eb6f53e854/transactions?command=charge-off" + " | Charge-off Loan"
+ " | \n")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = LoanTransactionsApiResourceSwagger.PostLoansLoanIdTransactionsRequest.class)))
@ApiResponses({
@@ -456,6 +461,8 @@ public class LoanTransactionsApiResource {
commandRequest = builder.loanForeclosure(resolvedLoanId).build();
} else if (CommandParameterUtil.is(commandParam, "creditBalanceRefund")) {
commandRequest = builder.creditBalanceRefund(resolvedLoanId).build();
} else if (CommandParameterUtil.is(commandParam, CHARGE_OFF_COMMAND_VALUE)) {
commandRequest = builder.chargeOff(resolvedLoanId).build();
}
if (commandRequest == null) {
@@ -529,6 +536,8 @@ public class LoanTransactionsApiResource {
transactionData = this.loanReadPlatformService.retrieveLoanForeclosureTemplate(resolvedLoanId, transactionDate);
} else if (CommandParameterUtil.is(commandParam, "creditBalanceRefund")) {
transactionData = this.loanReadPlatformService.retrieveCreditBalanceRefundTemplate(resolvedLoanId);
} else if (CommandParameterUtil.is(commandParam, CHARGE_OFF_COMMAND_VALUE)) {
transactionData = this.loanReadPlatformService.retrieveLoanChargeOffTemplate(resolvedLoanId);
} else {
throw new UnrecognizedQueryParamException("command", commandParam);
}
@@ -258,7 +258,10 @@ final class LoanTransactionsApiResourceSwagger {
public Integer loanChargeId;
@Schema(example = "28 June 2022")
public String dueDate;
@Schema(example = "1")
public Long chargeOffReasonId;
@Schema(example = "1")
public Long writeoffReasonId;
}
@Schema(description = "PostLoansLoanIdTransactionsResponse")
@@ -247,6 +247,14 @@ final class LoansApiResourceSwagger {
public LocalDate expectedMaturityDate;
@Schema(example = "[2012, 4, 3]")
public LocalDate closedOnDate;
@Schema(example = "[2012, 4, 10]")
public LocalDate chargedOffOnDate;
@Schema(example = "admin")
public String chargedOffByUsername;
@Schema(example = "App")
public String chargedOffByFirstname;
@Schema(example = "Administrator")
public String chargedOffByLastname;
}
static final class GetLoansLoanIdRepaymentSchedule {
@@ -601,6 +609,10 @@ final class LoansApiResourceSwagger {
@Schema(example = "0.000000")
public Double totalRepaymentTransactionReversed;
public Set<GetLoansLoanIdOverdueCharges> overdueCharges;
@Schema(example = "1")
public Long chargeOffReasonId;
@Schema(example = "reason")
public String chargeOffReason;
}
static final class GetLoansLoanIdPaymentType {
@@ -1028,6 +1040,8 @@ final class LoansApiResourceSwagger {
public LocalDate lastClosedBusinessDate;
@Schema(example = "[2013, 11, 1]")
public LocalDate overpaidOnDate;
@Schema(example = "false")
public Boolean chargedOff;
}
@Schema(description = "GetLoansResponse")
@@ -248,11 +248,12 @@ public class LoanAccountData {
private String linkAccountId;
private Long groupId;
private LocalDate expectedDisbursementDate;
private LocalDate overpaidOnDate;
private LocalDate overpaidOnDate;
private CollectionData delinquent;
private DelinquencyRangeData delinquencyRange;
private LocalDate lastClosedBusinessDate;
private Boolean chargedOff;
public static LoanAccountData importInstanceIndividual(EnumOptionData loanTypeEnumOption, Long clientId, Long productId,
Long loanOfficerId, LocalDate submittedOnDate, Long fundId, BigDecimal principal, Integer numberOfRepayments,
@@ -381,7 +382,7 @@ public class LoanAccountData {
.setIsEqualAmortization(acc.isEqualAmortization).setRates(acc.rates).setIsRatesEnabled(acc.isRatesEnabled)
.setFixedPrincipalPercentagePerInstallment(acc.fixedPrincipalPercentagePerInstallment).setDelinquent(acc.delinquent)
.setDelinquencyRange(acc.delinquencyRange).setDisallowExpectedDisbursements(acc.disallowExpectedDisbursements)
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate);
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate).setChargedOff(acc.chargedOff);
}
/**
@@ -452,7 +453,7 @@ public class LoanAccountData {
.setIsEqualAmortization(acc.isEqualAmortization).setRates(acc.rates).setIsRatesEnabled(acc.isRatesEnabled)
.setFixedPrincipalPercentagePerInstallment(acc.fixedPrincipalPercentagePerInstallment).setDelinquent(acc.delinquent)
.setDelinquencyRange(acc.delinquencyRange).setDisallowExpectedDisbursements(acc.disallowExpectedDisbursements)
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate);
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate).setChargedOff(acc.chargedOff);
}
public static LoanAccountData loanProductWithTemplateDefaults(final LoanProductData product,
@@ -620,7 +621,7 @@ public class LoanAccountData {
.setIsEqualAmortization(product.isEqualAmortization()).setRates(acc.rates).setIsRatesEnabled(acc.isRatesEnabled)
.setFixedPrincipalPercentagePerInstallment(product.getFixedPrincipalPercentagePerInstallment()).setDelinquent(delinquent)
.setDisallowExpectedDisbursements(product.getDisallowExpectedDisbursements()).setFraud(acc.fraud)
.setOverpaidOnDate(acc.overpaidOnDate);
.setOverpaidOnDate(acc.overpaidOnDate).setChargedOff(acc.chargedOff);
}
/*
@@ -654,7 +655,7 @@ public class LoanAccountData {
final boolean canUseForTopup, final boolean isTopup, final Long closureLoanId, final String closureLoanAccountNo,
final BigDecimal topupAmount, final boolean isEqualAmortization, final BigDecimal fixedPrincipalPercentagePerInstallment,
final DelinquencyRangeData delinquencyRange, final boolean disallowExpectedDisbursements, final boolean fraud,
LocalDate lastClosedBusinessDate, LocalDate overpaidOnDate) {
LocalDate lastClosedBusinessDate, LocalDate overpaidOnDate, final boolean chargedOff) {
final CollectionData delinquent = CollectionData.template();
@@ -694,7 +695,7 @@ public class LoanAccountData {
.setClosureLoanAccountNo(closureLoanAccountNo).setTopupAmount(topupAmount).setIsEqualAmortization(isEqualAmortization)
.setFixedPrincipalPercentagePerInstallment(fixedPrincipalPercentagePerInstallment).setDelinquent(delinquent)
.setDelinquencyRange(delinquencyRange).setDisallowExpectedDisbursements(disallowExpectedDisbursements).setFraud(fraud)
.setLastClosedBusinessDate(lastClosedBusinessDate).setOverpaidOnDate(overpaidOnDate);
.setLastClosedBusinessDate(lastClosedBusinessDate).setOverpaidOnDate(overpaidOnDate).setChargedOff(chargedOff);
}
/*
@@ -778,7 +779,8 @@ public class LoanAccountData {
.setIsEqualAmortization(acc.isEqualAmortization)
.setFixedPrincipalPercentagePerInstallment(acc.fixedPrincipalPercentagePerInstallment)
.setDelinquencyRange(acc.delinquencyRange).setDisallowExpectedDisbursements(acc.disallowExpectedDisbursements)
.setFraud(acc.fraud).setLastClosedBusinessDate(acc.getLastClosedBusinessDate()).setOverpaidOnDate(acc.overpaidOnDate);
.setFraud(acc.fraud).setLastClosedBusinessDate(acc.getLastClosedBusinessDate()).setOverpaidOnDate(acc.overpaidOnDate)
.setChargedOff(acc.chargedOff);
}
public static LoanAccountData associationsAndTemplate(final LoanAccountData acc, final Collection<LoanProductData> productOptions,
@@ -853,7 +855,7 @@ public class LoanAccountData {
.setIsEqualAmortization(acc.isEqualAmortization).setRates(acc.rates).setIsRatesEnabled(acc.isRatesEnabled)
.setFixedPrincipalPercentagePerInstallment(acc.fixedPrincipalPercentagePerInstallment).setDelinquent(acc.delinquent)
.setDelinquencyRange(acc.delinquencyRange).setDisallowExpectedDisbursements(acc.disallowExpectedDisbursements)
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate);
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate).setChargedOff(acc.chargedOff);
}
public static LoanAccountData associateMemberVariations(final LoanAccountData acc, final Map<Long, Integer> memberLoanCycle) {
@@ -950,7 +952,7 @@ public class LoanAccountData {
.setIsEqualAmortization(acc.isEqualAmortization).setRates(acc.rates).setIsRatesEnabled(acc.isRatesEnabled)
.setFixedPrincipalPercentagePerInstallment(acc.fixedPrincipalPercentagePerInstallment).setDelinquent(acc.delinquent)
.setDelinquencyRange(acc.delinquencyRange).setDisallowExpectedDisbursements(acc.disallowExpectedDisbursements)
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate);
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate).setChargedOff(acc.chargedOff);
}
public static LoanAccountData withInterestRecalculationCalendarData(final LoanAccountData acc, final CalendarData calendarData,
@@ -1015,7 +1017,7 @@ public class LoanAccountData {
.setIsEqualAmortization(acc.isEqualAmortization).setRates(acc.rates).setIsRatesEnabled(acc.isRatesEnabled)
.setFixedPrincipalPercentagePerInstallment(acc.fixedPrincipalPercentagePerInstallment).setDelinquent(acc.delinquent)
.setDelinquencyRange(acc.delinquencyRange).setDisallowExpectedDisbursements(acc.disallowExpectedDisbursements)
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate);
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate).setChargedOff(acc.chargedOff);
}
public static LoanAccountData withLoanCalendarData(final LoanAccountData acc, final CalendarData calendarData) {
@@ -1073,7 +1075,7 @@ public class LoanAccountData {
.setIsEqualAmortization(acc.isEqualAmortization).setRates(acc.rates).setIsRatesEnabled(acc.isRatesEnabled)
.setFixedPrincipalPercentagePerInstallment(acc.fixedPrincipalPercentagePerInstallment).setDelinquent(acc.delinquent)
.setDelinquencyRange(acc.delinquencyRange).setDisallowExpectedDisbursements(acc.disallowExpectedDisbursements)
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate);
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate).setChargedOff(acc.chargedOff);
}
public static LoanAccountData withOriginalSchedule(final LoanAccountData acc, final LoanScheduleData originalSchedule) {
@@ -1134,7 +1136,7 @@ public class LoanAccountData {
.setIsEqualAmortization(acc.isEqualAmortization).setRates(acc.rates).setIsRatesEnabled(acc.isRatesEnabled)
.setFixedPrincipalPercentagePerInstallment(acc.fixedPrincipalPercentagePerInstallment).setDelinquent(acc.delinquent)
.setDelinquencyRange(acc.delinquencyRange).setDisallowExpectedDisbursements(acc.disallowExpectedDisbursements)
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate);
.setFraud(acc.fraud).setOverpaidOnDate(acc.overpaidOnDate).setChargedOff(acc.chargedOff);
}
public static final Comparator<LoanAccountData> ClientNameComparator = (loan1, loan2) -> {
@@ -62,6 +62,11 @@ public class LoanApplicationTimelineData {
private final String writeOffByFirstname;
private final String writeOffByLastname;
private final LocalDate chargedOffOnDate;
private final String chargedOffByUsername;
private final String chargedOffByFirstname;
private final String chargedOffByLastname;
public static LoanApplicationTimelineData templateDefault(final LocalDate expectedDisbursementDate) {
final LocalDate submittedOnDate = null;
@@ -93,13 +98,18 @@ public class LoanApplicationTimelineData {
final String writeOffByUsername = null;
final String writeOffByFirstname = null;
final String writeOffByLastname = null;
final LocalDate chargedOffOnDate = null;
final String chargedOffByUsername = null;
final String chargedOffByFirstname = null;
final String chargedOffByLastname = null;
return new LoanApplicationTimelineData(submittedOnDate, submittedByUsername, submittedByFirstname, submittedByLastname,
rejectedOnDate, rejectedByUsername, rejectedByFirstname, rejectedByLastname, withdrawnOnDate, withdrawnByUsername,
withdrawnByFirstname, withdrawnByLastname, approvedOnDate, approvedByUsername, approvedByFirstname, approvedByLastname,
expectedDisbursementDate, actualDisbursementDate, disbursedByUsername, disbursedByFirstname, disbursedByLastname,
closedOnDate, closedByUsername, closedByFirstname, closedByLastname, expectedMaturityDate, writeOffOnDate,
writeOffByUsername, writeOffByFirstname, writeOffByLastname);
writeOffByUsername, writeOffByFirstname, writeOffByLastname, chargedOffOnDate, chargedOffByUsername, chargedOffByFirstname,
chargedOffByLastname);
}
public RepaymentScheduleRelatedLoanData repaymentScheduleRelatedData(final CurrencyData currency, final BigDecimal principal,
@@ -87,6 +87,8 @@ public class LoanSummaryData {
private BigDecimal totalCreditBalanceRefundReversed;
private BigDecimal totalRepaymentTransaction;
private BigDecimal totalRepaymentTransactionReversed;
private final Long chargeOffReasonId;
private final String chargeOffReason;
public LoanSummaryData(final CurrencyData currency, final BigDecimal principalDisbursed, final BigDecimal principalAdjustments,
final BigDecimal principalPaid, final BigDecimal principalWrittenOff, final BigDecimal principalOutstanding,
@@ -100,7 +102,7 @@ public class LoanSummaryData {
final BigDecimal totalRepayment, final BigDecimal totalExpectedCostOfLoan, final BigDecimal totalCostOfLoan,
final BigDecimal totalWaived, final BigDecimal totalWrittenOff, final BigDecimal totalOutstanding,
final BigDecimal totalOverdue, final LocalDate overdueSinceDate, final Long writeoffReasonId, final String writeoffReason,
final BigDecimal totalRecovered) {
final BigDecimal totalRecovered, final Long chargeOffReasonId, final String chargeOffReason) {
this.currency = currency;
this.principalDisbursed = principalDisbursed;
this.principalAdjustments = principalAdjustments;
@@ -139,6 +141,8 @@ public class LoanSummaryData {
this.writeoffReasonId = writeoffReasonId;
this.writeoffReason = writeoffReason;
this.totalRecovered = totalRecovered;
this.chargeOffReasonId = chargeOffReasonId;
this.chargeOffReason = chargeOffReason;
}
public static LoanSummaryData withTransactionAmountsSummary(final LoanSummaryData defaultSummaryData,
@@ -193,12 +197,13 @@ public class LoanSummaryData {
defaultSummaryData.totalExpectedRepayment, defaultSummaryData.totalRepayment, defaultSummaryData.totalExpectedCostOfLoan,
defaultSummaryData.totalCostOfLoan, defaultSummaryData.totalWaived, defaultSummaryData.totalWrittenOff,
defaultSummaryData.totalOutstanding, defaultSummaryData.totalOverdue, defaultSummaryData.overdueSinceDate,
defaultSummaryData.writeoffReasonId, defaultSummaryData.writeoffReason, defaultSummaryData.totalRecovered)
.setTotalMerchantRefund(totalMerchantRefund).setTotalMerchantRefundReversed(totalMerchantRefundReversed)
.setTotalPayoutRefund(totalPayoutRefund).setTotalPayoutRefundReversed(totalPayoutRefundReversed)
.setTotalGoodwillCredit(totalGoodwillCredit).setTotalGoodwillCreditReversed(totalGoodwillCreditReversed)
.setTotalChargeAdjustment(totalChargeAdjustment).setTotalChargeAdjustmentReversed(totalChargeAdjustmentReversed)
.setTotalChargeback(totalChargeback).setTotalCreditBalanceRefund(totalCreditBalanceRefund)
defaultSummaryData.writeoffReasonId, defaultSummaryData.writeoffReason, defaultSummaryData.totalRecovered,
defaultSummaryData.chargeOffReasonId, defaultSummaryData.chargeOffReason).setTotalMerchantRefund(totalMerchantRefund)
.setTotalMerchantRefundReversed(totalMerchantRefundReversed).setTotalPayoutRefund(totalPayoutRefund)
.setTotalPayoutRefundReversed(totalPayoutRefundReversed).setTotalGoodwillCredit(totalGoodwillCredit)
.setTotalGoodwillCreditReversed(totalGoodwillCreditReversed).setTotalChargeAdjustment(totalChargeAdjustment)
.setTotalChargeAdjustmentReversed(totalChargeAdjustmentReversed).setTotalChargeback(totalChargeback)
.setTotalCreditBalanceRefund(totalCreditBalanceRefund)
.setTotalCreditBalanceRefundReversed(totalCreditBalanceRefundReversed)
.setTotalRepaymentTransaction(totalRepaymentTransaction)
.setTotalRepaymentTransactionReversed(totalRepaymentTransactionReversed);
@@ -96,6 +96,8 @@ public class LoanTransactionData {
private List<LoanTransactionRelationData> transactionRelations;
private Collection<CodeValueData> chargeOffReasonOptions = null;
public static LoanTransactionData importInstance(BigDecimal repaymentAmount, LocalDate lastRepaymentDate, Long repaymentTypeId,
Integer rowIndex, String locale, String dateFormat) {
return new LoanTransactionData(repaymentAmount, lastRepaymentDate, repaymentTypeId, rowIndex, locale, dateFormat);
@@ -369,6 +371,10 @@ public class LoanTransactionData {
this.writeOffReasonOptions = writeOffReasonOptions;
}
public void setChargeOffReasonOptions(Collection<CodeValueData> chargeOffReasonOptions) {
this.chargeOffReasonOptions = chargeOffReasonOptions;
}
public void setLoanChargePaidByList(Collection<LoanChargePaidByData> loanChargePaidByList) {
this.loanChargePaidByList = loanChargePaidByList;
}
@@ -439,6 +439,20 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
@Column(name = "last_closed_business_date")
private LocalDate lastClosedBusinessDate;
@Column(name = "is_charged_off", nullable = false)
private boolean chargedOff;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "charge_off_reason_cv_id")
private CodeValue chargeOffReason;
@Column(name = "charged_off_on_date")
private LocalDate chargedOffOnDate;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "charged_off_by_userid")
private AppUser chargedOffBy;
public static Loan newIndividualLoanApplication(final String accountNo, final Client client, final Integer loanType,
final LoanProduct loanProduct, final Fund fund, final Staff officer, final CodeValue loanPurpose,
final String transactionProcessingStrategyCode, final LoanProductRelatedDetail loanRepaymentScheduleDetail,
@@ -6949,4 +6963,23 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom {
public void setLastClosedBusinessDate(LocalDate lastClosedBusinessDate) {
this.lastClosedBusinessDate = lastClosedBusinessDate;
}
public void markAsChargedOff(final LocalDate chargedOffOn, final AppUser chargedOffBy, final CodeValue chargeOffReason) {
this.chargedOff = true;
this.chargedOffBy = chargedOffBy;
this.chargedOffOnDate = chargedOffOn;
this.chargeOffReason = chargeOffReason;
}
public void liftChargeOff() {
this.chargedOff = false;
this.chargedOffBy = null;
this.chargedOffOnDate = null;
this.chargeOffReason = null;
}
public boolean isChargedOff() {
return this.chargedOff;
}
}
@@ -347,6 +347,25 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
return new LoanTransaction(loan, office, LoanTransactionType.WRITEOFF, null, writeOffDate, externalId);
}
public static LoanTransaction chargeOff(final Loan loan, final LocalDate chargeOffDate, final ExternalId externalId) {
BigDecimal principalPortion = loan.getLoanSummary().getTotalPrincipalOutstanding().compareTo(BigDecimal.ZERO) != 0
? loan.getLoanSummary().getTotalPrincipalOutstanding()
: null;
BigDecimal interestPortion = loan.getLoanSummary().getTotalInterestOutstanding().compareTo(BigDecimal.ZERO) != 0
? loan.getLoanSummary().getTotalInterestOutstanding()
: null;
BigDecimal feePortion = loan.getLoanSummary().getTotalFeeChargesOutstanding().compareTo(BigDecimal.ZERO) != 0
? loan.getLoanSummary().getTotalFeeChargesOutstanding()
: null;
BigDecimal penaltyPortion = loan.getLoanSummary().getTotalPenaltyChargesOutstanding().compareTo(BigDecimal.ZERO) != 0
? loan.getLoanSummary().getTotalPenaltyChargesOutstanding()
: null;
BigDecimal totalOutstanding = loan.getLoanSummary().getTotalOutstanding();
return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.CHARGE_OFF.getValue(), chargeOffDate, totalOutstanding,
principalPortion, interestPortion, feePortion, penaltyPortion, null, false, null, externalId);
}
private LoanTransaction(final Loan loan, final Office office, final LoanTransactionType type, final BigDecimal amount,
final LocalDate date, final ExternalId externalId) {
this.loan = loan;
@@ -647,6 +666,10 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
return getTypeOf().isWriteOff() && isNotReversed();
}
public boolean isChargeOff() {
return getTypeOf().isChargeOff() && isNotReversed();
}
public boolean isIdentifiedBy(final Long identifier) {
return getId().equals(identifier);
}
@@ -786,7 +809,8 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom {
return isNotReversed() && (LoanTransactionType.CONTRA.equals(getTypeOf())
|| LoanTransactionType.MARKED_FOR_RESCHEDULING.equals(getTypeOf())
|| LoanTransactionType.APPROVE_TRANSFER.equals(getTypeOf()) || LoanTransactionType.INITIATE_TRANSFER.equals(getTypeOf())
|| LoanTransactionType.REJECT_TRANSFER.equals(getTypeOf()) || LoanTransactionType.WITHDRAW_TRANSFER.equals(getTypeOf()));
|| LoanTransactionType.REJECT_TRANSFER.equals(getTypeOf()) || LoanTransactionType.WITHDRAW_TRANSFER.equals(getTypeOf())
|| LoanTransactionType.CHARGE_OFF.equals(getTypeOf()));
}
public void updateOutstandingLoanBalance(BigDecimal outstandingLoanBalance) {
@@ -58,7 +58,8 @@ public enum LoanTransactionType {
GOODWILL_CREDIT(23, "loanTransactionType.goodwillCredit"), //
CHARGE_REFUND(24, "loanTransactionType.chargeRefund"), //
CHARGEBACK(25, "loanTransactionType.chargeback"), //
CHARGE_ADJUSTMENT(26, "loanTransactionType.chargeAdjustment");
CHARGE_ADJUSTMENT(26, "loanTransactionType.chargeAdjustment"), //
CHARGE_OFF(27, "loanTransactionType.chargeOff");
private final Integer value;
private final String code;
@@ -100,6 +101,7 @@ public enum LoanTransactionType {
case 24 -> LoanTransactionType.CHARGE_REFUND;
case 25 -> LoanTransactionType.CHARGEBACK;
case 26 -> LoanTransactionType.CHARGE_ADJUSTMENT;
case 27 -> LoanTransactionType.CHARGE_OFF;
default -> LoanTransactionType.INVALID;
};
}
@@ -184,4 +186,7 @@ public enum LoanTransactionType {
return this.equals(LoanTransactionType.CHARGE_ADJUSTMENT);
}
public boolean isChargeOff() {
return this.equals(LoanTransactionType.CHARGE_OFF);
}
}
@@ -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 = "CHARGEOFF")
public class ChargeOffLoanCommandHandler implements NewCommandSourceHandler {
private final LoanWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return writePlatformService.chargeOff(command);
}
}
@@ -341,6 +341,36 @@ public final class LoanEventApiJsonValidator {
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateChargeOffTransaction(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Set<String> chargeOffParameters = new HashSet<>(
Arrays.asList("transactionDate", "note", "locale", "dateFormat", "chargeOffReasonId", "externalId"));
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, chargeOffParameters);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.transaction");
final JsonElement element = fromApiJsonHelper.parse(json);
final LocalDate transactionDate = fromApiJsonHelper.extractLocalDateNamed("transactionDate", element);
baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull();
final String note = fromApiJsonHelper.extractStringNamed("note", element);
baseDataValidator.reset().parameter("note").value(note).ignoreIfNull().notExceedingLengthOf(1000);
final String externalId = fromApiJsonHelper.extractStringNamed("externalId", element);
baseDataValidator.reset().parameter("externalId").value(externalId).ignoreIfNull().notExceedingLengthOf(100);
final Long chargeOffReasonId = fromApiJsonHelper.extractLongNamed("chargeOffReasonId", element);
baseDataValidator.reset().parameter("chargeOffReasonId").value(chargeOffReasonId).ignoreIfNull().integerGreaterThanZero();
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateUpdateOfLoanOfficer(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
@@ -116,6 +116,8 @@ public interface LoanReadPlatformService {
Collection<LoanScheduleAccrualData> retrievePeriodicAccrualData(LocalDate tillDate);
LoanTransactionData retrieveLoanChargeOffTemplate(Long loanId);
Collection<Long> fetchLoansForInterestRecalculation();
List<Long> fetchLoansForInterestRecalculation(Integer pageSize, Long maxLoanIdInList, String officeHierarchy);
@@ -677,7 +677,10 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService {
+ " lpvi.minimum_gap as minimuminstallmentgap, lpvi.maximum_gap as maximuminstallmentgap, "
+ " lp.can_use_for_topup as canUseForTopup, l.is_topup as isTopup, topup.closure_loan_id as closureLoanId, "
+ " l.total_recovered_derived as totalRecovered, topuploan.account_no as closureLoanAccountNo, "
+ " topup.topup_amount as topupAmount, l.last_closed_business_date as lastClosedBusinessDate,l.overpaidon_date as overpaidOnDate from m_loan l" //
+ " topup.topup_amount as topupAmount, l.last_closed_business_date as lastClosedBusinessDate,l.overpaidon_date as overpaidOnDate, "
+ " l.is_charged_off as isChargedOff, l.charge_off_reason_cv_id as chargeOffReasonId, codec.code_value as chargeOffReason, l.charged_off_on_date as chargedOffOnDate, "
+ " cobu.username as chargedOffByUsername, cobu.firstname as chargedOffByFirstname, cobu.lastname as chargedOffByLastname "
+ " from m_loan l" //
+ " join m_product_loan lp on lp.id = l.product_id" //
+ " left join m_loan_recalculation_details lir on lir.loan_id = l.id join m_currency rc on rc."
+ sqlGenerator.escape("code") + " = l.currency_code" //
@@ -690,8 +693,10 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService {
+ " left join m_appuser wbu on wbu.id = l.withdrawnon_userid"
+ " left join m_appuser abu on abu.id = l.approvedon_userid"
+ " left join m_appuser dbu on dbu.id = l.disbursedon_userid left join m_appuser cbu on cbu.id = l.closedon_userid"
+ " left join m_appuser cobu on cobu.id = l.charged_off_by_userid "
+ " left join m_code_value cv on cv.id = l.loanpurpose_cv_id"
+ " left join m_code_value codev on codev.id = l.writeoff_reason_cv_id"
+ " left join m_code_value codec on codec.id = l.charge_off_reason_cv_id"
+ " left join m_product_loan_variable_installment_config lpvi on lpvi.loan_product_id = l.product_id"
+ " left join m_loan_topup as topup on l.id = topup.loan_id"
+ " left join m_loan as topuploan on topuploan.id = topup.closure_loan_id ";
@@ -792,12 +797,21 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService {
final Integer minimumGap = rs.getInt("minimuminstallmentgap");
final Integer maximumGap = rs.getInt("maximuminstallmentgap");
final LocalDate chargedOffOnDate = JdbcSupport.getLocalDate(rs, "chargedOffOnDate");
final String chargedOffByUsername = rs.getString("chargedOffByUsername");
final String chargedOffByFirstname = rs.getString("chargedOffByFirstname");
final String chargedOffByLastname = rs.getString("chargedOffByLastname");
final Long chargeOffReasonId = JdbcSupport.getLong(rs, "chargeOffReasonId");
final String chargeOffReason = rs.getString("chargeOffReason");
final boolean isChargedOff = rs.getBoolean("isChargedOff");
final LoanApplicationTimelineData timeline = new LoanApplicationTimelineData(submittedOnDate, submittedByUsername,
submittedByFirstname, submittedByLastname, rejectedOnDate, rejectedByUsername, rejectedByFirstname, rejectedByLastname,
withdrawnOnDate, withdrawnByUsername, withdrawnByFirstname, withdrawnByLastname, approvedOnDate, approvedByUsername,
approvedByFirstname, approvedByLastname, expectedDisbursementDate, actualDisbursementDate, disbursedByUsername,
disbursedByFirstname, disbursedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname,
expectedMaturityDate, writtenOffOnDate, closedByUsername, closedByFirstname, closedByLastname);
expectedMaturityDate, writtenOffOnDate, closedByUsername, closedByFirstname, closedByLastname, chargedOffOnDate,
chargedOffByUsername, chargedOffByFirstname, chargedOffByLastname);
final BigDecimal principal = rs.getBigDecimal("principal");
final BigDecimal approvedPrincipal = rs.getBigDecimal("approvedPrincipal");
@@ -914,7 +928,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService {
penaltyChargesCharged, penaltyChargesPaid, penaltyChargesWaived, penaltyChargesWrittenOff,
penaltyChargesOutstanding, penaltyChargesOverdue, totalExpectedRepayment, totalRepayment, totalExpectedCostOfLoan,
totalCostOfLoan, totalWaived, totalWrittenOff, totalOutstanding, totalOverdue, overdueSinceDate, writeoffReasonId,
writeoffReason, totalRecovered);
writeoffReason, totalRecovered, chargeOffReasonId, chargeOffReason);
}
GroupGeneralData groupData = null;
@@ -1022,7 +1036,7 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService {
createStandingInstructionAtDisbursement, isvariableInstallmentsAllowed, minimumGap, maximumGap, loanSubStatus,
canUseForTopup, isTopup, closureLoanId, closureLoanAccountNo, topupAmount, isEqualAmortization,
fixedPrincipalPercentagePerInstallment, delinquencyRange, disallowExpectedDisbursements, isFraud,
lastClosedBusinessDate, overpaidOnDate);
lastClosedBusinessDate, overpaidOnDate, isChargedOff);
}
}
@@ -1917,6 +1931,26 @@ public class LoanReadPlatformServiceImpl implements LoanReadPlatformService {
return loanTransactionData;
}
@Override
public LoanTransactionData retrieveLoanChargeOffTemplate(final Long loanId) {
final LoanAccountData loan = this.retrieveOne(loanId);
final LoanTransactionEnumData transactionType = LoanEnumerations.transactionType(LoanTransactionType.CHARGE_OFF);
final BigDecimal totalOutstanding = loan.getSummary() != null ? loan.getSummary().getTotalOutstanding() : null;
final BigDecimal totalPrincipalOutstanding = loan.getSummary() != null ? loan.getSummary().getPrincipalOutstanding() : null;
final BigDecimal totalInterestOutstanding = loan.getSummary() != null ? loan.getSummary().getInterestOutstanding() : null;
final BigDecimal totalFeeOutstanding = loan.getSummary() != null ? loan.getSummary().getFeeChargesOutstanding() : null;
final BigDecimal totalPenaltyOutstanding = loan.getSummary() != null ? loan.getSummary().getPenaltyChargesOutstanding() : null;
final List<CodeValueData> chargeOffReasonOptions = new ArrayList<>(
this.codeValueReadPlatformService.retrieveCodeValuesByCode(LoanApiConstants.CHARGE_OFF_REASONS));
LoanTransactionData loanTransactionData = new LoanTransactionData(null, null, null, transactionType, null, loan.getCurrency(),
DateUtils.getBusinessLocalDate(), totalOutstanding, loan.getNetDisbursalAmount(), totalPrincipalOutstanding,
totalInterestOutstanding, totalFeeOutstanding, totalPenaltyOutstanding, null, ExternalId.empty(), null, null, null, null,
false, loanId, loan.getExternalId());
loanTransactionData.setChargeOffReasonOptions(chargeOffReasonOptions);
return loanTransactionData;
}
@Override
public Collection<Long> fetchLoansForInterestRecalculation() {
StringBuilder sqlBuilder = new StringBuilder();
@@ -111,4 +111,5 @@ public interface LoanWritePlatformService {
CommandProcessingResult markLoanAsFraud(Long loanId, JsonCommand command);
CommandProcessingResult chargeOff(JsonCommand command);
}
@@ -2644,6 +2644,58 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
.build();
}
@Override
@Transactional
public CommandProcessingResult chargeOff(JsonCommand command) {
loanEventApiJsonValidator.validateChargeOffTransaction(command.json());
final Map<String, Object> changes = new LinkedHashMap<>();
changes.put(LoanApiConstants.transactionDateParamName,
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());
final LocalDate transactionDate = command.localDateValueOfParameterNamed(LoanApiConstants.transactionDateParamName);
final ExternalId txnExternalId = externalIdFactory.createFromCommand(command, LoanApiConstants.externalIdParameterName);
final AppUser currentUser = getAppUserIfPresent();
if (command.hasParameter(LoanApiConstants.chargeOffReasonIdParamName)) {
Long chargeOffReasonId = command.longValueOfParameterNamed(LoanApiConstants.chargeOffReasonIdParamName);
CodeValue chargeOffReason = this.codeValueRepository
.findOneByCodeNameAndIdWithNotFoundDetection(LoanApiConstants.CHARGE_OFF_REASONS, chargeOffReasonId);
changes.put(LoanApiConstants.chargeOffReasonIdParamName, chargeOffReasonId);
loan.markAsChargedOff(transactionDate, currentUser, chargeOffReason);
} else {
loan.markAsChargedOff(transactionDate, currentUser, null);
}
LoanTransaction chargeOffTransaction = LoanTransaction.chargeOff(loan, transactionDate, txnExternalId);
loanTransactionRepository.saveAndFlush(chargeOffTransaction);
String noteText = command.stringValueOfParameterNamed(LoanApiConstants.noteParameterName);
if (StringUtils.isNotBlank(noteText)) {
changes.put(LoanApiConstants.noteParameterName, noteText);
final Note note = Note.loanTransactionNote(loan, chargeOffTransaction, noteText);
this.noteRepository.save(note);
}
// TODO: add accounting
// TODO: add external events
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(chargeOffTransaction.getId()) //
.withEntityExternalId(chargeOffTransaction.getExternalId()) //
.withOfficeId(loan.getOfficeId()) //
.withClientId(loan.getClientId()) //
.withGroupId(loan.getGroupId()) //
.withLoanId(command.getLoanId()) //
.with(changes).build();
}
private void validateIsMultiDisbursalLoanAndDisbursedMoreThanOneTranche(Loan loan) {
if (!loan.isMultiDisburmentLoan()) {
final String errorMessage = "loan.product.does.not.support.multiple.disbursals.cannot.undo.last.disbursal";
@@ -291,6 +291,8 @@ public final class LoanEnumerations {
LoanTransactionType.CHARGEBACK.getCode(), "Chargeback");
case CHARGE_ADJUSTMENT -> new LoanTransactionEnumData(LoanTransactionType.CHARGE_ADJUSTMENT.getValue().longValue(),
LoanTransactionType.CHARGE_ADJUSTMENT.getCode(), "Charge Adjustment");
case CHARGE_OFF -> new LoanTransactionEnumData(LoanTransactionType.CHARGE_OFF.getValue().longValue(),
LoanTransactionType.CHARGE_OFF.getCode(), "Charge-off");
};
}
@@ -165,9 +165,9 @@ final class SelfLoansApiResourceSwagger {
public String description;
}
static final class GetLoansLoanIdTimeline {
static final class GetSelfLoanIdTimeline {
private GetLoansLoanIdTimeline() {}
private GetSelfLoanIdTimeline() {}
@Schema(example = "[2012, 4, 3]")
public LocalDate submittedOnDate;
@@ -201,9 +201,9 @@ final class SelfLoansApiResourceSwagger {
public LocalDate closedOnDate;
}
static final class GetLoansLoanIdSummary {
static final class GetSelfLoanIdSummary {
private GetLoansLoanIdSummary() {}
private GetSelfLoanIdSummary() {}
static final class GetLoansLoanIdEmiVariations {
@@ -510,8 +510,8 @@ final class SelfLoansApiResourceSwagger {
public GetLoansLoanIdInterestCalculationPeriodType interestCalculationPeriodType;
@Schema(example = "mifos-standard-strategy")
public String transactionProcessingStrategyCode;
public GetLoansLoanIdTimeline timeline;
public GetLoansLoanIdSummary summary;
public GetSelfLoanIdTimeline timeline;
public GetSelfLoanIdSummary summary;
}
@Schema(description = "GetSelfLoansLoanIdTransactionsTransactionIdResponse")
@@ -554,7 +554,7 @@ final class SelfLoansApiResourceSwagger {
public LocalDate date;
@Schema(example = "false")
public Boolean manuallyReversed;
public GetSelfLoansLoanIdResponse.GetLoansLoanIdSummary.GetLoansLoanIdOverdueCharges.GetLoanCurrency currency;
public GetSelfLoansLoanIdResponse.GetSelfLoanIdSummary.GetLoansLoanIdOverdueCharges.GetLoanCurrency currency;
@Schema(example = "559.88")
public Float amount;
@Schema(example = "559.88")
@@ -602,7 +602,7 @@ final class SelfLoansApiResourceSwagger {
public Double percentage;
@Schema(example = "0")
public Double amountPercentageAppliedTo;
public GetSelfLoansLoanIdResponse.GetLoansLoanIdSummary.GetLoansLoanIdOverdueCharges.GetLoanCurrency currency;
public GetSelfLoansLoanIdResponse.GetSelfLoanIdSummary.GetLoansLoanIdOverdueCharges.GetLoanCurrency currency;
@Schema(example = "100")
public Float amount;
@Schema(example = "0")
@@ -98,4 +98,5 @@
<include file="parts/0076_add_loan_transaction_enum_values.xml" relativeToChangelogFile="true" />
<include file="parts/0077_add_overpaid_date_for_loan.xml" relativeToChangelogFile="true" />
<include file="parts/0078_add_configuration_cob_bulk_event.xml" relativeToChangelogFile="true" />
<include file="parts/0079_add_charge_off_details_to_loan.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?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.3.xsd">
<changeSet author="fineract" id="1">
<insert tableName="m_code">
<column name="code_name" value="ChargeOffReasons"/>
<column name="is_system_defined" valueBoolean="true"/>
</insert>
</changeSet>
<changeSet author="fineract" id="2">
<addColumn tableName="m_loan">
<column defaultValueBoolean="false" name="is_charged_off" type="boolean">
<constraints nullable="false"/>
</column>
<column defaultValueComputed="NULL" name="charged_off_on_date" type="date"/>
<column defaultValueComputed="NULL" name="charge_off_reason_cv_id" type="BIGINT"/>
<column defaultValueComputed="NULL" name="charged_off_by_userid" type="BIGINT"/>
</addColumn>
</changeSet>
<changeSet id="3" author="fineract">
<insert tableName="m_permission">
<column name="grouping" value="transaction_loan"/>
<column name="code" value="CHARGEOFF_LOAN"/>
<column name="entity_name" value="LOAN"/>
<column name="action_name" value="CHARGEOFF"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -19,7 +19,9 @@
package org.apache.fineract.integrationtests;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -89,6 +91,7 @@ import org.apache.fineract.integrationtests.common.savings.AccountTransferHelper
import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper;
import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper;
import org.apache.fineract.integrationtests.common.savings.SavingsStatusChecker;
import org.apache.fineract.integrationtests.common.system.CodeHelper;
import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType;
import org.apache.fineract.portfolio.charge.domain.ChargePaymentMode;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
@@ -6939,6 +6942,58 @@ public class ClientLoanIntegrationTest {
assertEquals(0.0f, loanSummary.get("totalWaived"));
}
@Test
public void chargeOff() {
final Account assetAccount = this.accountHelper.createAssetAccount();
final Account incomeAccount = this.accountHelper.createIncomeAccount();
final Account expenseAccount = this.accountHelper.createExpenseAccount();
final Account overpaymentAccount = this.accountHelper.createLiabilityAccount();
String randomText = Utils.randomStringGenerator("en", 5) + Utils.randomNumberGenerator(6) + Utils.randomStringGenerator("is", 5);
Integer chargeOffReasonId = CodeHelper.createChargeOffCodeValue(requestSpec, responseSpec, randomText, 1);
final Integer loanProductID = createLoanProductWithPeriodicAccrualAccountingNoInterest(assetAccount, incomeAccount, expenseAccount,
overpaymentAccount);
final Integer clientID = ClientHelper.createClient(requestSpec, responseSpec, "01 January 2011");
final Integer loanID = applyForLoanApplication(clientID, loanProductID);
HashMap<String, Object> loanStatusHashMap = LoanStatusChecker.getStatusOfLoan(requestSpec, responseSpec, loanID);
LoanStatusChecker.verifyLoanIsPending(loanStatusHashMap);
loanStatusHashMap = this.loanTransactionHelper.approveLoan("02 September 2022", loanID);
LoanStatusChecker.verifyLoanIsApproved(loanStatusHashMap);
LoanStatusChecker.verifyLoanIsWaitingForDisbursal(loanStatusHashMap);
loanStatusHashMap = this.loanTransactionHelper.disburseLoanWithNetDisbursalAmount("03 September 2022", loanID, "1000");
LoanStatusChecker.verifyLoanIsActive(loanStatusHashMap);
GetLoansLoanIdResponse loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanID);
assertTrue(loanDetails.getStatus().getActive());
assertEquals(1000.0, loanDetails.getSummary().getTotalOutstanding());
assertFalse(loanDetails.getChargedOff());
assertNull(loanDetails.getSummary().getChargeOffReasonId());
assertNull(loanDetails.getSummary().getChargeOffReason());
assertNull(loanDetails.getTimeline().getChargedOffOnDate());
assertNull(loanDetails.getTimeline().getChargedOffByUsername());
assertNull(loanDetails.getTimeline().getChargedOffByFirstname());
assertNull(loanDetails.getTimeline().getChargedOffByLastname());
String transactionExternalId = UUID.randomUUID().toString();
this.loanTransactionHelper.chargeOffLoan((long) loanID, new PostLoansLoanIdTransactionsRequest().transactionDate("4 September 2022")
.locale("en").dateFormat("dd MMMM yyyy").externalId(transactionExternalId).chargeOffReasonId((long) chargeOffReasonId));
loanDetails = this.loanTransactionHelper.getLoanDetails((long) loanID);
assertTrue(loanDetails.getStatus().getActive());
assertEquals(1000.0, loanDetails.getSummary().getTotalOutstanding());
assertTrue(loanDetails.getChargedOff());
assertEquals((long) chargeOffReasonId, loanDetails.getSummary().getChargeOffReasonId());
assertEquals(randomText, loanDetails.getSummary().getChargeOffReason());
assertEquals(LocalDate.of(2022, 9, 4), loanDetails.getTimeline().getChargedOffOnDate());
assertEquals("mifos", loanDetails.getTimeline().getChargedOffByUsername());
assertEquals("App", loanDetails.getTimeline().getChargedOffByFirstname());
assertEquals("Administrator", loanDetails.getTimeline().getChargedOffByLastname());
}
private Integer applyForLoanApplication(final Integer clientID, final Integer loanProductID) {
LOG.info("--------------------------------APPLYING FOR LOAN APPLICATION--------------------------------");
final String loanApplicationJSON = new LoanApplicationTestBuilder().withPrincipal("1000").withLoanTermFrequency("1")
@@ -188,6 +188,7 @@ public class ExternalIdSupportIntegrationTest extends IntegrationTest {
loanTransactionHelper.retrieveTransactionTemplate(loanExternalIdStr, "refundbycash", null, null, null);
loanTransactionHelper.retrieveTransactionTemplate(loanExternalIdStr, "foreclosure", null, null, null);
loanTransactionHelper.retrieveTransactionTemplate(loanExternalIdStr, "creditBalanceRefund", null, null, null);
loanTransactionHelper.retrieveTransactionTemplate(loanExternalIdStr, "charge-off", null, null, null);
// Check whether an external id was generated
String waiveChargeExternalIdStr = UUID.randomUUID().toString();
@@ -1069,6 +1070,23 @@ public class ExternalIdSupportIntegrationTest extends IntegrationTest {
.externalId(transactionExternalId2));
assertEquals(transactionExternalId2, forecloseResult.getResourceExternalId());
String loanExternalIdStr16 = UUID.randomUUID().toString();
String transactionExternalId3 = UUID.randomUUID().toString();
applyForLoanApplication(client.getClientId().intValue(), loanProductID, loanExternalIdStr16);
this.loanTransactionHelper.approveLoan(loanExternalIdStr16,
new PostLoansLoanIdRequest().approvedOnDate("2 September 2022").approvedLoanAmount(new BigDecimal("1000"))
.expectedDisbursementDate("2 September 2022").locale("en").dateFormat("dd MMMM yyyy"));
this.loanTransactionHelper.disburseLoan(loanExternalIdStr16,
new PostLoansLoanIdRequest().actualDisbursementDate("2 September 2022").transactionAmount(new BigDecimal("1000"))
.locale("en").dateFormat("dd MMMM yyyy"));
this.loanTransactionHelper.disburseLoan(loanExternalIdStr16,
new PostLoansLoanIdRequest().actualDisbursementDate("2 September 2022").transactionAmount(new BigDecimal("1000"))
.locale("en").dateFormat("dd MMMM yyyy"));
PostLoansLoanIdTransactionsResponse chargeOffResult = this.loanTransactionHelper.chargeOffLoan(loanExternalIdStr16,
new PostLoansLoanIdTransactionsRequest().transactionDate("2 September 2022").locale("en").dateFormat("dd MMMM yyyy")
.externalId(transactionExternalId3));
assertEquals(transactionExternalId3, chargeOffResult.getResourceExternalId());
} finally {
GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.FALSE);
GlobalConfigurationHelper.updateEnabledFlagForGlobalConfiguration(requestSpec, responseSpec, 50, false);
@@ -1719,4 +1719,12 @@ public class LoanTransactionHelper extends IntegrationTest {
public PostLoansLoanIdTransactionsResponse forecloseLoan(String loanExternalId, PostLoansLoanIdTransactionsRequest request) {
return ok(fineract().loanTransactions.executeLoanTransaction1(loanExternalId, request, "foreclosure"));
}
public PostLoansLoanIdTransactionsResponse chargeOffLoan(String loanExternalId, PostLoansLoanIdTransactionsRequest request) {
return ok(fineract().loanTransactions.executeLoanTransaction1(loanExternalId, request, "charge-off"));
}
public PostLoansLoanIdTransactionsResponse chargeOffLoan(Long loanId, PostLoansLoanIdTransactionsRequest request) {
return ok(fineract().loanTransactions.executeLoanTransaction(loanId, request, "charge-off"));
}
}
@@ -34,6 +34,7 @@ public final class CodeHelper {
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() {
@@ -175,6 +176,13 @@ public final class CodeHelper {
return createCodeValue(requestSpec, responseSpec, countryCode, countryName, position);
}
public static Integer createChargeOffCodeValue(final RequestSpecification requestSpec, final ResponseSpecification responseSpec,
final String value, final Integer position) {
HashMap<String, Object> code = getCodeByName(requestSpec, responseSpec, CHARGE_OFF_REASONS_CODE_NAME);
Integer countryCode = (Integer) code.get("id");
return createCodeValue(requestSpec, responseSpec, countryCode, value, position);
}
public static Integer createCodeValue(final RequestSpecification requestSpec, final ResponseSpecification responseSpec,
final Integer codeId, final String codeValueName, final Integer position) {
return (Integer) createCodeValue(requestSpec, responseSpec, codeId, codeValueName, position, SUBRESPONSE_ID_ATTRIBUTE_NAME);