blob: f672c6d52cc786e03c5e1d4f187eb78ac65f2cf0 [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.portfolio.api.v1.domain.*;
import io.mifos.portfolio.service.internal.service.ChargeDefinitionService;
import io.mifos.portfolio.service.internal.service.ProductService;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
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 Map<String, List<ChargeDefinition>> chargeDefinitionsMappedByAction;
private Set<String> expectedChargeIdentifiers = new HashSet<>(Arrays.asList(ChargeIdentifiers.INTEREST_ID, ChargeIdentifiers.REPAYMENT_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 chargeDefinitionsMappedByAction(final Map<String, List<ChargeDefinition>> newVal) {
this.chargeDefinitionsMappedByAction = newVal;
return this;
}
TestCase expectedChargeIdentifiers(final Set<String> newVal) {
this.expectedChargeIdentifiers = newVal;
return this;
}
TestCase expectAdditionalChargeIdentifier(final String newVal) {
this.expectedChargeIdentifiers.add(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;
}
@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 Product product;
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));
//I know: this is cheating in a unit test. But I really didn't want to put this data together by hand.
final Map<String, List<ChargeDefinition>> chargeDefinitionsMappedByAction = constructCharges(0.01);
final ChargeDefinition processingFeeCharge = getFixedSingleChargeDefinition(10.0, Action.OPEN, PROCESSING_FEE_ID, AccountDesignators.PROCESSING_FEE_INCOME);
chargeDefinitionsMappedByAction.put(Action.OPEN.name(), Collections.singletonList(processingFeeCharge));
final ChargeDefinition loanOriginationFeeCharge = getFixedSingleChargeDefinition(100.0, Action.APPROVE, LOAN_ORIGINATION_FEE_ID, AccountDesignators.ORIGINATION_FEE_INCOME);
final List<ChargeDefinition> existingApprovalCharges = chargeDefinitionsMappedByAction.get(Action.APPROVE.name());
final List<ChargeDefinition> approvalChargesWithLoanOriginationFeeReplaced = existingApprovalCharges.stream().map(x -> {
if (x.getIdentifier().equals(LOAN_ORIGINATION_FEE_ID))
return loanOriginationFeeCharge;
else
return x;
}).collect(Collectors.toList());
chargeDefinitionsMappedByAction.put(Action.APPROVE.name(), approvalChargesWithLoanOriginationFeeReplaced);
return new TestCase("simpleCase")
.minorCurrencyUnitDigits(2)
.caseParameters(caseParameters)
.initialDisbursementDate(initialDisbursementDate)
.chargeDefinitionsMappedByAction(chargeDefinitionsMappedByAction)
.expectAdditionalChargeIdentifier(PROCESSING_FEE_ID)
.expectAdditionalChargeIdentifier(LOAN_FUNDS_ALLOCATION_ID)
.expectAdditionalChargeIdentifier(LOAN_ORIGINATION_FEE_ID)
.expectChargeInstancesForActionDatePair(Action.OPEN, initialDisbursementDate, Collections.singletonList(processingFeeCharge))
.expectChargeInstancesForActionDatePair(Action.APPROVE, initialDisbursementDate,
Collections.singletonList(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 Map<String, List<ChargeDefinition>> chargeDefinitionsMappedByAction = constructCharges(0.10);
return new TestCase("yearLoanTestCase")
.minorCurrencyUnitDigits(3)
.caseParameters(caseParameters)
.initialDisbursementDate(initialDisbursementDate)
.chargeDefinitionsMappedByAction(chargeDefinitionsMappedByAction);
}
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 Map<String, List<ChargeDefinition>> chargeDefinitionsMappedByAction = constructCharges(0.05);
return new TestCase("chargeDefaultsCase")
.minorCurrencyUnitDigits(2)
.caseParameters(caseParameters)
.initialDisbursementDate(initialDisbursementDate)
.chargeDefinitionsMappedByAction(chargeDefinitionsMappedByAction)
.expectedChargeIdentifiers(new HashSet<>(Arrays.asList(PROCESSING_FEE_ID, LOAN_FUNDS_ALLOCATION_ID, RETURN_DISBURSEMENT_ID, LOAN_ORIGINATION_FEE_ID, INTEREST_ID, DISBURSEMENT_FEE_ID, REPAYMENT_ID)));
}
private static Map<String, List<ChargeDefinition>> constructCharges(final double interestRate) {
final List<ChargeDefinition> defaultLoanCharges = IndividualLendingPatternFactory.defaultIndividualLoanCharges();
final Map<String, List<ChargeDefinition>> chargeDefinitionsMappedByAction = defaultLoanCharges.stream()
.collect(Collectors.groupingBy(ChargeDefinition::getChargeAction,
Collectors.mapping(x -> x, Collectors.toList())));
chargeDefinitionsMappedByAction.put(Action.APPLY_INTEREST.name(), getInterestChargeDefinition(interestRate, ChronoUnit.YEARS));
return chargeDefinitionsMappedByAction;
}
private static List<ChargeDefinition> getInterestChargeDefinition(final double amount, final ChronoUnit forCycleSizeUnit) {
final ChargeDefinition ret = new ChargeDefinition();
ret.setAmount(BigDecimal.valueOf(amount));
ret.setIdentifier(ChargeIdentifiers.INTEREST_ID);
ret.setAccrueAction(Action.APPLY_INTEREST.name());
ret.setChargeAction(Action.ACCEPT_PAYMENT.name());
ret.setChargeMethod(ChargeDefinition.ChargeMethod.PROPORTIONAL);
ret.setProportionalTo(ChargeIdentifiers.RUNNING_BALANCE_DESIGNATOR);
ret.setFromAccountDesignator(AccountDesignators.CUSTOMER_LOAN);
ret.setAccrualAccountDesignator(AccountDesignators.INTEREST_ACCRUAL);
ret.setToAccountDesignator(AccountDesignators.INTEREST_INCOME);
ret.setForCycleSizeUnit(forCycleSizeUnit);
return Collections.singletonList(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.ENTRY);
ret.setToAccountDesignator(feeAccountDesignator);
ret.setForCycleSizeUnit(null);
return ret;
}
private static ChargeDefinition getProportionalSingleChargeDefinition(
final double amount,
final Action action,
final String chargeIdentifier,
final String fromAccountDesignator,
final String toAccountDesignator) {
final ChargeDefinition ret = new ChargeDefinition();
ret.setAmount(BigDecimal.valueOf(amount));
ret.setIdentifier(chargeIdentifier);
ret.setAccrueAction(null);
ret.setChargeAction(action.name());
ret.setChargeMethod(ChargeDefinition.ChargeMethod.PROPORTIONAL);
ret.setProportionalTo(ChargeIdentifiers.MAXIMUM_BALANCE_DESIGNATOR);
ret.setFromAccountDesignator(fromAccountDesignator);
ret.setToAccountDesignator(toAccountDesignator);
ret.setForCycleSizeUnit(null);
return ret;
}
public IndividualLoanServiceTest(final TestCase testCase)
{
this.testCase = testCase;
final ProductService productServiceMock = Mockito.mock(ProductService.class);
final ChargeDefinitionService chargeDefinitionServiceMock = Mockito.mock(ChargeDefinitionService.class);
product = new Product();
product.setMinorCurrencyUnitDigits(testCase.minorCurrencyUnitDigits);
Mockito.doReturn(Optional.of(product)).when(productServiceMock).findByIdentifier(testCase.productIdentifier);
Mockito.doReturn(testCase.chargeDefinitionsMappedByAction).when(chargeDefinitionServiceMock).getChargeDefinitionsMappedByChargeAction(testCase.productIdentifier);
testSubject = new IndividualLoanService(productServiceMock, chargeDefinitionServiceMock, new PeriodChargeCalculator());
}
@Test
public void getPlannedPayments() throws Exception {
final PlannedPaymentPage firstPage = testSubject.getPlannedPaymentsPage(testCase.productIdentifier,
testCase.caseParameters,
0,
20,
testCase.initialDisbursementDate);
final List<PlannedPayment> allPlannedPayments =
Stream.iterate(0, x -> x + 1).limit(firstPage.getTotalPages())
.map(x -> testSubject.getPlannedPaymentsPage(testCase.productIdentifier,
testCase.caseParameters, x, 20, testCase.initialDisbursementDate))
.flatMap(x -> x.getElements().stream())
.collect(Collectors.toList());
//Remaining principal should correspond with the other cost components.
Stream.iterate(0, x -> x+1).limit(allPlannedPayments.size()-2).forEach(x ->
{
final BigDecimal costComponentSum = allPlannedPayments.get(x+1).getCostComponents().stream()
.map(CostComponent::getAmount)
.reduce(BigDecimal::add)
.orElse(BigDecimal.ZERO)
.negate();
final BigDecimal principalDifference = allPlannedPayments.get(x).getRemainingPrincipal().subtract(allPlannedPayments.get(x + 1).getRemainingPrincipal());
Assert.assertEquals(costComponentSum, principalDifference);
Assert.assertNotEquals("Remaining principle should always be positive or zero.",
allPlannedPayments.get(x).getRemainingPrincipal().signum(), -1);
}
);
//All entries should have the correct scale.
allPlannedPayments.forEach(x -> {
x.getCostComponents().forEach(y -> Assert.assertEquals(product.getMinorCurrencyUnitDigits(), y.getAmount().scale()));
Assert.assertEquals(product.getMinorCurrencyUnitDigits(), x.getRemainingPrincipal().scale());
});
//All customer payments should be within one percent of each other.
final Set<BigDecimal> customerRepayments = allPlannedPayments.stream()
.map(this::getCustomerRepayment)
.filter(Optional::isPresent)
.map(Optional::get)
.collect(Collectors.toSet());
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, percentDifference < 0.01);
//Final balance should be zero.
Assert.assertEquals(BigDecimal.ZERO.setScale(testCase.minorCurrencyUnitDigits, BigDecimal.ROUND_HALF_EVEN),
allPlannedPayments.get(allPlannedPayments.size()-1).getRemainingPrincipal());
//All charge identifers 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);
}
@Test
public void getScheduledCharges() {
final List<ScheduledAction> scheduledActions = ScheduledActionHelpers.getHypotheticalScheduledActions(testCase.initialDisbursementDate, testCase.caseParameters);
final List<ScheduledCharge> scheduledCharges = testSubject.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 = testCase.chargeDefinitionsMappedByAction.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();
}
private Optional<BigDecimal> getCustomerRepayment(final PlannedPayment plannedPayment) {
final Optional<CostComponent> ret = plannedPayment.getCostComponents().stream()
.filter(y -> y.getChargeIdentifier().equals(ChargeIdentifiers.REPAYMENT_ID))
.findAny();
return ret.map(x -> x.getAmount().abs());
}
}