| /* |
| * 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()); |
| } |
| } |