FINERACT-2081: Payments after loan charge - payment allocation

This commit is contained in:
Marta Jankovics
2024-09-25 10:31:08 +02:00
committed by Adam Saghy
parent 2d0c8db993
commit 31c9079ad9
17 changed files with 746 additions and 228 deletions
@@ -120,6 +120,10 @@ public enum ChargeCalculationType {
|| isPercentageOfDisbursementAmount();
}
public boolean hasInterest() {
return isPercentageOfInterest() || isPercentageOfAmountAndInterest();
}
public boolean isPercentageOfDisbursementAmount() {
return this.value.equals(ChargeCalculationType.PERCENT_OF_DISBURSEMENT_AMOUNT.getValue());
}
@@ -338,6 +338,10 @@ public final class MathUtil {
// ----------------- Money -----------------
public static BigDecimal toBigDecimal(Money value) {
return value == null ? null : value.getAmount();
}
public static Money nullToZero(Money value, @NotNull MonetaryCurrency currency) {
return nullToDefault(value, Money.zero(currency));
}
@@ -20,7 +20,36 @@ package org.apache.fineract.test.data.loanproduct;
public enum DefaultLoanProduct implements LoanProduct {
LP1, LP1_DUE_DATE, LP1_INTEREST_FLAT, LP1_INTEREST_DECLINING_BALANCE_PERIOD_SAME_AS_PAYMENT, LP1_INTEREST_DECLINING_BALANCE_PERIOD_DAILY, LP1_1MONTH_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_MONTHLY, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_REDUCE_NR_INST, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_RESCH_NEXT_REP, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE, LP1_INTEREST_DECLINING_BALANCE_SAR_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE_MULTIDISB, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_INTEREST_FLAT, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_INTEREST_FLAT, LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT, LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT_INTEREST, LP2_DOWNPAYMENT, LP2_DOWNPAYMENT_AUTO, LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION, LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION, LP2_DOWNPAYMENT_INTEREST, LP2_DOWNPAYMENT_INTEREST_AUTO, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_VERTICAL, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_INSTALLMENT_LEVEL_DELINQUENCY, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROG_SCHEDULE_HOR_INST_LVL_DELINQUENCY_CREDIT_ALLOCATION, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH, LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE, LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY;
LP1, //
LP1_DUE_DATE, //
LP1_INTEREST_FLAT, //
LP1_INTEREST_DECLINING_BALANCE_PERIOD_SAME_AS_PAYMENT, //
LP1_INTEREST_DECLINING_BALANCE_PERIOD_DAILY, //
LP1_1MONTH_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_MONTHLY, //
LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE, //
LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_REDUCE_NR_INST, //
LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_RESCH_NEXT_REP, //
LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE, //
LP1_INTEREST_DECLINING_BALANCE_SAR_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE_MULTIDISB, //
LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE, //
LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_INTEREST_FLAT, //
LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE, //
LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_INTEREST_FLAT, //
LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT, //
LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT_INTEREST, //
LP2_DOWNPAYMENT, LP2_DOWNPAYMENT_AUTO, //
LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION, //
LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION, //
LP2_DOWNPAYMENT_INTEREST, LP2_DOWNPAYMENT_INTEREST_AUTO, //
LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, //
LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_VERTICAL, //
LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_INSTALLMENT_LEVEL_DELINQUENCY, //
LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROG_SCHEDULE_HOR_INST_LVL_DELINQUENCY_CREDIT_ALLOCATION, //
LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE, //
LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY, //
LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, //
;
@Override
public String getName() {
@@ -770,6 +770,116 @@ public class LoanProductsRequestFactory {
.incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF));//
}
public PostLoanProductsRequest defaultLoanProductsRequestLP2NoDown() {
String name = Utils.randomNameGenerator(NAME_PREFIX, 4);
String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX, 3);
List<Integer> principalVariationsForBorrowerCycle = new ArrayList<>();
List<Integer> numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>();
List<Integer> interestRateVariationsForBorrowerCycle = new ArrayList<>();
List<ChargeData> charges = new ArrayList<>();
List<ChargeToGLAccountMapper> penaltyToIncomeAccountMappings = new ArrayList<>();
List<GetLoanFeeToIncomeAccountMappings> feeToIncomeAccountMappings = new ArrayList<>();
List<GetLoanPaymentChannelToFundSourceMappings> paymentChannelToFundSourceMappings = new ArrayList<>();
GetLoanPaymentChannelToFundSourceMappings loanPaymentChannelToFundSourceMappings = new GetLoanPaymentChannelToFundSourceMappings();
loanPaymentChannelToFundSourceMappings.fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.FUND_RECEIVABLES));
loanPaymentChannelToFundSourceMappings.paymentTypeId(paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER));
paymentChannelToFundSourceMappings.add(loanPaymentChannelToFundSourceMappings);
return new PostLoanProductsRequest()//
.name(name)//
.shortName(shortName)//
.description(DESCRIPTION_LP2)//
.fundId(FUND_ID)//
.startDate(null)//
.closeDate(null)//
.includeInBorrowerCycle(false)//
.currencyCode(CURRENCY_CODE)//
.digitsAfterDecimal(2)//
.inMultiplesOf(0)//
.installmentAmountInMultiplesOf(1)//
.useBorrowerCycle(false)//
.minPrincipal(100.0)//
.principal(1000.0)//
.maxPrincipal(10000.0)//
.minNumberOfRepayments(1)//
.numberOfRepayments(1)//
.maxNumberOfRepayments(30)//
.isLinkedToFloatingInterestRates(false)//
.minInterestRatePerPeriod((double) 0)//
.interestRatePerPeriod((double) 0)//
.maxInterestRatePerPeriod((double) 0)//
.interestRateFrequencyType(INTEREST_RATE_FREQUENCY_TYPE_MONTH)//
.repaymentEvery(30)//
.repaymentFrequencyType(REPAYMENT_FREQUENCY_TYPE_DAYS)//
.principalVariationsForBorrowerCycle(principalVariationsForBorrowerCycle)//
.numberOfRepaymentVariationsForBorrowerCycle(numberOfRepaymentVariationsForBorrowerCycle)//
.interestRateVariationsForBorrowerCycle(interestRateVariationsForBorrowerCycle)//
.amortizationType(AMORTIZATION_TYPE)//
.interestType(INTEREST_TYPE_DECLINING_BALANCE)//
.isEqualAmortization(false)//
.interestCalculationPeriodType(INTEREST_CALCULATION_PERIOD_TYPE_SAME_AS_REPAYMENT)//
.transactionProcessingStrategyCode(TRANSACTION_PROCESSING_STRATEGY_CODE)//
.daysInYearType(DAYS_IN_YEAR_TYPE)//
.daysInMonthType(DAYS_IN_MONTH_TYPE)//
.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(charges)//
.accountingRule(LOAN_ACCOUNTING_RULE)//
.fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.SUSPENSE_CLEARING_ACCOUNT))//
.loanPortfolioAccountId(accountTypeResolver.resolve(DefaultAccountType.LOANS_RECEIVABLE))//
.transfersInSuspenseAccountId(accountTypeResolver.resolve(DefaultAccountType.TRANSFER_IN_SUSPENSE_ACCOUNT))//
.interestOnLoanAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME))//
.incomeFromFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_INCOME))//
.incomeFromPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_INCOME))//
.incomeFromRecoveryAccountId(accountTypeResolver.resolve(DefaultAccountType.RECOVERIES))//
.writeOffAccountId(accountTypeResolver.resolve(DefaultAccountType.WRITTEN_OFF))//
.overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OVERPAYMENT_ACCOUNT))//
.receivableInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.receivableFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.receivablePenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))//
.dateFormat(DATE_FORMAT)//
.locale(LOCALE_EN)//
.disallowExpectedDisbursements(true)//
.allowApprovedDisbursedAmountsOverApplied(true)//
.overAppliedCalculationType(OVER_APPLIED_CALCULATION_TYPE)//
.overAppliedNumber(OVER_APPLIED_NUMBER)//
.delinquencyBucketId(DELINQUENCY_BUCKET_ID.longValue())//
.goodwillCreditAccountId(accountTypeResolver.resolve(DefaultAccountType.GOODWILL_EXPENSE_ACCOUNT))//
.incomeFromGoodwillCreditInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))//
.incomeFromGoodwillCreditFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.incomeFromGoodwillCreditPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.paymentChannelToFundSourceMappings(paymentChannelToFundSourceMappings)//
.penaltyToIncomeAccountMappings(penaltyToIncomeAccountMappings)//
.feeToIncomeAccountMappings(feeToIncomeAccountMappings)//
.incomeFromChargeOffInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))//
.incomeFromChargeOffFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))//
.chargeOffExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT))//
.chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD))//
.incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF));//
}
public PostLoanProductsRequest defaultLoanProductsRequestLP2InterestFlat() {
String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_FLAT_LP2, 4);
String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_INTEREST, 3);
@@ -534,6 +534,23 @@ public class LoanProductGlobalInitializerStep implements FineractGlobalInitializ
TestContext.INSTANCE.set(
TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY,
responseLoanProductsRequestLP2AdvancedpaymentInterest36030InterestRecalcTillRestFrequency);
String name40 = DefaultLoanProduct.LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL.getName();
PostLoanProductsRequest loanProductsRequestLP2AdvPmtAllocProgressiveLoanScheduleHorizontal = loanProductsRequestFactory
.defaultLoanProductsRequestLP2NoDown()//
.name(name40)//
.transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION.getValue())//
.loanScheduleType("PROGRESSIVE") //
.loanScheduleProcessingType("HORIZONTAL")//
.paymentAllocation(List.of(//
createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), //
createPaymentAllocation("GOODWILL_CREDIT", "NEXT_INSTALLMENT"), //
createPaymentAllocation("MERCHANT_ISSUED_REFUND", "NEXT_INSTALLMENT"), //
createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT")));//
Response<PostLoanProductsResponse> responseLP2AdvPmtAllocProgressiveLoanScheduleHorizontal = loanProductsApi
.createLoanProduct(loanProductsRequestLP2AdvPmtAllocProgressiveLoanScheduleHorizontal).execute();
TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL,
responseLP2AdvPmtAllocProgressiveLoanScheduleHorizontal);
}
public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule,
@@ -79,6 +79,7 @@ public abstract class TestContextKey {
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH = "loanProductCreateResponseLP2DownPaymentProgressiveLoanScheduleFixedLength";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationTillPreClose";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationTillRestFrequency";
public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL = "loanProductCreateResponseLP2ProgressiveLoanScheduleHorizontal";
public static final String CHARGE_FOR_LOAN_PERCENT_LATE_CREATE_RESPONSE = "ChargeForLoanPercentLateCreateResponse";
public static final String CHARGE_FOR_LOAN_PERCENT_LATE_AMOUNT_PLUS_INTEREST_CREATE_RESPONSE = "ChargeForLoanPercentLateAmountPlusInterestCreateResponse";
public static final String CHARGE_FOR_LOAN_PERCENT_PROCESSING_CREATE_RESPONSE = "ChargeForLoanPercentProcessingCreateResponse";
@@ -3241,4 +3241,139 @@ Feature: LoanRepayment
And Customer makes "AUTOPAY" repayment on "01 September 2023" with 250 EUR transaction amount
When Admin sets the business date to "04 September 2023"
#Run COB to check there is no accounting meltdown and accrual is handled properly
When Admin runs inline COB job for Loan
When Admin runs inline COB job for Loan
@AdvancedPaymentAllocation @ProgressiveLoanSchedule
Scenario: Verify that payment allocation is correct in case of fee charged and payment is backdated
# --- 7/23 - Loan Created & Approved ---
When Admin sets the business date to "23 July 2024"
When Admin creates a client with random data
When Admin creates a fully customized loan with the following data:
| LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy |
| LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 23 July 2024 | 150 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 1 | MONTHS | 1 | MONTHS | 1 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION |
# --- 7/23 - Disbursement for 111.92 EUR ---
And Admin successfully approves the loan on "23 July 2024" with "111.92" amount and expected disbursement date on "23 July 2024"
When Admin successfully disburse the loan on "23 July 2024" with "111.92" EUR transaction amount
# --- 8/8 - Partial merchant issued refund - 76.48 Eur ---
When Admin sets the business date to "08 August 2024"
When Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "08 August 2024" with 76.48 EUR transaction amount and system-generated Idempotency key
# --- 8/13 - Manual Repayment - 35.44 Eur (Account closed) ---
When Admin sets the business date to "13 August 2024"
And Customer makes "MONEY_TRANSFER" repayment on "13 August 2024" with 35.44 EUR transaction amount
Then Loan status will be "CLOSED_OBLIGATIONS_MET"
Then Loan has 0 outstanding amount
# --- 8/15 - Repayment reversed (Account reopened) ---
When Admin sets the business date to "15 August 2024"
When Customer undo "1"th "Repayment" transaction made on "13 August 2024"
Then Loan status will be "ACTIVE"
Then Loan has 35.44 outstanding amount
# --- 8/22 - Autopay posted for 35.44 Eur ---
When Admin sets the business date to "22 August 2024"
And Customer makes "AUTOPAY" repayment on "22 August 2024" with 35.44 EUR transaction amount
Then Loan status will be "CLOSED_OBLIGATIONS_MET"
Then Loan has 0 outstanding amount
# --- 8/24 - Autopay reversed ---
When Admin sets the business date to "24 August 2024"
When Customer undo "1"th "Repayment" transaction made on "22 August 2024"
Then Loan status will be "ACTIVE"
Then Loan has 35.44 outstanding amount
# --- 8/24 - Loan Charge created for 2.80 Eur ---
When Admin adds "LOAN_NSF_FEE" due date charge with "24 August 2024" due date and 2.80 EUR transaction amount
Then Loan status will be "ACTIVE"
Then Loan has 38.24 outstanding amount
# --- 8/24 (after COB) - Accrual created ---
When Admin sets the business date to "25 August 2024"
When Admin runs inline COB job for Loan
Then Loan Repayment schedule has 2 periods, with the following data for periods:
| Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | |
| 1 | 31 | 23 August 2024 | | 0.0 | 111.92 | 0.0 | 0.0 | 0.0 | 111.92 | 76.48 | 76.48 | 0.0 | 35.44 |
| 2 | 1 | 24 August 2024 | | 0.0 | 0.0 | 0.0 | 0.0 | 2.8 | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 |
Then Loan Repayment schedule has the following data in Total row:
| Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| 111.92 | 0.0 | 0.0 | 2.8 | 114.72 | 76.48 | 76.48 | 0.0 | 38.24 |
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false |
| 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false |
| 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false |
# --- 8/28 - Backdated Autopay posted for 38.24 Eur (35.44 principal + 2.80 penalty) with transactionDate 8/22 ---
When Admin sets the business date to "28 August 2024"
And Customer makes "AUTOPAY" repayment on "22 August 2024" with 38.24 EUR transaction amount
Then Loan status will be "CLOSED_OBLIGATIONS_MET"
Then Loan has 0 outstanding amount
# TODO check data
Then Loan Repayment schedule has 2 periods, with the following data for periods:
| Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | |
| 1 | 31 | 23 August 2024 | 22 August 2024 | 0.0 | 111.92 | 0.0 | 0.0 | 0.0 | 111.92 | 111.92 | 111.92 | 0.0 | 0.0 |
| 2 | 1 | 24 August 2024 | 22 August 2024 | 0.0 | 0.0 | 0.0 | 0.0 | 2.8 | 2.8 | 2.8 | 2.8 | 0.0 | 0.0 |
Then Loan Repayment schedule has the following data in Total row:
| Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| 111.92 | 0.0 | 0.0 | 2.8 | 114.72 | 114.72 | 114.72 | 0.0 | 0.0 |
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false |
| 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false |
| 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false |
| 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false |
When Customer makes "AUTOPAY" repayment on "23 August 2024" with 10 EUR transaction amount
Then Loan status will be "OVERPAID"
Then Loan has 0 outstanding amount
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false |
| 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false |
| 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false |
| 23 August 2024 | Repayment | 10.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | false |
| 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false |
Then Loan Repayment schedule has 2 periods, with the following data for periods:
| Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | |
| 1 | 31 | 23 August 2024 | 22 August 2024 | 0.0 | 111.92 | 0.0 | 0.0 | 0.0 | 111.92 | 111.92 | 111.92 | 0.0 | 0.0 |
| 2 | 1 | 24 August 2024 | 22 August 2024 | 0.0 | 0.0 | 0.0 | 0.0 | 2.8 | 2.8 | 2.8 | 2.8 | 0.0 | 0.0 |
When Admin sets the business date to "29 August 2024"
When Admin runs inline COB job for Loan
When Admin adds "LOAN_NSF_FEE" due date charge with "22 August 2024" due date and 5 EUR transaction amount
Then Loan status will be "OVERPAID"
Then Loan has 0 outstanding amount
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false |
| 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false |
| 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false |
| 23 August 2024 | Repayment | 10.0 | 0.0 | 0.0 | 0.0 | 5.0 | 0.0 | false |
| 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false |
Then Loan Repayment schedule has 2 periods, with the following data for periods:
| Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | |
| 1 | 31 | 23 August 2024 | 23 August 2024 | 0.0 | 111.92 | 0.0 | 0.0 | 5.0 | 116.92 | 116.92 | 114.72 | 0.0 | 0.0 |
| 2 | 1 | 24 August 2024 | 23 August 2024 | 0.0 | 0.0 | 0.0 | 0.0 | 2.8 | 2.8 | 2.8 | 2.8 | 0.0 | 0.0 |
When Admin adds "LOAN_NSF_FEE" due date charge with "25 August 2024" due date and 5 EUR transaction amount
When Admin sets the business date to "30 August 2024"
When Admin runs inline COB job for Loan
Then Loan status will be "CLOSED_OBLIGATIONS_MET"
Then Loan has 0 outstanding amount
Then Loan Transactions tab has the following data:
| Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted |
| 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false |
| 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false |
| 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true |
| 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false |
| 23 August 2024 | Repayment | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | false |
| 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false |
| 25 August 2024 | Accrual | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | false |
Then Loan Repayment schedule has 2 periods, with the following data for periods:
| Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding |
| | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | |
| 1 | 31 | 23 August 2024 | 23 August 2024 | 0.0 | 111.92 | 0.0 | 0.0 | 5.0 | 116.92 | 116.92 | 114.72 | 0.0 | 0.0 |
| 2 | 2 | 25 August 2024 | 23 August 2024 | 0.0 | 0.0 | 0.0 | 0.0 | 7.8 | 7.8 | 7.8 | 7.8 | 0.0 | 0.0 |
@@ -704,8 +704,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.charges.add(loanCharge);
this.summary = updateSummaryWithTotalFeeChargesDueAtDisbursement(deriveSumTotalOfChargesDueAtDisbursement());
// store Id's of existing loan transactions and existing reversed loan
// transactions
// store Id's of existing loan transactions and existing reversed loan transactions
final SingleLoanChargeRepaymentScheduleProcessingWrapper wrapper = new SingleLoanChargeRepaymentScheduleProcessingWrapper();
wrapper.reprocess(getCurrency(), getDisbursementDate(), getRepaymentScheduleInstallments(), loanCharge);
updateLoanSummaryDerivedFields();
@@ -721,7 +720,6 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
changedTransactionDetail = loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(getDisbursementDate(),
allNonContraTransactionsPostDisbursement, getCurrency(), getRepaymentScheduleInstallments(), getActiveCharges());
for (final Map.Entry<Long, LoanTransaction> mapEntry : changedTransactionDetail.getNewTransactionMappings().entrySet()) {
mapEntry.getValue().updateLoan(this);
}
this.loanTransactions.addAll(changedTransactionDetail.getNewTransactionMappings().values());
@@ -729,6 +727,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return changedTransactionDetail;
}
public ChangedTransactionDetail reprocessTransactionsWithPostTransactionChecks(LocalDate transactionDate) {
ChangedTransactionDetail changedDetail = reprocessTransactions();
doPostLoanTransactionChecks(transactionDate, loanLifecycleStateMachine);
return changedDetail;
}
/**
* Creates a loanTransaction for "Apply Charge Event" with transaction date set to "suppliedTransactionDate". The
* newly created transaction is also added to the Loan on which this method is called.
@@ -2499,7 +2503,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
private boolean doPostLoanTransactionChecks(final LocalDate transactionDate,
final LoanLifecycleStateMachine loanLifecycleStateMachine) {
boolean statusChanged = false;
boolean isOverpaid = getTotalOverpaid() != null && getTotalOverpaid().compareTo(BigDecimal.ZERO) > 0;
boolean isOverpaid = MathUtil.isGreaterThanZero(totalOverpaid);
if (isOverpaid) {
// FIXME - kw - update account balance to negative amount.
handleLoanOverpayment(transactionDate, loanLifecycleStateMachine);
@@ -2510,7 +2514,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} else {
loanLifecycleStateMachine.transition(LoanEvent.LOAN_REPAYMENT_OR_WAIVER, this);
}
if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) {
if (MathUtil.isEmpty(totalOverpaid)) {
this.overpaidOnDate = null;
}
return statusChanged;
@@ -2526,8 +2530,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.actualMaturityDate = transactionDate;
loanLifecycleStateMachine.transition(LoanEvent.REPAID_IN_FULL, this);
} else if (LoanStatus.fromInt(this.loanStatus).isOverpaid()) {
if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) {
} else if (getStatus().isOverpaid()) {
if (MathUtil.isEmpty(totalOverpaid)) {
this.overpaidOnDate = null;
}
loanLifecycleStateMachine.transition(LoanEvent.LOAN_REPAYMENT_OR_WAIVER, this);
@@ -4498,7 +4502,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
loanTransaction.updateOutstandingLoanBalance(MathUtil.negativeToZero(outstanding.getAmount()));
} else if (loanTransaction.isChargeback() || loanTransaction.isCreditBalanceRefund()) {
Money transactionOutstanding = loanTransaction.getPrincipalPortion(getCurrency());
if (!loanTransaction.getOverPaymentPortion(getCurrency()).isZero()) {
if (loanTransaction.isOverPaid()) {
// in case of advanced payment strategy and creditAllocations the full amount is recognized first
if (this.getCreditAllocationRules() != null && this.getCreditAllocationRules().size() > 0) {
Money payedPrincipal = loanTransaction.getLoanTransactionToRepaymentScheduleMappings().stream() //
@@ -4792,7 +4796,7 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
updateLoanSummaryDerivedFields();
if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) {
if (MathUtil.isEmpty(totalOverpaid)) {
this.overpaidOnDate = null;
this.closedOnDate = newCreditBalanceRefundTransaction.getTransactionDate();
defaultLoanLifecycleStateMachine.transition(LoanEvent.LOAN_CREDIT_BALANCE_REFUND, this);
@@ -5497,12 +5501,12 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return getLoanTransactions().stream().filter(predicate).toList();
}
public LoanTransaction getLoanTransaction(Predicate<LoanTransaction> predicate) {
return getLoanTransactions().stream().filter(predicate).findFirst().orElse(null);
}
public LoanTransaction findChargedOffTransaction() {
return getLoanTransactions().stream() //
.filter(LoanTransaction::isNotReversed) //
.filter(LoanTransaction::isChargeOff) //
.findFirst() //
.orElse(null);
return getLoanTransaction(e -> e.isNotReversed() && e.isChargeOff());
}
public void handleMaturityDateActivate() {
@@ -38,9 +38,11 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@@ -303,6 +305,13 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
return newTransaction;
}
public LoanTransaction copyTransactionPropertiesAndMappings() {
LoanTransaction newTransaction = copyTransactionProperties(this);
newTransaction.updateLoanTransactionToRepaymentScheduleMappings(loanTransactionToRepaymentScheduleMappings);
newTransaction.updateLoanChargePaidMappings(loanChargesPaid);
return newTransaction;
}
public static LoanTransaction accrueLoanCharge(final Loan loan, final Office office, final Money amount, final LocalDate applyDate,
final Money feeCharges, final Money penaltyCharges, final ExternalId externalId) {
final LoanTransaction applyCharge = new LoanTransaction(loan, office, LoanTransactionType.ACCRUAL, amount.getAmount(), applyDate,
@@ -920,37 +929,84 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
public void updateLoanTransactionToRepaymentScheduleMappings(final Collection<LoanTransactionToRepaymentScheduleMapping> mappings) {
Collection<LoanTransactionToRepaymentScheduleMapping> retainMappings = new ArrayList<>();
for (LoanTransactionToRepaymentScheduleMapping updatedrepaymentScheduleMapping : mappings) {
updateMapingDetail(retainMappings, updatedrepaymentScheduleMapping);
for (LoanTransactionToRepaymentScheduleMapping updatedMapping : mappings) {
updateMappingDetail(retainMappings, updatedMapping, true);
}
this.loanTransactionToRepaymentScheduleMappings.retainAll(retainMappings);
}
private boolean updateMapingDetail(final Collection<LoanTransactionToRepaymentScheduleMapping> retainMappings,
final LoanTransactionToRepaymentScheduleMapping updatedrepaymentScheduleMapping) {
public void addLoanTransactionToRepaymentScheduleMappings(final Collection<LoanTransactionToRepaymentScheduleMapping> updatedMappings) {
for (LoanTransactionToRepaymentScheduleMapping updatedMapping : updatedMappings) {
updateMappingDetail(null, updatedMapping, false);
}
}
private boolean updateMappingDetail(final Collection<LoanTransactionToRepaymentScheduleMapping> retainMappings,
final LoanTransactionToRepaymentScheduleMapping updatedMapping, boolean overwrite) {
boolean isMappingUpdated = false;
for (LoanTransactionToRepaymentScheduleMapping repaymentScheduleMapping : this.loanTransactionToRepaymentScheduleMappings) {
if (updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId() != null
&& repaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate()
.equals(updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate())
&& updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId()
.equals(repaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId())) {
repaymentScheduleMapping.setComponents(updatedrepaymentScheduleMapping.getPrincipalPortion(),
updatedrepaymentScheduleMapping.getInterestPortion(), updatedrepaymentScheduleMapping.getFeeChargesPortion(),
updatedrepaymentScheduleMapping.getPenaltyChargesPortion());
LoanRepaymentScheduleInstallment updatedInstallment = updatedMapping.getLoanRepaymentScheduleInstallment();
for (LoanTransactionToRepaymentScheduleMapping existingMapping : this.loanTransactionToRepaymentScheduleMappings) {
LoanRepaymentScheduleInstallment existingInstallment = existingMapping.getLoanRepaymentScheduleInstallment();
if (DateUtils.isEqual(existingInstallment.getDueDate(), updatedInstallment.getDueDate()) && updatedInstallment.getId() != null
&& updatedInstallment.getId().equals(existingInstallment.getId())) {
if (overwrite) {
existingMapping.setComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(),
updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion());
} else {
existingMapping.updateComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(),
updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion());
}
isMappingUpdated = true;
retainMappings.add(repaymentScheduleMapping);
if (retainMappings != null) {
retainMappings.add(existingMapping);
}
break;
}
}
if (!isMappingUpdated) {
updatedrepaymentScheduleMapping.setLoanTransaction(this);
this.loanTransactionToRepaymentScheduleMappings.add(updatedrepaymentScheduleMapping);
retainMappings.add(updatedrepaymentScheduleMapping);
LoanTransactionToRepaymentScheduleMapping newMapping = LoanTransactionToRepaymentScheduleMapping.createFrom(this,
updatedInstallment, null, null, null, null);
newMapping.setComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(),
updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion());
this.loanTransactionToRepaymentScheduleMappings.add(newMapping);
if (retainMappings != null) {
retainMappings.add(newMapping);
}
}
return isMappingUpdated;
}
public void updateLoanChargePaidMappings(final Collection<LoanChargePaidBy> updatedMappings) {
Collection<LoanChargePaidBy> retainMappings = new ArrayList<>();
for (LoanChargePaidBy updatedMapping : updatedMappings) {
updateLoanChargePaid(retainMappings, updatedMapping);
}
this.loanChargesPaid.retainAll(retainMappings);
}
private boolean updateLoanChargePaid(final Collection<LoanChargePaidBy> retainMappings, final LoanChargePaidBy updatedMapping) {
boolean updated = false;
LoanCharge updatedCharge = updatedMapping.getLoanCharge();
Integer updatedInstallment = updatedMapping.getInstallmentNumber();
for (LoanChargePaidBy existingMapping : loanChargesPaid) {
LoanCharge existingCharge = existingMapping.getLoanCharge();
Integer existingInstallment = existingMapping.getInstallmentNumber();
if (existingCharge.equals(updatedCharge)
&& (existingInstallment == null ? updatedInstallment == null : existingInstallment.equals(updatedInstallment))) {
existingMapping.setAmount(updatedMapping.getAmount());
updated = true;
retainMappings.add(existingMapping);
break;
}
}
if (!updated) {
LoanChargePaidBy newMapping = new LoanChargePaidBy(this, updatedCharge, updatedMapping.getAmount(), updatedInstallment);
this.loanChargesPaid.add(newMapping);
retainMappings.add(newMapping);
}
return updated;
}
public Set<LoanTransactionToRepaymentScheduleMapping> getLoanTransactionToRepaymentScheduleMappings() {
return this.loanTransactionToRepaymentScheduleMappings;
}
@@ -989,18 +1045,21 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
}
public boolean hasLoanTransactionRelations() {
return (loanTransactionRelations != null && loanTransactionRelations.size() > 0);
}
public boolean hasChargebackLoanTransactionRelations() {
return (loanTransactionRelations != null
&& loanTransactionRelations.stream().anyMatch(e -> LoanTransactionRelationTypeEnum.CHARGEBACK.equals(e.getRelationType())));
return !loanTransactionRelations.isEmpty();
}
public Set<LoanTransactionRelation> getLoanTransactionRelations() {
return loanTransactionRelations;
}
public List<LoanTransactionRelation> getLoanTransactionRelations(Predicate<LoanTransactionRelation> predicate) {
return loanTransactionRelations.stream().filter(predicate).toList();
}
public boolean hasChargebackLoanTransactionRelations() {
return !getLoanTransactionRelations(e -> LoanTransactionRelationTypeEnum.CHARGEBACK.equals(e.getRelationType())).isEmpty();
}
public void copyLoanTransactionRelations(Set<LoanTransactionRelation> sourceLoanTransactionRelations) {
for (LoanTransactionRelation existingLoanTransactionRelation : sourceLoanTransactionRelations) {
loanTransactionRelations.add(new LoanTransactionRelation(this, existingLoanTransactionRelation.getToTransaction(),
@@ -1055,6 +1114,10 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
return chargeRefundChargeType;
}
public boolean isOverPaid() {
return MathUtil.isGreaterThanZero(overPaymentPortion);
}
// TODO missing hashCode(), equals(Object obj), but probably OK as long as
// this is never stored in a Collection.
}
@@ -24,8 +24,10 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
@@ -77,15 +79,11 @@ public class LoanTransactionToRepaymentScheduleMapping extends AbstractPersistab
final Money feeChargesPortion, final Money penaltyChargesPortion) {
return new LoanTransactionToRepaymentScheduleMapping(loanTransaction, installment, defaultToNullIfZero(principalPortion),
defaultToNullIfZero(interestPortion), defaultToNullIfZero(feeChargesPortion), defaultToNullIfZero(penaltyChargesPortion),
defaultToNullIfZero(principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion)));
defaultToNullIfZero(MathUtil.plus(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion)));
}
private static BigDecimal defaultToNullIfZero(final Money value) {
BigDecimal result = value.getAmount();
if (value.isZero()) {
result = null;
}
return result;
return (value == null || value.isZero()) ? null : value.getAmount();
}
private BigDecimal defaultToZeroIfNull(final BigDecimal value) {
@@ -100,10 +98,15 @@ public class LoanTransactionToRepaymentScheduleMapping extends AbstractPersistab
return this.installment;
}
public void updateComponents(final Money principal, final Money interest, final Money feeCharges, final Money penaltyCharges) {
final MonetaryCurrency currency = principal.getCurrency();
this.principalPortion = defaultToNullIfZero(getPrincipalPortion(currency).plus(principal));
this.interestPortion = defaultToNullIfZero(getInterestPortion(currency).plus(interest));
public void updateComponents(@NotNull Money principal, @NotNull Money interest, @NotNull Money feeCharges,
@NotNull Money penaltyCharges) {
updateComponents(principal.getAmount(), interest.getAmount(), feeCharges.getAmount(), penaltyCharges.getAmount());
}
void updateComponents(final BigDecimal principal, final BigDecimal interest, final BigDecimal feeCharges,
final BigDecimal penaltyCharges) {
this.principalPortion = MathUtil.zeroToNull(MathUtil.add(getPrincipalPortion(), principal));
this.interestPortion = MathUtil.zeroToNull(MathUtil.add(getInterestPortion(), interest));
updateChargesComponents(feeCharges, penaltyCharges);
updateAmount();
}
@@ -122,10 +125,9 @@ public class LoanTransactionToRepaymentScheduleMapping extends AbstractPersistab
updateAmount();
}
private void updateChargesComponents(final Money feeCharges, final Money penaltyCharges) {
final MonetaryCurrency currency = feeCharges.getCurrency();
this.feeChargesPortion = defaultToNullIfZero(getFeeChargesPortion(currency).plus(feeCharges));
this.penaltyChargesPortion = defaultToNullIfZero(getPenaltyChargesPortion(currency).plus(penaltyCharges));
private void updateChargesComponents(final BigDecimal feeCharges, final BigDecimal penaltyCharges) {
this.feeChargesPortion = MathUtil.zeroToNull(MathUtil.add(getFeeChargesPortion(), feeCharges));
this.penaltyChargesPortion = MathUtil.zeroToNull(MathUtil.add(getPenaltyChargesPortion(), penaltyCharges));
}
public Money getPrincipalPortion(final MonetaryCurrency currency) {
@@ -28,7 +28,9 @@ import org.apache.fineract.infrastructure.core.data.EnumOptionData;
@RequiredArgsConstructor
public enum LoanScheduleProcessingType {
HORIZONTAL("Horizontal"), VERTICAL("Vertical");
HORIZONTAL("Horizontal"), //
VERTICAL("Vertical"), //
;
private final String humanReadableName;
@@ -39,4 +41,12 @@ public enum LoanScheduleProcessingType {
public EnumOptionData asEnumOptionData() {
return new EnumOptionData((long) this.ordinal(), this.name(), this.humanReadableName);
}
public boolean isHorizontal() {
return this == HORIZONTAL;
}
public boolean isVertical() {
return this == VERTICAL;
}
}
@@ -50,4 +50,8 @@ public enum PaymentAllocationTransactionType {
public static List<EnumOptionData> getValuesAsEnumOptionDataList() {
return Arrays.stream(values()).map(v -> new EnumOptionData((long) (v.ordinal() + 1), v.name(), v.getHumanReadableName())).toList();
}
public boolean isDefault() {
return this == DEFAULT;
}
}
@@ -26,10 +26,9 @@ import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.FE
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.INTEREST;
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PENALTY;
import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PRINCIPAL;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE;
import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_REST_FREQUENCY_DATE;
import static org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType.DEFAULT;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
@@ -91,8 +90,6 @@ import org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocat
import org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail;
import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@Slf4j
@RequiredArgsConstructor
@@ -180,20 +177,37 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
final LoanProductRelatedDetail loanProductRelatedDetail = loan.getLoanRepaymentScheduleDetail();
ProgressiveLoanInterestScheduleModel scheduleModel = emiCalculator.generateModel(loanProductRelatedDetail,
installmentAmountInMultiplesOf, installments, mc);
ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder,
changedTransactionDetail, scheduleModel);
List<LoanTransaction> overpaidTransactions = new ArrayList<>();
for (final ChargeOrTransaction chargeOrTransaction : chargeOrTransactions) {
chargeOrTransaction.getLoanTransaction().ifPresent(loanTransaction -> processSingleTransaction(loanTransaction, ctx));
chargeOrTransaction.getLoanCharge()
.ifPresent(loanCharge -> processSingleCharge(loanCharge, currency, installments, disbursementDate));
if (chargeOrTransaction.isTransaction()) {
LoanTransaction transaction = chargeOrTransaction.getLoanTransaction().get();
processSingleTransaction(transaction, ctx);
transaction = getProcessedTransaction(changedTransactionDetail, transaction);
if (transaction.isOverPaid() && transaction.isRepaymentLikeType()) { // TODO CREDIT, DEBIT
overpaidTransactions.add(transaction);
}
} else {
LoanCharge loanCharge = chargeOrTransaction.getLoanCharge().get();
processSingleCharge(loanCharge, currency, installments, disbursementDate);
if (!loanCharge.isFullyPaid() && !overpaidTransactions.isEmpty()) {
overpaidTransactions = processOverpaidTransactions(overpaidTransactions, currency, installments, charges,
changedTransactionDetail, overpaymentHolder, scheduleModel);
}
}
}
Map<Long, LoanTransaction> newTransactionMappings = changedTransactionDetail.getNewTransactionMappings();
for (Long oldTransactionId : newTransactionMappings.keySet()) {
LoanTransaction oldTransaction = loanTransactions.stream().filter(e -> oldTransactionId.equals(e.getId())).findFirst().get();
LoanTransaction newTransaction = newTransactionMappings.get(oldTransactionId);
createNewTransaction(oldTransaction, newTransaction, ctx);
}
List<LoanTransaction> txs = chargeOrTransactions.stream() //
.map(ChargeOrTransaction::getLoanTransaction) //
.filter(Optional::isPresent) //
.map(Optional::get).toList();
recalculateInterestForDate(ThreadLocalContextUtil.getBusinessDate(), ctx, true);
List<LoanTransaction> txs = chargeOrTransactions.stream() //
.filter(ChargeOrTransaction::isTransaction) //
.map(e -> e.getLoanTransaction().get()).toList();
reprocessInstallments(disbursementDate, txs, installments, currency);
return Pair.of(changedTransactionDetail, scheduleModel);
}
@@ -204,6 +218,12 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
return reprocessProgressiveLoanTransactions(disbursementDate, loanTransactions, currency, installments, charges).getLeft();
}
@NotNull
private static LoanTransaction getProcessedTransaction(ChangedTransactionDetail changedTransactionDetail, LoanTransaction transaction) {
LoanTransaction newTransaction = changedTransactionDetail.getNewTransactionMappings().get(transaction.getId());
return newTransaction == null ? transaction : newTransaction;
}
@Override
public void processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) {
switch (loanTransaction.getTypeOf()) {
@@ -283,36 +303,33 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
.stream().anyMatch(e -> e.getTransactionType().getLoanTransactionType().equals(loanTransaction.getTypeOf())));
}
protected LoanTransaction findOriginalTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) {
if (loanTransaction.getId() != null) { // this the normal case without reverse-replay
Optional<LoanTransaction> originalTransaction = loanTransaction.getLoan().getLoanTransactions().stream()
.filter(tr -> tr.getLoanTransactionRelations().stream()
.anyMatch(this.hasMatchingToLoanTransaction(loanTransaction.getId(), CHARGEBACK)))
.findFirst();
if (originalTransaction.isEmpty()) {
throw new RuntimeException("Chargeback transaction must have an original transaction");
protected LoanTransaction findChargebackOriginalTransaction(LoanTransaction chargebackTransaction, TransactionCtx ctx) {
ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail();
Long chargebackId = chargebackTransaction.getId(); // this the normal case without reverse-replay
if (changedTransactionDetail != null) {
if (chargebackId == null) {
// the chargeback transaction was changed, so we need to look it up from the ctx.
chargebackId = changedTransactionDetail.getCurrentTransactionToOldId().get(chargebackTransaction);
}
return originalTransaction.get();
} else { // when there is no id, then it might be that the original transaction is changed, so we need to look
// it up from the Ctx.
Long originalChargebackTransactionId = ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().get(loanTransaction);
Collection<LoanTransaction> updatedTransactions = ctx.getChangedTransactionDetail().getNewTransactionMappings().values();
Optional<LoanTransaction> updatedTransaction = updatedTransactions.stream().filter(tr -> tr.getLoanTransactionRelations()
.stream().anyMatch(this.hasMatchingToLoanTransaction(originalChargebackTransactionId, CHARGEBACK))).findFirst();
if (updatedTransaction.isPresent()) {
return updatedTransaction.get();
} else { // if it is not there, then it simply means that this has not changed during reverse replay
Optional<LoanTransaction> originalTransaction = loanTransaction.getLoan().getLoanTransactions().stream()
.filter(tr -> tr.getLoanTransactionRelations().stream()
.anyMatch(this.hasMatchingToLoanTransaction(originalChargebackTransactionId, CHARGEBACK)))
.findFirst();
if (originalTransaction.isEmpty()) {
throw new RuntimeException("Chargeback transaction must have an original transaction");
}
return originalTransaction.get();
Long toId = chargebackId;
Collection<LoanTransaction> updatedTransactions = changedTransactionDetail.getNewTransactionMappings().values();
Optional<LoanTransaction> fromTransaction = updatedTransactions.stream()
.filter(tr -> tr.getLoanTransactionRelations().stream().anyMatch(hasMatchingToLoanTransaction(toId, CHARGEBACK)))
.findFirst();
if (fromTransaction.isPresent()) {
return fromTransaction.get();
}
}
Long toId = chargebackId;
// if the original transaction is not in the ctx, then it means that it has not changed during reverse replay
Optional<LoanTransaction> fromTransaction = chargebackTransaction.getLoan().getLoanTransactions().stream()
.filter(tr -> tr.getLoanTransactionRelations().stream().anyMatch(this.hasMatchingToLoanTransaction(toId, CHARGEBACK)))
.findFirst();
if (fromTransaction.isEmpty()) {
throw new RuntimeException("Chargeback transaction must have an original transaction");
}
return fromTransaction.get();
}
protected void processCreditTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) {
@@ -331,7 +348,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
if (transactionAmount.isGreaterThanZero()) {
if (loanTransaction.isChargeback()) {
LoanTransaction originalTransaction = findOriginalTransaction(loanTransaction, ctx);
LoanTransaction originalTransaction = findChargebackOriginalTransaction(loanTransaction, ctx);
// get the original allocation from the opriginal transaction
Map<AllocationType, Money> originalAllocationNotAdjusted = getOriginalAllocation(originalTransaction,
ctx.getCurrency());
@@ -420,8 +437,11 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransaction> chargebacks = allTransactions.stream().filter(LoanTransaction::isChargeback).toList();
// let's figure out the original transaction for these chargebacks, and order them by ascending order
Comparator<LoanTransaction> comparator = loanTransactionDateComparator();
List<LoanTransaction> chargebacksForTheSameOriginal = chargebacks.stream()
.filter(tr -> findOriginalTransaction(tr, ctx) == originalTransaction).sorted(loanTransactionDateComparator()).toList();
.filter(tr -> findChargebackOriginalTransaction(tr, ctx) == originalTransaction
&& comparator.compare(tr, chargeBackTransaction) < 0)
.sorted(comparator).toList();
Map<AllocationType, Money> allocation = new HashMap<>(originalAllocation);
for (LoanTransaction loanTransaction : chargebacksForTheSameOriginal) {
@@ -518,16 +538,11 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
Money transactionAmountUnprocessed = loanTransaction.getAmount(currency);
List<LoanPaymentAllocationRule> paymentAllocationRules = loanTransaction.getLoan().getPaymentAllocationRules();
LoanPaymentAllocationRule defaultPaymentAllocationRule = paymentAllocationRules.stream()
.filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow();
LoanPaymentAllocationRule paymentAllocationRule = paymentAllocationRules.stream()
.filter(e -> loanTransaction.getTypeOf().equals(e.getTransactionType().getLoanTransactionType())).findFirst()
.orElse(defaultPaymentAllocationRule);
LoanPaymentAllocationRule paymentAllocationRule = getAllocationRule(loanTransaction);
Balances balances = new Balances(zero, zero, zero, zero);
List<PaymentAllocationType> paymentAllocationTypes;
FutureInstallmentAllocationRule futureInstallmentAllocationRule;
if (DEFAULT.equals(paymentAllocationRule.getTransactionType())) {
if (paymentAllocationRule.getTransactionType().isDefault()) {
// if the allocation rule is not defined then the reverse order of the default allocation rule will be used
paymentAllocationTypes = new ArrayList<>(paymentAllocationRule.getAllocationTypes());
Collections.reverse(paymentAllocationTypes);
@@ -536,8 +551,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
paymentAllocationTypes = paymentAllocationRule.getAllocationTypes();
futureInstallmentAllocationRule = paymentAllocationRule.getFutureInstallmentAllocationRule();
}
if (LoanScheduleProcessingType.HORIZONTAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
Loan loan = loanTransaction.getLoan();
LoanScheduleProcessingType scheduleProcessingType = loan.getLoanProductRelatedDetail().getLoanScheduleProcessingType();
if (scheduleProcessingType.isHorizontal()) {
LinkedHashMap<DueType, List<PaymentAllocationType>> paymentAllocationsMap = paymentAllocationTypes.stream().collect(
Collectors.groupingBy(PaymentAllocationType::getDueType, LinkedHashMap::new, mapping(Function.identity(), toList())));
@@ -549,8 +565,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
break;
}
}
} else if (LoanScheduleProcessingType.VERTICAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
} else if (scheduleProcessingType.isVertical()) {
for (PaymentAllocationType paymentAllocationType : paymentAllocationTypes) {
transactionAmountUnprocessed = refundTransactionVertically(loanTransaction, currency, installments, zero,
transactionMappings, transactionAmountUnprocessed, futureInstallmentAllocationRule, charges, balances,
@@ -567,55 +582,152 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
private void processSingleTransaction(LoanTransaction loanTransaction, final ProgressiveTransactionCtx ctx) {
final MonetaryCurrency currency = ctx.getCurrency();
final ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail();
if (loanTransaction.getId() == null) {
processLatestTransaction(loanTransaction, ctx);
if (loanTransaction.isInterestWaiver()) {
loanTransaction.adjustInterestComponent(currency);
}
boolean isNew = loanTransaction.getId() == null;
LoanTransaction processTransaction = loanTransaction;
if (!isNew) {
// For existing transactions, check if the re-payment breakup (principal, interest, fees, penalties) has
// changed.
processTransaction = LoanTransaction.copyTransactionProperties(loanTransaction);
ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().put(processTransaction, loanTransaction.getId());
}
// Reset derived component of new loan transaction and re-process transaction
processLatestTransaction(processTransaction, ctx);
if (loanTransaction.isInterestWaiver()) {
processTransaction.adjustInterestComponent(ctx.getCurrency());
}
if (isNew) {
checkRegisteredNewTransaction(loanTransaction, ctx);
} else {
/*
* For existing transactions, check if the re-payment breakup (principal, interest, fees, penalties) has
* changed.<br>
*/
final LoanTransaction newLoanTransaction = LoanTransaction.copyTransactionProperties(loanTransaction);
ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().put(newLoanTransaction, loanTransaction.getId());
// Reset derived component of new loan transaction and
// re-process transaction
processLatestTransaction(newLoanTransaction, ctx);
if (loanTransaction.isInterestWaiver()) {
newLoanTransaction.adjustInterestComponent(currency);
}
/*
* Check if the transaction amounts have changed or was there any transaction for the same date which was
* reverse-replayed. If so, reverse the original transaction and update changedTransactionDetail accordingly
*/
boolean aTransactionWasAlreadyReplayedForTheSameDate = changedTransactionDetail.getNewTransactionMappings().values().stream()
.anyMatch(lt -> lt.getTransactionDate().equals(loanTransaction.getTransactionDate()));
if (LoanTransaction.transactionAmountsMatch(currency, loanTransaction, newLoanTransaction)
&& !aTransactionWasAlreadyReplayedForTheSameDate) {
loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(
newLoanTransaction.getLoanTransactionToRepaymentScheduleMappings());
} else {
createNewTransaction(loanTransaction, newLoanTransaction, changedTransactionDetail);
checkAndUpdateReplayedChargebackRelationWithReplayedTransaction(loanTransaction, newLoanTransaction, ctx);
}
updateOrRegisterNewTransaction(loanTransaction, processTransaction, ctx);
}
}
private void checkAndUpdateReplayedChargebackRelationWithReplayedTransaction(LoanTransaction loanTransaction,
LoanTransaction newLoanTransaction, TransactionCtx ctx) {
// if chargeback is getting reverse-replayed
// find replayed transaction with CHARGEBACK relation with reversed chargeback transaction
// for replayed transaction, add relation to point to new Chargeback transaction
if (loanTransaction.getTypeOf().isChargeback()) {
LoanTransaction originalTransaction = findOriginalTransaction(newLoanTransaction, ctx);
originalTransaction.getLoanTransactionRelations()
.add(LoanTransactionRelation.linkToTransaction(originalTransaction, newLoanTransaction, CHARGEBACK));
private List<LoanTransaction> processOverpaidTransactions(List<LoanTransaction> overpaidTransactions, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges, ChangedTransactionDetail changedTransactionDetail,
MoneyHolder overpaymentHolder, ProgressiveLoanInterestScheduleModel scheduleModel) {
List<LoanTransaction> remainingTransactions = new ArrayList<>(overpaidTransactions);
TransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder, changedTransactionDetail,
scheduleModel);
Money zero = Money.zero(currency);
for (LoanTransaction transaction : overpaidTransactions) {
Money overpayment = transaction.getOverPaymentPortion(currency);
Money ctxOverpayment = overpaymentHolder.getMoneyObject();
Money processAmount = MathUtil.min(ctxOverpayment, overpayment, false);
if (MathUtil.isEmpty(processAmount)) {
continue;
}
LoanTransaction processTransaction = transaction;
boolean isNew = transaction.getId() == null;
if (!isNew) {
processTransaction = transaction.copyTransactionPropertiesAndMappings();
ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().put(processTransaction, transaction.getId());
}
processTransaction.setOverPayments(overpayment = MathUtil.minus(overpayment, processAmount));
overpaymentHolder.setMoneyObject(ctxOverpayment = MathUtil.minus(ctxOverpayment, processAmount));
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
Balances balances = new Balances(zero, zero, zero, zero);
Money unprocessed = processPeriods(processTransaction, processAmount, charges, transactionMappings, balances, ctx);
processTransaction.setOverPayments(MathUtil.plus(overpayment, unprocessed));
overpaymentHolder.setMoneyObject(MathUtil.plus(ctxOverpayment, unprocessed));
processTransaction.updateComponents(balances.getAggregatedPrincipalPortion(), balances.getAggregatedInterestPortion(),
balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion());
processTransaction.addLoanTransactionToRepaymentScheduleMappings(transactionMappings);
if (processTransaction.isInterestWaiver()) {
processTransaction.adjustInterestComponent(currency);
}
if (isNew) {
processTransaction = checkRegisteredNewTransaction(transaction, ctx);
} else {
processTransaction = updateOrRegisterNewTransaction(transaction, processTransaction, ctx);
}
remainingTransactions.remove(transaction);
if (processTransaction.isOverPaid()) {
remainingTransactions.add(processTransaction);
break;
}
}
return remainingTransactions;
}
private LoanTransaction checkRegisteredNewTransaction(LoanTransaction newTransaction, TransactionCtx ctx) {
ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail();
Long oldTransactionId = changedTransactionDetail.getCurrentTransactionToOldId().get(newTransaction);
if (oldTransactionId != null) {
LoanTransaction oldTransaction = newTransaction.getLoan().getLoanTransaction(e -> oldTransactionId.equals(e.getId()));
LoanTransaction applicableTransaction = useOldTransactionIfApplicable(oldTransaction, newTransaction, ctx);
if (applicableTransaction != null) {
return applicableTransaction;
}
}
return newTransaction;
}
private LoanTransaction updateOrRegisterNewTransaction(LoanTransaction oldTransaction, LoanTransaction newTransaction,
TransactionCtx ctx) {
LoanTransaction applicableTransaction = useOldTransactionIfApplicable(oldTransaction, newTransaction, ctx);
if (applicableTransaction != null) {
return applicableTransaction;
}
newTransaction.copyLoanTransactionRelations(oldTransaction.getLoanTransactionRelations());
ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail();
changedTransactionDetail.getNewTransactionMappings().put(oldTransaction.getId(), newTransaction);
changedTransactionDetail.getCurrentTransactionToOldId().put(newTransaction, oldTransaction.getId());
return newTransaction;
}
@Nullable
private static LoanTransaction useOldTransactionIfApplicable(LoanTransaction oldTransaction, LoanTransaction newTransaction,
TransactionCtx ctx) {
MonetaryCurrency currency = ctx.getCurrency();
ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail();
Map<Long, LoanTransaction> newTransactionMappings = changedTransactionDetail.getNewTransactionMappings();
/*
* Check if the transaction amounts have changed or was there any transaction for the same date which was
* reverse-replayed. If so, reverse the original transaction and update changedTransactionDetail accordingly to
* keep the original order of the transactions.
*/
boolean alreadyProcessed = newTransactionMappings.values().stream()
.anyMatch(lt -> !lt.equals(newTransaction) && lt.getTransactionDate().equals(oldTransaction.getTransactionDate()));
boolean amountMatch = LoanTransaction.transactionAmountsMatch(currency, oldTransaction, newTransaction);
if (!alreadyProcessed && amountMatch) {
if (!oldTransaction.getTypeOf().isWaiveCharges()) { // WAIVE_CHARGES is not reprocessed
oldTransaction
.updateLoanTransactionToRepaymentScheduleMappings(newTransaction.getLoanTransactionToRepaymentScheduleMappings());
oldTransaction.updateLoanChargePaidMappings(newTransaction.getLoanChargesPaid());
}
changedTransactionDetail.getCurrentTransactionToOldId().remove(newTransaction);
newTransactionMappings.remove(oldTransaction.getId());
return oldTransaction;
}
return null;
}
protected void createNewTransaction(LoanTransaction oldTransaction, LoanTransaction newTransaction, TransactionCtx ctx) {
oldTransaction.updateExternalId(null);
oldTransaction.getLoanChargesPaid().clear();
// Adding Replayed relation from newly created transaction to reversed transaction
oldTransaction.getLoanTransactionRelations()
.add(LoanTransactionRelation.linkToTransaction(newTransaction, oldTransaction, LoanTransactionRelationTypeEnum.REPLAYED));
// if chargeback is getting reverse-replayed, find the original transaction with CHARGEBACK relation and point
// the relation to the new chargeback transaction
if (oldTransaction.getTypeOf().isChargeback()) {
LoanTransaction originalTransaction = findChargebackOriginalTransaction(newTransaction, ctx);
Set<LoanTransactionRelation> relations = originalTransaction.getLoanTransactionRelations();
List<LoanTransactionRelation> oldChargebackRelations = originalTransaction.getLoanTransactionRelations(
e -> CHARGEBACK.equals(e.getRelationType()) && e.getToTransaction().equals(oldTransaction));
oldChargebackRelations.forEach(relations::remove);
relations.add(LoanTransactionRelation.linkToTransaction(originalTransaction, newTransaction, CHARGEBACK));
}
oldTransaction.reverse();
}
private void processSingleCharge(LoanCharge loanCharge, MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments,
@@ -738,32 +850,21 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
private void allocateOverpayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) {
if (transactionCtx.getOverpaymentHolder().getMoneyObject().isGreaterThanZero()) {
if (transactionCtx.getOverpaymentHolder().getMoneyObject()
.isGreaterThan(loanTransaction.getAmount(transactionCtx.getCurrency()))) {
loanTransaction.setOverPayments(loanTransaction.getAmount(transactionCtx.getCurrency()));
} else {
loanTransaction.setOverPayments(transactionCtx.getOverpaymentHolder().getMoneyObject());
}
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
List<LoanPaymentAllocationRule> paymentAllocationRules = loanTransaction.getLoan().getPaymentAllocationRules();
LoanPaymentAllocationRule defaultPaymentAllocationRule = paymentAllocationRules.stream()
.filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow();
MoneyHolder overpaymentHolder = transactionCtx.getOverpaymentHolder();
Money overpayment = overpaymentHolder.getMoneyObject();
if (overpayment.isGreaterThanZero()) {
MonetaryCurrency currency = transactionCtx.getCurrency();
Money transactionAmount = loanTransaction.getAmount(currency);
loanTransaction.setOverPayments(MathUtil.min(transactionAmount, overpayment, false));
Money transactionAmountUnprocessed = transactionCtx.getOverpaymentHolder().getMoneyObject();
Money zero = Money.zero(transactionCtx.getCurrency());
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
Money zero = Money.zero(currency);
Balances balances = new Balances(zero, zero, zero, zero);
if (LoanScheduleProcessingType.HORIZONTAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
defaultPaymentAllocationRule, transactionMappings, Set.of(), balances);
} else if (LoanScheduleProcessingType.VERTICAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(),
transactionCtx.getInstallments(), transactionAmountUnprocessed, defaultPaymentAllocationRule, transactionMappings,
Set.of(), balances);
}
transactionCtx.getOverpaymentHolder().setMoneyObject(transactionAmountUnprocessed);
LoanPaymentAllocationRule defaultAllocationRule = getDefaultAllocationRule(loanTransaction.getLoan());
Money transactionAmountUnprocessed = processPeriods(loanTransaction, overpayment, defaultAllocationRule, Set.of(),
transactionMappings, balances, transactionCtx);
overpaymentHolder.setMoneyObject(transactionAmountUnprocessed);
loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings);
}
}
@@ -811,7 +912,6 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
private void adjustOverduePrincipalForInstallment(LocalDate currentDate, boolean isLastRecalculation,
LoanRepaymentScheduleInstallment currentInstallment, Money overduePrincipal, ProgressiveTransactionCtx ctx) {
LocalDate fromDate = currentInstallment.getFromDate();
boolean hasUpdate = false;
@@ -1209,27 +1309,12 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
private void processTransaction(LoanTransaction loanTransaction, TransactionCtx transactionCtx, Money transactionAmountUnprocessed) {
Money zero = Money.zero(transactionCtx.getCurrency());
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
List<LoanPaymentAllocationRule> paymentAllocationRules = loanTransaction.getLoan().getPaymentAllocationRules();
LoanPaymentAllocationRule defaultPaymentAllocationRule = paymentAllocationRules.stream()
.filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow();
LoanPaymentAllocationRule paymentAllocationRule = paymentAllocationRules.stream()
.filter(e -> loanTransaction.getTypeOf().equals(e.getTransactionType().getLoanTransactionType())).findFirst()
.orElse(defaultPaymentAllocationRule);
Money zero = Money.zero(transactionCtx.getCurrency());
Balances balances = new Balances(zero, zero, zero, zero);
transactionAmountUnprocessed = processPeriods(loanTransaction, transactionAmountUnprocessed, transactionCtx.getCharges(),
transactionMappings, balances, transactionCtx);
if (LoanScheduleProcessingType.HORIZONTAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed,
paymentAllocationRule, transactionMappings, transactionCtx.getCharges(), balances);
} else if (LoanScheduleProcessingType.VERTICAL
.equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) {
transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(),
transactionCtx.getInstallments(), transactionAmountUnprocessed, paymentAllocationRule, transactionMappings,
transactionCtx.getCharges(), balances);
}
loanTransaction.updateComponents(balances.getAggregatedPrincipalPortion(), balances.getAggregatedInterestPortion(),
balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion());
loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings);
@@ -1237,6 +1322,29 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
handleOverpayment(transactionAmountUnprocessed, loanTransaction, transactionCtx.getOverpaymentHolder());
}
private Money processPeriods(LoanTransaction transaction, Money processAmount, Set<LoanCharge> charges,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances, TransactionCtx transactionCtx) {
LoanPaymentAllocationRule allocationRule = getAllocationRule(transaction);
return processPeriods(transaction, processAmount, allocationRule, charges, transactionMappings, balances, transactionCtx);
}
private Money processPeriods(LoanTransaction transaction, Money processAmount, LoanPaymentAllocationRule allocationRule,
Set<LoanCharge> charges, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Balances balances,
TransactionCtx transactionCtx) {
MonetaryCurrency currency = transactionCtx.getCurrency();
LoanScheduleProcessingType scheduleProcessingType = transaction.getLoan().getLoanProductRelatedDetail()
.getLoanScheduleProcessingType();
if (scheduleProcessingType.isHorizontal()) {
return processPeriodsHorizontally(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, charges,
balances);
}
if (scheduleProcessingType.isVertical()) {
return processPeriodsVertically(transaction, currency, transactionCtx.getInstallments(), processAmount, allocationRule,
transactionMappings, charges, balances);
}
return processAmount;
}
private Money processPeriodsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx,
Money transactionAmountUnprocessed, LoanPaymentAllocationRule paymentAllocationRule,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges, Balances balances) {
@@ -1606,4 +1714,17 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
default -> throw new UnsupportedOperationException(reAgeParameter.getFrequencyType().getCode());
};
}
@NotNull
public static LoanPaymentAllocationRule getAllocationRule(LoanTransaction loanTransaction) {
Loan loan = loanTransaction.getLoan();
return loan.getPaymentAllocationRules().stream()
.filter(e -> loanTransaction.getTypeOf() == e.getTransactionType().getLoanTransactionType()).findFirst()
.orElse(getDefaultAllocationRule(loan));
}
@NotNull
public static LoanPaymentAllocationRule getDefaultAllocationRule(Loan loan) {
return loan.getPaymentAllocationRules().stream().filter(e -> e.getTransactionType().isDefault()).findFirst().orElseThrow();
}
}
@@ -23,6 +23,7 @@ import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.Optional;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.jetbrains.annotations.NotNull;
@@ -43,6 +44,14 @@ public class ChargeOrTransaction implements Comparable<ChargeOrTransaction> {
this.loanCharge = Optional.empty();
}
public boolean isTransaction() {
return loanTransaction.isPresent();
}
public boolean isCharge() {
return loanCharge.isPresent();
}
private LocalDate getEffectiveDate() {
if (loanCharge.isPresent()) {
if (isBackdatedCharge()) {
@@ -58,11 +67,11 @@ public class ChargeOrTransaction implements Comparable<ChargeOrTransaction> {
}
private boolean isAccrualActivity() {
return loanTransaction.isPresent() && loanTransaction.get().isAccrualActivity();
return isTransaction() && loanTransaction.get().isAccrualActivity();
}
private boolean isBackdatedCharge() {
return loanCharge.get().getDueDate().isBefore(loanCharge.get().getSubmittedOnDate());
return isCharge() && DateUtils.isBefore(loanCharge.get().getDueDate(), loanCharge.get().getSubmittedOnDate());
}
private LocalDate getSubmittedOnDate() {
@@ -88,17 +97,15 @@ public class ChargeOrTransaction implements Comparable<ChargeOrTransaction> {
@Override
@SuppressFBWarnings(value = "EQ_COMPARETO_USE_OBJECT_EQUALS", justification = "TODO: fix this! See: https://stackoverflow.com/questions/2609037/findbugs-how-to-solve-eq-compareto-use-object-equals")
public int compareTo(@NotNull ChargeOrTransaction o) {
int datePortion = this.getEffectiveDate().compareTo(o.getEffectiveDate());
int datePortion = DateUtils.compare(this.getEffectiveDate(), o.getEffectiveDate());
if (datePortion == 0) {
if (this.isAccrualActivity() && !o.isAccrualActivity()) {
return 1;
boolean isAccrual = isAccrualActivity();
if (isAccrual != o.isAccrualActivity()) {
return isAccrual ? 1 : -1;
}
if (!this.isAccrualActivity() && o.isAccrualActivity()) {
return -1;
}
int submittedDate = this.getSubmittedOnDate().compareTo(o.getSubmittedOnDate());
int submittedDate = DateUtils.compare(getSubmittedOnDate(), o.getSubmittedOnDate());
if (submittedDate == 0) {
return this.getCreatedDateTime().compareTo(o.getCreatedDateTime());
return DateUtils.compare(getCreatedDateTime(), o.getCreatedDateTime());
}
return submittedDate;
}
@@ -252,7 +252,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
assertEquals(0, chargeAmount.compareTo(installment.getFeeChargesCharged(currency).getAmount()));
assertEquals(0, BigDecimal.ZERO.compareTo(installment.getFeeChargesOutstanding(currency).getAmount()));
assertEquals(0, BigDecimal.valueOf(80).compareTo(installment.getPrincipalOutstanding(currency).getAmount()));
Mockito.verify(loan, times(1)).getPaymentAllocationRules();
Mockito.verify(loan, times(2)).getPaymentAllocationRules();
}
@Test
@@ -531,7 +531,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
TransactionCtx ctx = mock(TransactionCtx.class);
// when
LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackTransaction, ctx);
LoanTransaction originalTransaction = underTest.findChargebackOriginalTransaction(chargebackTransaction, ctx);
// then
Assertions.assertEquals(originalTransaction, repayment2);
@@ -554,7 +554,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
// when + then
RuntimeException runtimeException = Assertions.assertThrows(RuntimeException.class,
() -> underTest.findOriginalTransaction(chargebackTransaction, ctx));
() -> underTest.findChargebackOriginalTransaction(chargebackTransaction, ctx));
Assertions.assertEquals("Chargeback transaction must have an original transaction", runtimeException.getMessage());
}
@@ -580,7 +580,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
when(changedTransactionDetail.getNewTransactionMappings()).thenReturn(Map.of(122L, repayment1, 121L, repayment2));
// when
LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackReplayed, ctx);
LoanTransaction originalTransaction = underTest.findChargebackOriginalTransaction(chargebackReplayed, ctx);
// then
Assertions.assertEquals(originalTransaction, repayment2);
@@ -611,7 +611,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
when(changedTransactionDetail.getNewTransactionMappings()).thenReturn(Map.of());
// when
LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackReplayed, ctx);
LoanTransaction originalTransaction = underTest.findChargebackOriginalTransaction(chargebackReplayed, ctx);
// then
Assertions.assertEquals(originalTransaction, repayment2);
@@ -209,29 +209,34 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo
boolean isAppliedOnBackDate = false;
LoanCharge loanCharge = null;
LocalDate recalculateFrom = loan.fetchInterestRecalculateFromDate();
LocalDate transactionDate = null;
if (chargeDefinition.isPercentageOfDisbursementAmount()) {
LoanTrancheDisbursementCharge loanTrancheDisbursementCharge;
ExternalId externalId = externalIdFactory.createFromCommand(command, "externalId");
boolean needToGenerateNewExternalId = false;
boolean isFirst = true;
for (LoanDisbursementDetails disbursementDetail : loanDisburseDetails) {
if (disbursementDetail.actualDisbursementDate() == null) {
// If multiple charges to be applied, only the first one will get the provided externalId, for the
// rest we generate new ones (if needed)
if (needToGenerateNewExternalId) {
if (!isFirst) {
externalId = externalIdFactory.create();
}
LocalDate dueDate = disbursementDetail.expectedDisbursementDateAsLocalDate();
loanCharge = loanChargeAssembler.createNewWithoutLoan(chargeDefinition, disbursementDetail.principal(), null, null,
null, disbursementDetail.expectedDisbursementDateAsLocalDate(), null, null, externalId);
null, dueDate, null, null, externalId);
loanTrancheDisbursementCharge = new LoanTrancheDisbursementCharge(loanCharge, disbursementDetail);
loanCharge.updateLoanTrancheDisbursementCharge(loanTrancheDisbursementCharge);
businessEventNotifierService.notifyPreBusinessEvent(new LoanAddChargeBusinessEvent(loanCharge));
validateAddLoanCharge(loan, chargeDefinition, loanCharge);
addCharge(loan, chargeDefinition, loanCharge);
isAppliedOnBackDate = true;
if (DateUtils.isAfter(recalculateFrom, disbursementDetail.expectedDisbursementDateAsLocalDate())) {
recalculateFrom = disbursementDetail.expectedDisbursementDateAsLocalDate();
if (DateUtils.isAfter(recalculateFrom, dueDate)) {
recalculateFrom = dueDate;
}
needToGenerateNewExternalId = true;
if (isFirst) {
transactionDate = loanCharge.getEffectiveDueDate();
}
isFirst = false;
}
}
if (loanCharge == null) {
@@ -246,35 +251,38 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo
validateAddLoanCharge(loan, chargeDefinition, loanCharge);
isAppliedOnBackDate = addCharge(loan, chargeDefinition, loanCharge);
if (loanCharge.getDueLocalDate() == null || DateUtils.isAfter(recalculateFrom, loanCharge.getDueLocalDate())) {
if (DateUtils.isAfter(recalculateFrom, loanCharge.getDueLocalDate())) {
isAppliedOnBackDate = true;
recalculateFrom = loanCharge.getDueLocalDate();
}
transactionDate = loanCharge.getEffectiveDueDate();
}
boolean reprocessRequired = true;
if (loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) {
// overpaid transactions will be reprocessed and pay this charge
boolean overpaidReprocess = !loanCharge.isDueAtDisbursement() && !loanCharge.isPaid() && loan.getStatus().isOverpaid();
if (!overpaidReprocess && loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) {
if (isAppliedOnBackDate && loan.isFeeCompoundingEnabledForInterestRecalculation()) {
loan = runScheduleRecalculation(loan, recalculateFrom);
reprocessRequired = false;
}
this.loanWritePlatformService.updateOriginalSchedule(loan);
}
// [For Adv payment allocation strategy] check if charge due date is earlier than last transaction
// date, if yes trigger reprocess else no reprocessing
if (AdvancedPaymentScheduleTransactionProcessor.ADVANCED_PAYMENT_ALLOCATION_STRATEGY.equals(loan.transactionProcessingStrategy())) {
if (!overpaidReprocess && AdvancedPaymentScheduleTransactionProcessor.ADVANCED_PAYMENT_ALLOCATION_STRATEGY
.equals(loan.transactionProcessingStrategy())) {
// [For Adv payment allocation strategy] check if charge due date is earlier than last transaction
// date, if yes trigger reprocess else no reprocessing
LoanTransaction lastPaymentTransaction = loan.getLastTransactionForReprocessing();
if (lastPaymentTransaction != null) {
if (loanCharge.getEffectiveDueDate() != null
&& DateUtils.isAfter(loanCharge.getEffectiveDueDate(), lastPaymentTransaction.getTransactionDate())) {
reprocessRequired = false;
}
if (lastPaymentTransaction != null
&& DateUtils.isAfter(loanCharge.getEffectiveDueDate(), lastPaymentTransaction.getTransactionDate())) {
reprocessRequired = false;
}
}
if (reprocessRequired) {
ChangedTransactionDetail changedTransactionDetail = loan.reprocessTransactions();
ChangedTransactionDetail changedTransactionDetail = overpaidReprocess
? loan.reprocessTransactionsWithPostTransactionChecks(transactionDate)
: loan.reprocessTransactions();
if (changedTransactionDetail != null) {
for (final Map.Entry<Long, LoanTransaction> mapEntry : changedTransactionDetail.getNewTransactionMappings().entrySet()) {
loanAccountDomainService.saveLoanTransactionWithDataIntegrityViolationChecks(mapEntry.getValue());
@@ -284,7 +292,6 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo
replayedTransactionBusinessEventService.raiseTransactionReplayedEvents(changedTransactionDetail);
}
loan = loanAccountDomainService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan);
}
postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
@@ -1355,7 +1355,7 @@ public class ClientLoanIntegrationTest {
* amount
*/
@Test
public void loanWithCahargesOfTypeAmountPercentageAndCashBasedAccountingEnabled() {
public void loanWithChargesOfTypeAmountPercentageAndCashBasedAccountingEnabled() {
final Integer clientID = ClientHelper.createClient(REQUEST_SPEC, RESPONSE_SPEC);
ClientHelper.verifyClientCreatedOnServer(REQUEST_SPEC, RESPONSE_SPEC, clientID);