blob: 8c491770a991a95ba4b79bd1bc9a28e914c91d88 [file] [log] [blame]
/*
* Copyright 2017 The Mifos Initiative.
*
* Licensed 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 io.mifos.individuallending.internal.service;
import io.mifos.individuallending.IndividualLendingPatternFactory;
import io.mifos.individuallending.api.v1.domain.caseinstance.CaseParameters;
import io.mifos.individuallending.api.v1.domain.caseinstance.ChargeName;
import io.mifos.individuallending.api.v1.domain.caseinstance.PlannedPayment;
import io.mifos.individuallending.api.v1.domain.caseinstance.PlannedPaymentPage;
import io.mifos.individuallending.api.v1.domain.product.AccountDesignators;
import io.mifos.individuallending.api.v1.domain.product.ChargeIdentifiers;
import io.mifos.individuallending.api.v1.domain.workflow.Action;
import io.mifos.individuallending.internal.mapper.CaseParametersMapper;
import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
import io.mifos.portfolio.api.v1.domain.CostComponent;
import io.mifos.portfolio.api.v1.domain.PaymentCycle;
import io.mifos.portfolio.api.v1.domain.TermRange;
import io.mifos.portfolio.service.internal.repository.BalanceSegmentRepository;
import io.mifos.portfolio.service.internal.repository.CaseEntity;
import io.mifos.portfolio.service.internal.repository.ProductEntity;
import io.mifos.portfolio.service.internal.service.ChargeDefinitionService;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.mockito.Matchers;
import org.mockito.Mockito;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static io.mifos.individuallending.api.v1.domain.product.ChargeIdentifiers.*;
/**
* @author Myrle Krantz
*/
@RunWith(Parameterized.class)
public class IndividualLoanServiceTest {
private static class ActionDatePair {
final Action action;
final LocalDate localDate;
ActionDatePair(final Action action, final LocalDate localDate) {
this.action = action;
this.localDate = localDate;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ActionDatePair that = (ActionDatePair) o;
return action == that.action &&
Objects.equals(localDate, that.localDate);
}
@Override
public int hashCode() {
return Objects.hash(action, localDate);
}
@Override
public String toString() {
return "ActionDatePair{" +
"action=" + action +
", localDate=" + localDate +
'}';
}
}
private static class TestCase {
private final String description;
private String productIdentifier = "blah";
private int minorCurrencyUnitDigits = 2;
private CaseParameters caseParameters;
private LocalDate initialDisbursementDate;
private List<ChargeDefinition> chargeDefinitions;
private BigDecimal interest;
private Set<String> expectedChargeIdentifiers = new HashSet<>(Arrays.asList(
PROCESSING_FEE_ID,
LOAN_ORIGINATION_FEE_ID,
INTEREST_ID,
DISBURSEMENT_FEE_ID,
REPAY_PRINCIPAL_ID,
DISBURSE_PAYMENT_ID,
LATE_FEE_ID
));
private Map<ActionDatePair, List<ChargeDefinition>> chargeDefinitionsForActions = new HashMap<>();
//This is an abuse of the ChargeInstance since everywhere else it's intended to contain account identifiers and not
//account designators. Don't copy the code around charge instances in this test without thinking about what you're
//doing carefully first.
TestCase(final String description) {
this.description = description;
}
TestCase minorCurrencyUnitDigits(final int newVal) {
this.minorCurrencyUnitDigits = newVal;
return this;
}
TestCase caseParameters(final CaseParameters newVal) {
this.caseParameters = newVal;
return this;
}
TestCase initialDisbursementDate(final LocalDate newVal) {
this.initialDisbursementDate = newVal;
return this;
}
TestCase chargeDefinitions(final List<ChargeDefinition> newVal) {
this.chargeDefinitions = newVal;
return this;
}
TestCase interest(final BigDecimal newVal) {
this.interest = newVal;
return this;
}
TestCase expectChargeInstancesForActionDatePair(final Action action,
final LocalDate forDate,
final List<ChargeDefinition> chargeDefinitions) {
this.chargeDefinitionsForActions.put(new ActionDatePair(action, forDate), chargeDefinitions);
return this;
}
DataContextOfAction getDataContextOfAction() {
final ProductEntity product = new ProductEntity();
product.setMinorCurrencyUnitDigits(minorCurrencyUnitDigits);
product.setIdentifier(productIdentifier);
final CaseEntity customerCase = new CaseEntity();
customerCase.setInterest(interest);
return new DataContextOfAction(product, customerCase, CaseParametersMapper.map(1L, caseParameters), Collections.emptyList());
}
@Override
public String toString() {
return "TestCase{" +
"description='" + description + '\'' +
'}';
}
}
@Parameterized.Parameters
public static Collection testCases() {
final Collection<TestCase> ret = new ArrayList<>();
ret.add(simpleCase());
ret.add(yearLoanTestCase());
ret.add(chargeDefaultsCase());
return ret;
}
private final TestCase testCase;
private final IndividualLoanService testSubject;
private final ScheduledChargesService scheduledChargesService;
private final Map<String, List<ChargeDefinition>> chargeDefinitionsByChargeAction;
private final Map<String, List<ChargeDefinition>> chargeDefinitionsByAccrueAction;
private static TestCase simpleCase()
{
final LocalDate initialDisbursementDate = LocalDate.of(2017, 1, 5);
//final Period firstRepaymentPeriod = new Period(initialDisbursementDate, 1);
final CaseParameters caseParameters = Fixture.getTestCaseParameters();
caseParameters.setTermRange(new TermRange(ChronoUnit.WEEKS, 3));
caseParameters.setPaymentCycle(new PaymentCycle(ChronoUnit.WEEKS, 1, 0, null, null));
final ChargeDefinition processingFeeCharge = getFixedSingleChargeDefinition(10.0, Action.DISBURSE, PROCESSING_FEE_ID, AccountDesignators.PROCESSING_FEE_INCOME);
final ChargeDefinition loanOriginationFeeCharge = getFixedSingleChargeDefinition(100.0, Action.DISBURSE, LOAN_ORIGINATION_FEE_ID, AccountDesignators.ORIGINATION_FEE_INCOME);
final List<ChargeDefinition> defaultChargesWithFeesReplaced =
charges().stream().map(x -> {
switch (x.getIdentifier()) {
case PROCESSING_FEE_ID:
return processingFeeCharge;
case LOAN_ORIGINATION_FEE_ID:
return loanOriginationFeeCharge;
default:
return x;
}
}).collect(Collectors.toList());
return new TestCase("simpleCase")
.minorCurrencyUnitDigits(2)
.caseParameters(caseParameters)
.initialDisbursementDate(initialDisbursementDate)
.chargeDefinitions(defaultChargesWithFeesReplaced)
.interest(BigDecimal.valueOf(1))
.expectChargeInstancesForActionDatePair(Action.DISBURSE, initialDisbursementDate, Arrays.asList(processingFeeCharge, loanOriginationFeeCharge));
}
private static TestCase yearLoanTestCase()
{
final LocalDate initialDisbursementDate = LocalDate.of(2017, 1, 1);
//final Period firstRepaymentPeriod = new Period(initialDisbursementDate, 1);
final CaseParameters caseParameters = Fixture.getTestCaseParameters();
caseParameters.setTermRange(new TermRange(ChronoUnit.YEARS, 1));
caseParameters.setPaymentCycle(new PaymentCycle(ChronoUnit.MONTHS, 1, 0, null, null));
caseParameters.setMaximumBalance(BigDecimal.valueOf(200000));
final List<ChargeDefinition> charges = charges();
return new TestCase("yearLoanTestCase")
.minorCurrencyUnitDigits(3)
.caseParameters(caseParameters)
.initialDisbursementDate(initialDisbursementDate)
.chargeDefinitions(charges)
.interest(BigDecimal.valueOf(10));
}
private static TestCase chargeDefaultsCase()
{
final LocalDate initialDisbursementDate = LocalDate.of(2017, 2, 6);
final CaseParameters caseParameters = Fixture.getTestCaseParameters();
caseParameters.setTermRange(new TermRange(ChronoUnit.MONTHS, 6));
caseParameters.setPaymentCycle(new PaymentCycle(ChronoUnit.WEEKS, 1, 1, 0, 0));
caseParameters.setMaximumBalance(BigDecimal.valueOf(2000));
final List<ChargeDefinition> charges = charges();
return new TestCase("chargeDefaultsCase")
.minorCurrencyUnitDigits(2)
.caseParameters(caseParameters)
.initialDisbursementDate(initialDisbursementDate)
.chargeDefinitions(charges)
.interest(BigDecimal.valueOf(5));
}
private static List<ChargeDefinition> charges() {
final List<ChargeDefinition> ret = IndividualLendingPatternFactory.requiredIndividualLoanCharges();
ret.addAll(IndividualLendingPatternFactory.defaultIndividualLoanCharges());
return ret;
}
private static ChargeDefinition getFixedSingleChargeDefinition(
final double amount,
final Action action,
final String chargeIdentifier,
final String feeAccountDesignator) {
final ChargeDefinition ret = new ChargeDefinition();
ret.setAmount(BigDecimal.valueOf(amount));
ret.setIdentifier(chargeIdentifier);
ret.setAccrueAction(null);
ret.setChargeAction(action.name());
ret.setChargeMethod(ChargeDefinition.ChargeMethod.FIXED);
ret.setProportionalTo(null);
ret.setFromAccountDesignator(AccountDesignators.CUSTOMER_LOAN_FEES);
ret.setToAccountDesignator(feeAccountDesignator);
ret.setForCycleSizeUnit(null);
return ret;
}
public IndividualLoanServiceTest(final TestCase testCase)
{
this.testCase = testCase;
final ChargeDefinitionService chargeDefinitionServiceMock = Mockito.mock(ChargeDefinitionService.class);
chargeDefinitionsByChargeAction = testCase.chargeDefinitions.stream()
.collect(Collectors.groupingBy(ChargeDefinition::getChargeAction,
Collectors.mapping(x -> x, Collectors.toList())));
chargeDefinitionsByAccrueAction = testCase.chargeDefinitions.stream()
.filter(x -> x.getAccrueAction() != null)
.collect(Collectors.groupingBy(ChargeDefinition::getAccrueAction,
Collectors.mapping(x -> x, Collectors.toList())));
Mockito.doReturn(chargeDefinitionsByChargeAction).when(chargeDefinitionServiceMock).getChargeDefinitionsMappedByChargeAction(testCase.productIdentifier);
Mockito.doReturn(chargeDefinitionsByAccrueAction).when(chargeDefinitionServiceMock).getChargeDefinitionsMappedByAccrueAction(testCase.productIdentifier);
final BalanceSegmentRepository balanceSegmentRepositoryMock = Mockito.mock(BalanceSegmentRepository.class);
Mockito.doReturn(Stream.empty()).when(balanceSegmentRepositoryMock).findByProductIdentifierAndSegmentSetIdentifier(Matchers.anyString(), Matchers.anyString());
scheduledChargesService = new ScheduledChargesService(chargeDefinitionServiceMock, balanceSegmentRepositoryMock);
testSubject = new IndividualLoanService(scheduledChargesService);
}
@Test
public void getPlannedPayments() throws Exception {
final PlannedPaymentPage firstPage = testSubject.getPlannedPaymentsPage(testCase.getDataContextOfAction(),
0,
20,
testCase.initialDisbursementDate);
Assert.assertFalse(firstPage.getElements().size() == 0);
final List<PlannedPayment> allPlannedPayments =
Stream.iterate(0, x -> x + 1).limit(firstPage.getTotalPages())
.map(x -> testSubject.getPlannedPaymentsPage(testCase.getDataContextOfAction(),
x,
20,
testCase.initialDisbursementDate))
.flatMap(x -> x.getElements().stream())
.collect(Collectors.toList());
//Remaining principal should correspond with the other cost components.
final Set<BigDecimal> customerRepayments = Stream.iterate(1, x -> x + 1).limit(allPlannedPayments.size() - 1)
.map(x ->
{
final BigDecimal valueOfRepaymentCostComponent = allPlannedPayments.get(x).getPayment().getCostComponents().stream()
.filter(costComponent -> costComponent.getChargeIdentifier().equals(ChargeIdentifiers.REPAY_PRINCIPAL_ID))
.map(CostComponent::getAmount)
.reduce(BigDecimal::add)
.orElse(BigDecimal.ZERO);
final BigDecimal valueOfInterestCostComponent = allPlannedPayments.get(x).getPayment().getCostComponents().stream()
.filter(costComponent -> costComponent.getChargeIdentifier().equals(ChargeIdentifiers.INTEREST_ID))
.map(CostComponent::getAmount)
.reduce(BigDecimal::add)
.orElse(BigDecimal.ZERO);
final BigDecimal principalDifference =
getBalanceForPayment(allPlannedPayments, AccountDesignators.CUSTOMER_LOAN_PRINCIPAL, x - 1)
.subtract(
getBalanceForPayment(allPlannedPayments, AccountDesignators.CUSTOMER_LOAN_PRINCIPAL, x));
Assert.assertEquals("Checking payment " + x, valueOfRepaymentCostComponent.subtract(valueOfInterestCostComponent), principalDifference);
Assert.assertNotEquals("Remaining principle should always be positive or zero.",
getBalanceForPayment(allPlannedPayments, AccountDesignators.CUSTOMER_LOAN_PRINCIPAL, x).signum(), -1);
final boolean containsLateFee = allPlannedPayments.get(x).getPayment().getCostComponents().stream().anyMatch(y -> y.getChargeIdentifier().equals(LATE_FEE_ID));
Assert.assertFalse("Late fee should not be included in planned payments", containsLateFee);
return valueOfRepaymentCostComponent;
}
).collect(Collectors.toSet());
//All entries should have the correct scale.
allPlannedPayments.forEach(x -> {
x.getPayment().getCostComponents().forEach(y -> Assert.assertEquals(testCase.minorCurrencyUnitDigits, y.getAmount().scale()));
Assert.assertEquals(testCase.minorCurrencyUnitDigits, x.getBalances().get(AccountDesignators.CUSTOMER_LOAN_PRINCIPAL).scale());
final int uniqueChargeIdentifierCount = x.getPayment().getCostComponents().stream()
.map(CostComponent::getChargeIdentifier)
.collect(Collectors.toSet())
.size();
Assert.assertEquals("There should be only one cost component per charge per planned payment.",
x.getPayment().getCostComponents().size(), uniqueChargeIdentifierCount);
});
Assert.assertEquals("Final principal balance should be zero.",
BigDecimal.ZERO.setScale(testCase.minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN),
getBalanceForPayment(allPlannedPayments, AccountDesignators.CUSTOMER_LOAN_PRINCIPAL, allPlannedPayments.size() - 1));
Assert.assertEquals("Final interest balance should be zero.",
BigDecimal.ZERO.setScale(testCase.minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN),
getBalanceForPayment(allPlannedPayments, AccountDesignators.CUSTOMER_LOAN_INTEREST, allPlannedPayments.size() - 1));
Assert.assertEquals("Final fees balance should be zero.",
BigDecimal.ZERO.setScale(testCase.minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN),
getBalanceForPayment(allPlannedPayments, AccountDesignators.CUSTOMER_LOAN_FEES, allPlannedPayments.size() - 1));
//All customer payments should be within one percent of each other.
final Optional<BigDecimal> maxPayment = customerRepayments.stream().max(BigDecimal::compareTo);
final Optional<BigDecimal> minPayment = customerRepayments.stream().min(BigDecimal::compareTo);
Assert.assertTrue(maxPayment.isPresent());
Assert.assertTrue(minPayment.isPresent());
final double percentDifference = percentDifference(maxPayment.get(), minPayment.get());
Assert.assertTrue("Percent difference = " + percentDifference + ", max = " + maxPayment.get() + ", min = " + minPayment.get(),
percentDifference < 0.01);
//All charge identifiers should be associated with a name on the returned page.
final Set<String> resultChargeIdentifiers = firstPage.getChargeNames().stream()
.map(ChargeName::getIdentifier)
.collect(Collectors.toSet());
Assert.assertEquals(testCase.expectedChargeIdentifiers, resultChargeIdentifiers);
}
private BigDecimal getBalanceForPayment(
final List<PlannedPayment> allPlannedPayments,
final String accountDesignator,
int index) {
return allPlannedPayments.get(index).getBalances().get(accountDesignator);
}
@Test
public void getScheduledCharges() {
final List<ScheduledAction> scheduledActions = ScheduledActionHelpers.getHypotheticalScheduledActions(testCase.initialDisbursementDate, testCase.caseParameters);
final List<ScheduledCharge> scheduledCharges = scheduledChargesService.getScheduledCharges(testCase.productIdentifier,
scheduledActions);
final List<LocalDate> interestCalculationDates = scheduledCharges.stream()
.filter(scheduledCharge -> scheduledCharge.getScheduledAction().action == Action.APPLY_INTEREST)
.map(scheduledCharge -> scheduledCharge.getScheduledAction().when)
.collect(Collectors.toList());
final List<LocalDate> allTheDaysAfterTheInitialDisbursementDate
= Stream.iterate(testCase.initialDisbursementDate.plusDays(1), interestDay -> interestDay.plusDays(1))
.limit(interestCalculationDates.size())
.collect(Collectors.toList());
Assert.assertEquals(interestCalculationDates, allTheDaysAfterTheInitialDisbursementDate);
final List<LocalDate> acceptPaymentDates = scheduledCharges.stream()
.filter(scheduledCharge -> scheduledCharge.getScheduledAction().action == Action.ACCEPT_PAYMENT)
.map(scheduledCharge -> scheduledCharge.getScheduledAction().when)
.collect(Collectors.toList());
final long expectedAcceptPayments = scheduledActions.stream()
.filter(x -> x.action == Action.ACCEPT_PAYMENT).count();
final List<ChargeDefinition> chargeDefinitionsMappedToAcceptPayment = chargeDefinitionsByChargeAction.get(Action.ACCEPT_PAYMENT.name());
final int numberOfChangeDefinitionsMappedToAcceptPayment = chargeDefinitionsMappedToAcceptPayment == null ? 0 : chargeDefinitionsMappedToAcceptPayment.size();
Assert.assertEquals("check for correct number of scheduled charges for accept payment",
expectedAcceptPayments*numberOfChangeDefinitionsMappedToAcceptPayment,
acceptPaymentDates.size());
final Map<ActionDatePair, Set<ChargeDefinition>> searchableScheduledCharges = scheduledCharges.stream()
.collect(
Collectors.groupingBy(scheduledCharge ->
new ActionDatePair(scheduledCharge.getScheduledAction().action, scheduledCharge.getScheduledAction().when),
Collectors.mapping(ScheduledCharge::getChargeDefinition, Collectors.toSet())));
testCase.chargeDefinitionsForActions.forEach((key, value) ->
value.forEach(x -> Assert.assertTrue(searchableScheduledCharges.get(key).contains(x))));
}
private double percentDifference(final BigDecimal maxPayment, final BigDecimal minPayment) {
final BigDecimal difference = maxPayment.subtract(minPayment);
final BigDecimal percentDifference = difference.divide(maxPayment, 4, BigDecimal.ROUND_UP);
return percentDifference.doubleValue();
}
}